@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.
- package/package.json +15 -4
- package/tools/adapters/islands/trigger_source.py +53 -0
- package/tools/cli/backend.mjs +24 -0
- package/tools/cli/main.py +38 -0
- package/tools/cli/minifish.mjs +38 -0
- package/tools/cli/minifish.py +52 -0
- package/tools/core/package-identity.mjs +23 -0
- package/tools/core/package_identity.py +58 -0
- package/tools/core/semantic_identity.py +149 -0
- package/tools/core/test_semantic_identity.py +136 -0
- package/tools/docs/README.md +43 -0
- package/tools/docs/backend-testing.md +36 -0
- package/tools/docs/iteration.md +34 -0
- package/tools/docs/minifish-testing.md +32 -0
- package/tools/docs/semantic-identity.md +61 -0
- package/tools/modules/backend/actions.mjs +10 -0
- package/tools/modules/backend/assert.mjs +20 -0
- package/tools/modules/backend/context-receiver.py +30 -0
- package/tools/modules/backend/facility.test.mjs +44 -0
- package/tools/modules/backend/grouped_context.py +100 -0
- package/tools/modules/backend/grpc.mjs +57 -0
- package/tools/modules/backend/index.mjs +9 -0
- package/tools/modules/backend/input-tree.mjs +14 -0
- package/tools/modules/backend/logs.mjs +24 -0
- package/tools/modules/backend/logs.test.mjs +28 -0
- package/tools/modules/backend/metadata.mjs +66 -0
- package/tools/modules/backend/package-lock.json +362 -0
- package/tools/modules/backend/package.json +15 -0
- package/tools/modules/backend/paths.mjs +11 -0
- package/tools/modules/backend/product.mjs +48 -0
- package/tools/modules/backend/rejection-logs.mjs +33 -0
- package/tools/modules/backend/report.mjs +13 -0
- package/tools/modules/backend/report.test.mjs +13 -0
- package/tools/modules/backend/run.mjs +50 -0
- package/tools/modules/backend/util.mjs +24 -0
- package/tools/modules/minifish/checks/guard.test.mjs +13 -0
- package/tools/modules/minifish/checks/package.test.mjs +23 -0
- package/tools/modules/minifish/compiler/build-case.mjs +39 -0
- package/tools/modules/minifish/compiler/case-plan.mjs +175 -0
- package/tools/modules/minifish/compiler/config.mjs +29 -0
- package/tools/modules/minifish/compiler/observation-module-name.mjs +2 -0
- package/tools/modules/minifish/compiler/package-runner.mjs +43 -0
- package/tools/modules/minifish/compiler/semantic-module-gate.mjs +27 -0
- package/tools/modules/minifish/compiler/tool-identity.mjs +14 -0
- package/tools/modules/minifish/harness/batch_cases.py +91 -0
- package/tools/modules/minifish/harness/batch_plugin_groups.py +56 -0
- package/tools/modules/minifish/harness/batch_report.py +104 -0
- package/tools/modules/minifish/harness/browser.mjs +146 -0
- package/tools/modules/minifish/harness/build-plugin-artifact.mjs +25 -0
- package/tools/modules/minifish/harness/capture_contract.py +78 -0
- package/tools/modules/minifish/harness/carousel-observation.mjs +62 -0
- package/tools/modules/minifish/harness/carriers.mjs +49 -0
- package/tools/modules/minifish/harness/check-independent-batch.py +84 -0
- package/tools/modules/minifish/harness/check-runner-isolation.py +102 -0
- package/tools/modules/minifish/harness/component_runtime.py +125 -0
- package/tools/modules/minifish/harness/contract-value.mjs +32 -0
- package/tools/modules/minifish/harness/deferred-visual.mjs +21 -0
- package/tools/modules/minifish/harness/device-prepare.mjs +26 -0
- package/tools/modules/minifish/harness/execution-evidence.mjs +37 -0
- package/tools/modules/minifish/harness/foreground.mjs +20 -0
- package/tools/modules/minifish/harness/header-layout.mjs +47 -0
- package/tools/modules/minifish/harness/held-snapshot.mjs +67 -0
- package/tools/modules/minifish/harness/host-device-fixture.mjs +28 -0
- package/tools/modules/minifish/harness/host-language.mjs +73 -0
- package/tools/modules/minifish/harness/host-native-ui.mjs +39 -0
- package/tools/modules/minifish/harness/host-navigation.mjs +96 -0
- package/tools/modules/minifish/harness/image-loading.mjs +35 -0
- package/tools/modules/minifish/harness/independent-case-runner.mjs +175 -0
- package/tools/modules/minifish/harness/independent-visual.mjs +107 -0
- package/tools/modules/minifish/harness/inline-svg.mjs +32 -0
- package/tools/modules/minifish/harness/input-lifecycle-evidence.mjs +16 -0
- package/tools/modules/minifish/harness/jsapi.mjs +92 -0
- package/tools/modules/minifish/harness/loading-input.mjs +52 -0
- package/tools/modules/minifish/harness/loading-observation.mjs +25 -0
- package/tools/modules/minifish/harness/local-navigation-evidence.mjs +82 -0
- package/tools/modules/minifish/harness/miniapp-screenshot.mjs +38 -0
- package/tools/modules/minifish/harness/native-dom-sampling.mjs +40 -0
- package/tools/modules/minifish/harness/native-enabled.mjs +11 -0
- package/tools/modules/minifish/harness/native-rpc-failure.mjs +11 -0
- package/tools/modules/minifish/harness/native-state.mjs +14 -0
- package/tools/modules/minifish/harness/native-ui-contract.mjs +29 -0
- package/tools/modules/minifish/harness/navigation.mjs +19 -0
- package/tools/modules/minifish/harness/network_isolation.py +134 -0
- package/tools/modules/minifish/harness/painted-source-image.mjs +54 -0
- package/tools/modules/minifish/harness/playwright.mjs +1 -0
- package/tools/modules/minifish/harness/plugin-events.mjs +16 -0
- package/tools/modules/minifish/harness/plugin-package.mjs +23 -0
- package/tools/modules/minifish/harness/plugin_artifact.py +23 -0
- package/tools/modules/minifish/harness/product_identity.py +8 -0
- package/tools/modules/minifish/harness/project_config.py +14 -0
- package/tools/modules/minifish/harness/prototype-visual.mjs +123 -0
- package/tools/modules/minifish/harness/region-order.mjs +16 -0
- package/tools/modules/minifish/harness/renderer-image-evidence.mjs +32 -0
- package/tools/modules/minifish/harness/request-guard.mjs +65 -0
- package/tools/modules/minifish/harness/request-plan.mjs +124 -0
- package/tools/modules/minifish/harness/response-consumption.mjs +106 -0
- package/tools/modules/minifish/harness/retained-page-lifecycle.mjs +48 -0
- package/tools/modules/minifish/harness/rpc-wire.mjs +34 -0
- package/tools/modules/minifish/harness/run-batch.py +133 -0
- package/tools/modules/minifish/harness/run-platform.py +293 -0
- package/tools/modules/minifish/harness/runtime_isolation.py +71 -0
- package/tools/modules/minifish/harness/scroll-container-observation.mjs +40 -0
- package/tools/modules/minifish/harness/semantic-anchor.mjs +139 -0
- package/tools/modules/minifish/harness/semantic-observation.mjs +197 -0
- package/tools/modules/minifish/harness/skeleton-appearance.mjs +49 -0
- package/tools/modules/minifish/harness/target-actions.mjs +120 -0
- package/tools/modules/minifish/harness/test_batch_concurrency.py +125 -0
- package/tools/modules/minifish/harness/test_batch_plugin_groups.py +97 -0
- package/tools/modules/minifish/harness/test_batch_runner.py +107 -0
- package/tools/modules/minifish/harness/test_common_report.py +47 -0
- package/tools/modules/minifish/harness/test_held_capture_contract.py +50 -0
- package/tools/modules/minifish/harness/test_pty_tail.py +39 -0
- package/tools/modules/minifish/harness/test_runtime_dependencies.py +49 -0
- package/tools/modules/minifish/harness/zero-business-plan.mjs +19 -0
- package/tools/modules/minifish/identity.py +6 -0
- package/tools/modules/minifish/package-lock.json +74 -0
- package/tools/modules/minifish/package.json +10 -0
- package/tools/modules/minifish/sdk/package-lock.json +57745 -0
- package/tools/modules/minifish/sdk/package.json +13 -0
- package/tools/modules/workflow/diff_review.py +74 -0
- package/tools/modules/workflow/iteration.py +175 -0
- 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
|
+
}
|