12/**3 * Headless smoke harness for desktop/plugin.js4 *5 * Verifies:6 * 1. ESM parsing (no CommonJS, valid imports)7 * 2. No JSX syntax outside jsx()/jsxs() calls8 * 3. Allowed imports only (@hermes/plugin-sdk, react, react/jsx-runtime)9 * 4. Default export is valid HermesPlugin (id + register)10 * 5. ROUTES_AREA + SIDEBAR_NAV_AREA contributions registered11 * 6. Path is /openspec, sidebar nav has label + codicon12 * 7. ctx.rest('/sources') invocation (static + dynamic)13 * 8. Loading/error/empty state transitions14 *15 * Run: node spikes/001-desktop-route-api/smoke.mjs16 * Exit 0 = pass, exit 1 = failure.17 */1819import { readFileSync } from 'node:fs'20import { execSync } from 'node:child_process'21import { dirname, resolve } from 'node:path'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 = 03031function ok(name) { passed++; console.log(` PASS ${name}`) }32function fail(name, d) { failed++; console.error(` FAIL ${name}${d ? ': ' + d : ''}`) }33function check(c, n, d) { c ? ok(n) : fail(n, d) }3435console.log('=== Smoke harness: desktop/plugin.js ===\n')3637// ── 1. ESM structure ───────────────────────────────────────────────────────38console.log('[1] ESM structure')39check(source.includes('export default'), 'Has default export')40check(source.includes('import {'), 'Has named imports')41check(!source.includes('module.exports'), 'No CommonJS exports')42check(!source.includes('require('), 'No require() calls')4344// ── 2. No raw JSX ──────────────────────────────────────────────────────────45console.log('\n[2] No raw JSX')46{47 const bad = source.split('\n').filter(l => {48 const t = l.trim()49 if (t.startsWith('//') || t.startsWith('*') || t.startsWith('/*')) return false50 if (t.includes('jsx(') || t.includes('jsxs(')) return false51 const stripped = t.replace(/['"`](?:[^'"`\\]|\\.)*['"`]/g, '')52 return /<[A-Z]/.test(stripped)53 })54 check(bad.length === 0, 'No raw JSX outside jsx()/jsxs()', bad.length ? `Line: ${bad[0].trim()}` : undefined)55}5657// ── 3. Allowed imports ─────────────────────────────────────────────────────58console.log('\n[3] Allowed imports')59{60 const specs = [...source.matchAll(/from\s+['"]([^'"]+)['"]/g)].map(m => m[1])61 const allowed = new Set(['@hermes/plugin-sdk', 'react', 'react/jsx-runtime'])62 const bad = specs.filter(s => !allowed.has(s))63 check(specs.length > 0, `Found ${specs.length} import(s)`)64 check(bad.length === 0, 'No disallowed imports', bad.length ? `Bad: ${bad.join(', ')}` : undefined)65}6667// ── 4. Dynamic import — plugin identity ────────────────────────────────────68console.log('\n[4] Dynamic import: plugin identity')6970// Build child code as array to avoid template literal escaping issues71const childLines = [72 "import { readFileSync } from 'node:fs'",73 "import { pathToFileURL } from 'node:url'",74 "",75 "const src = readFileSync(" + JSON.stringify(pluginPath) + ", 'utf8')",76 "",77 "const sdkShim = 'export const cn=(...a)=>a.filter(Boolean).join(\" \");'",78 " + 'export const ROUTES_AREA=\"routes\";'",79 " + 'export const SIDEBAR_NAV_AREA=\"sidebar.nav\";'",80 " + 'export const useQuery=()=>({data:null,isLoading:true,error:null});'",81 " + 'export const Loader=()=>null;'",82 " + 'export const EmptyState=()=>null;'",83 " + 'export const ErrorState=()=>null;' + ' export const Badge=()=>null;' + ' export const Button=()=>null;' + ' export const Codicon=()=>null;' + ' export const CopyButton=()=>null;' + ' export const Dialog=()=>null;' + ' export const DialogContent=()=>null;' + ' export const Input=()=>null;' + ' export const ScrollArea=()=>null;' + ' export const SearchField=()=>null;' + ' export const Select=()=>null;' + ' export const SelectContent=()=>null;' + ' export const SelectItem=()=>null;' + ' export const SelectTrigger=()=>null;' + ' export const SelectValue=()=>null;' + ' export const SegmentedControl=()=>null;' + ' export const Tabs=()=>null;' + ' export const TabsList=()=>null;' + ' export const TabsTrigger=()=>null;'",84 "",85 "const reactShim = 'export function useState(v){return[v,function(){},function(){},v]};'",86 " + 'export function useEffect(f,d){return undefined};'",87 " + 'export function useRef(v){return{current:v}};'",88 " + 'export function useCallback(f,d){return f};'",89 " + 'export function useMemo(f,d){return f()};'",90 " + 'export function useContext(c){return undefined};'",91 " + 'export default {}'",92 "",93 "const jsxShim = 'export function jsx(c,p){return{component:c,props:p,_t:\"jsx\"}}'",94 " + 'export function jsxs(c,p){return{component:c,props:p,_t:\"jsxs\"}}'",95 "",96 "const sdkUrl = 'data:text/javascript,' + encodeURIComponent(sdkShim)",97 "const reactUrl = 'data:text/javascript,' + encodeURIComponent(reactShim)",98 "const jsxUrl = 'data:text/javascript,' + encodeURIComponent(jsxShim)",99 "const blobSrc = src",100 " .replace(/from\\s+['\"]@hermes\\/plugin-sdk['\"]/g, \"from '\" + sdkUrl + \"'\")",101 " .replace(/from\\s+['\"]react\\/jsx-runtime['\"]/g, \"from '\" + jsxUrl + \"'\")",102 " .replace(/from\\s+['\"]react['\"]/g, \"from '\" + reactUrl + \"'\")",103 "",104 "const blobUrl = 'data:text/javascript,' + encodeURIComponent(blobSrc)",105 "const mod = await import(blobUrl)",106 "const p = mod.default",107 "",108 "const results = {",109 " id: p && p.id,",110 " hasRegister: typeof (p && p.register) === 'function',",111 " hasName: typeof (p && p.name) === 'string',",112 " description: p && p.description,",113 "}",114 "",115 "const contribs = []",116 "let restCalls = []",117 "",118 "const mockCtx = {",119 " register(c) { contribs.push(c) },",120 " registerMany(arr) { for (const c of arr) contribs.push(c) },",121 " rest(path) { restCalls.push(path); return Promise.resolve({ sources: [] }) }",122 "}",123 "",124 "try {",125 " p.register(mockCtx)",126 "} catch(e) {",127 " results.registerError = e.message",128 "}",129 "",130 "results.contribCount = contribs.length",131 "results.restCalls = restCalls",132 "results.routeContrib = contribs.find(c => c.area === 'routes')",133 "results.navContrib = contribs.find(c => c.area === 'sidebar.nav')",134 "results.hasRender = typeof (results.routeContrib && results.routeContrib.render) === 'function'",135 "",136 "if (results.hasRender) {",137 " try {",138 " const rendered = results.routeContrib.render()",139 " results.renderReturned = rendered != null",140 " results.renderIsJsx = rendered && (rendered._t === 'jsx' || rendered._t === 'jsxs')",141 " } catch(e) {",142 " results.renderError = e.message",143 " }",144 "}",145 "",146 "process.stdout.write(JSON.stringify(results))",147]148149const childPath = resolve(__dirname, '_smoke_child.mjs')150import { writeFileSync, unlinkSync } from 'node:fs'151writeFileSync(childPath, childLines.join('\n'))152153try {154 const raw = execSync(`node ${childPath}`, { encoding: 'utf8', timeout: 15000 }).trim()155 const r = JSON.parse(raw)156157 console.log(` id=${r.id} register=${r.hasRegister} name="${r.description}"`)158 check(r.id === 'openspec', 'Plugin id is "openspec"', r.id ? `Got: ${r.id}` : 'null')159 check(r.hasRegister, 'Has register() function')160 check(r.hasName, 'Has name property')161 check(!r.registerError, 'register() runs without error', r.registerError)162163 // [5] Contributions164 console.log(`\n[5] Contributions: ${r.contribCount} registered`)165 check(r.contribCount >= 2, 'At least 2 contributions')166 check(r.routeContrib !== undefined, 'Has ROUTES_AREA contribution')167 check(r.navContrib !== undefined, 'Has SIDEBAR_NAV_AREA contribution')168169 // [6] Path and nav metadata170 console.log('\n[6] Path and nav metadata')171 const rp = r.routeContrib && r.routeContrib.data && r.routeContrib.data.path172 check(rp === '/openspec', `Route path "/openspec"`, rp ? `Got: ${rp}` : 'missing')173 check(r.navContrib && r.navContrib.data && r.navContrib.data.label === 'OpenSpec', 'Sidebar nav label "OpenSpec"')174 check(r.navContrib && r.navContrib.data && r.navContrib.data.codicon != null, 'Sidebar nav has codicon')175 check(r.navContrib && r.navContrib.data && r.navContrib.data.path === '/openspec', 'Sidebar nav path /openspec')176177 // [7] Render function178 console.log('\n[7] Render function')179 check(r.hasRender, 'Route contribution has render()')180 check(r.renderReturned === true, 'render() returns a value', r.renderError || 'returned null')181 check(r.renderIsJsx === true, 'render() returns jsx descriptor',182 !r.renderIsJsx && r.renderReturned ? `Got type: ${typeof r.renderReturned}` : undefined)183184} catch (e) {185 fail('Dynamic import', e.message)186} finally {187 try { unlinkSync(childPath) } catch {}188}189190// ── 8. ctx.rest('/sources') — static analysis ──────────────────────────────191console.log('\n[8] ctx.rest("/sources") invocation')192check(source.includes("api.sources()"), "Calls api.sources()")193check(!source.includes("fetch('"), 'No direct fetch() calls')194check(!source.includes('fetch("/'), 'No direct fetch() calls')195check(!source.includes('window.fetch'), 'No window.fetch calls')196197// Verify the rest call is inside the render path (useQuery queryFn)198const restInQueryFn = /queryFn.*api\.sources/.test(source)199 || /api\.sources[\s\S]*queryFn/.test(source)200 || source.includes("queryFn: function() { return api.sources() }")201check(restInQueryFn, 'api.sources() is in useQuery queryFn')202203// ── 9. Loading/error/empty state transitions ───────────────────────────────204console.log('\n[9] Rendering transitions')205check(source.includes('isLoading'), 'Checks isLoading state')206check(source.includes('error'), 'Checks error state')207check(source.includes('Loader'), 'Uses Loader component')208check(source.includes('ErrorState'), 'Uses ErrorState component')209check(source.includes('EmptyState'), 'Uses EmptyState component')210211// ── 10. Source quality ──────────────────────────────────────────────────────212console.log('\n[10] Source quality')213check(!source.includes('console.log'), 'No debug console.log')214check(!source.includes('// TODO'), 'No TODO comments')215check(!source.includes('alert('), 'No alert() calls')216check(source.includes("id: 'openspec'"), 'Plugin id is "openspec"')217check(source.includes("name: 'OpenSpec'"), 'Plugin name is "OpenSpec"')218219// ── Summary ─────────────────────────────────────────────────────────────────220console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`)221process.exit(failed > 0 ? 1 : 0)