120 lines
12 KiB
JavaScript
120 lines
12 KiB
JavaScript
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';
|
|
|
|
const root=resolve(dirname(fileURLToPath(import.meta.url)),'../..');
|
|
const temporary=await realpath(await mkdtemp(join(tmpdir(),'endoleap-headless-package-')));
|
|
const run=(program,args,options={})=>execFileSync(program,args,{cwd:root,encoding:'utf8',stdio:['ignore','pipe','pipe'],...options});
|
|
try {
|
|
run(process.execPath,['scripts/build-headless.mjs']);
|
|
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);
|
|
run('tar',['-xzf',join(temporary,packed[0].filename),'-C',temporary]);
|
|
const packageRoot=join(temporary,'package');
|
|
const metadata=JSON.parse(await readFile(join(packageRoot,'package.json'),'utf8'));
|
|
const readme=await readFile(join(packageRoot,'README.md'),'utf8');
|
|
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));
|
|
// Resolve every import through the packed artifact, never checkout node_modules.
|
|
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;
|
|
}
|
|
`);
|
|
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);}});
|
|
`);
|
|
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);
|
|
// No DOM library: browser adapter declarations must not leak into portable ports.
|
|
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})});
|
|
}
|
|
`);
|
|
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});
|
|
// A separate consumer opts into framework peers. The portable guard above
|
|
// remains strict and has already proved /bindings does not resolve any peers.
|
|
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');
|
|
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');
|
|
`);
|
|
const react=run(process.execPath,[join(packageRoot,'react-consumer.mjs')],{cwd:temporary});process.stdout.write(react);
|
|
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}]);
|
|
`);
|
|
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});
|
|
|
|
const browser=run(process.execPath,['tests/headless/browser-storage.mjs','--package']);
|
|
process.stdout.write(browser);
|
|
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}));
|
|
} catch(error){
|
|
if(error.stdout)process.stderr.write(error.stdout);
|
|
if(error.stderr)process.stderr.write(error.stderr);
|
|
throw error;
|
|
} finally {
|
|
await rm(temporary,{recursive:true,force:true});
|
|
}
|