/** * 发布包消费测试入口。先执行 build-headless 生成 .headless-dist,再离线 npm pack 到临时目录,从解包产物验证 Node、类型和浏览器入口。 * 执行会触发编译/打包及浏览器检查,不是 node:test 的轻量单测;最后清理临时消费者目录,构建目录仍由构建脚本管理。 * 内嵌模板是待生成的独立消费者源码,注释在各模板外说明,避免改变测试输入本身。 */ import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { mkdtemp, readFile, realpath, rm, writeFile, mkdir, symlink } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; // 以脚本路径定位仓库根,避免调用者 cwd 不同导致打包错误目录。 const root=resolve(dirname(fileURLToPath(import.meta.url)),'../..'); // 临时目录先解析真实路径,后面的包内路径守卫据此判定 import 是否越出解包根。 const temporary=await realpath(await mkdtemp(join(tmpdir(),'endoleap-headless-package-'))); // 同步运行子步骤,默认在仓库根且捕获输出;options 可指定消费者 cwd 和独立 npm cache,失败交给外层统一报告。 const run=(program,args,options={})=>execFileSync(program,args,{cwd:root,encoding:'utf8',stdio:['ignore','pipe','pipe'],...options}); try { // 先编译真正发布产物;后面的测试都消费该产物,不能用源文件 TypeScript stripping 冒充包可用。 run(process.execPath,['scripts/build-headless.mjs']); // npm pack 禁用生命周期脚本、使用离线模式并写入临时目录;这验证已有构建产物的包装,不触发额外安装。 const packed=JSON.parse(run('npm',['pack','--offline','--ignore-scripts','--json','--pack-destination',temporary],{cwd:join(root,'.headless-dist'),env:{...process.env,npm_config_cache:join(temporary,'npm-cache')}})); assert.equal(packed.length,1); // 解包唯一 tarball,后续 packageRoot 代表实际发布文件布局。 run('tar',['-xzf',join(temporary,packed[0].filename),'-C',temporary]); const packageRoot=join(temporary,'package'); // 读取打包后的 package.json 与 README,检查私有 ESM 包、无直接运行时依赖,以及可选框架 peer 元数据。 const metadata=JSON.parse(await readFile(join(packageRoot,'package.json'),'utf8')); const readme=await readFile(join(packageRoot,'README.md'),'utf8'); // 逐个检查 README 的相对 .md 链接可在包内读取且非空;外部 URL 不在此处做网络可达性验证。 for(const match of readme.matchAll(/\]\(([^)]+\.md)\)/g)){ if(/^[a-z]+:/i.test(match[1]))continue; assert.ok((await readFile(join(packageRoot,match[1]),'utf8')).length>0,`packed README link is empty: ${match[1]}`); } assert.equal(metadata.private,true);assert.equal(metadata.type,'module'); assert.equal(Object.keys(metadata.dependencies??{}).length,0); assert.deepEqual(Object.keys(metadata.peerDependencies??{}).sort(),['@xyflow/react','react','react-dom']);assert.ok(Object.values(metadata.peerDependenciesMeta).every(value=>value.optional===true)); // portable 消费者的非内建 import 必须来自解包产物,不能偷偷借用仓库 node_modules。 // 生成 ESM resolve 钩子:node: 内建模块放行,其他解析结果必须是 packageRoot 内的 file URL。 await writeFile(join(temporary,'guard-loader.mjs'),` import {fileURLToPath} from 'node:url'; import {sep} from 'node:path'; const root=${JSON.stringify(packageRoot)}; export async function resolve(specifier,context,next){ if(specifier.startsWith('node:'))return next(specifier,context); const result=await next(specifier,context); if(!result.url.startsWith('file:')||!fileURLToPath(result.url).startsWith(root+sep))throw Error('External dependency: '+specifier); return result; } `); // 生成 Node 启动守卫:注册路径钩子,并将 window/document/navigator/IndexedDB/Worker 读取设为抛错 getter。 await writeFile(join(temporary,'guard.mjs'),` import {register} from 'node:module'; register('./guard-loader.mjs',import.meta.url); for(const name of ['window','document','navigator','indexedDB','Worker'])Object.defineProperty(globalThis,name,{configurable:true,get(){throw Error('Browser global: '+name);}}); `); // 在独立进程运行打包 consumer.mjs;portable、重开和元素数由消费者真实执行结果断言,不从源码文本推断。 const report=JSON.parse(run(process.execPath,['--import',join(temporary,'guard.mjs'),join(packageRoot,'consumer.mjs')],{cwd:temporary})); assert.equal(report.status,'ok');assert.equal(report.portableBindings,true);assert.equal(report.reopened,true);assert.equal(report.elements,2); // 不加载 DOM 类型库,验证 portable 端口的公开声明没有泄漏浏览器专属类型。 // 生成不带 DOM 的 TS 消费者:检查 loading v2-v6、导入可用性、部分查询、场景投影/命中和 StoragePort 装配的公开类型。 await writeFile(join(packageRoot,'consumer-types.mts'),` import {createRegistry,builtinComponents,createDocument} from '@endoleap/headless-canvas/core'; import {openProjectEngine,type StoragePort,type StorageScope,type LoadingState} from '@endoleap/headless-canvas/runtime'; import {projectCanvas,hitProjectedCanvas,type ProjectionNode,type ProjectionEdge} from '@endoleap/headless-canvas/bindings'; import {createFoundryPersistence,type ServiceTransport} from '@endoleap/headless-canvas/service'; const registry=createRegistry(builtinComponents()); export async function completePartial(engine:Awaited>,document:Parameters[0]['document'],versions:ReadonlyMap,state:LoadingState){ const accepted=engine.loading.accept(document,versions,state); return accepted.status==='applied'?engine.loading.complete(document,versions,{sourceToken:state.sourceToken}):accepted; } export const certified:LoadingState={version:2,sourceToken:'consumer-read',declaredCanvasIds:['root'],receivedElements:0,totalElements:1,canvasElementCounts:{root:1}}; export const scoped:LoadingState={...certified,version:3}; export const structural:LoadingState={...scoped,version:4,elementOwners:{node:'root'}}; export const importable:LoadingState={...structural,version:5,sourceCanvases:[{id:'root',parent:null}]}; export const images:LoadingState={...importable,version:6}; export function imageAvailability(engine:Awaited>){return engine.canStageFiles('root');} export function importAvailability(engine:Awaited>){return [engine.imports.canStart('root'),engine.imports.canDuplicate([{id:'node',incarnation:1}],{viewCanvasId:'root'})];} export function inspectPartial(engine:Awaited>){ const queries=engine.editor.getQueries(),lookup=queries.lookupElement('node'); const id:string|undefined=lookup.status==='present'?lookup.element.id:undefined; const complete:boolean=queries.membersResult(engine.getSnapshot().document.projectId).complete; return {id,complete,projectComplete:queries.coverage.projectComplete}; } export function project(engine:Awaited>){const snapshot=engine.getSnapshot(),queries=engine.editor.getQueries();const scene=projectCanvas(snapshot.document,snapshot.document.projectId,registry,new Set(),()=>undefined);const nodes:ProjectionNode[]=scene.nodes,edges:ProjectionEdge[]=scene.edges;return {nodes,edges,hit:hitProjectedCanvas(scene,queries,registry,'view',{space:'scene',point:{x:0,y:0},zoom:1})};} export function open(scope:StorageScope,storage:StoragePort,transport:ServiceTransport){ return openProjectEngine({scope,storage,registry,document:createDocument(scope.projectId,[{id:scope.projectId,parent:null}],[]),versions:new Map([[scope.projectId,1]]),persistence:createFoundryPersistence({registry,transport,rootId:scope.projectId,workspaceId:scope.workspaceId})}); } `); // 仅使用 ES2022 与 Node 类型进行 noEmit 检查,防止 portable 端口意外依赖浏览器声明。 run(process.execPath,[join(root,'node_modules/typescript/bin/tsc'),'--noEmit','--strict','--target','ES2022','--module','NodeNext','--moduleResolution','NodeNext','--lib','ES2022','--types','node','--typeRoots',join(root,'node_modules/@types'),join(packageRoot,'consumer-types.mts')],{cwd:temporary}); // 框架 peers 由第二个消费者显式接入;上面的路径守卫保持严格,/bindings 不能依赖这些 peers。 // 另一个显式启用框架的消费者才连接 React/ReactFlow peers,前面的严格 portable 检查已独立完成。 await mkdir(join(packageRoot,'node_modules/@xyflow'),{recursive:true}); for(const peer of ['react','react-dom','@xyflow/react'])await symlink(await realpath(join(root,'node_modules',peer)),join(packageRoot,'node_modules',peer),'dir'); await mkdir(join(packageRoot,'node_modules/@types'),{recursive:true}); for(const peer of ['react','react-dom'])await symlink(await realpath(join(root,'node_modules/@types',peer)),join(packageRoot,'node_modules/@types',peer),'dir'); // 生成 SSR 消费者:HookView 读取公开 hooks,View 取得 renderer 命令 context;实际渲染并派发一次命令检查绑定。 await writeFile(join(packageRoot,'react-consumer.mjs'),` import assert from 'node:assert/strict'; import React from 'react';import {renderToStaticMarkup} from 'react-dom/server'; import {createReactRenderers,CanvasRendererCommandsContext,useCanvasRendererCommands} from '@endoleap/headless-canvas/react-flow'; import {createRegistry,builtinComponents} from '@endoleap/headless-canvas/core'; import {CanvasProvider,useCanvas,useSyncStatus,useElement,useMode,useGestureBindings,useOperation,useAsset} from '@endoleap/headless-canvas/react'; const snapshot={rootRevision:0,runtime:{ledger:{operations:new Map(),requests:new Map()},resources:new Map()},document:{projectId:'react-consumer'}};const durable={rootRevision:0,durableRootRevision:0,closed:false,closing:false};const hookEngine={getSnapshot:()=>snapshot,getDurability:()=>durable,subscribeDurability:()=>()=>{},editor:{subscribe:()=>()=>{},getQueries:()=>({lookupElement:()=>({status:'absent'})})},sessions:{getSnapshot:()=>({views:new Map([['ssr',{modes:new Map()}]]),gesture:null}),subscribe:()=>()=>{}}}; function HookView(){const id=useCanvas(value=>value.document.projectId),status=useSyncStatus(),elementStatus=useElement('missing',view=>view.status),mode=useMode({id:'missing',incarnation:1}),gesture=useGestureBindings({id:'missing',incarnation:1}),operation=useOperation('missing'),asset=useAsset('missing');return React.createElement('span',null,id+':'+status.status+':'+elementStatus+':'+mode.mode+':'+gesture.active+':'+(operation===undefined)+':'+asset.status);} assert.equal(renderToStaticMarkup(React.createElement(CanvasProvider,{engine:hookEngine,viewId:'ssr'},React.createElement(HookView))),'react-consumer:synced:absent:null:false:true:loading'); const registry=createRegistry(builtinComponents());let dispatched=0,seen;const commands={dispatch(){dispatched++;return{status:'applied',revision:1,operationIds:[]};},transaction(){throw Error('unused');}}; function View(){seen=useCanvasRendererCommands();return React.createElement('span',null,'peer renderer');} const renderers=createReactRenderers([{componentId:'text',dataVersion:1,structure:'node',rendererId:'fixture',render:View}],registry); const html=renderToStaticMarkup(React.createElement(CanvasRendererCommandsContext.Provider,{value:commands},React.createElement(renderers.nodeTypes.fixture))); assert.equal(html,'peer renderer');assert.strictEqual(seen,commands);seen.dispatch({type:'element.data',target:{id:'fixture',incarnation:1},patch:{title:'command'}});assert.equal(dispatched,1);console.log('PASS explicit React peer renderer/context consumer'); `); // 运行实际解包后的 React 入口,显示该独立消费者输出。 const react=run(process.execPath,[join(packageRoot,'react-consumer.mjs')],{cwd:temporary});process.stdout.write(react); // 生成框架 TS 消费者:Hooks 检查选择器、actions 和资源/操作视图,Card/Link 分别验证公开节点及边投影类型。 await writeFile(join(packageRoot,'react-consumer-types.mts'),` import {createElement} from 'react';import type {NodeProps,EdgeProps} from '@xyflow/react'; import {createReactRenderers,type ReactFlowProjectionNode,type ReactFlowProjectionEdge} from '@endoleap/headless-canvas/react-flow'; import {CanvasProvider,useCanvas,useElement,useActions,useMode,useGestureBindings,useOperation,useAsset,useSyncStatus,type OperationView} from '@endoleap/headless-canvas/react'; import type {ProjectEngine} from '@endoleap/headless-canvas/runtime'; function Hooks(){const ref={id:'a',incarnation:1};const title=useCanvas(root=>root.document.projectId);useElement(ref);const selectedId:string|undefined=useElement(ref,view=>view.status==='present'?view.element.id:undefined);useElement(ref,view=>({status:view.status}),(a,b)=>a.status===b.status);void selectedId;const actions=useActions(ref);useMode(ref);useGestureBindings(ref);const operation:OperationView=useOperation('operation');void operation;useAsset('resource');useSyncStatus('canvas');return createElement('button',{onClick:()=>actions.rename(title,{label:'Rename'})},title);} export function BoundHooks({engine}:{engine:ProjectEngine}){return createElement(CanvasProvider,{engine,viewId:'view'},createElement(Hooks));} function Card({data}:NodeProps){return createElement('div',null,data.title);} function Link({data}:EdgeProps){return createElement('span',null,data?.__headlessRecord?.id);} export const adapter=createReactRenderers([{componentId:'text',dataVersion:1,structure:'node',rendererId:'card',render:Card},{componentId:'connection',dataVersion:1,structure:'connection',rendererId:'link',render:Link}]); `); // 框架消费者单独启用 DOM 和 React 类型;这不能替代前面不带 DOM 的 portable 检查。 run(process.execPath,[join(root,'node_modules/typescript/bin/tsc'),'--noEmit','--strict','--target','ES2022','--module','NodeNext','--moduleResolution','NodeNext','--lib','ES2022,DOM,DOM.Iterable','--types','react,react-dom,node','--typeRoots',join(root,'node_modules/@types'),join(packageRoot,'react-consumer-types.mts')],{cwd:temporary}); // 继续执行浏览器包消费场景,直接加载编译 ESM 与 Worker、IndexedDB/Web Locks,独立验证浏览器适配器。 const browser=run(process.execPath,['tests/headless/browser-storage.mjs','--package']); process.stdout.write(browser); // 只有所有消费和类型检查完成后才输出整体报告;packedFiles 取自实际 npm pack 结果。 console.log(JSON.stringify({relativeDocumentation:true,compiledBrowserAdapters:true,portableBindings:true,explicitReactPeers:true,status:'ok',packedFiles:packed[0].files.length,consumer:report,nodeTypes:true,browserGlobalsBlocked:true,externalRuntimeDependenciesBlocked:true})); // 保留子进程 stdout/stderr 便于定位失败,再抛出原错误使入口失败。 } catch(error){ if(error.stdout)process.stderr.write(error.stdout); if(error.stderr)process.stderr.write(error.stderr); throw error; } finally { // 成功或失败都删除临时消费者目录;不在这里删除仓库的 .headless-dist。 await rm(temporary,{recursive:true,force:true}); }