@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,104 @@
|
|
|
1
|
+
"""Static, portable HTML from actual batch receipts; no inference of case success."""
|
|
2
|
+
import base64
|
|
3
|
+
import mimetypes
|
|
4
|
+
from html import escape
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from batch_cases import read_result
|
|
7
|
+
from capture_contract import associate_capture
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def render_report(summary, directory):
|
|
11
|
+
directory = Path(directory).resolve()
|
|
12
|
+
def link(file):
|
|
13
|
+
file = Path(file).resolve()
|
|
14
|
+
if not file.is_file() or directory not in file.parents:
|
|
15
|
+
return None
|
|
16
|
+
mime = mimetypes.guess_type(file.name)[0] or 'application/octet-stream'
|
|
17
|
+
return 'data:' + mime + ';base64,' + base64.b64encode(file.read_bytes()).decode('ascii')
|
|
18
|
+
sections = {}
|
|
19
|
+
current_product = summary.get('currentProduct')
|
|
20
|
+
def current(identity):
|
|
21
|
+
return bool(current_product and identity and identity.get('sha256') == current_product.get('sha256'))
|
|
22
|
+
for case in summary['cases']:
|
|
23
|
+
ident = str(case['id'])
|
|
24
|
+
group, _, title = ident.rpartition(' / ')
|
|
25
|
+
receipt_file = Path(case.get('receipt', directory / 'missing'))
|
|
26
|
+
receipt = read_result(receipt_file, dict) or {}
|
|
27
|
+
status = str(case.get('status', 'not-run'))
|
|
28
|
+
errors = [str(e) for e in receipt.get('errors', []) + receipt.get('artifactErrors', [])]
|
|
29
|
+
if case.get('error'): errors.insert(0, str(case['error']))
|
|
30
|
+
seconds = case.get('finishedAt', 0) - case.get('startedAt', 0)
|
|
31
|
+
phases = case.get('phases', {})
|
|
32
|
+
images, historical_images = [], []
|
|
33
|
+
evidence_product = receipt.get('productIdentity')
|
|
34
|
+
current_execution = current(evidence_product)
|
|
35
|
+
association_html = '<p><b>按住态展示截图(独立采集)</b> · 释放/跳转边界未执行,不计入通过数</p>' if receipt.get('executionRole') == 'held-artifact' else ''
|
|
36
|
+
shots = receipt.get('screenshots', [])
|
|
37
|
+
shot_root = receipt_file.parent
|
|
38
|
+
capture = case.get('captureEvidence')
|
|
39
|
+
if capture:
|
|
40
|
+
capture_file = Path(capture['receipt'])
|
|
41
|
+
capture_receipt = read_result(capture_file, dict) or {}
|
|
42
|
+
capture_product = capture_receipt.get('productIdentity')
|
|
43
|
+
different_product = bool(evidence_product and capture_product and evidence_product.get('sha256') != capture_product.get('sha256'))
|
|
44
|
+
association = associate_capture(receipt, capture_receipt, legacy=capture.get('legacy', False)) if not different_product else {'status':'historical-product','productIdentity':capture_product,'behaviorExecutionId':receipt.get('executionId'),'captureExecutionId':capture_receipt.get('executionId')}
|
|
45
|
+
evidence_product = capture_product
|
|
46
|
+
current_execution = current(capture_product)
|
|
47
|
+
shots = capture_receipt.get('screenshots', [])
|
|
48
|
+
shot_root = capture_file.parent
|
|
49
|
+
label = '按住态展示截图(独立采集)' if not capture.get('legacy') else '按住态展示截图(复用历史失败执行)'
|
|
50
|
+
association_html = f'<p><b>{label}</b> · 不计入正式case通过数</p><details><summary>双执行身份与冻结指纹</summary><pre>{escape(str(association))}</pre></details>'
|
|
51
|
+
if not shots and receipt.get('screenshot'): shots = [receipt['screenshot']]
|
|
52
|
+
for shot in shots:
|
|
53
|
+
if not isinstance(shot, dict) or not isinstance(shot.get('file'), str): continue
|
|
54
|
+
url = link(shot_root / shot['file'])
|
|
55
|
+
if url:
|
|
56
|
+
destination = images if current_execution else historical_images
|
|
57
|
+
destination.append(f'<figure><button class="preview" type="button" aria-label="放大查看截图"><img loading="lazy" src="{url}" alt="{escape(ident, quote=True)}"></button><figcaption>{escape(str(shot.get("phase", "")))} · 点击放大</figcaption></figure>')
|
|
58
|
+
product_label = '<p>证据实际产品身份:'+escape(str(evidence_product or receipt.get('fingerprints', {}).get('productSha256') or '未记录'))+'</p>'
|
|
59
|
+
history_html = '<details class="history"><summary>历史执行截图(非当前产品)</summary>'+product_label+'<div class="shots">'+''.join(historical_images)+'</div></details>' if historical_images else ''
|
|
60
|
+
report_link = link(receipt_file)
|
|
61
|
+
log_link = link(Path(case.get('report', directory / 'missing')) / 'check.log')
|
|
62
|
+
links = ' · '.join(f'<a href="{url}" download>{name}</a>' for name, url in [('结果 JSON', report_link), ('运行日志', log_link)] if url)
|
|
63
|
+
details = escape('\n'.join(errors))
|
|
64
|
+
card = f'''<article data-status="{escape(status, quote=True)}"><h3>{escape(title or ident)}</h3>
|
|
65
|
+
<p><b>{escape(status)}</b> · worker {case.get('worker', '?')} · {seconds:.2f}s</p>
|
|
66
|
+
<p class="path">{escape(ident)}</p><p>{escape(str(phases))}</p><p>{links}</p>
|
|
67
|
+
{('<pre>'+details+'</pre>') if details else ''}
|
|
68
|
+
<details><summary>工具版本、冻结测试/适配身份与自身断言</summary><pre>{escape(str({'toolIdentity':receipt.get('toolIdentity'), 'fingerprints':receipt.get('fingerprints'), 'adapterFiles':receipt.get('adapterFiles'), 'assertions':receipt.get('assertions')}))}</pre></details>{product_label}{association_html}<div class="shots current-product">{''.join(images) or '<p>当前产品最终图缺失(无截图);未沿用历史截图。</p>'}</div>{history_html}</article>'''
|
|
69
|
+
for suffix in (' / 状态测试', ' / 交互测试'):
|
|
70
|
+
if group.endswith(suffix): group = group[:-len(suffix)]
|
|
71
|
+
sections.setdefault(group, []).append(card)
|
|
72
|
+
body = ''.join(f'<section><h2>{escape(group)}</h2>{"".join(cards)}</section>' for group, cards in sections.items())
|
|
73
|
+
counts = escape(str(summary.get('counts', {})))
|
|
74
|
+
duration = summary['finishedAt'] - summary['startedAt']
|
|
75
|
+
concurrency = '<p>并发策略:'+escape(str(summary['concurrencyPolicy']))+'</p>' if 'concurrencyPolicy' in summary else ''
|
|
76
|
+
viewer_script = """
|
|
77
|
+
const zoom=document.getElementById('zoom');
|
|
78
|
+
zoom.addEventListener('input',()=>{document.documentElement.style.setProperty('--shot-width',(402*Number(zoom.value)/100)+'px');document.getElementById('zoom-value').textContent=zoom.value+'%';});
|
|
79
|
+
const viewer=document.getElementById('viewer'),stage=document.getElementById('viewer-stage');
|
|
80
|
+
let current=null;
|
|
81
|
+
function visibleShots(){return [...document.querySelectorAll('.preview img')].filter(img=>!img.closest('article').hidden&&(!img.closest('details.history')||img.closest('details.history').open));}
|
|
82
|
+
function showShot(img){current=img;stage.replaceChildren();const large=new Image();large.src=img.src;large.alt=img.alt;stage.append(large);document.getElementById('viewer-title').textContent=img.alt;document.getElementById('download').href=img.src;const shots=visibleShots(),i=shots.indexOf(img);document.getElementById('previous').disabled=i<=0;document.getElementById('next').disabled=i>=shots.length-1;}
|
|
83
|
+
document.querySelectorAll('.preview').forEach(b=>b.addEventListener('click',()=>{showShot(b.querySelector('img'));viewer.showModal();}));
|
|
84
|
+
document.getElementById('close-viewer').addEventListener('click',()=>viewer.close());
|
|
85
|
+
viewer.addEventListener('click',e=>{if(e.target===viewer){const r=viewer.getBoundingClientRect();if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)viewer.close();}});
|
|
86
|
+
function move(delta){const shots=visibleShots(),i=shots.indexOf(current)+delta;if(i>=0&&i<shots.length)showShot(shots[i]);}
|
|
87
|
+
document.getElementById('previous').addEventListener('click',()=>move(-1));document.getElementById('next').addEventListener('click',()=>move(1));
|
|
88
|
+
viewer.addEventListener('keydown',e=>{if(e.key==='ArrowLeft'){e.preventDefault();move(-1);}if(e.key==='ArrowRight'){e.preventDefault();move(1);}});
|
|
89
|
+
viewer.addEventListener('close',()=>{stage.replaceChildren();current=null;});
|
|
90
|
+
"""
|
|
91
|
+
html = f'''<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
92
|
+
<title>小程序批量测试报告</title><style>
|
|
93
|
+
body{{font:16px system-ui,sans-serif;background:#f3f5f8;color:#243042;max-width:1500px;margin:24px auto;padding:0 20px}}article{{background:white;border:1px solid #d8dfe8;border-radius:12px;padding:20px;margin:16px 0}}h2,.path{{overflow-wrap:anywhere}}.path{{color:#627084}}pre{{white-space:pre-wrap;overflow-wrap:anywhere;background:#fff0ef;padding:12px}}.shots{{display:flex;flex-wrap:wrap;gap:20px}}figure{{margin:0}}.shots img{{width:var(--shot-width,201px);max-width:100%;height:auto;border:1px solid #ddd}}figcaption{{color:#627084}}select,input{{font:inherit;padding:8px}}[hidden]{{display:none!important}}
|
|
94
|
+
.preview{{border:0;padding:0;background:none;cursor:zoom-in;display:block}}.toolbar{{position:sticky;top:0;background:#f3f5f8ee;padding:12px 0;z-index:1;display:flex;flex-wrap:wrap;align-items:center;gap:16px}}dialog{{width:min(1000px,90vw);max-height:92vh;border:0;border-radius:12px;padding:20px}}dialog::backdrop{{background:#000b}}.viewer-controls{{display:flex;gap:16px;align-items:center;position:sticky;top:0;background:white;padding:8px}}#viewer-title{{overflow-wrap:anywhere}}#viewer-stage{{text-align:center}}#viewer-stage img{{width:auto;max-width:100%;height:auto;max-height:72vh}}button{{font:inherit}}
|
|
95
|
+
|
|
96
|
+
body{{max-width:1800px}}section{{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,calc(var(--shot-width,201px) + 48px)),1fr));gap:16px;margin:24px 0;align-items:start}}section>h2{{grid-column:1/-1;margin:0;font-size:17px}}article{{margin:0;padding:14px;min-width:0;overflow-wrap:anywhere}}article h3{{font-size:16px;margin:0 0 8px}}article p{{font-size:12px;margin:7px 0}}article .path{{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}}.shots{{justify-content:center;gap:12px;margin-top:12px}}figcaption{{font-size:12px;margin-top:6px}}
|
|
97
|
+
</style><h1>小程序批量测试报告</h1><p>{counts} · 总耗时 {duration:.2f}s · 截图模式 {escape(str(summary.get('screenshots', 'none')))}</p>{concurrency}
|
|
98
|
+
<p>当前产品身份:{escape(str(current_product or '未指定'))}</p><p>结果直接来自运行收据;case 数不是 Islands 原始覆盖点数。</p>
|
|
99
|
+
<div class="toolbar"><label>图片大小 <input id="zoom" type="range" min="25" max="150" step="25" value="50"><output id="zoom-value">50%</output></label><label>结果 <select id="status"><option value="">全部</option>{''.join(f'<option>{s}</option>' for s in ['passed','failed','not-run','interrupted','invalidated','artifact-only'])}</select></label>
|
|
100
|
+
<label>目录或 case <input id="search" placeholder="输入名称筛选"></label></div>{body}
|
|
101
|
+
<dialog id="viewer" aria-labelledby="viewer-title"><div class="viewer-controls"><button id="previous" type="button">上一张</button><button id="next" type="button">下一张</button><a id="download" download="screenshot.png">下载原图</a><button id="close-viewer" type="button">关闭(Esc)</button></div><p id="viewer-title"></p><div id="viewer-stage"></div></dialog>
|
|
102
|
+
<script>function filter(){{let status=document.getElementById('status').value,q=document.getElementById('search').value.toLowerCase();document.querySelectorAll('article').forEach(a=>a.hidden=(status&&a.dataset.status!==status)||!a.textContent.toLowerCase().includes(q));document.querySelectorAll('section').forEach(s=>s.hidden=![...s.querySelectorAll('article')].some(a=>!a.hidden));}}document.getElementById('status').addEventListener('change',filter);document.getElementById('search').addEventListener('input',filter);{viewer_script}</script></html>'''
|
|
103
|
+
(directory / 'index.html').write_text(html)
|
|
104
|
+
return directory / 'index.html'
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import {installHostDeviceFixtureEvidence} from './host-device-fixture.mjs';
|
|
2
|
+
import {nativeUiResultPath} from './native-ui-contract.mjs';
|
|
3
|
+
import {observeLocalNavigationCandidate,localNavigationEvidence,delegateLocalNavigation,assertLocalNavigationComplete} from './local-navigation-evidence.mjs';
|
|
4
|
+
import {installLoadingObservation} from './loading-observation.mjs';
|
|
5
|
+
import {prepareIPhone16ProDevice} from './device-prepare.mjs';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import {pluginEventLedger} from './plugin-events.mjs';
|
|
8
|
+
import {pluginPackagePlan} from './plugin-package.mjs';
|
|
9
|
+
import {installHostLanguage} from './host-language.mjs';
|
|
10
|
+
import {installResponseConsumption} from './response-consumption.mjs';
|
|
11
|
+
import { fulfillRpc } from './rpc-wire.mjs';
|
|
12
|
+
import { bridgePath, jsapiPath, bridgeScript, fulfillJsapi, businessJsapiOption } from './jsapi.mjs';
|
|
13
|
+
|
|
14
|
+
// Official Lyra keeps earlier pages painted in the same simulator stack.
|
|
15
|
+
// The last non-exiting sibling is the active shell; route name alone is ambiguous.
|
|
16
|
+
export const activeShellSelector='.simulator > .tr-page:not(:has(iframe.tr-page-iframe-animation-out)):not(:has(~ .tr-page:not(:has(iframe.tr-page-iframe-animation-out))))';
|
|
17
|
+
export function currentPageSelector(routeName) {
|
|
18
|
+
return `${activeShellSelector} iframe[name=${JSON.stringify(routeName)}]:not(.tr-page-iframe-animation-out):visible`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const telemetry = new Set(['mdap.alipay.com', 'collect.alipay.com']);
|
|
22
|
+
const staticHosts = new Set(['gw.alipayobjects.com', 'mdn.alipayobjects.com']);
|
|
23
|
+
const frameworkConfigPaths = new Set([
|
|
24
|
+
'/p/alertserver/180020010101209785_config/index.json',
|
|
25
|
+
'/p/h5data/config-data_config-h5data.json',
|
|
26
|
+
'/p/h5data/lyra-h5data_appx-framework-config-h5data.json',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
export async function openMinifish({ page, plan, query, simulatorUrl, pageRoute, hostLanguage='zh-CN', requireHostLanguage=false, hostSystemPlan=null, materialMirrors=[], nativeApis=[], plugin=null, hostPolicy }) {
|
|
30
|
+
assert(hostPolicy&&['guideDiagnostics','queryExpectations','validatePluginEvents'].every(k=>typeof hostPolicy[k]==='function'),'Explicit project host policy required');
|
|
31
|
+
const {guideDiagnostics,queryExpectations,validatePluginEvents}=hostPolicy;
|
|
32
|
+
assert.ok(simulatorUrl, 'MINIFISH_SIMULATOR_URL must point to the official product simulator');
|
|
33
|
+
assert.ok(pageRoute, 'MINIFISH_PAGE_ROUTE must identify the delivered page');
|
|
34
|
+
assert.match(pageRoute, /^\/?[\w/-]+$/);
|
|
35
|
+
const url = new URL(simulatorUrl);
|
|
36
|
+
assert.ok(['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname), 'Use a local simulator');
|
|
37
|
+
url.searchParams.delete('dw');
|
|
38
|
+
url.searchParams.delete('remoteWorkerWsServerUrl');
|
|
39
|
+
url.searchParams.set('uuid', crypto.randomUUID());
|
|
40
|
+
url.searchParams.set('page', pageRoute.replace(/^\//, ''));
|
|
41
|
+
url.searchParams.set('pageQuery', new URLSearchParams(query).toString());
|
|
42
|
+
const hostDeviceEvidence=await installHostDeviceFixtureEvidence(page);
|
|
43
|
+
const loadingObservation=await installLoadingObservation(page);
|
|
44
|
+
await prepareIPhone16ProDevice(page,url);
|
|
45
|
+
const faults = [], blocked = [], materials = [];
|
|
46
|
+
const officialUi=[...new Set(plan.evidence().expectations.filter(r=>r.execution==='official-ui').map(r=>r.request.operation.slice(6)))];
|
|
47
|
+
const pluginPlan=plugin?pluginPackagePlan({appAddress:url.searchParams.get('appAddress'),appId:url.searchParams.get('appId'),...plugin}):null;
|
|
48
|
+
const pluginEvents=plugin?.events?pluginEventLedger(plugin.events,validatePluginEvents):null;
|
|
49
|
+
if(pluginEvents)page.on('console',message=>{const value=message.text();if(value.startsWith('BILL-PLUGIN-EVIDENCE ')){try{pluginEvents.receive(JSON.parse(value.slice(21)));}catch(error){faults.push('Plugin event: '+error.message);}}});
|
|
50
|
+
if(pluginPlan)page.on('response',response=>pluginPlan.observe(response));
|
|
51
|
+
const mirrors=new Map(materialMirrors.map(r=>[r.url,r]));
|
|
52
|
+
let closed = false, callbackCorrelation;
|
|
53
|
+
const handler = async (route) => {
|
|
54
|
+
const request = route.request(), target = new URL(request.url());
|
|
55
|
+
try {
|
|
56
|
+
if (target.origin === url.origin && target.pathname === bridgePath) return await route.fulfill({contentType:'application/javascript',body:bridgeScript(url.origin,nativeApis,Boolean(process.env.MINIFISH_LOADING_DIAGNOSTICS_DIR),officialUi,process.env.MINIFISH_CALLBACK_CORRELATION==='1')});
|
|
57
|
+
if (target.origin === url.origin && target.pathname === jsapiPath) {const wire=request.postDataJSON();callbackCorrelation?.transport(wire,request.frame());observeLocalNavigationCandidate(page,wire);if(officialUi.includes(wire.api)){plan.recordNativeUiRequest('jsapi:'+wire.api,businessJsapiOption(wire.option),wire);return await route.fulfill({status:204});}if(delegateLocalNavigation(page,wire))return await route.fulfill({status:204});return await fulfillJsapi(route, plan);}
|
|
58
|
+
if(target.origin===url.origin&&target.pathname===nativeUiResultPath){assert.equal(request.method(),'POST');const receipt=request.postDataJSON();plan.recordNativeUiResult('jsapi:'+receipt.api,businessJsapiOption(receipt.option),receipt);return await route.fulfill({status:204});}
|
|
59
|
+
if (target.origin === url.origin && target.pathname.startsWith('/lyraParams/')) {
|
|
60
|
+
const response = await route.fetch();
|
|
61
|
+
const config = await response.json();
|
|
62
|
+
config.rendererExtend = [...(config.rendererExtend ?? []), url.origin + bridgePath];
|
|
63
|
+
return await route.fulfill({response,json:config});
|
|
64
|
+
}
|
|
65
|
+
if (target.pathname === '/simulator/mobilegw/rpcProxy') {if(request.method()==='POST')callbackCorrelation?.transport(request.postDataJSON(),request.frame());return await fulfillRpc(route, plan);}
|
|
66
|
+
if(pluginPlan&&target.origin===url.origin&&target.pathname==='/delegate') {
|
|
67
|
+
assert.equal(request.method(),'POST','Plugin discovery must POST');
|
|
68
|
+
return await route.fulfill({json:pluginPlan.handle(request.postDataJSON())});
|
|
69
|
+
}
|
|
70
|
+
if(pluginPlan&&target.href.startsWith(pluginPlan.base)&&request.method()==='GET'&&/\.(?:js|json|css|png|svg|webp)$/.test(target.pathname))return await route.continue();
|
|
71
|
+
if (target.pathname.endsWith('/delegate') || target.pathname.includes('/simulator/mobilegw/')) {
|
|
72
|
+
throw new Error(`Unplanned platform call: ${target.pathname}`);
|
|
73
|
+
}
|
|
74
|
+
// Independently observed after official SDK native RPC failure; never send it.
|
|
75
|
+
if (target.origin === 'https://gm.mmstat.com' && target.pathname === '/fsp.1.1'
|
|
76
|
+
&& request.method() === 'GET') {
|
|
77
|
+
blocked.push({kind:'platform-sdk-failure-monitor', action:'abort', method:request.method(), url:target.href});
|
|
78
|
+
return await route.abort();
|
|
79
|
+
}
|
|
80
|
+
// Observed official alertserver framework config during consumer controls.
|
|
81
|
+
// This exact GET is aborted and recorded; other external calls stay faults.
|
|
82
|
+
if (target.origin === 'https://dataservice.alipayobjects.com'
|
|
83
|
+
&& target.pathname === '/alertserver/hash/713f0bfe13908ae65d6edaa964c02a8b'
|
|
84
|
+
&& request.method() === 'GET') {
|
|
85
|
+
blocked.push({kind:'platform-alertserver-config',action:'abort',url:target.href,resourceType:request.resourceType()});
|
|
86
|
+
return await route.abort();
|
|
87
|
+
}
|
|
88
|
+
if (telemetry.has(target.hostname) || target.hostname === 'render.alipay.com' && frameworkConfigPaths.has(target.pathname)) {
|
|
89
|
+
blocked.push({ kind: 'platform-telemetry', url: target.href });
|
|
90
|
+
return await route.abort();
|
|
91
|
+
}
|
|
92
|
+
if (target.origin === url.origin && ['GET', 'HEAD'].includes(request.method())) return await route.continue();
|
|
93
|
+
if(mirrors.has(target.href)&&request.method()==='GET'&&request.resourceType()==='image'){
|
|
94
|
+
const r=mirrors.get(target.href);materials.push({url:target.href,sha256:r.sha256,bytes:r.body.length});
|
|
95
|
+
return await route.fulfill({contentType:r.mime,body:r.body,headers:{'access-control-allow-origin':'*'}});
|
|
96
|
+
}
|
|
97
|
+
if (staticHosts.has(target.hostname) && request.method() === 'GET'
|
|
98
|
+
&& ['script', 'stylesheet', 'font', 'image'].includes(request.resourceType())) return await route.continue();
|
|
99
|
+
throw new Error(`Unexpected network: ${request.method()} ${target.origin}${target.pathname}`);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (!closed) faults.push(error.message);
|
|
102
|
+
await route.abort().catch(() => {});
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
const host=await installHostLanguage(page,hostLanguage);
|
|
106
|
+
const routeName = '/' + pageRoute.replace(/^\//, '');
|
|
107
|
+
const root = page.frameLocator('iframe[name="lyra-integration-web-sdk"]')
|
|
108
|
+
.frameLocator(currentPageSelector(routeName));
|
|
109
|
+
const guideDiagnostic=plan.evidence().expectations.some(r=>r.execution==='official-scroll')?guideDiagnostics(root):null;
|
|
110
|
+
callbackCorrelation=await installResponseConsumption(page,plan,guideDiagnostic);
|
|
111
|
+
await page.route('**/*', handler);
|
|
112
|
+
const api = {
|
|
113
|
+
root,
|
|
114
|
+
capturePayGuide:(stage)=>guideDiagnostic?.capture(stage),
|
|
115
|
+
flushPayGuide:()=>guideDiagnostic?.flush(),
|
|
116
|
+
assertComplete() {
|
|
117
|
+
assertLocalNavigationComplete(page,plan.evidence(),queryExpectations);
|
|
118
|
+
assert.deepEqual(faults, [], 'Network violations remain failures even when caught by product');
|
|
119
|
+
plan.assertComplete();
|
|
120
|
+
if(pluginPlan)pluginPlan.assertComplete();
|
|
121
|
+
if(pluginEvents)pluginEvents.assertComplete();
|
|
122
|
+
if(requireHostLanguage)host.assertObserved();
|
|
123
|
+
if(hostSystemPlan)host.assertSystemPlan(hostSystemPlan);
|
|
124
|
+
},
|
|
125
|
+
evidence() { return { ...plan.evidence(), hostDeviceFixture:hostDeviceEvidence.evidence(), callbackCorrelation:callbackCorrelation?.evidence()??[], payGuideDiagnostics:guideDiagnostic?.evidence()??[], hostLanguage:host.evidence(), localNavigation:localNavigationEvidence(page), networkViolations: [...faults], blocked: [...blocked], materials: [...materials], plugin:pluginPlan?.evidence()??null, pluginEvents:pluginEvents?.evidence()??null }; },
|
|
126
|
+
assertArtifactComplete(skipped){
|
|
127
|
+
assertLocalNavigationComplete(page,plan.evidence(),queryExpectations);
|
|
128
|
+
assert.deepEqual(faults, [], 'Artifact network violations remain failures');
|
|
129
|
+
const audit=plan.assertArtifactComplete(skipped);
|
|
130
|
+
if(pluginPlan)pluginPlan.assertComplete();
|
|
131
|
+
if(pluginEvents)pluginEvents.assertComplete();
|
|
132
|
+
if(requireHostLanguage)host.assertObserved();
|
|
133
|
+
if(hostSystemPlan)host.assertSystemPlan(hostSystemPlan);
|
|
134
|
+
return audit;
|
|
135
|
+
},
|
|
136
|
+
async close() {
|
|
137
|
+
closed = true;
|
|
138
|
+
try { await plan.close(); } finally {
|
|
139
|
+
// Keep interception installed until the page stops, including bridge failures.
|
|
140
|
+
try{await page.close();}finally{await loadingObservation?.save();}
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
try { await page.goto(url.href); } catch (error) { await api.close(); throw error; }
|
|
145
|
+
return api;
|
|
146
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {spawn} from 'node:child_process';
|
|
5
|
+
import {createHash} from 'node:crypto';
|
|
6
|
+
export async function buildPluginArtifact({planFile,output,modules,source,repositoryRoot,validatePluginEvents}){
|
|
7
|
+
assert(output&&modules&&source&&planFile&&repositoryRoot,'Explicit plugin plan/source/output/SDK required');
|
|
8
|
+
const root=path.resolve(repositoryRoot),work=path.resolve(output);
|
|
9
|
+
source=path.resolve(source);
|
|
10
|
+
assert(!work.startsWith(root+path.sep),'Artifact and build logs must be outside repository');
|
|
11
|
+
const plan=validatePluginEvents(JSON.parse(await fs.readFile(planFile,'utf8')));
|
|
12
|
+
await fs.mkdir(work);await fs.cp(source,path.join(work,'app'),{recursive:true});
|
|
13
|
+
await fs.symlink(path.resolve(modules),path.join(work,'app/node_modules'),'dir');
|
|
14
|
+
await fs.writeFile(path.join(work,'app/plugin/controlled-plan.js'),'export default '+JSON.stringify(plan)+';\n');
|
|
15
|
+
async function hashes(dir){const out={};async function walk(at){for(const e of await fs.readdir(at,{withFileTypes:true})){const p=path.join(at,e.name);if(e.isDirectory())await walk(p);else if(e.isFile())out[path.relative(dir,p)]=createHash('sha256').update(await fs.readFile(p)).digest('hex');}}await walk(dir);return out;}
|
|
16
|
+
const sourceHashes=await hashes(path.join(source,'plugin'));
|
|
17
|
+
const log=await fs.open(path.join(work,'build.log'),'w');
|
|
18
|
+
const child=spawn(path.join(path.resolve(modules),'.bin/minifish2'),['build',path.join(work,'app'),'--cacheDir',path.join(work,'cache')],{cwd:path.join(work,'app'),stdio:['ignore',log.fd,log.fd]});
|
|
19
|
+
const code=await new Promise((resolve,reject)=>{child.on('error',reject);child.on('exit',resolve);});await log.close();assert.equal(code,0,'Official plugin compilation failed; see build.log');
|
|
20
|
+
async function find(dir){for(const e of await fs.readdir(dir,{withFileTypes:true})){if(e.name==='node_modules'||e.name==='cache')continue;const p=path.join(dir,e.name);if(e.isDirectory()){const found=await find(p);if(found)return found;}else if(e.name==='appConfig.json'){const parent=path.dirname(p);if(path.basename(parent)!=='ng-plugin')continue;try{await fs.access(path.join(parent,'index.worker.js'));await fs.access(path.join(parent,'index.js'));return parent;}catch{}}}}
|
|
21
|
+
const artifact=await find(path.join(work,'app'));assert(artifact,'Official plugin artifact not found');
|
|
22
|
+
const artifacts=await hashes(artifact);
|
|
23
|
+
await fs.writeFile(path.join(artifact,'plugin-artifact.json'),JSON.stringify({schemaVersion:1,plan,sourceHashes,artifacts},null,2));
|
|
24
|
+
return {artifact,manifest:path.join(artifact,'plugin-artifact.json')};
|
|
25
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Artifact evidence never supplies a missing behavior pass."""
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def classify_receipt(receipt, ident, exit_code, role='behavior'):
|
|
6
|
+
if (receipt.get('caseId') != ident or exit_code != 0 or receipt.get('errors') != []
|
|
7
|
+
or receipt.get('artifactErrors')):
|
|
8
|
+
return 'failed'
|
|
9
|
+
actual = receipt.get('executionRole', 'behavior')
|
|
10
|
+
phases = [receipt.get(k) for k in ('preparation', 'target', 'boundary', 'cleanup')]
|
|
11
|
+
if role == 'behavior':
|
|
12
|
+
return 'passed' if actual == 'behavior' and phases == ['passed'] * 4 and receipt.get('outcome', 'passed') == 'passed' else 'failed'
|
|
13
|
+
if role != 'held-artifact' or actual != role or phases != ['passed', 'passed', 'not-executed-artifact-only', 'passed']:
|
|
14
|
+
return 'failed'
|
|
15
|
+
audit = receipt.get('artifactAudit', {})
|
|
16
|
+
evidence = receipt.get('evidence', {})
|
|
17
|
+
unexecuted = receipt.get('notExecutedActions', [])
|
|
18
|
+
skipped = {a.get('boundaryId') for a in unexecuted}
|
|
19
|
+
if (receipt.get('outcome') != 'artifact-only' or audit.get('status') != 'verified'
|
|
20
|
+
or audit.get('violations') != [] or evidence.get('violations') != []
|
|
21
|
+
or evidence.get('networkViolations') != [] or not skipped or None in skipped
|
|
22
|
+
or any(a.get('status') != 'not-executed-artifact-only' or a.get('kind') != 'release' for a in unexecuted)
|
|
23
|
+
or not any(a.get('kind') == 'cancel' and a.get('status') == 'passed' and a.get('protocol') == 'native-move-outside-device-then-up' for a in receipt.get('actions', []))):
|
|
24
|
+
return 'failed'
|
|
25
|
+
rows = {r['id']: r for r in evidence.get('expectations', [])}
|
|
26
|
+
states = {r['id']: r.get('status') for r in audit.get('expectations', [])}
|
|
27
|
+
if not skipped <= rows.keys() or rows.keys() != states.keys():
|
|
28
|
+
return 'failed'
|
|
29
|
+
for ident, row in rows.items():
|
|
30
|
+
if ident in skipped:
|
|
31
|
+
if states[ident] != 'not-executed-artifact-only' or any(row.get(k) != 0 for k in ('received', 'delivered', 'consumed')):
|
|
32
|
+
return 'failed'
|
|
33
|
+
elif (states[ident] != 'completed' or row.get('received') != row.get('times')
|
|
34
|
+
or row.get('delivered') != (0 if row.get('outcome', {}).get('kind') == 'hold' else row.get('times'))
|
|
35
|
+
or row.get('requireConsumption') and row.get('consumed') != row.get('times')):
|
|
36
|
+
return 'failed'
|
|
37
|
+
if not receipt.get('executionId') or not receipt.get('screenshots'):
|
|
38
|
+
return 'failed'
|
|
39
|
+
return 'artifact-only'
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def associate_capture(behavior, capture, *, legacy=False):
|
|
43
|
+
"""Strict frozen identities; legacy requires an explicit unchanged dependency map."""
|
|
44
|
+
if behavior.get('executionRole') != 'behavior':
|
|
45
|
+
raise ValueError('Behavior execution required')
|
|
46
|
+
left, right = behavior.get('fingerprints', {}), capture.get('fingerprints', {})
|
|
47
|
+
keys = ['caseId', 'planSha256', 'sourceSha256', 'productSha256', 'packageSha256', 'dependencyLockSha256']
|
|
48
|
+
for key in keys:
|
|
49
|
+
if not left.get(key) or not right.get(key):
|
|
50
|
+
raise ValueError('Missing frozen fingerprint: ' + key)
|
|
51
|
+
if key != 'caseId' and not all(re.fullmatch(r'[0-9a-f]{64}', x[key]) for x in (left, right)):
|
|
52
|
+
raise ValueError('Invalid fingerprint: ' + key)
|
|
53
|
+
if left[key] != right[key] and not (legacy and key in ('sourceSha256', 'packageSha256')):
|
|
54
|
+
raise ValueError('Capture input fingerprint mismatch: ' + key)
|
|
55
|
+
if not behavior.get('executionId') or not capture.get('executionId') or behavior['executionId'] == capture['executionId']:
|
|
56
|
+
raise ValueError('Two distinct execution identities required')
|
|
57
|
+
differences = []
|
|
58
|
+
if legacy:
|
|
59
|
+
# Only the capture/receipt lifecycle and its new audit method changed.
|
|
60
|
+
allowed = {'runner/independent-case-runner.mjs', 'runner/miniapp-screenshot.mjs',
|
|
61
|
+
'runner/input-lifecycle-evidence.mjs', 'runner/target-actions.mjs',
|
|
62
|
+
'runner/execution-evidence.mjs', 'runner/independent-boundary.mjs',
|
|
63
|
+
'runner/browser.mjs', 'runner/files.json'}
|
|
64
|
+
a, b = behavior.get('packageFiles'), capture.get('packageFiles')
|
|
65
|
+
if not a or not b:
|
|
66
|
+
raise ValueError('Legacy reuse requires actual frozen package maps')
|
|
67
|
+
differences = [k for k in sorted(a.keys() | b.keys()) if a.get(k) != b.get(k)]
|
|
68
|
+
if any(k not in allowed for k in differences):
|
|
69
|
+
raise ValueError('Legacy observation dependency changed')
|
|
70
|
+
if capture.get('historicalStatus') != 'failed' or capture.get('target') != 'passed':
|
|
71
|
+
raise ValueError('Legacy held observation must preserve failed execution identity')
|
|
72
|
+
elif classify_receipt(capture,left['caseId'],0,'held-artifact') != 'artifact-only':
|
|
73
|
+
raise ValueError('Independent artifact execution required')
|
|
74
|
+
return {'caseId': left['caseId'], 'behaviorExecutionId': behavior['executionId'],
|
|
75
|
+
'captureExecutionId': capture['executionId'], 'behaviorFingerprints': left,
|
|
76
|
+
'captureFingerprints': right, 'legacyObservationReuse': legacy,
|
|
77
|
+
'changedLifecycleDependencies': differences,
|
|
78
|
+
'behaviorOutcome': behavior.get('outcome'), 'captureOutcome': capture.get('historicalStatus') if legacy else capture.get('outcome')}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {expect} from '@playwright/test';
|
|
3
|
+
import {semanticLocator,synchronizeAnchors} from './semantic-anchor.mjs';
|
|
4
|
+
import {nativeMetrics,validateNativeImage} from './independent-visual.mjs';
|
|
5
|
+
|
|
6
|
+
export function validateCarouselContract(contract,images){
|
|
7
|
+
assert(contract&&typeof contract==='object','Explicit source carousel contract required');
|
|
8
|
+
assert.deepEqual(Object.keys(contract).sort(),['containerTarget','instances','memberTarget']);
|
|
9
|
+
for(const key of ['containerTarget','memberTarget'])assert(typeof contract[key]==='string'&&contract[key].trim()&&contract[key].includes(' / '),'Full source identity required');
|
|
10
|
+
assert.notEqual(contract.containerTarget,contract.memberTarget,'Container and collection members require separate source identities');
|
|
11
|
+
assert(Array.isArray(contract.instances)&&contract.instances.length>0&&contract.instances.every((v,i)=>v===i),'Immutable zero-based source member order required');
|
|
12
|
+
assert(Array.isArray(images)&&images.length===contract.instances.length&&images.every(v=>typeof v==='string'&&/^https:\/\//.test(v)),'Exact source image per member');
|
|
13
|
+
return contract;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Frame identity comes from declared source instances and actual intersection,
|
|
17
|
+
// never expected URL/text, DOM position, private swiper state or a default zero.
|
|
18
|
+
export function validateCarouselFrame(snapshot,instances){
|
|
19
|
+
assert(Array.isArray(instances)&&instances.length>0&&new Set(instances).size===instances.length,'Distinct source instances required');
|
|
20
|
+
assert.equal(snapshot.members.length,instances.length,'Exact collection cardinality');
|
|
21
|
+
const b=snapshot.box;
|
|
22
|
+
assert(b&&[b.x,b.y,b.width,b.height].every(Number.isFinite)&&b.width>0&&b.height>0,'Actual carousel viewport required');
|
|
23
|
+
const v=snapshot.viewport;assert(v&&Number.isFinite(v.width)&&Number.isFinite(v.height)&&v.width>0&&v.height>0,'Actual viewport required');
|
|
24
|
+
assert(b.x>=-.25&&b.y>=-.25&&b.x+b.width<=v.width+.25&&b.y+b.height<=v.height+.25,'Carousel viewport must actually paint without viewport clipping');
|
|
25
|
+
const active=[];
|
|
26
|
+
for(const [i,m]of snapshot.members.entries()){
|
|
27
|
+
assert.equal(m.instance,instances[i],'Source member ownership and order');assert.equal(m.owned,true,'Member must belong to the declared carousel');
|
|
28
|
+
assert(m.box&&Object.values(m.box).every(Number.isFinite),'Finite actual member geometry');
|
|
29
|
+
const r=m.box;
|
|
30
|
+
const width=Math.max(0,Math.min(b.x+b.width,r.x+r.width)-Math.max(b.x,r.x));
|
|
31
|
+
const height=Math.max(0,Math.min(b.y+b.height,r.y+r.height)-Math.max(b.y,r.y));
|
|
32
|
+
if(m.painted&&width>0&&height>0)active.push({instance:m.instance,width,height,full:Math.abs(width-b.width)<=.25&&Math.abs(height-b.height)<=.25});
|
|
33
|
+
}
|
|
34
|
+
assert.equal(active.length,1,'Exactly one painted current frame; transition/duplicate/offscreen is not a stable result');
|
|
35
|
+
assert(active[0].full,'Current image must cover the source viewport');
|
|
36
|
+
return {currentInstance:active[0].instance,intersection:active[0]};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function observeCarousel(root,{containerTarget,memberTarget,instances},images){
|
|
40
|
+
validateCarouselContract({containerTarget,memberTarget,instances},images?.urls);
|
|
41
|
+
await synchronizeAnchors(root);
|
|
42
|
+
const container=semanticLocator(root,containerTarget);await expect(container).toHaveCount(1);await expect(container).toBeVisible();
|
|
43
|
+
const c=await nativeMetrics(container),members=[];
|
|
44
|
+
for(const instance of instances){
|
|
45
|
+
const node=semanticLocator(root,memberTarget,{instance});await expect(node).toHaveCount(1);
|
|
46
|
+
// Ownership is checked independently of expected image URL.
|
|
47
|
+
const owned=await node.evaluate((el,target)=>{
|
|
48
|
+
for(let p=el.parentElement;p;p=p.parentElement)if(p.getAttribute('data-anchor-id')===target)return true;
|
|
49
|
+
return false;
|
|
50
|
+
},containerTarget);
|
|
51
|
+
const m=await nativeMetrics(node);
|
|
52
|
+
const painted=await node.evaluate(el=>{const r=el.getBoundingClientRect();for(let p=el;p;p=p.parentElement){const s=getComputedStyle(p);if(s.display==='none'||s.visibility!=='visible'||Number(s.opacity)===0)return false;if(p!==el){const clip=p.getBoundingClientRect();if(/hidden|clip|scroll|auto/.test(s.overflowX)&&(r.x<clip.x-.25||r.right>clip.right+.25))return false;if(/hidden|clip|scroll|auto/.test(s.overflowY)&&(r.y<clip.y-.25||r.bottom>clip.bottom+.25))return false;}}return true;});
|
|
53
|
+
members.push({instance,owned,painted,box:m.box,metrics:m});
|
|
54
|
+
}
|
|
55
|
+
const snapshot={box:c.box,viewport:c.viewport,members},frame=validateCarouselFrame(snapshot,instances);
|
|
56
|
+
if(images){
|
|
57
|
+
assert.equal(images.urls.length,instances.length,'Source image count');
|
|
58
|
+
const index=instances.indexOf(frame.currentInstance);
|
|
59
|
+
validateNativeImage(members[index].metrics,images.urls[index],images.records);
|
|
60
|
+
}
|
|
61
|
+
return {...frame,snapshot};
|
|
62
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
|
|
3
|
+
// A role is selected independently of its expected text/style. All declared
|
|
4
|
+
// carriers are counted before observing one; hidden history is excluded by scope.
|
|
5
|
+
export async function observeRole(carriers) {
|
|
6
|
+
const observed=[];
|
|
7
|
+
for(const {layer,locator,boundaries=[]} of carriers){
|
|
8
|
+
const count=await locator.count();
|
|
9
|
+
for(let i=0;i<count;i++){
|
|
10
|
+
// Enumeration preserves every candidate; it never resolves ambiguity.
|
|
11
|
+
const candidate=locator.nth(i);
|
|
12
|
+
if(!await candidate.isVisible())continue;
|
|
13
|
+
const box=await candidate.boundingBox();
|
|
14
|
+
const paint=await candidate.evaluate(e=>{
|
|
15
|
+
const s=getComputedStyle(e),r=e.getBoundingClientRect();let opacity=1;
|
|
16
|
+
for(let p=e;p;p=p.parentElement)opacity*=Number(getComputedStyle(p).opacity);
|
|
17
|
+
const points=[[.5,.5],[.2,.5],[.8,.5]];
|
|
18
|
+
return {text:e.textContent.trim(),opacity,visibility:s.visibility,
|
|
19
|
+
hit:points.every(([x,y])=>{const hit=document.elementFromPoint(r.x+r.width*x,r.y+r.height*y);return hit&&(e===hit||e.contains(hit));})};
|
|
20
|
+
});
|
|
21
|
+
const boundary=[];
|
|
22
|
+
for(const frame of boundaries){
|
|
23
|
+
const frameCount=await frame.count();
|
|
24
|
+
if(frameCount!==1||!box){boundary.push({count:frameCount,hit:false});continue;}
|
|
25
|
+
const outer=await frame.boundingBox();
|
|
26
|
+
if(!outer){boundary.push({count:1,hit:false});continue;}
|
|
27
|
+
const x=box.x+box.width/2,y=box.y+box.height/2;
|
|
28
|
+
const inside=box.x>=outer.x-.5&&box.y>=outer.y-.5&&box.x+box.width<=outer.x+outer.width+.5&&box.y+box.height<=outer.y+outer.height+.5;
|
|
29
|
+
const hit=inside&&await frame.evaluate((e,{x,y,outer})=>{
|
|
30
|
+
const r=e.getBoundingClientRect(),p=document.elementFromPoint(r.x+(x-outer.x)*r.width/outer.width,r.y+(y-outer.y)*r.height/outer.height);
|
|
31
|
+
return !!p&&(p===e||e.contains(p));
|
|
32
|
+
},{x,y,outer});
|
|
33
|
+
boundary.push({count:1,hit,inside});
|
|
34
|
+
}
|
|
35
|
+
observed.push({layer,box,paint,boundary});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return observed;
|
|
39
|
+
}
|
|
40
|
+
export function assertRole(observed,{role,text,count=1}){
|
|
41
|
+
assert.equal(observed.length,count,`${role}: visible carrier count must be ${count}; layers=${observed.map(x=>x.layer)}`);
|
|
42
|
+
for(const item of observed){
|
|
43
|
+
assert(item.box?.width>0&&item.box?.height>0,`${role}: missing geometry`);
|
|
44
|
+
assert.equal(item.paint.text,text,`${role}: wrong content`);
|
|
45
|
+
assert(item.paint.opacity>=.99&&item.paint.visibility==='visible',`${role}: not painted`);
|
|
46
|
+
assert(item.paint.hit&&item.boundary.every(b=>b.count===1&&b.hit),`${role}: obstructed or clipped carrier`);
|
|
47
|
+
}
|
|
48
|
+
return observed;
|
|
49
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Sequential fresh Playwright processes on one already-started official runtime."""
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import signal
|
|
8
|
+
import subprocess
|
|
9
|
+
import time
|
|
10
|
+
from batch_cases import read_cases, save
|
|
11
|
+
from capture_contract import classify_receipt
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def execute(rows, work, timeout):
|
|
15
|
+
results = []
|
|
16
|
+
current = None
|
|
17
|
+
def stop(signum, frame):
|
|
18
|
+
raise InterruptedError(f"batch interrupted: {signum}")
|
|
19
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
20
|
+
signal.signal(sig, stop)
|
|
21
|
+
try:
|
|
22
|
+
for index, row in enumerate(rows):
|
|
23
|
+
out = work / f"case-{index + 1:04d}"
|
|
24
|
+
out.mkdir()
|
|
25
|
+
item = dict(row, status="running", startedAt=time.time(), report=str(out))
|
|
26
|
+
results.append(item); save(work / "batch-results.json", results)
|
|
27
|
+
env = dict(os.environ, MINIFISH_CASE_PACKAGE_ROOT=row["package"], MINIFISH_TEST_REPORT_DIR=str(out / "results"), MINIFISH_FIXTURE_REPORT_DIR=str(out))
|
|
28
|
+
try:
|
|
29
|
+
with (out / "check.log").open("wb") as log:
|
|
30
|
+
current = subprocess.Popen([str(Path(row["package"]) / "node_modules/.bin/playwright"), "test", "--config", "playwright.config.mjs"], cwd=row["package"], env=env, stdout=log, stderr=log, start_new_session=True)
|
|
31
|
+
try:
|
|
32
|
+
item["exitCode"] = current.wait(timeout=timeout)
|
|
33
|
+
except subprocess.TimeoutExpired:
|
|
34
|
+
item["error"] = "case timeout"
|
|
35
|
+
item["exitCode"] = None
|
|
36
|
+
except OSError as error:
|
|
37
|
+
item["error"] = str(error); item["exitCode"] = None
|
|
38
|
+
finally:
|
|
39
|
+
if current is not None:
|
|
40
|
+
# Reap any browser grandchildren even after a failed CLI exits.
|
|
41
|
+
try: os.killpg(current.pid, signal.SIGTERM)
|
|
42
|
+
except ProcessLookupError: pass
|
|
43
|
+
try: current.wait(timeout=3)
|
|
44
|
+
except subprocess.TimeoutExpired: pass
|
|
45
|
+
try: os.killpg(current.pid, signal.SIGKILL)
|
|
46
|
+
except ProcessLookupError: pass
|
|
47
|
+
current.wait(); current = None
|
|
48
|
+
receipts = list((out / "results").rglob("case-result.json"))
|
|
49
|
+
item["status"] = "failed"
|
|
50
|
+
if len(receipts) == 1:
|
|
51
|
+
item["receipt"] = str(receipts[0])
|
|
52
|
+
try:
|
|
53
|
+
receipt = json.loads(receipts[0].read_text())
|
|
54
|
+
if not isinstance(receipt, dict):
|
|
55
|
+
raise ValueError("receipt must be an object")
|
|
56
|
+
item["phases"] = {key: receipt.get(key) for key in ("preparation", "target", "boundary", "cleanup")}
|
|
57
|
+
if receipt.get("caseId") != row["id"]:
|
|
58
|
+
item["error"] = "receipt identity mismatch"
|
|
59
|
+
else:
|
|
60
|
+
item["status"] = classify_receipt(receipt,row['id'],item.get('exitCode'),os.environ.get('MINIFISH_EXECUTION_ROLE','behavior'))
|
|
61
|
+
except (ValueError, OSError) as error:
|
|
62
|
+
item["error"] = f"Invalid case receipt: {error}"
|
|
63
|
+
if len(receipts) != 1:
|
|
64
|
+
item.setdefault("error", f"Expected exactly one case receipt, got {len(receipts)}")
|
|
65
|
+
item["finishedAt"] = time.time()
|
|
66
|
+
save(work / "batch-results.json", results)
|
|
67
|
+
finally:
|
|
68
|
+
if current is not None:
|
|
69
|
+
try: os.killpg(current.pid, signal.SIGKILL)
|
|
70
|
+
except ProcessLookupError: pass
|
|
71
|
+
current.wait()
|
|
72
|
+
for item in results:
|
|
73
|
+
if item["status"] == "running": item["status"] = "interrupted"
|
|
74
|
+
save(work / "batch-results.json", results)
|
|
75
|
+
return int(any(item["status"] not in ('passed','artifact-only') for item in results))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
p = argparse.ArgumentParser()
|
|
80
|
+
p.add_argument("--manifest", type=Path, required=True)
|
|
81
|
+
p.add_argument("--report-dir", type=Path, required=True)
|
|
82
|
+
p.add_argument("--case-timeout", type=int, default=90)
|
|
83
|
+
a = p.parse_args()
|
|
84
|
+
raise SystemExit(execute(read_cases(a.manifest), a.report_dir, a.case_timeout))
|