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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/package.json +15 -4
  2. package/tools/adapters/islands/trigger_source.py +53 -0
  3. package/tools/cli/backend.mjs +24 -0
  4. package/tools/cli/main.py +38 -0
  5. package/tools/cli/minifish.mjs +38 -0
  6. package/tools/cli/minifish.py +52 -0
  7. package/tools/core/package-identity.mjs +23 -0
  8. package/tools/core/package_identity.py +58 -0
  9. package/tools/core/semantic_identity.py +149 -0
  10. package/tools/core/test_semantic_identity.py +136 -0
  11. package/tools/docs/README.md +43 -0
  12. package/tools/docs/backend-testing.md +36 -0
  13. package/tools/docs/iteration.md +34 -0
  14. package/tools/docs/minifish-testing.md +32 -0
  15. package/tools/docs/semantic-identity.md +61 -0
  16. package/tools/modules/backend/actions.mjs +10 -0
  17. package/tools/modules/backend/assert.mjs +20 -0
  18. package/tools/modules/backend/context-receiver.py +30 -0
  19. package/tools/modules/backend/facility.test.mjs +44 -0
  20. package/tools/modules/backend/grouped_context.py +100 -0
  21. package/tools/modules/backend/grpc.mjs +57 -0
  22. package/tools/modules/backend/index.mjs +9 -0
  23. package/tools/modules/backend/input-tree.mjs +14 -0
  24. package/tools/modules/backend/logs.mjs +24 -0
  25. package/tools/modules/backend/logs.test.mjs +28 -0
  26. package/tools/modules/backend/metadata.mjs +66 -0
  27. package/tools/modules/backend/package-lock.json +362 -0
  28. package/tools/modules/backend/package.json +15 -0
  29. package/tools/modules/backend/paths.mjs +11 -0
  30. package/tools/modules/backend/product.mjs +48 -0
  31. package/tools/modules/backend/rejection-logs.mjs +33 -0
  32. package/tools/modules/backend/report.mjs +13 -0
  33. package/tools/modules/backend/report.test.mjs +13 -0
  34. package/tools/modules/backend/run.mjs +50 -0
  35. package/tools/modules/backend/util.mjs +24 -0
  36. package/tools/modules/minifish/checks/guard.test.mjs +13 -0
  37. package/tools/modules/minifish/checks/package.test.mjs +23 -0
  38. package/tools/modules/minifish/compiler/build-case.mjs +39 -0
  39. package/tools/modules/minifish/compiler/case-plan.mjs +175 -0
  40. package/tools/modules/minifish/compiler/config.mjs +29 -0
  41. package/tools/modules/minifish/compiler/observation-module-name.mjs +2 -0
  42. package/tools/modules/minifish/compiler/package-runner.mjs +43 -0
  43. package/tools/modules/minifish/compiler/semantic-module-gate.mjs +27 -0
  44. package/tools/modules/minifish/compiler/tool-identity.mjs +14 -0
  45. package/tools/modules/minifish/harness/batch_cases.py +91 -0
  46. package/tools/modules/minifish/harness/batch_plugin_groups.py +56 -0
  47. package/tools/modules/minifish/harness/batch_report.py +104 -0
  48. package/tools/modules/minifish/harness/browser.mjs +146 -0
  49. package/tools/modules/minifish/harness/build-plugin-artifact.mjs +25 -0
  50. package/tools/modules/minifish/harness/capture_contract.py +78 -0
  51. package/tools/modules/minifish/harness/carousel-observation.mjs +62 -0
  52. package/tools/modules/minifish/harness/carriers.mjs +49 -0
  53. package/tools/modules/minifish/harness/check-independent-batch.py +84 -0
  54. package/tools/modules/minifish/harness/check-runner-isolation.py +102 -0
  55. package/tools/modules/minifish/harness/component_runtime.py +125 -0
  56. package/tools/modules/minifish/harness/contract-value.mjs +32 -0
  57. package/tools/modules/minifish/harness/deferred-visual.mjs +21 -0
  58. package/tools/modules/minifish/harness/device-prepare.mjs +26 -0
  59. package/tools/modules/minifish/harness/execution-evidence.mjs +37 -0
  60. package/tools/modules/minifish/harness/foreground.mjs +20 -0
  61. package/tools/modules/minifish/harness/header-layout.mjs +47 -0
  62. package/tools/modules/minifish/harness/held-snapshot.mjs +67 -0
  63. package/tools/modules/minifish/harness/host-device-fixture.mjs +28 -0
  64. package/tools/modules/minifish/harness/host-language.mjs +73 -0
  65. package/tools/modules/minifish/harness/host-native-ui.mjs +39 -0
  66. package/tools/modules/minifish/harness/host-navigation.mjs +96 -0
  67. package/tools/modules/minifish/harness/image-loading.mjs +35 -0
  68. package/tools/modules/minifish/harness/independent-case-runner.mjs +175 -0
  69. package/tools/modules/minifish/harness/independent-visual.mjs +107 -0
  70. package/tools/modules/minifish/harness/inline-svg.mjs +32 -0
  71. package/tools/modules/minifish/harness/input-lifecycle-evidence.mjs +16 -0
  72. package/tools/modules/minifish/harness/jsapi.mjs +92 -0
  73. package/tools/modules/minifish/harness/loading-input.mjs +52 -0
  74. package/tools/modules/minifish/harness/loading-observation.mjs +25 -0
  75. package/tools/modules/minifish/harness/local-navigation-evidence.mjs +82 -0
  76. package/tools/modules/minifish/harness/miniapp-screenshot.mjs +38 -0
  77. package/tools/modules/minifish/harness/native-dom-sampling.mjs +40 -0
  78. package/tools/modules/minifish/harness/native-enabled.mjs +11 -0
  79. package/tools/modules/minifish/harness/native-rpc-failure.mjs +11 -0
  80. package/tools/modules/minifish/harness/native-state.mjs +14 -0
  81. package/tools/modules/minifish/harness/native-ui-contract.mjs +29 -0
  82. package/tools/modules/minifish/harness/navigation.mjs +19 -0
  83. package/tools/modules/minifish/harness/network_isolation.py +134 -0
  84. package/tools/modules/minifish/harness/painted-source-image.mjs +54 -0
  85. package/tools/modules/minifish/harness/playwright.mjs +1 -0
  86. package/tools/modules/minifish/harness/plugin-events.mjs +16 -0
  87. package/tools/modules/minifish/harness/plugin-package.mjs +23 -0
  88. package/tools/modules/minifish/harness/plugin_artifact.py +23 -0
  89. package/tools/modules/minifish/harness/product_identity.py +8 -0
  90. package/tools/modules/minifish/harness/project_config.py +14 -0
  91. package/tools/modules/minifish/harness/prototype-visual.mjs +123 -0
  92. package/tools/modules/minifish/harness/region-order.mjs +16 -0
  93. package/tools/modules/minifish/harness/renderer-image-evidence.mjs +32 -0
  94. package/tools/modules/minifish/harness/request-guard.mjs +65 -0
  95. package/tools/modules/minifish/harness/request-plan.mjs +124 -0
  96. package/tools/modules/minifish/harness/response-consumption.mjs +106 -0
  97. package/tools/modules/minifish/harness/retained-page-lifecycle.mjs +48 -0
  98. package/tools/modules/minifish/harness/rpc-wire.mjs +34 -0
  99. package/tools/modules/minifish/harness/run-batch.py +133 -0
  100. package/tools/modules/minifish/harness/run-platform.py +293 -0
  101. package/tools/modules/minifish/harness/runtime_isolation.py +71 -0
  102. package/tools/modules/minifish/harness/scroll-container-observation.mjs +40 -0
  103. package/tools/modules/minifish/harness/semantic-anchor.mjs +139 -0
  104. package/tools/modules/minifish/harness/semantic-observation.mjs +197 -0
  105. package/tools/modules/minifish/harness/skeleton-appearance.mjs +49 -0
  106. package/tools/modules/minifish/harness/target-actions.mjs +120 -0
  107. package/tools/modules/minifish/harness/test_batch_concurrency.py +125 -0
  108. package/tools/modules/minifish/harness/test_batch_plugin_groups.py +97 -0
  109. package/tools/modules/minifish/harness/test_batch_runner.py +107 -0
  110. package/tools/modules/minifish/harness/test_common_report.py +47 -0
  111. package/tools/modules/minifish/harness/test_held_capture_contract.py +50 -0
  112. package/tools/modules/minifish/harness/test_pty_tail.py +39 -0
  113. package/tools/modules/minifish/harness/test_runtime_dependencies.py +49 -0
  114. package/tools/modules/minifish/harness/zero-business-plan.mjs +19 -0
  115. package/tools/modules/minifish/identity.py +6 -0
  116. package/tools/modules/minifish/package-lock.json +74 -0
  117. package/tools/modules/minifish/package.json +10 -0
  118. package/tools/modules/minifish/sdk/package-lock.json +57745 -0
  119. package/tools/modules/minifish/sdk/package.json +13 -0
  120. package/tools/modules/workflow/diff_review.py +74 -0
  121. package/tools/modules/workflow/iteration.py +175 -0
  122. package/README.md +0 -3
