@peripheries/engine 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peripheries/engine",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Build, check, knowledge and deploy for peripheries component repos",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -39,7 +39,21 @@
39
39
  },
40
40
  "peerDependencies": {
41
41
  "@peripheries/renderer": ">=0.6.2 <1",
42
- "@peripheries/runtime": ">=0.6.2 <1"
42
+ "@peripheries/runtime": ">=0.6.2 <1",
43
+ "@vitejs/plugin-react": ">=6 <7",
44
+ "typescript": ">=5.6 <6",
45
+ "vite": ">=8.2 <9",
46
+ "playwright": ">=1.60 <2",
47
+ "react": ">=19.2 <20",
48
+ "react-dom": ">=19.2 <20"
49
+ },
50
+ "peerDependenciesMeta": {
51
+ "typescript": {
52
+ "optional": true
53
+ },
54
+ "playwright": {
55
+ "optional": true
56
+ }
43
57
  },
44
58
  "publishConfig": {
45
59
  "access": "public"
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import{spawnSync}from"node:child_process";import{existsSync}from"node:fs";import{createRequire}from"node:module";import{resolve}from"node:path";import{pathToFileURL}from"node:url";import{loadConfig}from"../lib/config.mjs";import{parseFlagValue}from"../lib/env.mjs";import{REPO_ROOT}from"../lib/root.mjs";import{runCommand}from"../lib/run.mjs";import{EXTERNALS}from"./externals.mjs";const argv=process.argv.slice(2);const only=parseFlagValue(argv,"lineage");const config=await loadConfig();const lineages=only?config.lineages.filter(l=>l.name===only):config.lineages;if(only&&lineages.length===0)throw new Error(`[build] --lineage ${only} is not in the config's lineages`);const fromRepo=createRequire(resolve(REPO_ROOT,"package.json"));const importFromRepo=name=>import(pathToFileURL(fromRepo.resolve(name)).href);function check(label,status){if(status!==0){console.error(`[build] ${label} failed (exit ${status}).`);process.exit(status??1)}}if(!argv.includes("--no-typecheck")&&existsSync(resolve(REPO_ROOT,"tsconfig.build.json"))){console.log("[build] typecheck: tsc -p tsconfig.build.json --noEmit");const tsc=fromRepo.resolve("typescript/bin/tsc");check("typecheck",spawnSync(process.execPath,[tsc,"-p","tsconfig.build.json","--noEmit"],{cwd:REPO_ROOT,stdio:"inherit"}).status)}process.env.NODE_ENV="production";const{build}=await importFromRepo("vite");const react=(await importFromRepo("@vitejs/plugin-react")).default;const external=[...EXTERNALS,...config.shared];for(const lineage of lineages){const entry=resolve(REPO_ROOT,lineage.entry);if(!existsSync(entry))throw new Error(`[build] no barrel at ${lineage.entry} for lineage "${lineage.name}"`);console.log(`
3
+ [build] ${lineage.name}: ${lineage.entry} \u2192 ${lineage.dist}/index.js`);await build({configFile:false,mode:"production",root:REPO_ROOT,logLevel:"warn",plugins:[react()],define:{"process.env.NODE_ENV":JSON.stringify("production"),__OA_BUNDLE_NAME__:JSON.stringify(config.org)},resolve:{alias:{"@":resolve(REPO_ROOT,"src")}},optimizeDeps:{exclude:external},build:{lib:{entry,formats:["es"],fileName:()=>"index.js"},rollupOptions:{external},outDir:resolve(REPO_ROOT,lineage.dist),emptyOutDir:true,sourcemap:true}});check(`${lineage.name} contracts`,runCommand("contracts",["--entry",lineage.entry,"--out",`${lineage.dist}/contracts.json`]));check(`${lineage.name} knowledge`,runCommand("knowledge",["--bundle",lineage.name]))}console.log(`
4
+ [build] \u2705 ${lineages.map(l=>l.name).join(", ")}`);
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import{execSync,spawnSync}from"node:child_process";import{loadConfig}from"../lib/config.mjs";import{parseFlagValue,parseTargetEnv}from"../lib/env.mjs";import{REPO_ROOT}from"../lib/root.mjs";import{runCommand}from"../lib/run.mjs";const root=REPO_ROOT;const argv=process.argv.slice(2);const target=parseTargetEnv(argv);const targetFlag=target==="local"?[]:[`--${target}`];const skipBuild=argv.includes("--skip-build");const only=parseFlagValue(argv,"lineage");const{lineages}=await loadConfig();const selected=only?lineages.filter(l=>l.name===only):lineages;if(only&&selected.length===0)throw new Error(`--lineage ${only} is not in the config's lineages (${lineages.map(l=>l.name).join(", ")})`);const git=args=>execSync(`git ${args}`,{cwd:root,encoding:"utf8"}).trim();const version=process.env.VERSION||(git("status --porcelain")===""?`v_${git("rev-parse --short HEAD")}`:`v_${target}_${new Date().toISOString().replace(/[-:T]/g,"").slice(0,14)}`);function check(label,status){if(status!==0){console.error(`[deploy] ${label} failed (exit ${status}). Later lineages were not deployed.`);process.exit(status??1)}}function build(lineage){const{script,env={}}=lineage.build;console.log(`
3
- [deploy] ${lineage.name} build: npm run ${script}`);const r=spawnSync("npm",["run",script],{cwd:root,stdio:"inherit",env:{...process.env,VERSION:version,...env}});check(`${lineage.name} build`,r.status)}function step(lineage,command,args){console.log(`
2
+ import{execSync,spawnSync}from"node:child_process";import{loadConfig}from"../lib/config.mjs";import{parseFlagValue,parseTargetEnv}from"../lib/env.mjs";import{REPO_ROOT}from"../lib/root.mjs";import{runCommand}from"../lib/run.mjs";const root=REPO_ROOT;const argv=process.argv.slice(2);const target=parseTargetEnv(argv);const targetFlag=target==="local"?[]:[`--${target}`];const skipBuild=argv.includes("--skip-build");const only=parseFlagValue(argv,"lineage");const{lineages}=await loadConfig();const selected=only?lineages.filter(l=>l.name===only):lineages;if(only&&selected.length===0)throw new Error(`--lineage ${only} is not in the config's lineages (${lineages.map(l=>l.name).join(", ")})`);const git=args=>execSync(`git ${args}`,{cwd:root,encoding:"utf8"}).trim();const version=process.env.VERSION||(git("status --porcelain")===""?`v_${git("rev-parse --short HEAD")}`:`v_${target}_${new Date().toISOString().replace(/[-:T]/g,"").slice(0,14)}`);function check(label,status){if(status!==0){console.error(`[deploy] ${label} failed (exit ${status}). Later lineages were not deployed.`);process.exit(status??1)}}function build(lineage){if(lineage.build?.script){const{script,env={}}=lineage.build;console.log(`
3
+ [deploy] ${lineage.name} build: npm run ${script}`);const r=spawnSync("npm",["run",script],{cwd:root,stdio:"inherit",env:{...process.env,VERSION:version,...env}});check(`${lineage.name} build`,r.status);return}console.log(`
4
+ [deploy] ${lineage.name} build: engine build --lineage ${lineage.name}`);check(`${lineage.name} build`,runCommand("build",["--lineage",lineage.name],{VERSION:version}))}function step(lineage,command,args){console.log(`
4
5
  [deploy] ${lineage.name} ${command}: engine ${command} ${args.join(" ")}`);check(`${lineage.name} ${command}`,runCommand(command,args,{VERSION:version}))}console.log(`[deploy] target=${target} version=${version} lineages=${selected.map(l=>l.name).join(", ")}`);for(const lineage of selected){if(!skipBuild)build(lineage);const bundle=lineage.name==="main"?[]:["--bundle",lineage.name];step(lineage,"upload",[...targetFlag,...bundle]);step(lineage,"register",[...targetFlag,...bundle]);step(lineage,"cutover",[...targetFlag,"--version",version,...bundle])}console.log(`
5
6
  [deploy] \u2705 ${selected.map(l=>l.name).join(", ")} live at ${version} on ${target}.`);
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import{parseFlagValue}from"../lib/env.mjs";import{createHarnessServer}from"../harness/server.mjs";import{HARNESS_PATH}from"../harness/plugin.mjs";const port=Number(parseFlagValue(process.argv.slice(2),"port")??5174);const{origin}=await createHarnessServer({port,open:HARNESS_PATH});console.log(`[harness] ${origin}${HARNESS_PATH}`);
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import{mkdir,rm,writeFile}from"node:fs/promises";import{createRequire}from"node:module";import{cpus}from"node:os";import{join,relative,resolve}from"node:path";import{pathToFileURL}from"node:url";import{loadConfig}from"../lib/config.mjs";import{parseFlagValue}from"../lib/env.mjs";import{REPO_ROOT}from"../lib/root.mjs";import{newDeterministicContext,prepare,settle}from"../harness/determinism.mjs";import{HARNESS_PATH}from"../harness/plugin.mjs";import{createHarnessServer}from"../harness/server.mjs";const argv=process.argv.slice(2);const{harness}=await loadConfig();const OUTPUT=resolve(REPO_ROOT,harness.output);const only=parseFlagValue(argv,"only");const[shardIndex,shardTotal]=(parseFlagValue(argv,"shard")??"1/1").split("/").map(Number);if(!(shardTotal>=1&&shardIndex>=1&&shardIndex<=shardTotal))throw new Error("[test-visual] --shard expects <i>/<n>");const fromRepo=createRequire(resolve(REPO_ROOT,"package.json"));const playwright=await import(pathToFileURL(fromRepo.resolve("playwright")).href);const chromium=playwright.chromium??playwright.default.chromium;const skipped=[];const{server,origin}=await createHarnessServer();let exitCode=0;try{const list=await listTargets(server);const targets=list.filter(t=>(!only||t.label.startsWith(`${only} `))&&t.index%shardTotal===shardIndex-1);await rm(OUTPUT,{recursive:true,force:true});await mkdir(OUTPUT,{recursive:true});console.log(`[test-visual] ${targets.length} target(s) (of ${list.length}) \xD7 ${harness.widths.length} width(s) + a ${harness.container}px container${skipped.length?`; ${skipped.length} public-page scenario(s) skipped (R6)`:""}`);const browser=await chromium.launch();{const warm=await browser.newPage();for(let i=0;i<3;i++){await warm.goto(`${origin}${HARNESS_PATH}`,{waitUntil:"load",timeout:12e4}).catch(()=>{});await warm.waitForFunction(()=>window.__ENGINE_HARNESS_READY===true,null,{timeout:12e4}).catch(()=>{});await new Promise(r=>setTimeout(r,1500))}await warm.close()}const concurrency=Math.max(1,Number(process.env.ENGINE_VISUAL_CONCURRENCY)||Math.min(6,Math.max(2,cpus().length-1)));const reports=new Array(targets.length);let next=0;await Promise.all(Array.from({length:Math.min(concurrency,targets.length)},async()=>{while(next<targets.length){const i=next++;const t=targets[i];const renders=[...harness.widths.map(w=>({width:w,container:null})),{width:Math.max(...harness.widths),container:harness.container}];const report={target:t.label,kind:t.kind,...t.designIntent?{designIntent:t.designIntent}:{},widths:[]};for(const r of renders)report.widths.push(await renderOnce(browser,t,r));reports[i]=report;const bad=report.widths.filter(w=>w.errors.length);const over=report.widths.filter(w=>w.overflow);console.log(` ${bad.length?"\u2717":over.length?"\u25B3":"\u2713"} ${t.label}${bad.length?` \u2014 ${bad[0].errors[0].split("\n")[0].slice(0,160)}`:""}${over.length?` (overflow at ${over.map(w=>w.container?`${w.container}px container`:`${w.width}px`).join(", ")})`:""}`)}}));await browser.close();const failed=reports.filter(r=>r.widths.some(w=>w.errors.length));const overflowing=reports.filter(r=>r.widths.some(w=>w.overflow));await writeFile(join(OUTPUT,"report.json"),JSON.stringify({widths:harness.widths,container:harness.container,reports,skipped},null,2)+"\n");console.log(`[test-visual] ${reports.length-failed.length}/${reports.length} rendered clean; ${overflowing.length} overflow; report \u2192 ${relative(REPO_ROOT,join(OUTPUT,"report.json"))}`);if(failed.length||harness.overflow==="error"&&overflowing.length)exitCode=1}finally{await server.close()}process.exit(exitCode);async function listTargets(vite){const{readdir}=await import("node:fs/promises");const walk=async dir=>{const out=[];for(const e of await readdir(dir,{withFileTypes:true}).catch(()=>[])){const p=join(dir,e.name);if(e.isDirectory())out.push(...await walk(p));else out.push(p)}return out};const targets=[];const fixtureFiles=(await walk(resolve(REPO_ROOT,"src/components"))).filter(f=>/\.fixtures\.tsx?$/.test(f)).sort();for(const file of fixtureFiles){const name=file.split("/").pop().replace(/\.fixtures\.tsx?$/,"");const mod=await vite.ssrLoadModule(file);for(const s of mod.scenarios??[]){if(s.surface==="public-page"){skipped.push(`${name} \xB7 ${s.name}`);continue}targets.push({kind:"fixture",label:`${name} \xB7 ${s.name}`,query:`fixture=${encodeURIComponent(name)}&scenario=${encodeURIComponent(s.name)}`,designIntent:s.designIntent})}}const pageFiles=[...(await walk(resolve(REPO_ROOT,"src/pages"))).filter(f=>f.endsWith("/page.json")&&f.split("/").length-resolve(REPO_ROOT,"src/pages").split("/").length===2),...(await walk(resolve(REPO_ROOT,"src/project_templates"))).filter(f=>/\/pages\/[^/]+\/page\.json$/.test(f))].sort();for(const file of pageFiles){const m=file.match(/\/project_templates\/([^/]+)\/pages\/([^/]+)\/page\.json$/)??file.match(/\/pages\/([^/]+)\/page\.json$/);const key=m.slice(1).join("/");targets.push({kind:"page",label:`page ${key}`,query:`page=${encodeURIComponent(key)}`})}return targets.map((t,index)=>({...t,index}))}async function renderOnce(browser,target,{width,container}){const context=await newDeterministicContext(browser,{width,height:900});const page=await context.newPage();await prepare(page);const consoleErrors=[];const pageErrors=[];page.on("console",m=>m.type()==="error"&&consoleErrors.push(m.text()));page.on("pageerror",e=>pageErrors.push(e.stack||e.message));const url=`${origin}${HARNESS_PATH}?${target.query}${container?`&container=${container}`:""}`;const result={width,...container?{container}:{},errors:[],diagnostics:[],overflow:false};try{await page.goto(url,{waitUntil:"load",timeout:3e4});await settle(page,{timeout:15e3});const harnessError=await page.evaluate(()=>window.__ENGINE_HARNESS_ERROR??null);if(harnessError)result.errors.push(harnessError);result.diagnostics=await page.evaluate(()=>window.__ENGINE_HARNESS_DIAGNOSTICS??[]);result.overflow=await page.evaluate(inContainer=>{const el=inContainer?document.querySelector("[data-engine-container]"):document.scrollingElement;return!!el&&el.scrollWidth>el.clientWidth+1},Boolean(container));result.aria=await page.locator("#root").ariaSnapshot().catch(()=>null)}catch(err){result.errors.push(err instanceof Error?err.message:String(err))}const dir=join(OUTPUT,target.kind,target.label.replace(/[^A-Za-z0-9._-]+/g,"_"));await mkdir(dir,{recursive:true});const shot=join(dir,`${container?`c${container}`:width}.png`);await page.screenshot({path:shot,fullPage:true}).then(()=>result.screenshot=relative(REPO_ROOT,shot),()=>{});const attributeWarning=/Unknown event handler property|React does not recognize the|Invalid DOM property|Invalid value for prop|for a non-boolean attribute/;result.warnings=consoleErrors.filter(m=>attributeWarning.test(m));result.errors.push(...pageErrors,...consoleErrors.filter(m=>!attributeWarning.test(m)));for(const d of result.diagnostics)if(d.severity==="error")result.errors.push(`${d.code}: ${d.message}`);await context.close();return result}
@@ -0,0 +1,2 @@
1
+ import{Fragment,jsx,jsxs}from"react/jsx-runtime";import"@peripheries/renderer/styles.css";import{pageRuntimeFromValues,projectCellActions}from"@peripheries/renderer/core";import{PageRenderer}from"@peripheries/renderer/react";import{getStackStore}from"@peripheries/renderer/runtime";import{FIXTURE_BASE_PROPS}from"@peripheries/renderer/testing";import{Component,isValidElement,useEffect,useMemo}from"react";import{createRoot}from"react-dom/client";const fixtureName=path=>path.split("/").pop().replace(/\.fixtures\.tsx?$/,"");const pageKey=path=>{const m=path.match(/\/project_templates\/([^/]+)\/pages\/([^/]+)\/page\.json$/)??path.match(/\/pages\/([^/]+)\/page\.json$/);return m?m.slice(1).join("/"):path};function isJson(v){if(v===null||["string","number","boolean"].includes(typeof v))return true;if(Array.isArray(v))return v.every(isJson);if(typeof v!=="object"||isValidElement(v))return false;const proto=Object.getPrototypeOf(v);return(proto===Object.prototype||proto===null)&&Object.values(v).every(isJson)}class Boundary extends Component{state={error:null};static getDerivedStateFromError(error){return{error}}componentDidCatch(error,info){window.__ENGINE_HARNESS_ERROR=`${error.message}
2
+ ${info.componentStack??""}`}render(){return this.state.error?jsx("pre",{role:"alert",children:this.state.error.message}):this.props.children}}function Ready({children}){useEffect(()=>{const id=requestAnimationFrame(()=>requestAnimationFrame(()=>window.__ENGINE_HARNESS_READY=true));return()=>cancelAnimationFrame(id)},[]);return jsx(Fragment,{children})}function RenderedPage({row,values,actions,baseProps,components,Wrapper,statusOverride}){const runtime=useMemo(()=>{const rt=pageRuntimeFromValues(row,{values});if(statusOverride&&(statusOverride.isLoading||statusOverride.error)){const error=statusOverride.error?new Error(statusOverride.error):null;for(const cell of rt.cells)rt.cellData[cell.id]??={static:{},declared:{}};for(const own of Object.values(rt.cellData)){const keys=Object.keys(own.static).length?Object.keys(own.static):["fixture"];for(const k of keys)own.static[k]={data:void 0,isLoading:Boolean(statusOverride.isLoading),error}}}return rt},[row,values,statusOverride]);const bundles=useMemo(()=>{const ThemeProvider=Wrapper?({children})=>jsx(Wrapper,{baseProps,children}):void 0;const bundle={id:"harness",label:"harness",components,contracts:{},...ThemeProvider?{ThemeProvider}:{}};return{scope:[bundle],forCell:()=>null}},[components,Wrapper,baseProps]);const environment=useMemo(()=>{const pageActions=Object.fromEntries(Object.keys(actions??{}).map(k=>[k,async input=>(console.info(`[harness] action ${k}`,input),{ok:true})]));window.__ENGINE_HARNESS_DIAGNOSTICS=[];return{core:{},navigate:()=>{},store:getStackStore(),actionsFor:cell=>cell.actions?projectCellActions(cell.actions,pageActions):Object.keys(pageActions).length?pageActions:void 0,onCellError:(cellId,error)=>console.error(`[harness] cell "${cellId}" failed:`,error),onDiagnostic:d=>{window.__ENGINE_HARNESS_DIAGNOSTICS.push({code:d.code,severity:d.severity,message:d.message,...d.cellId?{cellId:d.cellId}:{}});if(d.severity==="error")console.error(`[harness] ${d.code}: ${d.message}`)}}},[actions]);return jsx(PageRenderer,{runtime,bundles,params:{},baseProps,context:{environment:"harness"},environment})}function pageRow(id,json){return{id,name:json.name,org_id:null,project_id:null,process_id:null,graph_node_id:null,page_type:json.page_type??null,status:"published",is_template:json.is_template??false,content:json.content??{cells:[]},layout:json.layout??{type:"stack",gap:16},metadata:json.metadata??null}}function Index({fixtures,pages}){useEffect(()=>void(window.__ENGINE_HARNESS_READY=true),[]);const link=q=>`?${q}`;return jsxs("main",{style:{fontFamily:"system-ui, sans-serif",padding:24,maxWidth:960},children:[jsx("h1",{children:"Harness"}),jsx("h2",{children:"Fixtures"}),fixtures.map(([name,scenarios])=>jsxs("section",{children:[jsx("h3",{children:name}),jsx("ul",{children:scenarios.map(s=>jsxs("li",{children:[jsx("a",{href:link(`fixture=${encodeURIComponent(name)}&scenario=${encodeURIComponent(s.name)}`),children:s.name})," \xB7 ",jsx("a",{href:link(`fixture=${encodeURIComponent(name)}&scenario=${encodeURIComponent(s.name)}&container=320`),children:"in 320px"})]},s.name))})]},name)),jsx("h2",{children:"Pages"}),jsx("ul",{children:pages.map(p=>jsx("li",{children:jsx("a",{href:link(`page=${encodeURIComponent(p)}`),children:p})},p))})]})}function mountHarness(sources){const q=new URLSearchParams(window.location.search);const components=Object.assign({},...Object.values(sources.barrels));const fixtures=Object.entries(sources.fixtures).map(([path,mod])=>[fixtureName(path),[...mod.scenarios??[]]]).sort(([a],[b])=>a.localeCompare(b));const pages=Object.fromEntries(Object.entries(sources.pages).map(([path,json])=>[pageKey(path),{json,mock:sources.mocks[path.replace(/page\.json$/,"mock.json")]}]));let body;const fixture=q.get("fixture");const page=q.get("page");if(fixture){const scenario=fixtures.find(([n])=>n===fixture)?.[1].find(s=>s.name===q.get("scenario"));if(!scenario){window.__ENGINE_HARNESS_ERROR=`no scenario "${q.get("scenario")}" for ${fixture}`;body=null}else if(!isJson(scenario.props??{})){const Direct=components[fixture];if(!Direct)window.__ENGINE_HARNESS_ERROR=`${fixture} is not exported from any barrel`;const baseProps={...FIXTURE_BASE_PROPS,...scenario.baseProps};const element=Direct?jsx(Direct,{...scenario.props,baseProps}):null;body=sources.Wrapper?jsx(sources.Wrapper,{baseProps,children:element}):element}else{const cell={id:"fixture",component:fixture,props:scenario.props??{},...scenario.data?{data:{static:scenario.data}}:{},...scenario.selections?{selections:scenario.selections}:{},...scenario.viewState?{viewState:scenario.viewState}:{},...scenario.live?{live:scenario.live}:{}};body=jsx(RenderedPage,{row:pageRow(`${fixture}/${scenario.name}`,{name:`${fixture} \xB7 ${scenario.name}`,content:{cells:[cell]}}),values:{},actions:scenario.actions,baseProps:{...FIXTURE_BASE_PROPS,...scenario.baseProps},components,Wrapper:sources.Wrapper,statusOverride:scenario.status})}}else if(page){const entry=pages[page];if(!entry){window.__ENGINE_HARNESS_ERROR=`no page "${page}"`;body=null}else{const values={...entry.mock?.data};body=jsx(RenderedPage,{row:pageRow(page,entry.json),values,actions:entry.mock?.actions,baseProps:{...FIXTURE_BASE_PROPS,...entry.mock?.baseProps},components,Wrapper:sources.Wrapper})}}else{body=jsx(Index,{fixtures,pages:Object.keys(pages).sort()})}const container=Number(q.get("container"))||null;const framed=container?jsx("div",{"data-engine-container":"",style:{width:container,containerType:"inline-size",outline:"1px dashed #bbb"},children:body}):body;createRoot(document.getElementById("root")).render(jsx(Boundary,{children:jsx(Ready,{children:framed})}))}export{mountHarness};
@@ -0,0 +1,10 @@
1
+ const FROZEN_NOW=Date.UTC(2026,0,15,12,0,0);const PAGE_VIEWPORTS=[{width:1280,height:900},{width:390,height:844}];function newDeterministicContext(browser,viewport){return browser.newContext({viewport:{width:viewport.width,height:viewport.height},reducedMotion:"reduce",timezoneId:"UTC",locale:"en-CA"})}async function freezeClock(page,at=FROZEN_NOW){await page.addInitScript(frozen=>{const RealDate=Date;Date=class extends RealDate{constructor(...args){if(args.length===0)super(frozen);else super(...args)}};Date.now=RealDate.now.bind(RealDate);Date.parse=RealDate.parse.bind(RealDate);Date.UTC=RealDate.UTC.bind(RealDate)},at)}const KILL_ANIMATIONS_CSS=`
2
+ *, *::before, *::after {
3
+ animation-delay: -1ms !important;
4
+ animation-duration: 1ms !important;
5
+ animation-iteration-count: 1 !important;
6
+ transition-delay: -1ms !important;
7
+ transition-duration: 1ms !important;
8
+ scroll-behavior: auto !important;
9
+ }
10
+ `;async function killAnimations(page){await page.addInitScript(css=>{const install=()=>{const style=globalThis.document.createElement("style");style.id="engine-harness-kill-animations";style.appendChild(globalThis.document.createTextNode(css));globalThis.document.head.appendChild(style)};if(globalThis.document.head)install();else globalThis.document.addEventListener("DOMContentLoaded",install)},KILL_ANIMATIONS_CSS)}async function prepare(page){await freezeClock(page);await killAnimations(page)}async function settle(page,{timeout=2e4,readyFlag="__ENGINE_HARNESS_READY",errorFlag="__ENGINE_HARNESS_ERROR",paintStable=false,paintAttempts=6,paintGapMs=250}={}){await page.waitForFunction(([ready,error])=>globalThis[ready]===true||typeof globalThis[error]==="string",[readyFlag,errorFlag],{timeout});await page.evaluate(()=>globalThis.document.fonts?.ready??Promise.resolve());const geometry=await page.evaluate(([maxMs])=>{const measure=()=>{const parts=[];for(const el of globalThis.document.querySelectorAll("[data-cell-id], svg, canvas, img")){const r=el.getBoundingClientRect();parts.push(`${Math.round(r.x)},${Math.round(r.y)},${Math.round(r.width)},${Math.round(r.height)}`)}parts.push(String(Math.round(globalThis.document.body.scrollHeight)));return parts.join("|")};return new Promise(resolveOuter=>{const started=Date.now();let previous2=null;let samples=0;const tick=()=>{const current=measure();samples+=1;if(current===previous2){resolveOuter({settled:true,samples});return}if(Date.now()-started>maxMs){resolveOuter({settled:false,samples});return}previous2=current;globalThis.requestAnimationFrame(tick)};globalThis.requestAnimationFrame(tick)})},[5e3]);if(!paintStable)return geometry;let previous=null;let captures=0;for(let attempt=0;attempt<paintAttempts;attempt+=1){const shot=await page.screenshot({fullPage:true});captures+=1;if(previous&&Buffer.compare(previous,shot)===0){return{...geometry,paintStable:true,captures}}previous=shot;if(attempt<paintAttempts-1)await page.waitForTimeout(paintGapMs)}return{...geometry,paintStable:false,captures}}export{FROZEN_NOW,KILL_ANIMATIONS_CSS,PAGE_VIEWPORTS,freezeClock,killAnimations,newDeterministicContext,prepare,settle};
@@ -0,0 +1,13 @@
1
+ import{existsSync}from"node:fs";import{dirname,resolve}from"node:path";import{fileURLToPath}from"node:url";const HERE=dirname(fileURLToPath(import.meta.url));const APP=["app.tsx","app.mjs"].map(f=>resolve(HERE,f)).find(f=>existsSync(f));const ENTRY="virtual:engine-harness/entry";const HARNESS_PATH="/__engine/harness.html";const HTML=`<!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Harness</title>
7
+ <style>html, body { margin: 0; padding: 0; background: #fff; }</style>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/@id/${ENTRY}"><\/script>
12
+ </body>
13
+ </html>`;function engineHarness({root,wrapper=null}){const[wrapperFile,wrapperExport="default"]=wrapper?wrapper.split("#"):[];const wrapperPath=wrapperFile?resolve(root,wrapperFile):null;return{name:"engine-harness",resolveId(id){return id===ENTRY?`\0${ENTRY}`:void 0},load(id){if(id!==`\0${ENTRY}`)return void 0;return[`import { mountHarness } from ${JSON.stringify("/@fs"+APP)};`,wrapperPath?`import { ${wrapperExport} as Wrapper } from ${JSON.stringify("/@fs"+wrapperPath)};`:"const Wrapper = undefined;",`const fixtures = import.meta.glob('/src/components/**/*.fixtures.{ts,tsx}', { eager: true });`,`const barrels = import.meta.glob(['/src/index.ts', '/src/index.*.ts', '!/src/index.*.test.ts'], { eager: true });`,`const pages = import.meta.glob(['/src/pages/*/page.json', '/src/project_templates/*/pages/*/page.json'], { eager: true, import: 'default' });`,`const mocks = import.meta.glob(['/src/pages/*/mock.json', '/src/project_templates/*/pages/*/mock.json'], { eager: true, import: 'default' });`,`mountHarness({ fixtures, barrels, pages, mocks, Wrapper });`].join("\n")},configureServer(server){server.middlewares.use(async(req,res,next)=>{if(!req.url||!req.url.startsWith(HARNESS_PATH))return next();const html=await server.transformIndexHtml(req.url,HTML);res.setHeader("content-type","text/html; charset=utf-8");res.end(html)})}}}export{HARNESS_PATH,engineHarness};
@@ -0,0 +1 @@
1
+ import{createRequire}from"node:module";import{dirname,resolve}from"node:path";import{fileURLToPath,pathToFileURL}from"node:url";import{loadConfig}from"../lib/config.mjs";import{REPO_ROOT}from"../lib/root.mjs";import{engineHarness}from"./plugin.mjs";const fromRepo=createRequire(resolve(REPO_ROOT,"package.json"));const importFromRepo=name=>import(pathToFileURL(fromRepo.resolve(name)).href);async function createHarnessServer({port=0,open=false}={}){const config=await loadConfig();const{createServer}=await importFromRepo("vite");const react=(await importFromRepo("@vitejs/plugin-react")).default;const server=await createServer({configFile:false,root:REPO_ROOT,logLevel:"warn",plugins:[react(),engineHarness({root:REPO_ROOT,wrapper:config.harness.wrapper})],define:{__OA_BUNDLE_NAME__:JSON.stringify(config.org)},resolve:{alias:{"@":resolve(REPO_ROOT,"src"),...Object.fromEntries(Object.entries(config.harness.alias).map(([k,v])=>[k,resolve(REPO_ROOT,v)]))}},server:{port,strictPort:Boolean(port),open,fs:{allow:[REPO_ROOT,dirname(fileURLToPath(import.meta.url))]}}});await server.listen();const address=server.httpServer?.address();if(!address||typeof address==="string")throw new Error("[harness] the Vite server did not bind a port");return{server,origin:`http://localhost:${address.port}`}}export{createHarnessServer};
package/src/index.d.mts CHANGED
@@ -14,6 +14,15 @@ export type LineageSpec =
14
14
  export type EngineConfig = {
15
15
  org: string;
16
16
  lineages?: LineageSpec[];
17
+ shared?: string[];
18
+ harness?: {
19
+ wrapper?: string;
20
+ widths?: number[];
21
+ container?: number;
22
+ overflow?: 'warn' | 'error';
23
+ output?: string;
24
+ alias?: Record<string, string>;
25
+ };
17
26
  checks?: {
18
27
  run?: string[];
19
28
  peerRepos?: string[];
@@ -27,7 +36,7 @@ export type Lineage = {
27
36
  entry: string;
28
37
  sourceRoot: string | null;
29
38
  dist: string;
30
- build: { script: string; env?: Record<string, string> };
39
+ build?: { script: string; env?: Record<string, string> };
31
40
  };
32
41
 
33
42
  export const DEFAULT_CHECKS: string[];
@@ -37,3 +46,17 @@ export function defineConfig(config: EngineConfig): EngineConfig;
37
46
  export function lineageDefaults(name: string, kind?: LineageKind): Lineage;
38
47
  export function loadConfig(): Promise<{ org: string; lineages: Lineage[]; checks: { run: string[]; peerRepos: string[]; gated: { projectTemplates: string[]; orgTemplates: string[] } } }>;
39
48
  export function lineageConfig(name: string): Promise<Lineage>;
49
+
50
+ export type Scenario<P extends Record<string, unknown> = Record<string, unknown>> = {
51
+ name: string;
52
+ surface?: 'cell' | 'whole-page' | 'public-page';
53
+ props: P;
54
+ baseProps?: Record<string, unknown>;
55
+ data?: Record<string, unknown>;
56
+ status?: { isLoading?: boolean; error?: string | null };
57
+ actions?: Record<string, unknown>;
58
+ selections?: Record<string, unknown>;
59
+ viewState?: Record<string, unknown>;
60
+ live?: Record<string, unknown>;
61
+ designIntent?: string;
62
+ };
@@ -1 +1 @@
1
- import{existsSync,readFileSync}from"node:fs";import{resolve}from"node:path";import{pathToFileURL}from"node:url";import{REPO_ROOT}from"./root.mjs";const MAIN_LINEAGE="main";const DEFAULT_CHECKS=["validate-externals","check-wired","check-routes","check-design-refs","check-skills","check-work-orders","check-cutover-ownership","check-fixtures","knowledge"];const PUBLIC_LINEAGE="public";const CONFIG_FILES=["engine.config.ts","engine.config.mts","engine.config.mjs","engine.config.js"];function defineConfig(config){return config}function lineageDefaults(name,kind){if(name===MAIN_LINEAGE){return{name,kind:"main",entry:"src/index.ts",sourceRoot:null,dist:"dist",build:{script:"build"}}}if(name===PUBLIC_LINEAGE){return{name,kind:"public",entry:"src/index.public.ts",sourceRoot:"components/public/",dist:"dist-public",build:{script:"build:public"}}}const k=kind??"page";return{name,kind:k,entry:`src/index.${name}.ts`,sourceRoot:k==="form"?`components/form/${name}/`:`components/project_page/${name}/`,dist:`dist-bundles/${name}`,build:{script:"build:bundle",env:{OA_PROJECT_BUNDLE:name}}}}function normalize(raw,origin){if(!raw||typeof raw!=="object")throw new Error(`[engine] ${origin} must export a config object`);if(typeof raw.org!=="string"||!raw.org)throw new Error(`[engine] ${origin}: \`org\` (the org slug) is required`);const list=raw.lineages??[MAIN_LINEAGE];const lineages=list.map(l=>{const spec=typeof l==="string"?{name:l}:l;if(!spec?.name)throw new Error(`[engine] ${origin}: every lineage needs a name`);return{...lineageDefaults(spec.name,spec.kind),...spec}});if(lineages[0]?.name!==MAIN_LINEAGE)throw new Error(`[engine] ${origin}: lineages must start with "main"`);const gated=raw.checks?.gated??{};const checks={run:raw.checks?.run??DEFAULT_CHECKS,peerRepos:raw.checks?.peerRepos??[],gated:{projectTemplates:gated.projectTemplates??[],orgTemplates:gated.orgTemplates??[]}};return{org:raw.org,lineages,checks}}let cached;async function loadConfig(){if(cached)return cached;const file=CONFIG_FILES.map(f=>resolve(REPO_ROOT,f)).find(f=>existsSync(f));if(file){const mod=await import(pathToFileURL(file).href);cached=normalize(mod.default??mod,file);return cached}const pkg=JSON.parse(readFileSync(resolve(REPO_ROOT,"package.json"),"utf8"));let org=pkg.oaBundle?.name;const orgJson=resolve(REPO_ROOT,"template/config/org.json");if(existsSync(orgJson))org=JSON.parse(readFileSync(orgJson,"utf8")).slug??org;const lineages=(pkg.oaBundle?.lineages??[MAIN_LINEAGE]).map(n=>n==="workbooks"?{name:n,kind:"form"}:n);cached=normalize({org,lineages},"package.json oaBundle");return cached}async function lineageConfig(name){const{lineages}=await loadConfig();return lineages.find(l=>l.name===name)??lineageDefaults(name)}export{DEFAULT_CHECKS,MAIN_LINEAGE,PUBLIC_LINEAGE,defineConfig,lineageConfig,lineageDefaults,loadConfig};
1
+ import{existsSync,readFileSync}from"node:fs";import{resolve}from"node:path";import{pathToFileURL}from"node:url";import{REPO_ROOT}from"./root.mjs";const MAIN_LINEAGE="main";const DEFAULT_CHECKS=["validate-externals","check-wired","check-routes","check-design-refs","check-skills","check-work-orders","check-cutover-ownership","check-fixtures","knowledge"];const PUBLIC_LINEAGE="public";const CONFIG_FILES=["engine.config.ts","engine.config.mts","engine.config.mjs","engine.config.js"];function defineConfig(config){return config}function lineageDefaults(name,kind){if(name===MAIN_LINEAGE){return{name,kind:"main",entry:"src/index.ts",sourceRoot:null,dist:"dist"}}if(name===PUBLIC_LINEAGE){return{name,kind:"public",entry:"src/index.public.ts",sourceRoot:"components/public/",dist:"dist-public"}}const k=kind??"page";return{name,kind:k,entry:`src/index.${name}.ts`,sourceRoot:k==="form"?`components/form/${name}/`:`components/project_page/${name}/`,dist:`dist-bundles/${name}`}}function normalize(raw,origin){if(!raw||typeof raw!=="object")throw new Error(`[engine] ${origin} must export a config object`);if(typeof raw.org!=="string"||!raw.org)throw new Error(`[engine] ${origin}: \`org\` (the org slug) is required`);const list=raw.lineages??[MAIN_LINEAGE];const lineages=list.map(l=>{const spec=typeof l==="string"?{name:l}:l;if(!spec?.name)throw new Error(`[engine] ${origin}: every lineage needs a name`);return{...lineageDefaults(spec.name,spec.kind),...spec}});if(lineages[0]?.name!==MAIN_LINEAGE)throw new Error(`[engine] ${origin}: lineages must start with "main"`);const gated=raw.checks?.gated??{};const checks={run:raw.checks?.run??DEFAULT_CHECKS,peerRepos:raw.checks?.peerRepos??[],gated:{projectTemplates:gated.projectTemplates??[],orgTemplates:gated.orgTemplates??[]}};const shared=raw.shared??[];const harness={wrapper:raw.harness?.wrapper??null,widths:raw.harness?.widths??[390,768,1280],container:raw.harness?.container??320,overflow:raw.harness?.overflow??"warn",output:raw.harness?.output??"test/visual/__output__",alias:raw.harness?.alias??{}};return{org:raw.org,lineages,checks,shared,harness}}let cached;async function loadConfig(){if(cached)return cached;const file=CONFIG_FILES.map(f=>resolve(REPO_ROOT,f)).find(f=>existsSync(f));if(file){const mod=await import(pathToFileURL(file).href);cached=normalize(mod.default??mod,file);return cached}const pkg=JSON.parse(readFileSync(resolve(REPO_ROOT,"package.json"),"utf8"));let org=pkg.oaBundle?.name;const orgJson=resolve(REPO_ROOT,"template/config/org.json");if(existsSync(orgJson))org=JSON.parse(readFileSync(orgJson,"utf8")).slug??org;const lineages=(pkg.oaBundle?.lineages??[MAIN_LINEAGE]).map(n=>n==="workbooks"?{name:n,kind:"form"}:n);cached=normalize({org,lineages},"package.json oaBundle");return cached}async function lineageConfig(name){const{lineages}=await loadConfig();return lineages.find(l=>l.name===name)??lineageDefaults(name)}export{DEFAULT_CHECKS,MAIN_LINEAGE,PUBLIC_LINEAGE,defineConfig,lineageConfig,lineageDefaults,loadConfig};
package/src/lib/run.mjs CHANGED
@@ -1 +1 @@
1
- import{spawnSync}from"node:child_process";import{existsSync}from"node:fs";import{dirname,resolve}from"node:path";import{fileURLToPath}from"node:url";import{REPO_ROOT}from"./root.mjs";const COMMANDS_DIR=resolve(dirname(fileURLToPath(import.meta.url)),"..","commands");const TSX=import.meta.resolve("tsx/esm");const COMMANDS={deploy:"deploy.mjs",check:"check.mjs",upload:"upload-bundle.mjs",register:"register-components.mjs",cutover:"cutover.mjs",knowledge:"emit-knowledge.mjs",contracts:"emit-contracts.mjs","bundle-size":"bundle-size.mjs","check-bundle-leak":"check-bundle-leak.mjs","validate-externals":"validate-externals.mjs","check-wired":"check-wired-bindings.mjs","check-routes":"check-template-routes.mjs","check-design-refs":"check-design-refs.mjs","check-skills":"check-skills.mjs","check-work-orders":"check-work-orders.mjs","check-cutover-ownership":"check-cutover-ownership.mjs","check-fixtures":"check-fixtures.mjs","check-props-schema":"check-props-schema.mjs"};function commandFile(name){if(!COMMANDS[name])return null;const file=resolve(COMMANDS_DIR,COMMANDS[name]);const ts=file.replace(/\.mjs$/,".mts");return existsSync(file)?file:existsSync(ts)?ts:null}function runCommand(name,args=[],env={}){const file=commandFile(name);if(!file)throw new Error(`[engine] unknown command "${name}"`);const r=spawnSync(process.execPath,["--import",TSX,file,...args],{cwd:REPO_ROOT,stdio:"inherit",env:{...process.env,ENGINE_ROOT:REPO_ROOT,...env}});return r.status??1}export{COMMANDS,commandFile,runCommand};
1
+ import{spawnSync}from"node:child_process";import{existsSync}from"node:fs";import{dirname,resolve}from"node:path";import{fileURLToPath}from"node:url";import{REPO_ROOT}from"./root.mjs";const COMMANDS_DIR=resolve(dirname(fileURLToPath(import.meta.url)),"..","commands");const TSX=import.meta.resolve("tsx/esm");const COMMANDS={build:"build.mjs",deploy:"deploy.mjs",check:"check.mjs",harness:"harness.mjs","test-visual":"test-visual.mjs",upload:"upload-bundle.mjs",register:"register-components.mjs",cutover:"cutover.mjs",knowledge:"emit-knowledge.mjs",contracts:"emit-contracts.mjs","bundle-size":"bundle-size.mjs","check-bundle-leak":"check-bundle-leak.mjs","validate-externals":"validate-externals.mjs","check-wired":"check-wired-bindings.mjs","check-routes":"check-template-routes.mjs","check-design-refs":"check-design-refs.mjs","check-skills":"check-skills.mjs","check-work-orders":"check-work-orders.mjs","check-cutover-ownership":"check-cutover-ownership.mjs","check-fixtures":"check-fixtures.mjs","check-props-schema":"check-props-schema.mjs"};function commandFile(name){if(!COMMANDS[name])return null;const file=resolve(COMMANDS_DIR,COMMANDS[name]);const ts=file.replace(/\.mjs$/,".mts");return existsSync(file)?file:existsSync(ts)?ts:null}function runCommand(name,args=[],env={}){const file=commandFile(name);if(!file)throw new Error(`[engine] unknown command "${name}"`);const r=spawnSync(process.execPath,["--import",TSX,file,...args],{cwd:REPO_ROOT,stdio:"inherit",env:{...process.env,ENGINE_ROOT:REPO_ROOT,...env}});return r.status??1}export{COMMANDS,commandFile,runCommand};