// 渲染冒烟检测器:让"程序能发现的问题"从静态语法外推到真实挂载。 // // 为什么需要它(doc/soma 架构强化.md §1): // 静态闸门只能证明声明"自洽",证明不了它"能活"——声明可以语法全对、锚点全在, // 挂载那一刻却抛异常、或渲出一片空白。这类问题今天只有到了浏览器才暴露, // 而按体系铁律"重试的前提是程序能发现问题",发现不了就没法自愈。 // // 做法:闸门通过后,在服务端 jsdom 里用真引擎挂载一次,四项判定: // S1 挂载抛异常;S2 控制台 error;S3 结构性空渲染(锚点全是占位符 "--"); // S4 组件宿主未展开(注册的组件没长出内部结构)。 // 任一命中 → 并入 issues 走既有回喂循环(**共用三次尝试预算,绝不额外打模型**)。 // // 设计取舍: // - 组件定义不从 soma-ui 拿(包边界:soma-ai 不依赖组件库),而是按组件目录条目 // 合成"替身组件"——足以验证编译/绑定/锚点/挂载链路;组件内部行为由 soma-ui 自己的 // 测试负责,不在这里重复; // - jsdom 是 devDependency:生产环境按"检测器不可用"优雅跳过(返回 skipped), // 绝不因为缺依赖让生成失败; // - 其余载入失败(jsdom 装了却载不起来、soma-core 冒烟入口缺失或抛错)是真故障, // 不能冒充"没装 jsdom":带前缀记 error 日志后跳过——留痕,但仍不阻断生成; // - 超时(默认 2s)按通过处理并记日志——冒烟是增益,不是新的失败源。 import type { PageCatalogEntry, PageDeclarations } from './PageDeclarationValidator'; export interface SmokeResult { /** true = 未执行(依赖缺失/显式关闭),调用方按通过处理 */ skipped: boolean; /** 冒烟发现的问题(空 = 通过) */ issues: string[]; /** 耗时(毫秒) */ durationMs: number; } /** 关闭开关:SOMA_AI_SMOKE=off */ function smokeDisabled(): boolean { return String(process.env.SOMA_AI_SMOKE ?? '').toLowerCase() === 'off'; } /** 由目录条目合成替身组件:只验证链路,不复刻真实组件行为 */ function buildStandInComponents(catalog: PageCatalogEntry[]): Record { const defs: Record = {}; for (const entry of catalog) { const props: Record = {}; for (const prop of entry.props ?? []) { props[prop.name] = { type: 'any', default: null, desc: prop.name }; } const slots = entry.slots ?? []; const slotOutlets = slots .map((slot) => `
`) .join(''); defs[entry.name] = { contract: { name: entry.name, version: '0.0.0-smoke', layer: 'composite', props, emits: Object.fromEntries((entry.emits ?? []).map((e) => [e.name, { desc: e.name }])), slots: Object.fromEntries(slots.map((s) => [s.name, { desc: s.name, required: s.required }])), }, templateHtml: [ '
', '·', slotOutlets, '
', ].join(''), uiConfig: { bind: { obj: '__component', var: 'p' }, show: {} }, }; } return defs; } function escapeHtmlAttribute(value: string): string { return value .replace(/&/g, '&') .replace(/"/g, '"') .replace(//g, '>'); } /** * 结构性空判定:页面既没有任何**成功挂载的组件**,文本又只剩占位符 —— 才算空。 * * 注意替身组件自身会渲一个 "·" 标记,不能算作内容;反过来,一个纯组件页 * (数据全在 props 里、没有文本锚点)是完全正常的,不能因为"没有文本"就判空。 */ function looksEmpty(container: Element): boolean { const mountedComponents = Array.from(container.querySelectorAll('[data-soma-component]')) .filter((host) => host.children.length > 0); if (mountedComponents.length > 0) { return false; } const text = (container.textContent ?? '').replace(/\s+/g, ''); return text.length === 0 || /^[-·]+$/.test(text); } const MODULE_NOT_FOUND_CODES = new Set(['ERR_MODULE_NOT_FOUND', 'MODULE_NOT_FOUND']); /** 顺着 cause 链最多看几层:打包器/测试运行器会把原始的"找不到模块"包进自己的错误里 */ const ERROR_CAUSE_MAX_DEPTH = 5; /** * 是否就是"jsdom 没装"。只认模块解析失败且点名 jsdom 的错误; * jsdom 装了却在求值时抛错,或是别的包找不到,都不在此列。 */ function isJsdomMissing(error: unknown): boolean { let current: unknown = error; for (let depth = 0; depth < ERROR_CAUSE_MAX_DEPTH && current instanceof Error; depth += 1) { const code = (current as { code?: unknown }).code; if (typeof code === 'string' && MODULE_NOT_FOUND_CODES.has(code) && /\bjsdom\b/.test(current.message)) { return true; } current = current.cause; } return false; } /** 日志里带上 cause 链:包装层的文案往往只说"出错了",真正的原因在里面 */ function describeError(error: unknown): string { const messages: string[] = []; let current: unknown = error; for (let depth = 0; depth < ERROR_CAUSE_MAX_DEPTH && current !== undefined; depth += 1) { messages.push(current instanceof Error ? current.message : String(current)); current = current instanceof Error ? current.cause : undefined; } return messages.join(' ← '); } /** * 在 jsdom 里真实挂载一次声明,返回冒烟结果。 * 环境性问题(没装 jsdom、超时)一律 skipped;依赖载入的其他失败记 error 日志后 skipped。 * 两种情况都不阻断生成。 */ export async function smokeRenderDeclaration( decl: PageDeclarations, catalog: PageCatalogEntry[], options: { timeoutMs?: number } = {}, ): Promise { const startedAt = Date.now(); if (smokeDisabled()) { return { skipped: true, issues: [], durationMs: 0 }; } const skipped = (): SmokeResult => ({ skipped: true, issues: [], durationMs: Date.now() - startedAt }); let JSDOMCtor: typeof import('jsdom').JSDOM; try { ({ JSDOM: JSDOMCtor } = await import('jsdom')); } catch (error) { // 生产环境没装 jsdom(devDependency):检测器不可用,按通过处理 if (isJsdomMissing(error)) return skipped(); console.error(`[soma-ai][冒烟] jsdom 载入失败(不是未安装),本次跳过冒烟:${describeError(error)}`); return skipped(); } let SomaBootstrapCtor: typeof import('soma-core').SomaBootstrap; let SomaPageRootBaseCtor: typeof import('soma-core').SomaPageRootBase; let compilePageAssetsFn: typeof import('soma-core/server/page-assets').compilePageAssets; try { const core = await import('soma-core'); SomaBootstrapCtor = core.SomaBootstrap; SomaPageRootBaseCtor = core.SomaPageRootBase; // 构建器只能在实际执行服务端冒烟时再载入;不能穿过 soma-core 的客户端根入口, // 否则 OOUI 的 Node 内容指纹会进入 Host 的浏览器 bundle。 ({ compilePageAssets: compilePageAssetsFn } = await import('soma-core/server/page-assets')); } catch (error) { // soma-core 是 peerDependency,生成链本身就靠它:这里载不起来是 Host 装配/版本故障, // 不是"环境没装测试依赖",必须留痕,不能套用上面的静默跳过。 console.error(`[soma-ai][冒烟] soma-core 冒烟入口载入失败,本次跳过冒烟:${describeError(error)}`); return skipped(); } const timeoutMs = options.timeoutMs ?? 2000; const issues: string[] = []; const consoleErrors: string[] = []; const dom = new JSDOMCtor('
', { pretendToBeVisual: true, }); const { window } = dom; const container = window.document.getElementById('smoke-root')!; // 引擎跑在全局 DOM 上:临时接管,结束后原样还原(服务端不能留下污染) const globalScope = globalThis as unknown as Record; const saved = { window: globalScope.window, document: globalScope.document, HTMLElement: globalScope.HTMLElement, Node: globalScope.Node, Element: globalScope.Element, Event: globalScope.Event, CustomEvent: globalScope.CustomEvent, MutationObserver: globalScope.MutationObserver, AbortController: globalScope.AbortController, AbortSignal: globalScope.AbortSignal, getComputedStyle: globalScope.getComputedStyle, }; globalScope.window = window; globalScope.document = window.document; globalScope.HTMLElement = window.HTMLElement; globalScope.Node = window.Node; globalScope.Element = window.Element; globalScope.Event = window.Event; globalScope.CustomEvent = window.CustomEvent; globalScope.MutationObserver = window.MutationObserver; globalScope.AbortController = window.AbortController; globalScope.AbortSignal = window.AbortSignal; globalScope.getComputedStyle = window.getComputedStyle.bind(window); const originalConsoleError = console.error; console.error = (...args: unknown[]) => { consoleErrors.push(args.map((a) => String(a)).join(' ')); }; class SmokePageRoot extends SomaPageRootBaseCtor {} let bootstrap: InstanceType | null = null; let timedOut = false; try { bootstrap = new SomaBootstrapCtor(); const mount = bootstrap.mount(container as unknown as HTMLElement, compilePageAssetsFn({ html: decl.html, uiYaml: decl.uiYaml, eventYaml: decl.eventYaml ?? '', styleYaml: decl.styleYaml, DomainClass: SmokePageRoot, components: buildStandInComponents(catalog) as never, })); await Promise.race([ mount, new Promise((resolve) => { setTimeout(() => { timedOut = true; resolve(); }, timeoutMs).unref?.(); }), ]); if (!timedOut) { // S2 控制台 error if (consoleErrors.length > 0) { issues.push(`[冒烟] 挂载过程中出现控制台错误:${consoleErrors[0].slice(0, 160)}`); } // S3 结构性空渲染 if (looksEmpty(container as unknown as Element)) { issues.push('[冒烟] 页面渲染后没有任何实际内容(数据未绑定或 initProps 缺失),请检查 show 与数据是否对得上'); } // S4 组件宿主未展开 const hosts = container.querySelectorAll('[data-soma-component]'); for (const host of Array.from(hosts)) { if ((host as Element).children.length === 0) { issues.push( `[冒烟] 组件 "${(host as Element).getAttribute('data-soma-component')}" 的宿主没有渲染出内容——` + '通常是组件名或 props 声明有误', ); break; } } } } catch (error) { // S1 挂载抛异常 issues.push(`[冒烟] 挂载失败:${error instanceof Error ? error.message : String(error)}`); } finally { console.error = originalConsoleError; try { await bootstrap?.unmount(); } catch { // 卸载失败不影响判定 } for (const [key, value] of Object.entries(saved)) { if (value === undefined) { delete globalScope[key]; } else { globalScope[key] = value; } } window.close(); } if (timedOut) { console.warn(`[soma-ai][冒烟超时] ${timeoutMs}ms 内未完成挂载,按通过处理`); return { skipped: true, issues: [], durationMs: Date.now() - startedAt }; } return { skipped: false, issues, durationMs: Date.now() - startedAt }; }