@@ -0,0 +1,39 @@
1
+ import assert from 'node:assert/strict';
2
+ import {expect} from '@playwright/test';
3
+ const selectors={alert:'.adm-dialog-body,.adm-modal-body',showLoading:'.adm-toast-loading',showToast:'.adm-toast-wrap:not(:has(.adm-toast-loading))'};
4
+ const visibleSelector=api=>selectors[api].split(',').map(s=>s+':visible').join(',');
5
+ export function validateHostUiPaint(evidence,{content,title,buttonText}={}){
6
+ assert.equal(evidence.count,1,'Unique actual host carrier required');
7
+ const r=evidence.box;assert(r&&r.width>0&&r.height>0&&evidence.visible,'Actual host carrier must paint');
8
+ assert(r.x+r.width>0&&r.y+r.height>0&&r.x<evidence.viewport.width&&r.y<evidence.viewport.height,'Host carrier must intersect viewport');
9
+ if(content!==undefined)assert(evidence.text.includes(content),'Native host content must match source');
10
+ if(title!==undefined)assert(evidence.text.includes(title),'Native host title must match source');
11
+ if(buttonText!==undefined)assert.deepEqual(evidence.buttons,[buttonText],'Native host action and cardinality');
12
+ if(title!==undefined&&content!==undefined&&buttonText!==undefined)assert.equal(evidence.text.replace(/\s/g,''),(title+content+buttonText).replace(/\s/g,''),'No extra or incorrect native alert content');
13
+ return evidence;
14
+ }
15
+ export async function assertHostNativeUi(page,app,a){
16
+ assert(['alert','showLoading','showToast','addToHomeWithGuideDialog'].includes(a.api),'Finite native UI observation');
17
+ if(a.api==='addToHomeWithGuideDialog')throw Error('PUBLIC CAPABILITY UNAVAILABLE: official shortcut guide has no verified Web host carrier');
18
+ const host=page.frameLocator('iframe[name="lyra-integration-web-sdk"]');
19
+ const nodes=host.locator(visibleSelector(a.api));
20
+ if(a.state==='hidden'){await expect(nodes).toHaveCount(0);return {state:'hidden',api:a.api,carrierCount:0,level:'actual-host'};}
21
+ assert.equal(a.state,'visible');
22
+ const row=app.evidence().expectations.find(r=>r.id===a.boundaryId);assert(row&&row.execution==='official-ui','Actual official UI request required');assert.equal(row.received,1);
23
+ assert.equal(row.request.operation,'jsapi:'+a.api);
24
+ await expect(nodes).toHaveCount(1);
25
+ const evidence=await nodes.evaluate(el=>{
26
+ const r=el.getBoundingClientRect(),s=getComputedStyle(el);return {count:1,text:el.innerText,buttons:[...el.querySelectorAll('button,[role="button"]')].map(n=>n.innerText),box:{x:r.x,y:r.y,width:r.width,height:r.height},viewport:{width:innerWidth,height:innerHeight},visible:s.visibility==='visible'&&s.display!=='none'&&Number(s.opacity)>0,styles:{fontSize:s.fontSize,color:s.color,backgroundColor:s.backgroundColor,borderRadius:s.borderRadius},documentUrl:location.href};
27
+ });
28
+ validateHostUiPaint(evidence,a);
29
+ if(a.observation==='visual')throw Error('SOURCE PROJECTION REQUIRED: native host styles captured, but prototype-to-official-host visual equivalence is not approved');
30
+ return {api:a.api,boundaryId:a.boundaryId,request:row.request,carrier:evidence,officialResult:row.officialResult??null,resultPending:row.delivered===0,level:'actual-host-not-Mock'};
31
+ }
32
+ export async function dismissHostAlert(page,app,boundaryId){
33
+ const row=app.evidence().expectations.find(r=>r.id===boundaryId);assert(row?.execution==='official-ui'&&row.request.operation==='jsapi:alert'&&row.received===1,'Declared actual alert required');
34
+ const host=page.frameLocator('iframe[name="lyra-integration-web-sdk"]'),body=host.locator(visibleSelector('alert'));await expect(body).toHaveCount(1);
35
+ const buttons=body.locator('button,[role="button"]');await expect(buttons).toHaveCount(1);await expect(buttons).toHaveText(row.request.params.buttonText);await buttons.click();
36
+ await expect.poll(()=>app.evidence().expectations.find(r=>r.id===boundaryId).delivered).toBe(1);
37
+ if(row.requireConsumption)await expect.poll(()=>app.evidence().expectations.find(r=>r.id===boundaryId).consumed).toBe(1);
38
+ await expect(body).toHaveCount(0);
39
+ }
@@ -0,0 +1,96 @@
1
+ import assert from 'node:assert/strict';
2
+ import {expect} from '@playwright/test';
3
+ import {activeShellSelector} from './browser.mjs';
4
+
5
+ // Official Lyra 2.40.1 shell, outside the business page iframe. Select the
6
+ // current non-exiting shell before observing its title; no expected-word filter.
7
+ export function hostNavigationCarriers(page){
8
+ const host=page.frameLocator('iframe[name="lyra-integration-web-sdk"]');
9
+ const shell=host.locator(activeShellSelector);
10
+ return {shell,bar:shell.locator('.lyra-page-title .titlebar'),title:shell.locator('.lyra-page-title .titlebar .titlebar-center > .title')};
11
+ }
12
+ export function validateHostNavigation(observed,expected){
13
+ assert.equal(observed.title,expected.title,'Actual host title');
14
+ assert.equal(observed.backgroundColor,expected.backgroundColor,'Actual host navigation background');
15
+ assert(observed.box.width>0&&observed.box.height>0,'Host navigation must have a painted box');
16
+ assert(observed.opacity>0&&observed.hit,'Actual host navigation must be painted and unobstructed');
17
+ return observed;
18
+ }
19
+ export function validateDarkForeground(items){
20
+ for(const role of ['time','network','batteryText','batteryFill','batteryOutline','title','capsuleMore','capsuleClose']){
21
+ const item=items[role];assert(item?.visible,`Painted host foreground required: ${role}`);
22
+ assert(item.colors?.length,`Foreground paint evidence required: ${role}`);
23
+ for(const color of item.colors){
24
+ const match=/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)$/.exec(color);
25
+ assert(match&&Number(match[4]??1)>0,`Opaque foreground color: ${role}`);
26
+ assert(Math.max(...match.slice(1,4).map(Number))<128,`Approved dark foreground: ${role} (${color})`);
27
+ }
28
+ }
29
+ return items;
30
+ }
31
+ // Read actual CSS text, SVG icon paint and embedded capsule image paint. The
32
+ // SVG comes from this carrier's computed background, not a separate URL probe.
33
+ export async function observeHostForeground(shell){
34
+ const selectors={time:'.statusbar .time-info',network:'.statusbar .network-wifi',batteryText:'.statusbar .battery-level-text',batteryFill:'.statusbar .surplus-battery-level-process',title:'.titlebar-center > .title',capsuleMore:'.titlebar .actions .more-action',capsuleClose:'.titlebar .actions .close-action'};
35
+ const result={};
36
+ for(const [role,selector] of Object.entries(selectors)){
37
+ const node=shell.locator('.lyra-page-title '+selector);await expect(node).toHaveCount(1);await expect(node).toBeVisible();
38
+ result[role]=await node.evaluate((el,role)=>{
39
+ const style=getComputedStyle(el),box=el.getBoundingClientRect();
40
+ const norm=value=>{const c=document.createElement('canvas').getContext('2d');c.fillStyle=value;const hex=c.fillStyle;if(/^#[\da-f]{6}$/i.test(hex))return `rgb(${parseInt(hex.slice(1,3),16)}, ${parseInt(hex.slice(3,5),16)}, ${parseInt(hex.slice(5,7),16)})`;return hex;};
41
+ let colors=[];
42
+ if(role.startsWith('capsule')){
43
+ const m=/url\(["']?(data:image\/svg\+xml[^"')]+)["']?\)/.exec(style.backgroundImage);
44
+ if(!m)throw Error('Expected actual SDK embedded capsule SVG');
45
+ const [header,data]=m[1].split(',');const xml=header.includes(';base64')?atob(data):decodeURIComponent(data);
46
+ const doc=new DOMParser().parseFromString(xml,'image/svg+xml');
47
+ for(const p of doc.querySelectorAll('path,circle,rect,ellipse,polygon')){
48
+ const fill=p.getAttribute('fill')??p.parentElement?.getAttribute('fill')??'black';if(fill!=='none')colors.push(norm(fill));
49
+ const stroke=p.getAttribute('stroke');if(stroke&&stroke!=='none')colors.push(norm(stroke));
50
+ }
51
+ }else if(role==='network'){
52
+ for(const p of el.querySelectorAll('svg path')){const s=getComputedStyle(p);if(s.fill!=='none')colors.push(s.fill);if(s.stroke!=='none')colors.push(s.stroke);}
53
+ }else colors=[role==='batteryFill'?style.backgroundColor:style.color];
54
+ let opacity=1;for(let p=el;p;p=p.parentElement)opacity*=Number(getComputedStyle(p).opacity);
55
+ return {visible:box.width>0&&box.height>0&&opacity>0,computedColor:style.color,backgroundColor:style.backgroundColor,backgroundImage:style.backgroundImage,colors:[...new Set(colors)],box:{x:box.x,y:box.y,width:box.width,height:box.height},opacity};
56
+ },role);
57
+ }
58
+ // Battery outline uses an SDK raster background; record the exact computed
59
+ // carrier in addition to its independently checked actual fill and text.
60
+ const outline=shell.locator('.lyra-page-title .battery-level-icon');await expect(outline).toHaveCount(1);await expect(outline).toBeVisible();
61
+ result.batteryOutline=await outline.evaluate(async el=>{
62
+ const s=getComputedStyle(el),box=el.getBoundingClientRect(),match=/url\(["']?(data:image\/[^"')]+)["']?\)/.exec(s.backgroundImage);
63
+ if(!match)throw Error('Expected actual embedded SDK battery image');
64
+ const img=new Image();img.src=match[1];await img.decode();const c=document.createElement('canvas');c.width=img.naturalWidth;c.height=img.naturalHeight;
65
+ const ctx=c.getContext('2d');ctx.drawImage(img,0,0);const pixels=ctx.getImageData(0,0,c.width,c.height).data,colors=new Set();
66
+ for(let i=0;i<pixels.length;i+=4)if(pixels[i+3]>200)colors.add(`rgb(${pixels[i]}, ${pixels[i+1]}, ${pixels[i+2]})`);
67
+ return {visible:box.width>0&&box.height>0,computedColor:s.color,backgroundImage:s.backgroundImage,colors:[...colors],imageSize:{width:c.width,height:c.height},method:'decode actual computed embedded raster; opaque foreground pixels'};
68
+ });
69
+ return result;
70
+ }
71
+ export async function observeHostNavigation(page){
72
+ const {shell,bar,title}=hostNavigationCarriers(page);
73
+ for(const node of [shell,bar,title]){await expect(node).toHaveCount(1);await expect(node).toBeVisible();}
74
+ const paint=await bar.evaluate(el=>{
75
+ const s=getComputedStyle(el),r=el.getBoundingClientRect();let opacity=1;for(let p=el;p;p=p.parentElement)opacity*=Number(getComputedStyle(p).opacity);
76
+ const hit=el.ownerDocument.elementFromPoint(r.x+r.width/2,r.y+r.height/2);
77
+ return {backgroundColor:s.backgroundColor,opacity,hit:!!hit&&(hit===el||el.contains(hit)),box:{x:r.x,y:r.y,width:r.width,height:r.height}};
78
+ });
79
+ return {layer:'official-sdk-host',adapter:'lyra-2.40.1-titlebar-v1',title:await title.innerText(),...paint};
80
+ }
81
+ export async function assertHostNavigation(page,app,a,systemPlan){
82
+ await expect.poll(()=>app.evidence().hostLanguage.navigation.filter(r=>r.stage==='request').length,{message:'Product consumes system response then delegates actual navigation API'}).toBe(1);
83
+ const host=app.evidence().hostLanguage;
84
+ assert.equal(host.observations.length,systemPlan.times,'Exact public system response count');
85
+ assert.equal(host.systemRequests.length,systemPlan.times,'Exact public system request count');
86
+ assert.deepEqual(host.systemRequests[0].params,systemPlan.request.params,'Actual getSystemInfo parameters');
87
+ assert.equal(host.observations[0].mocked.language,systemPlan.outcome.value.language,'Declared public system response');
88
+ const navigation=host.navigation.filter(r=>r.stage==='request');
89
+ assert.equal(navigation[0].afterSystemResponse,systemPlan.times,'Navigation follows delivered system response');
90
+ assert.deepEqual(navigation[0].params,{title:a.title},'Actual delegated setNavigationBar parameters');
91
+ await expect.poll(()=>app.evidence().hostLanguage.navigation.filter(r=>r.stage==='success').length,{message:'Actual host setNavigationBar success callback'}).toBe(1);
92
+ const carriers=hostNavigationCarriers(page);await expect(carriers.title).toHaveText(a.title);
93
+ const observed=await observeHostNavigation(page);validateHostNavigation(observed,a);
94
+ const foreground=await observeHostForeground(carriers.shell);validateDarkForeground(foreground);
95
+ return {...observed,foreground,foregroundStandard:'user-approved official black theme; prototype #000 mapped, actual colors retained',system:app.evidence().hostLanguage,consumption:'delivered public system result -> product setNavigationBar -> actual host title'};
96
+ }
@@ -0,0 +1,35 @@
1
+ import {expect} from '@playwright/test';
2
+
3
+ // Official native image can use a single background on a role=img DIV.
4
+ export async function loadedImage(locator, expectedUrl, {pseudo}={}) {
5
+ await expect(locator).toHaveCount(1);
6
+ await expect(locator).toBeVisible();
7
+ return locator.evaluate(async (element, {expected,pseudo}) => {
8
+ const native = Boolean(pseudo)||element.tagName !== 'IMG';
9
+ const standard = !pseudo&&element.tagName === 'IMG';
10
+ if (!native && !standard) throw new Error('Unsupported image representation');
11
+ let actual;
12
+ if (standard) actual = element.currentSrc || element.getAttribute('src');
13
+ else {
14
+ const match = /^url\((["']?)(.*?)\1\)$/.exec(getComputedStyle(element,pseudo?'::'+pseudo:null).backgroundImage);
15
+ if (!match) throw new Error('Native image must have one material URL');
16
+ actual = match[2];
17
+ }
18
+ const resolve = url => new URL(url, element.ownerDocument.baseURI).href;
19
+ if (!actual || (expected!==undefined&&resolve(actual) !== resolve(expected))) throw new Error('Image material URL mismatch');
20
+ const image = standard ? element : new element.ownerDocument.defaultView.Image();
21
+ if (native) image.src = actual;
22
+ let timer;
23
+ try {
24
+ await Promise.race([image.decode(), new Promise((_, reject) => {
25
+ timer = setTimeout(() => reject(new Error('Image decode timed out')), 5000);
26
+ })]);
27
+ if (!image.complete || image.naturalWidth <= 0 || image.naturalHeight <= 0) throw new Error('Image did not load real pixels');
28
+ return {representation: standard ? 'img' : 'native-image', url: resolve(actual), width: image.naturalWidth, height: image.naturalHeight};
29
+ } finally { clearTimeout(timer); }
30
+ }, {expected:expectedUrl,pseudo});
31
+ }
32
+
33
+ export async function imageResourceURL(locator,{pseudo}={}){
34
+ return locator.evaluate((e,pseudo)=>{let url;if(!pseudo&&e.tagName==='IMG')url=e.currentSrc||e.getAttribute('src');else{const m=/^url\((["']?)(.*?)\1\)$/.exec(getComputedStyle(e,pseudo?'::'+pseudo:null).backgroundImage);url=m?.[2];}if(!url)throw Error('Missing public material URL');return new URL(url,e.ownerDocument.baseURI).href;},pseudo);
35
+ }
@@ -0,0 +1,175 @@
1
+ import {hostPolicy} from '@minifish/project-host';
2
+ import {prepareHeldNativeInput,executeHeldSnapshot} from './held-snapshot.mjs';
3
+ import {prepareHeldSnapshot,independentBoundary,assertNativeState,projectAssert,locateProject,queryBoundaryIds,validateReleasedPaymentRows} from '@minifish/project-runtime';
4
+ import {installInputLifecycleEvidence} from './input-lifecycle-evidence.mjs';
5
+ import {executionRole,executionEvidence,capturePolicy,needsFailureCapture} from './execution-evidence.mjs';
6
+ import {assertHostNavigation} from './host-navigation.mjs';
7
+ import {waitNativeEnabled} from './native-enabled.mjs';
8
+ import {targetActions} from './target-actions.mjs';
9
+ import {semanticLocator,synchronizeAnchors,closeAnchors} from './semantic-anchor.mjs';
10
+ import assert from 'node:assert/strict';
11
+ import {readFile, writeFile, unlink} from 'node:fs/promises';
12
+ import { expect } from '@playwright/test';
13
+ import { openMinifish } from './browser.mjs';
14
+ import { zeroBusinessPlan } from './zero-business-plan.mjs';
15
+ import { assertNativeVisual, nativeMetrics, near, geometryNear } from './independent-visual.mjs';
16
+ import { installRendererImageEvidence } from './renderer-image-evidence.mjs';
17
+ import { waitForNativeInput } from './loading-input.mjs';
18
+ import {assertHostNativeUi,dismissHostAlert} from './host-native-ui.mjs';
19
+ import {observeCarousel} from './carousel-observation.mjs';
20
+ import {retainedPageRoundTrip} from './retained-page-lifecycle.mjs';
21
+ import { assertSkeletonAppearance } from './skeleton-appearance.mjs';
22
+ import { prepareMiniappCapture, captureMiniappScreenshot } from './miniapp-screenshot.mjs';
23
+
24
+ export const screenshotPolicyVersion = 1;
25
+
26
+ function target(root, name) { return semanticLocator(root, name); }
27
+
28
+ export async function runMiniappCase({page, plan, testInfo}) {
29
+ assert.equal(plan.resources.length, 0, 'resource handling is not implemented in this runner yet');
30
+ const boundary = plan.boundaries.length ? independentBoundary(plan.boundaries) : zeroBusinessPlan();
31
+ const images = installRendererImageEvidence(page);
32
+ const result = { caseId: plan.case.id, preparation: 'not-run', target: 'not-run', boundary: 'not-run', cleanup: 'not-run', errors: [], assertions:[], observations:{} };
33
+ const screenshotMode = process.env.MINIFISH_SCREENSHOTS ?? (process.env.MINIFISH_FINAL_SCREENSHOTS === '1' ? 'all' : 'none');
34
+ assert(['none','failed','all'].includes(screenshotMode), 'Invalid screenshot policy');
35
+ result.screenshots = []; result.artifactErrors = [];
36
+ const role=executionRole(plan,process.env.MINIFISH_EXECUTION_ROLE??'behavior');
37
+ const artifactOnly=role==='held-artifact';
38
+ const policy=capturePolicy(plan,role,screenshotMode);
39
+ Object.assign(result,executionEvidence(plan,role));
40
+ if(policy.independentRequired)result.screenshotDisposition='independent-held-capture-required';
41
+ const capture = async phase => {
42
+ await trace?.sample('before-capture:'+phase,app?.root,boundary.evidence());
43
+ try { const shot = await captureMiniappScreenshot(page, testInfo, phase,phase==='target'?capturePreparation:undefined); result.screenshots.push({...shot,...(heldSpec?{inputState:'released',heldSnapshotImage:false}:{})}); }
44
+ catch(error) { result.artifactErrors.push(`Screenshot ${phase}: ${error.message}`); }
45
+ await trace?.sample('after-capture:'+phase,app?.root,boundary.evidence());
46
+ };
47
+ const trace=process.env.MINIFISH_INPUT_TRACE==='1'?await installInputLifecycleEvidence(page):null;
48
+ let app;
49
+ let input;
50
+ let capturePreparation;
51
+ result.actions=[];
52
+ let observation;
53
+ let heldSpec;
54
+ const locate=(root,name,options)=>locateProject(root,name,options,observation);
55
+ try {
56
+ result.preparation = 'running';
57
+ if(!artifactOnly&&(plan.beforeTarget??[]).some(a=>a.kind==='press'))heldSpec=prepareHeldSnapshot(plan);
58
+ if(plan.plugin){
59
+ assert(process.env.MINIFISH_PLUGIN_PACKAGE_URL&&process.env.MINIFISH_PLUGIN_ARTIFACT_MANIFEST,'Explicit compiled plugin artifact required');
60
+ const artifact=JSON.parse(await readFile(process.env.MINIFISH_PLUGIN_ARTIFACT_MANIFEST,'utf8'));
61
+ assert.deepEqual(artifact.plan,plan.plugin.events,'Case plugin plan must match compiled artifact');
62
+ }
63
+ if(plan.observationModule){observation=await import('./'+plan.observationModule);assert.equal(typeof observation.locate,'function');assert.equal(typeof observation.assertTarget,'function');}
64
+ for (const action of plan.actions) {
65
+ if (action.kind === 'open-page') {
66
+ assert(!app, 'duplicate page open');
67
+ app = await openMinifish({page, hostPolicy, plan:boundary, query:plan.inputs.query,
68
+ hostLanguage:plan.inputs.hostLanguage, requireHostLanguage:!!plan.hostSystemPlan, hostSystemPlan:plan.hostSystemPlan, simulatorUrl:process.env.MINIFISH_SIMULATOR_URL,
69
+ pageRoute:process.env.MINIFISH_PAGE_ROUTE,
70
+ plugin:plan.plugin?{provider:plan.plugin.provider,version:plan.plugin.version,events:plan.plugin.events,packageBaseUrl:process.env.MINIFISH_PLUGIN_PACKAGE_URL}:null,
71
+ nativeApis:[...plan.boundaries.filter(b=>b.execution!=='official-scroll').map(b=>b.request.operation).filter(o=>o.startsWith('jsapi:')).map(o=>o.slice(6)),'getGradeInfo','getBackgroundFetchData','pushWindow','redirectTo','alert','showToast','addToHomeWithGuideDialog']});
72
+ await synchronizeAnchors(app.root);
73
+ } else if (action.kind === 'wait-request') {
74
+ await expect.poll(()=>boundary.evidence().expectations.find(r=>r.id===action.boundaryId)?.received).toBe(1);
75
+ } else if(action.kind==='wait-response'){
76
+ await expect.poll(()=>boundary.evidence().expectations.find(r=>r.id===action.boundaryId)?.delivered).toBe(1);
77
+ const row=boundary.evidence().expectations.find(r=>r.id===action.boundaryId);
78
+ if(row.requireConsumption)await expect.poll(()=>boundary.evidence().expectations.find(r=>r.id===action.boundaryId)?.consumed).toBe(1);
79
+ } else if(action.kind==='wait-packet-complete'){
80
+ for(const id of action.boundaryIds){const row=()=>boundary.evidence().expectations.find(r=>r.id===id);assert(row(),'Declared packet boundary');await expect.poll(()=>row().received).toBe(row().times);await expect.poll(()=>row().delivered).toBe(row().times);if(row().requireConsumption)await expect.poll(()=>row().consumed).toBe(row().times);}
81
+ boundary.assertPacketComplete();
82
+ } else if(action.kind==='wait-loading-exit'){
83
+ const host=page.frameLocator('iframe[name="lyra-integration-web-sdk"]');
84
+ await expect(host.locator('.adm-toast-loading:visible')).toHaveCount(0);
85
+ } else if (action.kind === 'arrive') {
86
+ assert(app, 'page must open before arrival checks');
87
+ await synchronizeAnchors(app.root);
88
+ await expect(locate(app.root, action.target)).toHaveCount(1);
89
+ await expect(locate(app.root, action.target)).toBeVisible();
90
+ } else throw Error(`Unimplemented preparation action: ${action.kind}`);
91
+ }
92
+ assert(app, 'missing real page opening');
93
+ await trace?.attach();
94
+ input=targetActions({page,locate:(name,instance)=>locate(app.root,name,{instance}),synchronize:()=>synchronizeAnchors(app.root),boundary,receipts:result.actions,beforePress:async()=>{if(artifactOnly)capturePreparation=await prepareMiniappCapture(page);},observeInput:async(stage,action)=>trace?.sample(stage,app.root,boundary.evidence(),action)});
95
+ if(heldSpec)await prepareHeldNativeInput(app.root,heldSpec,locate(app.root,heldSpec.press.target,{instance:heldSpec.press.instance}));
96
+ for(const action of plan.beforeTarget??[]){
97
+ if(action.kind==='leave-return'){
98
+ const receipt={id:action.id,kind:action.kind,status:'running'};result.actions.push(receipt);
99
+ try{receipt.lifecycle=await retainedPageRoundTrip({page,app,boundary,action,queryBoundaryIds});receipt.status='passed';}catch(error){receipt.status='failed';receipt.error=error.message;throw error;}
100
+ }else await input.run(action);
101
+ }
102
+ result.preparation = 'passed';
103
+ result.target = 'running';
104
+ if(heldSpec){
105
+ const held=await executeHeldSnapshot({root:app.root,spec:heldSpec,release:action=>input.releaseHeldSnapshot(action),validators:observation});
106
+ result.heldSnapshot={version:heldSpec.version,snapshot:held.snapshot,timing:held.timing,imageStage:'released-independent-from-held-semantic-snapshot'};
107
+ result.assertions.push(...held.assertions);Object.assign(result.observations,held.observations);
108
+ for(const assertion of held.assertions)if(assertion.status!=='passed')result.errors.push(assertion.id+': '+assertion.error);
109
+ if(!held.timing.eligible){result.inputValidity='invalid';result.target='invalidated';}
110
+ }else for (const assertion of plan.assertions) {
111
+ await synchronizeAnchors(app.root);
112
+ const locator=assertion.kind==='host-navigation'?undefined:locate(app.root, assertion.target,{instance:assertion.instance});
113
+ try {
114
+ if(assertion.kind==='host-native-ui'){result.observations[assertion.id]=await assertHostNativeUi(page,app,assertion);
115
+ }else if(assertion.kind==='carousel-frame'){result.observations[assertion.id]=await observeCarousel(app.root,assertion.contract,{urls:assertion.images,records:images.records});
116
+ }else if(assertion.kind==='host-navigation'){result.observations[assertion.id]=await assertHostNavigation(page,app,assertion,plan.hostSystemPlan);
117
+ }else if(assertion.kind==='region'){result.observations[assertion.id]=await observation.assertTarget({app,page,assertion,images});
118
+ }else if(await projectAssert({root:app.root,assertion,locator,locate,images,result})){
119
+ }else if(['hidden','visible'].includes(assertion.kind)){await assertNativeState(locator,assertion);
120
+ }else if(assertion.kind==='text'){
121
+ await expect(locator).toHaveCount(assertion.count);await expect(locator).toBeVisible();await expect(locator).toHaveText(assertion.expected);
122
+ } else result.observations[assertion.id]=await assertNativeVisual(locator,assertion,images);
123
+ result.assertions.push({id:assertion.id,sourceCaseIds:assertion.sourceCaseIds,status:'passed'});
124
+ }catch(error){result.assertions.push({id:assertion.id,sourceCaseIds:assertion.sourceCaseIds,status:'failed',error:error.message});result.errors.push(error.message);}
125
+ }
126
+ if(result.target!=='invalidated')result.target = result.errors.length ? 'failed':'passed';
127
+ if (policy.target) await capture('target');
128
+ if(artifactOnly){
129
+ result.notExecutedActions=(plan.afterTarget??[]).map(action=>({...action,status:'not-executed-artifact-only'}));
130
+ await input.cancelForArtifact(capturePreparation.clip);
131
+ }else if(heldSpec){if(result.inputValidity!=='invalid')await input.completeHeldRelease(heldSpec.release);}
132
+ else for(const action of plan.afterTarget??[])await input.run(action);
133
+ for(const action of plan.completion){
134
+ if(action.kind==='wait-boundaries'){for(const id of action.boundaryIds){const row=()=>boundary.evidence().expectations.find(r=>r.id===id);assert(row(),'Declared completion boundary required');await expect.poll(()=>row().received).toBe(row().times);await expect.poll(()=>row().delivered).toBe(row().times);if(row().requireConsumption)await expect.poll(()=>row().consumed).toBe(row().times);}if(action.receiptPhase!==undefined){assert.equal(action.receiptPhase,'released-payment','Finite completion payment phase');validateReleasedPaymentRows(boundary.evidence().expectations,action.boundaryIds);}continue;}
135
+ if(action.kind==='dismiss-host-alert'){await dismissHostAlert(page,app,action.boundaryId);result.assertions.push({id:action.id,sourceCaseIds:action.sourceCaseIds,status:'passed'});continue;}
136
+ assert.equal(action.kind,'click-and-request');
137
+ const locator=locate(app.root,action.target,{instance:action.instance});await expect(locator).toHaveCount(1);await expect(locator).toBeVisible();await waitNativeEnabled(locator,true);
138
+ await locator.scrollIntoViewIfNeeded();await waitForNativeInput(page,locator);await locator.click();
139
+ await expect.poll(()=>boundary.evidence().expectations.find(r=>r.id===action.boundaryId)?.delivered).toBe(1);
140
+ const row=boundary.evidence().expectations.find(r=>r.id===action.boundaryId);
141
+ if(row.requireConsumption)await expect.poll(()=>boundary.evidence().expectations.find(r=>r.id===action.boundaryId)?.consumed).toBe(1);
142
+ result.assertions.push({id:action.id,sourceCaseIds:action.sourceCaseIds,status:'passed'});
143
+ }
144
+ } catch (error) {
145
+ if (result.preparation === 'running') result.preparation = 'failed';
146
+ if (result.target === 'running') result.target = 'failed';
147
+ result.errors.push(error.message);
148
+ } finally {
149
+ try{await app?.capturePayGuide('runner-finally');await app?.flushPayGuide();}catch{}
150
+ try{await input?.cleanup();}catch(error){result.errors.push('Input release: '+error.message);}
151
+ try { if (app) { result.anchors = await app.root.locator(':root').evaluate(el => Array.from(el.ownerDocument.querySelectorAll('[data-anchor-id]')).map(n => ({path:n.getAttribute('data-anchor-id'),tag:n.tagName,text:n.textContent?.slice(0,160)}))); await closeAnchors(app.root); } }
152
+ catch (error) { result.errors.push('Anchor cleanup: '+error.message); }
153
+ try { if(artifactOnly){assert(app,'Artifact requires real page');result.artifactAudit=app.assertArtifactComplete(plan.afterTarget.map(a=>a.boundaryId));result.boundary='not-executed-artifact-only';}else{if (app) app.assertComplete(); else boundary.assertComplete(); result.boundary = 'passed';} }
154
+ catch (error) { result.boundary = 'failed'; result.errors.push(error.message); }
155
+ if (needsFailureCapture(policy,screenshotMode,result)) await capture('failure');
156
+ if(trace)result.inputLifecycle=await trace.evidence(page);
157
+ try { if (app) await app.close(); else { await boundary.close(); await page.close(); } result.cleanup = 'passed'; }
158
+ catch (error) { result.cleanup = 'failed'; result.errors.push(error.message); }
159
+ if (screenshotMode === 'failed' && !result.errors.length && !result.artifactErrors.length) {
160
+ for (const shot of result.screenshots) {
161
+ await unlink(testInfo.outputPath(shot.file));
162
+ await unlink(testInfo.outputPath(`miniapp-${shot.phase}.json`));
163
+ }
164
+ result.screenshots = [];
165
+ }
166
+ result.images=await images.snapshot();images.close();
167
+ result.finishedAt=new Date().toISOString();
168
+ result.outcome=result.inputValidity==='invalid'?'invalid-input':result.errors.length||result.artifactErrors.length?'failed':artifactOnly?'artifact-only':'passed';
169
+ const evidenceFile = testInfo.outputPath('case-result.json');
170
+ await writeFile(evidenceFile, JSON.stringify({...result, evidence:app?.evidence() ?? boundary.evidence()}, null, 2));
171
+ await testInfo.attach('case-result', {path:evidenceFile, contentType:'application/json'});
172
+ }
173
+ if (result.artifactErrors.length) throw Error(result.artifactErrors.join("\n"));
174
+ if (result.errors.length) throw Error(result.errors.join('\n'));
175
+ }
@@ -0,0 +1,107 @@
1
+ import {sampleNativeMetrics} from './native-dom-sampling.mjs';
2
+ import {rendererImageFrameId} from './renderer-image-evidence.mjs';
3
+ import assert from 'node:assert/strict';
4
+ import {expect} from '@playwright/test';
5
+ export async function nativeMetricsFrameIdentity(locator) {
6
+ const handle=await locator.elementHandle();let frameId;
7
+ try{const frame=await handle?.ownerFrame();if(frame)frameId=rendererImageFrameId(locator.page(),frame);}finally{await handle?.dispose();}
8
+ return frameId;
9
+ }
10
+ export async function nativeMetrics(locator) {
11
+ const frameId=await nativeMetricsFrameIdentity(locator);
12
+ const metrics=await locator.evaluate(sampleNativeMetrics);
13
+ metrics.frameId=frameId;for(const paint of [...metrics.imagePaints,...metrics.imagePaintInventory])paint.frameId=frameId;return metrics;
14
+ }
15
+ export function near(actual,expected,label,tolerance=.02){assert(Math.abs(actual-expected)<=tolerance,`${label}: ${actual} != ${expected}`);}
16
+ export function imagePaintCarrier(m){
17
+ if(m.styles.backgroundImage!=='none')return m; // Existing background policy stays on its painted root.
18
+ const paints=(m.imagePaints??[]).filter(p=>p.visible);
19
+ assert.equal(paints.length,1,'One visible actual image paint carrier inside semantic root required');
20
+ const paint=paints[0];assert(paint.owned,'Image cannot belong to a different semantic child');
21
+ assert(paint.visible,'Actual image paint carrier must be visible');
22
+ assert(paint.box.width>0&&paint.box.height>0,'Nonzero actual image paint required');
23
+ for(const b of [...paint.clipping,{x:0,y:0,...paint.viewport,clipX:true,clipY:true}]){
24
+ const r=paint.box;if(b.clipX)assert(r.x>=b.x-.25&&r.x+r.width<=b.x+b.width+.25,'Image horizontal paint clipping');if(b.clipY)assert(r.y>=b.y-.25&&r.y+r.height<=b.y+b.height+.25,'Image vertical paint clipping');
25
+ }
26
+ return paint;
27
+ }
28
+ export function validateNativeImage(m,expected,records){
29
+ m=imagePaintCarrier(m);
30
+ const urls=[...m.styles.backgroundImage.matchAll(/url\(["']?(.*?)["']?\)/g)].map(v=>v[1]);
31
+ if(urls.length)assert.deepEqual(urls,[expected],'Exact background image on this carrier');
32
+ else assert(m.images.some(i=>i.src===expected),'Exact source image on this carrier');
33
+ assert(records.some(r=>r.url===expected&&r.frame===m.frame&&(r.frameId===undefined||r.frameId===m.frameId)&&r.status===200&&r.bytes>0&&r.decoded?.width>0&&r.decoded?.height>0),'Actual same-frame renderer response and original bytes decode');
34
+ }
35
+ export function validateImageFit(m,fit,expected,records){
36
+ const inventory=m.imagePaintInventory;
37
+ validateNativeImage(m,expected,records);
38
+ m=imagePaintCarrier(m);
39
+ if(m.styles.backgroundImage==='none'){assert.equal(m.styles.objectFit,fit,'Source image fit');return;}
40
+ if(m.styles.backgroundSize===fit)return;
41
+ if(fit==='fill'){
42
+ assert.equal(m.styles.backgroundSize,'contain','Finite source fill/native contain equivalence only');
43
+ const paints=inventory?.filter(p=>p.visible&&p.box.width>0&&p.box.height>0);assert.equal(paints?.length,1,'One actual visible image paint for fill equivalence');
44
+ assert(paints[0].owned&&paints[0].paintIndex===(m.paintIndex??0),'Equivalence belongs to the same semantic image paint');
45
+ }else{
46
+ assert.equal(fit,'contain','Only source contain has a finite native stretch equivalence');
47
+ assert.equal(m.styles.backgroundSize,'100% 100%','Native background fit');
48
+ }
49
+ assert.equal(m.styles.backgroundRepeat,'no-repeat','One nonrepeated image paint');
50
+ assert(['50% 50%','center center'].includes(m.styles.backgroundPosition),'Centered image paint');
51
+ const receipts=records.filter(r=>r.url===expected&&r.frame===m.frame&&(r.frameId===undefined||r.frameId===m.frameId)&&r.status===200&&r.bytes>0&&r.decoded?.width>0&&r.decoded?.height>0);
52
+ assert(receipts.length,'Same-frame original image decode required');
53
+ for(const r of receipts){geometryNear(m.box.height,m.box.width*r.decoded.height/r.decoded.width,'Equal source/carrier aspect projected height');geometryNear(m.box.width,m.box.height*r.decoded.width/r.decoded.height,'Equal source/carrier aspect projected width');}
54
+ }
55
+ export async function assertNativeImage(locator,a,images){
56
+ await expect(locator).toHaveCount(1);await expect(locator).toBeVisible();await locator.scrollIntoViewIfNeeded();
57
+ let m=await nativeMetrics(locator);const u=m.viewport.width/a.designWidth;
58
+ if(a.fit)await expect.poll(async()=>imageValidationStatus(m=await nativeMetrics(locator),metrics=>validateImageFit(metrics,a.fit,a.expected,images?.records??[])),{message:'Strict source contain/native image aspect and decoded receipt'}).toEqual({status:'matched'});
59
+ if(a.width!==undefined)geometryNear(m.box.width,a.width*u,'image width');
60
+ if(a.height!==undefined)geometryNear(m.box.height,a.height*u,'image height');
61
+ for(const b of [...m.clipping,{x:0,y:0,...m.viewport}])assert(m.box.x>=b.x-.1&&m.box.y>=b.y-.1&&m.box.x+m.box.width<=b.x+b.width+.1&&m.box.y+m.box.height<=b.y+b.height+.1,'Visible image carrier clipping');
62
+ await expect.poll(async()=>imageValidationStatus(m=await nativeMetrics(locator),metrics=>validateNativeImage(metrics,a.expected,images?.records??[])),{message:'Actual carrier URL and same-frame decoded renderer receipt'}).toEqual({status:'matched'});
63
+ return m;
64
+ }
65
+ // User-approved absolute budget in logical CSS pixels, independent of DPR/size.
66
+ export const GEOMETRY_TOLERANCE = 0.25;
67
+ export function geometryNear(actual,expected,label){
68
+ assert(Number.isFinite(actual)&&Number.isFinite(expected),`${label}: finite geometry required`);
69
+ near(actual,expected,label,GEOMETRY_TOLERANCE);
70
+ }
71
+ export async function assertNativeVisual(locator,a,images) {
72
+ await expect(locator).toHaveCount(1);await expect(locator).toBeVisible();
73
+ const m=await nativeMetrics(locator),u=m.viewport.width/a.designWidth;
74
+ const css=(key,n)=>(['fontSize','lineHeight','letterSpacing'].includes(key)?near:geometryNear)(parseFloat(m.styles[key]),n*u,key);
75
+ if(a.kind==='image') {
76
+ return assertNativeImage(locator,a,images);
77
+ } else {
78
+ await expect(locator).toHaveText(a.expected);
79
+ css('fontSize',a.fontSize);assert.equal(Number(m.styles.fontWeight),a.fontWeight);assert.equal(m.styles.color,a.color);
80
+ assert(/PingFang SC|sans-serif/.test(m.styles.fontFamily),'Source native/system font family');
81
+ if(a.kind==='text-visual') {
82
+ near(parseFloat(m.styles.lineHeight),a.fontSize*u*a.lineHeightRatio,'line height');css('marginTop',a.marginTop);css('marginBottom',a.marginBottom);
83
+ } else {
84
+ await expect(locator).toBeEnabled();geometryNear(m.box.height,a.height*u,'button height');assert(m.box.width+.02>=a.minWidth*u,'button minimum width');
85
+ css('borderRadius',a.radius);css('paddingLeft',a.horizontalPadding);css('paddingRight',a.horizontalPadding);assert.equal(m.styles.backgroundColor,a.backgroundColor);
86
+ }
87
+ assert(m.texts.length,'Actual rendered text geometry required');
88
+ for(const t of m.texts){assert(t.y>=m.box.y-.02&&t.y+t.height<=m.box.y+m.box.height+.02,'Text vertically clipped');assert(t.x>=m.box.x-.02&&t.x+t.width<=m.box.x+m.box.width+.02,'Text horizontally clipped');}
89
+ if(a.kind==='button-visual') {const top=Math.min(...m.texts.map(t=>t.y)),bottom=Math.max(...m.texts.map(t=>t.y+t.height));near((top+bottom)/2,m.box.y+m.box.height/2,'button text vertical center',1);}
90
+ }
91
+ return m;
92
+ }
93
+ // Exact resource enumeration stays inside an already resolved semantic root.
94
+ export function nativeImageIndex(metrics,url){
95
+ const matches=metrics.flatMap((m,index)=>{
96
+ const urls=[...m.styles.backgroundImage.matchAll(/url\(["']?(.*?)["']?\)/g)].map(v=>v[1]);
97
+ return (urls.includes(url)||m.images.some(i=>i.src===url))?[index]:[];
98
+ });
99
+ assert.equal(matches.length,1,'Unique exact source image in semantic root');return matches[0];
100
+ }
101
+ export async function nativeImageBySource(root,url){
102
+ const images=root.locator('[role="img"]');const metrics=[];
103
+ for(let i=0;i<await images.count();i++)metrics.push(await nativeMetrics(images.nth(i)));
104
+ return images.nth(nativeImageIndex(metrics,url));
105
+ }
106
+
107
+ export function imageValidationStatus(metrics,validate){try{validate(metrics);return {status:'matched'};}catch(error){return {status:'mismatch',error:error.message,metrics};}}
@@ -0,0 +1,32 @@
1
+ import assert from 'node:assert/strict';
2
+ import {expect,nativePart} from './semantic-observation.mjs';
3
+ import {nativeMetrics,geometryNear} from './independent-visual.mjs';
4
+ export function normalizeSvg(svg){return svg.trim().replace(/>\s+</g,'><');}
5
+ export function decodeInlineSvg(uri){
6
+ const m=/^data:image\/svg\+xml;base64,([A-Za-z0-9+/]+={0,2})$/.exec(uri);assert(m,'Only base64 image/svg+xml resource');
7
+ const bytes=Buffer.from(m[1],'base64');assert.equal(bytes.toString('base64'),m[1],'Canonical base64 bytes required');return bytes.toString('utf8');
8
+ }
9
+ export function validateInlineSvg(observed,expected){
10
+ assert.equal(normalizeSvg(decodeInlineSvg(observed.src)),normalizeSvg(expected),'Frozen SVG bytes');
11
+ assert(observed.decoded&&observed.naturalWidth>0&&observed.naturalHeight>0,'Actual browser SVG decode');return observed;
12
+ }
13
+ export function validateClearGeometry(button,image){
14
+ const u=button.viewport.width/375;for(const k of ['width','height']){geometryNear(button.box[k],34*u,'clear button '+k);geometryNear(image.box[k],18*u,'clear image '+k);}
15
+ for(const k of ['paddingLeft','paddingRight','paddingTop','paddingBottom'])geometryNear(parseFloat(button.styles[k]),8*u,k);
16
+ geometryNear(parseFloat(button.styles.marginRight),-8*u,'clear negative margin');
17
+ assert.equal(button.styles.backgroundColor,'rgba(0, 0, 0, 0)');assert.equal(button.borderWidth,0,'Borderless clear button');assert.equal(button.afterBorderWidth,0,'No native button after border');
18
+ assert.equal(image.styles.backgroundImage==='none'?image.styles.objectFit:image.styles.backgroundSize,'contain');
19
+ geometryNear(image.box.x+image.box.width/2,button.box.x+button.box.width/2,'clear image center x');geometryNear(image.box.y+image.box.height/2,button.box.y+button.box.height/2,'clear image center y');
20
+ }
21
+ export async function assertInlineClear(node,a){
22
+ await expect(node).toHaveCount(1);await expect(node).toBeVisible();
23
+ const nested=nativePart(node,{role:'button'});assert((await nested.count())<=1,'Duplicate native clear button');const button=await nested.count()?nested:node;
24
+ await expect(button).toBeVisible();await button.scrollIntoViewIfNeeded();const image=nativePart(button,{role:'img'});await expect(image).toHaveCount(1);await expect(image).toBeVisible();
25
+ const observed=await image.evaluate(async el=>{
26
+ const background=getComputedStyle(el).backgroundImage;const urls=[...background.matchAll(/url\(["']?(.*?)["']?\)/g)].map(m=>m[1]);
27
+ if(urls.length>1)throw Error('Duplicate image backgrounds');const src=urls.length?urls[0]:el.currentSrc||el.src||el.getAttribute('src');if(!src)throw Error('Actual image source missing');
28
+ const img=el instanceof HTMLImageElement?el:new Image();if(img!==el)img.src=src;await img.decode();return {src,decoded:true,naturalWidth:img.naturalWidth,naturalHeight:img.naturalHeight,method:img===el?'actual image decode':'actual carrier background bytes decode'};
29
+ });validateInlineSvg(observed,a.svg);
30
+ const b=await nativeMetrics(button),im=await nativeMetrics(image);const border=await button.evaluate(el=>{const s=getComputedStyle(el),p=getComputedStyle(el,'::after');return {borderWidth:Math.max(...['Top','Right','Bottom','Left'].map(k=>parseFloat(s['border'+k+'Width'])||0)),afterBorderWidth:Math.max(...['Top','Right','Bottom','Left'].map(k=>parseFloat(p['border'+k+'Width'])||0))};});Object.assign(b,border);
31
+ if(a.visual)validateClearGeometry(b,im);return {resource:observed,svg:normalizeSvg(a.svg),button:b,image:im};
32
+ }
@@ -0,0 +1,16 @@
1
+ // Optional public, read-only input/capture diagnostics. No dispatched events,
2
+ // scrolling, handlers, product memory or retries; listeners are passive.
3
+ export async function installInputLifecycleEvidence(page){
4
+ const key='__seeleInputLifecycleEvidence';
5
+ const attach=async()=>{for(const frame of page.frames())try{await frame.evaluate(({key})=>{
6
+ if(globalThis[key])return;const events=[];globalThis[key]=events;
7
+ for(const type of ['pointerdown','pointerup','pointercancel','mousedown','mouseup','click','touchstart','touchend','touchcancel','scroll'])document.addEventListener(type,e=>{const n=e.target?.closest?.('[data-anchor-id]');events.push({type,time:performance.timeOrigin+performance.now(),trusted:e.isTrusted,buttons:e.buttons,button:e.button,x:e.clientX,y:e.clientY,path:n?.getAttribute('data-anchor-id')??null,box:n?(()=>{const r=n.getBoundingClientRect();return {x:r.x,y:r.y,width:r.width,height:r.height};})():null,scrollX,scrollY});if(events.length>160)events.shift();},{capture:true,passive:true});
8
+ },{key});}catch{}};
9
+ const records=[];
10
+ return {attach,sample(stage,root,boundary,action){
11
+ records.push({stage,time:Date.now(),action,requests:boundary.expectations.map(r=>({id:r.id,received:r.received,delivered:r.delivered,consumed:r.consumed}))});
12
+ },async evidence(){
13
+ const frames=[];for(const frame of page.frames())try{frames.push(await frame.evaluate(({key})=>({url:location.href,events:globalThis[key]??[],viewport:{width:innerWidth,height:innerHeight,dpr:devicePixelRatio}}),{key}));}catch(e){frames.push({detached:true,error:e.message});}
14
+ return {samples:records,frames};
15
+ }};
16
+ }
@@ -0,0 +1,92 @@
1
+ import {hostDeviceFixture,configureHostDeviceMount,hostDeviceMountEvidence} from './host-device-fixture.mjs';
2
+ import assert from 'node:assert/strict';
3
+ import {nativeUiResultPath,nativeUiApis} from './native-ui-contract.mjs';
4
+ // Official Minifish rendererExtend -> beforeMount -> beforeBridgeInvokeAsync.
5
+ // Captures native calls without reading or altering product business state.
6
+ export const bridgePath = '/__bill_tests__/bridge.js';
7
+ export const jsapiPath = '/__bill_tests__/jsapi';
8
+ export function bridgeScript(origin, additionalApis = [], observeLoading = false, officialUi = [], observeCorrelation = false) {
9
+ assert(!additionalApis.includes('pageScrollTo'),'Official scroll cannot enter renderer Mock list');
10
+ assert(officialUi.every(api=>nativeUiApis.includes(api)),'Finite official UI delegation list');
11
+ const mockedApis = ['makePhoneCall','tradePay','getAuthCode','startApp','exitMiniProgram','exitApp', ...additionalApis,...(additionalApis.includes('getStorage')?['getTinyLocalStorage']:[]),...(additionalApis.includes('setStorage')?['setTinyLocalStorage']:[])];
12
+ return `window.lyraRendererExtendBridge.beforeMount(params => {
13
+ const hostFixture=${JSON.stringify(hostDeviceFixture)};
14
+ const mounted=(${configureHostDeviceMount.toString()})(params,hostFixture);
15
+ const hostEvidence=(${hostDeviceMountEvidence.toString()})(params,mounted,hostFixture);
16
+ const hostObserver=window.top?.__billHostDeviceFixture;
17
+ if(typeof hostObserver==='function')Promise.resolve(hostObserver(hostEvidence));
18
+ const officialRequests=new WeakMap(),bridgeIds=new WeakMap();let bridgeSequence=0;
19
+ const traceBridge=(kind,api,option,target)=>{if(!${observeCorrelation})return;try{
20
+ const observer=window.top.__billPublicApiTrace;if(typeof observer!=='function')return;
21
+ if(option&&typeof option==='object'&&!bridgeIds.has(option))bridgeIds.set(option,++bridgeSequence);
22
+ Promise.resolve(observer({kind,bridgeId:bridgeIds.get(option)??null,at:Date.now(),api,option,officialTarget:{pageUrl:target?.pageUrl,trAppId:target?.trAppId,trPageId:target?.trPageId,contextType:target?.contextType}})).catch(()=>{});
23
+ }catch{}};
24
+ return ({
25
+ ...mounted,
26
+ afterBridgeInvokeAsync: async (api, option, target, result) => {
27
+ traceBridge('bridge-after',api,option,target);
28
+ if(${JSON.stringify(officialUi)}.includes(api)){
29
+ const requestOption=officialRequests.get(option);
30
+ if(!requestOption)throw Error('Official UI result lost original invocation identity');
31
+ officialRequests.delete(option);
32
+ const response=await fetch(${JSON.stringify(origin+nativeUiResultPath)},{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({api,option:requestOption,actualResultOption:option,officialTarget:{pageUrl:target?.pageUrl,trAppId:target?.trAppId,trPageId:target?.trPageId,contextType:target?.contextType},resultPresent:result!==undefined,...(result!==undefined?{result}:{}),at:Date.now()})});
33
+ if(!response.ok)throw Error('Official UI result rejected');
34
+ }
35
+ ${observeLoading ? `
36
+ if(['showLoading','hideLoading'].includes(api))console.log('LOADING-OBS '+JSON.stringify({at:Date.now(),frame:location.href,kind:'api-after',api,option,target,result}));
37
+ ` : ''}
38
+ return result;
39
+ },
40
+ beforeBridgeInvokeAsync: async (api, option, target) => {
41
+ traceBridge('bridge-before',api,option,target);
42
+ ${observeLoading ? "if(['showLoading','hideLoading'].includes(api))console.log('LOADING-OBS '+JSON.stringify({at:Date.now(),frame:location.href,kind:'api-before',api,option,target}));" : ''}
43
+ if (!${JSON.stringify(mockedApis)}.includes(api)&&!${JSON.stringify(officialUi)}.includes(api)) return undefined;
44
+ const response = await fetch(${JSON.stringify(origin + jsapiPath)}, {
45
+ method:'POST', headers:{'content-type':'application/json'},
46
+ body:JSON.stringify({api,option,officialTarget:{pageUrl:target?.pageUrl,trAppId:target?.trAppId,trPageId:target?.trPageId,contextType:target?.contextType}})
47
+ });
48
+ if(response.status===204&&(api==='pushWindow'||${JSON.stringify(officialUi)}.includes(api))){
49
+ if(${JSON.stringify(officialUi)}.includes(api))officialRequests.set(option,JSON.parse(JSON.stringify(option)));
50
+ return undefined;
51
+ }
52
+ if (!response.ok) return {error:99999,errorMessage:'test boundary rejected native call'};
53
+ return await response.json();
54
+ }
55
+ });});`;
56
+ }
57
+ // Official dynamic-component fixture proves the SDK adds this app scope.
58
+ // Keep the raw option in wire evidence; all business keys remain exact.
59
+ export function businessJsapiOption(option) {
60
+ if (option && typeof option === 'object' && !Array.isArray(option)
61
+ && Object.hasOwn(option, '__appxDomain') && option.__appxDomain === 'app') {
62
+ const {__appxDomain, ...business} = option;
63
+ return business;
64
+ }
65
+ return option;
66
+ }
67
+ export async function fulfillJsapi(route, plan) {
68
+ const request = route.request();
69
+ const wire = request.postDataJSON();
70
+ const call=decodeNativeStorage(wire.api,businessJsapiOption(wire.option));
71
+ let result = await plan.receive(`jsapi:${call.api}`, call.params, wire);
72
+ if(wire.api==='getTinyLocalStorage'&&Object.hasOwn(result??{},'data'))result={...result,data:JSON.stringify({APDataStorage:result.data})};
73
+ await route.fulfill({json:result});
74
+ plan.markDelivered(`jsapi:${call.api}`,call.params);
75
+ }
76
+
77
+ // Official @ali/antcube-appx-bridge-my root/storage/boost/impl.js maps
78
+ // public storage methods to native TinyLocalStorage and adds scope metadata.
79
+ export function decodeNativeStorage(api,params){
80
+ if(!['getTinyLocalStorage','setTinyLocalStorage'].includes(api))return {api,params};
81
+ assert.equal(params.type,'user','Native storage user scope');
82
+ if(api==='getTinyLocalStorage'){
83
+ assert.deepEqual(Object.keys(params).sort(),['isAppFirstPage','key','type']);
84
+ assert.equal(typeof params.isAppFirstPage,'boolean');
85
+ return {api:'getStorage',params:{key:params.key}};
86
+ }
87
+ assert(Object.keys(params).every(k=>['key','type','data','value'].includes(k)),'Unexpected native storage fields');
88
+ assert.equal(typeof params.data,'string');
89
+ if(Object.hasOwn(params,'value'))assert.equal(params.value,params.data,'Native storage duplicate encoding mismatch');
90
+ const parsed=JSON.parse(params.data);assert.deepEqual(Object.keys(parsed),['APDataStorage']);
91
+ return {api:'setStorage',params:{key:params.key,data:parsed.APDataStorage}};
92
+ }