clone

a hermes agent plugin for openspec: tools for spec workflows and a dashboard tab for browsing changes and specs

main
hermes-openspec / tests / desktop-plugin-smoke.mjs
desktop-plugin-smoke.mjs 155.8K
1#!/usr/bin/env node2/**3 * Headless smoke harness for desktop/plugin.js — full read-surface verification.4 *5 * Covers OpenSpec tasks 2.1–7.6:6 * 2.1 Contract: plugin id, route, sidebar, imports, no JSX, no fetch7 * 2.2 Path builders: encoded REST paths, no malformed output8 * 3.1 Source selection helpers9 * 4.1 Board helpers: status, sort, filter, archived toggle, artifact/task metadata10 * 5.1 Adapter/tab/task helpers: detail paths, conditional tabs, task parser11 * 6.1 Spec-browser helpers: current/dirty paths, availability flags12 * 7.1–7.6 Verification commands13 *14 * Run: node tests/desktop-plugin-smoke.mjs15 * Exit 0 = pass, exit 1 = failure.16 */1718import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'19import { execSync } from 'node:child_process'20import { dirname, resolve, join } from 'node:path'21import { tmpdir } from 'node:os'22import { fileURLToPath } from 'node:url'2324const __dirname = dirname(fileURLToPath(import.meta.url))25const pluginPath = resolve(__dirname, '../desktop/plugin.js')26const source = readFileSync(pluginPath, 'utf8')2728let passed = 029let failed = 030const failures = []3132function ok(name) { passed++; console.log(' PASS ' + name) }33function fail(name, d) {34 failed++35 const msg = ' FAIL ' + name + (d != null ? ': ' + d : '')36 console.error(msg)37 failures.push({ name, detail: d })38}39function check(c, n, d) { c ? ok(n) : fail(n, d) }4041console.log('=== Smoke harness: desktop/plugin.js (full read surface) ===\n')4243// ═══════════════════════════════════════════════════════════════════════════════44// 2.1 Contract: plugin id, route, sidebar, allowed imports, no JSX, no fetch45// ═══════════════════════════════════════════════════════════════════════════════46console.log('[2.1] Contract: plugin registration and API boundary')4748// ESM structure49check(source.includes('export default'), 'Has default export')50check(source.includes('import {'), 'Has named imports')51check(!source.includes('module.exports'), 'No CommonJS exports')52check(!source.includes('require('), 'No require() calls')5354// No raw JSX55{56 const bad = source.split('\n').filter(function(l) {57 const t = l.trim()58 if (t.startsWith('//') || t.startsWith('*') || t.startsWith('/*')) return false59 if (t.includes('jsx(') || t.includes('jsxs(')) return false60 const stripped = t.replace(/['"`](?:[^'"`\\]|\\.)*['"`]/g, '')61 return /<[A-Z]/.test(stripped)62 })63 check(bad.length === 0, 'No raw JSX outside jsx()/jsxs()', bad.length ? 'Line: ' + bad[0].trim() : undefined)64}6566// Allowed imports only67{68 const specs = [...source.matchAll(/from\s+['"]([^'"]+)['"]/g)].map(function(m) { return m[1] })69 const allowed = new Set(['@hermes/plugin-sdk', 'react', 'react/jsx-runtime'])70 const bad = specs.filter(function(s) { return !allowed.has(s) })71 check(specs.length > 0, 'Found ' + specs.length + ' import(s)')72 check(bad.length === 0, 'No disallowed imports', bad.length ? 'Bad: ' + bad.join(', ') : undefined)73}7475// No direct fetch76check(!source.includes("fetch('"), 'No direct fetch() calls')77check(!source.includes('fetch("/'), 'No direct fetch() calls')78check(!source.includes('window.fetch'), 'No window.fetch calls')7980// No mutation verbs in API adapter81check(!source.includes('ctx.rest.post'), 'No ctx.rest.post')82check(!source.includes('ctx.rest.put'), 'No ctx.rest.put')83check(!source.includes('ctx.rest.delete'), 'No ctx.rest.delete')84check(!source.includes('ctx.rest.patch'), 'No ctx.rest.patch')8586// No absolute API URLs87check(!source.match(/https?:\/\/[^\s'"]+/), 'No absolute HTTP URLs in source')8889// No dangerouslySetInnerHTML90check(!source.includes('dangerouslySetInnerHTML'), 'No dangerouslySetInnerHTML')9192// ═══════════════════════════════════════════════════════════════════════════════93// Dynamic import with shims — run helper module94// ═══════════════════════════════════════════════════════════════════════════════95console.log('\n[2.2–6.1] Dynamic import and __test execution')9697const helperPath = resolve(__dirname, '_dynamic_import_helper.mjs')9899let r100try {101 const raw = execSync('node ' + helperPath, { encoding: 'utf8', timeout: 30000 }).trim()102 r = JSON.parse(raw)103} catch (e) {104 fail('Dynamic import and __test execution', (e.message || '').slice(0, 500))105 r = {}106}107108// ═══════════════════════════════════════════════════════════════════════════════109// Identity/session/interaction driver — self-contained module for fixtures that110// exercise __test helpers and component descriptors (tasks 1.1/1.2/1.4/1.5/1.6).111// The driver is written to a temp file and executed by node; it builds the same112// SDK/react shims as _dynamic_import_helper.mjs, so descriptors carry real113// component references and can be walked structurally.114// ═══════════════════════════════════════════════════════════════════════════════115116function runIdentityDriver() {117 const driverPath = join(tmpdir(), 'openspec-identity-' + process.pid + '-' + Math.random().toString(36).slice(2) + '.mjs')118 try {119 writeFileSync(driverPath, IDENTITY_DRIVER, 'utf8')120 const raw = execSync('node ' + driverPath + ' ' + pluginPath, { encoding: 'utf8', timeout: 30000 }).trim()121 return JSON.parse(raw)122 } catch (e) {123 return { __driverError: (e.message || '').slice(0, 800) }124 } finally {125 try { rmSync(driverPath, { force: true }) } catch (e) { /* ignore */ }126 }127}128129const IDENTITY_DRIVER = `130import { readFileSync } from 'node:fs'131const pluginSrc = readFileSync(process.argv[2], 'utf8')132133const sdkExports = [134 'Codicon', 'useQuery',135 'Loader', 'EmptyState', 'ErrorState', 'Badge', 'Button',136 'CopyButton', 'Dialog', 'DialogContent', 'Input', 'ScrollArea', 'SearchField',137 'Select', 'SelectContent', 'SelectItem', 'SelectTrigger', 'SelectValue',138 'SegmentedControl', 'Tabs', 'TabsList', 'TabsTrigger'139].map(n => 'export const ' + n + '=(...a)=>a').join(';')140const cnShim = 'export function cn(...a){ return a.filter(Boolean).join(" ") }'141const sdkShim = sdkExports142 + ';' + cnShim143 + ';export const ROUTES_AREA="routes"'144 + ';export const SIDEBAR_NAV_AREA="sidebar.nav"'145 + ';export default {}'146const reactShim = 'export function useState(v){return[v,function(){},function(){},v]};'147 + 'export function useEffect(f,d){return undefined};'148 + 'export function useRef(v){return{current:v}};'149 + 'export function useCallback(f,d){return f};'150 + 'export function useMemo(f,d){return f()};'151 + 'export function useContext(c){return undefined};'152 + 'export default { useState, useEffect, useRef, useCallback, useMemo, useContext }'153const jsxShim = 'export function jsx(c,p){return{component:c,props:p,_t:"jsx"}}'154 + 'export function jsxs(c,p){return{component:c,props:p,_t:"jsxs"}}'155 + 'export function Fragment(p){return p&&p.children}'156157const sdkUrl = 'data:text/javascript,' + encodeURIComponent(sdkShim)158const reactUrl = 'data:text/javascript,' + encodeURIComponent(reactShim)159const jsxUrl = 'data:text/javascript,' + encodeURIComponent(jsxShim)160const sdkMod = await import(sdkUrl)161162const blobSrc = pluginSrc163 .replace(/from\\s+['"]@hermes\\/plugin-sdk['"]/g, "from '" + sdkUrl + "'")164 .replace(/from\\s+['"]react\\/jsx-runtime['"]/g, "from '" + jsxUrl + "'")165 .replace(/from\\s+['"]react['"]/g, "from '" + reactUrl + "'")166const mod = await import('data:text/javascript,' + encodeURIComponent(blobSrc))167const t = mod.__test168const plugin = mod.default169170function walk(node, visit) {171 if (!node || typeof node !== 'object') return172 if (node._t) visit(node)173 var ch = node.props && node.props.children174 if (Array.isArray(ch)) { for (var i = 0; i < ch.length; i++) walk(ch[i], visit) }175 else if (ch && typeof ch === 'object') walk(ch, visit)176}177function walkDeep(node, visit) {178 if (!node || typeof node !== 'object') return179 visit(node)180 var ch = node.props && node.props.children181 if (Array.isArray(ch)) { for (var i = 0; i < ch.length; i++) walkDeep(ch[i], visit) }182 else if (ch && typeof ch === 'object') walkDeep(ch, visit)183}184function collectButtons(node) {185 var out = []186 walk(node, function(n) { if (n.component === sdkMod.CopyButton) out.push(n.props) })187 return out188}189function findByStyle(node, styleKey, styleValue) {190 var out = []191 walk(node, function(n) {192 var st = n.props && n.props.style193 if (st && st[styleKey] === styleValue) out.push(n)194 })195 return out196}197198const results = {}199200// ---- 1.1 source references ----201const SRC = { id: 'src-ivault', token: 'os_8e5049', name: 'ivault', path: '/repos/ivault' }202const SRC_DUP = { id: 'src-other', token: 'os_dup01', name: 'Ivault', path: '/repos/other' }203const SRC_BLANK = { id: 'src-blank', token: 'os_8e5049', name: ' ', path: '/repos/blank' }204const SRC_ID_ONLY = { id: 'src-id-only', token: '', name: '', path: '/repos/id' }205const SRC_NOTHING = { id: '', token: '', name: '', path: '/repos/nothing' }206const SRC_UNIQUE = { id: 'src-unique', token: 'os_u1', name: 'ivault', path: '/repos/u' }207const SRC_OTHER = { id: 'src-o2', token: 'os_u2', name: 'other', path: '/repos/o' }208results.src = {}209results.src.unique = t.buildSourceReference ? t.buildSourceReference(SRC_UNIQUE, [SRC_UNIQUE, SRC_OTHER]) : null210results.src.caseInsensitiveDup = t.buildSourceReference ? t.buildSourceReference(SRC, [SRC, SRC_DUP]) : null211results.src.blankName = t.buildSourceReference ? t.buildSourceReference(SRC_BLANK, [SRC_BLANK]) : null212results.src.tokenFallback = t.buildSourceReference ? t.buildSourceReference(SRC_ID_ONLY, [SRC_ID_ONLY]) : null213results.src.unavailable = t.buildSourceReference ? t.buildSourceReference(SRC_NOTHING, [SRC_NOTHING]) : null214215// ---- 1.2 item references ----216const ITEM_ACTIVE = { token: 'os_fd3445', name: 'hermes-desktop-ui-corrections', status: 'in-progress' }217const ITEM_ARCHIVED = { token: 'os_fd3445', name: 'archived-change', status: 'archived' }218const ITEM_IDEA = { token: 'os_1655dd', name: 'hermes-agentmail-inbox-integration', status: 'ideas' }219const ITEM_NO_TOKEN = { name: 'no-token-change' }220const ITEM_DUP_A = { token: 'os_dup', name: 'dup-a' }221const ITEM_DUP_B = { token: 'os_dup', name: 'dup-b' }222results.item = {}223results.item.active = t.buildItemReference ? t.buildItemReference(SRC, ITEM_ACTIVE, [SRC]) : null224results.item.archived = t.buildItemReference ? t.buildItemReference(SRC, ITEM_ARCHIVED, [SRC]) : null225results.item.idea = t.buildItemReference ? t.buildItemReference(SRC, ITEM_IDEA, [SRC]) : null226results.item.missingToken = t.buildItemReference ? t.buildItemReference(SRC, ITEM_NO_TOKEN, [SRC]) : null227results.item.missingSourceRef = t.buildItemReference ? t.buildItemReference(SRC_NOTHING, ITEM_ACTIVE, [SRC]) : null228results.item.literalNoEncoding = t.buildItemReference ? t.buildItemReference({ id: 'x', token: 'os_1', name: 'My Source' }, { token: 'tok/a b' }, [{ id: 'x', token: 'os_1', name: 'My Source' }]) : null229results.item.uniqueToken = t.hasUniqueItemToken ? t.hasUniqueItemToken(ITEM_DUP_A, [ITEM_DUP_A]) : null230results.item.duplicateToken = t.hasUniqueItemToken ? t.hasUniqueItemToken(ITEM_DUP_A, [ITEM_DUP_A, ITEM_DUP_B]) : null231results.item.blankToken = t.hasUniqueItemToken ? t.hasUniqueItemToken({ token: '' }, []) : null232233// ---- 1.4 session store ----234results.session = {}235const freshStore = t.createSessionStore ? t.createSessionStore() : null236results.session.defaultArchived = !!(freshStore && freshStore.showArchived === true)237results.session.defaultSelected = !!(freshStore && freshStore.selectedSourceId === null)238const contribs = []239const regCtx = {240 register: function(c) { contribs.push(c) },241 registerMany: function(arr) { for (var i = 0; i < arr.length; i++) contribs.push(arr[i]) },242 rest: function() { return Promise.resolve({}) }243}244plugin.register(regCtx)245const route = contribs.find(function(c) { return c.area === 'routes' })246const firstRender = route && typeof route.render === 'function' ? route.render() : null247const storeA = firstRender && firstRender.props && firstRender.props.sessionStore248results.session.storeWired = !!storeA249if (storeA) {250 storeA.selectedSourceId = 'os_retained'251 storeA.showArchived = false252 const secondRender = route.render()253 const storeB = secondRender && secondRender.props && secondRender.props.sessionStore254 results.session.sameStoreAcrossRenders = storeB === storeA255 results.session.retainedMutated = !!(storeB && storeB.selectedSourceId === 'os_retained' && storeB.showArchived === false)256}257results.session.fallback = {}258const FS = [ { id: 'a', valid: false, name: 'bad' }, { id: 'b', valid: true, name: 'good' } ]259results.session.fallback.retainedPresentInvalid = t.resolveRetainedSource ? t.resolveRetainedSource(FS, 'a') : null260results.session.fallback.retainedPresentValid = t.resolveRetainedSource ? t.resolveRetainedSource(FS, 'b') : null261results.session.fallback.retainedAbsent = t.resolveRetainedSource ? t.resolveRetainedSource(FS, 'zzz') : null262results.session.fallback.allInvalidFirstReturned = t.resolveRetainedSource ? t.resolveRetainedSource([{ id: 'x', valid: false }], 'zzz') : null263results.session.fallback.noSources = t.resolveRetainedSource ? t.resolveRetainedSource([], 'zzz') : null264265// ---- 1.5 detail fallbacks + card descriptors ----266results.detail = {}267results.detail.titleLoaded = t.detailTitle ? t.detailTitle({ title: 'Loaded' }, { title: 'Summary' }, { name: 'Item' }, 'change') : null268results.detail.titleSummary = t.detailTitle ? t.detailTitle({}, { title: 'Summary' }, { name: 'Item' }, 'change') : null269results.detail.titleItem = t.detailTitle ? t.detailTitle({}, {}, { name: 'Item' }, 'change') : null270results.detail.titleUntitledChange = t.detailTitle ? t.detailTitle({}, {}, {}, 'change') : null271results.detail.titleUntitledIdea = t.detailTitle ? t.detailTitle({}, {}, {}, 'idea') : null272results.detail.secondaryLoaded = t.detailSecondaryName ? t.detailSecondaryName({ name: 'Loaded' }, { name: 'Summary' }) : null273results.detail.secondarySummary = t.detailSecondaryName ? t.detailSecondaryName({}, { name: 'Summary' }) : null274results.detail.secondaryMissing = t.detailSecondaryName ? t.detailSecondaryName({}, {}) : null275results.detail.ideaFilename = t.ideaSecondaryName ? t.ideaSecondaryName('hermes-agentmail-inbox-integration') : null276results.detail.ideaFilenameNoDup = t.ideaSecondaryName ? t.ideaSecondaryName('already.md') : null277results.detail.ideaFilenameMissing = t.ideaSecondaryName ? t.ideaSecondaryName('') : null278279const CARD_ITEM = { token: 'os_fd3445', name: 'hermes-desktop-ui-corrections', title: 'UI Fixes', status: 'in-progress', hasProposal: true, hasTasks: true, hasDesign: false, hasSpecs: true, taskStats: { done: 2, total: 5 }, sequence: 3 }280const CARD_ALL = [CARD_ITEM, ITEM_IDEA]281const cardNode = t.BoardCard ? t.BoardCard({ item: CARD_ITEM, source: SRC, allSources: [SRC], allItems: CARD_ALL, onSelect: function() {} }) : null282results.card = {}283if (cardNode) {284 const cbs = collectButtons(cardNode)285 results.card.copyCount = cbs.length286 results.card.copyText = cbs[0] && cbs[0].text287 results.card.copyLabel = cbs[0] && cbs[0].label288 results.card.copyTitle = cbs[0] && cbs[0].title289 results.card.copyStopProp = !!(cbs[0] && cbs[0].stopPropagation === true)290 results.card.tabIndexZero = cardNode.props.tabIndex === 0291 results.card.roleButton = cardNode.props.role === 'button'292 results.card.hasKeyDown = typeof cardNode.props.onKeyDown === 'function'293 results.card.rightGroupAuto = findByStyle(cardNode, 'marginLeft', 'auto').length > 0294 results.card.tokenVisible = JSON.stringify(cardNode).includes('os_fd3445')295 results.card.leftGroupHasArtifacts = (function() {296 var found = false297 walk(cardNode, function(n) { if (n.component && n.component.name === 'CardArtifacts') found = true })298 return found299 })()300 results.card.leftGroupHasFraction = (function() {301 var found = false302 walk(cardNode, function(n) { if (n.component && n.component.name === 'CardTaskFraction') found = true })303 return found304 })()305 results.card.taskFraction = t.CardTaskFraction ? JSON.stringify(t.CardTaskFraction({ item: CARD_ITEM })).includes('✅2/5') : null306 results.card.progressLeft = (function() {307 var meta = cardNode.props && cardNode.props.children && cardNode.props.children[1]308 var rows = meta && meta.props && meta.props.children309 var footer = Array.isArray(rows) ? rows[1] : null310 var footerChildren = footer && footer.props && footer.props.children311 return Array.isArray(footerChildren) && footerChildren[0] && footerChildren[0].component && footerChildren[0].component.name === 'CardTaskFraction'312 })()313 results.card.identityRight = (function() {314 var meta = cardNode.props && cardNode.props.children && cardNode.props.children[1]315 var rows = meta && meta.props && meta.props.children316 var footer = Array.isArray(rows) ? rows[1] : null317 var footerChildren = footer && footer.props && footer.props.children318 var identity = Array.isArray(footerChildren) ? footerChildren[1] : null319 return !!(identity && identity.props && identity.props.style && identity.props.style.marginLeft === 'auto')320 })()321 results.card.leftBadges = t.CardArtifacts ? JSON.stringify(t.CardArtifacts({ item: CARD_ITEM })).includes('proposal') && JSON.stringify(t.CardArtifacts({ item: CARD_ITEM })).includes('tasks') : null322 results.card.statusBorder = !!(cardNode.props.style && cardNode.props.style.borderLeftColor)323 results.card.hoverExactCard = (function() {324 var cnv = cardNode && cardNode.props && cardNode.props.className325 if (!cnv) return false326 var cls = Array.isArray(cnv) ? cnv.join(' ') : String(cnv)327 return cls.includes('hover:bg-primary/[0.06]')328 })()329 results.card.hoverGenericAbsent = (function() {330 var cnv = cardNode && cardNode.props && cardNode.props.className331 if (!cnv) return true332 var cls = Array.isArray(cnv) ? cnv.join(' ') : String(cnv)333 return !cls.includes('--ui-hover-background')334 })()335 results.card.baseBgClass = (function() {336 var cnv = cardNode && cardNode.props && cardNode.props.className337 if (!cnv) return false338 var cls = Array.isArray(cnv) ? cnv.join(' ') : String(cnv)339 return cls.includes('bg-(--ui-bg-elevated)')340 })()341 results.card.noInlineBg = !(cardNode && cardNode.props && cardNode.props.style && cardNode.props.style.background)342 results.card.focusWashExact = (function() {343 var cnv = cardNode && cardNode.props && cardNode.props.className344 if (!cnv) return false345 var cls = Array.isArray(cnv) ? cnv.join(' ') : String(cnv)346 return cls.includes('focus-visible:bg-(--ui-control-hover-background)')347 })()348 results.card.noDeadOutline = (function() {349 var cnv = cardNode && cardNode.props && cardNode.props.className350 if (!cnv) return true351 var cls = Array.isArray(cnv) ? cnv.join(' ') : String(cnv)352 return !cls.includes('focus-visible:outline-2') && !cls.includes('focus-visible:outline-(--ui-focus-border)')353 })()354}355356// ---- 1.5b backend sequence metadata compatibility ----357// The dashboard source summary carries sequence metadata as an object whose358// ordering value is sequence.position. The Desktop consumer must normalize359// that boundary for both sorting and card badges, while ignoring malformed360// metadata rather than rendering [object Object].361results.sequence = {}362if (t.sortItems) {363 const backendSequenceItems = [364 { name: 'late', title: 'Late', sequence: { position: 2 } },365 { name: 'early', title: 'Early', sequence: { position: 1 } },366 ]367 results.sequence.sorted = t.sortItems(backendSequenceItems).map(function(i) { return i.name })368}369if (t.normalizeSequencePosition) {370 results.sequence.objectPosition = t.normalizeSequencePosition({ position: 7 })371 results.sequence.malformedPosition = t.normalizeSequencePosition({ position: '7' })372 results.sequence.missingPosition = t.normalizeSequencePosition({})373}374if (t.BoardCard) {375 const sequenceCard = t.BoardCard({376 item: { token: 'seq', name: 'sequence-card', title: 'Sequence Card', sequence: { position: 7 } },377 source: SRC,378 allSources: [SRC],379 allItems: [{ token: 'seq' }],380 onSelect: function() {},381 })382 const sequenceBadges = []383 walk(sequenceCard, function(n) {384 if (n.component === sdkMod.Badge && n.props) sequenceBadges.push(n.props.children)385 })386 results.sequence.backendBadge = sequenceBadges.includes('#7')387388 const malformedSequenceCard = t.BoardCard({389 item: { token: 'bad-seq', name: 'malformed-sequence', title: 'Malformed Sequence', sequence: { position: '7' } },390 source: SRC,391 allSources: [SRC],392 allItems: [{ token: 'bad-seq' }],393 onSelect: function() {},394 })395 const malformedSequenceBadges = []396 walk(malformedSequenceCard, function(n) {397 if (n.component === sdkMod.Badge && n.props) malformedSequenceBadges.push(n.props.children)398 })399 results.sequence.malformedBadgeCount = malformedSequenceBadges.length400}401402const SPARSE_ITEM = { token: 'os_x1', name: 'bare-card' }403const sparseNode = t.BoardCard ? t.BoardCard({ item: SPARSE_ITEM, source: SRC, allSources: [SRC], allItems: [SPARSE_ITEM], onSelect: function() {} }) : null404results.sparse = {}405if (sparseNode) {406 const scbs = collectButtons(sparseNode)407 results.sparse.copyText = scbs[0] && scbs[0].text408 results.sparse.noPlaceholderFraction = !JSON.stringify(sparseNode).includes('0/0')409 results.sparse.rightGroupAuto = findByStyle(sparseNode, 'marginLeft', 'auto').length > 0410 results.sparse.tokenVisible = JSON.stringify(sparseNode).includes('os_x1')411}412413const dupNode = t.BoardCard ? t.BoardCard({ item: ITEM_DUP_A, source: SRC, allSources: [SRC], allItems: [ITEM_DUP_A, ITEM_DUP_B], onSelect: function() {} }) : null414results.dupCard = {}415if (dupNode) {416 results.dupCard.copySuppressed = collectButtons(dupNode).length === 0417 results.dupCard.tokenStillVisible = JSON.stringify(dupNode).includes('os_dup')418}419420// ---- 1.6 keyboard + collapsed rail ----421results.keyboard = {}422let enterCount = 0, spaceCount = 0, otherCount = 0, spacePrevented = false423if (t.handleActivateKey) {424 t.handleActivateKey({ key: 'Enter', preventDefault: function() {} }, function() { enterCount++ })425 t.handleActivateKey({ key: ' ', preventDefault: function() { spacePrevented = true } }, function() { spaceCount++ })426 t.handleActivateKey({ key: 'a' }, function() { otherCount++ })427}428results.keyboard.enterActivatesOnce = enterCount === 1429results.keyboard.spaceActivatesOnce = spaceCount === 1430results.keyboard.spacePreventsDefault = spacePrevented === true431results.keyboard.otherKeysIgnored = otherCount === 0432433const railNode = t.BoardColumn ? t.BoardColumn({ status: 'todo', items: [], source: SRC, allSources: [SRC], allItems: [], onSelect: function() {}, collapsed: true, onExpand: function() {} }) : null434results.rail = {}435if (railNode) {436 results.rail.tabIndexZero = railNode.props.tabIndex === 0437 results.rail.roleButton = railNode.props.role === 'button'438 results.rail.hasKeyDown = typeof railNode.props.onKeyDown === 'function'439 results.rail.dotTonePresent = JSON.stringify(railNode).includes('var(--ui-text-secondary)')440 results.rail.hoverExactRail = (function() {441 var cnv = railNode && railNode.props && railNode.props.className442 if (!cnv) return false443 var cls = Array.isArray(cnv) ? cnv.join(' ') : String(cnv)444 return cls.includes('hover:bg-(--ui-bg-quinary)')445 })()446 results.rail.hoverGenericAbsent = (function() {447 var cnv = railNode && railNode.props && railNode.props.className448 if (!cnv) return true449 var cls = Array.isArray(cnv) ? cnv.join(' ') : String(cnv)450 return !cls.includes('--ui-hover-background')451 })()452 results.rail.baseBgClass = (function() {453 var cnv = railNode && railNode.props && railNode.props.className454 if (!cnv) return false455 var cls = Array.isArray(cnv) ? cnv.join(' ') : String(cnv)456 return cls.includes('bg-[color-mix(in_srgb,var(--ui-bg-quinary)_50%,transparent)]')457 })()458 results.rail.noInlineBg = !(railNode && railNode.props && railNode.props.style && railNode.props.style.background)459 results.rail.focusWashExact = (function() {460 var cnv = railNode && railNode.props && railNode.props.className461 if (!cnv) return false462 var cls = Array.isArray(cnv) ? cnv.join(' ') : String(cnv)463 return cls.includes('focus-visible:bg-(--ui-control-hover-background)')464 })()465 results.rail.noDeadOutline = (function() {466 var cnv = railNode && railNode.props && railNode.props.className467 if (!cnv) return true468 var cls = Array.isArray(cnv) ? cnv.join(' ') : String(cnv)469 return !cls.includes('focus-visible:outline-2') && !cls.includes('focus-visible:outline-(--ui-focus-border)')470 })()471}472473// ---- lane/drawer interaction descriptors ----474results.expandedHeader = {}475if (t.BoardColumn) {476 const headerItems = [{ name: 'item-a', status: 'todo', token: 'a' }]477 let activated = 0478 const headerDescriptor = t.BoardColumn({479 status: 'todo',480 items: headerItems,481 source: SRC,482 allSources: [SRC],483 allItems: headerItems,484 onSelect: function() {},485 collapsed: false,486 onExpand: function() { activated++ },487 })488 var firstButton = null489 walk(headerDescriptor, function(n) {490 if (n.component === 'button' && !firstButton) firstButton = n491 })492 if (firstButton) {493 results.expandedHeader.rootIsButton = true494 results.expandedHeader.typeButton = firstButton.props && firstButton.props.type === 'button'495 results.expandedHeader.labelStartsCollapse = !!(firstButton.props && firstButton.props['aria-label'] && firstButton.props['aria-label'].startsWith('Collapse'))496 results.expandedHeader.titleStartsCollapse = !!(firstButton.props && firstButton.props.title && firstButton.props.title.startsWith('Collapse'))497 results.expandedHeader.hasKeyDown = !!(firstButton.props && typeof firstButton.props.onKeyDown === 'function')498 results.expandedHeader.hasClick = !!(firstButton.props && typeof firstButton.props.onClick === 'function')499 results.expandedHeader.focusWashExact = (function() {500 var className = firstButton.props && firstButton.props.className501 return !!className && String(className).includes('focus-visible:bg-(--ui-control-hover-background)')502 })()503 results.expandedHeader.hasChevronLeft = false504 results.expandedHeader.hasMinus = false505 walk(firstButton, function(n) {506 if (n.component === sdkMod.Codicon) {507 if (n.props && n.props.name === 'chevron-left') results.expandedHeader.hasChevronLeft = true508 if (n.props && n.props.name === 'dash') results.expandedHeader.hasMinus = true509 }510 })511 var keyDown = firstButton.props.onKeyDown512 var click = firstButton.props.onClick513 function makeKeyEvent(key) {514 return { key: key, defaultPrevented: false, preventDefault: function() { this.defaultPrevented = true } }515 }516 var enterEvent = makeKeyEvent('Enter')517 keyDown(enterEvent)518 results.expandedHeader.enterActivatesOnce = activated === 1519 activated = 0520 var spaceEvent = makeKeyEvent(' ')521 keyDown(spaceEvent)522 results.expandedHeader.spaceActivatesOnce = activated === 1523 results.expandedHeader.spacePreventsDefault = !!spaceEvent.defaultPrevented524 activated = 0525 var otherEvent = makeKeyEvent('Tab')526 keyDown(otherEvent)527 results.expandedHeader.otherKeysIgnored = activated === 0528 activated = 0529 click()530 results.expandedHeader.clickActivatesOnce = activated === 1531 }532}533534results.detailDrawer = {}535if (t.DetailDrawer) {536 const drawerDescriptor = t.DetailDrawer({537 title: 'Title',538 secondary: 'secondary',539 itemRef: 'src/item',540 canCopy: true,541 token: 'item',542 onClose: function() {},543 })544 if (drawerDescriptor && drawerDescriptor._t) {545 results.detailDrawer.rootExists = true546 results.detailDrawer.isAbsoluteRight = !!(drawerDescriptor.props && String(drawerDescriptor.props.className).includes('absolute inset-y-0 right-0'))547 results.detailDrawer.hasLeftBorder = !!(drawerDescriptor.props && String(drawerDescriptor.props.className).includes('border-l'))548 results.detailDrawer.widthIsCompact = !!(drawerDescriptor.props && String(drawerDescriptor.props.className).includes('w-[26rem]'))549 var closeBtn = null550 var copyBtn = null551 walk(drawerDescriptor, function(n) {552 if (n.component === 'button' && !closeBtn) closeBtn = n553 if (n.component === sdkMod.CopyButton && !copyBtn) copyBtn = n554 })555 results.detailDrawer.hasCloseButton = !!closeBtn556 results.detailDrawer.closeLabel = closeBtn && closeBtn.props && closeBtn.props['aria-label']557 results.detailDrawer.copyLabel = copyBtn && copyBtn.props && copyBtn.props.label558 results.detailDrawer.copyStopProp = !!(copyBtn && copyBtn.props && copyBtn.props.stopPropagation)559 }560}561562results.sourceInvalidation = {}563if (t.resolveRetainedSource) {564 const invalidSource = { id: 'src-a', valid: false, error: 'No openspec/ directory found' }565 results.sourceInvalidation.retainedSameId = t.resolveRetainedSource([invalidSource], 'src-a')566 results.sourceInvalidation.idPreservedWhenInvalid = results.sourceInvalidation.retainedSameId === 'src-a'567}568569// ---- 1.4b retained-source restoration across the loading window ----570// Executes the real OpenSpecPage body (not just descriptors) with controllable571// useQuery/useState/useEffect shims to simulate the cold-cache remount sequence:572// mount with retained id while sourcesQuery.data is undefined (loading),573// then refreshed /sources data arrives.574const shimHooks = globalThis.__openspecHooks = { hookIdx: 0, stateReg: [], effects: [], lastDeps: [] }575const queryBox = globalThis.__openspecQuery = { data: undefined, isLoading: true, error: null }576577const sdkShim2 = [578 'cn', 'Codicon', 'Loader', 'EmptyState', 'ErrorState', 'Badge', 'Button',579 'CopyButton', 'Dialog', 'DialogContent', 'Input', 'ScrollArea', 'SearchField',580 'Select', 'SelectContent', 'SelectItem', 'SelectTrigger', 'SelectValue',581 'SegmentedControl', 'Tabs', 'TabsList', 'TabsTrigger'582].map(n => 'export const ' + n + '=(...a)=>a').join(';')583 + ';export const useQuery=(o)=>globalThis.__openspecQuery'584 + ';export const ROUTES_AREA="routes"'585 + ';export const SIDEBAR_NAV_AREA="sidebar.nav"'586 + ';export default {}'587const reactShim2 = 'export function useState(v){const i=globalThis.__openspecHooks.hookIdx++;if(globalThis.__openspecHooks.stateReg[i]===undefined)globalThis.__openspecHooks.stateReg[i]={v:v};const box=globalThis.__openspecHooks.stateReg[i];const set=function(nv){box.v=typeof nv==="function"?nv(box.v):nv};return[box.v,set]};'588 + 'export function useEffect(f,d){globalThis.__openspecHooks.effects.push({f:f,d:d});return undefined};'589 + 'export function useRef(v){return{current:v}};'590 + 'export function useCallback(f,d){return f};'591 + 'export function useMemo(f,d){return f()};'592 + 'export function useContext(c){return undefined};'593 + 'export default {useState:useState,useEffect:useEffect,useRef:useRef,useCallback:useCallback,useMemo:useMemo,useContext:useContext}'594595const sdkUrl2 = 'data:text/javascript,' + encodeURIComponent(sdkShim2)596const reactUrl2 = 'data:text/javascript,' + encodeURIComponent(reactShim2)597const sdkMod2 = await import(sdkUrl2)598const blobSrc2 = pluginSrc599 .replace(/from\\s+['"]@hermes\\/plugin-sdk['"]/g, "from '" + sdkUrl2 + "'")600 .replace(/from\\s+['"]react\\/jsx-runtime['"]/g, "from '" + jsxUrl + "'")601 .replace(/from\\s+['"]react['"]/g, "from '" + reactUrl2 + "'")602603results.mount = {}604try {605 const mod2 = await import('data:text/javascript,' + encodeURIComponent(blobSrc2))606 const plugin2 = mod2.default607 const contribs2 = []608 plugin2.register({ register: function(c) { contribs2.push(c) }, registerMany: function(arr) { for (var i = 0; i < arr.length; i++) contribs2.push(arr[i]) }, rest: function() { return Promise.resolve({}) } })609 const route2 = contribs2.find(function(c) { return c.area === 'routes' })610 const page2 = route2.render()611 const OpenSpecPage2 = page2.component612 const api2 = {613 sources: function() { return queryBox.data ? queryBox.data : { sources: [] } },614 initSource: function() { return Promise.resolve({}) },615 removeSource: function() { return Promise.resolve({}) },616 updateSource: function() { return Promise.resolve({}) }617 }618619 function sameDeps(a, b) {620 if (a === b) return true621 if (!a || !b || a.length !== b.length) return false622 for (var i = 0; i < a.length; i++) { if (!Object.is(a[i], b[i])) return false }623 return true624 }625 function renderPage(store) {626 shimHooks.hookIdx = 0627 const effStart = shimHooks.effects.length628 const node = OpenSpecPage2({ api: api2, sessionStore: store })629 for (var i = effStart; i < shimHooks.effects.length; i++) {630 const e = shimHooks.effects[i]631 const prev = shimHooks.lastDeps[i]632 if (prev === undefined || !sameDeps(prev, e.d)) e.f()633 }634 shimHooks.lastDeps = shimHooks.effects.map(function(e) { return e.d })635 return node636 }637 function mountSequence(retainedId, phases) {638 shimHooks.hookIdx = 0; shimHooks.stateReg = []; shimHooks.effects = []; shimHooks.lastDeps = []639 const store = t.createSessionStore()640 store.selectedSourceId = retainedId641 const out = {}642 for (var p = 0; p < phases.length; p++) {643 queryBox.data = phases[p].data644 queryBox.isLoading = phases[p].isLoading !== false645 queryBox.error = phases[p].error || null646 const node = renderPage(store)647 out['phase' + (p + 1)] = {648 store: store.selectedSourceId,649 state: shimHooks.stateReg[0] ? shimHooks.stateReg[0].v : undefined,650 component: node && node.component ? (node.component.name || 'anon') : 'none'651 }652 }653 return out654 }655656 const SRC_A = { id: 'src-a', token: 'os_a', name: 'alpha', valid: false }657 const SRC_B = { id: 'src-b', token: 'os_b', name: 'beta', valid: true }658 const SRC_C = { id: 'src-c', token: 'os_c', name: 'gamma', valid: false }659 const populated = { sources: [SRC_A, SRC_B] }660 const populatedWithC = { sources: [SRC_A, SRC_B, SRC_C] }661 const empty = { sources: [] }662663 // Retained valid source survives the loading window and stays selected.664 results.mount.retainedValid = mountSequence('src-b', [665 { data: undefined, isLoading: true }, { data: populated, isLoading: false }666 ])667 // Registered invalid source remains selected after data arrival.668 results.mount.retainedInvalid = mountSequence('src-c', [669 { data: undefined, isLoading: true }, { data: populatedWithC, isLoading: false }670 ])671 // Absent retained source falls back to first valid against the refreshed list.672 results.mount.retainedAbsent = mountSequence('zzz', [673 { data: undefined, isLoading: true }, { data: populated, isLoading: false }674 ])675 // Genuinely empty refreshed result resolves to null — only after data arrives.676 results.mount.genuinelyEmpty = mountSequence('src-b', [677 { data: undefined, isLoading: true }, { data: empty, isLoading: false }678 ])679680 // First-source registration must remain reachable from a successful empty681 // registry result; loading and error states still take their existing paths.682 results.mount.emptySource = {}683 try {684 shimHooks.hookIdx = 0; shimHooks.stateReg = []; shimHooks.effects = []; shimHooks.lastDeps = []685 const emptyStore = t.createSessionStore()686 queryBox.data = empty687 queryBox.isLoading = false688 queryBox.error = null689 const emptyNode = renderPage(emptyStore)690 const addButtons = []691 walk(emptyNode, function(n) {692 if (n.component === sdkMod2.Button && n.props && n.props['aria-label'] === 'Add source') addButtons.push(n)693 })694 results.mount.emptySource.addButtonCount = addButtons.length695 results.mount.emptySource.addButtonReachable = addButtons.length === 1696 } catch (e) {697 results.mount.emptySource.__error = (e && e.message ? e.message : String(e)).slice(0, 800)698 }699700 // Add succeeds before conditional initialization fails: retain the701 // registered source, surface the initialization error, and make one init702 // request only (the user can recover from the refreshed invalid row).703 results.sourceAdd = {}704 if (t.initializeAddedSource) {705 const initCalls = []706 const registered = { source: { id: 'src-added', valid: false, error: 'No openspec/ directory found' } }707 const recovered = await t.initializeAddedSource({708 initSource: function(sourceId) {709 initCalls.push(sourceId)710 return Promise.reject(new Error('initialization denied'))711 },712 }, registered)713 results.sourceAdd.initFailureSourceId = recovered && recovered.source && recovered.source.id714 results.sourceAdd.initFailureError = recovered && recovered.initError715 results.sourceAdd.initFailureCalls = initCalls716 results.sourceAdd.initFailureSingleAttempt = initCalls.length === 1 && initCalls[0] === 'src-added'717718 // Exercise OpenSpecPage's saved-result handoff: refresh first, then719 // restore the returned selection while retaining the init error state.720 const priorRefetch = queryBox.refetch721 let parentRefetchCalls = 0722 const registeredSource = { id: 'src-added', valid: false, error: 'No openspec/ directory found' }723 queryBox.data = populated724 queryBox.isLoading = false725 queryBox.error = null726 queryBox.refetch = function() {727 parentRefetchCalls++728 queryBox.data = { sources: [SRC_A, SRC_B, registeredSource] }729 return Promise.resolve({ data: queryBox.data })730 }731 shimHooks.hookIdx = 0; shimHooks.stateReg = []; shimHooks.effects = []; shimHooks.lastDeps = []732 const parentStore = t.createSessionStore()733 parentStore.selectedSourceId = SRC_B.id734 const parentNode = renderPage(parentStore)735 let addButton = null736 walk(parentNode, function(n) {737 if (!addButton && n.component === sdkMod2.Button && n.props && n.props['aria-label'] === 'Add source') addButton = n738 })739 if (!addButton || typeof addButton.props.onClick !== 'function') throw new Error('Add source control was not executable')740 addButton.props.onClick()741 const dialogNode = renderPage(parentStore)742 let savedDialog = null743 walk(dialogNode, function(n) {744 if (!savedDialog && n.component && n.component.name === 'SourceDialog') savedDialog = n745 })746 if (!savedDialog || typeof savedDialog.props.onSaved !== 'function') throw new Error('SourceDialog saved handoff was not reachable')747 savedDialog.props.onSaved({ source: registeredSource, initError: 'initialization denied' })748 await new Promise(function(resolve) { setTimeout(resolve, 0) })749 renderPage(parentStore)750 results.sourceAdd.parentRefetchCalls = parentRefetchCalls751 results.sourceAdd.parentSelectedSourceId = parentStore.selectedSourceId752 results.sourceAdd.parentStateSelectedSourceId = shimHooks.stateReg[0] && shimHooks.stateReg[0].v753 results.sourceAdd.parentInitError = shimHooks.stateReg[9] && shimHooks.stateReg[9].v754 queryBox.refetch = priorRefetch755 }756757 // ---- 3.6 remediation: source copy control immediately before source selector ----758 results.sourceRow = {}759 try {760 shimHooks.hookIdx = 0; shimHooks.stateReg = []; shimHooks.effects = []; shimHooks.lastDeps = []761 const store3 = t.createSessionStore()762 store3.selectedSourceId = SRC_B.id763 queryBox.data = populated764 queryBox.isLoading = false765 queryBox.error = null766 const rowNode = renderPage(store3)767 const ordered = []768 walk(rowNode, function(n) { ordered.push(n) })769 const copyIdx = ordered.findIndex(function(n) { return n.component === sdkMod2.CopyButton && n.props && n.props.label === 'Copy source reference' })770 const selectorIdx = ordered.findIndex(function(n) { return n.component === sdkMod2.Select && n.props && n.props.value === SRC_B.id })771 const selectorTrigger = ordered.find(function(n) { return n.component === sdkMod2.SelectTrigger })772 results.sourceRow.copyIndex = copyIdx773 results.sourceRow.selectorIndex = selectorIdx774 results.sourceRow.copyImmediatelyBeforeSelector = copyIdx >= 0 && selectorIdx >= 0 && copyIdx + 1 === selectorIdx775 const selectorClassName = selectorTrigger && selectorTrigger.props && selectorTrigger.props.className776 results.sourceRow.selectorHasImportantHover = String(selectorClassName || '').includes('hover:bg-primary/[0.06]!')777 results.sourceRow.selectorHasExactHoverPair = String(selectorClassName || '').includes('transition-colors hover:bg-primary/[0.06]')778 results.sourceRow.selectorHasHoverMarker = !!(selectorTrigger && selectorTrigger.props && selectorTrigger.props['data-openspec-source-trigger'] === 'true')779 const scopedWashStyle = ordered.find(function(n) {780 return n.component === 'style' && n.props && String(n.props.children || '').includes('[data-openspec-source-trigger].desktop-input-chrome:hover')781 })782 results.sourceRow.scopedHoverWash = !!(scopedWashStyle && String(scopedWashStyle.props.children || '').includes('background-color: var(--chrome-action-hover) !important;'))783 results.sourceRow.selectorNoInlineBackground = !(selectorTrigger && selectorTrigger.props && selectorTrigger.props.style && selectorTrigger.props.style.background)784 } catch (e) {785 results.sourceRow.__error = (e && e.message ? e.message : String(e)).slice(0, 800)786 }787 // ---- 3.7 remediation: invalid source transition clears stale detail selection ----788 results.detailInvalidation = {}789 try {790 const validSrcD = { id: 'src-b', token: 'os_b', name: 'beta', valid: true, openspec: { changes: [{ token: 'ch-1', name: 'Change 1', status: 'todo' }], ideas: [] } }791 const invalidSrcD = { id: 'src-b', token: 'os_b', name: 'beta', valid: false, error: 'No openspec/ directory found' }792 shimHooks.hookIdx = 0; shimHooks.stateReg = []; shimHooks.effects = []; shimHooks.lastDeps = []793 const storeD = t.createSessionStore()794 storeD.selectedSourceId = 'src-b'795 // Cold mount: loading, then valid data arrives796 queryBox.data = undefined; queryBox.isLoading = true797 renderPage(storeD)798 queryBox.data = { sources: [validSrcD] }; queryBox.isLoading = false799 renderPage(storeD)800 // selectedItem is the 4th useState call in OpenSpecPage (index 3).801 if (!shimHooks.stateReg[3]) throw new Error('selectedItem state slot not found')802 shimHooks.stateReg[3].v = { item: { token: 'ch-1', name: 'Change 1', _type: 'change' }, sourceId: 'src-b', type: 'change' }803 const nodeWithDetail = renderPage(storeD)804 var detailBefore = 0805 walkDeep(nodeWithDetail, function(n) { if (n.component && (n.component.name === 'ChangeDetailDrawer' || n.component.name === 'IdeaDetailDrawer')) detailBefore++ })806 results.detailInvalidation.detailOpenBefore = detailBefore > 0807 // Transition retained source to invalid: resolution effect must clear selectedItem808 queryBox.data = { sources: [invalidSrcD] }809 const nodeAfterInvalid = renderPage(storeD)810 var detailAfterInvalid = 0811 walkDeep(nodeAfterInvalid, function(n) { if (n.component && (n.component.name === 'ChangeDetailDrawer' || n.component.name === 'IdeaDetailDrawer')) detailAfterInvalid++ })812 results.detailInvalidation.detailClosedAfterInvalid = detailAfterInvalid === 0813 results.detailInvalidation.selectedItemCleared = shimHooks.stateReg[3].v === null814 // Recover validity: drawer must stay closed because state was cleared815 queryBox.data = { sources: [validSrcD] }816 const nodeAfterRevalid = renderPage(storeD)817 var detailAfterRevalid = 0818 walkDeep(nodeAfterRevalid, function(n) { if (n.component && (n.component.name === 'ChangeDetailDrawer' || n.component.name === 'IdeaDetailDrawer')) detailAfterRevalid++ })819 results.detailInvalidation.detailStaysClosedAfterRevalidation = detailAfterRevalid === 0820 } catch (e) {821 results.detailInvalidation.__error = (e && e.message ? e.message : String(e)).slice(0, 800)822 }823} catch (e) {824 results.mount.__error = (e && e.message ? e.message : String(e)).slice(0, 800)825}826827// ---- WorkView archived-button descriptor/behavior ----828results.archivedButton = {}829if (t.WorkView) {830 const SRC_W = { id: 'src-w', token: 'os_w', name: 'worksource', valid: true, openspec: { changes: [], ideas: [] } }831 let toggledTo = null832 const archivedBtnNode = t.WorkView({833 api: {}, source: SRC_W, sources: [SRC_W], onSelectItem: function() {},834 showArchived: false,835 onToggleArchived: function(v) { toggledTo = v }836 })837 let archivedButton = null838 walk(archivedBtnNode, function(n) {839 if (n.component === sdkMod.Button && !archivedButton) archivedButton = n840 })841 if (archivedButton) {842 const abProps = archivedButton.props843 results.archivedButton.isButton = true844 results.archivedButton.variantOutline = abProps.variant === 'outline'845 results.archivedButton.sizeSm = abProps.size === 'sm'846 results.archivedButton.typeButton = abProps.type === 'button'847 results.archivedButton.noCustomKeyHandler = typeof abProps.onKeyDown !== 'function'848 results.archivedButton.ariaPressedFalse = abProps['aria-pressed'] === false849 results.archivedButton.inactiveHasNoActiveWash = !(abProps.style && abProps.style.backgroundColor)850 results.archivedButton.labelIncludesArchived = (abProps.children || []).some(function(c) { return c === 'Archived' })851 results.archivedButton.noCheckboxSibling = (function() {852 let found = false853 walk(archivedBtnNode, function(n) { if (n.component === 'input' && n.props && n.props.type === 'checkbox') found = true })854 return !found855 })()856 if (typeof abProps.onClick === 'function') {857 abProps.onClick()858 results.archivedButton.clickTogglesOnce = toggledTo === true859 }860 // Active state with showArchived=true: verify pressed styling and second click toggles off861 toggledTo = null862 const activeNode = t.WorkView({863 api: {}, source: SRC_W, sources: [SRC_W], onSelectItem: function() {},864 showArchived: true,865 onToggleArchived: function(v) { toggledTo = v }866 })867 let activeBtn = null868 walk(activeNode, function(n) { if (n.component === sdkMod.Button && !activeBtn) activeBtn = n })869 if (activeBtn) {870 const activeProps = activeBtn.props871 results.archivedButton.ariaPressedTrue = activeProps['aria-pressed'] === true872 results.archivedButton.activeWash = !!(activeProps.style && activeProps.style.backgroundColor === 'var(--ui-control-active-background)')873 results.archivedButton.activeCheckWhenPressed = (activeProps.children || []).some(function(c) { return c === '\u2713' })874 if (typeof activeProps.onClick === 'function') {875 activeProps.onClick()876 results.archivedButton.secondClickTogglesOnce = toggledTo === false877 }878 }879880 // Native <button> activation dispatches one click for Enter and Space;881 // exercise that browser-level equivalent against the same click handler.882 function nativeButtonKey(buttonProps, key) {883 if (!buttonProps || buttonProps.type !== 'button' || typeof buttonProps.onClick !== 'function') return false884 if (key !== 'Enter' && key !== ' ') return false885 buttonProps.onClick({ type: 'click', detail: 0 })886 return true887 }888 let enterCalls = 0889 let enterValue = null890 const enterNode = t.WorkView({891 api: {}, source: SRC_W, sources: [SRC_W], onSelectItem: function() {},892 showArchived: false,893 onToggleArchived: function(v) { enterCalls++; enterValue = v }894 })895 let enterBtn = null896 walk(enterNode, function(n) { if (n.component === sdkMod.Button && !enterBtn) enterBtn = n })897 const enterActivated = nativeButtonKey(enterBtn && enterBtn.props, 'Enter')898 results.archivedButton.enterTogglesOnce = enterActivated && enterCalls === 1 && enterValue === true899900 let spaceCalls = 0901 let spaceValue = null902 const spaceNode = t.WorkView({903 api: {}, source: SRC_W, sources: [SRC_W], onSelectItem: function() {},904 showArchived: false,905 onToggleArchived: function(v) { spaceCalls++; spaceValue = v }906 })907 let spaceBtn = null908 walk(spaceNode, function(n) { if (n.component === sdkMod.Button && !spaceBtn) spaceBtn = n })909 const spaceActivated = nativeButtonKey(spaceBtn && spaceBtn.props, ' ')910 results.archivedButton.spaceTogglesOnce = spaceActivated && spaceCalls === 1 && spaceValue === true911 }912}913914console.log(JSON.stringify(results))915`916917const identity = runIdentityDriver()918919// Contract checks920console.log(' Plugin identity')921check(r.id === 'openspec', 'Plugin id is "openspec"', r.id ? 'Got: ' + r.id : 'null')922check(r.name === 'OpenSpec', 'Plugin name is "OpenSpec"', r.name)923check(r.hasRegister, 'Has register() function')924925console.log('\n Contributions')926check(r.contribCount >= 2, 'At least 2 contributions (got ' + r.contribCount + ')')927check(r.routeContrib !== undefined, 'Has ROUTES_AREA contribution')928check(r.navContrib !== undefined, 'Has SIDEBAR_NAV_AREA contribution')929930console.log('\n Route and sidebar metadata')931var rp = r.routeContrib && r.routeContrib.data && r.routeContrib.data.path932check(rp === '/openspec', 'Route path "/openspec"', rp ? 'Got: ' + rp : 'missing')933check(r.navContrib && r.navContrib.data && r.navContrib.data.label === 'OpenSpec', 'Sidebar nav label "OpenSpec"')934check(r.navContrib && r.navContrib.data && r.navContrib.data.codicon != null, 'Sidebar nav has codicon')935check(r.navContrib && r.navContrib.data && r.navContrib.data.path === '/openspec', 'Sidebar nav path /openspec')936937console.log('\n Render function')938var hasRender = r.routeContribHasRender === true939check(hasRender, 'Route contribution has render()')940if (r.renderResult) {941 check(!r.renderResult.error, 'render() executes without error', r.renderResult.error)942 check(r.renderResult.returned === true, 'render() returns a value')943 check(r.renderResult.isJsx === true, 'render() returns jsx descriptor')944} else if (hasRender) {945 fail('render() execution', 'renderResult not captured')946}947948// ═══════════════════════════════════════════════════════════════════════════════949// 2.2 Path builder verification950// ═══════════════════════════════════════════════════════════════════════════════951console.log('\n[2.2] Path builders (from __test)')952953check(r.__testAvailable, '__test export is available')954if (r.__testAvailable) {955 var pb = r.pathBuilders || {}956 check(pb.sources === '/sources', 'sourcesPath() returns "/sources"', pb.sources)957958 check(pb.change === '/sources/src-1/changes/my-change',959 'changePath(src-1, my-change) encodes correctly', pb.change)960 check(pb.changeSpecial === '/sources/src-1/changes/feat%2Fwith%20spaces%20%26%20special',961 'changePath encodes special characters', pb.changeSpecial)962963 check(pb.idea === '/sources/src-1/ideas/my-idea',964 'ideaPath(src-1, my-idea) encodes correctly', pb.idea)965 check(pb.ideaSpecial === '/sources/src-1/ideas/feat%2Fwith%20spaces%20%26%20special',966 'ideaPath encodes special characters', pb.ideaSpecial)967968 check(pb.specBrowser === '/sources/src-1/spec-browser',969 'specBrowserPath(src-1) returns clean path', pb.specBrowser)970 check(pb.specBrowserDirty === '/sources/src-1/spec-browser?dirty=true',971 'specBrowserPath dirty adds query param', pb.specBrowserDirty)972973 check(pb.spec === '/sources/src-1/specs?path=specs%2Freadme.md',974 'specPath encodes path parameter', pb.spec)975 check(pb.specSpecial === '/sources/src-1/specs?path=specs%2Fwith%20spaces.md',976 'specPath encodes special chars in path', pb.specSpecial)977978 // Verify no malformed paths979 check(!String(pb.sources).includes('undefined'), 'sources path has no undefined')980 check(!String(pb.change).includes('undefined'), 'change path has no undefined')981 check(!String(pb.idea).includes('undefined'), 'idea path has no undefined')982 check(!String(pb.spec).includes('undefined'), 'spec path has no undefined')983} else {984 fail('__test path builders', '__test not available')985}986987// ═══════════════════════════════════════════════════════════════════════════════988// 3.1 Source selection helpers989// ═══════════════════════════════════════════════════════════════════════════════990console.log('\n[3.1] Source selection helpers')991992if (r.__testAvailable) {993 var ss = r.sourceSelection || {}994 check(ss.firstValid && ss.firstValid.id === 'src-2', 'selectFirstValid picks first valid source',995 ss.firstValid && ss.firstValid.id ? 'Got: ' + ss.firstValid.id : 'null')996 check(ss.allInvalid && ss.allInvalid.id === 'src-1', 'selectFirstValid falls back to first when all invalid',997 ss.allInvalid && ss.allInvalid.id ? 'Got: ' + ss.allInvalid.id : 'null')998 check(ss.noSources === null || ss.noSources === undefined, 'selectFirstValid returns null for empty array',999 JSON.stringify(ss.noSources))1000} else {1001 fail('Source selection helpers', '__test not available')1002}10031004// ═══════════════════════════════════════════════════════════════════════════════1005// 4.1 Board helpers1006// ═══════════════════════════════════════════════════════════════════════════════1007console.log('\n[4.1] Board helpers')10081009if (r.__testAvailable) {1010 var bd = r.board || {}10111012 // Status normalization1013 console.log(' Status normalization')1014 check(bd.normalizeInProgress === 'in-progress', 'normalizeStatus("in-progress") = "in-progress"',1015 String(bd.normalizeInProgress))1016 check(bd.normalizeDraft === 'draft', 'normalizeStatus("draft") = "draft"',1017 String(bd.normalizeDraft))1018 check(bd.normalizeArchived === 'archived', 'normalizeStatus("archived") = "archived"',1019 String(bd.normalizeArchived))1020 check(bd.normalizeIdeas === 'ideas', 'normalizeStatus("ideas") = "ideas"',1021 String(bd.normalizeIdeas))1022 check(bd.normalizeUnknown != null, 'normalizeStatus handles unknown status',1023 String(bd.normalizeUnknown))10241025 // Group by status1026 console.log(' Group by status')1027 check(Array.isArray(bd.groupKeys) && bd.groupKeys.length >= 4, 'groupByStatus returns groups',1028 'Got ' + (bd.groupKeys && bd.groupKeys.length) + ' groups: ' + (bd.groupKeys && bd.groupKeys.join(', ')))1029 check(bd.groupCounts && bd.groupCounts['in-progress'] === 1, 'Group "in-progress" has 1 item',1030 JSON.stringify(bd.groupCounts && bd.groupCounts['in-progress']))1031 check(bd.groupCounts && bd.groupCounts.ideas === 1, 'Group "ideas" has 1 item',1032 String(bd.groupCounts && bd.groupCounts.ideas))1033 check(bd.groupCounts && bd.groupCounts.draft === 1, 'Group "draft" has 1 item',1034 JSON.stringify(bd.groupCounts && bd.groupCounts.draft))1035 check(bd.groupCounts && bd.groupCounts.done === 1, 'Group "done" has 1 item',1036 JSON.stringify(bd.groupCounts && bd.groupCounts.done))10371038 // in_progress (underscore) grouping — backend emits this spelling1039 console.log(' in_progress grouping')1040 check(bd.inProgressGroupCount === 3, 'All in_progress/in-progress/inprogress variants group to "in-progress"',1041 'Got ' + bd.inProgressGroupCount)1042 check(bd.inProgressNormalized === 'in-progress', 'normalizeStatus("in_progress") = "in-progress"',1043 JSON.stringify(bd.inProgressNormalized))10441045 // Sort1046 console.log(' Sort')1047 check(Array.isArray(bd.sorted) && bd.sorted.length === 3, 'sortItems returns sorted array',1048 JSON.stringify(bd.sorted))1049 check(bd.sorted && bd.sorted[0] === 'c', 'sortItems: sequence 1, title Alpha first', JSON.stringify(bd.sorted))10501051 // Backend-shaped sequence metadata: the producer returns {position, ...},1052 // not a number. Verify the consumer boundary drives both ordering and UI.1053 const seq = identity && identity.sequence ? identity.sequence : {}1054 check(Array.isArray(seq.sorted) && seq.sorted.join(',') === 'early,late',1055 'sortItems orders backend sequence.position metadata', JSON.stringify(seq.sorted))1056 check(seq.objectPosition === 7, 'sequence.position normalizes to numeric position', JSON.stringify(seq.objectPosition))1057 check(seq.malformedPosition === null && seq.missingPosition === null,1058 'Malformed or absent sequence metadata is ignored', JSON.stringify({ malformed: seq.malformedPosition, missing: seq.missingPosition }))1059 check(seq.backendBadge === true, 'BoardCard renders backend sequence.position badge', JSON.stringify(seq.backendBadge))1060 check(seq.malformedBadgeCount === 0, 'BoardCard omits malformed sequence badge', JSON.stringify(seq.malformedBadgeCount))10611062 // Filter1063 console.log(' Filter')1064 check(bd.filteredAlpha && bd.filteredAlpha.length === 1 && bd.filteredAlpha[0] === 'alpha-change',1065 'filterItems by title substring', JSON.stringify(bd.filteredAlpha))1066 check(bd.filteredBeta && bd.filteredBeta.length === 1 && bd.filteredBeta[0] === 'beta-fix',1067 'filterItems by token', JSON.stringify(bd.filteredBeta))1068 check(bd.filteredEmpty && bd.filteredEmpty.length === 0, 'filterItems no match returns empty',1069 JSON.stringify(bd.filteredEmpty))1070 check(bd.filteredClear && bd.filteredClear.length === 3, 'filterItems empty string returns all',1071 JSON.stringify(bd.filteredClear))10721073 // Archived toggle1074 console.log(' Archived toggle')1075 check(bd.withArchived === false, 'toggleArchived(true) = false', JSON.stringify(bd.withArchived))1076 check(bd.withoutArchived === true, 'toggleArchived(false) = true', JSON.stringify(bd.withoutArchived))1077} else {1078 fail('Board helpers', '__test not available')1079}10801081// ═══════════════════════════════════════════════════════════════════════════════1082// 5.1 Adapter and detail helpers1083// ═══════════════════════════════════════════════════════════════════════════════1084console.log('\n[5.1] Detail helpers')10851086if (r.__testAvailable) {1087 // Task parser1088 var t = r.tasks || {}1089 console.log(' Task parser')1090 check(t.total === 4, 'parseTasks total count = 4', JSON.stringify(t.total))1091 check(t.done === 2, 'parseTasks done count = 2', JSON.stringify(t.done))1092 check(t.sections != null, 'parseTasks returns sections', JSON.stringify(t.sections))10931094 // Markdown parser1095 var md = r.markdown || {}1096 console.log(' Markdown parser')1097 check(md.isArray === true, 'renderMarkdown returns array', JSON.stringify(md.isArray))1098 check(md.length > 0, 'renderMarkdown produces elements', JSON.stringify(md.length))10991100 // State classifier1101 console.log(' State classifier')1102 check(md.stateLoading === 'loading', 'classifyState loading', JSON.stringify(md.stateLoading))1103 check(md.stateError === 'error', 'classifyState error', JSON.stringify(md.stateError))1104 check(md.stateEmpty === 'empty', 'classifyState empty', JSON.stringify(md.stateEmpty))1105 check(md.stateContent === 'content', 'classifyState content', JSON.stringify(md.stateContent))1106} else {1107 fail('Detail helpers', '__test not available')1108}11091110// ═══════════════════════════════════════════════════════════════════════════════1111// 6.1 Spec-browser helpers1112// ═══════════════════════════════════════════════════════════════════════════════1113console.log('\n[6.1] Spec-browser helpers')11141115if (r.__testAvailable) {1116 var df = r.diff || {}11171118 console.log(' Diff line classifier')1119 check(df.added === 'added', 'classifyDiffLine("+...") = "added"', JSON.stringify(df.added))1120 check(df.removed === 'removed', 'classifyDiffLine("-...") = "removed"', JSON.stringify(df.removed))1121 check(df.hunk === 'hunk', 'classifyDiffLine("@@...") = "hunk"', JSON.stringify(df.hunk))1122 check(df.context === 'context', 'classifyDiffLine(" ...") = "context"', JSON.stringify(df.context))11231124 console.log(' Diff mode availability')1125 check(df.hasSemantic === true, 'hasDiffMode semantic available', JSON.stringify(df.hasSemantic))1126 check(df.hasSplit === true, 'hasDiffMode split available', JSON.stringify(df.hasSplit))1127 check(df.hasRaw === true, 'hasDiffMode raw available', JSON.stringify(df.hasRaw))1128 check(df.noSemantic === false, 'hasDiffMode semantic unavailable when absent', JSON.stringify(df.noSemantic))11291130 // URL safety1131 console.log(' URL safety')1132 var us = r.urlSafety || {}1133 check(us.httpsAllowed === true, 'isSafeUrl allows https', JSON.stringify(us.httpsAllowed))1134 check(us.httpAllowed === true, 'isSafeUrl allows http', JSON.stringify(us.httpAllowed))1135 check(us.relativeAllowed === true, 'isSafeUrl allows relative paths', JSON.stringify(us.relativeAllowed))1136 check(us.fragmentAllowed === true, 'isSafeUrl allows fragment links', JSON.stringify(us.fragmentAllowed))1137 check(us.queryAllowed === true, 'isSafeUrl allows query-only links', JSON.stringify(us.queryAllowed))1138 check(us.mailtoAllowed === true, 'isSafeUrl allows mailto', JSON.stringify(us.mailtoAllowed))1139 check(us.javascriptBlocked === true, 'isSafeUrl blocks javascript:', JSON.stringify(us.javascriptBlocked))1140 check(us.dataBlocked === true, 'isSafeUrl blocks data:', JSON.stringify(us.dataBlocked))1141 check(us.fileBlocked === true, 'isSafeUrl blocks file:', JSON.stringify(us.fileBlocked))1142 check(us.vbscriptBlocked === true, 'isSafeUrl blocks vbscript:', JSON.stringify(us.vbscriptBlocked))1143 check(us.emptyBlocked === true, 'isSafeUrl blocks empty string', JSON.stringify(us.emptyBlocked))1144 check(us.nullBlocked === true, 'isSafeUrl blocks null', JSON.stringify(us.nullBlocked))1145 // Whitespace/control obfuscation1146 check(us.wsJavascriptBlocked === true, 'isSafeUrl blocks whitespace-prefixed javascript:',1147 JSON.stringify(us.wsJavascriptBlocked))1148 check(us.tabJavascriptBlocked === true, 'isSafeUrl blocks tab-prefixed javascript:',1149 JSON.stringify(us.tabJavascriptBlocked))1150 check(us.newlineJavascriptBlocked === true, 'isSafeUrl blocks newline-prefixed javascript:',1151 JSON.stringify(us.newlineJavascriptBlocked))1152 check(us.controlJavascriptBlocked === true, 'isSafeUrl blocks control-char-prefixed javascript:',1153 JSON.stringify(us.controlJavascriptBlocked))1154 check(us.uppercaseJavascriptBlocked === true, 'isSafeUrl blocks JAVASCRIPT: (uppercase)',1155 JSON.stringify(us.uppercaseJavascriptBlocked))1156 check(us.mixedCaseDataBlocked === true, 'isSafeUrl blocks DaTa: (mixed case)',1157 JSON.stringify(us.mixedCaseDataBlocked))11581159 // __test is frozen1160 console.log(' __test frozen')1161 check(r.testFrozen === true, '__test object is frozen', JSON.stringify(r.testFrozen))11621163 // renderInline returns JSX (not plain objects) — verified via executable render checks1164 console.log(' renderInline executable checks')1165 var ix = r.inlineJsx || {}1166 check(ix.isArray === true, 'renderMarkdown returns array for inline test')1167 check(ix.hasParagraph === true, 'renderMarkdown produces paragraph with inline text')1168 // EXECUTABLE: MarkdownElement returns JSX descriptors, not parser objects1169 check(ix.blocksRenderToJsx === true, 'MarkdownElement renders all blocks to JSX (not parser objects)',1170 ix.anyBlockFailed ? 'Block failed to render' : undefined)1171 // EXECUTABLE: renderInline returns JSX elements for inline markers1172 check(ix.inline && ix.inline.isArray === true, 'renderInline returns array')1173 check(ix.inline && ix.inline.allJsx === true, 'renderInline returns JSX elements (not plain objects)')1174 // EXECUTABLE: mixed bold/code/link each produce JSX1175 check(ix.mixed && ix.mixed.hasThree === true, 'renderInline produces 3 JSX elements for bold+code+link',1176 ix.mixed ? 'Got ' + ix.mixed.count : 'no data')1177} else {1178 fail('Spec-browser helpers', '__test not available')1179}11801181// ═══════════════════════════════════════════════════════════════════════════════1182// Behavioral fixture tests — backend contract alignment1183// ═══════════════════════════════════════════════════════════════════════════════1184console.log('\n[Behavioral] Backend contract fixture tests')11851186if (r.__testAvailable) {1187 // 1. Semantic groups normalization1188 var sg = r.semanticGroups || {}1189 console.log(' Semantic groups normalization')1190 check(sg.isArray === true, 'normalizeSemanticGroups returns array', JSON.stringify(sg.isArray))1191 check(sg.length === 3, 'normalizeSemanticGroups returns 3 items (added+modified+removed)', 'Got ' + sg.length)1192 check(sg.addedLabel === 'Added', 'First item labeled "Added"', JSON.stringify(sg.addedLabel))1193 check(sg.modifiedLabel === 'Modified', 'Second item labeled "Modified"', JSON.stringify(sg.modifiedLabel))1194 check(sg.removedLabel === 'Removed', 'Third item labeled "Removed"', JSON.stringify(sg.removedLabel))1195 check(sg.noObjectString === true, 'No [object Object] in descriptions/before/after',1196 !sg.noObjectString ? 'Found [object Object]' : undefined)1197 check(sg.addedName === 'Auth', 'Added item preserves name', JSON.stringify(sg.addedName))1198 check(sg.modifiedName === 'Data Model', 'Modified item preserves name', JSON.stringify(sg.modifiedName))1199 check(sg.removedName === 'Legacy', 'Removed item preserves name', JSON.stringify(sg.removedName))1200 check(sg.beforeDesc === 'Old schema with 3 fields', 'Modified before extracted from object', JSON.stringify(sg.beforeDesc))1201 check(sg.afterDesc === 'New schema with 5 fields', 'Modified after extracted from object', JSON.stringify(sg.afterDesc))1202 check(sg.noCrash === true, 'normalizeSemanticGroups does not crash on backend shape')1203 check(sg.oldCodeWouldThrow === true, 'Old code (data.requirements.map) would throw on real backend shape')12041205 // 2. Card artifacts from has* booleans1206 var ca = r.cardArtifacts || {}1207 console.log(' Card artifacts from has* booleans')1208 check(ca.hasProposal === true, 'hasProposal boolean present', JSON.stringify(ca.hasProposal))1209 check(ca.hasTasks === true, 'hasTasks boolean present', JSON.stringify(ca.hasTasks))1210 check(ca.hasDesign === true, 'hasDesign boolean present', JSON.stringify(ca.hasDesign))1211 check(ca.hasSpecs === true, 'hasSpecs boolean present', JSON.stringify(ca.hasSpecs))1212 check(ca.noArtifactsField === true, 'No artifacts field on source summary')1213 check(ca.noArtifactNamesField === true, 'No artifactNames field on source summary')12141215 // 3. effectiveDiffMode fallback1216 var ed = r.effectiveDiffMode || {}1217 console.log(' Effective diff mode fallback')1218 check(ed.semanticAvailable === 'semantic', 'Returns semantic when available', JSON.stringify(ed.semanticAvailable))1219 check(ed.fallbackToSplit === 'split', 'Falls back to split when semantic unavailable', JSON.stringify(ed.fallbackToSplit))1220 check(ed.fallbackToRaw === 'raw', 'Falls back to raw when only raw available', JSON.stringify(ed.fallbackToRaw))1221 check(ed.nothingAvailable === 'semantic', 'Returns desired when nothing available', JSON.stringify(ed.nothingAvailable))1222 check(ed.splitToRaw === 'raw', 'Split desired falls back to raw', JSON.stringify(ed.splitToRaw))1223} else {1224 fail('Backend contract fixture tests', '__test not available')1225}12261227// ═══════════════════════════════════════════════════════════════════════════════1228// Canonical status tone mapping and style shape tests1229// ═══════════════════════════════════════════════════════════════════════════════1230console.log('\n[Canonical] Status tone mapping and style shape')12311232if (r.__testAvailable) {1233 var st = r.statusTone || {}1234 console.log(' STATUS_TONE mapping')1235 check(st.ideas === 'var(--ui-text-tertiary)', 'ideas -> triage tone', JSON.stringify(st.ideas))1236 check(st.draft === '#a78bfa', 'draft -> scheduled tone', JSON.stringify(st.draft))1237 check(st.todo === 'var(--ui-text-secondary)', 'todo -> todo tone', JSON.stringify(st.todo))1238 check(st.inProgress === '#34d399', 'in-progress -> running tone', JSON.stringify(st.inProgress))1239 check(st.done === 'var(--ui-text-tertiary)', 'done -> done tone', JSON.stringify(st.done))1240 check(st.archived === 'var(--ui-text-quaternary)', 'archived -> archived tone', JSON.stringify(st.archived))1241 check(st.unknown === 'var(--ui-text-secondary)', 'statusTone falls back to secondary for unknown', JSON.stringify(st.unknown))12421243 console.log(' STATUS_TONE values')1244 check(st.ideasIsVar === true, 'ideas tone uses var(--ui-text-tertiary)', JSON.stringify(st.ideasIsVar))1245 check(st.draftIsPurple === true, 'draft tone is #a78bfa', JSON.stringify(st.draftIsPurple))1246 check(st.todoIsVar === true, 'todo tone uses var(--ui-text-secondary)', JSON.stringify(st.todoIsVar))1247 check(st.inProgressIsGreen === true, 'in-progress tone is #34d399', JSON.stringify(st.inProgressIsGreen))1248 check(st.doneIsVar === true, 'done tone uses var(--ui-text-tertiary)', JSON.stringify(st.doneIsVar))1249 check(st.archivedIsVar === true, 'archived tone uses var(--ui-text-quaternary)', JSON.stringify(st.archivedIsVar))12501251 // Style shape checks1252 var ll = r.laneLayout || {}1253 console.log(' Canonical lane style shape')1254 check(ll.expandedHasBg === true, 'Expanded lane uses color-mix canonical wash', JSON.stringify(ll.expandedHasBg))1255 check(ll.expandedHasRounded === true, 'Expanded lane has 8px border-radius', JSON.stringify(ll.expandedHasRounded))1256 check(ll.expandedHasPadding === true, 'Expanded lane has 8px padding', JSON.stringify(ll.expandedHasPadding))1257 check(ll.railWidth === '32px', 'Rail width is 32px', JSON.stringify(ll.railWidth))1258 check(ll.railFlexShrink === 0, 'Rail flex-shrink is 0', JSON.stringify(ll.railFlexShrink))12591260 // Static source checks for canonical styling1261 console.log(' Canonical source patterns')1262 check(source.includes('statusTone'), 'statusTone function used in source')1263 check(source.includes('STATUS_TONE'), 'STATUS_TONE map present in source')1264 check(source.includes("borderLeftColor: statusTone(status)"), 'BoardCard uses statusTone for left border color')1265 check(source.includes("borderLeftWidth: '2px'"), 'BoardCard has 2px left border')1266 check(!source.includes("background: 'var(--ui-bg-elevated)'"), 'BoardCard has no inline elevated background (class-based surface)')1267 check(source.includes('bg-(--ui-bg-elevated)'), 'BoardCard base class is exact compiled bg-(--ui-bg-elevated)')1268 check(source.includes('border-(--ui-stroke-tertiary)'), 'BoardCard uses canonical stroke-tertiary border class')1269 check(source.includes("backgroundColor: statusTone(status)"), 'Lane/rail status dot uses statusTone')1270 check(!source.includes('#1a1a2e'), 'No hardcoded blue rail background')1271} else {1272 fail('Canonical status tone mapping', '__test not available')1273}12741275// ═══════════════════════════════════════════════════════════════════════════════1276// Focused descriptor tests — BoardColumn and BoardCard contracts (source-level)1277// ═══════════════════════════════════════════════════════════════════════════════1278console.log('\n[Descriptor] BoardColumn and BoardCard contracts')12791280// --- Collapsed rail ---1281console.log(' Collapsed rail')1282check(source.includes("writingMode: 'vertical-rl'"), 'Collapsed rail label uses writingMode vertical-rl')1283check(source.includes("display: 'grid'") && source.includes("height: '20px'"), 'Status dot occupies 20px grid header slot')1284check(source.includes("fontVariantNumeric: 'tabular-nums'"), 'Bare count uses tabular-nums')1285check(!source.includes("justifyContent: 'center'"), 'Rail has no justifyContent center (top-flowing)')12861287// --- Expanded lane header ---1288console.log(' Expanded lane header')1289check(!source.includes("'(' + count + ')'"), 'Expanded header count has no parentheses (bare)')1290check(source.includes("fontVariantNumeric: 'tabular-nums'"), 'Expanded header count uses tabular-nums')1291// NEW: lane-drawer-interactions contract assertions (source-level, RED before implementation)1292check(source.includes("name: 'chevron-left'"), 'Expanded lane header uses Codicon chevron-left')1293{1294const colStart = source.indexOf('function BoardColumn')1295const colEnd = source.indexOf('\nfunction ', colStart + 1)1296const colSec = source.slice(colStart, colEnd > 0 ? colEnd : source.length)1297// The expanded header must be one native <button> spanning the full header.1298const headerButtonMatches = colSec.match(/jsx\(\s*['"]button['"]\s*,\s*\{/g) || colSec.match(/jsxs\(\s*['"]button['"]\s*,\s*\{/g) || colSec.match(/jsx\('button',\s*\{/g) || colSec.match(/jsxs\('button',\s*\{/g)1299check(headerButtonMatches && headerButtonMatches.length === 1, 'Expanded header is a single native button', JSON.stringify(headerButtonMatches))1300check(colSec.includes("type: 'button'"), 'Expanded header button has type=button')1301check(colSec.includes('handleActivateKey'), 'Expanded header button uses handleActivateKey for keyboard')1302check(colSec.includes("'aria-label': 'Collapse '"), 'Expanded header button has accessible collapse label')1303check(colSec.includes("title: 'Collapse '"), 'Expanded header button has collapse title')1304check(!colSec.includes("'\\u2013'"), 'Expanded header does not render raw minus glyph')1305check(colSec.match(/onClick:\s*onExpand[^}]*title:/) || colSec.match(/title:[^}]*onClick:\s*onExpand/), 'Expanded header button uses native button activation')1306}1307// NEW: detail drawer contract assertions (source-level)1308{1309 const pageStart = source.indexOf('function OpenSpecPage')1310 const pageEnd = source.indexOf('\nfunction ', pageStart + 1)1311 const pageSec = source.slice(pageStart, pageEnd > 0 ? pageEnd : source.length)1312 check(pageSec.includes('DetailDrawer'), 'OpenSpecPage renders a DetailDrawer for selected items')1313 check(source.includes('className: cn(\'absolute inset-y-0 right-0'), 'Drawer root is positioned absolute inset-y-0 right-0')1314 check(!pageSec.includes('ChangeDetailDialog') && !pageSec.includes('IdeaDetailDialog'), 'Change/Idea detail is not wrapped in Dialog')1315 check(!pageSec.includes('jsx(Dialog, { open: true, onOpenChange: onClose, children: jsx(DetailDrawer'), 'Page does not wrap drawer in Dialog')1316 check(pageSec.includes('selectedItem && selectedItem.sourceId === selectedSourceId'), 'Detail visibility is still gated on selected source')1317 check(pageSec.includes('setSelectedItem(null)'), 'Close control clears selected item')1318 check(source.includes("'aria-label': 'Close detail'"), 'Drawer has accessible close button label')1319 check(source.includes('w-[26rem]'), 'Drawer uses Kanban-aligned compact width')1320 check(source.includes('border-l'), 'Drawer has left border')1321 check(source.includes("overflowY: 'auto'"), 'Drawer body supports vertical scrolling')1322}1323// NEW: detail drawer class tokens are present on the DetailDrawer root1324{1325 const ddStart = source.indexOf('function DetailDrawer')1326 const ddEnd = source.indexOf('\nfunction ', ddStart + 1)1327 const ddSec = source.slice(ddStart, ddEnd > 0 ? ddEnd : source.length)1328 check(ddSec.includes('w-[26rem]'), 'Drawer root uses compact width class')1329 check(ddSec.includes('border-l'), 'Drawer root has left border class')1330}1331// NEW: source-management dialogs remain modal1332{1333 const pageStart = source.indexOf('function OpenSpecPage')1334 const pageEnd = source.indexOf('\nfunction ', pageStart + 1)1335 const pageSec = source.slice(pageStart, pageEnd > 0 ? pageEnd : source.length)1336 check(pageSec.includes("jsx(SourceDialog") || pageSec.includes("jsxs(SourceDialog"), 'Source add/edit dialog is still rendered')1337 check(pageSec.includes("jsx(Dialog, { open: true, onOpenChange: function() { if (!removeBusy)"), 'Remove source confirmation remains a modal Dialog')1338 check(!pageSec.includes('SourceDialog') || pageSec.includes('SourceDialog') && (pageSec.includes('jsx(SourceDialog') || pageSec.includes('jsxs(SourceDialog')), 'SourceDialog is rendered directly (not inside drawer)')1339}1340// NEW: detail component names are drawer-specific1341{1342 check(source.includes('function ChangeDetailDrawer'), 'Change detail component exists')1343 check(source.includes('function IdeaDetailDrawer'), 'Idea detail component exists')1344 check(!source.includes('function ChangeDetailDialog'), 'Change detail component renamed to drawer')1345 check(!source.includes('function IdeaDetailDialog'), 'Idea detail component renamed to drawer')1346 // Loading/error detail states used Dialog previously; ensure they are gone.1347 check(!source.includes("jsx(Dialog, { open: true, onOpenChange: onClose, children: jsx(DialogContent, { children: jsx(Loader"), 'Detail loading no longer uses modal Dialog')1348 check(!source.includes("jsx(Dialog, { open: true, onOpenChange: onClose, children: jsx(DialogContent, { children: jsx(RetryErrorState"), 'Detail error no longer uses modal Dialog')1349}13501351// --- BoardCard ---1352console.log(' BoardCard')1353check(source.includes('border-(--ui-stroke-tertiary)'), 'Card uses canonical stroke-tertiary border class')1354check(!source.includes("background: 'var(--ui-bg-elevated)'"), 'Card background is class-based (no inline elevated surface)')1355check(source.includes('bg-(--ui-bg-elevated)'), 'Card base class is exact compiled bg-(--ui-bg-elevated)')1356check(source.includes("borderLeftWidth: '2px'"), 'Card has 2px tone left border')1357check(source.includes("borderLeftColor: statusTone(status)"), 'Card left border uses statusTone')1358{1359 var bcStart = source.indexOf('function BoardCard')1360 var bcEnd = source.indexOf('\nfunction ', bcStart + 1)1361 var bcSection = source.slice(bcStart, bcEnd > 0 ? bcEnd : source.length)1362 check(bcSection.includes('px-3 pt-3 pb-0'), 'Card removes excess bottom padding')1363 check(!bcSection.includes('pb-2'), 'Card no longer uses previous bottom padding')1364 check(bcSection.includes("flexDirection: 'column'"), 'Card footer uses explicit stacked rows')1365 check(source.includes("children: '✅' + done + '/' + total"), 'Card task fraction uses compact checkmark format')1366}13671368// --- BoardCard sequence badge guard ---1369console.log(' BoardCard sequence badge guard')1370{1371 // Extract the BoardCard function body to test sequence handling1372 var bcStart = source.indexOf('function BoardCard')1373 var bcEnd = source.indexOf('\nfunction ', bcStart + 1)1374 var bcSection = source.slice(bcStart, bcEnd > 0 ? bcEnd : source.length)13751376 // The consumer boundary normalizes backend objects and rejects non-finite1377 // or malformed values — no [object Object] substring anywhere.1378 check(bcSection.includes('normalizeSequencePosition'), 'Sequence badge uses sequence-position normalization')1379 check(source.includes('function normalizeSequencePosition'), 'Sequence normalization helper validates numeric positions')13801381 // Verify the guard pattern is correct by testing the expression directly1382 var seqGuard = function(seq) {1383 return typeof seq === 'number' && Number.isFinite(seq) ? '#' + seq : null1384 }1385 check(seqGuard(42) === '#42', 'sequence=42 renders #42')1386 check(seqGuard(0) === '#0', 'sequence=0 renders #0')1387 check(seqGuard(-3) === '#-3', 'sequence=-3 renders #-3')1388 check(seqGuard(NaN) === null, 'sequence=NaN renders null')1389 check(seqGuard(Infinity) === null, 'sequence=Infinity renders null')1390 check(seqGuard({}) === null, 'sequence={} renders null (no [object Object])')1391 check(seqGuard([]) === null, 'sequence=[] renders null')1392 check(seqGuard('5') === null, 'sequence="5" renders null (string)')1393 check(seqGuard(null) === null, 'sequence=null renders null')1394 check(seqGuard(undefined) === null, 'sequence=undefined renders null')1395}13961397// ═══════════════════════════════════════════════════════════════════════════════1398// Static analysis checks — SDK usage patterns1399// ═══════════════════════════════════════════════════════════════════════════════1400console.log('\n[Static] Source analysis')14011402// Loading/error/empty state transitions1403check(source.includes('isLoading'), 'Checks isLoading state')1404check(source.includes('error'), 'Checks error state')1405check(source.includes('Loader'), 'Uses Loader component')1406check(source.includes('ErrorState'), 'Uses ErrorState component')1407check(source.includes('EmptyState'), 'Uses EmptyState component')14081409// Source quality1410check(!source.includes('console.log'), 'No debug console.log')1411check(!source.includes('// TODO'), 'No TODO comments')1412check(!source.includes('alert('), 'No alert() calls')14131414// Retry handlers on error states1415check(source.includes('query.refetch()'), 'query.refetch() retry handler present')1416check(source.includes('specQuery.refetch()'), 'specQuery.refetch() retry handler present')1417// CRITICAL: No ErrorState uses onRetry (SDK does not accept it)1418// Retry is handled by RetryErrorState wrapper with Button child14191420// Stale detail guard — item bound to source at click1421check(source.includes('selectedItem.sourceId === selectedSourceId'), 'Detail dialog has source-matching gate')1422check(source.includes('{ item: item, sourceId: selectedSourceId, type:'), 'onSelectItem binds item to source')1423check(!source.includes('detailType'), 'Old detailType state removed')14241425// normalizeSemanticGroups exists and handles backend shape1426check(source.includes('function normalizeSemanticGroups'), 'normalizeSemanticGroups function defined')1427check(source.includes('data.requirements'), 'Reads data.requirements (backend contract)')1428check(source.includes('reqs.added'), 'Extracts added array from requirements')1429check(source.includes('reqs.modified'), 'Extracts modified array from requirements')1430check(source.includes('reqs.removed'), 'Extracts removed array from requirements')14311432// effectiveDiffMode fallback1433check(source.includes('function effectiveDiffMode'), 'effectiveDiffMode function defined')14341435// CardArtifacts reads has* booleans1436check(source.includes('item.hasProposal'), 'CardArtifacts reads hasProposal')1437check(source.includes('item.hasTasks'), 'CardArtifacts reads hasTasks')1438check(source.includes('item.hasDesign'), 'CardArtifacts reads hasDesign')1439check(source.includes('item.hasSpecs'), 'CardArtifacts reads hasSpecs')1440check(!source.includes('item.artifacts || item.artifactNames'), 'Old artifacts/artifactNames pattern removed')14411442// ═══════════════════════════════════════════════════════════════════════════════1443// Structural pattern checks — SDK compliance1444// ═══════════════════════════════════════════════════════════════════════════════1445console.log('\n[Structural] SDK usage pattern checks')14461447// 1. DialogContent: must be imported and used inside Dialog (source + remove confirm)1448check(source.includes('DialogContent'), 'DialogContent is imported from SDK')1449{1450 // Find DialogContent usage inside Dialog children (not just import). There1451 // must be exactly two modal usages: SourceDialog and remove confirmation.1452 const jsxMatches = source.match(/jsx\(DialogContent/g) || []1453 const jsxsMatches = source.match(/jsxs\(DialogContent/g) || []1454 const dialogContentUsage = jsxMatches.concat(jsxsMatches)1455 check(dialogContentUsage.length === 2,1456 'DialogContent used in exactly two modal dialogs (source dialog and remove confirmation)',1457 dialogContentUsage ? 'Found ' + dialogContentUsage.length + ' usages' : 'No usages found')1458}14591460// 2. Select compound pattern: SelectTrigger, SelectValue, SelectContent, SelectItem1461check(source.includes('SelectTrigger'), 'SelectTrigger imported from SDK')1462check(source.includes('SelectValue'), 'SelectValue imported from SDK')1463check(source.includes('SelectContent'), 'SelectContent imported from SDK')1464check(source.includes('SelectItem'), 'SelectItem imported from SDK')1465// Verify Select.Item is NOT used (old pattern)1466check(!source.includes('Select.Item'), 'No Select.Item (old pattern removed)')1467// Verify SelectTrigger is used in render1468{1469 const selectTriggerUsage = source.match(/jsx\(SelectTrigger/g)1470 check(selectTriggerUsage && selectTriggerUsage.length >= 1,1471 'SelectTrigger used in render', selectTriggerUsage ? 'Found' : 'Not found')1472}14731474// 3. SegmentedControl: options+value+onChange pattern (no SegmentedControl.Item)1475check(source.includes('onChange: setView'), 'SegmentedControl uses onChange prop')1476check(source.includes('options:'), 'SegmentedControl uses options prop')1477check(!source.includes('SegmentedControl.Item'), 'No SegmentedControl.Item (old pattern removed)')1478check(!source.includes('onValueChange: setView'), 'SegmentedControl does not use onValueChange')14791480// 4. Tabs: TabsList + TabsTrigger (no Tabs.Content, no Tabs.List, no Tabs.Trigger)1481check(source.includes('TabsList'), 'TabsList imported from SDK')1482check(source.includes('TabsTrigger'), 'TabsTrigger imported from SDK')1483check(!source.includes('Tabs.Content'), 'No Tabs.Content (not exported by SDK)')1484check(!source.includes('Tabs.List'), 'No Tabs.List (use TabsList instead)')1485check(!source.includes('Tabs.Trigger'), 'No Tabs.Trigger (use TabsTrigger instead)')14861487// 5. WorktreeDetailView: no useQuery, reads from files row1488{1489 const wtdStart = source.indexOf('function WorktreeDetailView')1490 // Scope to just this function (until next function definition)1491 const nextFn = source.indexOf('\nfunction ', wtdStart + 1)1492 const wtdSection = source.slice(wtdStart, nextFn > 0 ? nextFn : source.length)1493 check(!wtdSection.includes('useQuery'), 'WorktreeDetailView does not use useQuery')1494 check(wtdSection.includes('fileInfo') || wtdSection.includes('files.find'),1495 'WorktreeDetailView reads from files row directly')1496}14971498// 6. SpecsView: resets selectedFile on sourceId change1499{1500 const specsStart = source.indexOf('function SpecsView')1501 const nextFnSpecs = source.indexOf('\nfunction ', specsStart + 1)1502 const specsSection = source.slice(specsStart, nextFnSpecs > 0 ? nextFnSpecs : source.length)1503 check(specsSection.includes('setSelectedFile(null)'),1504 'SpecsView resets selectedFile on sourceId change')1505 // Verify the effect depends on sourceId1506 const effectMatch = specsSection.match(/useEffect\([\s\S]*?\[[^\]]*sourceId/)1507 check(effectMatch, 'Reset effect depends on sourceId')1508}15091510// 6b. SpecsView: source-matching gate — component remounts via key prop1511{1512 const specsStart = source.indexOf('function SpecsView')1513 const nextFnSpecs = source.indexOf('\nfunction ', specsStart + 1)1514 const specsSection = source.slice(specsStart, nextFnSpecs > 0 ? nextFnSpecs : source.length)1515 check(!specsSection.includes('selectedSourceId'),1516 'SpecsView no longer tracks selectedSourceId (removed dead code)')1517 check(!specsSection.match(/enabled:.*selectedSourceId\s*===\s*sourceId/),1518 'specQuery enabled no longer gates on selectedSourceId match')1519 check(!specsSection.includes('setSelectedSourceId'),1520 'SpecsView no longer calls setSelectedSourceId (removed dead code)')1521}15221523// 7. renderInline returns JSX elements, not plain objects1524check(source.includes("jsx('strong'") || source.includes('jsx("strong"'),1525 'renderInline produces JSX <strong> for bold')1526check(source.includes("jsx('code'") || source.includes('jsx("code"'),1527 'renderInline produces JSX <code> for inline code')1528check(source.includes("jsx('a'") || source.includes('jsx("a"'),1529 'renderInline produces JSX <a> for links')1530check(!source.includes("{ type: 'bold'"), 'renderInline does not return plain bold object')1531check(!source.includes("{ type: 'code'"), 'renderInline does not return plain code object')1532check(!source.includes("{ type: 'link'"), 'renderInline does not return plain link object')15331534// 8. URL safety: isSafeUrl blocks dangerous schemes1535check(source.includes('isSafeUrl'), 'isSafeUrl function defined')1536check(source.includes('javascript:'), 'isSafeUrl checks javascript: scheme')1537check(source.includes('data:'), 'isSafeUrl checks data: scheme')1538check(source.includes('file:'), 'isSafeUrl checks file: scheme')15391540// 9. __test is frozen1541check(source.includes('Object.freeze'), '__test export uses Object.freeze')15421543// 10. DialogContent in Dialog loading/error paths (not just content)1544{1545 const dialogLoadingMatch = source.match(/Dialog.*open.*onOpenChange.*DialogContent/)1546 check(dialogLoadingMatch, 'Dialog loading state wraps content in DialogContent')1547}15481549// 11. Dialog close button / scrollbar overlap fix1550// ChangeDetailDrawer and IdeaDetailDrawer bodies scroll independently so modal1551// close-button clearance is no longer relevant, but the legacy detail modal1552// section checks are retained under the new names.1553 // The body uses DetailDrawer, whose content region has overflowY auto.1554 check(source.includes('function DetailDrawer'), 'DetailDrawer component exists')1555 check(source.includes("overflowY: 'auto'"), 'Drawer content region has overflowY auto')1556// No scrollable DialogContent should use overflow: 'auto' (causes close X / scrollbar overlap)1557{1558 var overflowAutoMatches = source.match(/DialogContent[^}]*overflow:\s*'auto'/g)1559 check(!overflowAutoMatches, 'No DialogContent uses overflow: auto (causes close/scrollbar overlap)')1560}15611562// ═══════════════════════════════════════════════════════════════════════════════1563// SDK contract: ErrorState retry wrapper and CopyButton text prop1564// ═══════════════════════════════════════════════════════════════════════════════1565console.log('\n[SDK] ErrorState contract: no onRetry prop')15661567// CRITICAL: ErrorState does NOT accept onRetry — must not appear in any jsx() call1568// Use non-greedy [^\n]* to avoid matching nested jsx(Button, { onClick: onRetry })1569{1570 var errorStateOnRetry = source.match(/jsx\(ErrorState[^\n]*onRetry/g)1571 check(!errorStateOnRetry, 'No ErrorState call passes onRetry prop',1572 errorStateOnRetry ? 'Found ' + errorStateOnRetry.length + ' onRetry in ErrorState' : undefined)1573}15741575// RetryErrorState must exist as a reusable wrapper1576check(source.includes('function RetryErrorState'), 'RetryErrorState function defined')1577check(source.includes('RetryErrorState'), 'RetryErrorState is used in source')15781579// RetryErrorState renders ErrorState as root, Button as child with onClick1580// Verify via __test export: RetryErrorState descriptor structure1581if (r.__testAvailable && r.retryErrorState) {1582 var res = r.retryErrorState1583 console.log(' RetryErrorState descriptor')1584 check(res.rootIsErrorState === true, 'RetryErrorState renders ErrorState (function component) as root')1585 check(res.hasButtonChild === true, 'RetryErrorState has Button child')1586 check(res.buttonOnClickIsFunction === true, 'Button child onClick is a function')1587 check(res.buttonLabel === 'Retry', 'Button child label is "Retry"', JSON.stringify(res.buttonLabel))1588 // Execute onClick and verify it invokes the callback1589 check(res.onClickInvokesCallback === true, 'Button onClick invokes the supplied retry callback')1590}15911592console.log('\n[SDK] CopyButton contract: text prop, not value')15931594// CRITICAL: CopyButton requires text, not value1595{1596 var copyButtonValue = source.match(/jsx\(CopyButton[^)]*\bvalue:/g)1597 check(!copyButtonValue, 'No CopyButton call uses value prop',1598 copyButtonValue ? 'Found ' + copyButtonValue.length + ' value props on CopyButton' : undefined)1599}1600{1601 var copyButtonText = source.match(/jsx\(CopyButton[^)]*\btext:/g)1602 check(copyButtonText && copyButtonText.length >= 2, 'At least 2 CopyButton calls use text prop',1603 copyButtonText ? 'Found ' + copyButtonText.length : 'None found')1604}16051606// Board-card CopyButton must have stopPropagation: true (inside clickable card)1607{1608 var bcStart = source.indexOf('function BoardCard')1609 var bcEnd = source.indexOf('\nfunction ', bcStart + 1)1610 var bcSection = source.slice(bcStart, bcEnd > 0 ? bcEnd : source.length)1611 check(bcSection.includes('stopPropagation: true'), 'BoardCard CopyButton has stopPropagation: true')1612 check(bcSection.includes('text: itemRef'), 'BoardCard CopyButton uses text prop (not value)')1613 check(!bcSection.includes('value: itemRef'), 'BoardCard CopyButton does not use value prop')1614}16151616// Detail CopyButton should NOT have stopPropagation (not inside clickable card)1617{1618 var cdStart = source.indexOf('function ChangeDetailDrawer')1619 var cdEnd = source.indexOf('\nfunction IdeaDetailDrawer', cdStart + 1)1620 var cdSection = source.slice(cdStart, cdEnd > 0 ? cdEnd : source.length)1621}16221623// Drawer class tokens are present on the DetailDrawer root1624{1625 const ddStart = source.indexOf('function DetailDrawer')1626 const ddEnd = source.indexOf('\nfunction ', ddStart + 1)1627 const ddSec = source.slice(ddStart, ddEnd > 0 ? ddEnd : source.length)1628 check(ddSec.includes('w-[26rem]'), 'Drawer uses Kanban-aligned compact width')1629 check(ddSec.includes('border-l'), 'Drawer has left border')1630}16311632// ═══════════════════════════════════════════════════════════════════════════════1633// Lane layout: horizontal overflow, expanded/collapsed, auto-collapse1634// ═══════════════════════════════════════════════════════════════════════════════1635console.log('\n[Lane] Layout constants and helpers')16361637if (r.__testAvailable) {1638 var ll = r.laneLayout || {}16391640 // Width constants1641 console.log(' Width constants')1642 check(ll.expandedIs256 === true, 'LANE_EXPANDED_WIDTH is 256', JSON.stringify(ll.expandedIs256))1643 check(ll.collapsedIs32 === true, 'LANE_COLLAPSED_WIDTH is 32', JSON.stringify(ll.collapsedIs32))16441645 // laneWidth helper1646 console.log(' laneWidth helper')1647 check(ll.expandedWidth === 256, 'laneWidth returns 256 when not collapsed', JSON.stringify(ll.expandedWidth))1648 check(ll.collapsedWidth === 32, 'laneWidth returns 32 when collapsed', JSON.stringify(ll.collapsedWidth))1649 check(ll.noOverridesExpanded === 256, 'laneWidth returns 256 when status not in overrides', JSON.stringify(ll.noOverridesExpanded))16501651 // railStyle returns correct collapsed dimensions1652 console.log(' railStyle (collapsed rail)')1653 check(ll.railWidth === '32px', 'railStyle width is 32px', JSON.stringify(ll.railWidth))1654 check(ll.railFlexShrink === 0, 'railStyle flexShrink is 0', JSON.stringify(ll.railFlexShrink))1655 check(ll.railCursor === 'pointer', 'railStyle cursor is pointer', JSON.stringify(ll.railCursor))16561657 // expandedStyle returns correct dimensions1658 console.log(' expandedStyle (expanded lane)')1659 check(ll.expandedWidthPx === '256px', 'expandedStyle width is 256px', JSON.stringify(ll.expandedWidthPx))1660 check(ll.expandedFlexShrink === 0, 'expandedStyle flexShrink is 0', JSON.stringify(ll.expandedFlexShrink))1661 check(ll.expandedOverflow === 'hidden', 'expandedStyle overflow is hidden', JSON.stringify(ll.expandedOverflow))16621663 // autoCollapseEmptyLanes1664 console.log(' autoCollapseEmptyLanes')1665 check(ll.collapseEmptyWhenWork === true, 'Collapses empty lanes when board has work', JSON.stringify(ll.collapseEmptyWhenWork))1666 check(ll.expandNonEmpty === true, 'Does not collapse non-empty lanes', JSON.stringify(ll.expandNonEmpty))1667 check(ll.expandNonEmptyTodo === true, 'Does not collapse non-empty todo', JSON.stringify(ll.expandNonEmptyTodo))1668 check(ll.collapseInProgress === true, 'Collapses empty in-progress when work exists', JSON.stringify(ll.collapseInProgress))1669 check(ll.collapseDone === true, 'Collapses empty done when work exists', JSON.stringify(ll.collapseDone))1670 check(ll.noWorkNoCollapse === true, 'No collapse when board has no work', JSON.stringify(ll.noWorkNoCollapse))1671} else {1672 fail('Lane layout helpers', '__test not available')1673}16741675console.log('\n[Lane] Static source checks')16761677// Lane strip owns horizontal overflow1678check(source.includes('overflowX'), 'Lane strip has overflowX style')1679check(source.includes("'auto'"), 'overflowX uses auto value')16801681// Lane strip constrains width1682check(source.includes('minWidth: 0'), 'Lane strip has minWidth: 0 to prevent flex blowout')1683check(source.includes('minHeight: 0'), 'Lane strip has minHeight: 0')16841685// Expanded lane fixed width1686check(source.includes('256') || source.includes('LANE_EXPANDED_WIDTH'), 'Expanded lane uses 256px width')16871688// Collapsed rail width1689check(source.includes("'32px'") || source.includes('LANE_COLLAPSED_WIDTH'), 'Collapsed rail uses 32px width')16901691// Internal vertical card scrolling1692check(source.includes('overflowY'), 'Cards container has overflowY for vertical scroll')16931694// BoardColumn accepts collapsed and onExpand props1695{1696 var bcStart = source.indexOf('function BoardColumn')1697 var bcEnd = source.indexOf('\nfunction ', bcStart + 1)1698 var bcSec = source.slice(bcStart, bcEnd > 0 ? bcEnd : source.length)1699 check(bcSec.includes('collapsed'), 'BoardColumn accepts collapsed prop')1700 check(bcSec.includes('onExpand'), 'BoardColumn accepts onExpand prop')1701 check(bcSec.includes('railStyle'), 'BoardColumn renders rail when collapsed')1702 check(bcSec.includes('expandedStyle'), 'BoardColumn renders expanded style when not collapsed')1703 check(bcSec.includes('data-lane'), 'Expanded lane sets data-lane attribute')1704}17051706// WorkView manages collapse state1707{1708 var wvStart = source.indexOf('function WorkView')1709 var wvEnd = source.indexOf('\nfunction ', wvStart + 1)1710 var wvSec = source.slice(wvStart, wvEnd > 0 ? wvEnd : source.length)1711 check(wvSec.includes('manualOverrides'), 'WorkView tracks manual collapse overrides')1712 check(wvSec.includes('autoCollapseEmptyLanes'), 'WorkView uses autoCollapseEmptyLanes')1713 check(wvSec.includes('computeCollapsedSet'), 'WorkView uses computeCollapsedSet helper')1714 check(wvSec.includes('toggleOverride'), 'WorkView uses toggleOverride helper')1715 check(wvSec.includes('computeLanePhase'), 'WorkView uses computeLanePhase for phase tracking')1716 check(wvSec.includes('pruneStaleOverrides'), 'WorkView uses pruneStaleOverrides for phase pruning')1717 check(wvSec.includes('prevLanePhase'), 'WorkView tracks previous lane phase')1718 check(wvSec.includes('overflowX'), 'WorkView lane strip has overflowX')1719 check(!wvSec.includes('ScrollArea'), 'WorkView no longer uses ScrollArea')1720 check(wvSec.includes("name: 'filter'"), 'WorkView filter renders canonical filter Codicon')1721 check(wvSec.includes("'aria-label': 'Filter'"), 'WorkView filter keeps accessible label')1722}17231724// WorkView source keying1725{1726 var wvkPattern = /key:\s*selectedSource\.id/1727 check(wvkPattern.test(source), 'WorkView render has key={selectedSource.id} for source identity')1728}17291730// ═══════════════════════════════════════════════════════════════════════════════1731// Source action icons wiring — adapter, dialog, helpers1732// ═══════════════════════════════════════════════════════════════════════════════1733console.log('\n[Source] Source mutation adapter and helpers')17341735// Static source checks: createApi mutation methods exist1736check(source.includes('addSource'), 'createApi has addSource method')1737check(source.includes('updateSource'), 'createApi has updateSource method')1738check(source.includes('initSource'), 'createApi has initSource method')1739check(source.includes('removeSource'), 'createApi has removeSource method')17401741// Mutation methods use ctx.rest with method and body1742check(source.includes("method: 'POST'"), 'addSource sends POST')1743check(source.includes("method: 'PUT'"), 'updateSource sends PUT')1744check(source.includes("'/sources/' + encodeURIComponent(sourceId) + '/init', { method: 'POST'"), 'initSource sends POST')1745check(source.includes("method: 'DELETE'"), 'removeSource sends DELETE')17461747// Desktop ctx.rest forwards request bodies as objects, not pre-serialized JSON strings1748check(source.includes("body: { path: path, name: name || undefined }"), 'Mutation methods pass object request bodies')1749check(!source.includes("body: JSON.stringify({ path: path, name: name || undefined })"), 'Mutation methods do not pre-serialize request bodies')17501751// URL encoding in mutation paths1752check(source.includes("'/sources/' + encodeURIComponent(sourceId) + '/init'"), 'initSource uses encoded source init path')1753check(source.includes("'/sources/' + encodeURIComponent(sourceId)"), 'Mutation paths encode sourceId')17541755// Pure helpers exist1756check(source.includes('function trimPath'), 'trimPath function defined')1757check(source.includes('function extractApiError'), 'extractApiError function defined')17581759// trimPath behavior1760if (r.__testAvailable && r.trimPath) {1761 console.log(' trimPath helper')1762 check(r.trimPath.basicTrim === '/path/to/repo', 'trimPath trims whitespace', JSON.stringify(r.trimPath.basicTrim))1763 check(r.trimPath.noTrim === '/path/to/repo', 'trimPath passes clean strings', JSON.stringify(r.trimPath.noTrim))1764 check(r.trimPath.emptyString === '', 'trimPath returns empty for empty string', JSON.stringify(r.trimPath.emptyString))1765 check(r.trimPath.whitespaceOnly === '', 'trimPath returns empty for whitespace-only', JSON.stringify(r.trimPath.whitespaceOnly))1766 check(r.trimPath.nonString === '', 'trimPath returns empty for null', JSON.stringify(r.trimPath.nonString))1767 check(r.trimPath.number === '', 'trimPath returns empty for number', JSON.stringify(r.trimPath.number))1768} else {1769 fail('trimPath helper', '__test not available')1770}17711772// extractApiError behavior1773if (r.__testAvailable && r.extractApiError) {1774 console.log(' extractApiError helper')1775 check(r.extractApiError.nullInput === 'Unknown error', 'extractApiError handles null', JSON.stringify(r.extractApiError.nullInput))1776 check(r.extractApiError.stringInput === 'bad request', 'extractApiError passes strings', JSON.stringify(r.extractApiError.stringInput))1777 check(r.extractApiError.messageObj === 'not found', 'extractApiError extracts .message', JSON.stringify(r.extractApiError.messageObj))1778 check(r.extractApiError.detailObj === 'conflict', 'extractApiError extracts .detail', JSON.stringify(r.extractApiError.detailObj))1779 check(r.extractApiError.duplicateSource === 'Source already registered. Select it from the source list, or remove it before adding again.', 'extractApiError explains duplicate source', JSON.stringify(r.extractApiError.duplicateSource))1780 check(r.extractApiError.fallback === '[object Object]', 'extractApiError falls back to String()', JSON.stringify(r.extractApiError.fallback))1781} else {1782 fail('extractApiError helper', '__test not available')1783}17841785// Source dialog component1786check(source.includes('function SourceDialog'), 'SourceDialog component defined')1787check(source.includes("'Add source'") || source.includes('"Add source"'), 'SourceDialog or button has Add source text')1788check(source.includes("'Edit source'") || source.includes('"Edit source"'), 'SourceDialog or button has Edit source text')17891790check(source.includes('handleInitSource'), 'Invalid source has initialize handler')1791check(source.includes('Initialize OpenSpec'), 'Invalid source has initialize action')1792check(source.includes('initBusy'), 'OpenSpecPage tracks initialization state')1793check(source.includes('initError'), 'OpenSpecPage tracks initialization errors')1794check(source.includes('Path is required'), 'SourceDialog validates required path')1795check(source.includes('function sourceNeedsInitialization'), 'Source add detects missing OpenSpec layout')1796check(source.includes('api.initSource(result.source.id)'), 'Source add initializes missing OpenSpec layout')1797check(source.includes("source.error === 'No openspec/ directory found'"), 'Source add limits initialization to missing layout')17981799// Source dialog uses Dialog/DialogContent from SDK1800{1801 var sdStart = source.indexOf('function SourceDialog')1802 var sdEnd = source.indexOf('\nfunction ', sdStart + 1)1803 var sdSection = source.slice(sdStart, sdEnd > 0 ? sdEnd : source.length)1804 check(sdSection.includes('jsx(Dialog') || sdSection.includes('jsxs(Dialog'), 'SourceDialog renders Dialog')1805 check(sdSection.includes('jsx(DialogContent') || sdSection.includes('jsxs(DialogContent'), 'SourceDialog renders DialogContent')1806 check(sdSection.includes('jsx(Input'), 'SourceDialog renders Input fields')1807 check(sdSection.includes('jsx(Button'), 'SourceDialog renders Button controls')1808 check(sdSection.includes('disabled: busy'), 'SourceDialog disables controls while busy')1809}18101811// onClick handlers wired on source action buttons1812check(source.includes('onClick: handleEditSource'), 'Edit button has onClick: handleEditSource')1813check(source.includes('onClick: handleRemoveSource'), 'Remove button has onClick: handleRemoveSource')1814check(source.includes('onClick: handleAddSource'), 'Add button has onClick: handleAddSource')18151816// Visible source action labels and requested order1817{1818 var addAction = source.indexOf("children: ['+', ' Add']")1819 var editAction = source.indexOf("children: ['\\u270E', ' Edit']")1820 var deleteAction = source.indexOf("children: ['\\u2715', ' Delete']")1821 check(addAction > 0, 'Add source action has visible Add label')1822 check(editAction > 0, 'Edit source action has visible Edit label')1823 check(deleteAction > 0, 'Delete source action has visible Delete label')1824 check(addAction > 0 && editAction > addAction && deleteAction > editAction, 'Source actions order is Add, Edit, Delete')1825}18261827// Filter icon replaces the text label while retaining an accessible name1828check(source.includes("name: 'filter'"), 'Work filter uses canonical filter Codicon')1829check(source.includes("'aria-label': 'Filter'"), 'Work filter icon has accessible label')1830check(!source.includes("children: 'Filter'"), 'Work filter no longer renders Filter text')18311832// Accessible labels on source action buttons1833check(source.includes("'aria-label': 'Edit source'"), 'Edit button has aria-label')1834check(source.includes("'aria-label': 'Delete source'"), 'Delete button has aria-label')1835check(source.includes("'aria-label': 'Add source'"), 'Add button has aria-label')18361837// State management for source dialog and remove confirmation1838check(source.includes('sourceDialog'), 'OpenSpecPage tracks sourceDialog state')1839check(source.includes('removeConfirm'), 'OpenSpecPage tracks removeConfirm state')1840check(source.includes('removeBusy'), 'OpenSpecPage tracks removeBusy state')1841check(source.includes('removeError'), 'OpenSpecPage tracks removeError state')18421843// Remove confirmation dialog1844check(source.includes('Remove source'), 'Remove confirmation dialog exists')1845check(source.includes('unregisters the source'), 'Remove confirmation explains registry-only removal')18461847// Source mutation adapter __test results1848if (r.sourceMutation) {1849 console.log(' Source mutation adapter')1850 check(r.sourceMutation.addSourcePath === '/sources', 'addSource path is /sources', JSON.stringify(r.sourceMutation.addSourcePath))1851 check(r.sourceMutation.initSourcePath === '/sources/source%20id/init', 'initSource path encodes source ID', JSON.stringify(r.sourceMutation.initSourcePath))1852 check(r.sourceMutation.initSourceMethod === 'POST', 'initSource uses POST', JSON.stringify(r.sourceMutation.initSourceMethod))1853 check(r.sourceMutation.initSourceBody === undefined, 'initSource has no request body', JSON.stringify(r.sourceMutation.initSourceBody))1854 check(r.sourceMutation.needsInit === true, 'missing OpenSpec layout triggers initialization', JSON.stringify(r.sourceMutation.needsInit))1855 check(r.sourceMutation.validNeedsNoInit === false, 'valid source does not trigger initialization', JSON.stringify(r.sourceMutation.validNeedsNoInit))1856 check(!r.sourceMutation.error, 'No error in source mutation tests', r.sourceMutation.error)1857}18581859// Exact endpoint allowlist — dynamic adapter evidence for all four mutation endpoints1860if (r.sourceMutation) {1861 console.log(' Source mutation endpoint allowlist')1862 check(r.sourceMutation.addCallPath === '/sources', 'addSource dynamic path is /sources', JSON.stringify(r.sourceMutation.addCallPath))1863 check(r.sourceMutation.addCallMethod === 'POST', 'addSource dynamic method is POST', JSON.stringify(r.sourceMutation.addCallMethod))1864 check(r.sourceMutation.addCallBody && r.sourceMutation.addCallBody.path === '/repos/acme', 'addSource dynamic body carries path', JSON.stringify(r.sourceMutation.addCallBody))1865 check(r.sourceMutation.addCallBody && r.sourceMutation.addCallBody.name === 'Acme', 'addSource dynamic body carries optional name', JSON.stringify(r.sourceMutation.addCallBody))1866 check(r.sourceMutation.addBareCallBody && r.sourceMutation.addBareCallBody.name === undefined, 'addSource omits name when absent', JSON.stringify(r.sourceMutation.addBareCallBody))1867 check(r.sourceMutation.updateCallPath === '/sources/src%20id%2Fwith%20slash', 'updateSource dynamic path encodes sourceId', JSON.stringify(r.sourceMutation.updateCallPath))1868 check(r.sourceMutation.updateCallMethod === 'PUT', 'updateSource dynamic method is PUT', JSON.stringify(r.sourceMutation.updateCallMethod))1869 check(r.sourceMutation.updateCallBody && r.sourceMutation.updateCallBody.path === '/repos/acme-new', 'updateSource dynamic body carries path', JSON.stringify(r.sourceMutation.updateCallBody))1870 check(r.sourceMutation.updateCallBody && r.sourceMutation.updateCallBody.name === 'Acme New', 'updateSource dynamic body carries name', JSON.stringify(r.sourceMutation.updateCallBody))1871 check(r.sourceMutation.removeCallPath === '/sources/src%20id', 'removeSource dynamic path encodes sourceId', JSON.stringify(r.sourceMutation.removeCallPath))1872 check(r.sourceMutation.removeCallMethod === 'DELETE', 'removeSource dynamic method is DELETE', JSON.stringify(r.sourceMutation.removeCallMethod))1873 check(r.sourceMutation.removeCallBody === undefined, 'removeSource sends no request body (registry row only)', JSON.stringify(r.sourceMutation.removeCallBody))1874 check(r.sourceMutation.sourcesCallMethod === undefined, 'sources() refresh stays a plain GET /sources', JSON.stringify(r.sourceMutation.sourcesCallMethod))1875 check(r.sourceMutation.allowlistExact === true, 'createApi exposes exactly the four source-mutation methods', JSON.stringify(r.sourceMutation.mutationMethods))1876 check(r.sourceMutation.noGenericRequest === true, 'createApi has no generic arbitrary request helper', JSON.stringify(r.sourceMutation.mutationMethods))1877} else {1878 fail('Source mutation endpoint allowlist', '__test not available')1879}18801881// ═══════════════════════════════════════════════════════════════════════════════1882// [Source] Registry UI wiring — cancel, error preservation, refresh/selection1883// ═══════════════════════════════════════════════════════════════════════════════1884console.log('\n[Source] Registry UI wiring — cancel, errors, refresh, read-only boundary')18851886// Cancellation never issues a request1887{1888 var sdCancelStart = source.indexOf('function SourceDialog')1889 var sdCancelEnd = source.indexOf('\nfunction ', sdCancelStart + 1)1890 var sdCancelSec = source.slice(sdCancelStart, sdCancelEnd > 0 ? sdCancelEnd : source.length)1891 check(sdCancelSec.includes('onClick: onClose'), 'SourceDialog Cancel closes without an API call')1892}1893check(source.includes("onClick: function() { setRemoveConfirm(null); setRemoveError('') }"), 'Remove-confirm Cancel closes without an API call')18941895// Backend errors preserve registry state and selection — errors render, no optimistic deletion1896check(source.includes('setErr(extractApiError(e))'), 'Add/edit failure shows backend error and keeps dialog open')1897check(source.includes('setRemoveError(extractApiError(e))'), 'Remove failure shows backend error and keeps confirmation open')1898check(source.includes('setInitError(extractApiError(e))'), 'Initialize failure shows backend error without losing selection')18991900// Successful operations refresh /sources and restore selection where possible1901{1902 var osStart = source.indexOf('function onSourceSaved')1903 var osEnd = source.indexOf('\n function ', osStart + 1)1904 var osSec = source.slice(osStart, osEnd > 0 ? osEnd : source.length)1905 check(osSec.includes('sourcesQuery.refetch()'), 'Add/edit success refreshes the source list')1906 check(osSec.includes('setSelectedSourceId(result.source.id)'), 'Add/edit success restores the returned source selection')1907 check(osSec.includes('result.initError'), 'Add init failure retains the backend error for display')1908 check(osSec.includes('refresh.then'), 'Add/edit selection waits for refreshed registry data')1909}19101911// Add-success/init-failure recovery keeps the registered row truthful and1912// performs no implicit duplicate initialization retry.1913{1914 const addState = identity && identity.sourceAdd ? identity.sourceAdd : {}1915 check(addState.initFailureSourceId === 'src-added', 'Init failure preserves the registered source id', JSON.stringify(addState))1916 check(addState.initFailureError === 'initialization denied', 'Init failure retains the backend error', JSON.stringify(addState))1917 check(addState.initFailureSingleAttempt === true, 'Init failure performs one initialization attempt without duplicate retry', JSON.stringify(addState))1918 check(addState.parentRefetchCalls === 1, 'Add recovery refreshes the source registry once', JSON.stringify(addState))1919 check(addState.parentSelectedSourceId === 'src-added' && addState.parentStateSelectedSourceId === 'src-added',1920 'Add recovery preserves the registered source selection', JSON.stringify(addState))1921 check(addState.parentInitError === 'initialization denied', 'Add recovery retains the initialization error in page state', JSON.stringify(addState))1922}1923{1924 var hiStart = source.indexOf('function handleInitSource')1925 var hiEnd = source.indexOf('\n function ', hiStart + 1)1926 var hiSec = source.slice(hiStart, hiEnd > 0 ? hiEnd : source.length)1927 check(hiSec.includes('api.initSource(selectedSource.id)'), 'Initialize uses the selected source id')1928 check(hiSec.includes('sourcesQuery.refetch()'), 'Initialize success refreshes the source list')1929}1930{1931 var crStart = source.indexOf('function confirmRemove')1932 var crEnd = source.indexOf('\n function ', crStart + 1)1933 var crSec = source.slice(crStart, crEnd > 0 ? crEnd : source.length)1934 check(crSec.includes('api.removeSource(removeConfirm.id)'), 'Confirmed remove calls only the registry-row endpoint')1935 check(crSec.includes('sourcesQuery.refetch()'), 'Remove success refreshes the source list')1936}1937check(source.includes("onClick: function() { sourcesQuery.refetch() }"), 'Refresh button refetches GET /sources')19381939// Artifact/task read-only boundary — no mutation surface beyond the four source endpoints1940check(!source.includes('api.setTaskStatus'), 'No task-status mutation method')1941check(!source.includes('api.completeTask'), 'No task-completion method')1942check(!source.includes('api.updateChange'), 'No change-edit method')1943check(!source.includes('api.editIdea'), 'No idea-edit method')1944check(!source.includes('api.writeFile'), 'No file-write method')1945check(!source.includes("method: 'PATCH'"), 'No PATCH verb anywhere in the plugin')1946check(!source.includes('node:fs'), 'No node:fs import')1947check(!source.includes('child_process'), 'No child_process usage')19481949// ═══════════════════════════════════════════════════════════════════════════════1950// [Identity 1.1] Resolver-safe source references1951// ═══════════════════════════════════════════════════════════════════════════════1952console.log('\n[Identity 1.1] Resolver-safe source references')19531954check(source.includes('function buildSourceReference'), 'buildSourceReference helper defined')1955check(source.includes('buildSourceReference: buildSourceReference'), 'buildSourceReference exported via __test')19561957{1958 const s = identity && identity.src ? identity.src : {}1959 check(s.unique === 'ivault', 'Unique source name copies the vanity name', JSON.stringify(s.unique))1960 check(s.caseInsensitiveDup === 'os_8e5049', 'Case-insensitive duplicate name falls back to stable token', JSON.stringify(s.caseInsensitiveDup))1961 check(s.blankName === 'os_8e5049', 'Blank source name falls back to stable token', JSON.stringify(s.blankName))1962 check(s.tokenFallback === 'src-id-only', 'Missing name falls back to stable id', JSON.stringify(s.tokenFallback))1963 check(s.unavailable === '', 'Unavailable reference returns empty string (no malformed copy)', JSON.stringify(s.unavailable))1964}19651966// Reference helpers never construct paths or encode1967{1968 const start = source.indexOf('function buildSourceReference')1969 const end = source.indexOf('function computeCollapsedSet')1970 const refSec = source.slice(start, end > start ? end : source.length)1971 check(refSec.length > 0, 'Reference helper section present')1972 check(!refSec.includes('.path'), 'Reference helpers never use source.path', refSec.includes('.path') ? 'found .path usage' : undefined)1973 check(!refSec.includes('encodeURIComponent'), 'Reference helpers perform no URL encoding')1974}19751976// ═══════════════════════════════════════════════════════════════════════════════1977// [Identity 1.2] Resolver-safe item references1978// ═══════════════════════════════════════════════════════════════════════════════1979console.log('\n[Identity 1.2] Resolver-safe item references')19801981{1982 const it = identity && identity.item ? identity.item : {}1983 check(it.active === 'ivault/os_fd3445', 'Active change reference is source-qualified', JSON.stringify(it.active))1984 check(it.archived === 'ivault/os_fd3445', 'Archived change reference is identical (no archive segment)', JSON.stringify(it.archived))1985 check(it.idea === 'ivault/os_1655dd', 'Idea reference is source-qualified', JSON.stringify(it.idea))1986 check(it.missingToken === '', 'Missing item token produces no reference', JSON.stringify(it.missingToken))1987 check(it.missingSourceRef === '', 'Unavailable source reference produces no item reference', JSON.stringify(it.missingSourceRef))1988 check(it.literalNoEncoding === 'My Source/tok/a b', 'Names and tokens preserved literally without encoding', JSON.stringify(it.literalNoEncoding))1989 check(it.uniqueToken === true, 'Unique item token is copy-available', JSON.stringify(it.uniqueToken))1990 check(it.duplicateToken === false, 'Duplicate item token is not copy-available', JSON.stringify(it.duplicateToken))1991 check(it.blankToken === false, 'Blank item token is not copy-available', JSON.stringify(it.blankToken))1992}19931994// ═══════════════════════════════════════════════════════════════════════════════1995// [Session 1.4] Registration-scope navigation state1996// ═══════════════════════════════════════════════════════════════════════════════1997console.log('\n[Session 1.4] Registration-scope session store')19981999{2000 const ses = identity && identity.session ? identity.session : {}2001 check(ses.defaultArchived === true, 'Fresh session store defaults Archived to true', JSON.stringify(ses.defaultArchived))2002 check(ses.defaultSelected === true, 'Fresh session store defaults selectedSourceId to null', JSON.stringify(ses.defaultSelected))2003 check(ses.storeWired === true, 'register() passes session store into route render', JSON.stringify(ses.storeWired))2004 check(ses.sameStoreAcrossRenders === true, 'Second route render reads same registration-scope store', JSON.stringify(ses.sameStoreAcrossRenders))2005 check(ses.retainedMutated === true, 'Store mutations survive route-local state discard', JSON.stringify(ses.retainedMutated))2006 const fb = ses.fallback || {}2007 check(fb.retainedPresentInvalid === 'a', 'Retained invalid source stays selected while registered', JSON.stringify(fb.retainedPresentInvalid))2008 check(fb.retainedPresentValid === 'b', 'Retained valid source stays selected', JSON.stringify(fb.retainedPresentValid))2009 check(fb.retainedAbsent === 'b', 'Absent retained source falls back to first valid', JSON.stringify(fb.retainedAbsent))2010 check(fb.allInvalidFirstReturned === 'x', 'All-invalid sources fall back to first returned', JSON.stringify(fb.allInvalidFirstReturned))2011 check(fb.noSources === null, 'No sources resolve to null selection', JSON.stringify(fb.noSources))2012}20132014// ═══════════════════════════════════════════════════════════════════════════════2015// [Session 1.4b] Retained-source restoration across the loading window2016// ═══════════════════════════════════════════════════════════════════════════════2017console.log('\n[Session 1.4b] Retained-source restoration across the loading window')20182019{2020 const m = identity && identity.mount ? identity.mount : {}2021 check(m.__error === undefined, 'Mount-sequence simulation executed without driver error', m.__error || '')2022 const rv = m.retainedValid || {}2023 check(rv.phase1 && rv.phase1.store === 'src-b', 'Loading window does not wipe retained selection (store)', JSON.stringify(rv.phase1))2024 check(rv.phase1 && rv.phase1.state === 'src-b', 'Loading window does not wipe retained selection (state)', JSON.stringify(rv.phase1))2025 check(rv.phase1 && rv.phase1.component === 'Loader', 'Loading phase renders the loader', JSON.stringify(rv.phase1))2026 check(rv.phase2 && rv.phase2.store === 'src-b', 'Retained valid source survives remount after data arrival', JSON.stringify(rv.phase2))2027 check(rv.phase2 && rv.phase2.state === 'src-b', 'Retained valid source stays selected in React state', JSON.stringify(rv.phase2))2028 const ri = m.retainedInvalid || {}2029 check(ri.phase2 && ri.phase2.store === 'src-c', 'Registered invalid source remains selected after data arrival', JSON.stringify(ri.phase2))2030 check(ri.phase2 && ri.phase2.state === 'src-c', 'Registered invalid source stays selected in React state', JSON.stringify(ri.phase2))2031 const ra = m.retainedAbsent || {}2032 check(ra.phase2 && ra.phase2.store === 'src-b', 'Absent retained source falls back to first valid against refreshed list', JSON.stringify(ra.phase2))2033 check(ra.phase2 && ra.phase2.state === 'src-b', 'Absent-retained fallback lands in React state too', JSON.stringify(ra.phase2))2034 const ge = m.genuinelyEmpty || {}2035 check(ge.phase1 && ge.phase1.store === 'src-b', 'Retained id survives the loading render before empty result', JSON.stringify(ge.phase1))2036 check(ge.phase2 && ge.phase2.store === null, 'Genuinely empty refreshed result resolves to null selection', JSON.stringify(ge.phase2))2037 check(ge.phase2 && ge.phase2.state === null, 'Empty-result null resolution lands in React state too', JSON.stringify(ge.phase2))2038 const emptySource = m.emptySource || {}2039 check(emptySource.__error === undefined, 'Empty-source render simulation executed without driver error', emptySource.__error || '')2040 check(emptySource.addButtonReachable === true, 'First-source Add control is reachable from empty registry state', JSON.stringify(emptySource))2041}20422043// Static wiring — store created in register(), owned by OpenSpecPage2044{2045 const regStart = source.indexOf('register: function')2046 const regEnd = source.indexOf('\n}', regStart + 1)2047 const regSec = source.slice(regStart, regEnd > 0 ? regEnd : source.length)2048 check(regSec.includes('createSessionStore'), 'register() creates the session store')2049 check(regSec.includes('sessionStore'), 'register() passes session store to OpenSpecPage')2050}2051{2052 const ppStart = source.indexOf('function OpenSpecPage')2053 const ppEnd = source.indexOf('\nfunction ', ppStart + 1)2054 const ppSec = source.slice(ppStart, ppEnd > 0 ? ppEnd : source.length)2055 check(ppSec.includes('sessionStore.selectedSourceId'), 'OpenSpecPage initializes selection from session store')2056 check(ppSec.includes('sessionStore.showArchived'), 'OpenSpecPage initializes Archived from session store')2057 check(ppSec.includes('sessionStore.selectedSourceId ='), 'Explicit source selection writes session store')2058 check(ppSec.includes('sessionStore.showArchived ='), 'Archived toggle writes session store')2059 check(ppSec.includes('resolveRetainedSource'), 'OpenSpecPage applies retained-source fallback rules')2060 check(ppSec.includes('sourcesQuery.data === undefined'), 'Resolution effect is gated on data arrival (loading window safe)')2061 check(ppSec.includes('sourcesQuery.data, selectedSourceId'), 'Resolution effect re-runs when refreshed data arrives')2062 check(ppSec.includes('onToggleArchived'), 'WorkView receives controlled Archived toggle prop')2063}20642065// ═══════════════════════════════════════════════════════════════════════════════2066// [Identity 1.5] Copy controls, detail identity, footer layout2067// ═══════════════════════════════════════════════════════════════════════════════2068console.log('\n[Identity 1.5] Copy controls, detail identity, footer layout')20692070{2071 const dt = identity && identity.detail ? identity.detail : {}2072 check(dt.titleLoaded === 'Loaded', 'Detail title prefers loaded detail title', JSON.stringify(dt.titleLoaded))2073 check(dt.titleSummary === 'Summary', 'Detail title falls back to selected summary title', JSON.stringify(dt.titleSummary))2074 check(dt.titleItem === 'Item', 'Detail title falls back to item name', JSON.stringify(dt.titleItem))2075 check(dt.titleUntitledChange === 'Untitled change', 'Change title falls back to "Untitled change"', JSON.stringify(dt.titleUntitledChange))2076 check(dt.titleUntitledIdea === 'Untitled idea', 'Idea title falls back to "Untitled idea"', JSON.stringify(dt.titleUntitledIdea))2077 check(dt.secondaryLoaded === 'Loaded', 'Secondary identity prefers loaded detail name', JSON.stringify(dt.secondaryLoaded))2078 check(dt.secondarySummary === 'Summary', 'Secondary identity falls back to summary name', JSON.stringify(dt.secondarySummary))2079 check(dt.secondaryMissing === '', 'Missing secondary identity is omitted', JSON.stringify(dt.secondaryMissing))2080 check(dt.ideaFilename === 'hermes-agentmail-inbox-integration.md', 'Idea secondary identity appends .md exactly once', JSON.stringify(dt.ideaFilename))2081 check(dt.ideaFilenameNoDup === 'already.md', 'Existing .md suffix is not duplicated', JSON.stringify(dt.ideaFilenameNoDup))2082 check(dt.ideaFilenameMissing === '', 'Missing idea identity is omitted', JSON.stringify(dt.ideaFilenameMissing))2083}20842085{2086 const cd = identity && identity.card ? identity.card : {}2087 check(cd.copyCount === 1, 'Card renders exactly one copy control when available', JSON.stringify(cd.copyCount))2088 check(cd.copyText === 'ivault/os_fd3445', 'Card CopyButton.text equals canonical item reference', JSON.stringify(cd.copyText))2089 check(cd.copyLabel === 'Copy item reference', 'Card copy accessible label is "Copy item reference"', JSON.stringify(cd.copyLabel))2090 check(cd.copyTitle === 'ivault/os_fd3445', 'Card copy tooltip equals exact clipboard payload', JSON.stringify(cd.copyTitle))2091 check(cd.copyStopProp === true, 'Card copy stops propagation inside clickable card', JSON.stringify(cd.copyStopProp))2092 check(cd.rightGroupAuto === true, 'Identity group pinned lower-right with margin-left auto', JSON.stringify(cd.rightGroupAuto))2093 check(cd.tokenVisible === true, 'Short token visible as metadata on card', JSON.stringify(cd.tokenVisible))2094 check(cd.leftGroupHasArtifacts === true, 'Artifact badges occupy left footer group', JSON.stringify(cd.leftGroupHasArtifacts))2095 check(cd.leftGroupHasFraction === true, 'Task fraction remains rendered in card footer', JSON.stringify(cd.leftGroupHasFraction))2096 check(cd.progressLeft === true, 'Task fraction aligns on left side of card footer', JSON.stringify(cd.progressLeft))2097 check(cd.identityRight === true, 'Copy and token group remains on right side of card footer', JSON.stringify(cd.identityRight))2098 check(cd.leftBadges === true, 'Populated card renders proposal/tasks badges', JSON.stringify(cd.leftBadges))2099 check(cd.taskFraction === true, 'Task fraction stays in populated footer', JSON.stringify(cd.taskFraction))2100 check(cd.statusBorder === true, 'Status-colored border preserved on card', JSON.stringify(cd.statusBorder))2101 check(cd.hoverExactCard === true, 'Card hover uses exact bundled-Kanban utility hover:bg-primary/[0.06]', JSON.stringify(cd.hoverExactCard))2102 check(cd.hoverGenericAbsent === true, 'Card hover no longer uses generic --ui-hover-background', JSON.stringify(cd.hoverGenericAbsent))2103}21042105{2106 const sp = identity && identity.sparse ? identity.sparse : {}2107 check(sp.copyText === 'ivault/os_x1', 'Sparse card keeps canonical copy reference', JSON.stringify(sp.copyText))2108 check(sp.noPlaceholderFraction === true, 'Sparse footer has no placeholder task fraction', JSON.stringify(sp.noPlaceholderFraction))2109 check(sp.rightGroupAuto === true, 'Sparse identity group stays lower-right', JSON.stringify(sp.rightGroupAuto))2110 check(sp.tokenVisible === true, 'Sparse card keeps visible token metadata', JSON.stringify(sp.tokenVisible))2111}21122113{2114 const dc = identity && identity.dupCard ? identity.dupCard : {}2115 check(dc.copySuppressed === true, 'Duplicate item token suppresses copy control', JSON.stringify(dc.copySuppressed))2116 check(dc.tokenStillVisible === true, 'Visible identity remains when copy suppressed', JSON.stringify(dc.tokenStillVisible))2117}21182119// Detail drawer and source header wiring2120{2121 var cdStart = source.indexOf('function ChangeDetailDrawer')2122 var cdEnd = source.indexOf('\nfunction IdeaDetailDrawer', cdStart + 1)2123 var cdSection = source.slice(cdStart, cdEnd > 0 ? cdEnd : source.length)2124 check(cdSection.includes('itemRef'), 'ChangeDetailDrawer supplies itemRef to DetailDrawer')2125 check(cdSection.includes('buildItemReference'), 'Change detail uses canonical item reference')2126 check(cdSection.includes('hasUniqueItemToken'), 'Change detail guards copy on unique token')2127 check(cdSection.includes('detailTitle'), 'Change detail uses title fallback')2128 check(cdSection.includes('detailSecondaryName'), 'Change detail shows secondary folder identity')2129}2130{2131 const idStart = source.indexOf('function IdeaDetailDrawer')2132 const idEnd = source.indexOf('\nfunction ', idStart + 1)2133 const idSec = source.slice(idStart, idEnd > 0 ? idEnd : source.length)2134 check(idSec.includes('itemRef'), 'IdeaDetailDrawer supplies itemRef to DetailDrawer')2135 check(idSec.includes('buildItemReference'), 'Idea detail uses canonical item reference')2136 check(idSec.includes('ideaSecondaryName'), 'Idea detail appends .md exactly once')2137 check(idSec.includes('detailTitle'), 'Idea detail uses title fallback')2138}2139{2140 const ddStart = source.indexOf('function DetailDrawer')2141 const ddEnd = source.indexOf('\nfunction ', ddStart + 1)2142 const ddSec = source.slice(ddStart, ddEnd > 0 ? ddEnd : source.length)2143 check(ddSec.includes("'Copy item reference'"), 'DetailDrawer copy control label is "Copy item reference"')2144 check(ddSec.includes('data-detail-drawer'), 'DetailDrawer root carries data-detail-drawer marker')2145 check(ddSec.includes('absolute inset-y-0 right-0'), 'DetailDrawer root is absolute right overlay')2146 check(ddSec.includes('w-[26rem]'), 'DetailDrawer uses Kanban-aligned compact width')2147 check(ddSec.includes('border-l'), 'DetailDrawer has left border')2148 check(ddSec.includes("'Close detail'"), 'DetailDrawer close button has accessible label')2149}2150{2151 const ppStart = source.indexOf('function OpenSpecPage')2152 const ppEnd = source.indexOf('\nfunction ', ppStart + 1)2153 const ppSec = source.slice(ppStart, ppEnd > 0 ? ppEnd : source.length)2154 check(ppSec.includes("'Copy source reference'"), 'Source copy label is "Copy source reference"')2155 check(ppSec.includes('buildSourceReference'), 'Source header uses canonical source reference')2156 check(ppSec.includes('disabled:'), 'Unavailable source copy is disabled rather than malformed')2157 check(ppSec.includes("name: 'refresh'"), 'Source refresh control uses canonical refresh Codicon')2158 check(ppSec.includes("'aria-label': 'Refresh sources'"), 'Source refresh icon keeps accessible label')2159 check(!ppSec.includes("children: 'Refresh'"), 'Source refresh control no longer renders Refresh text')2160 check(!ppSec.includes("children: 'Source'"), 'Header no longer renders redundant Source label')2161 check(!ppSec.includes("children: selectedSource.name || selectedSource.id"), 'Source row no longer duplicates selected source name')2162 check(!ppSec.includes("children: shortToken"), 'Source row no longer renders redundant source id')2163 const headerStart = ppSec.indexOf('// Header')2164 const sourceControlsStart = ppSec.indexOf('// Source controls')2165 const viewSwitchStart = ppSec.indexOf('// View switch')2166 const headerSec = ppSec.slice(headerStart, sourceControlsStart)2167 const sourceControlsSec = ppSec.slice(sourceControlsStart, viewSwitchStart)2168 check(headerSec.includes('children: selectedSource.path'), 'Project path renders beside OpenSpec header')2169 check(!sourceControlsSec.includes('children: selectedSource.path'), 'Project path is not duplicated in source controls row')2170 check(sourceControlsSec.includes("className: 'bg-(--ui-bg-quinary) transition-colors hover:bg-primary/[0.06] hover:bg-primary/[0.06]!'"), 'Source selector has canonical visible wash and hover')2171}21722173// [Visual polish] Project-bar hover wash and Archived button2174{2175 const ppStart = source.indexOf('function OpenSpecPage')2176 const ppEnd = source.indexOf('\nfunction ', ppStart + 1)2177 const ppSec = source.slice(ppStart, ppEnd > 0 ? ppEnd : source.length)2178 const sourceControlsStart = ppSec.indexOf('// Source controls')2179 const viewSwitchStart = ppSec.indexOf('// View switch')2180 const sourceControlsSec = ppSec.slice(sourceControlsStart, viewSwitchStart)2181 check(sourceControlsSec.includes("SelectTrigger"), 'Project bar still uses SelectTrigger for source selection')2182 check(sourceControlsSec.includes('bg-(--ui-bg-quinary)'), 'Project bar keeps base bg-(--ui-bg-quinary)')2183 check(sourceControlsSec.includes('hover:bg-primary/[0.06]'), 'Project bar has canonical hover wash hover:bg-primary/[0.06]')2184 check(sourceControlsSec.includes('transition-colors'), 'Project bar hover is paired with transition-colors')2185}2186{2187 const wvStart = source.indexOf('function WorkView')2188 const wvEnd = source.indexOf('\nfunction ', wvStart + 1)2189 const wvSec = source.slice(wvStart, wvEnd > 0 ? wvEnd : source.length)2190 check(wvSec.includes("jsx(Button, { variant: 'outline', size: 'sm', type: 'button', 'aria-pressed': showArchived"), 'WorkView uses outlined sm Button with aria-pressed for Archived')2191 check(wvSec.includes("backgroundColor: showArchived ? 'var(--ui-control-active-background)' : undefined"), 'Archived button active wash uses canonical control token')2192 check(wvSec.includes("onClick: function() { onToggleArchived(!showArchived) }"), 'Archived button toggles through existing handler')2193 check(wvSec.includes("children: [showArchived ? '\\u2713' : null, ' ', 'Archived']"), 'Archived button renders checkmark conditional on showArchived plus label')2194 check(!wvSec.includes("type: 'checkbox'"), 'WorkView no longer contains a checkbox input')2195 check(!wvSec.includes("jsx('label'"), 'WorkView no longer wraps Archived in a separate label element')2196}21972198check(!source.includes('navigator.clipboard'), 'No custom Clipboard API added (SDK CopyButton feedback retained)')21992200if (r.__testAvailable) {2201 const ab = identity && identity.archivedButton ? identity.archivedButton : {}2202 console.log(' Archived button descriptor')2203 check(ab.isButton === true, 'Archived control is an SDK Button', JSON.stringify(ab.isButton))2204 check(ab.variantOutline === true, 'Archived button uses variant outline', JSON.stringify(ab.variantOutline))2205 check(ab.sizeSm === true, 'Archived button uses size sm', JSON.stringify(ab.sizeSm))2206 check(ab.typeButton === true, 'Archived control is an explicit native button', JSON.stringify(ab.typeButton))2207 check(ab.noCustomKeyHandler === true, 'Archived control preserves native keyboard activation', JSON.stringify(ab.noCustomKeyHandler))2208 check(ab.ariaPressedFalse === true, 'aria-pressed reflects false when showArchived is false', JSON.stringify(ab.ariaPressedFalse))2209 check(ab.ariaPressedTrue === true, 'aria-pressed reflects true when showArchived is true', JSON.stringify(ab.ariaPressedTrue))2210 check(ab.inactiveHasNoActiveWash === true, 'Archived button is not washed when unpressed', JSON.stringify(ab.inactiveHasNoActiveWash))2211 check(ab.activeWash === true, 'Archived button gets canonical active wash when pressed', JSON.stringify(ab.activeWash))2212 check(ab.labelIncludesArchived === true, 'Archived button contains visible "Archived" label', JSON.stringify(ab.labelIncludesArchived))2213 check(ab.activeCheckWhenPressed === true, 'Archived button shows checkmark when pressed', JSON.stringify(ab.activeCheckWhenPressed))2214 check(ab.noCheckboxSibling === true, 'No separate checkbox remains in WorkView', JSON.stringify(ab.noCheckboxSibling))2215 check(ab.clickTogglesOnce === true, 'Click toggles showArchived from false to true once', JSON.stringify(ab.clickTogglesOnce))2216 check(ab.secondClickTogglesOnce === true, 'Second click toggles showArchived from true to false once', JSON.stringify(ab.secondClickTogglesOnce))2217 check(ab.enterTogglesOnce === true, 'Native Enter activation toggles Archived once', JSON.stringify(ab.enterTogglesOnce))2218 check(ab.spaceTogglesOnce === true, 'Native Space activation toggles Archived once', JSON.stringify(ab.spaceTogglesOnce))2219} else {2220 fail('Archived button descriptor', '__test not available')2221}22222223// Static proof: WorkView archived button wired through existing handler2224{2225 const wvStart = source.indexOf('function WorkView')2226 const wvEnd = source.indexOf('\nfunction ', wvStart + 1)2227 const wvSec = source.slice(wvStart, wvEnd > 0 ? wvEnd : source.length)2228 check(wvSec.includes('onToggleArchived(!showArchived)'), 'Archived button calls existing onToggleArchived handler')2229}22302231// [Visual polish] Project-bar hover wash — static contract check2232{2233 const ppStart = source.indexOf('function OpenSpecPage')2234 const ppEnd = source.indexOf('\nfunction ', ppStart + 1)2235 const ppSec = source.slice(ppStart, ppEnd > 0 ? ppEnd : source.length)2236 const sourceControlsStart = ppSec.indexOf('// Source controls')2237 const viewSwitchStart = ppSec.indexOf('// View switch')2238 const sourceControlsSec = ppSec.slice(sourceControlsStart, viewSwitchStart)2239 check(sourceControlsSec.includes('bg-(--ui-bg-quinary)'), 'Project bar keeps base bg-(--ui-bg-quinary)')2240 check(sourceControlsSec.includes('transition-colors hover:bg-primary/[0.06]'), 'Project bar has exact canonical hover pair')2241 check(sourceControlsSec.includes('hover:bg-primary/[0.06]!'), 'Project bar important hover override beats unlayered input chrome')2242 check(sourceControlsSec.includes("'data-openspec-source-trigger': 'true'"), 'Project bar carries scoped hover marker')2243 check(source.includes('[data-openspec-source-trigger].desktop-input-chrome {'), 'Project bar has scoped ambient selector for runtime CSS')2244 check(source.includes('[data-openspec-source-trigger].desktop-input-chrome:hover'), 'Project bar has scoped hover selector for runtime CSS')2245 check(source.includes('background-color: var(--chrome-action-hover) !important;'), 'Project bar scoped wash uses canonical control hover token')2246 check(!sourceControlsSec.includes("style: { minWidth: '120px', flexShrink: 1, background"), 'Project bar has no inline background override')2247}22482249// ═══════════════════════════════════════════════════════════════════════════════2250// [Interaction 1.6] Keyboard activation, hover/focus markers2251// ═══════════════════════════════════════════════════════════════════════════════2252console.log('\n[Interaction 1.6] Keyboard activation and interaction markers')22532254{2255 const kb = identity && identity.keyboard ? identity.keyboard : {}2256 check(kb.enterActivatesOnce === true, 'Enter activates action once', JSON.stringify(kb.enterActivatesOnce))2257 check(kb.spaceActivatesOnce === true, 'Space activates action once on keydown', JSON.stringify(kb.spaceActivatesOnce))2258 check(kb.spacePreventsDefault === true, 'Space prevents default scrolling', JSON.stringify(kb.spacePreventsDefault))2259 check(kb.otherKeysIgnored === true, 'Unrelated keys do nothing', JSON.stringify(kb.otherKeysIgnored))2260}2261{2262 const cd = identity && identity.card ? identity.card : {}2263 check(cd.tabIndexZero === true, 'Card is keyboard-addressable (tabIndex 0)', JSON.stringify(cd.tabIndexZero))2264 check(cd.roleButton === true, 'Card has button semantics', JSON.stringify(cd.roleButton))2265 check(cd.hasKeyDown === true, 'Card wires keydown handler', JSON.stringify(cd.hasKeyDown))2266}2267{2268 const rl = identity && identity.rail ? identity.rail : {}2269 check(rl.tabIndexZero === true, 'Collapsed rail is keyboard-addressable (tabIndex 0)', JSON.stringify(rl.tabIndexZero))2270 check(rl.roleButton === true, 'Collapsed rail has button semantics', JSON.stringify(rl.roleButton))2271 check(rl.hasKeyDown === true, 'Collapsed rail wires keydown handler', JSON.stringify(rl.hasKeyDown))2272 check(rl.dotTonePresent === true, 'Status dot preserved on collapsed rail', JSON.stringify(rl.dotTonePresent))2273 check(rl.hoverExactRail === true, 'Collapsed rail hover uses exact bundled-Kanban utility hover:bg-(--ui-bg-quinary)', JSON.stringify(rl.hoverExactRail))2274 check(rl.hoverGenericAbsent === true, 'Collapsed rail hover no longer uses generic --ui-hover-background', JSON.stringify(rl.hoverGenericAbsent))2275}2276// Static interaction markers — hover/focus-visible + shared handler + status preservation2277{2278 const bcStart = source.indexOf('function BoardCard')2279 const bcEnd = source.indexOf('\nfunction ', bcStart + 1)2280 const bcSec = source.slice(bcStart, bcEnd > 0 ? bcEnd : source.length)2281 check(bcSec.includes('focus-visible'), 'Card has focus-visible marker')2282 check(bcSec.includes('hover:'), 'Card has hover marker')2283 check(bcSec.includes('hover:bg-primary/[0.06]'), 'Card hover marker is exact bundled-Kanban utility hover:bg-primary/[0.06]')2284 check(!bcSec.includes('--ui-hover-background'), 'Card has no generic --ui-hover-background hover marker')2285 check(bcSec.includes('tabIndex: 0'), 'Card sets tabIndex 0')2286 check(bcSec.includes('onKeyDown'), 'Card wires onKeyDown')2287 check(bcSec.includes('handleActivateKey'), 'Card uses shared Enter/Space handler')2288 check(bcSec.includes('borderLeftColor: statusTone(status)'), 'Card keeps status-colored left border')2289}2290{2291 const colStart = source.indexOf('function BoardColumn')2292 const colEnd = source.indexOf('\nfunction ', colStart + 1)2293 const colSec = source.slice(colStart, colEnd > 0 ? colEnd : source.length)2294 check(colSec.includes('focus-visible'), 'Collapsed rail has focus-visible marker')2295 check(colSec.includes('hover:bg-(--ui-bg-quinary)'), 'Collapsed rail hover marker is exact bundled-Kanban utility hover:bg-(--ui-bg-quinary)')2296 check(!colSec.includes('--ui-hover-background'), 'Collapsed rail has no generic --ui-hover-background hover marker')2297 check(colSec.includes('tabIndex: 0'), 'Rail sets tabIndex 0')2298 check(colSec.includes('onKeyDown'), 'Rail wires onKeyDown')2299 check(colSec.includes('handleActivateKey'), 'Rail uses shared Enter/Space handler')2300 check(colSec.includes('backgroundColor: statusTone(status)'), 'Rail keeps status dot')2301 // Expanded header is the same full-width native button with focus-visible.2302 check(colSec.includes("'Collapse ' + label"), 'Expanded header button has collapse label/title')2303 check(colSec.includes("name: 'chevron-left'"), 'Expanded header uses chevron-left Codicon')2304 check(colSec.includes('type: \'button\''), 'Expanded header uses native button')2305 check(colSec.includes('focus-visible:bg-(--ui-control-hover-background)'), 'Expanded header has visible focus wash')2306}2307// --- CSS-cascade base backgrounds: railStyle loses inline background, rail uses compiled class ---2308{2309 const railFnStart = source.indexOf('function railStyle')2310 const railFnEnd = source.indexOf('\n}', railFnStart + 1)2311 const railFnSec = source.slice(railFnStart, railFnEnd > 0 ? railFnEnd : source.length)2312 check(!railFnSec.includes('background'), 'railStyle has no inline background (class-based wash)')2313 check(source.includes('bg-[color-mix(in_srgb,var(--ui-bg-quinary)_50%,transparent)]'),2314 'Collapsed rail base class is exact compiled bg-[color-mix(in_srgb,var(--ui-bg-quinary)_50%,transparent)]')2315 check(source.includes("background: 'color-mix(in srgb, var(--ui-bg-quinary) 50%, transparent)'"),2316 'Expanded lane keeps inline color-mix background')2317}23182319// ═══════════════════════════════════════════════════════════════════════════════2320// [Lane/drawer interaction] Expanded header activation behavior2321// ═══════════════════════════════════════════════════════════════════════════════2322console.log('\n[Lane/drawer interaction] Expanded header activation')23232324if (r.__testAvailable) {2325 const expandedHeader = identity.expandedHeader || {}2326 console.log(' Expanded header descriptor')2327 check(expandedHeader.rootIsButton === true, 'Expanded lane header root is a native button', JSON.stringify(expandedHeader.rootIsButton))2328 check(expandedHeader.typeButton === true, 'Expanded header button type is button', JSON.stringify(expandedHeader.typeButton))2329 check(expandedHeader.labelStartsCollapse === true, 'Expanded header label starts with "Collapse"', JSON.stringify(expandedHeader.labelStartsCollapse))2330 check(expandedHeader.hasChevronLeft === true, 'Expanded header renders chevron-left Codicon', JSON.stringify(expandedHeader.hasChevronLeft))2331 check(expandedHeader.hasMinus === false, 'Expanded header does not render raw minus glyph', JSON.stringify(expandedHeader.hasMinus))2332 check(expandedHeader.clickActivatesOnce === true, 'Click activates collapse once', JSON.stringify(expandedHeader.clickActivatesOnce))2333 check(expandedHeader.enterActivatesOnce === true, 'Enter activates collapse once', JSON.stringify(expandedHeader.enterActivatesOnce))2334 check(expandedHeader.spaceActivatesOnce === true, 'Space activates collapse once', JSON.stringify(expandedHeader.spaceActivatesOnce))2335 check(expandedHeader.spacePreventsDefault === true, 'Space prevents default scrolling on header', JSON.stringify(expandedHeader.spacePreventsDefault))2336 check(expandedHeader.otherKeysIgnored === true, 'Unrelated keys ignored on header', JSON.stringify(expandedHeader.otherKeysIgnored))2337 check(expandedHeader.focusWashExact === true, 'Expanded header descriptor exposes exact focus wash class', JSON.stringify(expandedHeader.focusWashExact))2338} else {2339 fail('Expanded header activation behavior', '__test not available')2340}23412342if (r.__testAvailable) {2343 const drawer = identity.detailDrawer || {}2344 console.log(' DetailDrawer descriptor')2345 check(drawer.rootExists === true, 'DetailDrawer root exists', JSON.stringify(drawer.rootExists))2346 check(drawer.isAbsoluteRight === true, 'DetailDrawer root is positioned absolute right', JSON.stringify(drawer.isAbsoluteRight))2347 check(drawer.hasLeftBorder === true, 'DetailDrawer has left border', JSON.stringify(drawer.hasLeftBorder))2348 check(drawer.widthIsCompact === true, 'DetailDrawer width is compact (26rem)', JSON.stringify(drawer.widthIsCompact))2349 check(drawer.hasCloseButton === true, 'DetailDrawer has close button', JSON.stringify(drawer.hasCloseButton))2350 check(drawer.closeLabel === 'Close detail', 'DetailDrawer close button label is "Close detail"', JSON.stringify(drawer.closeLabel))2351 check(drawer.copyLabel === 'Copy item reference', 'DetailDrawer copy button label is "Copy item reference"', JSON.stringify(drawer.copyLabel))2352 check(drawer.copyStopProp === false, 'DetailDrawer copy button does not stop propagation (not inside clickable card)', JSON.stringify(drawer.copyStopProp))2353} else {2354 fail('DetailDrawer render/descriptor', '__test not available')2355}23562357// ═══════════════════════════════════════════════════════════════════════════════2358// [Source invalidation] Stale detail cleared when retained source becomes invalid2359// ═══════════════════════════════════════════════════════════════════════════════2360console.log('\n[Source invalidation] Stale detail cleared on invalid transition')23612362if (r.__testAvailable) {2363 const inv = identity.sourceInvalidation || {}2364 check(inv.idPreservedWhenInvalid === true, 'Retained source id is preserved even when invalid', JSON.stringify(inv.idPreservedWhenInvalid))2365 const di = identity && identity.detailInvalidation ? identity.detailInvalidation : {}2366 check(di.__error === undefined, 'Detail invalidation lifecycle executed without error', di.__error || '')2367 check(di.detailOpenBefore === true, 'Detail opens after selecting an item on valid source', JSON.stringify(di))2368 check(di.detailClosedAfterInvalid === true, 'Detail closes when retained source becomes invalid', JSON.stringify(di))2369 check(di.selectedItemCleared === true, 'selectedItem state is cleared on invalidity transition', JSON.stringify(di))2370 check(di.detailStaysClosedAfterRevalidation === true, 'Detail stays closed after source recovers (selectedItem was cleared)', JSON.stringify(di))2371} else {2372 fail('Source invalidation helper', '__test not available')2373}23742375// Static proof: invalid-source cleanup effect uses validity-aware transition2376{2377 const ppStart = source.indexOf('function OpenSpecPage')2378 const ppEnd = source.indexOf('\nfunction ', ppStart + 1)2379 const ppSec = source.slice(ppStart, ppEnd > 0 ? ppEnd : source.length)2380 check(ppSec.includes('previousResolvedInvalid'), 'OpenSpecPage tracks previous resolved validity state')2381 check(ppSec.includes('resolvedInvalid'), 'OpenSpecPage computes resolved-invalid flag')2382 check(ppSec.includes('setSelectedItem(null)'), 'OpenSpecPage clears selectedItem on invalidity transition')2383}23842385// ═══════════════════════════════════════════════════════════════════════════════2386// [Remediation 3.6] Source copy-before-name ordering (deep-rendered)2387// ═══════════════════════════════════════════════════════════════════════════════2388console.log('\n[Remediation 3.6] Source copy control order')23892390{2391 const sr = identity && identity.sourceRow ? identity.sourceRow : {}2392 check(sr.__error === undefined, 'Source-row deep render executed without error', sr.__error || '')2393 check(sr.copyImmediatelyBeforeSelector === true, 'Source CopyButton renders immediately before source selector',2394 'copyIndex=' + sr.copyIndex + ' selectorIndex=' + sr.selectorIndex)2395 check(sr.selectorHasExactHoverPair === true, 'Source trigger keeps exact transition-colors + hover pair', JSON.stringify(sr.selectorHasExactHoverPair))2396 check(sr.selectorHasImportantHover === true, 'Source trigger important hover override is present', JSON.stringify(sr.selectorHasImportantHover))2397 check(sr.selectorHasHoverMarker === true, 'Source trigger carries scoped hover marker', JSON.stringify(sr.selectorHasHoverMarker))2398 check(sr.scopedHoverWash === true, 'Source trigger has runtime scoped hover wash rule', JSON.stringify(sr.scopedHoverWash))2399 check(sr.selectorNoInlineBackground === true, 'Source trigger has no inline background override', JSON.stringify(sr.selectorNoInlineBackground))2400}24012402// ════════════════════════════════════════════════════════════════════════════════2403// [Remediation 3.10] CSS-cascade base backgrounds (deep-rendered)2404// ════════════════════════════════════════════════════════════════════════════════2405console.log('\n[Remediation 3.10] CSS-cascade base backgrounds')24062407{2408 const cd = identity && identity.card ? identity.card : {}2409 check(cd.baseBgClass === true, 'Card base class is exact compiled bg-(--ui-bg-elevated)', JSON.stringify(cd.baseBgClass))2410 check(cd.noInlineBg === true, 'Card node has no inline style.background', JSON.stringify(cd.noInlineBg))2411 check(cd.hoverExactCard === true, 'Card hover remains exact hover:bg-primary/[0.06]', JSON.stringify(cd.hoverExactCard))2412}2413{2414 const rl = identity && identity.rail ? identity.rail : {}2415 check(rl.baseBgClass === true, 'Collapsed rail base class is exact compiled bg-[color-mix(in_srgb,var(--ui-bg-quinary)_50%,transparent)]',2416 JSON.stringify(rl.baseBgClass))2417 check(rl.noInlineBg === true, 'Collapsed rail node has no inline style.background', JSON.stringify(rl.noInlineBg))2418 check(rl.hoverExactRail === true, 'Collapsed rail hover remains exact hover:bg-(--ui-bg-quinary)', JSON.stringify(rl.hoverExactRail))2419}24202421// ═══════════════════════════════════════════════════════════════════════════════2422// [Remediation 3.14] Keyboard-focus wash (deep-rendered)2423// ═══════════════════════════════════════════════════════════════════════════════2424console.log('\n[Remediation 3.14] Visible keyboard focus wash')24252426{2427 const cd = identity && identity.card ? identity.card : {}2428 check(cd.focusWashExact === true, 'Card focus wash is exact compiled focus-visible:bg-(--ui-control-hover-background)',2429 JSON.stringify(cd.focusWashExact))2430 check(cd.noDeadOutline === true, 'Card no longer relies on focus-visible:outline-2 + focus-visible:outline-(--ui-focus-border)',2431 JSON.stringify(cd.noDeadOutline))2432}2433{2434 const rl = identity && identity.rail ? identity.rail : {}2435 check(rl.focusWashExact === true, 'Collapsed rail focus wash is exact compiled focus-visible:bg-(--ui-control-hover-background)',2436 JSON.stringify(rl.focusWashExact))2437 check(rl.noDeadOutline === true, 'Collapsed rail no longer relies on focus-visible:outline-2 + focus-visible:outline-(--ui-focus-border)',2438 JSON.stringify(rl.noDeadOutline))2439}24402441// ═══════════════════════════════════════════════════════════════════════════════2442// Summary2443// ═══════════════════════════════════════════════════════════════════════════════2444console.log('\n=== Results: ' + passed + ' passed, ' + failed + ' failed ===')2445if (failures.length > 0) {2446 console.log('\nFailed tests:')2447 for (var i = 0; i < failures.length; i++) {2448 console.log(' - ' + failures[i].name + (failures[i].detail ? ': ' + failures[i].detail : ''))2449 }2450}2451process.exit(failed > 0 ? 1 : 0)