@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.
- package/package.json +1 -1
- package/tools/adapters/islands/component_reference.py +62 -0
- package/tools/adapters/islands/visual_targets.py +106 -0
- package/tools/cli/main.py +2 -2
- package/tools/cli/minifish.mjs +1 -1
- package/tools/cli/minifish.py +13 -1
- package/tools/docs/minifish-testing.md +53 -1
- package/tools/docs/semantic-identity.md +3 -3
- package/tools/modules/minifish/anchor_check.py +108 -0
- package/tools/modules/minifish/anchor_templates.py +31 -0
- package/tools/modules/minifish/checks/package.test.mjs +1 -1
- package/tools/modules/minifish/compiler/anchor-coverage.mjs +37 -0
- package/tools/modules/minifish/compiler/anchor-values-cli.mjs +16 -0
- package/tools/modules/minifish/compiler/anchor-values.mjs +56 -0
- package/tools/modules/minifish/compiler/build-case.mjs +7 -1
- package/tools/modules/minifish/compiler/compile-check.mjs +16 -3
- 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 +192 -0
- package/tools/modules/minifish/component_index_bridge.py +28 -0
- package/tools/modules/minifish/harness/browser.mjs +14 -3
- 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 +23 -6
- package/tools/modules/minifish/harness/held-spatial.mjs +25 -0
- package/tools/modules/minifish/harness/independent-case-runner.mjs +17 -3
- package/tools/modules/minifish/harness/jsapi.mjs +4 -1
- package/tools/modules/minifish/harness/official-bridge-dispatch.mjs +20 -0
- package/tools/modules/minifish/harness/spatial-contract.mjs +20 -0
- package/tools/modules/minifish/harness/visual-carrier.mjs +7 -0
- package/tools/modules/minifish/harness/visual-expectation.mjs +1 -1
- 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
|
-
|
|
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
|
|
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
|
|
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'){
|
|
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
|
-
|
|
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
|
-
|
|
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'))
|
|
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||
|
|
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
|
-
|
|
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 =
|
|
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);
|