@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,133 @@
1
+ #!/usr/bin/env python3
2
+ """Bounded parallel official runtimes; each reuses startup for independent cases."""
3
+ import argparse
4
+ from concurrent.futures import ThreadPoolExecutor
5
+ import os
6
+ from pathlib import Path
7
+ import signal
8
+ import subprocess
9
+ import sys
10
+ import threading
11
+ import time
12
+ from product_identity import product_identity
13
+ from batch_cases import read_cases, save, read_result, worker_certified, product_fingerprint
14
+ from network_isolation import add_network_arguments
15
+ from batch_report import render_report
16
+ from batch_plugin_groups import plugin_groups
17
+
18
+
19
+ def main():
20
+ p = argparse.ArgumentParser()
21
+ p.add_argument('--project-root', type=Path, required=True)
22
+ p.add_argument('--config', type=Path, required=True)
23
+ p.add_argument('--manifest', type=Path, required=True, help='JSON array of exact id/package pairs')
24
+ p.add_argument('--product', type=Path, required=True)
25
+ p.add_argument('--node-modules', type=Path, required=True)
26
+ p.add_argument('--report-dir', type=Path, required=True)
27
+ p.add_argument('--workers', type=int, default=8, help='Requested concurrency (1..16, default: 8); non-8 requires --workers-reason')
28
+ p.add_argument('--workers-reason', help='Evidence for reducing concurrency or purpose of a higher-concurrency resource experiment')
29
+ p.add_argument('--screenshots', choices=['none', 'failed', 'all'], default='none')
30
+ p.add_argument('--case-timeout', type=int, default=90)
31
+ p.add_argument('--startup-budget', type=int, default=180)
32
+ p.add_argument('--plugin-artifacts', type=Path)
33
+ p.add_argument('--plugin-artifact-map', type=Path, help='JSON case-id to exact preflighted artifact directory; partitions one unified batch by plugin identity')
34
+ p.add_argument('--design-reference', type=Path)
35
+ add_network_arguments(p)
36
+ a = p.parse_args()
37
+ if not 1 <= a.workers <= 16 or a.case_timeout < 1 or a.startup_budget < 1:
38
+ p.error('workers must be 1..16 and time budgets positive')
39
+ reason = (a.workers_reason or '').strip()
40
+ if a.workers != 8 and not reason:
41
+ p.error('Non-8 workers require a nonempty --workers-reason before worker startup')
42
+ rows = read_cases(a.manifest)
43
+ if a.screenshots != 'none':
44
+ for row in rows:
45
+ runner = Path(row['package']) / 'runner/tools/harness/independent-case-runner.mjs'
46
+ if not runner.is_file() or 'export const screenshotPolicyVersion = 1;' not in runner.read_text():
47
+ p.error('Screenshot policy requires rebuilding case packages with the current compiler/runner')
48
+ here = Path(__file__).resolve().parent
49
+ report = a.report_dir.resolve()
50
+ from project_config import load_project
51
+ config = load_project(a.project_root, a.config)
52
+ repo = (a.project_root / config['repositoryRoot']).resolve()
53
+ if report == repo or repo in report.parents:
54
+ p.error('Reports must be outside the source repository')
55
+ if not a.product.is_dir() or not (a.node_modules / '.bin/minifish2').is_file():
56
+ p.error('Real product and installed official toolchain required')
57
+ partitions = plugin_groups(rows, a.workers, a.plugin_artifact_map, a.plugin_artifacts, source=a.project_root / config['pluginSource'])
58
+ report.mkdir(parents=True, exist_ok=False)
59
+ product_inputs = product_fingerprint(a.product.resolve())
60
+ save(report / 'product-input.json', product_inputs)
61
+ count = min(a.workers, len(rows))
62
+ shards = [group for group, artifact in partitions]
63
+ stopped = threading.Event()
64
+ active, mutex = {}, threading.Lock()
65
+ started = time.time()
66
+ concurrency_policy = {'defaultWorkers': 8, 'requestedWorkers': a.workers,
67
+ 'actualConcurrency': min(count, len(shards)),
68
+ 'workersReason': reason or None,
69
+ 'shardSizes': [len(group) for group in shards]}
70
+ save(report / 'batch-input.json', {'cases': rows, 'workers': count,
71
+ 'concurrencyPolicy': concurrency_policy, 'startedAt': started})
72
+ def interrupt(signum, frame):
73
+ stopped.set()
74
+ with mutex:
75
+ for process in active.values():
76
+ if process.poll() is None:
77
+ process.send_signal(signal.SIGTERM)
78
+ for sig in (signal.SIGINT, signal.SIGTERM):
79
+ signal.signal(sig, interrupt)
80
+ def worker(index):
81
+ group = shards[index]
82
+ plugin_artifact = partitions[index][1]
83
+ manifest = report / f'worker-{index + 1:02d}.json'
84
+ out = report / f'worker-{index + 1:02d}'
85
+ save(manifest, group)
86
+ command = [sys.executable, str(here / 'run-platform.py'), '--project-root', str(a.project_root.resolve()), '--config', str(a.config.resolve()), '--product', str(a.product.resolve()), '--product-manifest', str(report / 'product-input.json'), '--node-modules', str(a.node_modules.resolve()), '--independent-case-list', str(manifest), '--report-dir', str(out), '--timeout', str(a.startup_budget + len(group) * (a.case_timeout + 5)), '--case-timeout', str(a.case_timeout)]
87
+ for flag, value in [('slirp4netns', a.slirp4netns), ('slirp-library-path', a.slirp_library_path), ('plugin-artifacts', Path(plugin_artifact) if plugin_artifact else None), ('design-reference', a.design_reference)]:
88
+ if value: command.extend(['--'+flag, str(value.resolve())])
89
+ info = {'worker': index + 1, 'startedAt': time.time(), 'command': command}
90
+ try:
91
+ with (report / f'worker-{index + 1:02d}.log').open('wb') as log:
92
+ with mutex:
93
+ if stopped.is_set(): return dict(info, exitCode=None, error='interrupted before start')
94
+ process = subprocess.Popen(command, stdout=log, stderr=log, start_new_session=True, env=dict(os.environ, MINIFISH_SCREENSHOTS=a.screenshots, MINIFISH_FINAL_SCREENSHOTS='1' if a.screenshots == 'all' else '0', MINIFISH_FINAL_DELIVERY_SCREENSHOTS='1' if a.screenshots == 'all' else '0'))
95
+ active[index] = process
96
+ info['exitCode'] = process.wait()
97
+ except OSError as error:
98
+ info.update(exitCode=None, error=str(error))
99
+ finally:
100
+ with mutex: active.pop(index, None)
101
+ info['finishedAt'] = time.time()
102
+ return info
103
+ with ThreadPoolExecutor(max_workers=count) as pool:
104
+ workers = list(pool.map(worker, range(len(shards))))
105
+ product_unchanged = product_fingerprint(a.product.resolve()) == product_inputs
106
+ results = []
107
+ import json
108
+ for index, group in enumerate(shards):
109
+ out = report / f'worker-{index + 1:02d}'
110
+ received = read_result(out / 'batch-results.json', list)
111
+ lifecycle = read_result(out / 'lifecycle.json', dict)
112
+ network = read_result(out / 'network-lifecycle.json', dict)
113
+ certified = product_unchanged and worker_certified(received, lifecycle, network, [r['id'] for r in group], workers[index].get('exitCode'))
114
+ workers[index]['certified'] = certified
115
+ by_id = {item.get('id'): item for item in (received or []) if isinstance(item, dict)}
116
+ for row in group:
117
+ item = by_id.get(row['id'], dict(row, status='not-run'))
118
+ item['worker'] = index + 1
119
+ item['workerExitCode'] = workers[index].get('exitCode')
120
+ if item.get('status') in ('passed','artifact-only') and not certified:
121
+ item.update(status='invalidated', error='worker inputs, result identities or cleanup were not certified')
122
+ results.append(item)
123
+ summary = {'startedAt': started, 'finishedAt': time.time(), 'workers': workers, 'concurrency': concurrency_policy['actualConcurrency'], 'concurrencyPolicy': concurrency_policy, 'cases': results, 'counts': {s: sum(x['status'] == s for x in results) for s in ['passed', 'failed', 'not-run', 'interrupted', 'invalidated', 'artifact-only']}}
124
+ summary['currentProduct'] = product_identity(product_inputs)
125
+ summary['screenshots'] = a.screenshots
126
+ save(report / 'batch-summary.json', summary)
127
+ render_report(summary, report)
128
+ print(json.dumps({'report': str(report / 'batch-summary.json'), 'seconds': summary['finishedAt']-started, 'counts': summary['counts']}, ensure_ascii=False))
129
+ return int(stopped.is_set() or any(x['status'] not in ('passed','artifact-only') for x in results) or any(w.get('exitCode') != 0 for w in workers))
130
+
131
+
132
+ if __name__ == '__main__':
133
+ raise SystemExit(main())
@@ -0,0 +1,293 @@
1
+ #!/usr/bin/env python3
2
+ """Run the independent official-SDK fixture; never substitutes for product E2E."""
3
+ import argparse
4
+ from product_identity import product_identity
5
+ from plugin_artifact import validate_plugin_artifact
6
+ from batch_cases import read_cases, fingerprint, product_fingerprint
7
+ from network_isolation import add_network_arguments, active_namespace, entry
8
+ from runtime_isolation import prepare_dependencies, dev_command, drain_pty
9
+ import os
10
+ from pathlib import Path
11
+ import pty
12
+ import re
13
+ import select
14
+ import shutil
15
+ import signal
16
+ import subprocess
17
+ import tempfile
18
+ import time
19
+ import json
20
+ import hashlib
21
+ import threading
22
+ from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
23
+
24
+ parser = argparse.ArgumentParser()
25
+ parser.add_argument('--project-root', type=Path, required=True)
26
+ parser.add_argument('--config', type=Path, required=True)
27
+ parser.add_argument('--node-modules', type=Path, required=True, help='Explicit installed locked official SDK node_modules')
28
+ parser.add_argument('--report-dir', type=Path, help='New directory outside repository for logs and compiled fixture')
29
+ parser.add_argument('--check', choices=['common-input','common-plugin','expanded-common','plugin-host','foreground','platform','current-native','transport','images','header-layout','navigation','host-language','host-navigation','isolation'], default='platform', help='Independent facility check to run')
30
+ parser.add_argument('--design-reference', type=Path, help='Independent Prototype reference.html built outside repository')
31
+ parser.add_argument('--plugin-artifacts', type=Path, help='Officially compiled controlled external plugin assets; serves locally without replacing product')
32
+ parser.add_argument('--product', type=Path, help='Run an unchanged product copy with current page tests')
33
+ parser.add_argument('--suite', choices=['product'], help='Current business product fixture domain')
34
+ parser.add_argument('--timeout', type=int, default=240, help='Whole build/check budget in seconds; use 7200 for a full product suite')
35
+ parser.add_argument('--product-manifest', type=Path, help='Expected product input hashes for a batch')
36
+ parser.add_argument('--independent-case-list', type=Path, help='Exact JSON id/package list, one reused runtime and fresh browser process per case')
37
+ parser.add_argument('--case-timeout', type=int, default=90)
38
+ parser.add_argument('--independent-case', type=Path, help='Generated standalone case package; no legacy suite discovery')
39
+ parser.add_argument('--platform-lock-timeout', type=float, default=0, help='Deprecated compatibility no-op; runs are isolated and never locked')
40
+ parser.add_argument('--isolation-marker', help='Independent isolation fixture marker')
41
+ parser.add_argument('--isolation-control-dir', type=Path, help='Independent facility controller signals')
42
+ parser.add_argument('--isolation-mode', choices=['hold','fault'], default='hold')
43
+ parser.add_argument('--dev-verbose', action='store_true', help='Official CLI full streaming diagnostics')
44
+ add_network_arguments(parser)
45
+ args = parser.parse_args()
46
+ batch = None
47
+ if args.independent_case_list:
48
+ if not args.product or args.independent_case:
49
+ parser.error('independent-case-list requires product and excludes other case selectors')
50
+ args.independent_case_list = args.independent_case_list.resolve()
51
+ batch = read_cases(args.independent_case_list)
52
+ if args.case_timeout < 1:
53
+ parser.error('case-timeout must be positive')
54
+ if args.independent_case:
55
+ args.independent_case = args.independent_case.resolve()
56
+ if not args.product:
57
+ parser.error('independent-case requires product')
58
+ for name in ('case.spec.mjs', 'playwright.config.mjs', 'node_modules/.bin/playwright'):
59
+ if not (args.independent_case / name).is_file():
60
+ parser.error('Independent case package missing: ' + name)
61
+ if args.check == 'isolation':
62
+ if args.product or not args.isolation_marker or not re.fullmatch(r'[A-Z0-9-]+', args.isolation_marker) or not args.isolation_control_dir:
63
+ parser.error('isolation requires a marker/control directory and no product')
64
+ if not args.isolation_control_dir.is_dir():
65
+ parser.error('isolation control directory must exist')
66
+ if args.product and not args.independent_case and not batch:
67
+ parser.error('Product execution requires --independent-case or --independent-case-list; use harness/run-batch.py')
68
+ here = Path(__file__).resolve().parent
69
+ from project_config import load_project
70
+ config = load_project(args.project_root, args.config)
71
+ if args.product:
72
+ args.suite = 'product'
73
+ elif args.suite:
74
+ parser.error('--suite requires --product')
75
+
76
+ modules = args.node_modules.resolve()
77
+ cli = modules / '.bin/minifish2'
78
+ if not cli.is_file():
79
+ parser.error('Official minifish2 executable not found')
80
+ here = Path(__file__).resolve().parent
81
+ if args.report_dir:
82
+ work = args.report_dir.resolve()
83
+ repository = (args.project_root / config['repositoryRoot']).resolve()
84
+ if work == repository or repository in work.parents:
85
+ parser.error('Reports must be outside source repository')
86
+ if not active_namespace(work):
87
+ work.mkdir(parents=True, exist_ok=False)
88
+ else:
89
+ work = Path(tempfile.mkdtemp(prefix='minifish-platform-check-'))
90
+ entry(work, args)
91
+ if args.product:
92
+ product_inputs = product_fingerprint(args.product.resolve())
93
+ if args.product_manifest and product_inputs != json.loads(args.product_manifest.read_text()):
94
+ raise RuntimeError('Product differs from batch snapshot before copy')
95
+ shutil.copytree(args.product.resolve(), work / 'app', ignore=shutil.ignore_patterns('node_modules','.git','dist-*','.mini-ide'))
96
+ if product_fingerprint(work / 'app') != product_inputs:
97
+ raise RuntimeError('Product changed during runtime copy')
98
+ (work / 'product-source-manifest.json').write_text(json.dumps(product_inputs, indent=2))
99
+ else:
100
+ shutil.copytree(args.project_root / config['facilityFixtures'][args.check], work / 'app')
101
+ if args.check == 'isolation':
102
+ fixture = work / 'app/src/pages/index/index.ts'
103
+ fixture.write_text(fixture.read_text().replace("'00123'", repr(args.isolation_marker)))
104
+ if args.check == 'header-layout':
105
+ if args.product:
106
+ parser.error('header-layout facility check requires the independent platform fixture')
107
+ config_path = work / 'app/src/app.json'
108
+ config = json.loads(config_path.read_text())
109
+ config['window'] = {'defaultTitle': '缴费账单', 'transparentTitle': 'always'}
110
+ config_path.write_text(json.dumps(config, ensure_ascii=False))
111
+ component_archives = {}
112
+ if args.product:
113
+ dependencies = json.loads((args.product.resolve() / 'package.json').read_text()).get('dependencies', {})
114
+ for name in config['localComponentPackages']:
115
+ dependency = dependencies.get(name)
116
+ if not dependency or not dependency.startswith('file:'):
117
+ parser.error('Explicit local component archive required: ' + name)
118
+ archive = (args.product.resolve() / dependency[5:]).resolve()
119
+ if not archive.is_file(): parser.error('Declared product component archive does not exist')
120
+ component_archives[name] = archive
121
+ prepare_dependencies(modules, work / 'app/node_modules', component_archives)
122
+ def test_source_fingerprint():
123
+ roots = {'project': args.project_root.resolve(), 'tool': here.parent}
124
+ return {kind + '/' + str(p.relative_to(base)): hashlib.sha256(p.read_bytes()).hexdigest()
125
+ for kind, base in roots.items() for p in sorted(base.rglob('*')) if p.is_file()
126
+ and not any(part in ('node_modules', '.git', '__pycache__') for part in p.relative_to(base).parts)}
127
+ source_fingerprint = test_source_fingerprint()
128
+ (work / 'source-manifest.json').write_text(json.dumps({'testFiles': source_fingerprint}, indent=2))
129
+ independent_fingerprint = None
130
+ if args.independent_case:
131
+ independent_fingerprint = {name: hashlib.sha256((args.independent_case / name).read_bytes()).hexdigest()
132
+ for name in ['case.spec.mjs', 'playwright.config.mjs', 'package.json', 'package-lock.json',
133
+ *[str(file.relative_to(args.independent_case)) for file in sorted((args.independent_case / 'runner').rglob('*')) if file.is_file()]]}
134
+ (work / 'independent-case-manifest.json').write_text(json.dumps(independent_fingerprint, indent=2))
135
+ batch_fingerprint = fingerprint(batch) if batch else None
136
+ if batch:
137
+ (work / 'batch-source-manifest.json').write_text(json.dumps(batch_fingerprint, indent=2))
138
+ plugin_server = None
139
+ plugin_reads = []
140
+ plugin_package_url = None
141
+ if args.plugin_artifacts:
142
+ if not args.product and args.check not in ('plugin-host','common-plugin'):
143
+ parser.error('--plugin-artifacts requires product or independent plugin-host check')
144
+ plugin_root = args.plugin_artifacts.resolve()
145
+ compiled_plugin_manifest = validate_plugin_artifact(plugin_root, args.project_root / config['pluginSource'])
146
+ (work / 'compiled-plugin-manifest.json').write_text(json.dumps(compiled_plugin_manifest, indent=2))
147
+ for name in ('appConfig.json', 'index.worker.js', 'index.js'):
148
+ if not (plugin_root / name).is_file():
149
+ parser.error('Official compiled plugin asset missing: ' + name)
150
+ class PluginResources(SimpleHTTPRequestHandler):
151
+ def __init__(self, *values, **options):
152
+ super().__init__(*values, directory=str(plugin_root), **options)
153
+ def end_headers(self):
154
+ self.send_header('Access-Control-Allow-Origin', '*')
155
+ super().end_headers()
156
+ def log_message(self, pattern, *values):
157
+ plugin_reads.append({'path': self.path, 'message': pattern % values})
158
+ plugin_server = ThreadingHTTPServer(('127.0.0.1', 0), PluginResources)
159
+ threading.Thread(target=plugin_server.serve_forever, daemon=True).start()
160
+ plugin_package_url = f'http://127.0.0.1:{plugin_server.server_port}/'
161
+ (work / 'plugin-artifacts.json').write_text(json.dumps({'source': str(plugin_root), 'sha256': {name: hashlib.sha256((plugin_root / name).read_bytes()).hexdigest() for name in ('appConfig.json','index.worker.js','index.js')}}, indent=2))
162
+ print(f'Facility logs: {work}', flush=True)
163
+ master, slave = pty.openpty()
164
+ children = []
165
+ succeeded = False
166
+ inputs_verified = False
167
+
168
+ def interrupted(signum, frame):
169
+ raise InterruptedError(f'interrupted by signal {signum}')
170
+
171
+ for sig in (signal.SIGTERM, signal.SIGINT):
172
+ signal.signal(sig, interrupted)
173
+
174
+ try:
175
+ dev_argv = dev_command(cli, work)
176
+ if args.dev_verbose:
177
+ dev_argv.append('--verbose')
178
+ dev = subprocess.Popen(dev_argv, cwd=work / 'app', stdin=slave,
179
+ stdout=slave, stderr=slave, start_new_session=True)
180
+ children.append(dev)
181
+ (work / 'dev-launch.json').write_text(json.dumps({
182
+ 'argv': dev_argv, 'pid': dev.pid, 'pgid': os.getpgid(dev.pid), 'at': time.time(),
183
+ 'cwd': str(work / 'app'),
184
+ 'environment': {key: os.environ[key] for key in ('NODE_OPTIONS', 'MINIFISH_ISOLATION_MARKER', 'MINIFISH_ISOLATION_CONTROL_DIR') if key in os.environ}
185
+ }, indent=2))
186
+ os.close(slave)
187
+ slave = None
188
+ buffer = ''
189
+ sent_web = False
190
+ checker = None
191
+ deadline = time.monotonic() + args.timeout
192
+ with (work / 'platform.log').open('wb') as log, (work / 'check.log').open('wb') as check_log:
193
+ while time.monotonic() < deadline:
194
+ if select.select([master], [], [], 0.2)[0]:
195
+ try:
196
+ data = os.read(master, 65536)
197
+ except OSError:
198
+ data = b''
199
+ if data:
200
+ log.write(data)
201
+ log.flush()
202
+ buffer = (buffer + data.decode('utf8', errors='replace'))[-200000:]
203
+ plain = re.sub(r'\x1b\[[0-9;]*[A-Za-z]', '', buffer)
204
+ if not sent_web and 'minifish>' in plain:
205
+ os.write(master, b'web\n')
206
+ sent_web = True
207
+ if 'error[Parse]:' in plain or '编译错误' in plain or 'Could not resolve import' in plain:
208
+ raise RuntimeError('Official fixture compilation failed; see platform.log')
209
+ match = re.search(r"simulator: '([^']+)'", plain)
210
+ if match and checker is None:
211
+ env = dict(os.environ, MINIFISH_FIXTURE_URL=match.group(1), MINIFISH_FIXTURE_REPORT_DIR=str(work))
212
+ (work / 'runtime.json').write_text(json.dumps({'url': match.group(1), 'devPid': dev.pid, 'at': time.time(), 'cacheDir': str(work / 'build-cache'), 'network': json.loads(os.environ['MINIFISH_RUN_NETWORK'])}, indent=2))
213
+ if args.check == 'isolation':
214
+ env.update(MINIFISH_ISOLATION_MARKER=args.isolation_marker, MINIFISH_ISOLATION_CONTROL_DIR=str(args.isolation_control_dir.resolve()), MINIFISH_ISOLATION_MODE=args.isolation_mode)
215
+ if plugin_package_url:
216
+ env['MINIFISH_PLUGIN_PACKAGE_URL'] = plugin_package_url
217
+ env['MINIFISH_PLUGIN_ARTIFACT_MANIFEST'] = str(plugin_root / 'plugin-artifact.json')
218
+ command = [] if args.product else ['node', str(args.project_root / config['facilityChecks'][args.check])]
219
+ if args.product:
220
+ if plugin_package_url:
221
+ env['MINIFISH_PLUGIN_PACKAGE_URL'] = plugin_package_url
222
+ env['MINIFISH_PLUGIN_ARTIFACT_MANIFEST'] = str(plugin_root / 'plugin-artifact.json')
223
+ if args.design_reference:
224
+ env['MINIFISH_DESIGN_REFERENCE_HTML'] = str(args.design_reference.resolve())
225
+ env['MINIFISH_PRODUCT_IDENTITY'] = json.dumps(product_identity(product_inputs))
226
+ env.update(MINIFISH_SUITE=args.suite, MINIFISH_SIMULATOR_URL=match.group(1), MINIFISH_PAGE_ROUTE=config['pageRoute'], MINIFISH_TEST_REPORT_DIR=str(work / 'results'), MINIFISH_SOURCE_MANIFEST=str(work / 'source-manifest.json'))
227
+ check_cwd = here.parent
228
+ if args.independent_case:
229
+ env['MINIFISH_CASE_PACKAGE_ROOT'] = str(args.independent_case)
230
+ command = [str(args.independent_case / 'node_modules/.bin/playwright'), 'test', '--config', 'playwright.config.mjs']
231
+ check_cwd = args.independent_case
232
+ if batch:
233
+ command = ['python3', str(here / 'check-independent-batch.py'), '--manifest', str(args.independent_case_list), '--report-dir', str(work), '--case-timeout', str(args.case_timeout)]
234
+ checker = subprocess.Popen(command, cwd=check_cwd,
235
+ env=env, stdout=check_log, stderr=check_log,
236
+ start_new_session=True)
237
+ children.append(checker)
238
+ if checker and checker.poll() is not None:
239
+ check_log.flush()
240
+ print((work / 'check.log').read_text(), flush=True)
241
+ if test_source_fingerprint() != source_fingerprint:
242
+ raise RuntimeError('Test source changed during execution; this run cannot certify the current source')
243
+ if independent_fingerprint and any(hashlib.sha256((args.independent_case / name).read_bytes()).hexdigest() != digest
244
+ for name, digest in independent_fingerprint.items()):
245
+ raise RuntimeError('Independent case changed during execution')
246
+ if batch and fingerprint(batch) != batch_fingerprint:
247
+ raise RuntimeError('Batch case source changed during execution')
248
+ if args.product and product_fingerprint(args.product.resolve()) != product_inputs:
249
+ raise RuntimeError('Product source changed during execution')
250
+ inputs_verified = True
251
+ if checker.returncode:
252
+ raise RuntimeError(f'Platform checks failed: {checker.returncode}')
253
+ succeeded = True
254
+ break
255
+ if dev.poll() is not None:
256
+ detected = time.time()
257
+ tail_bytes = drain_pty(master, log)
258
+ (work / 'dev-exit.json').write_text(json.dumps({'pid': dev.pid, 'returncode': dev.returncode, 'detectedAt': detected, 'tailDrainedAt': time.time(), 'tailBytes': tail_bytes}, indent=2))
259
+ raise RuntimeError(f'Official dev server exited: {dev.returncode}; see platform.log')
260
+ else:
261
+ raise TimeoutError(f'Platform build/check exceeded {args.timeout} seconds')
262
+ finally:
263
+ for sig in (signal.SIGTERM, signal.SIGINT):
264
+ signal.signal(sig, signal.SIG_IGN)
265
+ # Kill process groups even when their parent exited; grandchildren may still serve ports.
266
+ for child in reversed(children):
267
+ try:
268
+ os.killpg(child.pid, signal.SIGTERM)
269
+ except ProcessLookupError:
270
+ pass
271
+ for child in reversed(children):
272
+ try:
273
+ child.wait(timeout=5)
274
+ except subprocess.TimeoutExpired:
275
+ pass
276
+ try:
277
+ os.killpg(child.pid, signal.SIGKILL)
278
+ except ProcessLookupError:
279
+ pass
280
+ child.wait()
281
+ with (work / 'platform.log').open('ab') as final_log:
282
+ drain_pty(master, final_log)
283
+ (work / 'lifecycle.json').write_text(json.dumps({
284
+ 'success': succeeded, 'inputsVerified': inputs_verified, 'children': [{'pid': child.pid, 'returncode': child.returncode} for child in children],
285
+ 'cleanup': 'all managed process groups terminated and reaped'
286
+ }, indent=2))
287
+ if plugin_server:
288
+ plugin_server.shutdown()
289
+ plugin_server.server_close()
290
+ (work / 'plugin-resource-reads.json').write_text(json.dumps(plugin_reads, indent=2))
291
+ os.close(master)
292
+ if slave is not None:
293
+ os.close(slave)
@@ -0,0 +1,71 @@
1
+ """Per-run writable dependencies; installed tool packages are read-only inputs."""
2
+ import shutil
3
+ import tarfile
4
+
5
+
6
+ def prepare_dependencies(modules, destination, component_archives=None):
7
+ destination.mkdir()
8
+ for entry in modules.iterdir():
9
+ if entry.name == '.cache' or entry.name in (component_archives or {}):
10
+ continue
11
+ (destination / entry.name).symlink_to(entry.resolve(), target_is_directory=entry.is_dir())
12
+ (destination / '.cache').mkdir()
13
+ # Minifish's component resolver rejects realpaths outside projectRoot.
14
+ # Keep the locked official component package inside each worker; never
15
+ # write through the shared @alipay scope link or modify package bytes.
16
+ official = modules / '@alipay' / 'industry-mini'
17
+ if official.is_dir():
18
+ scope = destination / '@alipay'
19
+ scope.unlink()
20
+ scope.mkdir()
21
+ for package in (modules / '@alipay').iterdir():
22
+ target = scope / package.name
23
+ if package.name == 'industry-mini':
24
+ shutil.copytree(package.resolve(), target)
25
+ else:
26
+ target.symlink_to(package.resolve(), target_is_directory=package.is_dir())
27
+ for package_name, archive_path in (component_archives or {}).items():
28
+ generated = destination / package_name
29
+ with tarfile.open(archive_path) as archive:
30
+ archive.extractall(generated, filter='data')
31
+ nested = generated / 'package'
32
+ if nested.exists():
33
+ for entry in nested.iterdir():
34
+ shutil.move(str(entry), generated / entry.name)
35
+ nested.rmdir()
36
+
37
+
38
+ def dev_command(cli, work):
39
+ # Minifish 2.40.1 detectPort maps CLI port 0 to port 1, not an ephemeral port.
40
+ # Give its official allocator an OS-selected per-run starting port instead.
41
+ import socket
42
+ with socket.socket() as reservation:
43
+ reservation.bind(('127.0.0.1', 0))
44
+ port = reservation.getsockname()[1]
45
+ return [str(cli), 'dev', '--port', str(port), '--host', '127.0.0.1',
46
+ '--cacheDir', str(work / 'build-cache')]
47
+
48
+
49
+ def drain_pty(fd, log, budget=0.5):
50
+ """Bounded tail collection; PTY EIO means the final slave has closed."""
51
+ import errno
52
+ import os
53
+ import select
54
+ import time
55
+ end = time.monotonic() + budget
56
+ total = 0
57
+ while time.monotonic() < end:
58
+ if not select.select([fd], [], [], min(0.05, max(0, end-time.monotonic())))[0]:
59
+ continue
60
+ try:
61
+ data = os.read(fd, 65536)
62
+ except OSError as error:
63
+ if error.errno == errno.EIO:
64
+ break
65
+ raise
66
+ if not data:
67
+ break
68
+ log.write(data)
69
+ total += len(data)
70
+ log.flush()
71
+ return total
@@ -0,0 +1,40 @@
1
+ import assert from 'node:assert/strict';
2
+
3
+ // Read-only public layout evidence. No scroll assignment, events or Page data.
4
+ export async function observeScrollContainers(node){
5
+ return node.evaluate(target=>{
6
+ const doc=target.ownerDocument,win=doc.defaultView,rows=[];
7
+ for(let el=target;el;el=el.parentElement){
8
+ const s=win.getComputedStyle(el),r=el.getBoundingClientRect();
9
+ rows.push({depth:rows.length,anchor:el.getAttribute('data-anchor-id'),
10
+ documentScroller:el===doc.scrollingElement,scrollTop:el.scrollTop,scrollLeft:el.scrollLeft,
11
+ scrollHeight:el.scrollHeight,clientHeight:el.clientHeight,
12
+ overflowX:s.overflowX,overflowY:s.overflowY,
13
+ rect:{x:r.x,y:r.y,width:r.width,height:r.height},
14
+ visible:s.display!=='none'&&s.visibility==='visible'&&Number(s.opacity)>0});
15
+ }
16
+ return {url:win.location.href,window:{x:win.scrollX,y:win.scrollY},viewport:{width:win.innerWidth,height:win.innerHeight},rows};
17
+ });
18
+ }
19
+
20
+ export function validateScrollArrival(before,after,{minimumMovement=0,targetInViewport=true}={}){
21
+ assert(Number.isFinite(minimumMovement)&&minimumMovement>=0,'Finite nonnegative movement required');
22
+ assert.equal(after.url,before.url,'Scroll must preserve the public page identity');
23
+ assert(before.rows.length&&after.rows.length,'Real target and ancestry required');
24
+ assert.equal(after.rows.length,before.rows.length,'Scroll ancestry changed');
25
+ const moved=[];
26
+ for(const [i,row]of after.rows.entries()){
27
+ const old=before.rows[i];
28
+ assert.deepEqual([row.depth,row.anchor,row.documentScroller],[old.depth,old.anchor,old.documentScroller],'Scroll container identity changed');
29
+ for(const key of ['scrollTop','scrollHeight','clientHeight'])assert(Number.isFinite(row[key])&&Number.isFinite(old[key]),'Finite public scroll metrics required');
30
+ const scrollable=row.documentScroller||['auto','scroll','overlay'].includes(row.overflowY);
31
+ if(scrollable&&row.visible&&row.scrollHeight>row.clientHeight&&Math.abs(row.scrollTop-old.scrollTop)>minimumMovement)moved.push(i);
32
+ }
33
+ assert(moved.length,'Successful callback without actual scroll movement is not arrival');
34
+ const t=after.rows[0];assert(t.visible&&t.rect.width>0&&t.rect.height>0,'Target must actually paint');
35
+ if(targetInViewport){
36
+ const clips=[{x:0,y:0,...after.viewport},...after.rows.slice(1).filter(r=>['auto','scroll','hidden','clip','overlay'].includes(r.overflowY)).map(r=>r.rect)];
37
+ for(const r of clips)assert(t.rect.y+t.rect.height>r.y&&t.rect.y<r.y+r.height,'Target must intersect every public clipping container');
38
+ }
39
+ return {movedContainers:moved,target:t.rect};
40
+ }