@peripheries/engine 0.9.0 → 0.10.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,2 +1,2 @@
|
|
|
1
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{buildAndServeHarness,createHarnessServer}from"../harness/server.mjs";const argv=process.argv.slice(2);const
|
|
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{buildAndServeHarness,createHarnessServer}from"../harness/server.mjs";import{selectChanged}from"../harness/changed.mjs";const argv=process.argv.slice(2);const config=await loadConfig();const{harness}=config;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 base=parseFlagValue(argv,"changed");const listOnly=argv.includes("--list");const{fixtureFiles,pageFiles}=await sourceFiles();let selection=null;if(base){const s=selection=await selectChanged({root:REPO_ROOT,base,config,fixtureFiles,pageFiles,pageKey});if(s.all)console.log(`[test-visual] --changed ${base}: running everything \u2014 ${s.all}`);else console.log(`[test-visual] --changed ${base}: ${s.changed.length} changed file(s) \u2192 ${s.fixtures.size} fixture file(s), ${s.pages.size} page(s)`);if(!s.all&&!s.fixtures.size&&!s.pages.size){console.log("[test-visual] nothing the changes affect renders here; nothing to do");if(!listOnly){await rm(OUTPUT,{recursive:true,force:true});await mkdir(OUTPUT,{recursive:true});await writeFile(join(OUTPUT,"report.json"),JSON.stringify({widths:harness.widths,container:harness.container,mode:"changed",base,selected:[],reports:[],skipped:[]},null,2)+"\n")}process.exit(0)}if(listOnly){if(!s.all){for(const[file,why]of s.fixtures)console.log(` fixture ${relative(REPO_ROOT,file)} \u2014 ${why}`);for(const[key,why]of s.pages)console.log(` page ${key} \u2014 ${why}`)}process.exit(0)}}const reasonOf=t=>!selection?void 0:selection.all?`everything: ${selection.all}`:t.kind==="page"?selection.pages.get(t.key):selection.fixtures.get(t.file);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}=await createHarnessServer();const served=await buildAndServeHarness();const origin=served.origin;const HARNESS_PATH=served.path;let exitCode=0;try{const list=await listTargets(server);const targets=list.filter(t=>(!only||t.label.startsWith(`${only} `))&&t.index%shardTotal===shardIndex-1&&(!selection||selection.all||reasonOf(t)));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 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));const changed=selection?{mode:"changed",base,selected:targets.map(t=>({target:t.label,reason:reasonOf(t)}))}:{};await writeFile(join(OUTPUT,"report.json"),JSON.stringify({widths:harness.widths,container:harness.container,...changed,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();await served.close()}process.exit(exitCode);async function sourceFiles(){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 fixtureFiles2=(await walk(resolve(REPO_ROOT,"src"))).filter(f=>/\.fixtures\.tsx?$/.test(f)&&!relative(REPO_ROOT,f).includes("node_modules/")).sort();const pageFiles2=[...(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();return{fixtureFiles:fixtureFiles2,pageFiles:pageFiles2}}function pageKey(file){const m=file.match(/\/project_templates\/([^/]+)\/pages\/([^/]+)\/page\.json$/)??file.match(/\/pages\/([^/]+)\/page\.json$/);return m.slice(1).join("/")}async function listTargets(vite){const targets=[];for(const file of fixtureFiles){const name=file.split("/").pop().replace(/\.fixtures\.tsx?$/,"");if(selection&&!selection.all&&!selection.fixtures.has(file))continue;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",file,label:`${name} \xB7 ${s.name}`,query:`fixture=${encodeURIComponent(name)}&scenario=${encodeURIComponent(s.name)}`,designIntent:s.designIntent})}}for(const file of pageFiles){const key=pageKey(file);targets.push({kind:"page",key,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??[]);const missing=await page.$$eval("[data-missing-component]",els=>els.map(e=>e.getAttribute("data-missing-component")));for(const name of new Set(missing))result.errors.push(`${name} isn't exported by any barrel or shared lineage`);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 @@
|
|
|
1
|
+
import{execFileSync}from"node:child_process";import{existsSync,readFileSync,readdirSync,statSync}from"node:fs";import{dirname,join,relative,resolve,sep}from"node:path";import{compilerOptions,loadTypeScript}from"../lib/shared-lineages.mjs";const SOURCE=/\.(?:[cm]?[jt]sx?)$/;const EXTENSIONS=[".ts",".tsx",".mts",".js",".jsx",".mjs"];const git=(root,args)=>execFileSync("git",["-C",root,...args],{encoding:"utf8",stdio:["ignore","pipe","pipe"]});const lines=s=>s.split("\n").map(l=>l.trim()).filter(Boolean);function changedFiles(root,base){try{git(root,["rev-parse","--verify","--quiet",`${base}^{commit}`]);git(root,["merge-base",base,"HEAD"])}catch{return{error:`can't resolve "${base}" here (a shallow clone? fetch it: git fetch origin develop, or check out with fetch-depth: 0)`}}const files=new Set([...lines(git(root,["diff","--name-only","--relative",`${base}...HEAD`])),...lines(git(root,["diff","--name-only","--relative","HEAD"])),...lines(git(root,["ls-files","--others","--exclude-standard"]))]);return{files:[...files].sort()}}function globalReason(path,{root,barrels}){const name=path.split("/").pop();if(name==="package.json"||/^(package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?)$/.test(name))return`${path} (dependencies)`;if(/^engine\.config\.[cm]?[jt]s$/.test(path))return`${path} (the engine config)`;if(/^tsconfig[^/]*\.json$/.test(path)||/^vite[^/]*\.[cm]?[jt]s$/.test(path))return`${path} (build config)`;if(path.startsWith(".github/"))return`${path} (CI)`;if(path.startsWith("test/harness/"))return`${path} (the harness)`;if(path.startsWith("src/theme/")||path.startsWith("tokens/")||path.includes("/tokens/"))return`${path} (theme or tokens)`;if(path.split("/").includes("_shared"))return`${path} (a _shared helper)`;if(barrels.has(path))return`${path} (a barrel)`;if(path.endsWith(".css")){const stem=join(root,path.slice(0,-".css".length));if(!EXTENSIONS.some(e=>existsSync(stem+e)))return`${path} (CSS that isn't one component's)`}if(path.startsWith("src/")&&(SOURCE.test(path)||path.endsWith(".css"))&&!existsSync(join(root,path)))return`${path} (a source file was deleted)`;return null}async function importGraph(root,config){const ts=await loadTypeScript();const options=ts?compilerOptions(ts,root):null;const aliases=[["@",resolve(root,"src")],...Object.entries(config.harness.alias??{}).map(([k,v])=>[k,resolve(root,v)])];const asFile=p=>{if(existsSync(p)&&statSync(p).isFile())return p;for(const e of EXTENSIONS)if(existsSync(p+e))return p+e;const bare=p.replace(/\.[cm]?jsx?$/,"");if(bare!==p){for(const e of EXTENSIONS)if(existsSync(bare+e))return bare+e}for(const e of EXTENSIONS)if(existsSync(join(p,"index"+e)))return join(p,"index"+e);return null};const resolveImport=(spec,from)=>{if(spec.startsWith("."))return asFile(resolve(dirname(from),spec));for(const[find,to]of aliases){if(spec===find)return asFile(to);if(find==="@"&&spec.startsWith("@/"))return asFile(join(to,spec.slice(2)))}if(!ts)return null;const r=ts.resolveModuleName(spec,from,options,ts.sys).resolvedModule;return r?.resolvedFileName&&!r.isExternalLibraryImport&&!r.resolvedFileName.includes(`${sep}node_modules${sep}`)?r.resolvedFileName:null};const specsOf=(file,text)=>{if(file.endsWith(".css"))return[...text.matchAll(/@import\s+(?:url\()?\s*['"]([^'"]+)['"]/g)].map(m=>m[1]);if(ts)return ts.preProcessFile(text,true,true).importedFiles.map(f=>f.fileName);return[...text.matchAll(/(?:\bfrom\s*|\bimport\s*\(?\s*)['"]([^'"]+)['"]/g)].map(m=>m[1])};const edges=new Map;const depsOf=file=>{if(!edges.has(file)){let text="";try{text=readFileSync(file,"utf8")}catch{}const deps=new Set;if(SOURCE.test(file)||file.endsWith(".css"))for(const s of specsOf(file,text)){const to=resolveImport(s,file);if(to&&to.startsWith(root+sep))deps.add(to)}edges.set(file,deps)}return edges.get(file)};const closure=file=>{const seen=new Set;const stack=[file];while(stack.length){const f=stack.pop();if(seen.has(f))continue;seen.add(f);for(const d of depsOf(f))stack.push(d)}return seen};return{closure,resolveImport}}function exportMap(files,resolveImport){const names=new Map;const stars=new Set;for(const file of files){const text=readFileSync(file,"utf8");for(const m of text.matchAll(/export\s*(?:type\s*)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g)){const to=resolveImport(m[2],file);if(!to)continue;for(const part of m[1].split(",")){const name=part.trim().split(/\s+as\s+/).pop()?.trim();if(name&&!names.has(name))names.set(name,to)}}for(const m of text.matchAll(/export\s+(?:default\s+)?(?:async\s+)?(?:function\*?|const|let|class)\s+([A-Za-z_$][\w$]*)/g))if(!names.has(m[1]))names.set(m[1],file);for(const m of text.matchAll(/export\s*\*\s*(?:as\s+\w+\s*)?from\s*['"]([^'"]+)['"]/g)){const to=resolveImport(m[1],file);if(to)stars.add(to)}}return{names,stars}}function componentsOf(json){const out=new Set;const visit=v=>{if(Array.isArray(v))v.forEach(visit);else if(v&&typeof v==="object"){if(typeof v.component==="string")out.add(v.component);Object.values(v).forEach(visit)}};visit(json);return out}async function selectChanged({root,base,config,fixtureFiles,pageFiles,pageKey}){const found=changedFiles(root,base);const result={base,changed:found.files??[],all:null,fixtures:new Map,pages:new Map};if(found.error)return{...result,all:found.error};const barrelFiles=[...new Set([...config.lineages.map(l=>l.entry),...existsSync(join(root,"src"))?readdirSync(join(root,"src")).filter(f=>/^index[^/]*\.[cm]?tsx?$/.test(f)&&!/\.test\./.test(f)).map(f=>`src/${f}`):[]])];const barrels=new Set(barrelFiles);for(const path of result.changed){const why=globalReason(path,{root,barrels});if(why)return{...result,all:why}}const graph=await importGraph(root,config);const changedAbs=new Set(result.changed.map(p=>resolve(root,p)));const hit=file=>{for(const f of graph.closure(file))if(changedAbs.has(f))return relative(root,f);return null};if(config.harness.wrapper){const wrapper=resolve(root,config.harness.wrapper.split("#")[0]);const why=hit(wrapper);if(why)return{...result,all:`${why} (the harness wrapper imports it)`}}const local=Object.values(config.harness.alias??{}).map(p=>resolve(root,p));const{names,stars}=exportMap([...barrelFiles.map(f=>resolve(root,f)),...local].filter(f=>existsSync(f)),graph.resolveImport);const componentHit=new Map;const componentChanged=name=>{if(!componentHit.has(name)){let why=null;if(names.has(name))why=hit(names.get(name));else for(const s of stars)if(why=hit(s))break;componentHit.set(name,why)}return componentHit.get(name)};for(const file of fixtureFiles){const own=hit(file);const name=file.split("/").pop().replace(/\.fixtures\.tsx?$/,"");const beside=EXTENSIONS.map(e=>join(dirname(file),name+e)).find(f=>existsSync(f));const why=own??componentChanged(name)??(beside&&!names.has(name)?hit(beside):null);if(why)result.fixtures.set(file,why===relative(root,file)?`its fixtures changed (${why})`:`${why} changed`)}for(const file of pageFiles){const key=pageKey(file);const own=["page.json","mock.json"].map(f=>relative(root,join(dirname(file),f))).find(p=>result.changed.includes(p));if(own){result.pages.set(key,`${own} changed`);continue}let json;try{json=JSON.parse(readFileSync(file,"utf8"))}catch{result.pages.set(key,"its page.json does not parse");continue}for(const name of componentsOf(json)){const why=componentChanged(name);if(why){result.pages.set(key,`names ${name} (${why} changed)`);break}}}return result}export{changedFiles,selectChanged};
|