@antprofuse/saddle-islands-tools 0.0.0-stage → 0.1.4
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 +15 -4
- package/tools/adapters/islands/trigger_source.py +53 -0
- package/tools/cli/backend.mjs +24 -0
- package/tools/cli/main.py +38 -0
- package/tools/cli/minifish.mjs +38 -0
- package/tools/cli/minifish.py +52 -0
- package/tools/core/package-identity.mjs +23 -0
- package/tools/core/package_identity.py +58 -0
- package/tools/core/semantic_identity.py +149 -0
- package/tools/core/test_semantic_identity.py +136 -0
- package/tools/docs/README.md +43 -0
- package/tools/docs/backend-testing.md +36 -0
- package/tools/docs/iteration.md +34 -0
- package/tools/docs/minifish-testing.md +32 -0
- package/tools/docs/semantic-identity.md +61 -0
- package/tools/modules/backend/actions.mjs +10 -0
- package/tools/modules/backend/assert.mjs +20 -0
- package/tools/modules/backend/context-receiver.py +30 -0
- package/tools/modules/backend/facility.test.mjs +44 -0
- package/tools/modules/backend/grouped_context.py +100 -0
- package/tools/modules/backend/grpc.mjs +57 -0
- package/tools/modules/backend/index.mjs +9 -0
- package/tools/modules/backend/input-tree.mjs +14 -0
- package/tools/modules/backend/logs.mjs +24 -0
- package/tools/modules/backend/logs.test.mjs +28 -0
- package/tools/modules/backend/metadata.mjs +66 -0
- package/tools/modules/backend/package-lock.json +362 -0
- package/tools/modules/backend/package.json +15 -0
- package/tools/modules/backend/paths.mjs +11 -0
- package/tools/modules/backend/product.mjs +48 -0
- package/tools/modules/backend/rejection-logs.mjs +33 -0
- package/tools/modules/backend/report.mjs +13 -0
- package/tools/modules/backend/report.test.mjs +13 -0
- package/tools/modules/backend/run.mjs +50 -0
- package/tools/modules/backend/util.mjs +24 -0
- package/tools/modules/minifish/checks/guard.test.mjs +13 -0
- package/tools/modules/minifish/checks/package.test.mjs +23 -0
- package/tools/modules/minifish/compiler/build-case.mjs +39 -0
- package/tools/modules/minifish/compiler/case-plan.mjs +175 -0
- package/tools/modules/minifish/compiler/config.mjs +29 -0
- package/tools/modules/minifish/compiler/observation-module-name.mjs +2 -0
- package/tools/modules/minifish/compiler/package-runner.mjs +43 -0
- package/tools/modules/minifish/compiler/semantic-module-gate.mjs +27 -0
- package/tools/modules/minifish/compiler/tool-identity.mjs +14 -0
- package/tools/modules/minifish/harness/batch_cases.py +91 -0
- package/tools/modules/minifish/harness/batch_plugin_groups.py +56 -0
- package/tools/modules/minifish/harness/batch_report.py +104 -0
- package/tools/modules/minifish/harness/browser.mjs +146 -0
- package/tools/modules/minifish/harness/build-plugin-artifact.mjs +25 -0
- package/tools/modules/minifish/harness/capture_contract.py +78 -0
- package/tools/modules/minifish/harness/carousel-observation.mjs +62 -0
- package/tools/modules/minifish/harness/carriers.mjs +49 -0
- package/tools/modules/minifish/harness/check-independent-batch.py +84 -0
- package/tools/modules/minifish/harness/check-runner-isolation.py +102 -0
- package/tools/modules/minifish/harness/component_runtime.py +125 -0
- package/tools/modules/minifish/harness/contract-value.mjs +32 -0
- package/tools/modules/minifish/harness/deferred-visual.mjs +21 -0
- package/tools/modules/minifish/harness/device-prepare.mjs +26 -0
- package/tools/modules/minifish/harness/execution-evidence.mjs +37 -0
- package/tools/modules/minifish/harness/foreground.mjs +20 -0
- package/tools/modules/minifish/harness/header-layout.mjs +47 -0
- package/tools/modules/minifish/harness/held-snapshot.mjs +67 -0
- package/tools/modules/minifish/harness/host-device-fixture.mjs +28 -0
- package/tools/modules/minifish/harness/host-language.mjs +73 -0
- package/tools/modules/minifish/harness/host-native-ui.mjs +39 -0
- package/tools/modules/minifish/harness/host-navigation.mjs +96 -0
- package/tools/modules/minifish/harness/image-loading.mjs +35 -0
- package/tools/modules/minifish/harness/independent-case-runner.mjs +175 -0
- package/tools/modules/minifish/harness/independent-visual.mjs +107 -0
- package/tools/modules/minifish/harness/inline-svg.mjs +32 -0
- package/tools/modules/minifish/harness/input-lifecycle-evidence.mjs +16 -0
- package/tools/modules/minifish/harness/jsapi.mjs +92 -0
- package/tools/modules/minifish/harness/loading-input.mjs +52 -0
- package/tools/modules/minifish/harness/loading-observation.mjs +25 -0
- package/tools/modules/minifish/harness/local-navigation-evidence.mjs +82 -0
- package/tools/modules/minifish/harness/miniapp-screenshot.mjs +38 -0
- package/tools/modules/minifish/harness/native-dom-sampling.mjs +40 -0
- package/tools/modules/minifish/harness/native-enabled.mjs +11 -0
- package/tools/modules/minifish/harness/native-rpc-failure.mjs +11 -0
- package/tools/modules/minifish/harness/native-state.mjs +14 -0
- package/tools/modules/minifish/harness/native-ui-contract.mjs +29 -0
- package/tools/modules/minifish/harness/navigation.mjs +19 -0
- package/tools/modules/minifish/harness/network_isolation.py +134 -0
- package/tools/modules/minifish/harness/painted-source-image.mjs +54 -0
- package/tools/modules/minifish/harness/playwright.mjs +1 -0
- package/tools/modules/minifish/harness/plugin-events.mjs +16 -0
- package/tools/modules/minifish/harness/plugin-package.mjs +23 -0
- package/tools/modules/minifish/harness/plugin_artifact.py +23 -0
- package/tools/modules/minifish/harness/product_identity.py +8 -0
- package/tools/modules/minifish/harness/project_config.py +14 -0
- package/tools/modules/minifish/harness/prototype-visual.mjs +123 -0
- package/tools/modules/minifish/harness/region-order.mjs +16 -0
- package/tools/modules/minifish/harness/renderer-image-evidence.mjs +32 -0
- package/tools/modules/minifish/harness/request-guard.mjs +65 -0
- package/tools/modules/minifish/harness/request-plan.mjs +124 -0
- package/tools/modules/minifish/harness/response-consumption.mjs +106 -0
- package/tools/modules/minifish/harness/retained-page-lifecycle.mjs +48 -0
- package/tools/modules/minifish/harness/rpc-wire.mjs +34 -0
- package/tools/modules/minifish/harness/run-batch.py +133 -0
- package/tools/modules/minifish/harness/run-platform.py +293 -0
- package/tools/modules/minifish/harness/runtime_isolation.py +71 -0
- package/tools/modules/minifish/harness/scroll-container-observation.mjs +40 -0
- package/tools/modules/minifish/harness/semantic-anchor.mjs +139 -0
- package/tools/modules/minifish/harness/semantic-observation.mjs +197 -0
- package/tools/modules/minifish/harness/skeleton-appearance.mjs +49 -0
- package/tools/modules/minifish/harness/target-actions.mjs +120 -0
- package/tools/modules/minifish/harness/test_batch_concurrency.py +125 -0
- package/tools/modules/minifish/harness/test_batch_plugin_groups.py +97 -0
- package/tools/modules/minifish/harness/test_batch_runner.py +107 -0
- package/tools/modules/minifish/harness/test_common_report.py +47 -0
- package/tools/modules/minifish/harness/test_held_capture_contract.py +50 -0
- package/tools/modules/minifish/harness/test_pty_tail.py +39 -0
- package/tools/modules/minifish/harness/test_runtime_dependencies.py +49 -0
- package/tools/modules/minifish/harness/zero-business-plan.mjs +19 -0
- package/tools/modules/minifish/identity.py +6 -0
- package/tools/modules/minifish/package-lock.json +74 -0
- package/tools/modules/minifish/package.json +10 -0
- package/tools/modules/minifish/sdk/package-lock.json +57745 -0
- package/tools/modules/minifish/sdk/package.json +13 -0
- package/tools/modules/workflow/diff_review.py +74 -0
- package/tools/modules/workflow/iteration.py +175 -0
- package/README.md +0 -3
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import {spawn} from 'node:child_process';
|
|
2
|
+
import {createServer} from 'node:net';
|
|
3
|
+
import {mkdir,open} from 'node:fs/promises';
|
|
4
|
+
import {resolve,relative} from 'node:path';
|
|
5
|
+
import {setTimeout as sleep} from 'node:timers/promises';
|
|
6
|
+
export {sleep};
|
|
7
|
+
export async function port() {
|
|
8
|
+
const s=createServer(); await new Promise((yes,no)=>{s.once('error',no);s.listen(0,'127.0.0.1',yes);});
|
|
9
|
+
const p=s.address().port; await new Promise(r=>s.close(r)); return p;
|
|
10
|
+
}
|
|
11
|
+
export async function processStart(binary,args,dir,env={}) {
|
|
12
|
+
await mkdir(dir,{recursive:true}); const log=await open(resolve(dir,'process.log'),'a',0o600);
|
|
13
|
+
const child=spawn(binary,args,{cwd:dir,env:{...process.env,...env},stdio:['ignore',log.fd,log.fd]});
|
|
14
|
+
const done=new Promise((yes,no)=>{child.once('error',no);child.once('exit',(code,signal)=>yes({code,signal}));});
|
|
15
|
+
done.catch(()=>{});
|
|
16
|
+
let closed=false;
|
|
17
|
+
return {child,done,async stop(){if(closed)return;closed=true;try{if(child.exitCode===null && child.signalCode===null){child.kill('SIGTERM');const cancel=new AbortController();let result;try{result=await Promise.race([done,sleep(12000,null,{signal:cancel.signal}).then(()=>null)]);}finally{cancel.abort();}if(!result){child.kill('SIGKILL');await done;throw Error('process required SIGKILL');}}}finally{await log.close();}}};
|
|
18
|
+
}
|
|
19
|
+
export async function command(binary,args,dir,env={}) {
|
|
20
|
+
const p=await processStart(binary,args,dir,env); const result=await p.done; await p.stop();
|
|
21
|
+
if(result.code!==0) throw Error(`command failed: ${binary} exit ${result.code}; see ${dir}/process.log`);
|
|
22
|
+
}
|
|
23
|
+
export async function until(fn,timeout=30000) { const end=Date.now()+timeout;let error;while(Date.now()<end){try{return await fn();}catch(e){error=e;await sleep(100);}}throw error ?? Error('readiness timeout'); }
|
|
24
|
+
export function externalOutput(project,out) { const p=resolve(out);const rel=relative(resolve(project),p);if(rel!=='..'&&!rel.startsWith('../')) throw Error('output must be outside source repository');return p; }
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {createBoundary} from '../harness/request-guard.mjs';
|
|
4
|
+
import {validateObservationModuleName} from '../compiler/observation-module-name.mjs';
|
|
5
|
+
import {validateObservationModule} from '../compiler/semantic-module-gate.mjs';
|
|
6
|
+
const policy={validate:r=>assert(r.id&&r.request&&r.outcome),validatePlan:()=>{},comparison:(operation,value)=>structuredClone(value),requiresConsumption:r=>!!r.requireConsumption,assertPacketCompletion:e=>assert.deepEqual(e.violations,[])};
|
|
7
|
+
const row=()=>({id:'alternate-request',times:1,request:{operation:'sample.service.read',params:{key:'alternate'}},outcome:{kind:'response',value:{value:1}},requireConsumption:true});
|
|
8
|
+
test('exact request, response delivery and callback consumption remain separate',async()=>{const r=row(),guard=createBoundary([r],policy);assert.deepEqual(await guard.receive(r.request.operation,r.request.params),r.outcome.value);assert.throws(()=>guard.assertComplete());guard.markDelivered(r.request.operation,r.request.params);assert.throws(()=>guard.assertComplete());guard.markConsumed(r.request.operation,r.request.params,{kind:'success'});guard.assertComplete();await guard.close();});
|
|
9
|
+
for(const [name,op,params] of [['wrong operation','other.service',{key:'alternate'}],['missing field','sample.service.read',{}],['extra field','sample.service.read',{key:'alternate',extra:true}],['wrong value','sample.service.read',{key:'wrong'}]])test(name+' stays a durable failure',async()=>{const guard=createBoundary([row()],policy);await assert.rejects(guard.receive(op,params));assert.throws(()=>guard.assertComplete());});
|
|
10
|
+
test('duplicate, absent request, and callback type mismatch are rejected',async()=>{const r=row(),guard=createBoundary([r],policy);assert.throws(()=>guard.assertComplete());await guard.receive(r.request.operation,r.request.params);await assert.rejects(guard.receive(r.request.operation,r.request.params));assert.throws(()=>guard.markConsumed(r.request.operation,r.request.params,{kind:'fail'}));assert.throws(()=>guard.assertComplete());});
|
|
11
|
+
test('explicit alternate observation set replaces region constants without widening AST capability',()=>{validateObservationModuleName('views/alternate.mjs',['views/alternate.mjs']);assert.throws(()=>validateObservationModuleName('regions/T1.mjs',['views/alternate.mjs']));assert.throws(()=>validateObservationModuleName('views/alternate.mjs',undefined));validateObservationModule("import assert from 'node:assert/strict';export const value=1;",['node:assert/strict']);for(const source of ["root.locator('x')","root['evaluate'](fn)","import('./hidden.mjs')","document.querySelector('x')","Function('return document')()","root[method]()","const {locator:read}=root;read('x')","import fs from 'node:fs';"])assert.throws(()=>validateObservationModule(source,['node:assert/strict']));});
|
|
12
|
+
|
|
13
|
+
test('adapter cannot remove explicit callback consumption or mutate exact counts',async()=>{const r=row(),guard=createBoundary([r],{...policy,validate:v=>{v.times=0;v.requireConsumption=false;},requiresConsumption:()=>false});await guard.receive(r.request.operation,r.request.params);guard.markDelivered(r.request.operation,r.request.params);assert.throws(()=>guard.assertComplete(),/callback/);guard.markConsumed(r.request.operation,r.request.params,{kind:'success'});guard.assertComplete();assert.equal(r.times,1);assert.equal(r.requireConsumption,true);assert.throws(()=>createBoundary([{...r,times:0}],policy),/Positive/);});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {mkdtemp,mkdir,writeFile,readFile,symlink,rm} from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {loadProject} from '../compiler/config.mjs';
|
|
7
|
+
import {packageRunner} from '../compiler/package-runner.mjs';
|
|
8
|
+
import {compileCase} from '../compiler/case-plan.mjs';
|
|
9
|
+
async function fixture(){
|
|
10
|
+
const root=await mkdtemp(path.join(os.tmpdir(),'alternate-tool-project-')),project=path.join(root,'alternate'),configFile=path.join(project,'config.json');
|
|
11
|
+
await mkdir(path.join(project,'harness/regions'),{recursive:true});await mkdir(path.join(project,'definitions'));await mkdir(path.join(project,'harness/plugin'));
|
|
12
|
+
const config={schemaVersion:1,definitions:'definitions',catalog:'catalog.json',compilerAdapter:'harness/compiler.mjs',adapterRoots:['harness'],observationRoot:'harness',observationModules:['regions/alternate.mjs'],observationImports:['node:assert/strict','@antprofuse/saddle-islands-tools/tools/modules/minifish/harness/semantic-anchor.mjs'],pageRoute:'pages/alternate/view',pluginSource:'harness/plugin',repositoryRoot:'.',runtimeAdapters:{'@minifish/project-runtime':'harness/runtime.mjs','@minifish/project-host':'harness/host.mjs'}};
|
|
13
|
+
await writeFile(configFile,JSON.stringify(config));
|
|
14
|
+
await writeFile(path.join(project,'harness/compiler.mjs'),'export function compile(){throw Error("synthetic compile adapter")}');
|
|
15
|
+
await writeFile(path.join(project,'harness/runtime.mjs'),'export const prepareHeldSnapshot=()=>{},independentBoundary=()=>{},assertNativeState=()=>{},projectAssert=()=>{},locateProject=()=>{},queryBoundaryIds=()=>{},validateReleasedPaymentRows=()=>{};');
|
|
16
|
+
await writeFile(path.join(project,'harness/host.mjs'),'export const hostPolicy={};');
|
|
17
|
+
await writeFile(path.join(project,'harness/regions/alternate.mjs'),"import {semanticLocator} from '@antprofuse/saddle-islands-tools/tools/modules/minifish/harness/semantic-anchor.mjs';export function locate(root,name){return semanticLocator(root,name)};export function assertTarget(){}");
|
|
18
|
+
return {root,project,configFile,config,loaded:await loadProject(project,configFile)};
|
|
19
|
+
}
|
|
20
|
+
test('alternate project static closure records tool/adapter separately and copies no unrelated region',async()=>{const f=await fixture();try{const output=path.join(f.root,'artifact'),records=await packageRunner(f.loaded,output,'regions/alternate.mjs');assert(records.some(r=>r.kind==='tool'&&r.path==='harness/semantic-anchor.mjs'));assert(records.some(r=>r.kind==='adapter'&&r.path==='harness/regions/alternate.mjs'));assert(records.every(r=>/^[a-f0-9]{64}$/.test(r.sha256)));assert(!records.some(r=>r.path.includes('T1')));assert((await readFile(path.join(output,'adapters/harness/regions/alternate.mjs'),'utf8')).includes('../../../tools/harness/semantic-anchor.mjs'));}finally{await rm(f.root,{recursive:true,force:true});}});
|
|
21
|
+
for(const [name,code] of [['unknown package',"import 'fs';"],['dependency traversal',"import '../../outside.mjs';"],['direct DOM',"export const read=root=>root.evaluate(()=>1);"],['dynamic import',"export const read=()=>import('./other.mjs');"]])test(name+' is rejected from the closed observation dependency graph',async()=>{const f=await fixture();try{await writeFile(path.join(f.project,'harness/regions/alternate.mjs'),code);await writeFile(path.join(f.project,'outside.mjs'),'export const leaked=true;');await assert.rejects(packageRunner(f.loaded,path.join(f.root,'artifact'),'regions/alternate.mjs'));}finally{await rm(f.root,{recursive:true,force:true});}});
|
|
22
|
+
test('symlink aliases, undeclared module, missing config, and package output are rejected',async()=>{const f=await fixture();try{const module=path.join(f.project,'harness/regions/alternate.mjs');await rm(module);await writeFile(path.join(f.project,'harness/other.mjs'),'export const safe=true;');await symlink('../other.mjs',module);await assert.rejects(packageRunner(f.loaded,path.join(f.root,'alias'),'regions/alternate.mjs'),/Symlink/);await assert.rejects(packageRunner(f.loaded,path.join(f.root,'unknown'),'regions/unknown.mjs'),/declared module/);await assert.rejects(loadProject(f.project,path.join(f.project,'missing.json')));await assert.rejects(packageRunner(f.loaded,path.join(f.project,'artifact'),'regions/alternate.mjs'),/outside/);}finally{await rm(f.root,{recursive:true,force:true});}});
|
|
23
|
+
test('generic composition preserves exact preparation and original ID mappings without project names',()=>{const noop=()=>{},policy={validateSemanticTargets:noop,supplementalDefinition:()=>null,officialTitle:()=>false,validatePreparationOverrides:noop,validateFixtureTypes:noop,validateSpecialBoundaries:noop,validateIndependentFixture:noop,validatePacketPlan:noop,validatePluginEvents:noop,validatePluginAssertionIdentity:noop,validateObservationModuleName:noop};const catalog={cases:[{id:'alternate / page / value / content',title:'value'}]},definition={caseId:catalog.cases[0].id,ancestors:['open'],basis:[{path:'alternate/source.yaml'}],preparation:{},assertions:[{id:'value',kind:'text',target:'alternate / page / value',expected:'42',count:1}]},fragment={id:'open',inputs:{query:{key:'42'}},boundaries:[],actions:[{kind:'open-page'}]};const plan=compileCase(catalog,definition,[fragment],policy);assert.deepEqual(plan.inputs,fragment.inputs);assert.deepEqual(plan.assertions,definition.assertions);assert.equal(plan.case.id,definition.caseId);assert.throws(()=>compileCase(catalog,{...definition,ancestors:['unknown']},[fragment],policy),/missing preparation/);});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {readFile,mkdir,writeFile,realpath} from 'node:fs/promises';
|
|
4
|
+
import {pathToFileURL} from 'node:url';
|
|
5
|
+
import {toolRoot,loadProject,checkOutput} from './config.mjs';
|
|
6
|
+
import {packageRunner} from './package-runner.mjs';
|
|
7
|
+
import {toolIdentity} from './tool-identity.mjs';
|
|
8
|
+
export async function buildCase(file,output,options){
|
|
9
|
+
const project=await loadProject(options.projectRoot,options.config),root=project.root;
|
|
10
|
+
const definitionRoot=await realpath(path.resolve(root,project.config.definitions)),source=await realpath(file);
|
|
11
|
+
assert(source.startsWith(definitionRoot+path.sep),'case must belong to definitions');
|
|
12
|
+
const fragments=[];
|
|
13
|
+
for(let dir=path.dirname(source);;dir=path.dirname(dir)){
|
|
14
|
+
try{fragments.unshift(JSON.parse(await readFile(path.join(dir,'prepare.json'),'utf8')));}catch(error){if(error.code!=='ENOENT')throw error;}
|
|
15
|
+
if(dir===definitionRoot)break;assert(dir.startsWith(definitionRoot+path.sep),'Preparation escaped definitions');
|
|
16
|
+
}
|
|
17
|
+
const definition=JSON.parse(await readFile(source,'utf8'));assert.deepEqual(definition.ancestors,fragments.map(f=>f.id),'declared preparation must match physical ancestry');
|
|
18
|
+
const catalog=JSON.parse(await readFile(path.resolve(root,project.config.catalog),'utf8'));
|
|
19
|
+
const adapter=await import(pathToFileURL(await project.resolveAdapter(project.config.compilerAdapter)).href);
|
|
20
|
+
assert.equal(typeof adapter.compile,'function','Explicit project compiler adapter required');
|
|
21
|
+
const plan=await adapter.compile({projectRoot:root,config:project.config,catalog,definition,fragments});
|
|
22
|
+
return buildPlanPackage(plan,output,options);
|
|
23
|
+
}
|
|
24
|
+
export async function buildPlanPackage(plan,output,options){
|
|
25
|
+
const project=await loadProject(options.projectRoot,options.config),identity=await toolIdentity(project.config.toolSource,project.config.toolSourceManifest?path.resolve(project.root,project.config.toolSourceManifest):undefined),destination=await checkOutput(output,project);await mkdir(destination,{recursive:true});
|
|
26
|
+
const files=await packageRunner(project,path.join(destination,'runner'),plan.observationModule);
|
|
27
|
+
const body=`// Generated from project definitions; do not edit.\nimport {test} from '@playwright/test';\nexport const plan = ${JSON.stringify(plan,null,2)};\ntest(plan.case.id,async({page},testInfo)=>{const {runMiniappCase}=await import('./runner/independent-case-runner.mjs');await runMiniappCase({page,plan,testInfo});});\n`;
|
|
28
|
+
const artifact=path.join(destination,'case.spec.mjs');await writeFile(artifact,body);
|
|
29
|
+
const lock=JSON.parse(await readFile(path.join(toolRoot,'package-lock.json'),'utf8')),version=lock.packages['node_modules/@playwright/test'].version;
|
|
30
|
+
const manifest={name:'independent-miniapp-case',private:true,type:'module',scripts:{test:'playwright test --config playwright.config.mjs'},devDependencies:{'@playwright/test':version}};
|
|
31
|
+
// Keep only the locked Playwright runtime closure, not compiler dependencies.
|
|
32
|
+
const packages=Object.fromEntries(Object.entries(lock.packages).filter(([name])=>name===''||['node_modules/@playwright/test','node_modules/playwright','node_modules/playwright-core','node_modules/fsevents'].includes(name)));
|
|
33
|
+
packages['']={name:manifest.name,devDependencies:manifest.devDependencies};
|
|
34
|
+
await writeFile(path.join(destination,'package.json'),JSON.stringify(manifest,null,2)+'\n');await writeFile(path.join(destination,'package-lock.json'),JSON.stringify({...lock,name:manifest.name,packages},null,2)+'\n');
|
|
35
|
+
await writeFile(path.join(destination,'playwright.config.mjs'),`import {defineConfig} from '@playwright/test';\nimport os from 'node:os';\nimport path from 'node:path';\nexport default defineConfig({testDir:'.',testMatch:'case.spec.mjs',workers:1,retries:0,timeout:45000,expect:{timeout:10000},outputDir:process.env.MINIFISH_TEST_REPORT_DIR??path.join(os.tmpdir(),'independent-miniapp-case',String(Date.now())),reporter:'list',use:{headless:true,viewport:{width:1400,height:1100},deviceScaleFactor:3,serviceWorkers:'block',screenshot:'off',trace:'off',video:'off'}});\n`);
|
|
36
|
+
const packageInfo=JSON.parse(await readFile(path.resolve(toolRoot,'../../../package.json'),'utf8'));
|
|
37
|
+
await writeFile(path.join(destination,'tool-input.json'),JSON.stringify({package:packageInfo.name,version:packageInfo.version,toolIdentity:identity,projectConfig:project.config,files},null,2)+'\n');
|
|
38
|
+
return {caseId:plan.case.id,artifact};
|
|
39
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {validateTargetActions} from '../harness/target-actions.mjs';
|
|
3
|
+
function jsonValue(value, path = '$') {
|
|
4
|
+
assert(value !== undefined, `${path}: undefined is not a fixed input`);
|
|
5
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
|
|
6
|
+
if (typeof value === 'number') { assert(Number.isFinite(value), `${path}: non-finite number`); return; }
|
|
7
|
+
assert(typeof value === 'object', `${path}: expected JSON data`);
|
|
8
|
+
assert(Array.isArray(value) || Object.getPrototypeOf(value) === Object.prototype, `${path}: non-JSON object`);
|
|
9
|
+
for (const [key, item] of Object.entries(value)) jsonValue(item, `${path}.${key}`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function unique(items, key, kind) {
|
|
13
|
+
const seen = new Set();
|
|
14
|
+
for (const item of items) {
|
|
15
|
+
assert(typeof item[key] === 'string' && item[key].trim(), `${kind}: missing ${key}`);
|
|
16
|
+
assert(!seen.has(item[key]), `${kind}: duplicate ${item[key]}`);
|
|
17
|
+
seen.add(item[key]);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Imports identity and source location only. No generated Islands steps are read. */
|
|
22
|
+
export function importCatalog(input) {
|
|
23
|
+
assert(Array.isArray(input.cases), 'official cases must be an array');
|
|
24
|
+
unique(input.cases, 'id', 'official catalog');
|
|
25
|
+
return { schemaVersion: 1, cases: input.cases.map(item => {
|
|
26
|
+
assert(item.title && item.formula && item.location, `incomplete catalog entry: ${item.id}`);
|
|
27
|
+
return { id: item.id, title: item.title, formula: item.formula,
|
|
28
|
+
category: item.category, domain: item.domain, location: structuredClone(item.location) };
|
|
29
|
+
}) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Explicitly ordered ancestor fragments, then local preparation; no other case assertions. */
|
|
33
|
+
export function compileCase(catalog, definition, fragments,policy) {
|
|
34
|
+
assert(policy&&typeof policy.validateSemanticTargets==='function','Explicit project plan policy required');
|
|
35
|
+
const {validateSemanticTargets,validateIndependentFixture,validatePacketPlan,validatePluginEvents,validatePluginAssertionIdentity,validateObservationModuleName}=policy;
|
|
36
|
+
jsonValue(definition);
|
|
37
|
+
validateSemanticTargets(definition);
|
|
38
|
+
jsonValue(fragments);
|
|
39
|
+
for(const fragment of fragments)validateSemanticTargets(fragment);
|
|
40
|
+
const supplementalDefinition=policy.supplementalDefinition(definition);
|
|
41
|
+
const supplemental=!!supplementalDefinition;
|
|
42
|
+
const merged = definition.sourceCaseIds !== undefined&&!supplemental;
|
|
43
|
+
let official, sourceCases;
|
|
44
|
+
if(supplemental){official=supplementalDefinition;sourceCases=[];}
|
|
45
|
+
else if (merged) {
|
|
46
|
+
assert(typeof definition.scope === 'string' && definition.scope.trim(), 'merged case requires a directory scope');
|
|
47
|
+
assert(typeof definition.caseId === 'string' && (definition.caseId === definition.scope || definition.caseId.startsWith(definition.scope + ' / ')), 'case identity must belong to its directory scope');
|
|
48
|
+
assert(typeof definition.mergeReason === 'string' && definition.mergeReason.trim(), 'explicit related-scenario merge reason required');
|
|
49
|
+
assert(Array.isArray(definition.sourceCaseIds) && definition.sourceCaseIds.length > 0, 'source identities required');
|
|
50
|
+
assert(new Set(definition.sourceCaseIds).size === definition.sourceCaseIds.length, 'duplicate source identity');
|
|
51
|
+
sourceCases = definition.sourceCaseIds.map(id => {
|
|
52
|
+
assert(typeof id === 'string' && id.startsWith(definition.scope + ' / '), `source outside directory scope: ${id}`);
|
|
53
|
+
const entry = catalog.cases.find(item => item.id === id);
|
|
54
|
+
assert(entry, `unknown official case: ${id}`);
|
|
55
|
+
return entry;
|
|
56
|
+
});
|
|
57
|
+
official = {id: definition.caseId, title: definition.title ?? definition.caseId, scope: definition.scope};
|
|
58
|
+
} else {
|
|
59
|
+
official = catalog.cases.find(item => item.id === definition.caseId);
|
|
60
|
+
assert(official, `unknown official case: ${definition.caseId}`);
|
|
61
|
+
sourceCases = [official];
|
|
62
|
+
}
|
|
63
|
+
const officialTitle=policy.officialTitle(definition,supplemental);
|
|
64
|
+
assert(Array.isArray(definition.ancestors), 'ancestors must be explicit');
|
|
65
|
+
unique(fragments, 'id', 'fragment');
|
|
66
|
+
assert(new Set(definition.ancestors).size === definition.ancestors.length, 'duplicate ancestor');
|
|
67
|
+
const byId = new Map(fragments.map(fragment => [fragment.id, fragment]));
|
|
68
|
+
const chain = definition.ancestors.map(id => {
|
|
69
|
+
assert(byId.has(id), `missing preparation fragment: ${id}`);
|
|
70
|
+
const fragment = structuredClone(byId.get(id));
|
|
71
|
+
if (fragment.variants && definition.preparationVariant !== undefined) {
|
|
72
|
+
assert(typeof definition.preparationVariant === 'string', 'explicit preparationVariant required');
|
|
73
|
+
const variant = fragment.variants[definition.preparationVariant];
|
|
74
|
+
assert(variant, `unknown preparationVariant: ${definition.preparationVariant}`);
|
|
75
|
+
delete fragment.variants;
|
|
76
|
+
fragment.inputs = {...fragment.inputs, ...variant.inputs};
|
|
77
|
+
fragment.boundaries = [...(fragment.boundaries ?? []), ...(variant.boundaries ?? [])];
|
|
78
|
+
fragment.actions = [...(fragment.actions ?? []), ...(variant.actions ?? [])];
|
|
79
|
+
}
|
|
80
|
+
return fragment;
|
|
81
|
+
});
|
|
82
|
+
chain.push({ id: '$case', ...definition.preparation });
|
|
83
|
+
const inputs = {}, origins = {}, boundaries = [], actions = [];
|
|
84
|
+
for (const fragment of chain) {
|
|
85
|
+
assert(!('assertions' in fragment), `parent assertions are not inherited: ${fragment.id}`);
|
|
86
|
+
for (const [name, value] of Object.entries(fragment.inputs ?? {})) {
|
|
87
|
+
assert(!(name in origins), `input conflict ${name}: ${origins[name]} / ${fragment.id}`);
|
|
88
|
+
inputs[name] = value;
|
|
89
|
+
origins[name] = fragment.id;
|
|
90
|
+
}
|
|
91
|
+
for (const boundary of fragment.boundaries ?? []) {
|
|
92
|
+
assert(boundary.request && typeof boundary.request === 'object', `missing request: ${boundary.id}`);
|
|
93
|
+
assert(boundary.outcome && ['response', 'error', 'hold'].includes(boundary.outcome.kind), `missing complete outcome: ${boundary.id}`);
|
|
94
|
+
assert(Object.hasOwn(boundary.outcome, 'value'), `missing outcome value: ${boundary.id}`);
|
|
95
|
+
assert(Number.isInteger(boundary.times) && boundary.times > 0, `invalid call count: ${boundary.id}`);
|
|
96
|
+
boundaries.push({ ...boundary, fragment: fragment.id });
|
|
97
|
+
}
|
|
98
|
+
actions.push(...(fragment.actions ?? []));
|
|
99
|
+
}
|
|
100
|
+
if(definition.preparationOverrides !== undefined){
|
|
101
|
+
policy.validatePreparationOverrides(definition);
|
|
102
|
+
const patch=definition.preparationOverrides;
|
|
103
|
+
assert(typeof patch.reason==='string' && patch.reason.trim(),'Source-based override reason required');
|
|
104
|
+
unique(patch.boundaries??[], 'id', 'override boundary');
|
|
105
|
+
for(const replacement of patch.boundaries??[]){
|
|
106
|
+
const at=boundaries.findIndex(b=>b.id===replacement.id);assert(at>=0,'Override must name an existing boundary');
|
|
107
|
+
assert(replacement.request && replacement.outcome && Number.isInteger(replacement.times),'Override requires complete request/outcome pair');
|
|
108
|
+
boundaries[at]={...replacement,fragment:'$case-override'};
|
|
109
|
+
}
|
|
110
|
+
for(const id of patch.omitBoundaryIds??[]){const at=boundaries.findIndex(b=>b.id===id);assert(at>=0,'Omitted boundary must exist');boundaries.splice(at,1);}
|
|
111
|
+
if(patch.query !== undefined)inputs.query=structuredClone(patch.query);
|
|
112
|
+
if(patch.actions !== undefined){assert(Array.isArray(patch.actions));actions.splice(0,actions.length,...patch.actions);}
|
|
113
|
+
}
|
|
114
|
+
if(supplemental||officialTitle){inputs.hostLanguage=definition.hostSystemPlan.outcome.value.language;origins.hostLanguage='$public-getSystemInfo-language-response';}
|
|
115
|
+
unique(boundaries, 'id', 'boundary');
|
|
116
|
+
for(const b of boundaries){assert(Number.isInteger(b.times)&&b.times>0,'Invalid override call count');assert(b.outcome && ['response','hold','error'].includes(b.outcome.kind) && Object.hasOwn(b.outcome,'value'),'Complete override outcome required');}
|
|
117
|
+
for(const b of boundaries)for(const id of b.after??[])assert(boundaries.some(r=>r.id===id),'Missing boundary dependency');
|
|
118
|
+
for(const a of actions)if(a.boundaryId)assert(boundaries.some(b=>b.id===a.boundaryId),'Preparation action references omitted boundary');
|
|
119
|
+
for(const boundary of boundaries)if(boundary.request.params!==undefined)validateIndependentFixture(boundary);
|
|
120
|
+
const activeDependencies=new Set(),checkedDependencies=new Set();
|
|
121
|
+
const checkDependencies=id=>{assert(!activeDependencies.has(id),'Cyclic boundary dependency');if(checkedDependencies.has(id))return;activeDependencies.add(id);for(const prior of boundaries.find(b=>b.id===id).after??[])checkDependencies(prior);activeDependencies.delete(id);checkedDependencies.add(id);};
|
|
122
|
+
for(const boundary of boundaries)checkDependencies(boundary.id);
|
|
123
|
+
validatePacketPlan(boundaries);
|
|
124
|
+
for(const a of actions)if(a.kind==='wait-packet-complete'){assert(Array.isArray(a.boundaryIds)&&a.boundaryIds.length,'Packet barrier IDs required');for(const id of a.boundaryIds)assert(boundaries.some(b=>b.id===id),'Unknown packet barrier boundary');}
|
|
125
|
+
validateTargetActions(definition.beforeTarget,definition.afterTarget,boundaries);
|
|
126
|
+
for(const a of definition.completion??[])if(a.kind==='wait-boundaries'){
|
|
127
|
+
assert(Array.isArray(a.boundaryIds)&&a.boundaryIds.length&&new Set(a.boundaryIds).size===a.boundaryIds.length,'Explicit unique completion request batch');
|
|
128
|
+
for(const id of a.boundaryIds)assert(boundaries.some(b=>b.id===id),'Undeclared completion boundary');
|
|
129
|
+
}
|
|
130
|
+
for(const a of definition.assertions??[])if(a.kind==='host-native-ui'){
|
|
131
|
+
assert(['alert','showLoading','showToast','addToHomeWithGuideDialog'].includes(a.api),'Finite official native UI API');
|
|
132
|
+
assert(['visible','hidden'].includes(a.state),'Explicit actual host state');
|
|
133
|
+
assert(['content','state','visual'].includes(a.observation),'Explicit source native responsibility');
|
|
134
|
+
if(a.state==='visible')assert(boundaries.some(b=>b.id===a.boundaryId&&b.execution==='official-ui'&&b.request.operation==='jsapi:'+a.api),'Visible host UI requires exact official request boundary');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
policy.validateSpecialBoundaries(definition,official,boundaries);
|
|
138
|
+
assert(Array.isArray(definition.assertions) && definition.assertions.length, 'case target assertions are required');
|
|
139
|
+
unique(definition.assertions, 'id', 'assertion');
|
|
140
|
+
if (merged) {
|
|
141
|
+
const mapped = new Set();
|
|
142
|
+
for (const item of [...definition.assertions, ...(definition.completion ?? [])]) {
|
|
143
|
+
assert(Array.isArray(item.sourceCaseIds) && item.sourceCaseIds.length, `source mapping required: ${item.id ?? item.kind}`);
|
|
144
|
+
for (const id of item.sourceCaseIds) {
|
|
145
|
+
assert(definition.sourceCaseIds.includes(id), `undeclared assertion source: ${id}`);
|
|
146
|
+
mapped.add(id);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
for (const id of definition.sourceCaseIds) assert(mapped.has(id), `source has no assertion mapping: ${id}`);
|
|
150
|
+
}
|
|
151
|
+
assert(Array.isArray(definition.basis) && definition.basis.length, 'Spec basis is required');
|
|
152
|
+
if(definition.assertions.some(a=>a.kind==='region'))assert(definition.observationModule,'region assertion requires observationModule');
|
|
153
|
+
policy.validateFixtureTypes(definition,boundaries);
|
|
154
|
+
if (definition.observationModule !== undefined) {
|
|
155
|
+
validateObservationModuleName(definition.observationModule);
|
|
156
|
+
}
|
|
157
|
+
if(definition.plugin){assert(typeof definition.plugin.provider==='string','Declared plugin provider required');validatePluginEvents(definition.plugin.events);}
|
|
158
|
+
const result = { ...(definition.plugin?{plugin:definition.plugin}:{}), ...(supplemental?{projectSupplemental:definition.projectSupplemental}:{}), ...((supplemental||officialTitle)?{hostSystemPlan:definition.hostSystemPlan}:{}), ...(definition.observationModule ? {observationModule:definition.observationModule} : {}), schemaVersion: 2, case: official, sourceCases,
|
|
159
|
+
...(merged ? {mergeReason: definition.mergeReason} : {}), basis: definition.basis, inputs,
|
|
160
|
+
boundaries, actions, assertions: definition.assertions,
|
|
161
|
+
beforeTarget: definition.beforeTarget ?? [], afterTarget: definition.afterTarget ?? [],
|
|
162
|
+
completion: definition.completion ?? [], resources: definition.resources ?? [],
|
|
163
|
+
provenance: { fragments: chain.map(item => item.id), inputOrigins: origins } };
|
|
164
|
+
validatePluginAssertionIdentity(result);
|
|
165
|
+
jsonValue(result);
|
|
166
|
+
return structuredClone(result);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** An independent data module. Actual platform execution belongs to the declared runner. */
|
|
170
|
+
export function renderCaseModule(plan,validateSemanticTargets) {
|
|
171
|
+
assert.equal(typeof validateSemanticTargets,'function','Explicit project source validator required');
|
|
172
|
+
jsonValue(plan);
|
|
173
|
+
validateSemanticTargets(plan);
|
|
174
|
+
return `// Generated from miniapp case definitions. Do not edit.\nexport default ${JSON.stringify(plan, null, 2)};\n`;
|
|
175
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {readFile,realpath} from 'node:fs/promises';
|
|
4
|
+
export const toolRoot=path.resolve(import.meta.dirname,'..');
|
|
5
|
+
export async function loadProject(projectRoot,configFile){
|
|
6
|
+
assert(projectRoot&&configFile,'Explicit project root and config required');
|
|
7
|
+
const root=await realpath(projectRoot),file=await realpath(configFile);
|
|
8
|
+
assert(file.startsWith(root+path.sep),'Config must belong to project');
|
|
9
|
+
const config=JSON.parse(await readFile(file,'utf8'));
|
|
10
|
+
assert.equal(config.schemaVersion,1,'Supported explicit config required');
|
|
11
|
+
for(const key of ['definitions','catalog','compilerAdapter','observationRoot','pageRoute','pluginSource','repositoryRoot'])assert(typeof config[key]==='string'&&config[key],'Missing project config: '+key);
|
|
12
|
+
assert(Array.isArray(config.adapterRoots)&&config.adapterRoots.length,'Explicit adapter roots required');
|
|
13
|
+
assert(Array.isArray(config.observationModules)&&config.observationModules.length,'Explicit observation modules required');
|
|
14
|
+
assert(Array.isArray(config.observationImports),'Explicit observation import capabilities required');
|
|
15
|
+
const adapterRoots=[];
|
|
16
|
+
for(const relative of config.adapterRoots){const declared=path.resolve(root,relative),actual=await realpath(declared);assert.equal(actual,declared,'Symlink adapter root forbidden');assert(actual.startsWith(root+path.sep),'Adapter root must belong to project');adapterRoots.push(actual);}
|
|
17
|
+
const resolveAdapter=async relative=>{const declared=path.resolve(root,relative),actual=await realpath(declared);assert.equal(actual,declared,'Symlink adapter dependency forbidden');assert(adapterRoots.some(r=>actual.startsWith(r+path.sep)),'Adapter dependency escaped declared roots');return actual;};
|
|
18
|
+
assert(config.runtimeAdapters&&typeof config.runtimeAdapters==='object','Explicit runtime adapters required');
|
|
19
|
+
for(const name of ['@minifish/project-runtime','@minifish/project-host'])await resolveAdapter(config.runtimeAdapters[name]);
|
|
20
|
+
return {root,config,adapterRoots,resolveAdapter};
|
|
21
|
+
}
|
|
22
|
+
export async function checkOutput(output,project){
|
|
23
|
+
assert(output,'Explicit output directory required');const destination=path.resolve(output);
|
|
24
|
+
const repository=await realpath(path.resolve(project.root,project.config.repositoryRoot));
|
|
25
|
+
for(const forbidden of [repository,path.resolve(toolRoot,'../../..')])assert(destination!==forbidden&&!destination.startsWith(forbidden+path.sep),'Output must be outside source and tool package');
|
|
26
|
+
// Resolve existing ancestry to reject output symlink escape into either input.
|
|
27
|
+
let parent=destination;while(true){try{const actual=await realpath(parent);for(const forbidden of [repository,path.resolve(toolRoot,'../../..')])assert(actual!==forbidden&&!actual.startsWith(forbidden+path.sep),'Output symlink enters source/package');break;}catch(error){if(error.code!=='ENOENT')throw error;const next=path.dirname(parent);assert(next!==parent);parent=next;}}
|
|
28
|
+
return destination;
|
|
29
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
export function validateObservationModuleName(name,declared){assert(Array.isArray(declared)&&declared.length&&new Set(declared).size===declared.length,'Explicit observation module set required');assert(typeof name==='string'&&declared.includes(name)&&!name.split('/').includes('..')&&!name.startsWith('/'),'observationModule must select a declared module');return name;}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import {SourceTextModule} from 'node:vm';
|
|
2
|
+
import {readFile,mkdir,writeFile,realpath} from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import {createHash} from 'node:crypto';
|
|
6
|
+
import ts from 'typescript';
|
|
7
|
+
import {toolRoot,checkOutput} from './config.mjs';
|
|
8
|
+
import {validateObservationModuleName} from './observation-module-name.mjs';
|
|
9
|
+
import {validateObservationModule} from './semantic-module-gate.mjs';
|
|
10
|
+
const publicPrefix='@antprofuse/saddle-islands-tools/tools/modules/minifish/';
|
|
11
|
+
export async function packageRunner(project,output,observation){
|
|
12
|
+
assert.equal(typeof SourceTextModule,'function','Run compiler with --experimental-vm-modules');
|
|
13
|
+
const destination=await checkOutput(output,project),actualTool=await realpath(toolRoot),visited=new Map();
|
|
14
|
+
const ownership=file=>file.startsWith(actualTool+path.sep)?'tool':project.adapterRoots.some(r=>file.startsWith(r+path.sep))?'adapter':null;
|
|
15
|
+
const target=file=>path.join(destination,ownership(file)==='tool'?'tools':'adapters',path.relative(ownership(file)==='tool'?actualTool:project.root,file));
|
|
16
|
+
const resolve=async (file,name)=>{
|
|
17
|
+
if(name.startsWith('node:')||name==='@playwright/test')return null;
|
|
18
|
+
let declared;
|
|
19
|
+
if(name.startsWith('.'))declared=path.resolve(path.dirname(file),name);
|
|
20
|
+
else if(name.startsWith(publicPrefix))declared=path.resolve(actualTool,name.slice(publicPrefix.length));
|
|
21
|
+
else if(Object.hasOwn(project.config.runtimeAdapters,name))declared=await project.resolveAdapter(project.config.runtimeAdapters[name]);
|
|
22
|
+
else throw Error('undeclared runner package: '+name);
|
|
23
|
+
const actual=await realpath(declared);assert.equal(actual,declared,'Symlink runner dependency forbidden');assert(ownership(actual),'runner dependency escaped declared roots');return actual;
|
|
24
|
+
};
|
|
25
|
+
const visit=async declared=>{
|
|
26
|
+
const file=await realpath(declared);assert.equal(file,declared,'Symlink runner dependency forbidden');assert(ownership(file),'runner dependency escaped declared roots');
|
|
27
|
+
if(visited.has(file))return;const code=await readFile(file,'utf8');visited.set(file,{kind:ownership(file),path:path.relative(ownership(file)==='tool'?actualTool:project.root,file),sha256:createHash('sha256').update(code).digest('hex')});
|
|
28
|
+
const observationRoot=path.resolve(project.root,project.config.observationRoot,'regions');
|
|
29
|
+
if(file.startsWith(observationRoot+path.sep))validateObservationModule(code,project.config.observationImports);
|
|
30
|
+
const parsed=new SourceTextModule(code),dependencies=new Map();
|
|
31
|
+
for(const name of parsed.dependencySpecifiers){const dependency=await resolve(file,name);if(dependency){dependencies.set(name,dependency);await visit(dependency);}}
|
|
32
|
+
const ast=ts.createSourceFile(file,code,ts.ScriptTarget.Latest,true,ts.ScriptKind.JS),edits=[];
|
|
33
|
+
for(const statement of ast.statements){const node=statement.moduleSpecifier;if(node&&dependencies.has(node.text)){let relative=path.relative(path.dirname(target(file)),target(dependencies.get(node.text))).split(path.sep).join('/');if(!relative.startsWith('.'))relative='./'+relative;edits.push({start:node.getStart(ast),end:node.end,text:JSON.stringify(relative)});}}
|
|
34
|
+
let transformed=code;for(const edit of edits.reverse())transformed=transformed.slice(0,edit.start)+edit.text+transformed.slice(edit.end);
|
|
35
|
+
visited.get(file).bundledSha256=createHash('sha256').update(transformed).digest('hex');
|
|
36
|
+
await mkdir(path.dirname(target(file)),{recursive:true});await writeFile(target(file),transformed);
|
|
37
|
+
};
|
|
38
|
+
await visit(path.join(actualTool,'harness/independent-case-runner.mjs'));
|
|
39
|
+
if(observation){validateObservationModuleName(observation,project.config.observationModules);const file=await project.resolveAdapter(path.join(project.config.observationRoot,observation));await visit(file);const bridge=path.join(destination,'tools/harness',observation);await mkdir(path.dirname(bridge),{recursive:true});let relative=path.relative(path.dirname(bridge),target(file)).split(path.sep).join('/');await writeFile(bridge,'export * from '+JSON.stringify(relative.startsWith('.')?relative:'./'+relative)+';\n');}
|
|
40
|
+
await writeFile(path.join(destination,'independent-case-runner.mjs'),"export * from './tools/harness/independent-case-runner.mjs';\n");
|
|
41
|
+
const records=[...visited.values()].sort((a,b)=>(a.kind+a.path).localeCompare(b.kind+b.path));
|
|
42
|
+
await writeFile(path.join(destination,'files.json'),JSON.stringify(records,null,2)+'\n');return records;
|
|
43
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {createRequire}from 'node:module';
|
|
5
|
+
const require=createRequire(import.meta.url);
|
|
6
|
+
const ts=require('typescript');assert.equal(ts.version,'5.9.3','Locked author-time parser version');
|
|
7
|
+
const forbidden=new Set(['locator','evaluate','evaluateAll','getAttribute','getByText','getByRole','getByTestId','querySelector','querySelectorAll','or']);
|
|
8
|
+
export function validateObservationModule(source,approvedImports){
|
|
9
|
+
assert(Array.isArray(approvedImports),'Explicit observation import policy required');const approved=new Set(approvedImports);
|
|
10
|
+
const ast=ts.createSourceFile('observation.mjs',source,ts.ScriptTarget.Latest,true,ts.ScriptKind.JS);
|
|
11
|
+
assert.equal(ast.parseDiagnostics.length,0,'Invalid business module');
|
|
12
|
+
const walk=node=>{
|
|
13
|
+
if(ts.isImportDeclaration(node)||ts.isExportDeclaration(node)&&node.moduleSpecifier)assert(approved.has(node.moduleSpecifier.text),'Undeclared observation dependency');
|
|
14
|
+
if(ts.isCallExpression(node)){
|
|
15
|
+
assert(node.expression.kind!==ts.SyntaxKind.ImportKeyword,'Dynamic observation import forbidden');
|
|
16
|
+
if(ts.isElementAccessExpression(node.expression))assert(ts.isStringLiteral(node.expression.argumentExpression),'Dynamic method execution forbidden');
|
|
17
|
+
}
|
|
18
|
+
if(ts.isPropertyAccessExpression(node))assert(!forbidden.has(node.name.text),'Direct DOM observation forbidden');
|
|
19
|
+
if(ts.isElementAccessExpression(node)&&ts.isStringLiteral(node.argumentExpression))assert(!forbidden.has(node.argumentExpression.text),'Computed DOM observation forbidden');
|
|
20
|
+
if(ts.isIdentifier(node)){
|
|
21
|
+
assert(!['document','window','globalThis','Function','eval'].includes(node.text),'Ambient DOM execution forbidden');
|
|
22
|
+
if(ts.isBindingElement(node)&&node.name)assert(!forbidden.has(node.name.text),'Destructured DOM capability forbidden');
|
|
23
|
+
}
|
|
24
|
+
if(ts.isBindingElement(node))assert(!forbidden.has(node.propertyName?.text??node.name.text),'Destructured DOM capability forbidden');
|
|
25
|
+
ts.forEachChild(node,walk);
|
|
26
|
+
};walk(ast);return true;
|
|
27
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {readFile,realpath} from 'node:fs/promises';
|
|
4
|
+
import {createHash} from 'node:crypto';
|
|
5
|
+
import {verifyIdentity} from '../../../core/package-identity.mjs';
|
|
6
|
+
export {verifyIdentity};
|
|
7
|
+
const packageRoot=path.resolve(import.meta.dirname,'../../../..');
|
|
8
|
+
const sha=bytes=>createHash('sha256').update(bytes).digest('hex');
|
|
9
|
+
export async function toolIdentity(expected,manifestFile=process.env.MINIFISH_TOOL_SOURCE_LOCK){
|
|
10
|
+
let identity=expected,manifestSha256;
|
|
11
|
+
if(manifestFile){const file=await realpath(manifestFile);assert(file!==packageRoot&&!file.startsWith(packageRoot+path.sep),'Trust manifest must be outside tool package');const bytes=await readFile(file);identity=JSON.parse(bytes);manifestSha256=sha(bytes);if(expected)for(const key of ['repository','commit','packagePath','version'])assert.equal(identity[key],expected[key],'Fixed tool source mismatch: '+key);}
|
|
12
|
+
assert(identity?.files,'Trusted complete external file manifest required');await verifyIdentity(packageRoot,identity);
|
|
13
|
+
return {package:identity.package,version:identity.version,repository:identity.repository,commit:identity.commit,packagePath:identity.packagePath,kind:'verified-content',manifestSha256,sourceFilesSha256:sha(JSON.stringify(identity.files)),sourceFiles:Object.keys(identity.files).length};
|
|
14
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Exact standalone case identities and immutable inputs for batch runners."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def read_cases(file):
|
|
8
|
+
file = Path(file).resolve()
|
|
9
|
+
rows = json.loads(file.read_text())
|
|
10
|
+
if not isinstance(rows, list) or not rows:
|
|
11
|
+
raise ValueError("Batch manifest must be a nonempty array")
|
|
12
|
+
seen, paths, result = set(), set(), []
|
|
13
|
+
for row in rows:
|
|
14
|
+
if not isinstance(row, dict) or set(row) != {"id", "package"}:
|
|
15
|
+
raise ValueError("Each row requires exactly id and package")
|
|
16
|
+
ident = row["id"]
|
|
17
|
+
if not isinstance(row["package"], str) or not row["package"]:
|
|
18
|
+
raise ValueError("Case package must be a nonempty path string")
|
|
19
|
+
package = (file.parent / row["package"]).resolve()
|
|
20
|
+
if not isinstance(ident, str) or not ident or ident in seen or package in paths:
|
|
21
|
+
raise ValueError("Case identities and package paths must be unique")
|
|
22
|
+
for name in ("case.spec.mjs", "playwright.config.mjs", "package.json", "package-lock.json", "node_modules/.bin/playwright"):
|
|
23
|
+
if not (package / name).is_file():
|
|
24
|
+
raise ValueError(f"Incomplete independent package: {package / name}")
|
|
25
|
+
seen.add(ident); paths.add(package)
|
|
26
|
+
result.append({"id": ident, "package": str(package)})
|
|
27
|
+
return result
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def fingerprint(rows):
|
|
31
|
+
result = {}
|
|
32
|
+
for row in rows:
|
|
33
|
+
p = Path(row["package"])
|
|
34
|
+
files = [p / n for n in ("case.spec.mjs", "playwright.config.mjs", "package.json", "package-lock.json")]
|
|
35
|
+
files += sorted(f for f in (p / "runner").rglob("*") if f.is_file())
|
|
36
|
+
result[row["id"]] = {str(f.relative_to(p)): hashlib.sha256(f.read_bytes()).hexdigest() for f in files}
|
|
37
|
+
return result
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def save(file, data):
|
|
41
|
+
file = Path(file)
|
|
42
|
+
temp = file.with_suffix(file.suffix + ".tmp")
|
|
43
|
+
temp.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n")
|
|
44
|
+
temp.replace(file)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def read_result(file, expected_type):
|
|
48
|
+
try:
|
|
49
|
+
value = json.loads(Path(file).read_text())
|
|
50
|
+
if not isinstance(value, expected_type):
|
|
51
|
+
raise ValueError("unexpected result shape")
|
|
52
|
+
return value
|
|
53
|
+
except (OSError, ValueError):
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def worker_certified(received, lifecycle, network, expected_ids, exit_code):
|
|
58
|
+
if not isinstance(received, list) or not isinstance(lifecycle, dict) or not isinstance(network, dict):
|
|
59
|
+
return False
|
|
60
|
+
if any(not isinstance(row, dict) for row in received):
|
|
61
|
+
return False
|
|
62
|
+
if [row.get("id") for row in received] != expected_ids:
|
|
63
|
+
return False
|
|
64
|
+
if any(row.get("status") not in ("passed", "failed", "artifact-only") for row in received):
|
|
65
|
+
return False
|
|
66
|
+
ordinary_failure = any(row["status"] == "failed" for row in received)
|
|
67
|
+
if exit_code != int(ordinary_failure) or lifecycle.get("inputsVerified") is not True:
|
|
68
|
+
return False
|
|
69
|
+
cleanup = network.get("namespaceCleanup")
|
|
70
|
+
if not isinstance(cleanup, dict) or cleanup.get("remaining") != []:
|
|
71
|
+
return False
|
|
72
|
+
for report in (lifecycle, network):
|
|
73
|
+
children = report.get("children")
|
|
74
|
+
if not isinstance(children, list) or not children:
|
|
75
|
+
return False
|
|
76
|
+
if any(not isinstance(child, dict) or child.get("returncode") is None for child in children):
|
|
77
|
+
return False
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def product_fingerprint(root):
|
|
82
|
+
"""Match the runner's copied product inputs, excluding generated runtime trees."""
|
|
83
|
+
root = Path(root)
|
|
84
|
+
result = {}
|
|
85
|
+
for file in sorted(root.rglob('*')):
|
|
86
|
+
relative = file.relative_to(root)
|
|
87
|
+
if any(part in ('node_modules', '.git', '.mini-ide') or part.startswith('dist-') for part in relative.parts):
|
|
88
|
+
continue
|
|
89
|
+
if file.is_file():
|
|
90
|
+
result[str(relative)] = hashlib.sha256(file.read_bytes()).hexdigest()
|
|
91
|
+
return result
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Partition a unified batch by exact preflighted plugin artifact identity."""
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from plugin_artifact import validate_plugin_artifact
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def plugin_groups(rows, workers, mapping_file=None, default_artifact=None, *, source):
|
|
8
|
+
mapping = json.loads(Path(mapping_file).read_text()) if mapping_file else {}
|
|
9
|
+
if not isinstance(mapping, dict) or set(mapping) - {r['id'] for r in rows}:
|
|
10
|
+
raise ValueError('Plugin mapping contains unknown case identities')
|
|
11
|
+
artifacts, manifests, errors = {}, {}, []
|
|
12
|
+
for row in rows:
|
|
13
|
+
ident = row['id']
|
|
14
|
+
try:
|
|
15
|
+
plan = read_compiled_plan(row['package'])
|
|
16
|
+
value = mapping.get(ident, default_artifact)
|
|
17
|
+
if ident in mapping and (not isinstance(value, str) or not value):
|
|
18
|
+
raise ValueError('Plugin mapping requires exact artifact paths')
|
|
19
|
+
artifact = Path(value).resolve() if value else None
|
|
20
|
+
if artifact and artifact not in manifests:
|
|
21
|
+
manifests[artifact] = validate_plugin_artifact(artifact, source)
|
|
22
|
+
plugin = plan.get('plugin')
|
|
23
|
+
if plugin is not None:
|
|
24
|
+
if not isinstance(plugin, dict) or not isinstance(plugin.get('events'), dict):
|
|
25
|
+
raise ValueError('Invalid compiled case plugin plan')
|
|
26
|
+
if artifact is None:
|
|
27
|
+
raise ValueError('Explicit compiled plugin artifact required; use --plugin-artifact-map or --plugin-artifacts')
|
|
28
|
+
# Same exact plan contract as independent-case-runner, before any worker starts.
|
|
29
|
+
if json.dumps(manifests[artifact]['plan'], sort_keys=True) != json.dumps(plugin['events'], sort_keys=True):
|
|
30
|
+
raise ValueError('Case plugin plan must match compiled artifact')
|
|
31
|
+
artifacts[ident] = str(artifact) if artifact else None
|
|
32
|
+
except (OSError, ValueError, KeyError, TypeError) as error:
|
|
33
|
+
errors.append(f"{ident}: {error}")
|
|
34
|
+
if errors:
|
|
35
|
+
raise ValueError('Plugin preflight failed before worker startup:\n' + '\n'.join(errors))
|
|
36
|
+
groups = {}
|
|
37
|
+
for row in rows:
|
|
38
|
+
groups.setdefault(artifacts[row['id']], []).append(row)
|
|
39
|
+
shards = []
|
|
40
|
+
for artifact, group in groups.items():
|
|
41
|
+
count = min(len(group), max(1, round(workers * len(group) / len(rows))))
|
|
42
|
+
shards.extend((group[i::count], artifact) for i in range(count))
|
|
43
|
+
return shards
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def read_compiled_plan(package):
|
|
47
|
+
"""Read the compiler's JSON literal without importing/executing the test module."""
|
|
48
|
+
text = (Path(package) / 'case.spec.mjs').read_text()
|
|
49
|
+
marker = '\nexport const plan = '
|
|
50
|
+
if text.count(marker) != 1:
|
|
51
|
+
raise ValueError('Rebuild case package: expected one compiler-generated plan')
|
|
52
|
+
body = text.split(marker, 1)[1]
|
|
53
|
+
plan, end = json.JSONDecoder().raw_decode(body)
|
|
54
|
+
if not isinstance(plan, dict) or not body[end:].startswith(';\ntest(plan.case.id,'):
|
|
55
|
+
raise ValueError('Rebuild case package: invalid compiler-generated plan literal')
|
|
56
|
+
return plan
|