@antprofuse/saddle-islands-tools 0.1.9 → 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.
Files changed (32) hide show
  1. package/package.json +1 -1
  2. package/tools/adapters/islands/component_reference.py +62 -0
  3. package/tools/adapters/islands/visual_targets.py +106 -0
  4. package/tools/cli/main.py +2 -2
  5. package/tools/cli/minifish.mjs +1 -1
  6. package/tools/cli/minifish.py +13 -1
  7. package/tools/docs/minifish-testing.md +53 -1
  8. package/tools/docs/semantic-identity.md +3 -3
  9. package/tools/modules/minifish/anchor_check.py +108 -0
  10. package/tools/modules/minifish/anchor_templates.py +31 -0
  11. package/tools/modules/minifish/checks/package.test.mjs +1 -1
  12. package/tools/modules/minifish/compiler/anchor-coverage.mjs +37 -0
  13. package/tools/modules/minifish/compiler/anchor-values-cli.mjs +16 -0
  14. package/tools/modules/minifish/compiler/anchor-values.mjs +56 -0
  15. package/tools/modules/minifish/compiler/build-case.mjs +7 -1
  16. package/tools/modules/minifish/compiler/compile-check.mjs +16 -3
  17. package/tools/modules/minifish/compiler/component-contract.mjs +37 -0
  18. package/tools/modules/minifish/compiler/config.mjs +1 -1
  19. package/tools/modules/minifish/component_index.py +192 -0
  20. package/tools/modules/minifish/component_index_bridge.py +28 -0
  21. package/tools/modules/minifish/harness/browser.mjs +14 -3
  22. package/tools/modules/minifish/harness/component-membership.mjs +32 -0
  23. package/tools/modules/minifish/harness/component-state.mjs +43 -0
  24. package/tools/modules/minifish/harness/held-snapshot.mjs +23 -6
  25. package/tools/modules/minifish/harness/held-spatial.mjs +25 -0
  26. package/tools/modules/minifish/harness/independent-case-runner.mjs +17 -3
  27. package/tools/modules/minifish/harness/jsapi.mjs +4 -1
  28. package/tools/modules/minifish/harness/official-bridge-dispatch.mjs +20 -0
  29. package/tools/modules/minifish/harness/spatial-contract.mjs +20 -0
  30. package/tools/modules/minifish/harness/visual-carrier.mjs +7 -0
  31. package/tools/modules/minifish/harness/visual-expectation.mjs +1 -1
  32. package/tools/modules/workflow/compile_gate.py +29 -1
@@ -1,3 +1,6 @@
1
+ import {validateComponentStates} from '../harness/component-state.mjs';
2
+ import {loadComponentContract} from './component-contract.mjs';
3
+ import {assertionComponents,compareCoverage} from './anchor-coverage.mjs';
1
4
  import assert from 'node:assert/strict';
2
5
  import path from 'node:path';
3
6
  import {readFile,realpath,mkdir,writeFile} from 'node:fs/promises';
@@ -37,6 +40,11 @@ export async function compileCheck(options){
37
40
  const manifest=await readTracked(options.manifest),catalog=await readTracked(path.resolve(root,config.catalog));
38
41
  const parsedManifest=JSON.parse(manifest.bytes),parsedCatalog=JSON.parse(catalog.bytes);
39
42
  const rows=activeCases(parsedManifest,parsedCatalog);
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;
40
48
  const adapter=await import(pathToFileURL(await project.resolveAdapter(config.compilerAdapter)).href);
41
49
  const definitionRoot=await realpath(path.resolve(root,config.definitions)),results=[];
42
50
  for(const row of rows){
@@ -49,13 +57,18 @@ export async function compileCheck(options){
49
57
  if(dir===definitionRoot)break;assert(dir.startsWith(definitionRoot+path.sep),'Preparation escaped root');
50
58
  }
51
59
  assert.deepEqual(definition.ancestors,fragments.map(f=>f.id),'Preparation ancestry mismatch');
52
- await adapter.compile({projectRoot:root,config,catalog:parsedCatalog,definition,fragments});
53
- results.push({id:row.definition,definition:row.definition,status:'passed'});
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);
63
+ const componentIds=assertionComponents(plan);
64
+ results.push({id:row.definition,definition:row.definition,status:'passed',componentIds});
54
65
  }catch(e){results.push({id:row.definition,definition:row.definition,status:'failed',error:e.message});}
