@antprofuse/saddle-islands-tools 0.1.15 → 0.1.17
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 +1 -1
- package/tools/docs/minifish-testing.md +22 -0
- package/tools/modules/minifish/anchor_check.py +50 -3
- package/tools/modules/minifish/checks/guard.test.mjs +33 -0
- package/tools/modules/minifish/compiler/anchor-coverage.mjs +3 -1
- package/tools/modules/minifish/compiler/build-case.mjs +2 -1
- package/tools/modules/minifish/compiler/compile-check.mjs +3 -2
- package/tools/modules/minifish/compiler/component-contract.mjs +23 -2
- package/tools/modules/minifish/compiler/generated-ports.mjs +67 -0
- package/tools/modules/minifish/compiler/port-wiring-cli.mjs +17 -0
- package/tools/modules/minifish/compiler/port-wiring.mjs +36 -0
- package/tools/modules/minifish/component_index_bridge.py +1 -1
- package/tools/modules/minifish/harness/boundary-observation.mjs +40 -0
- package/tools/modules/minifish/harness/component-port.mjs +55 -0
- package/tools/modules/minifish/harness/component-state.mjs +2 -1
- package/tools/modules/minifish/harness/held-snapshot.mjs +7 -1
- package/tools/modules/minifish/harness/held-spatial.mjs +38 -3
- package/tools/modules/minifish/harness/independent-case-runner.mjs +5 -3
- package/tools/modules/minifish/harness/request-guard.mjs +4 -4
- package/tools/modules/minifish/harness/spatial-contract.mjs +22 -3
- package/tools/modules/minifish/port_wiring.py +49 -0
package/package.json
CHANGED
|
@@ -87,6 +87,16 @@ MT使用 `iteration stage-gate` 时必须提供 `--compile-result NEW_EXTERNAL_D
|
|
|
87
87
|
|
|
88
88
|
按压region断言可声明公共 `spatial`:`order: [{above: {target, instance?}, below: {target, instance?}}]` 与 `absent: [{target, instance?}]`。目标使用cmp引用。该版本只支持按压region,其他用法在静态编译及运行输入前拒绝。原有普通区域顺序公共接口保持独立,不静默解释该字段。
|
|
89
89
|
|
|
90
|
+
`spatial.ownership: [{parent: {target, instance?, carrier?}, child: {target, instance?, carrier?}}]` 要求实际选中父载体严格包含子载体,不能用坐标包含替代原生归属。每个载体必须唯一可见;循环实例必须明确选择。`spatial.align` 是相对几何关系数组,每项固定字段如下:
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{"subject":{"target":"cmp_123abc","instance":0},"reference":{"target":"cmp_456def","instance":0},"axis":"x","subjectEdge":"end","referenceEdge":"end","offset":0,"designWidth":375}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`axis` 取 `x/y`,两边的 edge 取 `start/center/end`;工具比较 subject 边位置减 reference 边位置是否等于源 offset × viewportWidth / designWidth,容差固定0.5实际CSS像素。offset与designWidth必须显式提供,不从产品反推期望。右齐可用x/end/end,垂直居中可用y/center/center。`subjectMeasure` 默认 `box`;取 `text` 时比较语义组件自己拥有的实际可见文字Range并集,拒绝空、隐藏、被裁切或属于其他子组件的文字;reference仍是组件盒。文字中心可对同一组件盒检查;同盒对自身的空洞对齐声明拒绝。尺寸、颜色等仍使用原visual合同,归属与对齐各自声明,互不推断。
|
|
97
|
+
|
|
98
|
+
这些关系由公共准备统一收集目标,在原held单次采样中同时记录真实contains、原生几何与文字证据,立即释放后纯判定。未知字段及非活动组件引用在编译时拒绝。不要将业务layoutContext/layoutBasis作为公共接口;测试按源义务声明公开spatial,不扩大项目白名单来忽略义务。
|
|
99
|
+
|
|
90
100
|
公共准备将该字段与项目核心按压合同分开校验;一次held快照同时记录原目标、关系目标的几何及排除目标的计数。补充目标不触发等待出现;释放后只判定冻结观察,错误时仍释放。输入350ms限制不变。关系要求两个唯一可见、非重叠且上方在前的真实载体;排除要求零匹配。缺观察、重复身份、隐藏关系目标及未知声明失败。本控制经过真实浏览器单快照验证,不等同于每个业务小程序已经验收。
|
|
91
101
|
|
|
92
102
|
|
|
@@ -118,3 +128,15 @@ Owner在项目配置填写 `pageSourceId`,精确引用冻结 `anchorInventory`
|
|
|
118
128
|
接口要求原始内容精确为空,拒绝空白/非空、缺失/重复、隐藏或透明祖先;允许空字形及零几何,但仍检查有限几何、祖先/视口裁剪。样式取真实内容叶而非外包装;用既有 `validateNativeTextStyles(e.leafStyles, expected, scale)` 比较源期望。采到样式本身不代表样式正确。
|
|
119
129
|
|
|
120
130
|
业务行、兄弟组件、实例顺序、布局和源允许为空的条件仍由项目测试依据Spec声明,通过公开语义集合与所有权接口检查;不从cmp字符串推断业务路径,不把允许空字形扩展成普通文字可隐藏。MT只做静态及合成设施检查,MP负责实际产品运行。
|
|
131
|
+
|
|
132
|
+
### 非视觉输入与事务组件
|
|
133
|
+
|
|
134
|
+
源组件身份仍保留在统一活动索引中。非视觉输入、事务端口不需要伪造DOM;以冻结的生成端口及真实公开行为校验。该能力目前支持插件事件驱动的输入/事务,不能任意把视觉组件声明成非视觉免检项。
|
|
135
|
+
|
|
136
|
+
Owner准备 `componentPorts: {path, sha256}` 配置,指向测试目录内的冻结清单。清单为 `{schemaVersion:1, inventorySha256, bindings:[...]}`;每项包含 `componentId`、准确的 `sourceId`、`ownerSourceId`、`portName`、`generated:{path,sha256}` 及产品 `implementation:{chain:[{template,tag},...]}`。generated.path相对测试目录;chain.template相对Git根,首项为宿主模板,后项须沿实际usingComponents逐级到生成端口组件。清单和生成源必须已提交、干净、无符号链接且摘要匹配;组件归属按官方完整位置验证,不按末级名称猜测。此输入由Owner冻结维护,执行者不自行增添免检名单。
|
|
137
|
+
|
|
138
|
+
测试在已有case中添加 `kind:"component-port"` 显式断言:`target` 为稳定cmp ID,`plugin` 指定 `instanceId/eventId/name/payload`,`boundaries` 指定实际产品边界的 `id/operation/times/fields`。fields每项为 `{path:["字段", "子字段"], equals:期望值}`。编译核对冻结端口、插件计划和必填期望;运行要求真实挂载、事件及回调收据,与实际产品请求字段、交付和消费次数分别相符。预设Mock参数不作为实际请求证据;仅准备事件或引用字符串不计覆盖。这里证明公开回调与外部产品效果,不宣称观察了私有输入存储或严格因果。业务字段期望仍由测试按Spec确定。
|
|
139
|
+
|
|
140
|
+
该断言不支持按住快照、视觉或componentState属性;这些义务仍由相应组件断言承担。
|
|
141
|
+
|
|
142
|
+
产品 `anchor-check` 同时提供 `--port-manifest`(Git相对路径)和 `--port-manifest-sha256` 时,核同业务冻结清单、生成源、AXML回调属性、usingComponents链及实际Page/Component注册函数;视觉声明与通过接线校验的端口集合共同对齐源组件全集。重复视觉/非视觉声明、悬空回调、错分支借用、动态无法判定的注册均拒绝。静态接线通过不证明业务数据流、条件可达性或处理结果,后者由运行断言保障。
|
|
@@ -21,7 +21,7 @@ def git(repo, *args):
|
|
|
21
21
|
return subprocess.check_output(['git', '-C', str(repo), *args])
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
def check(repo, product, head, inventory, parser, index, retired):
|
|
24
|
+
def check(repo, product, head, inventory, parser, index, retired, port_components=None):
|
|
25
25
|
if not re.fullmatch('[a-f0-9]{40}', head): raise ValueError('Full candidate commit required')
|
|
26
26
|
if not product or product.startswith('/') or any(p in ('', '.', '..') for p in product.split('/')):
|
|
27
27
|
raise ValueError('Repository-relative product directory required')
|
|
@@ -52,6 +52,11 @@ def check(repo, product, head, inventory, parser, index, retired):
|
|
|
52
52
|
declared.update(values['values'])
|
|
53
53
|
unknown = sorted(set(values['values'])-allowed)
|
|
54
54
|
if unknown: issues.append({**row,'kind':'unknown','values':unknown,'precision':values['precision']})
|
|
55
|
+
ports = port_components or {}
|
|
56
|
+
if set(ports)-allowed: raise ValueError('Unknown nonvisual component binding')
|
|
57
|
+
overlap = set(ports)&declared
|
|
58
|
+
if overlap: issues.append({'kind':'conflicting-visual-port-declarations','values':sorted(overlap)})
|
|
59
|
+
declared.update(ports)
|
|
55
60
|
missing = sorted(allowed-declared)
|
|
56
61
|
if missing: issues.append({'kind':'missing-components','values':missing})
|
|
57
62
|
if git(repo,'rev-parse','HEAD').decode().strip()!=head or git(repo,'status','--porcelain','--untracked-files=all','--',product).strip():
|
|
@@ -59,7 +64,7 @@ def check(repo, product, head, inventory, parser, index, retired):
|
|
|
59
64
|
return {'schemaVersion':1,'check':'component-anchor-set-equality','candidate':head,'productDirectory':product,
|
|
60
65
|
'sourceSnapshot':inventory['snapshotId'],'sourceQuerySha256':inventory['querySha256'],
|
|
61
66
|
'identityContract':'allocated-component-id','sourceComponentIds':sorted(active.values()),'activeComponentIds':sorted(allowed),'declaredComponentIds':sorted(declared),
|
|
62
|
-
'templateFiles':files,'declarations':len(rows),'issues':issues,'passed':not issues,
|
|
67
|
+
'portBindings':ports,'templateFiles':files,'declarations':len(rows),'issues':issues,'passed':not issues,
|
|
63
68
|
'limitations':['Does not prove all business components are annotated','Does not prove anchor carrier semantics or scenario visibility','Dynamic conditional results are conservative, not a reachability proof']}
|
|
64
69
|
|
|
65
70
|
|
|
@@ -72,6 +77,8 @@ def main(argv, identity):
|
|
|
72
77
|
parser.add_argument('--inventory-sha256',required=True)
|
|
73
78
|
parser.add_argument('--registry-directory',required=True,help='Git-relative code/.registry directory')
|
|
74
79
|
parser.add_argument('--tool-node-modules',type=Path,required=True)
|
|
80
|
+
parser.add_argument('--port-manifest',help='Optional Git-relative frozen component port manifest')
|
|
81
|
+
parser.add_argument('--port-manifest-sha256')
|
|
75
82
|
parser.add_argument('--output',type=Path,required=True)
|
|
76
83
|
args=parser.parse_args(argv)
|
|
77
84
|
data=args.inventory.read_bytes()
|
|
@@ -98,7 +105,47 @@ def main(argv, identity):
|
|
|
98
105
|
raw=git(repo,'show',args.head+':'+file)
|
|
99
106
|
bindings[name]={'path':file,'sha256':digest(raw)}
|
|
100
107
|
values.append(parse_registry(raw.decode()))
|
|
101
|
-
|
|
108
|
+
ports={}
|
|
109
|
+
if args.port_manifest:
|
|
110
|
+
from tools.modules.minifish.port_wiring import inspect_chain
|
|
111
|
+
base=reg[:-len('.registry')]+'minifish-tests/'
|
|
112
|
+
checked={}
|
|
113
|
+
def committed(name):
|
|
114
|
+
if not isinstance(name,str) or name.startswith('/') or any(p in ('','.','..') for p in name.split('/')):raise ValueError('Git-relative regular port input required')
|
|
115
|
+
parts=git(repo,'ls-tree',args.head,'--',name).decode().split()
|
|
116
|
+
if not parts:raise FileNotFoundError(name)
|
|
117
|
+
if parts[0]!='100644' or (repo/name).resolve()!=repo/name:raise ValueError('Regular non-symlink port input required')
|
|
118
|
+
if git(repo,'status','--porcelain','--untracked-files=all','--',name).strip():raise ValueError('Port input must be clean')
|
|
119
|
+
raw=git(repo,'show',args.head+':'+name)
|
|
120
|
+
if (repo/name).read_bytes()!=raw:raise ValueError('Port input differs from candidate')
|
|
121
|
+
checked[name]=digest(raw);return raw
|
|
122
|
+
if not args.port_manifest.startswith(base):raise ValueError('Port manifest must belong to same business test inputs')
|
|
123
|
+
raw=committed(args.port_manifest)
|
|
124
|
+
if digest(raw)!=args.port_manifest_sha256:raise ValueError('Port manifest digest mismatch')
|
|
125
|
+
manifest=json.loads(raw)
|
|
126
|
+
if manifest.get('schemaVersion')!=1 or manifest.get('inventorySha256')!=digest(data):raise ValueError('Port source inventory mismatch')
|
|
127
|
+
entries=manifest.get('bindings')
|
|
128
|
+
if not isinstance(entries,list) or not entries:raise ValueError('Nonempty source port bindings required')
|
|
129
|
+
active=validate(values[0],json.loads(data),values[1])
|
|
130
|
+
def inspect(payload):
|
|
131
|
+
proc=subprocess.run(['node',str(Path(__file__).parent/'compiler/port-wiring-cli.mjs'),str(args.tool_node_modules.resolve())],input=json.dumps(payload),text=True,capture_output=True,check=True)
|
|
132
|
+
return json.loads(proc.stdout)
|
|
133
|
+
for entry in entries:
|
|
134
|
+
cmp=entry['componentId']
|
|
135
|
+
if cmp in ports or active.get(cmp)!=entry['sourceId']:raise ValueError('Unknown or duplicate component port binding')
|
|
136
|
+
name=entry['generated']['path']
|
|
137
|
+
if name.startswith('/') or any(p in ('','.','..') for p in name.split('/')):raise ValueError('Frozen generated source must be test-relative')
|
|
138
|
+
frozen=committed(base+name)
|
|
139
|
+
if digest(frozen)!=entry['generated']['sha256']:raise ValueError('Generated source digest mismatch')
|
|
140
|
+
payload={'inventory':json.loads(data),'binding':entry,'frozenSource':frozen.decode()}
|
|
141
|
+
expected=inspect({**payload,'mode':'source'})
|
|
142
|
+
wire=inspect_chain(committed,args.product_directory+'/src',entry['implementation']['chain'],expected['publication']['callback'])
|
|
143
|
+
observed=inspect({**payload,'productSource':wire['productSource'],'hostSource':wire['hostSource'],'handler':wire['handler']})
|
|
144
|
+
ports[cmp]={**observed,'handler':wire['handler'],'generatedFile':wire['generatedFile'],'files':{name:digest(raw) for name,raw in wire['files'].items()}}
|
|
145
|
+
for name,expected in checked.items():
|
|
146
|
+
if digest(committed(name))!=expected:raise ValueError('Port input changed during inspection')
|
|
147
|
+
elif args.port_manifest_sha256:raise ValueError('Port manifest required with digest')
|
|
148
|
+
result=check(repo,args.product_directory,args.head,json.loads(data),resolve,*values,port_components=ports)
|
|
102
149
|
if git(repo,'status','--porcelain','--untracked-files=all','--',reg).strip(): raise ValueError('Registry changed during inspection')
|
|
103
150
|
result['registry']=bindings
|
|
104
151
|
result.update(sourceInventorySha256=digest(data),tool={'commit':identity['commit'],'version':identity['version']})
|
|
@@ -11,3 +11,36 @@ test('duplicate, absent request, and callback type mismatch are rejected',async(
|
|
|
11
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
12
|
|
|
13
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/);});
|
|
14
|
+
|
|
15
|
+
// Preserve raw observations separately from expected fixtures and comparison normalization.
|
|
16
|
+
test('actual requests are bound to the selected row, immutable, and not fabricated by fixture fields',async()=>{
|
|
17
|
+
const first=row(), second={...row(),id:'second',request:{operation:'sample.service.read',params:{key:'second'}}};
|
|
18
|
+
first.actualRequests=[{request:{forged:true}}];
|
|
19
|
+
const guard=createBoundary([first,second],{...policy,comparison:(op,v)=>({key:v.key})});
|
|
20
|
+
assert.deepEqual(guard.evidence().expectations.map(r=>r.actualRequests),[[],[]]);
|
|
21
|
+
const request={key:'second',actualExtra:7},wire={frame:'actual-frame'};
|
|
22
|
+
await guard.receive(second.request.operation,request,wire);
|
|
23
|
+
request.actualExtra=99;wire.frame='changed';
|
|
24
|
+
const evidence=guard.evidence();
|
|
25
|
+
assert.deepEqual(evidence.expectations[0].actualRequests,[]);
|
|
26
|
+
assert.deepEqual(evidence.expectations[1].actualRequests,[{operation:second.request.operation,request:{key:'second',actualExtra:7},wire:{frame:'actual-frame'}}]);
|
|
27
|
+
evidence.expectations[1].actualRequests[0].request.actualExtra=0;
|
|
28
|
+
assert.equal(guard.evidence().expectations[1].actualRequests[0].request.actualExtra,7);
|
|
29
|
+
assert.equal(guard.evidence().expectations[1].delivered,0);
|
|
30
|
+
assert.equal(guard.evidence().expectations[1].consumed,0);
|
|
31
|
+
});
|
|
32
|
+
test('rejected requests remain violations, not matched component evidence',async()=>{
|
|
33
|
+
const guard=createBoundary([row()],policy);
|
|
34
|
+
await assert.rejects(guard.receive('wrong.operation',{key:'alternate'}));
|
|
35
|
+
assert.deepEqual(guard.evidence().expectations[0].actualRequests,[]);
|
|
36
|
+
assert.equal(guard.evidence().attempts.length,1);
|
|
37
|
+
assert.throws(()=>guard.assertComplete());
|
|
38
|
+
});
|
|
39
|
+
test('official scroll records actual request without fabricating callback completion',()=>{
|
|
40
|
+
const r={...row(),execution:'official-scroll'},guard=createBoundary([r],policy);
|
|
41
|
+
guard.recordOfficialRequest(r.request.operation,r.request.params);
|
|
42
|
+
assert.deepEqual(guard.evidence().expectations[0].actualRequests,[{operation:r.request.operation,request:r.request.params,execution:'official-scroll'}]);
|
|
43
|
+
assert.equal(guard.evidence().expectations[0].consumed,0);
|
|
44
|
+
guard.recordOfficialCallback(r.request.operation,r.request.params,{kind:'success',value:r.outcome.value});
|
|
45
|
+
guard.assertComplete();
|
|
46
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {validateComponentPort} from '../harness/component-port.mjs';
|
|
1
2
|
import {validatePageVisual} from '../harness/page-visual.mjs';
|
|
2
3
|
import {validateSpatial} from '../harness/spatial-contract.mjs';
|
|
3
4
|
import assert from 'node:assert/strict';
|
|
@@ -12,11 +13,12 @@ export function sourceComponents(inventory,active,retired){
|
|
|
12
13
|
// Only actual target assertions count. Actions, sourceTarget, basis and nested
|
|
13
14
|
// strings are never recursively treated as evidence of an executed assertion.
|
|
14
15
|
const kinds=new Set(['component-state','region','hidden','visible','text','image','text-visual','button-visual','host-navigation','host-native-ui','carousel-frame']);
|
|
15
|
-
export function assertionComponents(plan){
|
|
16
|
+
export function assertionComponents(plan,contract){
|
|
16
17
|
assert(Array.isArray(plan?.assertions)&&plan.assertions.length,'Compiled target assertions required');
|
|
17
18
|
const seen=new Set(),components=new Set();
|
|
18
19
|
for(const a of plan.assertions){
|
|
19
20
|
assert(a&&typeof a.id==='string'&&a.id&&!seen.has(a.id),'Unique assertion identity required');seen.add(a.id);
|
|
21
|
+
if(a.kind==='component-port'){validateComponentPort(a,contract);components.add(a.target);continue;}
|
|
20
22
|
if(a.kind==='page-visual'){validatePageVisual(a);assert(!(plan.beforeTarget??[]).some(x=>x.kind==='press'),'Page visual is not supported during held input');continue;}
|
|
21
23
|
if(a.spatial!==undefined){
|
|
22
24
|
assert(a.kind==='region'&&(plan.beforeTarget??[]).some(x=>x.kind==='press'),'Spatial snapshot currently requires held region assertion');
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {validateComponentPorts} from '../harness/component-port.mjs';
|
|
1
2
|
import {validateComponentStates} from '../harness/component-state.mjs';
|
|
2
3
|
import {loadComponentContract} from './component-contract.mjs';
|
|
3
4
|
import assert from 'node:assert/strict';
|
|
@@ -27,7 +28,7 @@ export async function buildPlanPackage(plan,output,options){
|
|
|
27
28
|
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);
|
|
28
29
|
assert(plan.componentContract===undefined,'Project must not override compiler component contract');
|
|
29
30
|
plan={...plan,componentContract:await loadComponentContract(project)};
|
|
30
|
-
validateComponentStates(plan,plan.componentContract);
|
|
31
|
+
validateComponentStates(plan,plan.componentContract);validateComponentPorts(plan,plan.componentContract);
|
|
31
32
|
await mkdir(destination,{recursive:true});
|
|
32
33
|
const files=await packageRunner(project,path.join(destination,'runner'),plan.observationModule);
|
|
33
34
|
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`;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {validateComponentPorts} from '../harness/component-port.mjs';
|
|
1
2
|
import {validateComponentStates} from '../harness/component-state.mjs';
|
|
2
3
|
import {loadComponentContract} from './component-contract.mjs';
|
|
3
4
|
import {assertionComponents,compareCoverage} from './anchor-coverage.mjs';
|
|
@@ -59,8 +60,8 @@ export async function compileCheck(options){
|
|
|
59
60
|
assert.deepEqual(definition.ancestors,fragments.map(f=>f.id),'Preparation ancestry mismatch');
|
|
60
61
|
const plan=await adapter.compile({projectRoot:root,config,catalog:parsedCatalog,definition,fragments});
|
|
61
62
|
assert(plan.componentContract===undefined,'Project must not override compiler component contract');
|
|
62
|
-
validateComponentStates(plan,componentContract);
|
|
63
|
-
const componentIds=assertionComponents(plan);
|
|
63
|
+
validateComponentStates(plan,componentContract);validateComponentPorts(plan,componentContract);
|
|
64
|
+
const componentIds=assertionComponents(plan,componentContract);
|
|
64
65
|
results.push({id:row.definition,definition:row.definition,status:'passed',componentIds});
|
|
65
66
|
}catch(e){results.push({id:row.definition,definition:row.definition,status:'failed',error:e.message});}
|
|
66
67
|
}
|
|
@@ -36,8 +36,29 @@ export async function loadComponentContract(project){
|
|
|
36
36
|
assert(typeof config.pageRoute==='string'&&config.pageRoute,'Configured product route required');
|
|
37
37
|
page={sourceId:config.pageSourceId,route:config.pageRoute};
|
|
38
38
|
}
|
|
39
|
-
const {activeComponentIds,componentStates,branches}=JSON.parse(execFileSync('python3',[fileURLToPath(new URL('../component_index_bridge.py',import.meta.url))],{input:JSON.stringify({inventory:JSON.parse(inventory.bytes),active:active.bytes.toString(),retired:retired.bytes.toString(),withStates:true}),encoding:'utf8',env:{...process.env,PYTHONDONTWRITEBYTECODE:'1'}}));
|
|
39
|
+
const {activeComponentIds,componentSources,componentStates,branches}=JSON.parse(execFileSync('python3',[fileURLToPath(new URL('../component_index_bridge.py',import.meta.url))],{input:JSON.stringify({inventory:JSON.parse(inventory.bytes),active:active.bytes.toString(),retired:retired.bytes.toString(),withStates:true}),encoding:'utf8',env:{...process.env,PYTHONDONTWRITEBYTECODE:'1'}}));
|
|
40
|
+
const ports={},portInputs=[];
|
|
41
|
+
if(config.componentPorts!==undefined){
|
|
42
|
+
const {readGeneratedPorts,bindSourcePort}=await import('./generated-ports.mjs');
|
|
43
|
+
const entry=config.componentPorts;
|
|
44
|
+
assert(entry?.path&&/^[a-f0-9]{64}$/.test(entry.sha256??''),'Frozen component port manifest required');
|
|
45
|
+
const file=path.resolve(root,entry.path);assert(file.startsWith(root+path.sep),'Port manifest escaped test inputs');
|
|
46
|
+
const manifest=await read(file);assert.equal(manifest.sha256,entry.sha256,'Port manifest digest mismatch');
|
|
47
|
+
const data=JSON.parse(manifest.bytes);
|
|
48
|
+
assert.equal(data.schemaVersion,1,'Unsupported port manifest');
|
|
49
|
+
assert.equal(data.inventorySha256,inventory.sha256,'Port manifest source inventory mismatch');
|
|
50
|
+
assert(Array.isArray(data.bindings)&&data.bindings.length,'Explicit source port bindings required');
|
|
51
|
+
portInputs.push({path:manifest.path,sha256:manifest.sha256});
|
|
52
|
+
for(const b of data.bindings){
|
|
53
|
+
assert(componentSources[b.componentId]===b.sourceId&&!Object.hasOwn(ports,b.componentId),'Unknown or duplicate port component binding');
|
|
54
|
+
assert(typeof b.generated?.path==='string'&&/^[a-f0-9]{64}$/.test(b.generated.sha256??''),'Frozen generated source required');
|
|
55
|
+
const file=path.resolve(root,b.generated.path);assert(file.startsWith(root+path.sep),'Generated source escaped test inputs');
|
|
56
|
+
const source=await read(file);assert.equal(source.sha256,b.generated.sha256,'Generated source digest mismatch');
|
|
57
|
+
ports[b.componentId]=bindSourcePort(parsedInventory,b,readGeneratedPorts(source.bytes.toString()));
|
|
58
|
+
portInputs.push({path:source.path,sha256:source.sha256});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
40
61
|
assert.equal(git('rev-parse','HEAD'),head,'Candidate changed during contract construction');
|
|
41
62
|
const binding=x=>({path:x.path,sha256:x.sha256});
|
|
42
|
-
return {schemaVersion:1,head,activeComponentIds,componentStates,branches,...(page?{page}:{}),config:binding(configInput),inventory:binding(inventory),registry:{active:binding(active),retired:binding(retired)}};
|
|
63
|
+
return {schemaVersion:1,head,activeComponentIds,componentStates,branches,...(portInputs.length?{ports,portInputs}:{}),...(page?{page}:{}),config:binding(configInput),inventory:binding(inventory),registry:{active:binding(active),retired:binding(retired)}};
|
|
43
64
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** Inspect generated port metadata without importing or executing product code.
|
|
2
|
+
* This proves a declaration, not runtime reachability or business correctness.
|
|
3
|
+
*/
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import ts from 'typescript';
|
|
6
|
+
|
|
7
|
+
export function readGeneratedPorts(source) {
|
|
8
|
+
const file=ts.createSourceFile('generated.js',source,ts.ScriptTarget.Latest,true,ts.ScriptKind.JS);
|
|
9
|
+
assert.equal(file.parseDiagnostics.length,0,'Invalid generated port source');
|
|
10
|
+
const declarations=[];
|
|
11
|
+
function walk(node){if(ts.isVariableDeclaration(node)&&ts.isIdentifier(node.name)&&node.name.text==='ports')declarations.push(node);ts.forEachChild(node,walk);}
|
|
12
|
+
walk(file);assert.equal(declarations.length,1,'Unique generated ports declaration required');
|
|
13
|
+
function literal(node){
|
|
14
|
+
assert(node,'Missing generated port initializer');
|
|
15
|
+
if(ts.isStringLiteral(node))return node.text;
|
|
16
|
+
if(ts.isArrayLiteralExpression(node))return node.elements.map(literal);
|
|
17
|
+
if(ts.isObjectLiteralExpression(node)){
|
|
18
|
+
const out=Object.create(null);
|
|
19
|
+
for(const item of node.properties){
|
|
20
|
+
assert(ts.isPropertyAssignment(item)&&(ts.isIdentifier(item.name)||ts.isStringLiteral(item.name)),'Literal generated port properties required');
|
|
21
|
+
const key=item.name.text;assert(!Object.hasOwn(out,key),'Duplicate generated port property');out[key]=literal(item.initializer);
|
|
22
|
+
}return out;
|
|
23
|
+
}
|
|
24
|
+
throw Error('Generated port metadata must be literal, not executable');
|
|
25
|
+
}
|
|
26
|
+
const ports=literal(declarations[0].initializer);
|
|
27
|
+
assert(Array.isArray(ports)&&ports.length,'Nonempty generated ports required');
|
|
28
|
+
const names=new Set(),callbacks=new Set(),physical=new Set();
|
|
29
|
+
for(const port of ports){
|
|
30
|
+
assert.deepEqual(Object.keys(port).sort(),['kind','name','publication','requiredInputs'],'Unexpected generated port fields');
|
|
31
|
+
assert(typeof port.name==='string'&&port.name&&!names.has(port.name),'Unique port name required');names.add(port.name);
|
|
32
|
+
assert(['input','interaction'].includes(port.kind),'Unsupported port kind');
|
|
33
|
+
assert(Array.isArray(port.requiredInputs),'Explicit port inputs required');
|
|
34
|
+
assert(port.publication&&typeof port.publication.name==='string'&&port.publication.name&&typeof port.publication.callback==='string'&&port.publication.callback,'Published port callback required');
|
|
35
|
+
assert.deepEqual(Object.keys(port.publication).sort(),['callback','name'],'Unexpected publication fields');
|
|
36
|
+
assert(!callbacks.has(port.publication.callback)&&!physical.has(port.publication.name),'Duplicate port publication');callbacks.add(port.publication.callback);physical.add(port.publication.name);
|
|
37
|
+
if(port.kind==='input')assert.equal(port.requiredInputs.length,0,'Input port cannot depend on other inputs');
|
|
38
|
+
}
|
|
39
|
+
for(const port of ports){
|
|
40
|
+
const inputs=new Set();
|
|
41
|
+
for(const input of port.requiredInputs){
|
|
42
|
+
assert.deepEqual(Object.keys(input).sort(),['name','physicalName','type'],'Unexpected input fields');
|
|
43
|
+
const source=ports.find(p=>p.name===input.name&&p.kind==='input');
|
|
44
|
+
assert(source,'Required input port not declared');
|
|
45
|
+
assert.equal(input.physicalName,source.publication.name,'Required input publication mismatch');
|
|
46
|
+
assert(typeof input.type==='string'&&input.type&&!inputs.has(input.name),'Unique typed required inputs needed');inputs.add(input.name);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return JSON.parse(JSON.stringify(ports));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function bindSourcePort(inventory,{sourceId,ownerSourceId,portName},ports){
|
|
53
|
+
const unique=id=>{const rows=inventory.targets.filter(t=>t.id===id&&t.kind==='component');assert.equal(rows.length,1,'Exact source component required');return rows[0];};
|
|
54
|
+
const child=unique(sourceId),owner=unique(ownerSourceId);
|
|
55
|
+
assert(typeof child.source?.file==='string'&&child.source.file,'Source file required');
|
|
56
|
+
assert.equal(child.source?.file,owner.source?.file,'Port and owner source files differ');
|
|
57
|
+
const location=row=>{const found=row.locations.filter(l=>l.kind==='visualComponent');assert.equal(found.length,1,'Unique component source location required');return found[0];};
|
|
58
|
+
const c=location(child),p=location(owner);
|
|
59
|
+
const path=l=>l.componentPath??[{component:l.component}];
|
|
60
|
+
assert.equal(c.page,p.page,'Port owner page mismatch');
|
|
61
|
+
assert.deepEqual(path(c).slice(0,-1),path(p),'Port must be a direct source child of its physical owner');
|
|
62
|
+
assert.equal(path(c).at(-1).component,portName,'Port source component name mismatch');
|
|
63
|
+
assert(!path(c).at(-1).branch,'Branch is not a port identity');
|
|
64
|
+
const candidates=ports.filter(port=>port.name===portName);assert.equal(candidates.length,1,'Unique generated source port required');
|
|
65
|
+
const {name,kind,requiredInputs,publication}=candidates[0];
|
|
66
|
+
return {sourceId,ownerSourceId,name,kind,requiredInputs:structuredClone(requiredInputs),publication:structuredClone(publication)};
|
|
67
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import {createRequire, registerHooks} from 'node:module';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {pathToFileURL} from 'node:url';
|
|
4
|
+
const modules = process.argv[2];
|
|
5
|
+
if (modules) {
|
|
6
|
+
const require = createRequire(path.resolve(modules,'../package.json'));
|
|
7
|
+
registerHooks({resolve(specifier, context, next) {
|
|
8
|
+
if(specifier === 'typescript')return {url:pathToFileURL(require.resolve(specifier)).href,shortCircuit:true};
|
|
9
|
+
return next(specifier,context);
|
|
10
|
+
}});
|
|
11
|
+
}
|
|
12
|
+
const {readGeneratedPorts,bindSourcePort}=await import('./generated-ports.mjs');
|
|
13
|
+
const {validatePortInspection}=await import('./port-wiring.mjs');
|
|
14
|
+
let data='';for await(const part of process.stdin)data+=part;
|
|
15
|
+
const request=JSON.parse(data);
|
|
16
|
+
const result=request.mode==='source'?bindSourcePort(request.inventory,request.binding,readGeneratedPorts(request.frozenSource)):validatePortInspection(request);
|
|
17
|
+
console.log(JSON.stringify(result));
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import ts from 'typescript';
|
|
3
|
+
import {readGeneratedPorts,bindSourcePort} from './generated-ports.mjs';
|
|
4
|
+
|
|
5
|
+
export function registeredHandler(source,name){
|
|
6
|
+
const file=ts.createSourceFile('host.ts',source,ts.ScriptTarget.Latest,true,ts.ScriptKind.TS);
|
|
7
|
+
assert.equal(file.parseDiagnostics.length,0,'Invalid host source');
|
|
8
|
+
const registrations=[];
|
|
9
|
+
function walk(node){
|
|
10
|
+
if(ts.isCallExpression(node)&&ts.isIdentifier(node.expression)&&['Page','Component'].includes(node.expression.text))registrations.push(node);
|
|
11
|
+
ts.forEachChild(node,walk);
|
|
12
|
+
}
|
|
13
|
+
walk(file);assert.equal(registrations.length,1,'Unique literal host registration required');
|
|
14
|
+
const call=registrations[0],object=call.arguments[0];
|
|
15
|
+
assert(ts.isObjectLiteralExpression(object),'Literal host registration required');
|
|
16
|
+
let properties=object.properties;
|
|
17
|
+
if(call.expression.text==='Component'){
|
|
18
|
+
const methods=properties.filter(p=>p.name&&(ts.isIdentifier(p.name)||ts.isStringLiteral(p.name))&&p.name.text==='methods');
|
|
19
|
+
assert.equal(methods.length,1,'Unique component methods required');assert(ts.isPropertyAssignment(methods[0])&&ts.isObjectLiteralExpression(methods[0].initializer),'Literal component methods required');properties=methods[0].initializer.properties;
|
|
20
|
+
}
|
|
21
|
+
assert(!properties.some(p=>ts.isSpreadAssignment(p)||p.name&&ts.isComputedPropertyName(p.name)),'Dynamic host properties prevent binding verification');
|
|
22
|
+
const candidates=properties.filter(p=>p.name&&(ts.isIdentifier(p.name)||ts.isStringLiteral(p.name))&&p.name.text===name);
|
|
23
|
+
assert.equal(candidates.length,1,'Published callback handler missing or duplicate');
|
|
24
|
+
const p=candidates[0],fn=ts.isMethodDeclaration(p)?p:ts.isPropertyAssignment(p)?p.initializer:null;
|
|
25
|
+
assert(fn&&(ts.isMethodDeclaration(fn)||ts.isFunctionExpression(fn)||ts.isArrowFunction(fn)),'Handler must be a declared function');
|
|
26
|
+
assert(fn.parameters.length>0&&fn.body,'Handler must accept the published value');
|
|
27
|
+
assert(!ts.isBlock(fn.body)||fn.body.statements.length>0,'Empty callback handler');
|
|
28
|
+
return {name,observation:'registered-handler-declaration'};
|
|
29
|
+
}
|
|
30
|
+
export function validatePortInspection({inventory,binding,frozenSource,productSource,hostSource,handler}){
|
|
31
|
+
const frozen=readGeneratedPorts(frozenSource),actual=readGeneratedPorts(productSource);
|
|
32
|
+
const expected=bindSourcePort(inventory,binding,frozen),observed=bindSourcePort(inventory,binding,actual);
|
|
33
|
+
assert.deepEqual(observed,expected,'Product port metadata differs from frozen source');
|
|
34
|
+
registeredHandler(hostSource,handler);
|
|
35
|
+
return expected;
|
|
36
|
+
}
|
|
@@ -23,6 +23,6 @@ if __name__=='__main__':
|
|
|
23
23
|
values=states.setdefault(resolved['component'],[])
|
|
24
24
|
if resolved['state'] not in values:
|
|
25
25
|
values.append(resolved['state'])
|
|
26
|
-
print(json.dumps({'activeComponentIds':list(active),'componentStates':states,'branches':branches}))
|
|
26
|
+
print(json.dumps({'activeComponentIds':list(active),'componentSources':active,'componentStates':states,'branches':branches}))
|
|
27
27
|
else:
|
|
28
28
|
print(json.dumps(list(active)))
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
|
|
3
|
+
// Read observed requests, never the fixture's request.params. This proves an
|
|
4
|
+
// external boundary result, not an internal write or causality from an event.
|
|
5
|
+
export function observeCompletedBoundary(evidence, specification) {
|
|
6
|
+
assert(evidence && Array.isArray(evidence.expectations) && Array.isArray(evidence.violations), 'Boundary ledger required');
|
|
7
|
+
assert.deepEqual(evidence.violations, [], 'Boundary violations invalidate observations');
|
|
8
|
+
assert(specification && typeof specification.id === 'string' && specification.id, 'Boundary id required');
|
|
9
|
+
assert(Number.isSafeInteger(specification.times) && specification.times > 0, 'Exact positive observation count required');
|
|
10
|
+
assert(Array.isArray(specification.fields) && specification.fields.length, 'Explicit actual request fields required');
|
|
11
|
+
const rows = evidence.expectations.filter(row => row.id === specification.id);
|
|
12
|
+
assert.equal(rows.length, 1, 'Unique observed boundary required');
|
|
13
|
+
const row = rows[0];
|
|
14
|
+
assert.equal(row.request?.operation, specification.operation, 'Boundary operation mismatch');
|
|
15
|
+
for (const key of ['times', 'received', 'delivered', 'consumed']) assert.equal(row[key], specification.times, 'Incomplete boundary ' + key);
|
|
16
|
+
assert.equal(row.cancelled, 0, 'Cancelled boundary cannot provide completion evidence');
|
|
17
|
+
assert.equal(row.actualRequests?.length, specification.times, 'Actual matched request evidence required');
|
|
18
|
+
assert.equal(row.callbacks?.length, specification.times, 'Actual callback evidence required');
|
|
19
|
+
const paths = new Set();
|
|
20
|
+
for (const field of specification.fields) {
|
|
21
|
+
assert(Array.isArray(field.path) && field.path.length && field.path.every(key => typeof key === 'string' && key.length && !['__proto__', 'prototype', 'constructor'].includes(key)), 'Explicit safe field path required');
|
|
22
|
+
assert(Object.hasOwn(field, 'equals') && field.equals !== undefined, 'Explicit field expectation required');
|
|
23
|
+
const identity = JSON.stringify(field.path);
|
|
24
|
+
assert(!paths.has(identity), 'Duplicate field expectation'); paths.add(identity);
|
|
25
|
+
}
|
|
26
|
+
const observations = row.actualRequests.map(receipt => {
|
|
27
|
+
assert.equal(receipt.operation, specification.operation, 'Actual operation mismatch');
|
|
28
|
+
const fields = specification.fields.map(field => {
|
|
29
|
+
let value = receipt.request;
|
|
30
|
+
for (const key of field.path) {
|
|
31
|
+
assert(value !== null && typeof value === 'object' && Object.hasOwn(value, key), 'Missing actual field ' + field.path.join('.'));
|
|
32
|
+
value = value[key];
|
|
33
|
+
}
|
|
34
|
+
assert.deepEqual(value, field.equals, 'Actual request field mismatch: ' + field.path.join('.'));
|
|
35
|
+
return {path: [...field.path], value: structuredClone(value)};
|
|
36
|
+
});
|
|
37
|
+
return {operation: receipt.operation, fields};
|
|
38
|
+
});
|
|
39
|
+
return {boundaryId: row.id, observations, callbacks: structuredClone(row.callbacks)};
|
|
40
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {observeCompletedBoundary} from './boundary-observation.mjs';
|
|
3
|
+
|
|
4
|
+
export function validateComponentPort(assertion, contract){
|
|
5
|
+
assert(assertion?.kind==='component-port','Component port assertion required');
|
|
6
|
+
assert(/^cmp_[a-f0-9]{6}$/.test(assertion.target??''),'Allocated component target required');
|
|
7
|
+
const binding=contract?.ports?.[assertion.target];
|
|
8
|
+
assert(binding&&['input','interaction'].includes(binding.kind),'Frozen source port binding required');
|
|
9
|
+
assert(assertion.plugin&&typeof assertion.plugin.instanceId==='string'&&assertion.plugin.instanceId&&typeof assertion.plugin.eventId==='string'&&assertion.plugin.eventId&&typeof assertion.plugin.name==='string'&&assertion.plugin.name,'Explicit plugin event identity required');
|
|
10
|
+
assert(Object.hasOwn(assertion.plugin,'payload')&&assertion.plugin.payload!==undefined,'Explicit event payload expectation required');
|
|
11
|
+
assert(assertion.componentState===undefined&&assertion.visual===undefined&&assertion.spatial===undefined,'Port assertions do not support visual or state observations');
|
|
12
|
+
assert(Array.isArray(assertion.boundaries)&&assertion.boundaries.length,'Observed product effects required');
|
|
13
|
+
const seen=new Set();
|
|
14
|
+
for(const b of assertion.boundaries){
|
|
15
|
+
assert(typeof b.id==='string'&&b.id&&!seen.has(b.id),'Unique boundary identity required');seen.add(b.id);
|
|
16
|
+
assert(typeof b.operation==='string'&&b.operation&&Number.isSafeInteger(b.times)&&b.times>0,'Exact boundary operation and count required');
|
|
17
|
+
assert(Array.isArray(b.fields)&&b.fields.length,'Actual field assertions required');
|
|
18
|
+
for(const f of b.fields)assert(Array.isArray(f.path)&&f.path.length&&f.path.every(x=>typeof x==='string'&&x&&!['__proto__','constructor','prototype'].includes(x))&&Object.hasOwn(f,'equals')&&f.equals!==undefined,'Explicit actual field expectation required');
|
|
19
|
+
}
|
|
20
|
+
return binding;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Mount/event/callback observations plus external effects are independently
|
|
24
|
+
// checked. This does not claim observation of private input storage or causality.
|
|
25
|
+
export function observeComponentPort(assertion, contract, evidence){
|
|
26
|
+
const binding=validateComponentPort(assertion,contract),p=assertion.plugin,ledger=evidence?.pluginEvents;
|
|
27
|
+
assert(ledger&&Array.isArray(ledger.rows),'Actual plugin event ledger required');
|
|
28
|
+
assert.deepEqual(ledger.violations,[],'Plugin violations invalidate port evidence');
|
|
29
|
+
const select=kind=>ledger.rows.filter(r=>r.instanceId===p.instanceId&&r.kind===kind&&(kind==='mounted'||r.eventId===p.eventId));
|
|
30
|
+
const mount=select('mounted');assert.equal(mount.length,1,'Unique actual plugin mount required');
|
|
31
|
+
const event=select('event'),callback=select('callback');
|
|
32
|
+
assert.equal(event.length,1,'Unique actual plugin event required');assert.equal(callback.length,1,'Actual plugin callback completion required');
|
|
33
|
+
for(const row of [event[0],callback[0]]){
|
|
34
|
+
assert.equal(row.name,p.name,'Actual plugin event name mismatch');assert.deepEqual(row.payload,p.payload,'Actual plugin payload mismatch');
|
|
35
|
+
assert.deepEqual(row.props,mount[0].props,'Plugin instance props mismatch');
|
|
36
|
+
}
|
|
37
|
+
assert(ledger.rows.indexOf(mount[0])<ledger.rows.indexOf(event[0])&&ledger.rows.indexOf(event[0])<ledger.rows.indexOf(callback[0]),'Plugin lifecycle order mismatch');
|
|
38
|
+
const boundaries=assertion.boundaries.map(b=>observeCompletedBoundary(evidence,b));
|
|
39
|
+
return {componentId:assertion.target,sourceId:binding.sourceId,port:binding.name,kind:binding.kind,
|
|
40
|
+
plugin:{instanceId:p.instanceId,eventId:p.eventId,name:p.name,payload:structuredClone(event[0].payload)},boundaries,
|
|
41
|
+
observation:'public-plugin-callback-and-external-product-effects'};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function validateComponentPorts(plan,contract){
|
|
45
|
+
for(const a of plan.assertions??[])if(a.kind==='component-port'){
|
|
46
|
+
validateComponentPort(a,contract);
|
|
47
|
+
assert(!(plan.beforeTarget??[]).some(x=>x.kind==='press'),'Component port assertions are not supported in held snapshots');
|
|
48
|
+
const instance=plan.plugin?.events?.instances?.filter(x=>x.id===a.plugin.instanceId)??[];
|
|
49
|
+
assert.equal(instance.length,1,'Declared unique plugin instance required');
|
|
50
|
+
const events=instance[0].events.filter(x=>x.id===a.plugin.eventId);
|
|
51
|
+
assert.equal(events.length,1,'Declared unique plugin event required');
|
|
52
|
+
assert.equal(events[0].name,a.plugin.name,'Plugin event declaration mismatch');
|
|
53
|
+
assert.deepEqual(events[0].payload,a.plugin.payload,'Plugin payload declaration mismatch');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {spatialReferences} from './spatial-contract.mjs';
|
|
1
2
|
import {validatePageVisual} from './page-visual.mjs';
|
|
2
3
|
import assert from 'node:assert/strict';
|
|
3
4
|
|
|
@@ -21,7 +22,7 @@ export function validateComponentStates(plan,contract){
|
|
|
21
22
|
for(const a of plan.assertions??[]){
|
|
22
23
|
if(a.kind==='page-visual'){validatePageVisual(a);assert(contract.page?.sourceId&&contract.page.route,'Compiler-bound page required');assert(!(plan.beforeTarget??[]).some(x=>x.kind==='press'),'Page visual is not supported during held input');continue;}
|
|
23
24
|
reference(a);
|
|
24
|
-
for(const ref of
|
|
25
|
+
for(const ref of (a.spatial?spatialReferences(a.spatial):[]))reference(ref);
|
|
25
26
|
if(a.kind==='component-state')assert(a.componentState!==undefined,'Branch state required');
|
|
26
27
|
if(a.componentState===undefined)continue;
|
|
27
28
|
assert(typeof a.componentState==='string'&&a.componentState.length>0,'Nonempty componentState required');
|
|
@@ -40,7 +40,13 @@ export async function captureHeldSnapshot(root,spec){
|
|
|
40
40
|
if(groups.every(g=>g.target.waitForPresence===false||g.nodes.length)||now()-start.time>=350)break;
|
|
41
41
|
await new Promise(resolve=>{const timer=setTimeout(resolve,Math.max(0,350-(now()-start.time)));requestAnimationFrame(()=>{clearTimeout(timer);resolve();});});
|
|
42
42
|
}
|
|
43
|
-
const captureStartedAt=now();const membership=spec.componentContract?(${sampleDeclaredComponents.toString()})(root):undefined;const result={version:spec.version,...(membership?{componentMembership:membership}:{}),captureStartedAt,pressAt:start.time,timedOut:groups.some(g=>g.target.waitForPresence!==false&&!g.nodes.length),groups:groups.map(g=>({key:g.target.key,target:g.target.target,instance:g.target.instance,count:g.nodes.length,members:g.nodes.map(node=>{const selected=(${resolveVisualCarrier.toString()})(node,g.target.carrier??'semantic-root');const m=metrics(selected);m.frameId=spec.frameId;for(const p of [...m.imagePaints,...m.imagePaintInventory])p.frameId=spec.frameId;m.text=node.textContent.trim();m.styles={...m.styles,...style(node,{keys:g.target.styleKeys})};return {identity:identity(node),componentState:(${sampleComponentState.toString()})(node),state:state(node),active:pressed(node),metrics:m,textPaint:paint(node)};})})),eventsAtSnapshot:timeline.events.slice()};result.
|
|
43
|
+
const captureStartedAt=now();const membership=spec.componentContract?(${sampleDeclaredComponents.toString()})(root):undefined;const result={version:spec.version,...(membership?{componentMembership:membership}:{}),captureStartedAt,pressAt:start.time,timedOut:groups.some(g=>g.target.waitForPresence!==false&&!g.nodes.length),groups:groups.map(g=>({key:g.target.key,target:g.target.target,instance:g.target.instance,count:g.nodes.length,members:g.nodes.map(node=>{const selected=(${resolveVisualCarrier.toString()})(node,g.target.carrier??'semantic-root');const m=metrics(selected);m.frameId=spec.frameId;for(const p of [...m.imagePaints,...m.imagePaintInventory])p.frameId=spec.frameId;m.text=node.textContent.trim();m.styles={...m.styles,...style(node,{keys:g.target.styleKeys})};return {identity:identity(node),componentState:(${sampleComponentState.toString()})(node),state:state(node),active:pressed(node),metrics:m,textPaint:paint(node)};})})),eventsAtSnapshot:timeline.events.slice()};result.spatialOwnership=(spec.spatialOwnership??[]).map(pair=>{
|
|
44
|
+
const parents=groups.find(g=>g.target.key===pair.parentKey),children=groups.find(g=>g.target.key===pair.childKey);
|
|
45
|
+
if(!parents||!children)throw Error('Missing held ownership targets');
|
|
46
|
+
const p=parents.nodes.length===1?(${resolveVisualCarrier.toString()})(parents.nodes[0],parents.target.carrier??'semantic-root'):null;
|
|
47
|
+
const c=children.nodes.length===1?(${resolveVisualCarrier.toString()})(children.nodes[0],children.target.carrier??'semantic-root'):null;
|
|
48
|
+
return {...pair,contains:!!(p&&c&&p!==c&&p.contains(c))};
|
|
49
|
+
});result.snapshotAt=now();return result;
|
|
44
50
|
}`;
|
|
45
51
|
return freezeHeldSnapshot(await root.locator(':root').evaluate(Function('return ('+body+')')(),spec));
|
|
46
52
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import {validateRegionOrder} from './region-order.mjs';
|
|
3
|
-
import {validateSpatial,spatialTarget as target} from './spatial-contract.mjs';
|
|
3
|
+
import {validateSpatial,spatialReferences,spatialTarget as target} from './spatial-contract.mjs';
|
|
4
4
|
export {validateSpatial} from './spatial-contract.mjs';
|
|
5
5
|
export function prepareHeldWithSpatial(plan,prepareProject){
|
|
6
6
|
const clean=structuredClone(plan);
|
|
@@ -10,9 +10,10 @@ export function prepareHeldWithSpatial(plan,prepareProject){
|
|
|
10
10
|
for(const a of plan.assertions){
|
|
11
11
|
if(a.visual?.carrier==='exclusive-parent'){const t=target({target:a.target,instance:a.instance,carrier:'exclusive-parent'});if(!result.targets.some(existing=>existing.key===t.key))result.targets.push(t);}
|
|
12
12
|
if(!a.spatial)continue;
|
|
13
|
-
const refs=
|
|
13
|
+
const refs=spatialReferences(a.spatial);
|
|
14
14
|
for(const ref of refs){const t=target(ref);if(!result.targets.some(existing=>existing.key===t.key))result.targets.push(t);}
|
|
15
15
|
}
|
|
16
|
+
result.spatialOwnership=plan.assertions.flatMap(a=>(a.spatial?.ownership??[]).map(p=>({parentKey:target(p.parent).key,childKey:target(p.child).key})));
|
|
16
17
|
return result;
|
|
17
18
|
}
|
|
18
19
|
export function validateFrozenSpatial(snapshot,spec){
|
|
@@ -21,5 +22,39 @@ export function validateFrozenSpatial(snapshot,spec){
|
|
|
21
22
|
const box=ref=>{const g=group(ref);assert.equal(g.count,1,'Spatial target must be unique');assert.equal(g.members.length,1);const m=g.members[0];assert(m.state.visible,'Spatial target must be visible');return m.metrics.box;};
|
|
22
23
|
const order=(spec.order??[]).map(pair=>validateRegionOrder(box(pair.above),box(pair.below)));
|
|
23
24
|
const absent=(spec.absent??[]).map(ref=>{const g=group(ref);assert.equal(g.count,0,'Forbidden component exists');assert.equal(g.members.length,0);return ref;});
|
|
24
|
-
return
|
|
25
|
+
const member=ref=>{box(ref);return group(ref).members[0];};
|
|
26
|
+
const ownership=(spec.ownership??[]).map(pair=>{
|
|
27
|
+
member(pair.parent);member(pair.child);
|
|
28
|
+
const actual=snapshot.spatialOwnership?.find(p=>p.parentKey===target(pair.parent).key&&p.childKey===target(pair.child).key);
|
|
29
|
+
assert(actual?.contains===true,'Actual frozen native ownership required');return actual;
|
|
30
|
+
});
|
|
31
|
+
const align=(spec.align??[]).map(item=>{
|
|
32
|
+
const subject=member(item.subject),reference=member(item.reference);
|
|
33
|
+
assert(subject.metrics.frameId&&subject.metrics.frameId===reference.metrics.frameId,'Same native frame required for alignment');
|
|
34
|
+
const width=reference.metrics.viewport?.width;
|
|
35
|
+
assert(Number.isFinite(width)&&width>0&&subject.metrics.viewport?.width===width,'Same viewport required for alignment');
|
|
36
|
+
let measured=subject.metrics.box;
|
|
37
|
+
if(item.subjectMeasure==='text'){
|
|
38
|
+
const paint=subject.textPaint;assert(paint?.entries?.length,'Actual owned text paint required');
|
|
39
|
+
for(const entry of paint.entries){
|
|
40
|
+
assert(entry.owned&&entry.visible&&entry.opacity>0,'Owned visible text paint required');
|
|
41
|
+
const r=entry.range;
|
|
42
|
+
for(const bound of [...entry.clips,{...paint.viewport,clipX:true,clipY:true},{...paint.box,clipX:true,clipY:true}]){
|
|
43
|
+
if(bound.clipX)assert(r.x>=bound.x-.5&&r.x+r.width<=bound.x+bound.width+.5,'Alignment text is horizontally clipped');
|
|
44
|
+
if(bound.clipY)assert(r.y>=bound.y-.5&&r.y+r.height<=bound.y+bound.height+.5,'Alignment text is vertically clipped');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const rects=paint.entries.map(e=>e.range),x=Math.min(...rects.map(r=>r.x)),y=Math.min(...rects.map(r=>r.y));
|
|
48
|
+
measured={x,y,width:Math.max(...rects.map(r=>r.x+r.width))-x,height:Math.max(...rects.map(r=>r.y+r.height))-y};
|
|
49
|
+
}
|
|
50
|
+
const coordinate=(rect,edge)=>{
|
|
51
|
+
for(const field of ['x','y','width','height'])assert(Number.isFinite(rect[field]),'Finite native geometry required');
|
|
52
|
+
assert(rect.width>0&&rect.height>0,'Nonempty native geometry required');
|
|
53
|
+
return rect[item.axis]+rect[item.axis==='x'?'width':'height']*({start:0,center:.5,end:1}[edge]);
|
|
54
|
+
};
|
|
55
|
+
const actual=coordinate(measured,item.subjectEdge)-coordinate(reference.metrics.box,item.referenceEdge),expected=item.offset*width/item.designWidth;
|
|
56
|
+
assert(Math.abs(actual-expected)<=.5,`Frozen relative alignment differs: ${actual} != ${expected}`);
|
|
57
|
+
return {actual,expected,axis:item.axis,subjectMeasure:item.subjectMeasure??'box'};
|
|
58
|
+
});
|
|
59
|
+
return {order,absent,ownership,align,snapshotAt:snapshot.snapshotAt};
|
|
25
60
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {validateComponentPorts,observeComponentPort} from './component-port.mjs';
|
|
1
2
|
import {observePageVisual,assertPageRoute} from './page-visual.mjs';
|
|
2
3
|
import {sampleComponentState,assertComponentState,assertComponentBranch,validateComponentStates} from './component-state.mjs';
|
|
3
4
|
import {sampleDeclaredComponents,validateComponentMembership} from './component-membership.mjs';
|
|
@@ -32,7 +33,7 @@ function target(root, name) { return semanticLocator(root, name); }
|
|
|
32
33
|
|
|
33
34
|
export async function runMiniappCase({page, plan, testInfo}) {
|
|
34
35
|
validateRegionVisualAssertions(plan.assertions);
|
|
35
|
-
validateComponentStates(plan,plan.componentContract);
|
|
36
|
+
validateComponentStates(plan,plan.componentContract);validateComponentPorts(plan,plan.componentContract);
|
|
36
37
|
if(plan.assertions.some(a=>a.kind==='page-visual'))assertPageRoute(plan.componentContract.page,process.env.MINIFISH_PAGE_ROUTE);
|
|
37
38
|
assert(plan.componentContract?.schemaVersion===1,'Compiler-bound component membership contract required');
|
|
38
39
|
validateComponentMembership({declarations:[]},plan.componentContract.activeComponentIds);
|
|
@@ -122,11 +123,12 @@ export async function runMiniappCase({page, plan, testInfo}) {
|
|
|
122
123
|
if(!held.timing.eligible){result.inputValidity='invalid';result.target='invalidated';}
|
|
123
124
|
}else for (const assertion of plan.assertions) {
|
|
124
125
|
await synchronizeAnchors(app.root);
|
|
125
|
-
const locator=['host-navigation','page-visual'].includes(assertion.kind)?undefined:locate(app.root, assertion.target,{instance:assertion.instance});
|
|
126
|
+
const locator=['host-navigation','page-visual','component-port'].includes(assertion.kind)?undefined:locate(app.root, assertion.target,{instance:assertion.instance});
|
|
126
127
|
try {
|
|
127
128
|
result.observations.componentMembership=validateComponentMembership(await app.root.locator(':root').evaluate(sampleDeclaredComponents),plan.componentContract.activeComponentIds);
|
|
128
129
|
if(assertion.componentState!==undefined&&assertion.kind!=='component-state'){await expect(locator).toHaveCount(1);await expect(locator).toBeVisible();result.observations[assertion.id+':componentState']=assertComponentState(await locator.evaluate(sampleComponentState),assertion.componentState);}
|
|
129
|
-
if(assertion.kind==='
|
|
130
|
+
if(assertion.kind==='component-port'){result.observations[assertion.id]=observeComponentPort(assertion,plan.componentContract,app.evidence());
|
|
131
|
+
}else if(assertion.kind==='page-visual'){result.observations[assertion.id]=await observePageVisual(app.root,assertion,plan.componentContract.page);
|
|
130
132
|
}else if(assertion.kind==='component-state'){
|
|
131
133
|
const count=await locator.count(),members=[];assert(count<=1,'Unique branch owner required');
|
|
132
134
|
if(count)members.push({componentState:await locator.evaluate(sampleComponentState),visible:await locator.isVisible()});
|
|
@@ -6,7 +6,7 @@ export function createBoundary(definitions,policy) {
|
|
|
6
6
|
assert(Object.values(policy).every(v=>typeof v==='function'),'Explicit boundary policy required');
|
|
7
7
|
assert(new Set(definitions.map(d=>d.id)).size===definitions.length,'Duplicate boundary id');
|
|
8
8
|
validatePacketPlan(structuredClone(definitions));
|
|
9
|
-
const rows=definitions.map(item=>{assert(Number.isSafeInteger(item.times)&&item.times>0,'Positive exact request count required');validateIndependentFixture(structuredClone(item));return {...structuredClone(item),received:0,delivered:0,consumed:0,cancelled:0,released:false,callbacks:[]};});
|
|
9
|
+
const rows=definitions.map(item=>{assert(Number.isSafeInteger(item.times)&&item.times>0,'Positive exact request count required');validateIndependentFixture(structuredClone(item));return {...structuredClone(item),received:0,delivered:0,consumed:0,cancelled:0,released:false,callbacks:[],actualRequests:[]};});
|
|
10
10
|
const requiresConsumption=r=>!!r.requireConsumption||!!requiresSourceConsumption(structuredClone(r));
|
|
11
11
|
const attempts=[],violations=[],pending=new Map();let closed=false;
|
|
12
12
|
const effective=r=>r.outcome.kind==='hold'?r.released?r.outcome.release:null:r.outcome;
|
|
@@ -22,7 +22,7 @@ export function createBoundary(definitions,policy) {
|
|
|
22
22
|
const row=select(operation,request,r=>r.received<r.times);
|
|
23
23
|
assert(row,`Unexpected operation, parameters or duplicate call: ${operation}`);
|
|
24
24
|
for(const id of row.after??[]){const prior=rows.find(r=>r.id===id);assert(prior&&prior.delivered===prior.times,`Boundary before required response ${id}`);}
|
|
25
|
-
assert(!closed,'Boundary is closed');assert(!row.execution,'Official execution cannot receive a Mock response');row.received++;
|
|
25
|
+
assert(!closed,'Boundary is closed');assert(!row.execution,'Official execution cannot receive a Mock response');row.received++;row.actualRequests.push(structuredClone({operation,request,...(wire!==undefined?{wire}:{})}));
|
|
26
26
|
if(row.outcome.kind==='hold')await new Promise((resolve,reject)=>pending.set(row.id,[...(pending.get(row.id)??[]),{resolve,reject,row}]));
|
|
27
27
|
return result(row);
|
|
28
28
|
}catch(e){if(e.message!=='Controlled held call cancelled at case cleanup')violations.push(e.message);throw e;}
|
|
@@ -31,7 +31,7 @@ export function createBoundary(definitions,policy) {
|
|
|
31
31
|
recordNativeUiRequest(operation,request,wire){try{
|
|
32
32
|
assert(!closed,'Boundary is closed');const r=select(operation,request,r=>r.execution==='official-ui'&&r.received===0);assert(r,'Unexpected, wrong or duplicate official UI request');
|
|
33
33
|
for(const id of r.after??[]){const prior=rows.find(r=>r.id===id);assert(prior?.delivered===prior.times,'Official UI before '+id);}
|
|
34
|
-
r.received++;attempts.push(structuredClone({operation,request,wire,execution:'official-ui'}));return r.id;
|
|
34
|
+
r.received++;r.actualRequests.push(structuredClone({operation,request,wire,execution:'official-ui'}));attempts.push(structuredClone({operation,request,wire,execution:'official-ui'}));return r.id;
|
|
35
35
|
}catch(e){violations.push(e.message);throw e;}},
|
|
36
36
|
recordNativeUiResult(operation,request,receipt){try{
|
|
37
37
|
attempts.push(structuredClone({operation,request,execution:'official-ui-result',receipt}));
|
|
@@ -40,7 +40,7 @@ export function createBoundary(definitions,policy) {
|
|
|
40
40
|
assert.deepEqual(receipt.officialTarget,actualRequest.wire.officialTarget,'Actual result must belong to the same official host request');
|
|
41
41
|
r.delivered++;r.officialResult=structuredClone(receipt);
|
|
42
42
|
}catch(e){violations.push(e.message);throw e;}},
|
|
43
|
-
recordOfficialRequest(operation,request){try{assert(!closed,'Boundary is closed');const r=select(operation,request,r=>r.execution==='official-scroll'&&r.received===0);assert(r,'Unexpected official scroll request');for(const id of r.after??[]){const prior=rows.find(r=>r.id===id);assert(prior?.delivered===prior.times,'Official scroll before '+id);}r.received++;attempts.push(structuredClone({operation,request,execution:'official-scroll'}));}catch(e){violations.push(e.message);throw e;}},
|
|
43
|
+
recordOfficialRequest(operation,request){try{assert(!closed,'Boundary is closed');const r=select(operation,request,r=>r.execution==='official-scroll'&&r.received===0);assert(r,'Unexpected official scroll request');for(const id of r.after??[]){const prior=rows.find(r=>r.id===id);assert(prior?.delivered===prior.times,'Official scroll before '+id);}r.received++;r.actualRequests.push(structuredClone({operation,request,execution:'official-scroll'}));attempts.push(structuredClone({operation,request,execution:'official-scroll'}));}catch(e){violations.push(e.message);throw e;}},
|
|
44
44
|
recordOfficialCallback(operation,request,callback){try{const r=select(operation,request,r=>r.execution==='official-scroll'&&r.received===1&&r.delivered===0);assert(r,'Official callback without request or duplicate');assert.equal(callback?.kind,'success','Official scroll success required');assert.deepEqual(callback.value,r.outcome.value,'Actual official scroll result');r.delivered++;r.consumed++;r.callbacks.push(structuredClone(callback));}catch(e){violations.push(e.message);throw e;}},
|
|
45
45
|
markDelivered(operation,request){try{const r=select(operation,request,r=>r.delivered<r.received&&effective(r)&&!r.cancelled);assert(r,'Unexpected response delivery');r.delivered++;}catch(e){violations.push(e.message);throw e;}},
|
|
46
46
|
markConsumed(operation,request,callback){try{const r=select(operation,request,r=>r.consumed<r.received&&effective(r)&&!r.cancelled);assert(r,'Unexpected response consumption');const expected=effective(r).kind==='error'||effective(r).value?.$testTransportFailure?'fail':'success';if(r.requireConsumption||callback?.kind)assert.equal(callback?.kind,expected,'Unexpected callback type');r.consumed++;r.callbacks.push(structuredClone(callback??{}));}catch(e){violations.push(e.message);throw e;}},
|
|
@@ -9,12 +9,31 @@ export function spatialTarget(ref){
|
|
|
9
9
|
return {...ref,key,styleKeys:[],waitForPresence:false};
|
|
10
10
|
}
|
|
11
11
|
export function validateSpatial(spec){
|
|
12
|
-
assert(spec&&Object.keys(spec).every(k=>['order','absent'].includes(k)),'Unknown spatial obligation');
|
|
13
|
-
for(const name of ['order','absent'])assert(spec[name]===undefined||Array.isArray(spec[name]),'Spatial obligation must be an array');
|
|
14
|
-
assert(
|
|
12
|
+
assert(spec&&Object.keys(spec).every(k=>['order','absent','ownership','align'].includes(k)),'Unknown spatial obligation');
|
|
13
|
+
for(const name of ['order','absent','ownership','align'])assert(spec[name]===undefined||Array.isArray(spec[name]),'Spatial obligation must be an array');
|
|
14
|
+
assert(['order','absent','ownership','align'].some(k=>spec[k]?.length),'Empty spatial obligation');
|
|
15
15
|
for(const pair of spec.order??[]){
|
|
16
16
|
assert(pair&&Object.keys(pair).length===2&&pair.above&&pair.below,'Order pair required');
|
|
17
17
|
assert.notEqual(spatialTarget(pair.above).key,spatialTarget(pair.below).key,'Order requires distinct targets');
|
|
18
18
|
}
|
|
19
19
|
for(const ref of spec.absent??[])spatialTarget(ref);
|
|
20
|
+
for(const pair of spec.ownership??[]){
|
|
21
|
+
assert(pair&&Object.keys(pair).length===2&&pair.parent&&pair.child,'Ownership parent/child required');
|
|
22
|
+
assert.notEqual(spatialTarget(pair.parent).key,spatialTarget(pair.child).key,'Ownership requires distinct targets');
|
|
23
|
+
}
|
|
24
|
+
for(const item of spec.align??[]){
|
|
25
|
+
assert(item&&Object.keys(item).every(k=>['subject','reference','axis','subjectEdge','referenceEdge','subjectMeasure','offset','designWidth'].includes(k)),'Unknown alignment field');
|
|
26
|
+
const subject=spatialTarget(item.subject),reference=spatialTarget(item.reference);
|
|
27
|
+
assert(['x','y'].includes(item.axis),'Alignment axis required');
|
|
28
|
+
for(const edge of [item.subjectEdge,item.referenceEdge])assert(['start','center','end'].includes(edge),'Alignment edge required');
|
|
29
|
+
assert(item.subjectMeasure===undefined||['box','text'].includes(item.subjectMeasure),'Unsupported alignment measurement');
|
|
30
|
+
assert(Number.isFinite(item.offset),'Explicit finite alignment offset required');
|
|
31
|
+
assert(Number.isFinite(item.designWidth)&&item.designWidth>0,'Positive alignment designWidth required');
|
|
32
|
+
assert(subject.key!==reference.key||item.subjectMeasure==='text','Self box alignment is not an obligation');
|
|
33
|
+
assert(item.subjectMeasure!=='text'||!item.subject.carrier||item.subject.carrier==='semantic-root','Text alignment requires semantic root');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function spatialReferences(spec){
|
|
37
|
+
validateSpatial(spec);
|
|
38
|
+
return [...(spec.absent??[]),...(spec.order??[]).flatMap(p=>[p.above,p.below]),...(spec.ownership??[]).flatMap(p=>[p.parent,p.child]),...(spec.align??[]).flatMap(p=>[p.subject,p.reference])];
|
|
20
39
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Inspect explicit custom-component template chains from committed product files.
|
|
2
|
+
|
|
3
|
+
This proves static registration and callback wiring, not data flow or reachability.
|
|
4
|
+
"""
|
|
5
|
+
import json
|
|
6
|
+
import posixpath
|
|
7
|
+
from html.parser import HTMLParser
|
|
8
|
+
|
|
9
|
+
class Tags(HTMLParser):
|
|
10
|
+
def __init__(self):
|
|
11
|
+
super().__init__(convert_charrefs=True);self.rows=[]
|
|
12
|
+
def handle_starttag(self,tag,attrs):
|
|
13
|
+
names=[k for k,v in attrs]
|
|
14
|
+
if len(names)!=len(set(names)):raise ValueError('Duplicate template attributes')
|
|
15
|
+
self.rows.append((tag,dict(attrs)))
|
|
16
|
+
def handle_startendtag(self,tag,attrs):self.handle_starttag(tag,attrs)
|
|
17
|
+
|
|
18
|
+
def inspect_chain(read,product_source_root,chain,callback):
|
|
19
|
+
if not isinstance(chain,list) or not chain:raise ValueError('Explicit component template chain required')
|
|
20
|
+
prefix=product_source_root+'/'
|
|
21
|
+
def safe(name):
|
|
22
|
+
if not isinstance(name,str) or not name.startswith(prefix) or posixpath.normpath(name)!=name:raise ValueError('Component chain escaped product source root')
|
|
23
|
+
return name
|
|
24
|
+
first=None;expected=None;seen=set();files={}
|
|
25
|
+
def load(name):
|
|
26
|
+
name=safe(name);raw=read(name);files[name]=raw;return raw.decode()
|
|
27
|
+
for link in chain:
|
|
28
|
+
template=safe(link['template']);tag=link['tag']
|
|
29
|
+
if template in seen:raise ValueError('Cyclic component template chain')
|
|
30
|
+
seen.add(template)
|
|
31
|
+
if expected is not None and template!=expected+'.axml':raise ValueError('Template chain skips registered component')
|
|
32
|
+
if not template.endswith('.axml'):raise ValueError('AXML template required')
|
|
33
|
+
parsed=Tags();parsed.feed(load(template));parsed.close()
|
|
34
|
+
rows=[attrs for name,attrs in parsed.rows if name==tag]
|
|
35
|
+
if len(rows)!=1:raise ValueError('Unique component tag instance required for static binding')
|
|
36
|
+
if first is None:
|
|
37
|
+
handler=rows[0].get(callback.lower())
|
|
38
|
+
if not handler or not handler.isidentifier():raise ValueError('Literal published callback handler required')
|
|
39
|
+
first={'template':template,'handler':handler}
|
|
40
|
+
config=json.loads(load(template[:-5]+'.json'))
|
|
41
|
+
target=config.get('usingComponents',{}).get(tag)
|
|
42
|
+
if not isinstance(target,str) or not target or '://' in target:raise ValueError('Local registered component required')
|
|
43
|
+
expected=posixpath.normpath(product_source_root+target if target.startswith('/') else posixpath.join(posixpath.dirname(template),target))
|
|
44
|
+
safe(expected)
|
|
45
|
+
product_source=load(expected+'.js')
|
|
46
|
+
host_stem=first['template'][:-5]
|
|
47
|
+
try:host_source=load(host_stem+'.ts')
|
|
48
|
+
except FileNotFoundError:host_source=load(host_stem+'.js')
|
|
49
|
+
return {**first,'productSource':product_source,'hostSource':host_source,'generatedFile':expected+'.js','files':files}
|