@antprofuse/saddle-islands-tools 0.1.12 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antprofuse/saddle-islands-tools",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "Independent Saddle–Islands engineering tools",
5
5
  "files": [
6
6
  "tools"
@@ -0,0 +1,62 @@
1
+ """Resolve compiled branch locations without inventing component identities.
2
+
3
+ This resolves source references only; it does not observe or prove product state.
4
+ """
5
+ from copy import deepcopy
6
+ from tools.modules.minifish.component_index import validate
7
+
8
+
9
+ def _locations(row):
10
+ source = row.get('source', {}).get('file')
11
+ locations = row.get('locations')
12
+ if not isinstance(source, str) or not source or not isinstance(locations, list) or not locations:
13
+ raise ValueError('Complete compiled source locations required')
14
+ result = []
15
+ for loc in locations:
16
+ if loc.get('kind') != 'visualComponent':
17
+ raise ValueError('Visual component location required')
18
+ page, path = loc.get('page'), loc.get('componentPath')
19
+ # Official top-level locations omit componentPath; nested ones include it.
20
+ if 'componentPath' not in loc:
21
+ path = [{'component': loc.get('component')}]
22
+ if not isinstance(page, str) or not page or not isinstance(path, list) or not path:
23
+ raise ValueError('Complete component path required')
24
+ for segment in path:
25
+ if not isinstance(segment, dict) or set(segment)-{'component', 'branch'}:
26
+ raise ValueError('Unsupported component location segment')
27
+ if any(not isinstance(value, str) or not value for value in segment.values()) or 'component' not in segment:
28
+ raise ValueError('Invalid component location segment')
29
+ if loc.get('component') != path[-1]['component']:
30
+ raise ValueError('Component name disagrees with compiled path')
31
+ result.append((source, page, deepcopy(path)))
32
+ return result
33
+
34
+
35
+ def resolve_branch(inventory, index, retired, branch_id):
36
+ """Bind an exact official branch ID to one cmp and its terminal state.
37
+
38
+ All ancestor branch segments are retained. No suffix/name matching or ID
39
+ reconstruction is allowed. Multiple inconsistent locations fail closed.
40
+ """
41
+ active = validate(index, inventory, retired)
42
+ rows = inventory['targets']
43
+ branch = next((r for r in rows if r['id'] == branch_id), None)
44
+ if not branch or branch.get('kind') != 'branch':
45
+ raise ValueError('Exact official branch reference required')
46
+ reverse = {source: key for key, source in active.items()}
47
+ components = [(r, _locations(r)) for r in rows if r.get('kind') == 'component']
48
+ matches = []
49
+ for file, page, path in _locations(branch):
50
+ state = path[-1].pop('branch', None)
51
+ if state is None:
52
+ raise ValueError('Branch location has no terminal state')
53
+ candidates = [r for r, locations in components if (file, page, path) in locations]
54
+ if len(candidates) != 1:
55
+ raise ValueError('Branch owner must resolve uniquely by compiled location')
56
+ matches.append((reverse[candidates[0]['id']], candidates[0]['id'], state))
57
+ if len(set(matches)) != 1:
58
+ raise ValueError('Inconsistent branch owner locations')
59
+ component, source_id, state = matches[0]
60
+ return {'component': component, 'sourceId': source_id, 'state': state,
61
+ 'branchSourceId': branch_id, 'evidence': 'compiled-location',
62
+ 'observed': False}
@@ -41,7 +41,7 @@ Legacy component-host integrations must supply an explicit `repositoryRoot` to `
41
41
 
42
42
  可选 `carrier` 默认为 `semantic-root`。`source-content-group` 在语义root内对可见文字/图像取最低公共元素祖先;`owned-text-block` 在该组内要求唯一不属于其他语义目标的文字分支。选择与期待样式、class、原型HTML标签无关,候选缺失或歧义明确失败;不适用的拓扑不得借此猜测观察目标。`viewportInsets` 可声明 left/right/top/bottom 到业务frame视口的设计长度,返回采样记录包含原root盒及是否选中root。
43
43
 
44
- 按压快照目前只支持 `semantic-root`,其他carrier在按下前拒绝,不能释放后采样冒充按住状态。这是明确能力限制,不能默默降级。内容分组不覆盖背景装饰/无文字无图像部件或Shadow DOM;相应责任另用适用的公开观察合同。
44
+ 按压快照支持 `semantic-root` 和 `exclusive-parent`,其他carrier在按下前拒绝,不能释放后采样冒充按住状态。这是明确能力限制,不能默默降级。内容分组不覆盖背景装饰/无文字无图像部件或Shadow DOM;相应责任另用适用的公开观察合同。
45
45
 
46
46
  ### 全量定义编译门禁
47
47
 
@@ -88,3 +88,25 @@ MT使用 `iteration stage-gate` 时必须提供 `--compile-result NEW_EXTERNAL_D
88
88
  按压region断言可声明公共 `spatial`:`order: [{above: {target, instance?}, below: {target, instance?}}]` 与 `absent: [{target, instance?}]`。目标使用cmp引用。该版本只支持按压region,其他用法在静态编译及运行输入前拒绝。原有普通区域顺序公共接口保持独立,不静默解释该字段。
89
89
 
90
90
  公共准备将该字段与项目核心按压合同分开校验;一次held快照同时记录原目标、关系目标的几何及排除目标的计数。补充目标不触发等待出现;释放后只判定冻结观察,错误时仍释放。输入350ms限制不变。关系要求两个唯一可见、非重叠且上方在前的真实载体;排除要求零匹配。缺观察、重复身份、隐藏关系目标及未知声明失败。本控制经过真实浏览器单快照验证,不等同于每个业务小程序已经验收。
91
+
92
+
93
+ ## 组件引用、源状态与关联载体
94
+
95
+ `component-index resolve` 沿用 `--inventory`、`--inventory-sha256`、`--index`、`--index-sha256`、`--retired`、`--retired-sha256` 和外部 `--tool-source`,另传 `--source-id` 官方原文。组件返回分配ID;分支根据官方编译locations精确归属到组件并返回 `state`,保留祖先分支。页面、歧义或不完整来源拒绝,不猜测近似匹配。输出 `observed:false` 仅表示源定位,不能充当产品状态证据。
96
+
97
+ 分支断言仍以 `target: "cmp_123abc"` 指定组件,并添加 `componentState: "源分支名"`。产品在同一组件根通过 `data-anchor-state` 声明实际状态,原生 `$$dataset.anchorState` 优先;公共工具独立比较实际声明,编译器只允许该组件正式源中存在的状态。不得为了测试写死预期状态:声明由实际产品状态产生,不驱动业务逻辑。该检查不代替分支的内容、样式、行为断言。缺失/错误状态必须失败;存在性为零的断言不能声称观察到状态。
98
+
99
+ `visual.carrier: "exclusive-parent"` 观察组件根的直接无锚点父包装。父包装必须仅包含这个元素子节点,不能另有非空文本,不能是document根、body或其他组件。它不成为新组件或新覆盖点;选择过程不依赖预期颜色、尺寸、类名。不符合拓扑就报告不支持,不上溯搜索一个有利节点。普通观察可使用 `nativeMetrics(locator,{carrier:'exclusive-parent'})`,业务几何期望仍来自源。held的visual和spatial引用支持该carrier,随原目标在同一次按压快照采样,释放后纯判定。
100
+
101
+ 每个编译计划由公共编译器绑定冻结配置、源清单、活动/退役索引的Git提交和SHA;项目编译器不得提供或覆盖该合同。目标、动作、空间关系中的组件引用必须属于活动集合。实际运行在每个目标状态检查全部锚点声明均属于活动集合,包括隐藏节点;held使用同一次快照。该能力承接旧路径残留排除,无需为源中已删除对象分配活动ID或维护负ID列表。它不证明无标注节点或错误借用有效ID不存在,也不代替Spec规定的条件隐藏断言。产品静态集合检查及逐点内容/布局覆盖继续执行。
102
+
103
+
104
+ 分支未出现与组件本身隐藏必须区分。仅检查分支是否出现时使用 `{kind:"component-state", target:"cmp_123abc", componentState:"源分支名", present:false}`;同一组件可处于另一个正式状态而保持可见。公共工具观察唯一组件、实际状态和可见性,未知或缺失状态不能冒充“该分支不存在”。`present:true` 验证当前可见分支。它仍是原case内的断言,不新增平行case;分支内部的具体组件及内容继续独立断言。
105
+
106
+ ### 页面视觉(非组件身份)
107
+
108
+ `kind: "page-visual"` 保留页面视觉责任,不声明 `target`、`instance` 或组件状态,不进入cmp覆盖计数。期望支持 `fullWidth: true`、`fullHeight: true`、`noHorizontalOverflow: true`、`backgroundColor: "rgb(...)"`,至少声明一项;暂不支持按住阶段。尺寸是业务页视口的完整宽高(含起点),不是宿主壳或某个组件的尺寸。
109
+
110
+ Owner在项目配置填写 `pageSourceId`,精确引用冻结 `anchorInventory` 中 `kind: page` 的源ID;与已有 `pageRoute` 一并绑定到编译产物。公共编译拒绝未知页面、缺绑定及借用组件身份,执行前拒绝与冻结绑定不同的运行路由。
111
+
112
+ 产品在业务页面唯一根节点声明 `data-page-root="true"`,它是布尔页面载体标记,不是组件ID,不加入 `.registry`。工具在已选择的业务frame内采集该根节点,Minifish原生 `$$dataset.pageRoot` 优先;缺失、重复或非法标记失败。不按期望颜色、尺寸、CSS类猜选根节点。背景取该实际根的计算值,水平溢出同时检查该根和业务文档;运行证据保留源页面绑定与实际几何。测试阶段静态验证声明和绑定,实际渲染结果由产品阶段验证。
@@ -17,7 +17,7 @@ async function fixture(){
17
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
18
  return {root,project,configFile,config,loaded:await loadProject(project,configFile)};
19
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'));for(const name of ['held-spatial.mjs','spatial-contract.mjs','region-order.mjs','visual-expectation.mjs','visual-carrier.mjs','jsapi.mjs','browser.mjs','official-bridge-dispatch.mjs']){assert(records.some(r=>r.kind==='tool'&&r.path==='harness/'+name));assert((await readFile(path.join(output,'tools/harness',name),'utf8')).length>0);}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});}});
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'));for(const name of ['component-membership.mjs','held-spatial.mjs','spatial-contract.mjs','region-order.mjs','visual-expectation.mjs','visual-carrier.mjs','jsapi.mjs','browser.mjs','official-bridge-dispatch.mjs']){assert(records.some(r=>r.kind==='tool'&&r.path==='harness/'+name));assert((await readFile(path.join(output,'tools/harness',name),'utf8')).length>0);}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
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
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
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/);});
@@ -1,3 +1,4 @@
1
+ import {validatePageVisual} from '../harness/page-visual.mjs';
1
2
  import {validateSpatial} from '../harness/spatial-contract.mjs';