55
66
  }
56
67
  git('diff','--exit-code','HEAD','--');assert.equal(git('rev-parse','HEAD'),head,'Candidate changed during compilation');
57
- const result={schemaVersion:1,kind:'minifish-full-compile',head,projectPath,projectTree:git('rev-parse',head+':'+projectPath),
68
+ const anchorCoverage=compareCoverage(sourceComponentIds,results.filter(r=>r.status==='passed'));
69
+ const result={schemaVersion:2,kind:'minifish-full-compile',head,projectPath,projectTree:git('rev-parse',head+':'+projectPath),
58
70
  manifest:{path:manifest.relative,sha256:sha(manifest.bytes)},catalog:{path:catalog.relative,sha256:sha(catalog.bytes)},
71
+ anchorInventory:inventory,anchorCoverage,registry,componentContract,
59
72
  toolIdentity:await toolIdentity(config.toolSource,path.resolve(root,config.toolSourceManifest)),
60
73
  total:results.length,passed:results.filter(r=>r.status==='passed').length,failed:results.filter(r=>r.status==='failed').length,
61
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);
@@ -0,0 +1,192 @@
1
+ """Stable allocated references. Mapping changes never imply business decisions."""
2
+ import hashlib
3
+ import json
4
+ import re
5
+ import secrets
6
+
7
+
8
+ def fingerprint(value):
9
+ return hashlib.sha256(json.dumps(value,ensure_ascii=False,sort_keys=True,separators=(',',':')).encode()).hexdigest()
10
+
11
+
12
+ def source_ids(inventory):
13
+ if inventory.get('schemaVersion')!=2 or inventory.get('identityContract')!='islands-point-id':
14
+ raise ValueError('Official inventory required')
15
+ if not re.fullmatch('[a-f0-9]{64}',inventory.get('source',{}).get('manifestSha256','')):
16
+ raise ValueError('Frozen source identity required')
17
+ seen=set(); components=set()
18
+ for row in inventory.get('targets',[]):
19
+ key=row.get('id')
20
+ if not isinstance(key,str) or not key.startswith('视图:') or key!=row.get('target') or key in seen:
21
+ raise ValueError('Invalid or duplicate source identity')
22
+ seen.add(key)
23
+ if row.get('kind')=='component': components.add(key)
24
+ if not components: raise ValueError('Empty component inventory')
25
+ return components
26
+
27
+
28
+ def validate(index, inventory=None, retired=None):
29
+ if not isinstance(index,dict): raise ValueError('Active index must be an object')
30
+ def valid(key): return isinstance(key,str) and re.fullmatch('cmp_[a-f0-9]{6}',key)
31
+ if any(not valid(k) or not isinstance(v,str) or not v.startswith('视图:') for k,v in index.items()):
32
+ raise ValueError('Invalid component mapping')
33
+ if len(set(index.values()))!=len(index): raise ValueError('Duplicate source mapping')
34
+ if retired is not None:
35
+ if not isinstance(retired,list) or any(not valid(k) for k in retired) or len(set(retired))!=len(retired):
36
+ raise ValueError('Invalid retired ID list')
37
+ if set(index)&set(retired): raise ValueError('Active and retired IDs overlap')
38
+ if inventory is not None and set(index.values())!=source_ids(inventory):
39
+ raise ValueError('Incomplete component mapping')
40
+ return dict(index)
41
+
42
+
43
+ def reconcile(inventory, previous=None, remap=None, retire=(), token=None, retired=None):
44
+ """Return active map and retired IDs. Never infer business identity changes."""
45
+ expected=source_ids(inventory); token=token or (lambda:secrets.token_hex(3))
46
+ previous={} if previous is None else previous
47
+ retired=[] if retired is None else retired
48
+ active=validate(previous,retired=retired)
49
+ remap={} if remap is None else remap
50
+ if not isinstance(remap,dict): raise ValueError('Remap object required')
51
+ if not isinstance(retire,(list,tuple)) or len(set(retire))!=len(retire): raise ValueError('Invalid retire list')
52
+ removing=set(retire)
53
+ if (set(remap)|removing)-active.keys(): raise ValueError('Only active references can change')
54
+ if set(remap)&removing: raise ValueError('Cannot remap and retire same reference')
55
+ for key,destination in remap.items():
56
+ if destination not in expected: raise ValueError('Unknown remap destination')
57
+ active[key]=destination
58
+ for key in removing: del active[key]
59
+ validate(active)
60
+ if set(active.values())-expected: raise ValueError('Removed source requires explicit remap or retirement')
61
+ reserved=set(retired)|removing
62
+ used=set(previous)|reserved
63
+ new_sources=[r['id'] for r in inventory['targets'] if r.get('kind')=='component' and r['id'] not in set(active.values())]
64
+ for source in new_sources:
65
+ for _ in range(1024):
66
+ key='cmp_'+token()
67
+ if not re.fullmatch('cmp_[a-f0-9]{6}',key): raise ValueError('Invalid random allocation')
68
+ if key not in used: break
69
+ else: raise ValueError('Collision retry limit reached')
70
+ used.add(key);active[key]=source
71
+ result=active; history=list(retired)+list(retire)
72
+ validate(result,inventory,history)
73
+ return result,history
74
+
75
+
76
+ def difference(inventory, previous):
77
+ current=source_ids(inventory)
78
+ active=validate(previous) if previous else {}
79
+ old=set(active.values())
80
+ return {'addedSourceIds':sorted(current-old),'removedSourceIds':sorted(old-current),
81
+ 'unchangedSourceIds':sorted(current&old)}
82
+
83
+
84
+
85
+ def registry_text(value):
86
+ """One active mapping or retired ID per line; deterministic Git-friendly JSON."""
87
+ if isinstance(value,dict):
88
+ validate(value)
89
+ elif isinstance(value,list):
90
+ validate({},retired=value)
91
+ else: raise ValueError('Registry object or retired list required')
92
+ dump=lambda x:json.dumps(x,ensure_ascii=False)
93
+ if isinstance(value,dict):
94
+ lines=[' '+dump(k)+': '+dump(v)+',' for k,v in value.items()]
95
+ return '{\n'+'\n'.join(lines)+('\n' if lines else '')+'\n}\n'
96
+ lines=[' '+dump(k)+',' for k in value]
97
+ return '[\n'+'\n'.join(lines)+('\n' if lines else '')+'\n]\n'
98
+
99
+
100
+
101
+ def parse_registry(text):
102
+ """Read the canonical one-entry-per-line JSONC registry subset, no comments."""
103
+ lines=text.splitlines()
104
+ if len(lines)<3 or (lines[0],lines[-1]) not in (('{','}'),('[',']')):
105
+ raise ValueError('Invalid registry JSONC framing')
106
+ obj=lines[0]=='{'; result={} if obj else []
107
+ for line in lines[1:-1]:
108
+ if not line.strip(): continue
109
+ if not line.endswith(','): raise ValueError('Registry entries require trailing comma')
110
+ raw=line[:-1]
111
+ if obj:
112
+ def unique(pairs):
113
+ result={}
114
+ for key,value in pairs:
115
+ if key in result: raise ValueError('Duplicate allocated ID')
116
+ result[key]=value
117
+ return result
118
+ row=json.loads('{'+raw+'}',object_pairs_hook=unique)
119
+ if len(row)!=1: raise ValueError('Exactly one mapping per line required')
120
+ key=next(iter(row))
121
+ if key in result: raise ValueError('Duplicate allocated ID')
122
+ result.update(row)
123
+ else: result.append(json.loads(raw))
124
+ if text!=registry_text(result): raise ValueError('Noncanonical registry formatting')
125
+ return result
126
+
127
+
128
+ def main(argv, identity):
129
+ import argparse
130
+ from pathlib import Path
131
+ parser=argparse.ArgumentParser(description=__doc__)
132
+ parser.add_argument('operation',choices=['reconcile','verify','diff','resolve'])
133
+ parser.add_argument('--inventory',type=Path,required=True)
134
+ parser.add_argument('--inventory-sha256',required=True)
135
+ parser.add_argument('--index',type=Path)
136
+ parser.add_argument('--index-sha256')
137
+ parser.add_argument('--retired',type=Path)
138
+ parser.add_argument('--retired-sha256')
139
+ parser.add_argument('--changes',type=Path)
140
+ parser.add_argument('--source-id')
141
+ parser.add_argument('--output',type=Path)
142
+ args=parser.parse_args(argv)
143
+ def read(path,sha,registry=False):
144
+ raw=path.read_bytes()
145
+ if hashlib.sha256(raw).hexdigest()!=sha: raise ValueError('Input hash mismatch: '+str(path))
146
+ return parse_registry(raw.decode()) if registry else json.loads(raw)
147
+ inventory=read(args.inventory,args.inventory_sha256)
148
+ if bool(args.index)!=bool(args.index_sha256): raise ValueError('Index path and hash required together')
149
+ previous=read(args.index,args.index_sha256,True) if args.index else None
150
+ if bool(args.retired)!=bool(args.retired_sha256): raise ValueError('Retired path and hash required together')
151
+ if bool(args.index)!=bool(args.retired): raise ValueError('Existing registry requires both files')
152
+ retired=read(args.retired,args.retired_sha256,True) if args.retired else []
153
+ if previous is not None:
154
+ validate(previous,retired=retired)
155
+ for file,value in [(args.index,previous),(args.retired,retired)]:
156
+ if file.read_bytes()!=registry_text(value).encode('utf-8'):
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')
172
+ if args.operation=='verify':
173
+ if previous is None or args.changes or args.output: raise ValueError('Verify requires index, no changes or output')
174
+ print(json.dumps({'passed':True,'activeComponents':len(validate(previous,inventory,retired))}))
175
+ return 0
176
+ if args.operation=='diff':
177
+ if args.changes or args.output: raise ValueError('Diff does not accept changes or output')
178
+ print(json.dumps(difference(inventory,previous),ensure_ascii=False))
179
+ return 0
180
+ if not args.output: raise ValueError('New output path required')
181
+ changes=json.loads(args.changes.read_text()) if args.changes else {}
182
+ if not isinstance(changes,dict) or set(changes)-{'remap','retire'}: raise ValueError('Unknown change fields')
183
+ result,history=reconcile(inventory,previous,changes.get('remap'),changes.get('retire',[]),retired=retired)
184
+ # A new staging directory prevents partially overwriting the live pair.
185
+ # Consumers accept both files in one Git commit after verification.
186
+ args.output.mkdir(parents=True,exist_ok=False)
187
+ for name,value in [('anchors.jsonc',result),('anchors-retired.jsonc',history)]:
188
+ with (args.output/name).open('x') as stream:
189
+ stream.write(registry_text(value))
190
+ print(json.dumps({'output':str(args.output),'activeComponents':len(result),
191
+ 'toolCommit':identity['commit']},ensure_ascii=False))
192
+ return 0
@@ -0,0 +1,28 @@
1
+ """Internal Node compiler bridge to the single registry parser/validator."""
2
+ import json
3
+ from pathlib import Path
4
+ import sys
5
+ sys.dont_write_bytecode = True
6
+ sys.path.insert(0,str(Path(__file__).resolve().parents[3]))
7
+ from tools.modules.minifish.component_index import parse_registry,validate
8
+
9
+ if __name__=='__main__':
10
+ request=json.load(sys.stdin)
11
+ active=parse_registry(request['active'])
12
+ retired=parse_registry(request['retired'])
13
+ validate(active,request['inventory'],retired)
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)))
@@ -1,3 +1,4 @@
1
+ import {adaptOfficialBridgeDispatch} from './official-bridge-dispatch.mjs';
1
2
  import {installHostDeviceFixtureEvidence} from './host-device-fixture.mjs';
