@antprofuse/saddle-islands-tools 0.0.0-stage → 0.1.4

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 (122) hide show
  1. package/package.json +15 -4
  2. package/tools/adapters/islands/trigger_source.py +53 -0
  3. package/tools/cli/backend.mjs +24 -0
  4. package/tools/cli/main.py +38 -0
  5. package/tools/cli/minifish.mjs +38 -0
  6. package/tools/cli/minifish.py +52 -0
  7. package/tools/core/package-identity.mjs +23 -0
  8. package/tools/core/package_identity.py +58 -0
  9. package/tools/core/semantic_identity.py +149 -0
  10. package/tools/core/test_semantic_identity.py +136 -0
  11. package/tools/docs/README.md +43 -0
  12. package/tools/docs/backend-testing.md +36 -0
  13. package/tools/docs/iteration.md +34 -0
  14. package/tools/docs/minifish-testing.md +32 -0
  15. package/tools/docs/semantic-identity.md +61 -0
  16. package/tools/modules/backend/actions.mjs +10 -0
  17. package/tools/modules/backend/assert.mjs +20 -0
  18. package/tools/modules/backend/context-receiver.py +30 -0
  19. package/tools/modules/backend/facility.test.mjs +44 -0
  20. package/tools/modules/backend/grouped_context.py +100 -0
  21. package/tools/modules/backend/grpc.mjs +57 -0
  22. package/tools/modules/backend/index.mjs +9 -0
  23. package/tools/modules/backend/input-tree.mjs +14 -0
  24. package/tools/modules/backend/logs.mjs +24 -0
  25. package/tools/modules/backend/logs.test.mjs +28 -0
  26. package/tools/modules/backend/metadata.mjs +66 -0
  27. package/tools/modules/backend/package-lock.json +362 -0
  28. package/tools/modules/backend/package.json +15 -0
  29. package/tools/modules/backend/paths.mjs +11 -0
  30. package/tools/modules/backend/product.mjs +48 -0
  31. package/tools/modules/backend/rejection-logs.mjs +33 -0
  32. package/tools/modules/backend/report.mjs +13 -0
  33. package/tools/modules/backend/report.test.mjs +13 -0
  34. package/tools/modules/backend/run.mjs +50 -0
  35. package/tools/modules/backend/util.mjs +24 -0
  36. package/tools/modules/minifish/checks/guard.test.mjs +13 -0
  37. package/tools/modules/minifish/checks/package.test.mjs +23 -0
  38. package/tools/modules/minifish/compiler/build-case.mjs +39 -0
  39. package/tools/modules/minifish/compiler/case-plan.mjs +175 -0
  40. package/tools/modules/minifish/compiler/config.mjs +29 -0
  41. package/tools/modules/minifish/compiler/observation-module-name.mjs +2 -0
  42. package/tools/modules/minifish/compiler/package-runner.mjs +43 -0
  43. package/tools/modules/minifish/compiler/semantic-module-gate.mjs +27 -0
  44. package/tools/modules/minifish/compiler/tool-identity.mjs +14 -0
  45. package/tools/modules/minifish/harness/batch_cases.py +91 -0
  46. package/tools/modules/minifish/harness/batch_plugin_groups.py +56 -0
  47. package/tools/modules/minifish/harness/batch_report.py +104 -0
  48. package/tools/modules/minifish/harness/browser.mjs +146 -0
  49. package/tools/modules/minifish/harness/build-plugin-artifact.mjs +25 -0
  50. package/tools/modules/minifish/harness/capture_contract.py +78 -0
  51. package/tools/modules/minifish/harness/carousel-observation.mjs +62 -0
  52. package/tools/modules/minifish/harness/carriers.mjs +49 -0
  53. package/tools/modules/minifish/harness/check-independent-batch.py +84 -0
  54. package/tools/modules/minifish/harness/check-runner-isolation.py +102 -0
  55. package/tools/modules/minifish/harness/component_runtime.py +125 -0
  56. package/tools/modules/minifish/harness/contract-value.mjs +32 -0
  57. package/tools/modules/minifish/harness/deferred-visual.mjs +21 -0
  58. package/tools/modules/minifish/harness/device-prepare.mjs +26 -0
  59. package/tools/modules/minifish/harness/execution-evidence.mjs +37 -0
  60. package/tools/modules/minifish/harness/foreground.mjs +20 -0
  61. package/tools/modules/minifish/harness/header-layout.mjs +47 -0
  62. package/tools/modules/minifish/harness/held-snapshot.mjs +67 -0
  63. package/tools/modules/minifish/harness/host-device-fixture.mjs +28 -0
  64. package/tools/modules/minifish/harness/host-language.mjs +73 -0
  65. package/tools/modules/minifish/harness/host-native-ui.mjs +39 -0
  66. package/tools/modules/minifish/harness/host-navigation.mjs +96 -0
  67. package/tools/modules/minifish/harness/image-loading.mjs +35 -0
  68. package/tools/modules/minifish/harness/independent-case-runner.mjs +175 -0
  69. package/tools/modules/minifish/harness/independent-visual.mjs +107 -0
  70. package/tools/modules/minifish/harness/inline-svg.mjs +32 -0
  71. package/tools/modules/minifish/harness/input-lifecycle-evidence.mjs +16 -0
  72. package/tools/modules/minifish/harness/jsapi.mjs +92 -0
  73. package/tools/modules/minifish/harness/loading-input.mjs +52 -0
  74. package/tools/modules/minifish/harness/loading-observation.mjs +25 -0
  75. package/tools/modules/minifish/harness/local-navigation-evidence.mjs +82 -0
  76. package/tools/modules/minifish/harness/miniapp-screenshot.mjs +38 -0
  77. package/tools/modules/minifish/harness/native-dom-sampling.mjs +40 -0
  78. package/tools/modules/minifish/harness/native-enabled.mjs +11 -0
  79. package/tools/modules/minifish/harness/native-rpc-failure.mjs +11 -0
  80. package/tools/modules/minifish/harness/native-state.mjs +14 -0
  81. package/tools/modules/minifish/harness/native-ui-contract.mjs +29 -0
  82. package/tools/modules/minifish/harness/navigation.mjs +19 -0
  83. package/tools/modules/minifish/harness/network_isolation.py +134 -0
  84. package/tools/modules/minifish/harness/painted-source-image.mjs +54 -0
  85. package/tools/modules/minifish/harness/playwright.mjs +1 -0
  86. package/tools/modules/minifish/harness/plugin-events.mjs +16 -0
  87. package/tools/modules/minifish/harness/plugin-package.mjs +23 -0
  88. package/tools/modules/minifish/harness/plugin_artifact.py +23 -0
  89. package/tools/modules/minifish/harness/product_identity.py +8 -0
  90. package/tools/modules/minifish/harness/project_config.py +14 -0
  91. package/tools/modules/minifish/harness/prototype-visual.mjs +123 -0
  92. package/tools/modules/minifish/harness/region-order.mjs +16 -0
  93. package/tools/modules/minifish/harness/renderer-image-evidence.mjs +32 -0
  94. package/tools/modules/minifish/harness/request-guard.mjs +65 -0
  95. package/tools/modules/minifish/harness/request-plan.mjs +124 -0
  96. package/tools/modules/minifish/harness/response-consumption.mjs +106 -0
  97. package/tools/modules/minifish/harness/retained-page-lifecycle.mjs +48 -0
  98. package/tools/modules/minifish/harness/rpc-wire.mjs +34 -0
  99. package/tools/modules/minifish/harness/run-batch.py +133 -0
  100. package/tools/modules/minifish/harness/run-platform.py +293 -0
  101. package/tools/modules/minifish/harness/runtime_isolation.py +71 -0
  102. package/tools/modules/minifish/harness/scroll-container-observation.mjs +40 -0
  103. package/tools/modules/minifish/harness/semantic-anchor.mjs +139 -0
  104. package/tools/modules/minifish/harness/semantic-observation.mjs +197 -0
  105. package/tools/modules/minifish/harness/skeleton-appearance.mjs +49 -0
  106. package/tools/modules/minifish/harness/target-actions.mjs +120 -0
  107. package/tools/modules/minifish/harness/test_batch_concurrency.py +125 -0
  108. package/tools/modules/minifish/harness/test_batch_plugin_groups.py +97 -0
  109. package/tools/modules/minifish/harness/test_batch_runner.py +107 -0
  110. package/tools/modules/minifish/harness/test_common_report.py +47 -0
  111. package/tools/modules/minifish/harness/test_held_capture_contract.py +50 -0
  112. package/tools/modules/minifish/harness/test_pty_tail.py +39 -0
  113. package/tools/modules/minifish/harness/test_runtime_dependencies.py +49 -0
  114. package/tools/modules/minifish/harness/zero-business-plan.mjs +19 -0
  115. package/tools/modules/minifish/identity.py +6 -0
  116. package/tools/modules/minifish/package-lock.json +74 -0
  117. package/tools/modules/minifish/package.json +10 -0
  118. package/tools/modules/minifish/sdk/package-lock.json +57745 -0
  119. package/tools/modules/minifish/sdk/package.json +13 -0
  120. package/tools/modules/workflow/diff_review.py +74 -0
  121. package/tools/modules/workflow/iteration.py +175 -0
  122. package/README.md +0 -3
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env python3
2
+ """Real dual formal-runner acceptance; faults are intentional, no retries."""
3
+ import argparse
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import signal
8
+ import subprocess
9
+ import sys
10
+ import time
11
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
12
+ from network_isolation import namespace_command, namespace_members
13
+
14
+ p=argparse.ArgumentParser()
15
+ p.add_argument('--project-root',type=Path,required=True)
16
+ p.add_argument('--config',type=Path,required=True)
17
+ p.add_argument('--node-modules',type=Path,required=True)
18
+ p.add_argument('--report-dir',type=Path,required=True)
19
+ p.add_argument('--dev-verbose',action='store_true')
20
+ p.add_argument('--slirp4netns',type=Path)
21
+ p.add_argument('--slirp-library-path',type=Path)
22
+ a=p.parse_args()
23
+ root=a.report_dir.resolve();root.mkdir(parents=True,exist_ok=False)
24
+ control=root/'control';control.mkdir()
25
+ runner=Path(__file__).resolve().parent/'run-platform.py'
26
+ children=[];events=[]
27
+ def event(name,**data):
28
+ events.append(dict(event=name,at=time.time(),**data))
29
+ (root/'events.json').write_text(json.dumps(events,indent=2))
30
+ def launch(marker,timeout=180,mode='hold'):
31
+ work=root/marker
32
+ cmd=[sys.executable,str(runner),'--project-root',str(a.project_root.resolve()),'--config',str(a.config.resolve()),'--node-modules',str(a.node_modules.resolve()),'--check','isolation','--isolation-marker',marker,'--isolation-control-dir',str(control),'--isolation-mode',mode,'--report-dir',str(work),'--timeout',str(timeout)]
33
+ if a.dev_verbose:cmd.append('--dev-verbose')
34
+ for flag, value in [('--slirp4netns', a.slirp4netns), ('--slirp-library-path', a.slirp_library_path)]:
35
+ if value:cmd.extend([flag,str(value.resolve())])
36
+ log=(root/f'{marker}.log').open('w')
37
+ proc=subprocess.Popen(cmd,stdout=log,stderr=log,start_new_session=True)
38
+ children.append((proc,log,work));event('launched',marker=marker,pid=proc.pid,command=cmd)
39
+ return proc,work
40
+ def ready(proc,marker):
41
+ deadline=time.monotonic()+150
42
+ while not (control/f'{marker}.ready').exists():
43
+ if proc.poll() is not None:raise RuntimeError(f'{marker} exited {proc.returncode} before ready')
44
+ if time.monotonic()>deadline:raise TimeoutError(f'{marker} not ready')
45
+ time.sleep(.1)
46
+ event('ready',marker=marker)
47
+ def reclaimed(work):
48
+ lifecycle=json.loads((work/'lifecycle.json').read_text())
49
+ for child in lifecycle['children']:
50
+ # Includes surviving grandchildren even when their group leader exited.
51
+ for stat in Path('/proc').glob('[0-9]*/stat'):
52
+ try:
53
+ fields=stat.read_text().rsplit(')',1)[1].split()
54
+ assert not (int(fields[2])==child['pid'] and fields[0]!='Z'), f'Live process group {child["pid"]}'
55
+ except FileNotFoundError:pass
56
+ network=json.loads((work/'network-lifecycle.json').read_text())
57
+ assert not namespace_members(network['network']['netns']), 'Run namespace still has live services'
58
+ assert not network['namespaceCleanup']['remaining']
59
+ for proc in network['children']:
60
+ assert not Path(f'/proc/{proc["pid"]}').exists(), f'Network helper still exists {proc}'
61
+ event('reclaimed',directory=str(work),lifecycle=lifecycle)
62
+ return lifecycle
63
+ passed=False
64
+ try:
65
+ one,w1=launch('ISOLATION-1');two,w2=launch('ISOLATION-2')
66
+ ready(one,'ISOLATION-1');ready(two,'ISOLATION-2')
67
+ assert one.poll() is None and two.poll() is None
68
+ runtimes=[json.loads((w/'runtime.json').read_text()) for w in (w1,w2)]
69
+ assert runtimes[0]['network']['netns'] != runtimes[1]['network']['netns']
70
+ for w in (w1,w2):
71
+ assert (w/'app/node_modules').is_dir() and not (w/'app/node_modules').is_symlink()
72
+ assert not (w/'app/node_modules/.cache').is_symlink()
73
+ event('concurrent',instances=runtimes)
74
+ one.send_signal(signal.SIGTERM);assert one.wait(timeout=20)!=0
75
+ reclaimed(w1)
76
+ assert two.poll() is None
77
+ alive=subprocess.run(namespace_command(runtimes[1])+['curl','--fail','--max-time','5','-s','-o','/dev/null',runtimes[1]['url']],check=True)
78
+ event('peer-http-200',network=runtimes[1]['network'])
79
+ event('peer-survives')
80
+ (control/'ISOLATION-2.continue').touch()
81
+ assert two.wait(timeout=30)==0
82
+ assert reclaimed(w2)['success'] is True
83
+ assert json.loads((w2/'isolation-result.json').read_text())['passed'] is True
84
+ fault,wf=launch('FAULT',mode='fault');ready(fault,'FAULT')
85
+ assert fault.wait(timeout=20)!=0
86
+ assert reclaimed(wf)['success'] is False
87
+ short,wt=launch('TIMEOUT',timeout=30);ready(short,'TIMEOUT')
88
+ assert short.wait(timeout=45)!=0
89
+ assert 'exceeded 30 seconds' in (root/'TIMEOUT.log').read_text()
90
+ assert reclaimed(wt)['success'] is False
91
+ passed=True
92
+ finally:
93
+ for proc,log,work in children:
94
+ if proc.poll() is None:
95
+ proc.send_signal(signal.SIGTERM)
96
+ try:proc.wait(timeout=20)
97
+ except subprocess.TimeoutExpired:
98
+ os.killpg(proc.pid,signal.SIGKILL);proc.wait()
99
+ log.close()
100
+ (root/'result.json').write_text(json.dumps({'passed':passed,'events':events,'children':[{'pid':c.pid,'returncode':c.returncode} for c,_,_ in children]},indent=2))
101
+ if not passed:sys.exit(1)
102
+ print('PASS formal runner concurrency, peer survival, intentional fault and timeout reclamation')
@@ -0,0 +1,125 @@
1
+ from network_isolation import add_network_arguments, active_namespace, entry
2
+ from runtime_isolation import prepare_dependencies, dev_command, drain_pty
3
+ #!/usr/bin/env python3
4
+ """Official Minifish component host, built outside the product repository."""
5
+ import argparse
6
+ import hashlib
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import pty
11
+ import re
12
+ import select
13
+ import shutil
14
+ import signal
15
+ import subprocess
16
+ import sys
17
+ import tempfile
18
+ import time
19
+
20
+ def fingerprint(root):
21
+ return {str(p.relative_to(root)): hashlib.sha256(p.read_bytes()).hexdigest()
22
+ for p in sorted(root.rglob('*')) if p.is_file()
23
+ and not any(part in ('node_modules', '__pycache__', 'dist', '.git') for part in p.relative_to(root).parts)}
24
+
25
+
26
+ def main(prepare,sourceRoot,projectRoot,repositoryRoot=None):
27
+ parser = argparse.ArgumentParser()
28
+ parser.add_argument('--product', type=Path, required=True)
29
+ parser.add_argument('--node-modules', type=Path, required=True)
30
+ parser.add_argument('--report-dir', type=Path, required=True)
31
+ parser.add_argument('--design-reference', type=Path, required=True)
32
+ parser.add_argument('--platform-lock-timeout', type=float, default=0, help='Deprecated no-op; no lock is acquired')
33
+ parser.add_argument('--timeout', type=int, default=240)
34
+ add_network_arguments(parser)
35
+ args = parser.parse_args()
36
+ work = args.report_dir.resolve()
37
+ if repositoryRoot is None: parser.error('Explicit repositoryRoot required')
38
+ repository = Path(repositoryRoot).resolve()
39
+ if not Path(projectRoot).resolve().is_relative_to(repository): parser.error('Project must belong to repositoryRoot')
40
+ if work == repository or repository in work.parents:
41
+ parser.error('Reports must be outside source repository')
42
+ if not active_namespace(work):
43
+ work.mkdir(parents=True, exist_ok=False)
44
+ entry(work, args)
45
+ if not prepare(args.product.resolve(), work):
46
+ return 2 # expected red: neither build nor browser launched
47
+ modules = args.node_modules.resolve()
48
+ cli = modules / '.bin/minifish2'
49
+ if not cli.is_file():
50
+ parser.error('Install the existing locked official harness/toolchain dependencies')
51
+ prepare_dependencies(modules, work / 'app/node_modules')
52
+ before = fingerprint(sourceRoot)
53
+ (work / 'test-manifest.json').write_text(json.dumps(before, indent=2))
54
+ children = []
55
+ master, slave = pty.openpty()
56
+ success = False
57
+ def interrupted(signum, frame):
58
+ raise InterruptedError(f'Signal {signum}')
59
+ for sig in (signal.SIGTERM, signal.SIGINT):
60
+ signal.signal(sig, interrupted)
61
+ try:
62
+ with (work / 'platform.log').open('wb') as log, (work / 'check.log').open('wb') as checklog:
63
+ dev = subprocess.Popen(dev_command(cli, work), cwd=work / 'app', stdin=slave,
64
+ stdout=slave, stderr=slave, start_new_session=True)
65
+ children.append(dev)
66
+ os.close(slave)
67
+ slave = None
68
+ buffer, sent_web, checker = '', False, None
69
+ deadline = time.monotonic() + args.timeout
70
+ while time.monotonic() < deadline:
71
+ if select.select([master], [], [], .2)[0]:
72
+ try:
73
+ data = os.read(master, 65536)
74
+ except OSError:
75
+ data = b''
76
+ log.write(data); log.flush()
77
+ buffer = (buffer + data.decode('utf8', errors='replace'))[-200000:]
78
+ plain = re.sub(r'\x1b\[[0-9;]*[A-Za-z]', '', buffer)
79
+ if not sent_web and 'minifish>' in plain:
80
+ os.write(master, b'web\n'); sent_web = True
81
+ if 'error[Parse]:' in plain or '编译错误' in plain or 'Could not resolve import' in plain:
82
+ raise RuntimeError('Official host compilation failed; see platform.log')
83
+ match = re.search(r"simulator: '([^']+)'", plain)
84
+ if match and checker is None:
85
+ (work / 'runtime.json').write_text(json.dumps({'url': match.group(1), 'devPid': dev.pid, 'at': time.time(), 'network': json.loads(os.environ['MINIFISH_RUN_NETWORK'])}, indent=2))
86
+ env = dict(os.environ, MINIFISH_COMPONENT_URL=match.group(1),
87
+ MINIFISH_COMPONENT_REPORT_DIR=str(work / 'results'),
88
+ MINIFISH_DESIGN_REFERENCE_HTML=str(args.design_reference.resolve()))
89
+ checker = subprocess.Popen([str(projectRoot / 'node_modules/.bin/playwright'), 'test',
90
+ '--config', str(sourceRoot / 'playwright.config.mjs')],
91
+ cwd=projectRoot, env=env, stdout=checklog, stderr=checklog,
92
+ start_new_session=True)
93
+ children.append(checker)
94
+ if checker and checker.poll() is not None:
95
+ print((work / 'check.log').read_text(), flush=True)
96
+ assert before == fingerprint(sourceRoot), 'Test source changed during execution'
97
+ if checker.returncode:
98
+ raise RuntimeError(f'Component tests failed: {checker.returncode}')
99
+ success = True
100
+ break
101
+ if dev.poll() is not None:
102
+ drain_pty(master, log)
103
+ raise RuntimeError(f'Official dev exited: {dev.returncode}')
104
+ else:
105
+ raise TimeoutError('Official host build/check exceeded budget')
106
+ finally:
107
+ for sig in (signal.SIGTERM, signal.SIGINT):
108
+ signal.signal(sig, signal.SIG_IGN)
109
+ for child in reversed(children):
110
+ try: os.killpg(child.pid, signal.SIGTERM)
111
+ except ProcessLookupError: pass
112
+ for child in reversed(children):
113
+ try: child.wait(timeout=5)
114
+ except subprocess.TimeoutExpired: pass
115
+ try: os.killpg(child.pid, signal.SIGKILL)
116
+ except ProcessLookupError: pass
117
+ child.wait()
118
+ with (work / 'platform.log').open('ab') as final_log:
119
+ drain_pty(master, final_log)
120
+ os.close(master)
121
+ if slave is not None: os.close(slave)
122
+ (work / 'lifecycle.json').write_text(json.dumps({'success': success,
123
+ 'children': [{'pid': c.pid, 'returncode': c.returncode} for c in children],
124
+ 'cleanup': 'managed process groups terminated and reaped'}, indent=2))
125
+ return 0
@@ -0,0 +1,32 @@
1
+ import assert from 'node:assert/strict';
2
+ export function validateContractValue(value, schema, types = {}, location = '$', {request=false,permittedFields=null} = {}) {
3
+ if(request && schema.nullable === true && value === undefined)return;
4
+ assert.notEqual(value, undefined, `${location}: field must be present`);
5
+ if (value === null) { assert.equal(schema.nullable, true, `${location}: null forbidden`); return; }
6
+ if (schema.type === 'ref') return validateContractValue(value, types[schema.ref], types, location,{request});
7
+ if (schema.type === 'discriminatedUnion') {
8
+ const selected = schema.cases[value?.[schema.discriminator.field]];
9
+ assert.ok(selected, `${location}: unknown or unreachable stage`);
10
+ const fields=request?Object.assign({},...Object.values(schema.cases).map(branch=>branch.fields)):null;
11
+ return validateContractValue(value, selected, types, location,{request,permittedFields:fields});
12
+ }
13
+ if (schema.type === 'object') {
14
+ assert.ok(typeof value === 'object' && !Array.isArray(value), `${location}: object required`);
15
+ if(request)assert.ok(Object.keys(value).every(key=>Object.hasOwn(permittedFields??schema.fields,key)), `${location}: extra request fields`);
16
+ if(!request)assert.deepEqual(Object.keys(value).sort(), Object.keys(schema.fields).sort(), `${location}: missing/extra fields`);
17
+ for (const [key, field] of Object.entries(schema.fields)) validateContractValue(value[key], field, types, `${location}.${key}`,{request});
18
+ if(request&&permittedFields)for(const key of Object.keys(value)){
19
+ if(Object.hasOwn(permittedFields,key)&&!Object.hasOwn(schema.fields,key)&&value[key]!==null)validateContractValue(value[key],permittedFields[key],types,`${location}.${key}`,{request});
20
+ }
21
+ } else if (schema.type === 'array') {
22
+ assert.ok(Array.isArray(value), `${location}: array required`);
23
+ value.forEach((item, index) => validateContractValue(item, schema.items, types, `${location}[${index}]`,{request}));
24
+ } else if (schema.type === 'enum') {
25
+ assert.ok(schema.values.includes(value), `${location}: enum mismatch`);
26
+ } else if (schema.type === 'decimal') {
27
+ assert.ok(typeof value === 'string' && /^-?\d+(?:\.\d{1,2})?$/.test(value), `${location}: exact yuan string required`);
28
+ } else {
29
+ assert.equal(typeof value, schema.type, `${location}: ${schema.type} required`);
30
+ if (schema.type === 'string' && schema.maxUtf8Bytes !== undefined) assert.ok(Buffer.byteLength(value, 'utf8') <= schema.maxUtf8Bytes, `${location}: UTF8 capacity exceeded`);
31
+ }
32
+ }
@@ -0,0 +1,21 @@
1
+ import {AsyncLocalStorage} from 'node:async_hooks';
2
+ const context=new AsyncLocalStorage();
3
+ const allowed=new Set(['NEXT-P7-CONNECTOR','NEXT-PROCESSING-SURFACE','NEXT-INIT-PRECISION','CORE-WIDGET-LENGTH','CORE-WIDGET-BACKGROUND','CORE-P7-MISSING-DECORATION','BOUNDARY-NUMERIC']);
4
+ export function withVisualFailures(failures,action){return context.run(failures,action);}
5
+ export async function deferredVisual(id,action){
6
+ try{return await action();}catch(error){
7
+ const failures=context.getStore();
8
+ const matcher=['NEXT-PROCESSING-SURFACE','CORE-WIDGET-BACKGROUND','CORE-P7-MISSING-DECORATION'].includes(id)?'toBe':'toBeLessThanOrEqual';
9
+ if(!allowed.has(id)||!failures||error.matcherResult?.name!==matcher)throw error;
10
+ failures.push({id,message:error.message,stack:error.stack});
11
+ return undefined;
12
+ }
13
+ }
14
+ export function visualErrors(failures){return failures.map(x=>new Error(x.id+': '+x.message));}
15
+
16
+ const boundaryContext=new AsyncLocalStorage();
17
+ export function withBoundaryVisual(enabled,action){return boundaryContext.run(enabled,action);}
18
+ export async function boundaryNumeric(action){
19
+ if(!boundaryContext.getStore())return action();
20
+ return deferredVisual('BOUNDARY-NUMERIC',action);
21
+ }
@@ -0,0 +1,26 @@
1
+ import {expect} from '@playwright/test';
2
+ // Use the official device editor in a separate preparation page. No business request leaves it.
3
+ export async function prepareIPhone16ProDevice(page,url){
4
+ const boot=await page.context().newPage();
5
+ await boot.route('**/*',async route=>{
6
+ const req=route.request(),u=new URL(req.url());
7
+ if(u.origin===url.origin&&['GET','HEAD'].includes(req.method())&&!/delegate|mobilegw/.test(u.pathname))return route.continue();
8
+ return route.abort();
9
+ });
10
+ try{
11
+ await boot.goto(url.href);
12
+ const shell=boot.frameLocator('iframe[name="lyra-integration-web-sdk"]');
13
+ await shell.getByText('iPhone 13',{exact:true}).click();
14
+ await shell.getByText('新增设备',{exact:true}).click();
15
+ await shell.locator('#device').fill('iPhone16Pro402x874');
16
+ await shell.locator('#width').fill('402');await shell.locator('#height').fill('874');await shell.locator('#dpr').fill('3');
17
+ await shell.getByRole('button',{name:'确 认',exact:true}).click();
18
+ await expect(shell.getByText('iPhone16Pro402x874',{exact:true}).first()).toBeVisible();
19
+ const cancel=shell.getByRole('button',{name:'取 消',exact:true});
20
+ await expect(cancel).toBeHidden();
21
+ await shell.getByText('iPhone16Pro402x874',{exact:true}).first().click();
22
+ await shell.getByText('关闭皮肤',{exact:true}).click();
23
+ await shell.getByText('自适应',{exact:true}).click();
24
+ await shell.getByRole('option',{name:'100%',exact:true}).click();
25
+ }finally{await boot.close();}
26
+ }
@@ -0,0 +1,37 @@
1
+ import {hostDeviceFixture} from './host-device-fixture.mjs';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import {createHash,randomUUID} from 'node:crypto';
5
+ import assert from 'node:assert/strict';
6
+ export const hash=value=>createHash('sha256').update(value).digest('hex');
7
+ export function executionRole(plan,role='behavior'){
8
+ assert(['behavior','held-artifact'].includes(role),'Unknown execution role');
9
+ if(role==='held-artifact'){
10
+ assert(plan.beforeTarget?.some(a=>a.kind==='press'),'Held artifact requires original press');
11
+ assert(plan.afterTarget?.length&&plan.afterTarget.every(a=>a.kind==='release'&&a.boundaryId),'Held artifact requires declared release boundaries');
12
+ assert(!plan.completion?.length,'Artifact cannot skip independent completion actions');
13
+ }
14
+ return role;
15
+ }
16
+ export function capturePolicy(plan,role,mode){
17
+ executionRole(plan,role);assert(['none','failed','all'].includes(mode),'Invalid screenshot mode');
18
+ if(role==='held-artifact')assert.equal(mode,'all','Artifact requires all screenshots');
19
+ const held=plan.beforeTarget?.some(a=>a.kind==='press');
20
+ return {target:mode!=='none'&&(!held||role==='held-artifact'),preflight:role==='held-artifact',independentRequired:held&&role==='behavior'&&mode!=='none'};
21
+ }
22
+ export function needsFailureCapture(policy,mode,result){
23
+ return mode!=='none'&&(result.errors.length>0||!result.screenshots.length&&!policy.independentRequired);
24
+ }
25
+ export function executionEvidence(plan,role){
26
+ const declaredRoot=process.env.MINIFISH_CASE_PACKAGE_ROOT;assert(declaredRoot,'Explicit independent case root required');
27
+ const packageRoot=fs.realpathSync(declaredRoot);
28
+ const walk=directory=>fs.readdirSync(directory,{withFileTypes:true}).flatMap(item=>item.isDirectory()?walk(path.join(directory,item.name)):[path.relative(packageRoot,path.join(directory,item.name))]);
29
+ const files=['case.spec.mjs','playwright.config.mjs','package.json','package-lock.json','tool-input.json',...walk(path.join(packageRoot,'runner'))].sort();
30
+ const toolInput=JSON.parse(fs.readFileSync(path.join(packageRoot,'tool-input.json'),'utf8'));
31
+ const packageFiles=Object.fromEntries(files.map(f=>[f,hash(fs.readFileSync(path.join(packageRoot,f)))]));
32
+ const manifest=process.env.MINIFISH_SOURCE_MANIFEST;assert(manifest,'Frozen source manifest required for execution identity');
33
+ const source=JSON.parse(fs.readFileSync(manifest,'utf8')),product=JSON.parse(fs.readFileSync(path.join(path.dirname(manifest),'product-source-manifest.json'),'utf8'));
34
+ const sorted=o=>JSON.stringify(Object.fromEntries(Object.entries(o).sort(([a],[b])=>a.localeCompare(b))));
35
+ const productIdentity=JSON.parse(process.env.MINIFISH_PRODUCT_IDENTITY??'null');assert(productIdentity?.sha256,'Actual product identity required');
36
+ return {toolIdentity:toolInput.toolIdentity,adapterFiles:toolInput.files.filter(f=>f.kind==='adapter'),hostDeviceFixture,productIdentity,executionId:randomUUID(),executionRole:role,startedAt:new Date().toISOString(),fingerprints:{hostDeviceFixtureSha256:hash(JSON.stringify(hostDeviceFixture)),caseId:plan.case.id,planSha256:hash(JSON.stringify(plan)),sourceSha256:hash(sorted(source.testFiles)),productSha256:hash(sorted(product)),packageSha256:hash(sorted(packageFiles)),dependencyLockSha256:packageFiles['package-lock.json']},packageFiles};
37
+ }
@@ -0,0 +1,20 @@
1
+ // Observe only public Mini Program SDK application lifecycle callbacks. Browser
2
+ // visibility, arbitrary onShow, page navigation and private methods are not proof.
3
+ export function assertForegroundCycle(proof){
4
+ if(!proof?.sdkAvailable||!proof.events?.some(e=>e.type==='appHide')||!proof.events?.some((e,i)=>e.type==='appShow'&&proof.events.slice(0,i).some(p=>p.type==='appHide')))throw Error('Real host foreground cycle unobservable: require public appHide followed by appShow');
5
+ if(proof.pageNavigation)throw Error('Page navigation is not a foreground cycle');
6
+ }
7
+ export async function installForegroundObservation(page){
8
+ return page.evaluate(()=>{
9
+ function find(w){try{if(typeof w.my?.onAppHide==='function'&&typeof w.my?.onAppShow==='function')return w.my;for(let i=0;i<w.frames.length;i++){const api=find(w.frames[i]);if(api)return api;}}catch{}return null;}
10
+ const events=[];const api=find(window);window.__billForegroundProof={sdkAvailable:!!api,events,pageNavigation:false};
11
+ if(api){api.onAppHide(()=>events.push({type:'appHide',time:Date.now()}));api.onAppShow(()=>events.push({type:'appShow',time:Date.now()}));}
12
+ return {sdkAvailable:!!api};
13
+ });
14
+ }
15
+ export async function browserTabCycle(page){
16
+ const other=await page.context().newPage();try{await other.goto('data:text/html,<title>Independent browser foreground probe</title>');await other.bringToFront();}finally{await other.close();await page.bringToFront();}
17
+ // Only an actual SDK callback pair may certify this operation as app lifecycle.
18
+ await page.evaluate(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))));
19
+ return page.evaluate(()=>({...window.__billForegroundProof,browserVisibility:document.visibilityState}));
20
+ }
@@ -0,0 +1,47 @@
1
+ import {currentPageSelector,activeShellSelector} from './browser.mjs';
2
+ import assert from 'node:assert/strict';
3
+ import {observeRole,assertRole} from './carriers.mjs';
4
+
5
+ // Locator.boundingBox is in top-level browser CSS pixels, including iframe offsets
6
+ // and simulator scaling. Never compare getBoundingClientRect from different frames.
7
+ export async function observeHeaderLayout({page,institution,nativeTitle,pageFrame,nativeFrame}) {
8
+ const read=async locator=>{
9
+ const count=await locator.count();
10
+ if(count!==1)return {count,box:null};
11
+ return {count,box:await locator.boundingBox(),visible:await locator.isVisible(),
12
+ paint:await locator.evaluate(e=>{
13
+ let opacity=1;for(let p=e;p;p=p.parentElement)opacity*=Number(getComputedStyle(p).opacity);
14
+ const s=getComputedStyle(e);
15
+ return {text:e.textContent.trim(),opacity,color:s.color,fontSize:parseFloat(s.fontSize),visibility:s.visibility};
16
+ })};
17
+ };
18
+ return {coordinateSpace:'top-level-browser-css-pixels',institution:await read(institution),
19
+ nativeTitle:await read(nativeTitle),pageFrame:await read(pageFrame),nativeFrame:await read(nativeFrame),
20
+ viewport:page.viewportSize(),
21
+ institutionCarriers:await observeRole([{layer:'institution',locator:institution,boundaries:[pageFrame,nativeFrame]}])};
22
+ }
23
+ export function assertHeaderReadable(evidence,{institutionText,nativeTitleText}) {
24
+ assert.equal(evidence.coordinateSpace,'top-level-browser-css-pixels','header observation coordinate space missing');
25
+ for(const name of ['institution','nativeTitle','pageFrame','nativeFrame']){
26
+ const item=evidence[name];
27
+ assert(item?.count===1&&item.visible&&item.box,`header observation missing/ambiguous/hidden: ${name}`);
28
+ assert(['x','y','width','height'].every(k=>Number.isFinite(item.box[k]))&&item.box.width>0&&item.box.height>0,`header geometry missing: ${name}`);
29
+ assert(item.paint?.opacity>=0.99&&item.paint.visibility==='visible',`header not painted: ${name}`);
30
+ }
31
+ const a=evidence.institution,b=evidence.nativeTitle;
32
+ assert.equal(a.paint.text,institutionText,'institution text unreadable');
33
+ assert.equal(b.paint.text,nativeTitleText,'native title observation mismatch');
34
+ assert(a.paint.fontSize>=12&&!/rgba\([^)]*,\s*0\)$/.test(a.paint.color),'institution text not painted/readable');
35
+ const contains=(outer,inner)=>inner.x>=outer.x-0.5&&inner.y>=outer.y-0.5&&inner.x+inner.width<=outer.x+outer.width+0.5&&inner.y+inner.height<=outer.y+outer.height+0.5;
36
+ assert(evidence.viewport,'header viewport observation missing');
37
+ for(const outer of [evidence.pageFrame.box,evidence.nativeFrame.box,{x:0,y:0,...evidence.viewport}])
38
+ assert(contains(outer,a.box),'institution clipped outside visible frame/viewport');
39
+ const overlap={width:Math.min(a.box.x+a.box.width,b.box.x+b.box.width)-Math.max(a.box.x,b.box.x),height:Math.min(a.box.y+a.box.height,b.box.y+b.box.height)-Math.max(a.box.y,b.box.y)};
40
+ assert(!(overlap.width>0&&overlap.height>0),`native navigation overlaps institution in global coordinates: ${JSON.stringify(overlap)}`);
41
+ assertRole(evidence.institutionCarriers,{role:'institution',text:institutionText});
42
+ return {overlap};
43
+ }
44
+ export const officialHeaderLocators=(page,{institution,pageRoute})=>({page,institution,
45
+ nativeTitle:page.frameLocator('iframe[name="lyra-integration-web-sdk"]').locator(activeShellSelector+' .title .container'),
46
+ nativeFrame:page.locator('iframe[name="lyra-integration-web-sdk"]'),
47
+ pageFrame:page.frameLocator('iframe[name="lyra-integration-web-sdk"]').locator(currentPageSelector(pageRoute))});
@@ -0,0 +1,67 @@
1
+ import assert from 'node:assert/strict';
2
+ import {sampleNativeMetrics,sampleNativeTextVisibility,sampleNativeStyle,sampleNativePressed,sampleHeldNativeState} from './native-dom-sampling.mjs';
3
+ import {nativeMetricsFrameIdentity} from './independent-visual.mjs';
4
+ import {readNativeSemanticIdentity,matchesNativeSemanticIdentity,validateSemanticInstance} from './semantic-anchor.mjs';
5
+ import {validateNativeTextVisibility} from './semantic-observation.mjs';
6
+ export const heldSnapshotVersion='native-held-snapshot-v1';
7
+ export const officialTapLimitMs=350;
8
+ export function heldSnapshotTargetKey(target,instance){
9
+ validateSemanticInstance(instance);
10
+ return JSON.stringify([target,instance===undefined?{unspecified:true}:instance]);
11
+ }
12
+ export function freezeHeldSnapshot(snapshot){for(const value of Object.values(snapshot))if(value&&typeof value==='object')freezeHeldSnapshot(value);return Object.freeze(snapshot);}
13
+ export async function prepareHeldNativeInput(root,spec,locator){
14
+ spec.frameId=await nativeMetricsFrameIdentity(root.locator(':root'));
15
+ await locator.evaluate(el=>{
16
+ const nodes=[el,...el.querySelectorAll('*')];
17
+ if(nodes.some(n=>!['DIV','SPAN','A'].includes(n.tagName)||getComputedStyle(n).display==='contents'))throw Error('Unsupported held native carrier topology before input');
18
+ });
19
+ await installHeldInputTimeline(root);
20
+ }
21
+ export async function installHeldInputTimeline(root){
22
+ await root.locator(':root').evaluate(el=>{
23
+ const win=el.ownerDocument.defaultView,key='__seeleHeldTimelineV1';if(win[key])throw Error('Duplicate held input timeline');
24
+ const events=[];win[key]={events};
25
+ for(const type of ['mousedown','mouseup','touchstart','touchend','touchcancel','pointercancel','scroll'])el.ownerDocument.addEventListener(type,event=>{events.push({type,time:performance.timeOrigin+performance.now(),trusted:event.isTrusted,x:event.clientX,y:event.clientY,scrollX,scrollY});},{capture:true,passive:true});
26
+ });
27
+ }
28
+ export async function captureHeldSnapshot(root,spec){
29
+ const body=`async (root,spec)=>{
30
+ const metrics=(${sampleNativeMetrics.toString()}),paint=(${sampleNativeTextVisibility.toString()}),style=(${sampleNativeStyle.toString()}),pressed=(${sampleNativePressed.toString()}),state=(${sampleHeldNativeState.toString()}),identity=(${readNativeSemanticIdentity.toString()}),matches=(${matchesNativeSemanticIdentity.toString()});
31
+ const timeline=root.ownerDocument.defaultView.__seeleHeldTimelineV1;if(!timeline)throw Error('Held timeline must be installed before pressing');
32
+ 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');
33
+ 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);})}));
34
+ if(groups.every(g=>g.nodes.length)||now()-start.time>=350)break;
35
+ await new Promise(resolve=>{const timer=setTimeout(resolve,Math.max(0,350-(now()-start.time)));requestAnimationFrame(()=>{clearTimeout(timer);resolve();});});
36
+ }
37
+ 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;
38
+ }`;
39
+ return freezeHeldSnapshot(await root.locator(':root').evaluate(Function('return ('+body+')')(),spec));
40
+ }
41
+ export async function readReleasedTimeline(root){return root.locator(':root').evaluate(el=>el.ownerDocument.defaultView.__seeleHeldTimelineV1?.events??[]);}
42
+ export function validateHeldInputTiming(snapshot,events){
43
+ const down=events.find(e=>e.type==='touchstart')??events.find(e=>e.type==='mousedown');const up=events.find(e=>e.type==='touchend')??events.find(e=>e.type==='mouseup');
44
+ assert(down&&up,'Native down/up events required');assert(events.some(e=>e.type==='mousedown'&&e.trusted)&&events.some(e=>e.type==='mouseup'&&e.trusted),'Original real native mouse input required');
45
+ const duration=up.time-down.time;assert(duration>=0&&duration<officialTapLimitMs,'Public input invalid: official 350ms tap eligibility exceeded');
46
+ assert(snapshot&&!snapshot.timedOut&&snapshot.pressAt===down.time&&snapshot.snapshotAt>=down.time&&snapshot.snapshotAt<=up.time,'Only actual held-time snapshot allowed');
47
+ 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};
48
+ }
49
+ export function validateFrozenHeldAssertion(snapshot,a,validators){
50
+ const group=snapshot.groups.find(g=>g.key===heldSnapshotTargetKey(a.target,a.instance));assert(group,'Original semantic snapshot required');
51
+ validators.validateState({count:group.count,visible:group.members.map(m=>m.state.visible)},a);
52
+ const observations=[];
53
+ 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.
54
+
55
+ if(a.enabled)assert.equal(member.state.enabled,true,'Original native enabled obligation');validators.validateObservation(member.metrics,a);
56
+ if(a.textFits)validateNativeTextVisibility(member.textPaint,{selfOverflow:'contain'});observations.push({...member.metrics,...(a.textFits?{textPaint:member.textPaint}:{})});
57
+ }
58
+ return {count:group.count,visible:group.members.map(m=>m.state.visible),observations,snapshotVersion:snapshot.version,snapshotAt:snapshot.snapshotAt};
59
+ }
60
+ // The release happens in finally before any Node validator or boundary wait.
61
+ export async function executeHeldSnapshot({root,spec,release,validators,capture=captureHeldSnapshot,readEvents=readReleasedTimeline}){
62
+ let snapshot,error,releaseReceipt;try{snapshot=await capture(root,spec);}catch(e){error=e;}finally{releaseReceipt=await release(spec.release);}
63
+ const events=await readEvents(root);let timing;
64
+ 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:{}};}
65
+ 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});}}
66
+ return {snapshot,releaseReceipt,timing,assertions,observations};
67
+ }
@@ -0,0 +1,28 @@
1
+ import assert from 'node:assert/strict';
2
+ export const hostDeviceFixture=Object.freeze({version:'official-host-device-v1',appxUtdid:'minifish-test-host-device-fixed',purpose:'simulator-device-telemetry'});
3
+ // Serialized into the official rendererExtend beforeMount hook. It only fills
4
+ // a missing device identifier; user identity/query/Mock and error callbacks stay intact.
5
+ export function configureHostDeviceMount(params,fixture){
6
+ if(!params||typeof params!=='object'||Array.isArray(params))throw Error('Official mount parameters required');
7
+ const launch=params.launchParams;
8
+ if(launch!==undefined&&launch!==null&&(typeof launch!=='object'||Array.isArray(launch)))throw Error('Official launchParams must be an object');
9
+ const value=launch?.appxUtdid;
10
+ if(value!==undefined&&value!==null&&typeof value!=='string')throw Error('Explicit appxUtdid must be a string');
11
+ const explicit=typeof value==='string'&&value.trim().length>0;
12
+ return {...params,launchParams:{...launch,appxUtdid:explicit?value:fixture.appxUtdid}};
13
+ }
14
+ export function hostDeviceMountEvidence(params,configured,fixture){
15
+ return {version:fixture.version,purpose:fixture.purpose,appxUtdid:configured.launchParams.appxUtdid,configurationSource:typeof params.launchParams?.appxUtdid==='string'&&params.launchParams.appxUtdid.trim()?'explicit-launchParams':'default-fixture',launchParamKeys:Object.keys(configured.launchParams).sort()};
16
+ }
17
+ export async function installHostDeviceFixtureEvidence(page){
18
+ const records=[];
19
+ await page.exposeBinding('__billHostDeviceFixture',(_source,event)=>{
20
+ assert.equal(event.version,hostDeviceFixture.version);assert.equal(event.purpose,hostDeviceFixture.purpose);
21
+ assert(typeof event.appxUtdid==='string'&&event.appxUtdid.trim(),'Actual nonempty host device fixture');
22
+ assert(['explicit-launchParams','default-fixture'].includes(event.configurationSource));
23
+ if(event.configurationSource==='default-fixture')assert.equal(event.appxUtdid,hostDeviceFixture.appxUtdid);
24
+ assert(Array.isArray(event.launchParamKeys)&&event.launchParamKeys.includes('appxUtdid'));
25
+ records.push(structuredClone(event));
26
+ });
27
+ return {evidence:()=>({fixture:hostDeviceFixture,mounts:structuredClone(records)})};
28
+ }
@@ -0,0 +1,73 @@
1
+ import assert from 'node:assert/strict';
2
+
3
+ // Mock only the documented public API success result. The simulator still
4
+ // supplies all device/layout fields; no product state or module is accessed.
5
+ export function observeSystemLanguage({language}) {
6
+ const proxies=new WeakMap(),owner=globalThis.top??globalThis;
7
+ let delivered=0;
8
+ const event=value=>owner.__billHostLanguage(value);
9
+ const params=options=>Object.fromEntries(Object.entries(options??{}).filter(([key])=>!['success','fail','complete'].includes(key)));
10
+ const navigation=(target,original,args,optionsIndex,successIndex)=>{
11
+ const options=args[optionsIndex]??{};event({stage:'request',kind:'navigation',params:params(options),afterSystemResponse:delivered});
12
+ const report=()=>event({stage:'success',kind:'navigation'});
13
+ if(successIndex!==undefined){const success=args[successIndex];args[successIndex]=function(...values){report();return success?.apply(this,values);};}
14
+ else {const success=options.success;args[optionsIndex]={...options,success:function(...values){report();return success?.apply(this,values);}};}
15
+ return original.apply(target,args);
16
+ };
17
+ const wrap=api=>{
18
+ if(!api||typeof api!=='object')return api;
19
+ if(proxies.has(api))return proxies.get(api);
20
+ const mock=(result,name)=>{
21
+ const mocked={...result};
22
+ if(language===null)delete mocked.language;else mocked.language=language;
23
+ delivered++;event({kind:'system-response',api:name,original:result,mocked});
24
+ return mocked;
25
+ };
26
+ const proxy=new Proxy(api,{get(target,key){
27
+ const original=Reflect.get(target,key);
28
+ if(key==='call'&&typeof original==='function')return function(name,options,success,...rest){
29
+ if(name==='setNavigationBar')return navigation(target,original,[name,options,success,...rest],1,2);
30
+ if(name!=='getSystemInfo')return original.call(target,name,options,success,...rest);
31
+ event({kind:'system-request',api:'my.call(getSystemInfo)',params:params(options)});
32
+ return original.call(target,name,options,function(result){
33
+ const mocked=mock(result,'my.call(getSystemInfo)');
34
+ if(typeof success==='function')return success.call(this,mocked);
35
+ },...rest);
36
+ };
37
+ if(key==='setNavigationBar'&&typeof original==='function')return function(options={}){return navigation(target,original,[options],0);};
38
+ if(key!=='getSystemInfo'||typeof original!=='function')return original;
39
+ return function(options={}){
40
+ event({kind:'system-request',api:'my.getSystemInfo',params:params(options)});
41
+ const success=options.success;
42
+ return original.call(target,{...options,success:function(result){
43
+ const mocked=mock(result,'my.getSystemInfo');
44
+ if(typeof success==='function')return success.call(this,mocked);
45
+ }});
46
+ };
47
+ }});
48
+ proxies.set(api,proxy);return proxy;
49
+ };
50
+ const descriptor=Object.getOwnPropertyDescriptor(globalThis,'my');
51
+ if(descriptor&&!descriptor.configurable)throw Error('Public my API cannot be observed');
52
+ let value=descriptor?.value;
53
+ Object.defineProperty(globalThis,'my',{configurable:true,enumerable:true,
54
+ get(){return wrap(descriptor?.get?descriptor.get.call(this):value);},
55
+ set(next){if(descriptor?.set)descriptor.set.call(this,next);else value=next;}});
56
+ }
57
+ export async function installHostLanguage(page,language='zh-CN'){
58
+ assert(language===null||typeof language==='string','Host language must be a string or missing (null)');
59
+ const observations=[],systemRequests=[],navigation=[];
60
+ await page.exposeBinding('__billHostLanguage',(_source,event)=>{if(event.kind==='system-response')observations.push(event);else if(event.kind==='system-request')systemRequests.push(event);else if(event.kind==='navigation')navigation.push(event);else throw Error('Unknown public host observation');});
61
+ await page.addInitScript(observeSystemLanguage,{language});
62
+ return {
63
+ evidence(){return {boundary:'my.getSystemInfo.success.language',language,observations:[...observations],systemRequests:[...systemRequests],navigation:[...navigation]};},
64
+ assertObserved(){assert(observations.length>0,'Product did not consume the public host system language API');},
65
+ assertSystemPlan(plan){
66
+ assert.equal(systemRequests.length,plan.times,'Exact final public system request count');assert.equal(observations.length,plan.times,'Exact final public system callback count');
67
+ for(const row of systemRequests)assert.deepEqual(row.params,plan.request.params,'Complete public system request');
68
+ for(const row of observations){const {language:originalLanguage,...original}=row.original;const {language:mockedLanguage,...mocked}=row.mocked;assert.deepEqual(mocked,original,'Actual device fields preserved');assert.equal(mockedLanguage,plan.outcome.value.language);}
69
+ const requests=navigation.filter(r=>r.stage==='request');assert.equal(requests.length,1,'Exact final navigation request count');assert.equal(requests[0].afterSystemResponse,plan.times,'Delivered public language response consumed before navigation');
70
+ assert.equal(navigation.filter(r=>r.stage==='success').length,1,'Exact actual navigation success count');
71
+ },
72
+ };
73
+ }