2
3
  import assert from 'node:assert/strict';
3
4
 
@@ -10,12 +11,13 @@ export function sourceComponents(inventory,active,retired){
10
11
  }
11
12
  // Only actual target assertions count. Actions, sourceTarget, basis and nested
12
13
  // strings are never recursively treated as evidence of an executed assertion.
13
- const kinds=new Set(['region','hidden','visible','text','image','text-visual','button-visual','host-navigation','host-native-ui','carousel-frame']);
14
+ const kinds=new Set(['component-state','region','hidden','visible','text','image','text-visual','button-visual','host-navigation','host-native-ui','carousel-frame']);
14
15
  export function assertionComponents(plan){
15
16
  assert(Array.isArray(plan?.assertions)&&plan.assertions.length,'Compiled target assertions required');
16
17
  const seen=new Set(),components=new Set();
17
18
  for(const a of plan.assertions){
18
19
  assert(a&&typeof a.id==='string'&&a.id&&!seen.has(a.id),'Unique assertion identity required');seen.add(a.id);
20
+ if(a.kind==='page-visual'){validatePageVisual(a);assert(!(plan.beforeTarget??[]).some(x=>x.kind==='press'),'Page visual is not supported during held input');continue;}
19
21
  if(a.spatial!==undefined){
20
22
  assert(a.kind==='region'&&(plan.beforeTarget??[]).some(x=>x.kind==='press'),'Spatial snapshot currently requires held region assertion');
21
23
  validateSpatial(a.spatial);
@@ -1,3 +1,5 @@
1
+ import {validateComponentStates} from '../harness/component-state.mjs';
2
+ import {loadComponentContract} from './component-contract.mjs';
1
3
  import assert from 'node:assert/strict';
2
4
  import path from 'node:path';
3
5
  import {readFile,mkdir,writeFile,realpath} from 'node:fs/promises';
@@ -22,7 +24,11 @@ export async function buildCase(file,output,options){
22
24
  return buildPlanPackage(plan,output,options);
23
25
  }
24
26
  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});
27
+ 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
+ assert(plan.componentContract===undefined,'Project must not override compiler component contract');
29
+ plan={...plan,componentContract:await loadComponentContract(project)};
30
+ validateComponentStates(plan,plan.componentContract);
31
+ await mkdir(destination,{recursive:true});
26
32
  const files=await packageRunner(project,path.join(destination,'runner'),plan.observationModule);
27
33
  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
34
  const artifact=path.join(destination,'case.spec.mjs');await writeFile(artifact,body);
@@ -1,4 +1,6 @@
1
- import {sourceComponents,assertionComponents,compareCoverage} from './anchor-coverage.mjs';
1
+ import {validateComponentStates} from '../harness/component-state.mjs';
2
+ import {loadComponentContract} from './component-contract.mjs';
3
+ import {assertionComponents,compareCoverage} from './anchor-coverage.mjs';
2
4
  import assert from 'node:assert/strict';
3
5
  import path from 'node:path';
4
6
  import {readFile,realpath,mkdir,writeFile} from 'node:fs/promises';
@@ -38,20 +40,11 @@ export async function compileCheck(options){
38
40
  const manifest=await readTracked(options.manifest),catalog=await readTracked(path.resolve(root,config.catalog));
39
41
  const parsedManifest=JSON.parse(manifest.bytes),parsedCatalog=JSON.parse(catalog.bytes);
40
42
  const rows=activeCases(parsedManifest,parsedCatalog);
41
- assert(config.anchorInventory?.path&&/^[a-f0-9]{64}$/.test(config.anchorInventory.sha256??''),'Owner-frozen anchorInventory required');
42
- const inventory=await readTracked(path.resolve(root,config.anchorInventory.path));
43
- assert.equal(sha(inventory.bytes),config.anchorInventory.sha256,'Source component inventory hash mismatch');
44
- assert(projectPath.endsWith('/code/minifish-tests'),'Expected business test directory');
45
- const registryPath=projectPath.slice(0,-'minifish-tests'.length)+'.registry';
46
- assert(!git('status','--porcelain','--untracked-files=all','--',registryPath),'Registry must be clean');
47
- const registry={},registryText=[];
48
- for(const name of ['anchors.jsonc','anchors-retired.jsonc']){
49
- const relative=registryPath+'/'+name;
50
- assert(git('ls-tree',head,'--',relative).startsWith('100644 '),'Regular committed registry file required');
51
- const bytes=execFileSync('git',['-C',repo,'show',head+':'+relative]);
52
- registry[name]={path:relative,sha256:sha(bytes)};registryText.push(bytes.toString('utf8'));
53
- }
54
- const sourceComponentIds=sourceComponents(JSON.parse(inventory.bytes),...registryText);
43
+ const componentContract=await loadComponentContract(project);
44
+ assert.equal(componentContract.head,head,'Component contract candidate mismatch');
45
+ const inventory=componentContract.inventory;
46
+ const registry={'anchors.jsonc':componentContract.registry.active,'anchors-retired.jsonc':componentContract.registry.retired};
47
+ const sourceComponentIds=componentContract.activeComponentIds;
55
48
  const adapter=await import(pathToFileURL(await project.resolveAdapter(config.compilerAdapter)).href);
56
49
  const definitionRoot=await realpath(path.resolve(root,config.definitions)),results=[];
57
50
  for(const row of rows){
@@ -65,6 +58,8 @@ export async function compileCheck(options){
65
58
  }
66
59
  assert.deepEqual(definition.ancestors,fragments.map(f=>f.id),'Preparation ancestry mismatch');
67
60
  const plan=await adapter.compile({projectRoot:root,config,catalog:parsedCatalog,definition,fragments});
61
+ assert(plan.componentContract===undefined,'Project must not override compiler component contract');
62
+ validateComponentStates(plan,componentContract);
68
63
  const componentIds=assertionComponents(plan);
69
64
  results.push({id:row.definition,definition:row.definition,status:'passed',componentIds});
70
65
  }catch(e){results.push({id:row.definition,definition:row.definition,status:'failed',error:e.message});}
@@ -73,7 +68,7 @@ export async function compileCheck(options){
73
68
  const anchorCoverage=compareCoverage(sourceComponentIds,results.filter(r=>r.status==='passed'));
74
69
  const result={schemaVersion:2,kind:'minifish-full-compile',head,projectPath,projectTree:git('rev-parse',head+':'+projectPath),
75
70
  manifest:{path:manifest.relative,sha256:sha(manifest.bytes)},catalog:{path:catalog.relative,sha256:sha(catalog.bytes)},
76
- anchorInventory:{path:inventory.relative,sha256:sha(inventory.bytes)},anchorCoverage,registry,
71
+ anchorInventory:inventory,anchorCoverage,registry,componentContract,
77
72
  toolIdentity:await toolIdentity(config.toolSource,path.resolve(root,config.toolSourceManifest)),
78
73
  total:results.length,passed:results.filter(r=>r.status==='passed').length,failed:results.filter(r=>r.status==='failed').length,
79
74
  nonExecutable:(parsedManifest.pending??[]).map(r=>({id:r.sourceCaseId,reason:r.reason})),
@@ -0,0 +1,43 @@
1
+ import assert from 'node:assert/strict';
2
+ import path from 'node:path';
3
+ import {readFile,realpath} from 'node:fs/promises';
4
+ import {execFileSync} from 'node:child_process';
5
+ import {createHash} from 'node:crypto';
6
+ import {fileURLToPath} from 'node:url';
7
+ const sha=bytes=>createHash('sha256').update(bytes).digest('hex');
8
+
9
+ export async function loadComponentContract(project){
10
+ const {root,config}=project;
11
+ const repo=await realpath(path.resolve(root,config.repositoryRoot));
12
+ const git=(...args)=>execFileSync('git',['-C',repo,...args],{encoding:'utf8'}).trim();
13
+ const head=git('rev-parse','HEAD'),relative=path.relative(repo,root).split(path.sep).join('/');
14
+ assert(relative&&!relative.startsWith('../')&&relative.endsWith('/code/minifish-tests'),'Business test directory required');
15
+ const source=config.anchorInventory;
16
+ assert(source?.path&&/^[a-f0-9]{64}$/.test(source.sha256??''),'Frozen anchor inventory required');
17
+ const read=async file=>{
18
+ assert.equal(await realpath(file),file,'Symlink component input forbidden');
19
+ const name=path.relative(repo,file).split(path.sep).join('/');
20
+ assert(git('ls-tree',head,'--',name).startsWith('100644 '),'Committed regular component input required');
21
+ assert(!git('status','--porcelain','--untracked-files=all','--',name),'Component input must be clean');
22
+ const bytes=execFileSync('git',['-C',repo,'show',head+':'+name]);
23
+ assert.equal(sha(await readFile(file)),sha(bytes),'Component input differs from candidate');
24
+ return {path:name,sha256:sha(bytes),bytes};
25
+ };
26
+ assert(project.configFile&&project.configFile.startsWith(root+path.sep),'Explicit project config required');
27
+ const configInput=await read(project.configFile);
28
+ assert.deepEqual(JSON.parse(configInput.bytes),config,'Project config changed during compilation');
29
+ const sourcePath=path.resolve(root,source.path);assert(sourcePath.startsWith(root+path.sep),'Inventory escaped project');
30
+ const inventory=await read(sourcePath);assert.equal(inventory.sha256,source.sha256,'Inventory digest mismatch');
31
+ const registry=path.resolve(root,'../.registry');
32
+ const active=await read(path.join(registry,'anchors.jsonc')),retired=await read(path.join(registry,'anchors-retired.jsonc'));
33
+ const parsedInventory=JSON.parse(inventory.bytes);let page;
34
+ if(config.pageSourceId!==undefined){
35
+ assert(typeof config.pageSourceId==='string'&&parsedInventory.targets.some(row=>row.kind==='page'&&row.id===config.pageSourceId),'Configured page must be an exact frozen source page');
36
+ assert(typeof config.pageRoute==='string'&&config.pageRoute,'Configured product route required');
37
+ page={sourceId:config.pageSourceId,route:config.pageRoute};
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'}}));
40
+ assert.equal(git('rev-parse','HEAD'),head,'Candidate changed during contract construction');
41
+ 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)}};
43
+ }
@@ -17,7 +17,7 @@ export async function loadProject(projectRoot,configFile){
17
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
18
  assert(config.runtimeAdapters&&typeof config.runtimeAdapters==='object','Explicit runtime adapters required');
19
19
  for(const name of ['@minifish/project-runtime','@minifish/project-host'])await resolveAdapter(config.runtimeAdapters[name]);
20
- return {root,config,adapterRoots,resolveAdapter};
20
+ return {root,config,configFile:file,adapterRoots,resolveAdapter};
21
21
  }
22
22
  export async function checkOutput(output,project){
23
23
  assert(output,'Explicit output directory required');const destination=path.resolve(output);
@@ -129,7 +129,7 @@ def main(argv, identity):
129
129
  import argparse
130
130
  from pathlib import Path
131
131
  parser=argparse.ArgumentParser(description=__doc__)
132
- parser.add_argument('operation',choices=['reconcile','verify','diff'])
132
+ parser.add_argument('operation',choices=['reconcile','verify','diff','resolve'])
133
133
  parser.add_argument('--inventory',type=Path,required=True)
134
134
  parser.add_argument('--inventory-sha256',required=True)
135
135
  parser.add_argument('--index',type=Path)
@@ -137,6 +137,7 @@ def main(argv, identity):
137
137
  parser.add_argument('--retired',type=Path)
138
138
  parser.add_argument('--retired-sha256')
139
139
  parser.add_argument('--changes',type=Path)
140
+ parser.add_argument('--source-id')
140
141
  parser.add_argument('--output',type=Path)
141
142
  args=parser.parse_args(argv)
142
143
  def read(path,sha,registry=False):
@@ -154,6 +155,20 @@ def main(argv, identity):
154
155
  for file,value in [(args.index,previous),(args.retired,retired)]:
155
156
  if file.read_bytes()!=registry_text(value).encode('utf-8'):
156
157
  raise ValueError('Noncanonical registry formatting: '+str(file))
158
+ if args.operation=='resolve':
159
+ if previous is None or not args.source_id or args.changes or args.output:
160
+ raise ValueError('Resolve requires registry and exact source ID, no changes/output')
161
+ validate(previous,inventory,retired)
162
+ reverse={value:key for key,value in previous.items()}
163
+ if args.source_id in reverse:
164
+ result={'component':reverse[args.source_id],'sourceId':args.source_id,'observed':False}
165
+ else:
166
+ from tools.adapters.islands.component_reference import resolve_branch
167
+ result=resolve_branch(inventory,previous,retired,args.source_id)
168
+ print(json.dumps(result,ensure_ascii=False))
169
+ return 0
170
+ if args.source_id is not None:
171
+ raise ValueError('Source ID is only accepted by resolve')
157
172
  if args.operation=='verify':
158
173
  if previous is None or args.changes or args.output: raise ValueError('Verify requires index, no changes or output')
159
174
  print(json.dumps({'passed':True,'activeComponents':len(validate(previous,inventory,retired))}))
@@ -11,4 +11,18 @@ if __name__=='__main__':
11
11
  active=parse_registry(request['active'])
12
12
  retired=parse_registry(request['retired'])
13
13
  validate(active,request['inventory'],retired)
14
- print(json.dumps(list(active)))
14
+ if request.get('withStates'):
15
+ from tools.adapters.islands.component_reference import resolve_branch
16
+ states={}
17
+ branches={}
18
+ for row in request['inventory']['targets']:
19
+ if row.get('kind')!='branch':
20
+ continue
21
+ resolved=resolve_branch(request['inventory'],active,retired,row['id'])
22
+ branches[row['id']]=resolved
23
+ values=states.setdefault(resolved['component'],[])
24
+ if resolved['state'] not in values:
25
+ values.append(resolved['state'])
26
+ print(json.dumps({'activeComponentIds':list(active),'componentStates':states,'branches':branches}))
27
+ else:
28
+ print(json.dumps(list(active)))
@@ -0,0 +1,32 @@
1
+ import assert from 'node:assert/strict';
2
+
3
+ // Serialized into the business frame or invoked inside the single held snapshot.
4
+ // Dataset is authoritative during AppX updates; synchronized DOM may lag.
5
+ export function sampleDeclaredComponents(root){
6
+ const declarations=[];
7
+ for(const node of root.ownerDocument.querySelectorAll('*')){
8
+ const native=Object.prototype.hasOwnProperty.call(node,'$$dataset');
9
+ const data=node.$$dataset;
10
+ const declared=native?!!data&&Object.prototype.hasOwnProperty.call(data,'anchorId'):node.hasAttribute('data-anchor-id');
11
+ if(!declared)continue;
12
+ const value=native?data.anchorId:node.getAttribute('data-anchor-id');
13
+ declarations.push({id:typeof value==='string'?value:null,valid:typeof value==='string'&&value.length>0});
14
+ }
15
+ return {declarations};
16
+ }
17
+
18
+ export function validateComponentMembership(snapshot,active){
19
+ assert(Array.isArray(active)&&active.length>0,'Frozen active component set required');
20
+ assert(active.every(x=>typeof x==='string'&&/^cmp_[a-f0-9]{6}$/.test(x)),'Invalid active component ID');
21
+ const allowed=new Set(active);assert.equal(allowed.size,active.length,'Duplicate active component ID');
22
+ assert(snapshot&&Array.isArray(snapshot.declarations),'Component membership snapshot required');
23
+ const unknown=new Set();
24
+ for(const row of snapshot.declarations){
25
+ assert(row&&row.valid===true&&typeof row.id==='string'&&row.id.length>0,'Invalid declared component identity');
26
+ if(!allowed.has(row.id))unknown.add(row.id);
27
+ }
28
+ assert.equal(unknown.size,0,'Unexpected component declarations: '+JSON.stringify([...unknown]));
29
+ return {check:'declared-components-are-active',count:snapshot.declarations.length,
30
+ observedComponentIds:[...new Set(snapshot.declarations.map(x=>x.id))],
31
+ limitation:'Unannotated nodes and incorrect reuse of a valid component ID are not proven absent'};
32
+ }
@@ -0,0 +1,45 @@
1
+ import {validatePageVisual} from './page-visual.mjs';
2
+ import assert from 'node:assert/strict';
3
+
4
+ // Serialized independently; the product declaration is observed, never inferred
5
+ // from the expected branch or treated as proof of its content/style obligations.
6
+ export function sampleComponentState(node){
7
+ const native=Object.prototype.hasOwnProperty.call(node,'$$dataset');
8
+ const value=native?node.$$dataset?.anchorState:node.getAttribute('data-anchor-state');
9
+ return {value:typeof value==='string'?value:null,valid:typeof value==='string'&&value.length>0};
10
+ }
11
+ export function assertComponentState(actual,expected){
12
+ assert(typeof expected==='string'&&expected.length>0,'Nonempty source component state required');
13
+ assert(actual?.valid===true,'Component state declaration missing or invalid');
14
+ assert.equal(actual.value,expected,'Observed component state differs from source expectation');
15
+ return {expected,actual:actual.value};
16
+ }
17
+ export function validateComponentStates(plan,contract){
18
+ const active=new Set(contract?.activeComponentIds??[]);
19
+ const reference=ref=>{if(ref?.target!==undefined)assert(active.has(ref.target),'Reference must belong to the frozen active component set');};
20
+ for(const actions of [plan.actions,plan.beforeTarget,plan.afterTarget,plan.completion])for(const action of actions??[])reference(action);
21
+ for(const a of plan.assertions??[]){
22
+ 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
+ reference(a);
24
+ for(const ref of [...(a.spatial?.absent??[]),...(a.spatial?.order??[]).flatMap(pair=>[pair.above,pair.below])])reference(ref);
25
+ if(a.kind==='component-state')assert(a.componentState!==undefined,'Branch state required');
26
+ if(a.componentState===undefined)continue;
27
+ assert(typeof a.componentState==='string'&&a.componentState.length>0,'Nonempty componentState required');
28
+ assert(/^cmp_[a-f0-9]{6}$/.test(a.target??''),'Component state requires a component target');
29
+ assert(contract.componentStates?.[a.target]?.includes(a.componentState),'Component state must belong to its frozen source component');
30
+ if(a.kind==='component-state')assert(typeof a.present==='boolean','Branch presence requires an explicit boolean');
31
+ else assert(a.present===undefined,'present is only accepted for component-state');
32
+ assert(a.kind!=='hidden'&&a.count!==0,'State observation requires an existing component');
33
+ }
34
+ }
35
+
36
+ export function assertComponentBranch(snapshot,expected,present,allowed){
37
+ assert(typeof present==='boolean','Explicit branch presence required');
38
+ assert(Array.isArray(allowed)&&allowed.includes(expected),'Frozen component states required');
39
+ assert(snapshot&&Number.isInteger(snapshot.count)&&snapshot.count>=0&&snapshot.count<=1,'Unique component owner required');
40
+ assert(Array.isArray(snapshot.members)&&snapshot.members.length===snapshot.count,'Complete branch observation required');
41
+ for(const member of snapshot.members){assert(member.componentState?.valid===true,'Component state declaration missing or invalid');assert(allowed.includes(member.componentState.value),'Observed component state absent from frozen source');}
42
+ const observed=snapshot.members.some(member=>member.componentState.value===expected&&member.visible===true);
43
+ assert.equal(observed,present,'Observed branch presence differs from source expectation');
44
+ return {expected,present,observed,members:snapshot.members};
45
+ }
@@ -1,3 +1,7 @@
1
+ import {spatialTarget} from './spatial-contract.mjs';
2
+ import {resolveVisualCarrier} from './visual-carrier.mjs';
3
+ import {sampleComponentState,assertComponentState,assertComponentBranch} from './component-state.mjs';
4
+ import {sampleDeclaredComponents,validateComponentMembership} from './component-membership.mjs';
1
5
  import {validateFrozenSpatial} from './held-spatial.mjs';
2
6
  import {assertVisualMetrics} from './visual-expectation.mjs';
3
7
  import assert from 'node:assert/strict';
@@ -36,7 +40,7 @@ export async function captureHeldSnapshot(root,spec){
36
40
  if(groups.every(g=>g.target.waitForPresence===false||g.nodes.length)||now()-start.time>=350)break;
37
41
  await new Promise(resolve=>{const timer=setTimeout(resolve,Math.max(0,350-(now()-start.time)));requestAnimationFrame(()=>{clearTimeout(timer);resolve();});});
38
42
  }
39
- const captureStartedAt=now();const result={version:spec.version,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 m=metrics(node);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),state:state(node),active:pressed(node),metrics:m,textPaint:paint(node)};})})),eventsAtSnapshot:timeline.events.slice()};result.snapshotAt=now();return 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.snapshotAt=now();return result;
40
44
  }`;
41
45
  return freezeHeldSnapshot(await root.locator(':root').evaluate(Function('return ('+body+')')(),spec));
42
46
  }
@@ -48,14 +52,24 @@ export function validateHeldInputTiming(snapshot,events){
48
52
  assert(snapshot&&!snapshot.timedOut&&snapshot.pressAt===down.time&&snapshot.snapshotAt>=down.time&&snapshot.snapshotAt<=up.time,'Only actual held-time snapshot allowed');
49
53
  assert(!events.some(e=>['touchcancel','pointercancel','scroll'].includes(e.type)),'Native held input cancelled or scrolled');return {eligible:true,holdMs:duration,pressAt:down.time,snapshotAt:snapshot.snapshotAt,releaseAt:up.time,events};
50
54
  }
51
- export function validateFrozenHeldAssertion(snapshot,a,validators){
55
+ export function validateFrozenHeldAssertion(snapshot,a,validators,contract){
52
56
  const group=snapshot.groups.find(g=>g.key===heldSnapshotTargetKey(a.target,a.instance));assert(group,'Original semantic snapshot required');
57
+ if(a.kind==='component-state')return assertComponentBranch({count:group.count,members:group.members.map(m=>({componentState:m.componentState,visible:m.state.visible}))},a.componentState,a.present,contract?.componentStates?.[a.target]);
53
58
  validators.validateState({count:group.count,visible:group.members.map(m=>m.state.visible)},a);
54
59
  const observations=[];
55
60
  for(const member of group.members){assert(matchesNativeSemanticIdentity(member.identity,a.target,a.instance),'Original semantic identity and optional instance must match');// Preserve measured :active; declared assertions do not require CSS :active.
56
61
 
62
+ if(a.componentState!==undefined)assertComponentState(member.componentState,a.componentState);
57
63
  if(a.enabled)assert.equal(member.state.enabled,true,'Original native enabled obligation');validators.validateObservation(member.metrics,a);
58
- if(a.kind==='region'&&a.check==='visual'){assert(!a.visual.carrier||a.visual.carrier==='semantic-root','Held visual carrier not captured');assertVisualMetrics(member.metrics,a.visual);}
64
+ if(a.kind==='region'&&a.check==='visual'){
65
+ let visualMetrics=member.metrics;
66
+ if(a.visual.carrier==='exclusive-parent'){
67
+ const key=spatialTarget({target:a.target,instance:a.instance,carrier:'exclusive-parent'}).key;
68
+ const carrier=snapshot.groups.find(g=>g.key===key);assert(carrier?.count===1&&carrier.members.length===1,'Unique frozen visual carrier required');
69
+ visualMetrics=carrier.members[0].metrics;
70
+ }else assert(!a.visual.carrier||a.visual.carrier==='semantic-root','Held visual carrier not captured');
71
+ assertVisualMetrics(visualMetrics,a.visual);
72
+ }
59
73
  if(a.textFits)validateNativeTextVisibility(member.textPaint,{selfOverflow:'contain'});observations.push({...member.metrics,...(a.textFits?{textPaint:member.textPaint}:{})});
60
74
  }
61
75
  const spatial=a.spatial?validateFrozenSpatial(snapshot,a.spatial):undefined;
@@ -66,6 +80,7 @@ export async function executeHeldSnapshot({root,spec,release,validators,capture=
66
80
  let snapshot,error,releaseReceipt;try{snapshot=await capture(root,spec);}catch(e){error=e;}finally{releaseReceipt=await release(spec.release);}
67
81
  const events=await readEvents(root);let timing;
68
82
  try{if(error)throw error;timing=validateHeldInputTiming(snapshot,events);}catch(e){return {snapshot,releaseReceipt,timing:{eligible:false,reason:e.message,events},assertions:spec.assertions.map(a=>({id:a.id,sourceCaseIds:a.sourceCaseIds,status:'invalidated',error:e.message})),observations:{}};}
69
- const assertions=[],observations={};for(const a of spec.assertions){try{observations[a.id]=validateFrozenHeldAssertion(snapshot,a,validators);assertions.push({id:a.id,sourceCaseIds:a.sourceCaseIds,status:'passed'});}catch(e){assertions.push({id:a.id,sourceCaseIds:a.sourceCaseIds,status:'failed',error:e.message});}}
83
+ let membership,membershipError;try{if(spec.componentContract)membership=validateComponentMembership(snapshot.componentMembership,spec.componentContract.activeComponentIds);}catch(e){membershipError=e;}
84
+ const assertions=[],observations={};if(membership)observations.componentMembership=membership;for(const a of spec.assertions){try{if(membershipError)throw membershipError;observations[a.id]=validateFrozenHeldAssertion(snapshot,a,validators,spec.componentContract);assertions.push({id:a.id,sourceCaseIds:a.sourceCaseIds,status:'passed'});}catch(e){assertions.push({id:a.id,sourceCaseIds:a.sourceCaseIds,status:'failed',error:e.message});}}
70
85
  return {snapshot,releaseReceipt,timing,assertions,observations};
71
86
  }
@@ -4,10 +4,11 @@ import {validateSpatial,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);
7
- for(const a of clean.assertions){if(a.spatial!==undefined)validateSpatial(a.spatial);delete a.spatial;}
7
+ for(const a of clean.assertions){if(a.spatial!==undefined)validateSpatial(a.spatial);delete a.spatial;delete a.componentState;}
8
8
  const result=prepareProject(clean);
9
9
  result.assertions=structuredClone(plan.assertions);
10
10
  for(const a of plan.assertions){
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);}
11
12
  if(!a.spatial)continue;
12
13
  const refs=[...(a.spatial.absent??[]),...(a.spatial.order??[]).flatMap(p=>[p.above,p.below])];
13
14
  for(const ref of refs){const t=target(ref);if(!result.targets.some(existing=>existing.key===t.key))result.targets.push(t);}
@@ -1,3 +1,6 @@
1
+ import {observePageVisual,assertPageRoute} from './page-visual.mjs';
2
+ import {sampleComponentState,assertComponentState,assertComponentBranch,validateComponentStates} from './component-state.mjs';
3
+ import {sampleDeclaredComponents,validateComponentMembership} from './component-membership.mjs';
1
4
  import {prepareHeldWithSpatial} from './held-spatial.mjs';
2
5
  import {validateRegionVisualAssertions,assertVisualMetrics} from './visual-expectation.mjs';
3
6
  import {hostPolicy} from '@minifish/project-host';
@@ -29,6 +32,10 @@ function target(root, name) { return semanticLocator(root, name); }
29
32
 
30
33
  export async function runMiniappCase({page, plan, testInfo}) {
31
34
  validateRegionVisualAssertions(plan.assertions);
35
+ validateComponentStates(plan,plan.componentContract);
36
+ if(plan.assertions.some(a=>a.kind==='page-visual'))assertPageRoute(plan.componentContract.page,process.env.MINIFISH_PAGE_ROUTE);
37
+ assert(plan.componentContract?.schemaVersion===1,'Compiler-bound component membership contract required');
38
+ validateComponentMembership({declarations:[]},plan.componentContract.activeComponentIds);
32
39
  assert.equal(plan.resources.length, 0, 'resource handling is not implemented in this runner yet');
33
40
  const boundary = plan.boundaries.length ? independentBoundary(plan.boundaries) : zeroBusinessPlan();
34
41
  const images = installRendererImageEvidence(page);
@@ -59,6 +66,7 @@ export async function runMiniappCase({page, plan, testInfo}) {
59
66
  result.preparation = 'running';
60
67
  if(!artifactOnly&&plan.assertions.some(a=>a.spatial!==undefined))assert((plan.beforeTarget??[]).some(a=>a.kind==='press'),'Spatial snapshot requires held input');
61
68
  if(!artifactOnly&&(plan.beforeTarget??[]).some(a=>a.kind==='press'))heldSpec=prepareHeldWithSpatial(plan,prepareHeldSnapshot);
69
+ if(heldSpec)heldSpec.componentContract=plan.componentContract;
62
70
  if(plan.plugin){
63
71
  assert(process.env.MINIFISH_PLUGIN_PACKAGE_URL&&process.env.MINIFISH_PLUGIN_ARTIFACT_MANIFEST,'Explicit compiled plugin artifact required');
64
72
  const artifact=JSON.parse(await readFile(process.env.MINIFISH_PLUGIN_ARTIFACT_MANIFEST,'utf8'));
@@ -96,7 +104,7 @@ export async function runMiniappCase({page, plan, testInfo}) {
96
104
  assert(app, 'missing real page opening');
97
105
  await trace?.attach();
98
106
  input=targetActions({page,locate:(name,instance)=>locate(app.root,name,{instance}),synchronize:()=>synchronizeAnchors(app.root),boundary,receipts:result.actions,beforePress:async()=>{if(artifactOnly)capturePreparation=await prepareMiniappCapture(page);},observeInput:async(stage,action)=>trace?.sample(stage,app.root,boundary.evidence(),action)});
99
- if(heldSpec)for(const a of plan.assertions)if(a.kind==='region'&&a.check==='visual')assert(!a.visual.carrier||a.visual.carrier==='semantic-root','Held visual carrier requires a supported frozen snapshot contract');
107
+ if(heldSpec)for(const a of plan.assertions)if(a.kind==='region'&&a.check==='visual')assert(!a.visual.carrier||['semantic-root','exclusive-parent'].includes(a.visual.carrier),'Held visual carrier requires a supported frozen snapshot contract');
100
108
  if(heldSpec)await prepareHeldNativeInput(app.root,heldSpec,locate(app.root,heldSpec.press.target,{instance:heldSpec.press.instance}));
101
109
  for(const action of plan.beforeTarget??[]){
102
110
  if(action.kind==='leave-return'){
@@ -114,9 +122,16 @@ export async function runMiniappCase({page, plan, testInfo}) {
114
122
  if(!held.timing.eligible){result.inputValidity='invalid';result.target='invalidated';}
115
123
  }else for (const assertion of plan.assertions) {
116
124
  await synchronizeAnchors(app.root);
117
- const locator=assertion.kind==='host-navigation'?undefined:locate(app.root, assertion.target,{instance:assertion.instance});
125
+ const locator=['host-navigation','page-visual'].includes(assertion.kind)?undefined:locate(app.root, assertion.target,{instance:assertion.instance});
118
126
  try {
119
- if(assertion.kind==='host-native-ui'){result.observations[assertion.id]=await assertHostNativeUi(page,app,assertion);
127
+ result.observations.componentMembership=validateComponentMembership(await app.root.locator(':root').evaluate(sampleDeclaredComponents),plan.componentContract.activeComponentIds);
128
+ 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==='page-visual'){result.observations[assertion.id]=await observePageVisual(app.root,assertion,plan.componentContract.page);
130
+ }else if(assertion.kind==='component-state'){
131
+ const count=await locator.count(),members=[];assert(count<=1,'Unique branch owner required');
132
+ if(count)members.push({componentState:await locator.evaluate(sampleComponentState),visible:await locator.isVisible()});
133
+ result.observations[assertion.id]=assertComponentBranch({count,members},assertion.componentState,assertion.present,plan.componentContract.componentStates[assertion.target]);
134
+ }else if(assertion.kind==='host-native-ui'){result.observations[assertion.id]=await assertHostNativeUi(page,app,assertion);
120
135
  }else if(assertion.kind==='carousel-frame'){result.observations[assertion.id]=await observeCarousel(app.root,assertion.contract,{urls:assertion.images,records:images.records});
121
136
  }else if(assertion.kind==='host-navigation'){result.observations[assertion.id]=await assertHostNavigation(page,app,assertion,plan.hostSystemPlan);
122
137
  }else if(assertion.kind==='region'){
@@ -0,0 +1,55 @@
1
+ import assert from 'node:assert/strict';
2
+ const fields=new Set(['fullWidth','fullHeight','noHorizontalOverflow','backgroundColor']);
3
+ export function validatePageVisual(a){
4
+ assert(a.kind==='page-visual','Page visual kind required');
5
+ assert(a.target===undefined&&a.instance===undefined&&a.componentState===undefined&&a.spatial===undefined&&a.visual===undefined,'Page observation cannot impersonate a component');
6
+ let checks=0;
7
+ for(const key of fields){
8
+ if(a[key]===undefined)continue;
9
+ if(key==='backgroundColor')assert(typeof a[key]==='string'&&a[key].trim(),'Page background must be explicit');
10
+ else assert(a[key]===true,'Page geometry flags must assert true');
11
+ checks++;
12
+ }
13
+ assert(checks>0,'Substantive page visual expectation required');
14
+ }
15
+ // Runs only in the selected business frame. The page marker is not a component ID.
16
+ export function sampleBusinessPage(root){
17
+ const doc=root.ownerDocument;
18
+ const candidates=[];
19
+ for(const n of doc.querySelectorAll('*')){
20
+ const native=Object.prototype.hasOwnProperty.call(n,'$$dataset');
21
+ const declared=native?!!n.$$dataset&&Object.prototype.hasOwnProperty.call(n.$$dataset,'pageRoot'):n.hasAttribute('data-page-root');
22
+ if(!declared)continue;
23
+ const value=native?n.$$dataset.pageRoot:n.getAttribute('data-page-root');
24
+ if(value!==true&&value!=='true')throw Error('Invalid business page marker');
25
+ candidates.push(n);
26
+ }
27
+ if(candidates.length!==1)throw Error('Exactly one declared business page root required');
28
+ const node=candidates[0],box=node.getBoundingClientRect(),style=doc.defaultView.getComputedStyle(node);
29
+ let visible=box.width>0&&box.height>0;
30
+ for(let n=node;n;n=n.parentElement){const s=doc.defaultView.getComputedStyle(n);visible&&=s.display!=='none'&&s.visibility==='visible'&&Number(s.opacity)>0;}
31
+ return {box:{x:box.x,y:box.y,width:box.width,height:box.height},viewport:{width:doc.defaultView.innerWidth,height:doc.defaultView.innerHeight},
32
+ visible,backgroundColor:style.backgroundColor,scrollWidth:node.scrollWidth,clientWidth:node.clientWidth,
33
+ documentScrollWidth:doc.documentElement.scrollWidth,frame:doc.defaultView.location.href};
34
+ }
35
+ export function assertPageVisual(metrics,a){
36
+ validatePageVisual(a);
37
+ assert(metrics?.visible,'Business page must be visible');
38
+ for(const value of [metrics.box?.x,metrics.box?.y,metrics.box?.width,metrics.box?.height,metrics.viewport?.width,metrics.viewport?.height])assert(Number.isFinite(value),'Finite page geometry required');
39
+ assert(metrics.viewport.width>0&&metrics.viewport.height>0&&metrics.box.width>0&&metrics.box.height>0,'Nonzero business page geometry required');
40
+ const near=(x,y,name)=>assert(Math.abs(x-y)<=.25,name);
41
+ if(a.fullWidth){near(metrics.box.x,0,'Page starts at viewport left');near(metrics.box.width,metrics.viewport.width,'Page full width');}
42
+ if(a.fullHeight){near(metrics.box.y,0,'Page starts at viewport top');near(metrics.box.height,metrics.viewport.height,'Page full height');}
43
+ if(a.noHorizontalOverflow){assert(Number.isFinite(metrics.scrollWidth)&&Number.isFinite(metrics.clientWidth)&&Number.isFinite(metrics.documentScrollWidth),'Page overflow measurements required');assert(metrics.scrollWidth<=metrics.clientWidth&&metrics.documentScrollWidth<=metrics.viewport.width,'No horizontal business overflow');}
44
+ if(a.backgroundColor!==undefined)assert.equal(metrics.backgroundColor,a.backgroundColor,'Page background');
45
+ return {check:'page-visual',metrics};
46
+ }
47
+ export async function observePageVisual(root,a,contract){
48
+ assert(contract?.sourceId&&contract.route,'Compiler-bound page source and route required');
49
+ return {...assertPageVisual(await root.locator(':root').evaluate(sampleBusinessPage),a),page:contract};
50
+ }
51
+
52
+ export function assertPageRoute(contract,route){
53
+ assert(contract?.sourceId&&contract.route,'Compiler-bound page required');
54
+ assert(typeof route==='string'&&route.replace(/^\//,'')===contract.route.replace(/^\//,''),'Runtime page route differs from frozen page binding');
55
+ }
@@ -1,10 +1,12 @@
1
1
  import assert from 'node:assert/strict';
2
2
  const keyOf=(target,instance)=>JSON.stringify([target,instance===undefined?{unspecified:true}:instance]);
3
3
  export function spatialTarget(ref){
4
- assert(ref&&Object.keys(ref).every(k=>['target','instance'].includes(k)),'Unknown spatial target field');
4
+ assert(ref&&Object.keys(ref).every(k=>['target','instance','carrier'].includes(k)),'Unknown spatial target field');
5
5
  assert(/^cmp_[0-9a-f]{6}$/.test(ref.target),'Allocated spatial component reference required');
6
6
  assert(ref.instance===undefined||(Number.isSafeInteger(ref.instance)&&ref.instance>=0),'Invalid spatial instance');
7
- return {...ref,key:keyOf(ref.target,ref.instance),styleKeys:[],waitForPresence:false};
7
+ assert(ref.carrier===undefined||['semantic-root','exclusive-parent'].includes(ref.carrier),'Unsupported spatial carrier');
8
+ const key=ref.carrier==='exclusive-parent'?JSON.stringify([ref.target,ref.instance===undefined?{unspecified:true}:ref.instance,ref.carrier]):keyOf(ref.target,ref.instance);
9
+ return {...ref,key,styleKeys:[],waitForPresence:false};
8
10
  }
9
11
  export function validateSpatial(spec){
10
12
  assert(spec&&Object.keys(spec).every(k=>['order','absent'].includes(k)),'Unknown spatial obligation');
@@ -1,6 +1,13 @@
1
1
  /** Browser-side, expectation-independent selection inside one semantic root. */
2
2
  export function resolveVisualCarrier(root, kind = 'semantic-root') {
3
3
  if(kind==='semantic-root')return root;
4
+ if(kind==='exclusive-parent'){
5
+ const parent=root.parentElement;
6
+ const marker=n=>Object.prototype.hasOwnProperty.call(n,'$$dataset')?n.$$dataset?.anchorId:n.getAttribute('data-anchor-id');
7
+ if(!marker(root)||!parent||['BODY','HTML'].includes(parent.tagName)||marker(parent))throw Error('Exclusive parent requires an unanchored non-document wrapper');
8
+ if(parent.children.length!==1||parent.firstElementChild!==root||[...parent.childNodes].some(n=>n.nodeType===3&&n.textContent.trim()))throw Error('Wrapper must exclusively own the component root');
9
+ return parent;
10
+ }
4
11
  if(!['source-content-group','owned-text-block'].includes(kind))throw Error('Unsupported visual carrier');
5
12
  const visible=element=>{
6
13
  for(let p=element;p;p=p.parentElement){const s=getComputedStyle(p);if(s.display==='none'||s.visibility!=='visible'||Number(s.opacity)===0)return false;}
@@ -23,7 +23,7 @@ export function validateVisualExpectation(value) {
23
23
  for (const key of Object.keys(value)) assert(top.has(key), `visual: unsupported ${key}`);
24
24
  number(value.designWidth,'visual.designWidth');
25
25
  assert(value.designWidth > 0,'visual.designWidth must be positive');
26
- if(value.carrier!==undefined)assert(['semantic-root','source-content-group','owned-text-block'].includes(value.carrier),'visual: unsupported carrier');
26
+ if(value.carrier!==undefined)assert(['semantic-root','source-content-group','owned-text-block','exclusive-parent'].includes(value.carrier),'visual: unsupported carrier');
27
27
  let checks = 0;
28
28
  if(value.viewportInsets!==undefined)checks+=keys(value.viewportInsets,new Set(['left','right','top','bottom']),'visual.viewportInsets',number);
29
29
  if (value.text !== undefined) {