2
3
  import {nativeUiResultPath} from './native-ui-contract.mjs';
3
4
  import {observeLocalNavigationCandidate,localNavigationEvidence,delegateLocalNavigation,assertLocalNavigationComplete} from './local-navigation-evidence.mjs';
@@ -9,7 +10,7 @@ import {pluginPackagePlan} from './plugin-package.mjs';
9
10
  import {installHostLanguage} from './host-language.mjs';
10
11
  import {installResponseConsumption} from './response-consumption.mjs';
11
12
  import { fulfillRpc } from './rpc-wire.mjs';
12
- import { bridgePath, jsapiPath, jsapiDeliveryPath, acknowledgeJsapiDelivery, bridgeScript, fulfillJsapi, businessJsapiOption } from './jsapi.mjs';
13
+ import { bridgePath, jsapiPath, jsapiDeliveryPath, acknowledgeJsapiDelivery, bridgeScript, managedMockApis, fulfillJsapi, businessJsapiOption } from './jsapi.mjs';
13
14
 
14
15
  // Official Lyra keeps earlier pages painted in the same simulator stack.
15
16
  // The last non-exiting sibling is the active shell; route name alone is ambiguous.
@@ -42,7 +43,7 @@ export async function openMinifish({ page, plan, query, simulatorUrl, pageRoute,
42
43
  const hostDeviceEvidence=await installHostDeviceFixtureEvidence(page);
43
44
  const loadingObservation=await installLoadingObservation(page);
44
45
  await prepareIPhone16ProDevice(page,url);
45
- const faults = [], blocked = [], materials = [];
46
+ const faults = [], blocked = [], materials = [], bridgeDispatchAdapters = [];
46
47
  const officialUi=[...new Set(plan.evidence().expectations.filter(r=>r.execution==='official-ui').map(r=>r.request.operation.slice(6)))];
47
48
  const pluginPlan=plugin?pluginPackagePlan({appAddress:url.searchParams.get('appAddress'),appId:url.searchParams.get('appId'),...plugin}):null;
48
49
  const pluginEvents=plugin?.events?pluginEventLedger(plugin.events,validatePluginEvents):null;
@@ -53,6 +54,14 @@ export async function openMinifish({ page, plan, query, simulatorUrl, pageRoute,
53
54
  const handler = async (route) => {
54
55
  const request = route.request(), target = new URL(request.url());
55
56
  try {
57
+ if(target.origin===url.origin&&target.pathname==='/lyra/runtime.bundle.js'){
58
+ assert.equal(request.method(),'GET');
59
+ const response=await route.fetch();
60
+ assert(response.ok(),'Official bridge runtime fetch failed');
61
+ const adapted=adaptOfficialBridgeDispatch(await response.text(),{managed:managedMockApis(nativeApis),officialUi});
62
+ bridgeDispatchAdapters.push(adapted.evidence);
63
+ return await route.fulfill({response,body:adapted.body});
64
+ }
56
65
  if (target.origin === url.origin && target.pathname === bridgePath) return await route.fulfill({contentType:'application/javascript',body:bridgeScript(url.origin,nativeApis,Boolean(process.env.MINIFISH_LOADING_DIAGNOSTICS_DIR),officialUi,process.env.MINIFISH_CALLBACK_CORRELATION==='1')});
57
66
  if(target.origin===url.origin&&target.pathname===jsapiDeliveryPath)return await acknowledgeJsapiDelivery(route,plan);
58
67
  if (target.origin === url.origin && target.pathname === jsapiPath) {const wire=request.postDataJSON();callbackCorrelation?.transport(wire,request.frame());observeLocalNavigationCandidate(page,wire);if(officialUi.includes(wire.api)){plan.recordNativeUiRequest('jsapi:'+wire.api,businessJsapiOption(wire.option),wire);return await route.fulfill({status:204});}if(delegateLocalNavigation(page,wire))return await route.fulfill({status:204});return await fulfillJsapi(route, plan);}
@@ -115,6 +124,7 @@ export async function openMinifish({ page, plan, query, simulatorUrl, pageRoute,
115
124
  capturePayGuide:(stage)=>guideDiagnostic?.capture(stage),
116
125
  flushPayGuide:()=>guideDiagnostic?.flush(),
117
126
  assertComplete() {
127
+ assert(bridgeDispatchAdapters.length>0,'Official bridge compatibility adapter was not observed');
118
128
  assertLocalNavigationComplete(page,plan.evidence(),queryExpectations);
119
129
  assert.deepEqual(faults, [], 'Network violations remain failures even when caught by product');
120
130
  plan.assertComplete();
@@ -123,8 +133,9 @@ export async function openMinifish({ page, plan, query, simulatorUrl, pageRoute,
123
133
  if(requireHostLanguage)host.assertObserved();
124
134
  if(hostSystemPlan)host.assertSystemPlan(hostSystemPlan);
125
135
  },
126
- evidence() { return { ...plan.evidence(), hostDeviceFixture:hostDeviceEvidence.evidence(), callbackCorrelation:callbackCorrelation?.evidence()??[], payGuideDiagnostics:guideDiagnostic?.evidence()??[], hostLanguage:host.evidence(), localNavigation:localNavigationEvidence(page), networkViolations: [...faults], blocked: [...blocked], materials: [...materials], plugin:pluginPlan?.evidence()??null, pluginEvents:pluginEvents?.evidence()??null }; },
136
+ evidence() { return { ...plan.evidence(), bridgeDispatchAdapters:[...bridgeDispatchAdapters], hostDeviceFixture:hostDeviceEvidence.evidence(), callbackCorrelation:callbackCorrelation?.evidence()??[], payGuideDiagnostics:guideDiagnostic?.evidence()??[], hostLanguage:host.evidence(), localNavigation:localNavigationEvidence(page), networkViolations: [...faults], blocked: [...blocked], materials: [...materials], plugin:pluginPlan?.evidence()??null, pluginEvents:pluginEvents?.evidence()??null }; },
127
137
  assertArtifactComplete(skipped){
138
+ assert(bridgeDispatchAdapters.length>0,'Official bridge compatibility adapter was not observed');
128
139
  assertLocalNavigationComplete(page,plan.evidence(),queryExpectations);
129
140
  assert.deepEqual(faults, [], 'Artifact network violations remain failures');
130
141
  const audit=plan.assertArtifactComplete(skipped);
@@ -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,8 @@
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';
5
+ import {validateFrozenSpatial} from './held-spatial.mjs';
1
6
  import {assertVisualMetrics} from './visual-expectation.mjs';
2
7
  import assert from 'node:assert/strict';
3
8
  import {sampleNativeMetrics,sampleNativeTextVisibility,sampleNativeStyle,sampleNativePressed,sampleHeldNativeState} from './native-dom-sampling.mjs';
@@ -32,10 +37,10 @@ export async function captureHeldSnapshot(root,spec){
32
37
  const timeline=root.ownerDocument.defaultView.__seeleHeldTimelineV1;if(!timeline)throw Error('Held timeline must be installed before pressing');
33
38
  const now=()=>performance.timeOrigin+performance.now();const start=timeline.events.find(e=>e.type==='touchstart')??timeline.events.find(e=>e.type==='mousedown');if(!start)throw Error('No actual native down event');
34
39
  let groups;while(true){const elements=[...root.ownerDocument.querySelectorAll('*')];groups=spec.targets.map(target=>({target,nodes:elements.filter(node=>{const id=identity(node);return matches(id,target.target,target.instance);})}));
35
- if(groups.every(g=>g.nodes.length)||now()-start.time>=350)break;
40
+ if(groups.every(g=>g.target.waitForPresence===false||g.nodes.length)||now()-start.time>=350)break;
36
41
  await new Promise(resolve=>{const timer=setTimeout(resolve,Math.max(0,350-(now()-start.time)));requestAnimationFrame(()=>{clearTimeout(timer);resolve();});});
37
42
  }
38
- const captureStartedAt=now();const result={version:spec.version,captureStartedAt,pressAt:start.time,timedOut:groups.some(g=>!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;
39
44
  }`;
40
45
  return freezeHeldSnapshot(await root.locator(':root').evaluate(Function('return ('+body+')')(),spec));
41
46
  }
@@ -47,23 +52,35 @@ export function validateHeldInputTiming(snapshot,events){
47
52
  assert(snapshot&&!snapshot.timedOut&&snapshot.pressAt===down.time&&snapshot.snapshotAt>=down.time&&snapshot.snapshotAt<=up.time,'Only actual held-time snapshot allowed');
48
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};
49
54
  }
50
- export function validateFrozenHeldAssertion(snapshot,a,validators){
55
+ export function validateFrozenHeldAssertion(snapshot,a,validators,contract){
51
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]);
52
58
  validators.validateState({count:group.count,visible:group.members.map(m=>m.state.visible)},a);
53
59
  const observations=[];
54
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.
55
61
 
62
+ if(a.componentState!==undefined)assertComponentState(member.componentState,a.componentState);
56
63
  if(a.enabled)assert.equal(member.state.enabled,true,'Original native enabled obligation');validators.validateObservation(member.metrics,a);
57
- 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
+ }
58
73
  if(a.textFits)validateNativeTextVisibility(member.textPaint,{selfOverflow:'contain'});observations.push({...member.metrics,...(a.textFits?{textPaint:member.textPaint}:{})});
59
74
  }
60
- return {count:group.count,visible:group.members.map(m=>m.state.visible),observations,snapshotVersion:snapshot.version,snapshotAt:snapshot.snapshotAt};
75
+ const spatial=a.spatial?validateFrozenSpatial(snapshot,a.spatial):undefined;
76
+ return {...(spatial?{spatial}:{}),count:group.count,visible:group.members.map(m=>m.state.visible),observations,snapshotVersion:snapshot.version,snapshotAt:snapshot.snapshotAt};
61
77
  }
62
78
  // The release happens in finally before any Node validator or boundary wait.
63
79
  export async function executeHeldSnapshot({root,spec,release,validators,capture=captureHeldSnapshot,readEvents=readReleasedTimeline}){
64
80
  let snapshot,error,releaseReceipt;try{snapshot=await capture(root,spec);}catch(e){error=e;}finally{releaseReceipt=await release(spec.release);}
65
81
  const events=await readEvents(root);let timing;
66
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:{}};}
67
- 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});}}
68
85
  return {snapshot,releaseReceipt,timing,assertions,observations};
69
86
  }
@@ -0,0 +1,25 @@
1
+ import assert from 'node:assert/strict';
2
+ import {validateRegionOrder} from './region-order.mjs';
3
+ import {validateSpatial,spatialTarget as target} from './spatial-contract.mjs';
4
+ export {validateSpatial} from './spatial-contract.mjs';
5
+ export function prepareHeldWithSpatial(plan,prepareProject){
6
+ const clean=structuredClone(plan);
7
+ for(const a of clean.assertions){if(a.spatial!==undefined)validateSpatial(a.spatial);delete a.spatial;delete a.componentState;}
8
+ const result=prepareProject(clean);
9
+ result.assertions=structuredClone(plan.assertions);
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);}
12
+ if(!a.spatial)continue;
13
+ const refs=[...(a.spatial.absent??[]),...(a.spatial.order??[]).flatMap(p=>[p.above,p.below])];
14
+ for(const ref of refs){const t=target(ref);if(!result.targets.some(existing=>existing.key===t.key))result.targets.push(t);}
15
+ }
16
+ return result;
17
+ }
18
+ export function validateFrozenSpatial(snapshot,spec){
19
+ validateSpatial(spec);
20
+ const group=ref=>{const t=target(ref),g=snapshot.groups.find(g=>g.key===t.key);assert(g,'Missing frozen spatial observation');return g;};
21
+ const box=ref=>{const g=group(ref);assert.equal(g.count,1,'Spatial target must be unique');assert.equal(g.members.length,1);const m=g.members[0];assert(m.state.visible,'Spatial target must be visible');return m.metrics.box;};
22
+ const order=(spec.order??[]).map(pair=>validateRegionOrder(box(pair.above),box(pair.below)));
23
+ const absent=(spec.absent??[]).map(ref=>{const g=group(ref);assert.equal(g.count,0,'Forbidden component exists');assert.equal(g.members.length,0);return ref;});
24
+ return {order,absent,snapshotAt:snapshot.snapshotAt};
25
+ }
@@ -1,3 +1,6 @@
1
+ import {sampleComponentState,assertComponentState,assertComponentBranch,validateComponentStates} from './component-state.mjs';
2
+ import {sampleDeclaredComponents,validateComponentMembership} from './component-membership.mjs';
3
+ import {prepareHeldWithSpatial} from './held-spatial.mjs';
1
4
  import {validateRegionVisualAssertions,assertVisualMetrics} from './visual-expectation.mjs';
2
5
  import {hostPolicy} from '@minifish/project-host';
3
6
  import {prepareHeldNativeInput,executeHeldSnapshot} from './held-snapshot.mjs';
@@ -28,6 +31,9 @@ function target(root, name) { return semanticLocator(root, name); }
28
31
 
29
32
  export async function runMiniappCase({page, plan, testInfo}) {
30
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);
31
37
  assert.equal(plan.resources.length, 0, 'resource handling is not implemented in this runner yet');
32
38
  const boundary = plan.boundaries.length ? independentBoundary(plan.boundaries) : zeroBusinessPlan();
33
39
  const images = installRendererImageEvidence(page);
@@ -56,7 +62,9 @@ export async function runMiniappCase({page, plan, testInfo}) {
56
62
  const locate=(root,name,options)=>locateProject(root,name,options,observation);
57
63
  try {
58
64
  result.preparation = 'running';
59
- if(!artifactOnly&&(plan.beforeTarget??[]).some(a=>a.kind==='press'))heldSpec=prepareHeldSnapshot(plan);
65
+ if(!artifactOnly&&plan.assertions.some(a=>a.spatial!==undefined))assert((plan.beforeTarget??[]).some(a=>a.kind==='press'),'Spatial snapshot requires held input');
66
+ if(!artifactOnly&&(plan.beforeTarget??[]).some(a=>a.kind==='press'))heldSpec=prepareHeldWithSpatial(plan,prepareHeldSnapshot);
67
+ if(heldSpec)heldSpec.componentContract=plan.componentContract;
60
68
  if(plan.plugin){
61
69
  assert(process.env.MINIFISH_PLUGIN_PACKAGE_URL&&process.env.MINIFISH_PLUGIN_ARTIFACT_MANIFEST,'Explicit compiled plugin artifact required');
62
70
  const artifact=JSON.parse(await readFile(process.env.MINIFISH_PLUGIN_ARTIFACT_MANIFEST,'utf8'));
@@ -94,7 +102,7 @@ export async function runMiniappCase({page, plan, testInfo}) {
94
102
  assert(app, 'missing real page opening');
95
103
  await trace?.attach();
96
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)});
97
- 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');
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');
98
106
  if(heldSpec)await prepareHeldNativeInput(app.root,heldSpec,locate(app.root,heldSpec.press.target,{instance:heldSpec.press.instance}));
99
107
  for(const action of plan.beforeTarget??[]){
100
108
  if(action.kind==='leave-return'){
@@ -114,7 +122,13 @@ export async function runMiniappCase({page, plan, testInfo}) {
114
122
  await synchronizeAnchors(app.root);
115
123
  const locator=assertion.kind==='host-navigation'?undefined:locate(app.root, assertion.target,{instance:assertion.instance});
116
124
  try {
117
- if(assertion.kind==='host-native-ui'){result.observations[assertion.id]=await assertHostNativeUi(page,app,assertion);
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);
118
132
  }else if(assertion.kind==='carousel-frame'){result.observations[assertion.id]=await observeCarousel(app.root,assertion.contract,{urls:assertion.images,records:images.records});
119
133
  }else if(assertion.kind==='host-navigation'){result.observations[assertion.id]=await assertHostNavigation(page,app,assertion,plan.hostSystemPlan);
120
134
  }else if(assertion.kind==='region'){
@@ -9,10 +9,13 @@ export const jsapiPath = '/__bill_tests__/jsapi';
9
9
  export const jsapiDeliveryPath = '/__bill_tests__/jsapi-delivery';
10
10
  const deliveryPlans = new WeakMap();
11
11
  const deliveryHeader = 'x-minifish-response-id';
12
+ export function managedMockApis(additionalApis=[]){
13
+ return ['makePhoneCall','tradePay','getAuthCode','startApp','exitMiniProgram','exitApp',...additionalApis,...(additionalApis.includes('getStorage')?['getTinyLocalStorage']:[]),...(additionalApis.includes('setStorage')?['setTinyLocalStorage']:[])];
14
+ }
12
15
  export function bridgeScript(origin, additionalApis = [], observeLoading = false, officialUi = [], observeCorrelation = false) {
13
16
  assert(!additionalApis.includes('pageScrollTo'),'Official scroll cannot enter renderer Mock list');
14
17
  assert(officialUi.every(api=>nativeUiApis.includes(api)),'Finite official UI delegation list');
15
- const mockedApis = ['makePhoneCall','tradePay','getAuthCode','startApp','exitMiniProgram','exitApp', ...additionalApis,...(additionalApis.includes('getStorage')?['getTinyLocalStorage']:[]),...(additionalApis.includes('setStorage')?['setTinyLocalStorage']:[])];
18
+ const mockedApis = managedMockApis(additionalApis);
16
19
  return `window.lyraRendererExtendBridge.beforeMount(params => {
17
20
  const hostFixture=${JSON.stringify(hostDeviceFixture)};
18
21
  const mounted=(${configureHostDeviceMount.toString()})(params,hostFixture);