149 lines
16 KiB
JavaScript
149 lines
16 KiB
JavaScript
/**
|
||
* 发布包消费测试入口。先执行 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<ReturnType<typeof openProjectEngine>>,document:Parameters<typeof openProjectEngine>[0]['document'],versions:ReadonlyMap<string,number>,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<ReturnType<typeof openProjectEngine>>){return engine.canStageFiles('root');}
|
||
export function importAvailability(engine:Awaited<ReturnType<typeof openProjectEngine>>){return [engine.imports.canStart('root'),engine.imports.canDuplicate([{id:'node',incarnation:1}],{viewCanvasId:'root'})];}
|
||
export function inspectPartial(engine:Awaited<ReturnType<typeof openProjectEngine>>){
|
||
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<ReturnType<typeof openProjectEngine>>){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})});
|
||
}
|
||
`);
|
||
// 模板中的导出函数仅参加类型检查,不在此处运行;运行时行为仍由上面的 consumer.mjs 和其它场景验证。
|
||
// 仅使用 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))),'<span>react-consumer:synced:absent:null:false:true:loading</span>');
|
||
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,'<span>peer renderer</span>');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');
|
||
`);
|
||
// SSR 验证首次读取及 context 传递,不运行客户端 effect、订阅更新或指针交互;命令计数来自显式 seen.dispatch。
|
||
// 运行实际解包后的 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<ReactFlowProjectionNode>){return createElement('div',null,data.title);}
|
||
function Link({data}:EdgeProps<ReactFlowProjectionEdge>){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});
|
||
}
|