@antprofuse/saddle-islands-tools 0.1.12 → 0.1.13
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/adapters/islands/component_reference.py +62 -0
- package/tools/docs/minifish-testing.md +15 -1
- package/tools/modules/minifish/checks/package.test.mjs +1 -1
- package/tools/modules/minifish/compiler/anchor-coverage.mjs +1 -1
- package/tools/modules/minifish/compiler/build-case.mjs +7 -1
- package/tools/modules/minifish/compiler/compile-check.mjs +11 -16
- package/tools/modules/minifish/compiler/component-contract.mjs +37 -0
- package/tools/modules/minifish/compiler/config.mjs +1 -1
- package/tools/modules/minifish/component_index.py +16 -1
- package/tools/modules/minifish/component_index_bridge.py +15 -1
- package/tools/modules/minifish/harness/component-membership.mjs +32 -0
- package/tools/modules/minifish/harness/component-state.mjs +43 -0
- package/tools/modules/minifish/harness/held-snapshot.mjs +19 -4
- package/tools/modules/minifish/harness/held-spatial.mjs +2 -1
- package/tools/modules/minifish/harness/independent-case-runner.mjs +14 -2
- package/tools/modules/minifish/harness/spatial-contract.mjs +4 -2
- package/tools/modules/minifish/harness/visual-carrier.mjs +7 -0
- package/tools/modules/minifish/harness/visual-expectation.mjs +1 -1
package/package.json
CHANGED
|
@@ -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
|
-
|
|
44
|
+
按压快照支持 `semantic-root` 和 `exclusive-parent`,其他carrier在按下前拒绝,不能释放后采样冒充按住状态。这是明确能力限制,不能默默降级。内容分组不覆盖背景装饰/无文字无图像部件或Shadow DOM;相应责任另用适用的公开观察合同。
|
|
45
45
|
|
|
46
46
|
### 全量定义编译门禁
|
|
47
47
|
|
|
@@ -88,3 +88,17 @@ 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;分支内部的具体组件及内容继续独立断言。
|
|
@@ -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/);});
|
|
@@ -10,7 +10,7 @@ export function sourceComponents(inventory,active,retired){
|
|
|
10
10
|
}
|
|
11
11
|
// Only actual target assertions count. Actions, sourceTarget, basis and nested
|
|
12
12
|
// 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']);
|
|
13
|
+
const kinds=new Set(['component-state','region','hidden','visible','text','image','text-visual','button-visual','host-navigation','host-native-ui','carousel-frame']);
|
|
14
14
|
export function assertionComponents(plan){
|
|
15
15
|
assert(Array.isArray(plan?.assertions)&&plan.assertions.length,'Compiled target assertions required');
|
|
16
16
|
const seen=new Set(),components=new Set();
|
|
@@ -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);
|
|
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 {
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const
|
|
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:
|
|
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,37 @@
|
|
|
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 {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'}}));
|
|
34
|
+
assert.equal(git('rev-parse','HEAD'),head,'Candidate changed during contract construction');
|
|
35
|
+
const binding=x=>({path:x.path,sha256:x.sha256});
|
|
36
|
+
return {schemaVersion:1,head,activeComponentIds,componentStates,branches,config:binding(configInput),inventory:binding(inventory),registry:{active:binding(active),retired:binding(retired)}};
|
|
37
|
+
}
|
|
@@ -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
|
-
|
|
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,43 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
|
|
3
|
+
// Serialized independently; the product declaration is observed, never inferred
|
|
4
|
+
// from the expected branch or treated as proof of its content/style obligations.
|
|
5
|
+
export function sampleComponentState(node){
|
|
6
|
+
const native=Object.prototype.hasOwnProperty.call(node,'$$dataset');
|
|
7
|
+
const value=native?node.$$dataset?.anchorState:node.getAttribute('data-anchor-state');
|
|
8
|
+
return {value:typeof value==='string'?value:null,valid:typeof value==='string'&&value.length>0};
|
|
9
|
+
}
|
|
10
|
+
export function assertComponentState(actual,expected){
|
|
11
|
+
assert(typeof expected==='string'&&expected.length>0,'Nonempty source component state required');
|
|
12
|
+
assert(actual?.valid===true,'Component state declaration missing or invalid');
|
|
13
|
+
assert.equal(actual.value,expected,'Observed component state differs from source expectation');
|
|
14
|
+
return {expected,actual:actual.value};
|
|
15
|
+
}
|
|
16
|
+
export function validateComponentStates(plan,contract){
|
|
17
|
+
const active=new Set(contract?.activeComponentIds??[]);
|
|
18
|
+
const reference=ref=>{if(ref?.target!==undefined)assert(active.has(ref.target),'Reference must belong to the frozen active component set');};
|
|
19
|
+
for(const actions of [plan.actions,plan.beforeTarget,plan.afterTarget,plan.completion])for(const action of actions??[])reference(action);
|
|
20
|
+
for(const a of plan.assertions??[]){
|
|
21
|
+
reference(a);
|
|
22
|
+
for(const ref of [...(a.spatial?.absent??[]),...(a.spatial?.order??[]).flatMap(pair=>[pair.above,pair.below])])reference(ref);
|
|
23
|
+
if(a.kind==='component-state')assert(a.componentState!==undefined,'Branch state required');
|
|
24
|
+
if(a.componentState===undefined)continue;
|
|
25
|
+
assert(typeof a.componentState==='string'&&a.componentState.length>0,'Nonempty componentState required');
|
|
26
|
+
assert(/^cmp_[a-f0-9]{6}$/.test(a.target??''),'Component state requires a component target');
|
|
27
|
+
assert(contract.componentStates?.[a.target]?.includes(a.componentState),'Component state must belong to its frozen source component');
|
|
28
|
+
if(a.kind==='component-state')assert(typeof a.present==='boolean','Branch presence requires an explicit boolean');
|
|
29
|
+
else assert(a.present===undefined,'present is only accepted for component-state');
|
|
30
|
+
assert(a.kind!=='hidden'&&a.count!==0,'State observation requires an existing component');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function assertComponentBranch(snapshot,expected,present,allowed){
|
|
35
|
+
assert(typeof present==='boolean','Explicit branch presence required');
|
|
36
|
+
assert(Array.isArray(allowed)&&allowed.includes(expected),'Frozen component states required');
|
|
37
|
+
assert(snapshot&&Number.isInteger(snapshot.count)&&snapshot.count>=0&&snapshot.count<=1,'Unique component owner required');
|
|
38
|
+
assert(Array.isArray(snapshot.members)&&snapshot.members.length===snapshot.count,'Complete branch observation required');
|
|
39
|
+
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');}
|
|
40
|
+
const observed=snapshot.members.some(member=>member.componentState.value===expected&&member.visible===true);
|
|
41
|
+
assert.equal(observed,present,'Observed branch presence differs from source expectation');
|
|
42
|
+
return {expected,present,observed,members:snapshot.members};
|
|
43
|
+
}
|
|
@@ -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(
|
|
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'){
|
|
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
|
-
|
|
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,5 @@
|
|
|
1
|
+
import {sampleComponentState,assertComponentState,assertComponentBranch,validateComponentStates} from './component-state.mjs';
|
|
2
|
+
import {sampleDeclaredComponents,validateComponentMembership} from './component-membership.mjs';
|
|
1
3
|
import {prepareHeldWithSpatial} from './held-spatial.mjs';
|
|
2
4
|
import {validateRegionVisualAssertions,assertVisualMetrics} from './visual-expectation.mjs';
|
|
3
5
|
import {hostPolicy} from '@minifish/project-host';
|
|
@@ -29,6 +31,9 @@ function target(root, name) { return semanticLocator(root, name); }
|
|
|
29
31
|
|
|
30
32
|
export async function runMiniappCase({page, plan, testInfo}) {
|
|
31
33
|
validateRegionVisualAssertions(plan.assertions);
|
|
34
|
+
validateComponentStates(plan,plan.componentContract);
|
|
35
|
+
assert(plan.componentContract?.schemaVersion===1,'Compiler-bound component membership contract required');
|
|
36
|
+
validateComponentMembership({declarations:[]},plan.componentContract.activeComponentIds);
|
|
32
37
|
assert.equal(plan.resources.length, 0, 'resource handling is not implemented in this runner yet');
|
|
33
38
|
const boundary = plan.boundaries.length ? independentBoundary(plan.boundaries) : zeroBusinessPlan();
|
|
34
39
|
const images = installRendererImageEvidence(page);
|
|
@@ -59,6 +64,7 @@ export async function runMiniappCase({page, plan, testInfo}) {
|
|
|
59
64
|
result.preparation = 'running';
|
|
60
65
|
if(!artifactOnly&&plan.assertions.some(a=>a.spatial!==undefined))assert((plan.beforeTarget??[]).some(a=>a.kind==='press'),'Spatial snapshot requires held input');
|
|
61
66
|
if(!artifactOnly&&(plan.beforeTarget??[]).some(a=>a.kind==='press'))heldSpec=prepareHeldWithSpatial(plan,prepareHeldSnapshot);
|
|
67
|
+
if(heldSpec)heldSpec.componentContract=plan.componentContract;
|
|
62
68
|
if(plan.plugin){
|
|
63
69
|
assert(process.env.MINIFISH_PLUGIN_PACKAGE_URL&&process.env.MINIFISH_PLUGIN_ARTIFACT_MANIFEST,'Explicit compiled plugin artifact required');
|
|
64
70
|
const artifact=JSON.parse(await readFile(process.env.MINIFISH_PLUGIN_ARTIFACT_MANIFEST,'utf8'));
|
|
@@ -96,7 +102,7 @@ export async function runMiniappCase({page, plan, testInfo}) {
|
|
|
96
102
|
assert(app, 'missing real page opening');
|
|
97
103
|
await trace?.attach();
|
|
98
104
|
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||
|
|
105
|
+
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
106
|
if(heldSpec)await prepareHeldNativeInput(app.root,heldSpec,locate(app.root,heldSpec.press.target,{instance:heldSpec.press.instance}));
|
|
101
107
|
for(const action of plan.beforeTarget??[]){
|
|
102
108
|
if(action.kind==='leave-return'){
|
|
@@ -116,7 +122,13 @@ export async function runMiniappCase({page, plan, testInfo}) {
|
|
|
116
122
|
await synchronizeAnchors(app.root);
|
|
117
123
|
const locator=assertion.kind==='host-navigation'?undefined:locate(app.root, assertion.target,{instance:assertion.instance});
|
|
118
124
|
try {
|
|
119
|
-
|
|
125
|
+
result.observations.componentMembership=validateComponentMembership(await app.root.locator(':root').evaluate(sampleDeclaredComponents),plan.componentContract.activeComponentIds);
|
|
126
|
+
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);}
|
|
127
|
+
if(assertion.kind==='component-state'){
|
|
128
|
+
const count=await locator.count(),members=[];assert(count<=1,'Unique branch owner required');
|
|
129
|
+
if(count)members.push({componentState:await locator.evaluate(sampleComponentState),visible:await locator.isVisible()});
|
|
130
|
+
result.observations[assertion.id]=assertComponentBranch({count,members},assertion.componentState,assertion.present,plan.componentContract.componentStates[assertion.target]);
|
|
131
|
+
}else if(assertion.kind==='host-native-ui'){result.observations[assertion.id]=await assertHostNativeUi(page,app,assertion);
|
|
120
132
|
}else if(assertion.kind==='carousel-frame'){result.observations[assertion.id]=await observeCarousel(app.root,assertion.contract,{urls:assertion.images,records:images.records});
|
|
121
133
|
}else if(assertion.kind==='host-navigation'){result.observations[assertion.id]=await assertHostNavigation(page,app,assertion,plan.hostSystemPlan);
|
|
122
134
|
}else if(assertion.kind==='region'){
|
|
@@ -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
|
-
|
|
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) {
|