Files
Endoleap/tests/headless/package.integration.mjs

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});
}