@antprofuse/saddle-islands-tools 0.0.0-stage → 0.1.3
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 +35 -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 +42 -0
- package/tools/docs/backend-testing.md +36 -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/README.md +0 -3
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Explicit project input contract; never infer a business checkout from tool paths."""
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
def load_project(root, file):
|
|
6
|
+
root, file = Path(root).resolve(), Path(file).resolve()
|
|
7
|
+
if not file.is_relative_to(root): raise ValueError('Config must belong to explicit project')
|
|
8
|
+
config = json.loads(file.read_text())
|
|
9
|
+
if config.get('schemaVersion') != 1: raise ValueError('Explicit supported config required')
|
|
10
|
+
for name in ['repositoryRoot', 'pageRoute', 'pluginSource', 'localComponentPackages']:
|
|
11
|
+
if name not in config: raise ValueError('Missing project config: '+name)
|
|
12
|
+
source = (root / config['pluginSource']).resolve()
|
|
13
|
+
if not source.is_relative_to(root) or not source.is_dir(): raise ValueError('Declared plugin source required')
|
|
14
|
+
return config
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {createHash} from 'node:crypto';
|
|
5
|
+
import {expect} from '@playwright/test';
|
|
6
|
+
// Exact property sets of the frozen Islands public test matchers (generate-tests guide).
|
|
7
|
+
export const APPEARANCE_PROPERTIES = Object.freeze(['color', 'font-family', 'font-size', 'font-weight', 'font-style', 'font-stretch', 'font-variant-caps', 'line-height', 'letter-spacing', 'word-spacing', 'text-align', 'text-indent', 'text-transform', 'white-space', 'word-break', 'overflow-wrap', 'text-overflow', 'text-decoration-line', 'text-decoration-style', 'text-decoration-color', 'text-decoration-thickness', 'text-shadow', 'background-color', 'background-image', 'background-position-x', 'background-position-y', 'background-size', 'background-repeat', 'background-origin', 'background-clip', 'background-attachment', 'border-top-style', 'border-right-style', 'border-bottom-style', 'border-left-style', 'border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color', 'border-top-left-radius', 'border-top-right-radius', 'border-bottom-right-radius', 'border-bottom-left-radius', 'box-shadow', 'opacity', 'visibility', 'filter', 'mix-blend-mode', 'object-fit', 'object-position']);
|
|
8
|
+
export const BOX_PROPERTIES = Object.freeze(['box-sizing', 'width', 'height', 'min-width', 'max-width', 'min-height', 'max-height', 'aspect-ratio', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width', 'display', 'position', 'top', 'right', 'bottom', 'left', 'z-index', 'float', 'clear', 'overflow-x', 'overflow-y', 'transform', 'transform-origin', 'flex-direction', 'flex-wrap', 'flex-grow', 'flex-shrink', 'flex-basis', 'order', 'justify-content', 'align-content', 'align-items', 'align-self', 'row-gap', 'column-gap', 'grid-template-columns', 'grid-template-rows', 'grid-template-areas', 'grid-auto-flow', 'grid-auto-columns', 'grid-auto-rows', 'grid-column-start', 'grid-column-end', 'grid-row-start', 'grid-row-end', 'justify-items', 'justify-self', 'writing-mode', 'direction']);
|
|
9
|
+
async function prepare(locator){
|
|
10
|
+
await expect(locator).toBeVisible();
|
|
11
|
+
await locator.evaluate(async el=>{
|
|
12
|
+
await el.ownerDocument.fonts.ready;
|
|
13
|
+
for(const animation of el.getAnimations({subtree:true}))animation.pause();
|
|
14
|
+
const images=[...(el.tagName==='IMG'?[el]:[]),...el.querySelectorAll('img')];
|
|
15
|
+
for(const img of images){await img.decode();if(!img.naturalWidth)throw Error('Visual image is not decoded');}
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
async function compareStyles(actual,design,properties){
|
|
19
|
+
const count=await design.count();assert.ok(count>0,'Missing independent design instance');
|
|
20
|
+
await expect(actual).toHaveCount(count);
|
|
21
|
+
const records=[];
|
|
22
|
+
for(let index=0;index<count;index++){
|
|
23
|
+
const a=actual.nth(index),d=design.nth(index);await prepare(a);await prepare(d);
|
|
24
|
+
const read=(locator)=>locator.evaluate((el,keys)=>{const style=getComputedStyle(el);return Object.fromEntries(keys.map(key=>[key,style.getPropertyValue(key).trim()]));},properties);
|
|
25
|
+
const [got,wanted]=await Promise.all([read(a),read(d)]);records.push({index,actual:got,design:wanted});
|
|
26
|
+
expect(got).toEqual(wanted);
|
|
27
|
+
}
|
|
28
|
+
return records;
|
|
29
|
+
}
|
|
30
|
+
export const assertPrototypeAppearance=(actual,design)=>compareStyles(actual,design,APPEARANCE_PROPERTIES);
|
|
31
|
+
export const assertPrototypeBoxStyle=(actual,design)=>compareStyles(actual,design,BOX_PROPERTIES);
|
|
32
|
+
// Each side is measured in its own document viewport CSS pixels. Playwright
|
|
33
|
+
// boundingBox is in the top-level host space and includes iframe offsets/scale.
|
|
34
|
+
export async function documentBox(locator){
|
|
35
|
+
await expect(locator).toHaveCount(1);await expect(locator).toBeVisible();
|
|
36
|
+
return locator.evaluate(async el=>{
|
|
37
|
+
await el.ownerDocument.fonts.ready;
|
|
38
|
+
const {x,y,width,height}=el.getBoundingClientRect();return {x,y,width,height};
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
export async function relativeDocumentBox(parent,child){
|
|
42
|
+
await expect(parent).toHaveCount(1);await expect(parent).toBeVisible();
|
|
43
|
+
await expect(child).toHaveCount(1);await expect(child).toBeVisible();
|
|
44
|
+
const handle=await child.elementHandle();assert.ok(handle,'Missing relative child');
|
|
45
|
+
try{return await parent.evaluate(async(el,child)=>{
|
|
46
|
+
if(el.ownerDocument!==child.ownerDocument||!el.contains(child))throw Error('Relative geometry requires parent and child in the same document');
|
|
47
|
+
await el.ownerDocument.fonts.ready;
|
|
48
|
+
const origin=el.getBoundingClientRect(),box=child.getBoundingClientRect();
|
|
49
|
+
return {x:box.x-origin.x,y:box.y-origin.y,width:box.width,height:box.height};
|
|
50
|
+
},handle);}finally{await handle.dispose();}
|
|
51
|
+
}
|
|
52
|
+
export async function assertPrototypeGeometry(actual,design,{actualParent,designParent}={}){
|
|
53
|
+
assert.equal(Boolean(actualParent),Boolean(designParent),'Both relative parents are required');
|
|
54
|
+
const [actualBox,sourceBox]=await Promise.all(actualParent
|
|
55
|
+
?[relativeDocumentBox(actualParent,actual),relativeDocumentBox(designParent,design)]
|
|
56
|
+
:[documentBox(actual),documentBox(design)]);
|
|
57
|
+
for(const key of ['x','y','width','height'])expect(Math.abs(actualBox[key]-sourceBox[key]),`Document CSS pixel ${key}: ${actualBox[key]} vs ${sourceBox[key]}`).toBeLessThanOrEqual(0.5);
|
|
58
|
+
return {actualBox,sourceBox};
|
|
59
|
+
}
|
|
60
|
+
export async function openPrototypeReference(productPage,anchor,{referencePath=process.env.MINIFISH_DESIGN_REFERENCE_HTML,imageUrls=[]}={}){
|
|
61
|
+
assert.ok(referencePath,'Configure MINIFISH_DESIGN_REFERENCE_HTML from build-prototype-reference.py; missing design cannot pass');
|
|
62
|
+
const html=await fs.readFile(referencePath,'utf8');
|
|
63
|
+
const manifest=JSON.parse(await fs.readFile(path.join(path.dirname(referencePath),'manifest.json'),'utf8'));
|
|
64
|
+
assert.equal(manifest.kind,'frozen-prototype-reference-v1');
|
|
65
|
+
assert.equal(createHash('sha256').update(html).digest('hex'),manifest.htmlSha256,'Design artifact changed after source build');
|
|
66
|
+
const viewport=await anchor.evaluate(el=>({width:el.ownerDocument.defaultView.innerWidth,height:el.ownerDocument.defaultView.innerHeight}));
|
|
67
|
+
assert.ok(viewport.width>0&&viewport.height>0,'Missing fixed product viewport');
|
|
68
|
+
const context=await productPage.context().browser().newContext({viewport});
|
|
69
|
+
try{
|
|
70
|
+
const allowedImages=new Set(imageUrls.map(value=>new URL(value).href)),materialRequests=[];
|
|
71
|
+
const page=await context.newPage();await page.route('**/*',async route=>{
|
|
72
|
+
const request=route.request(),allowed=allowedImages.has(request.url())&&request.method()==='GET'&&request.resourceType()==='image';
|
|
73
|
+
materialRequests.push({url:request.url(),method:request.method(),resourceType:request.resourceType(),action:allowed?'allow-exact-image':'abort'});
|
|
74
|
+
return allowed?route.fallback():route.abort();
|
|
75
|
+
});
|
|
76
|
+
await page.setContent(html,{waitUntil:'load'});await page.evaluate(()=>document.fonts.ready);
|
|
77
|
+
await page.addStyleTag({content:'*,*::before,*::after{animation:none!important;transition:none!important}'});
|
|
78
|
+
return {page,viewport,manifest,materialRequests,async point(name,text){
|
|
79
|
+
const node=page.locator(`[data-visual-point$=" / ${name}"]`);await expect(node).toHaveCount(1);
|
|
80
|
+
if(text!==undefined)await node.evaluate((el,value)=>{if(el.childElementCount!==0)throw Error('Design text slot is not a unique leaf');el.textContent=value;},text);
|
|
81
|
+
return node;
|
|
82
|
+
},close:()=>context.close()};
|
|
83
|
+
}catch(error){await context.close();throw error;}
|
|
84
|
+
}
|
|
85
|
+
// Callers must enumerate all direct semantic child components from the public source.
|
|
86
|
+
// Selectors may differ between product and Prototype; IDs must describe the same component.
|
|
87
|
+
export async function assertPrototypeChildrenLayout(actual,design,children){
|
|
88
|
+
assert.ok(children.length>0,'No public direct-child mapping');
|
|
89
|
+
assert.equal(new Set(children.map(c=>c.point)).size,children.length,'Duplicate semantic child mapping');
|
|
90
|
+
const count=await design.count();assert.ok(count>0,'Missing independent design parent');await expect(actual).toHaveCount(count);
|
|
91
|
+
const records=[];
|
|
92
|
+
for(let index=0;index<count;index++){
|
|
93
|
+
const a=actual.nth(index),d=design.nth(index);await prepare(a);await prepare(d);
|
|
94
|
+
const read=(parent,side)=>parent.evaluate((el,mapping)=>{
|
|
95
|
+
const origin=el.getBoundingClientRect(),entries=[];
|
|
96
|
+
for(const {point,selector}of mapping)for(const node of el.querySelectorAll(selector)){
|
|
97
|
+
const rect=node.getBoundingClientRect(),style=getComputedStyle(node);
|
|
98
|
+
const box=style.display==='none'||style.visibility==='hidden'||!node.getClientRects().length?null:{x:rect.x-origin.x,y:rect.y-origin.y,width:rect.width,height:rect.height};
|
|
99
|
+
entries.push({point,node,box});
|
|
100
|
+
}
|
|
101
|
+
if(new Set(entries.map(e=>e.node)).size!==entries.length)throw Error('Overlapping semantic selectors');
|
|
102
|
+
// Every mapped component must exist in the independent reference, including hidden carriers.
|
|
103
|
+
for(const {point}of mapping)if(!entries.some(e=>e.point===point))throw Error('Missing mapped semantic child '+point);
|
|
104
|
+
const covered=node=>entries.some(e=>e.node===node||e.node.contains(node));
|
|
105
|
+
const candidates=[...el.querySelectorAll('*')].filter(node=>{
|
|
106
|
+
if(covered(node))return false;
|
|
107
|
+
if(node.hasAttribute('data-visual-point'))return true;
|
|
108
|
+
return !node.children.length&&(node.textContent.trim()||['IMG','INPUT','BUTTON','SELECT','TEXTAREA'].includes(node.tagName));
|
|
109
|
+
});
|
|
110
|
+
if(candidates.length)throw Error('Unmapped semantic or content-bearing child');
|
|
111
|
+
entries.sort((l,r)=>l.node===r.node?0:l.node.compareDocumentPosition(r.node)&Node.DOCUMENT_POSITION_FOLLOWING?-1:1);
|
|
112
|
+
return entries.map(({point,box})=>({point,box}));
|
|
113
|
+
},children.map(c=>({point:c.point,selector:c[side]})));
|
|
114
|
+
const [got,wanted]=await Promise.all([read(a,'actual'),read(d,'design')]);
|
|
115
|
+
expect(got.map(c=>c.point)).toEqual(wanted.map(c=>c.point));
|
|
116
|
+
for(let i=0;i<wanted.length;i++){
|
|
117
|
+
if(!wanted[i].box||!got[i].box){expect(got[i].box).toEqual(wanted[i].box);continue;}
|
|
118
|
+
for(const key of ['x','y','width','height'])expect(Math.abs(got[i].box[key]-wanted[i].box[key])).toBeLessThanOrEqual(0.5);
|
|
119
|
+
}
|
|
120
|
+
records.push({index,actual:got,design:wanted});
|
|
121
|
+
}
|
|
122
|
+
return records;
|
|
123
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {expect} from '@playwright/test';
|
|
3
|
+
import {semanticLocator,anchorSelector} from './semantic-anchor.mjs';
|
|
4
|
+
import {nativeMetrics} from './independent-visual.mjs';
|
|
5
|
+
export function validateRegionOrder(above,below){
|
|
6
|
+
for(const box of [above,below])assert([box.x,box.y,box.width,box.height].every(Number.isFinite)&&box.width>0&&box.height>0,'Two painted region boxes required');
|
|
7
|
+
assert(above.y<below.y,'Source region order');
|
|
8
|
+
assert(above.y+above.height<=below.y+.25,'Source regions must not overlap');
|
|
9
|
+
return {above,below,gap:below.y-(above.y+above.height)};
|
|
10
|
+
}
|
|
11
|
+
export async function observeRegionOrder(root,{above,below}){
|
|
12
|
+
const first=semanticLocator(root,above),second=semanticLocator(root,below);
|
|
13
|
+
await expect(first).toHaveCount(1);await expect(second).toHaveCount(1);
|
|
14
|
+
await expect(first).toBeVisible();await expect(second).toBeVisible();
|
|
15
|
+
return {...validateRegionOrder((await nativeMetrics(first)).box,(await nativeMetrics(second)).box),sourcePaths:{above,below},responsibility:'project-supplemental-layout; excluded from Islands denominator'};
|
|
16
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
const collectors=new WeakMap();
|
|
3
|
+
const overrides=new WeakMap();
|
|
4
|
+
export function registerRendererImageEvidence(page,records){if(records===undefined)overrides.delete(page);else overrides.set(page,records);}
|
|
5
|
+
export function rendererImageFrameId(page,frame){return collectors.get(page)?.frameId(frame);}
|
|
6
|
+
export function rendererImageEvidence(page){return overrides.get(page)??collectors.get(page)?.records;}
|
|
7
|
+
// Install before product navigation. Decode original response bytes in their own frame;
|
|
8
|
+
// blob URLs do not issue a second material request.
|
|
9
|
+
export function installRendererImageEvidence(page){
|
|
10
|
+
if(collectors.has(page))throw Error('Renderer collector already installed');
|
|
11
|
+
const records=[],pending=new Set(),frameIds=new WeakMap();let nextFrameId=0;
|
|
12
|
+
const frameId=frame=>{if(!frameIds.has(frame))frameIds.set(frame,++nextFrameId);return frameIds.get(frame);};
|
|
13
|
+
const listener=response=>{
|
|
14
|
+
if(response.request().resourceType()!=='image')return;
|
|
15
|
+
const frame=response.frame(),record={url:response.url(),frame:frame.url(),frameId:frameId(frame),status:response.status(),contentType:response.headers()['content-type'],observedAt:new Date().toISOString()};
|
|
16
|
+
records.push(record);
|
|
17
|
+
const job=(async()=>{try{
|
|
18
|
+
const bytes=await response.body();record.bytes=bytes.length;record.sha256=crypto.createHash('sha256').update(bytes).digest('hex');
|
|
19
|
+
record.decoded=await frame.evaluate(async({base64,type})=>{
|
|
20
|
+
const bytes=Uint8Array.from(atob(base64),c=>c.charCodeAt(0));
|
|
21
|
+
const url=URL.createObjectURL(new Blob([bytes],{type}));
|
|
22
|
+
try{const image=new Image();image.src=url;await image.decode();return {width:image.naturalWidth,height:image.naturalHeight};}finally{URL.revokeObjectURL(url);}
|
|
23
|
+
},{base64:bytes.toString('base64'),type:record.contentType});
|
|
24
|
+
record.decodedAt=new Date().toISOString();
|
|
25
|
+
}catch(error){record.decodeError=error.message;}})();
|
|
26
|
+
pending.add(job);job.finally(()=>pending.delete(job));
|
|
27
|
+
};
|
|
28
|
+
page.on('response',listener);
|
|
29
|
+
const collector={records,frameId,async snapshot(){while(pending.size)await Promise.all([...pending]);return structuredClone(records);},close(){page.off('response',listener);collectors.delete(page);overrides.delete(page);}};
|
|
30
|
+
collectors.set(page,collector);return collector;
|
|
31
|
+
}
|
|
32
|
+
export async function captureRendererImageEvidence(page){const c=collectors.get(page);if(!c)throw Error('Renderer collector not installed before product start');return c.snapshot();}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {rpcTransportFailure} from './native-rpc-failure.mjs';
|
|
3
|
+
import {validateNativeUiResult} from './native-ui-contract.mjs';
|
|
4
|
+
export function createBoundary(definitions,policy) {
|
|
5
|
+
const {validate:validateIndependentFixture,validatePlan:validatePacketPlan,comparison,requiresConsumption:requiresSourceConsumption,assertPacketCompletion}=policy;
|
|
6
|
+
assert(Object.values(policy).every(v=>typeof v==='function'),'Explicit boundary policy required');
|
|
7
|
+
assert(new Set(definitions.map(d=>d.id)).size===definitions.length,'Duplicate boundary id');
|
|
8
|
+
validatePacketPlan(structuredClone(definitions));
|
|
9
|
+
const rows=definitions.map(item=>{assert(Number.isSafeInteger(item.times)&&item.times>0,'Positive exact request count required');validateIndependentFixture(structuredClone(item));return {...structuredClone(item),received:0,delivered:0,consumed:0,cancelled:0,released:false,callbacks:[]};});
|
|
10
|
+
const requiresConsumption=r=>!!r.requireConsumption||!!requiresSourceConsumption(structuredClone(r));
|
|
11
|
+
const attempts=[],violations=[],pending=new Map();let closed=false;
|
|
12
|
+
const effective=r=>r.outcome.kind==='hold'?r.released?r.outcome.release:null:r.outcome;
|
|
13
|
+
const result=r=>{const answer=effective(r);assert(answer,'Held response not released');return answer.kind==='error'&&!r.request.operation.startsWith('jsapi:')?rpcTransportFailure(answer.value.error,answer.value.errorMessage):structuredClone(answer.value);};
|
|
14
|
+
const desired=r=>r.outcome.kind==='hold'&&!r.released?0:r.times;
|
|
15
|
+
// deep equality ignores JSON key ordering, while preserving all actual evidence.
|
|
16
|
+
const equal=(o,a,b)=>{try{assert.deepEqual(comparison(o,structuredClone(a)),comparison(o,structuredClone(b)));return true;}catch{return false;}};
|
|
17
|
+
const select=(o,q,predicate)=>rows.find(r=>r.request.operation===o&&predicate(r)&&equal(o,q,r.request.params));
|
|
18
|
+
return {
|
|
19
|
+
async receive(operation,request,wire){
|
|
20
|
+
attempts.push(structuredClone({operation,request,...(wire!==undefined?{wire}:{})}));
|
|
21
|
+
try {
|
|
22
|
+
const row=select(operation,request,r=>r.received<r.times);
|
|
23
|
+
assert(row,`Unexpected operation, parameters or duplicate call: ${operation}`);
|
|
24
|
+
for(const id of row.after??[]){const prior=rows.find(r=>r.id===id);assert(prior&&prior.delivered===prior.times,`Boundary before required response ${id}`);}
|
|
25
|
+
assert(!closed,'Boundary is closed');assert(!row.execution,'Official execution cannot receive a Mock response');row.received++;
|
|
26
|
+
if(row.outcome.kind==='hold')await new Promise((resolve,reject)=>pending.set(row.id,[...(pending.get(row.id)??[]),{resolve,reject,row}]));
|
|
27
|
+
return result(row);
|
|
28
|
+
}catch(e){if(e.message!=='Controlled held call cancelled at case cleanup')violations.push(e.message);throw e;}
|
|
29
|
+
},
|
|
30
|
+
recordOfficialViolation(message){violations.push(message);throw Error(message);},
|
|
31
|
+
recordNativeUiRequest(operation,request,wire){try{
|
|
32
|
+
assert(!closed,'Boundary is closed');const r=select(operation,request,r=>r.execution==='official-ui'&&r.received===0);assert(r,'Unexpected, wrong or duplicate official UI request');
|
|
33
|
+
for(const id of r.after??[]){const prior=rows.find(r=>r.id===id);assert(prior?.delivered===prior.times,'Official UI before '+id);}
|
|
34
|
+
r.received++;attempts.push(structuredClone({operation,request,wire,execution:'official-ui'}));return r.id;
|
|
35
|
+
}catch(e){violations.push(e.message);throw e;}},
|
|
36
|
+
recordNativeUiResult(operation,request,receipt){try{
|
|
37
|
+
attempts.push(structuredClone({operation,request,execution:'official-ui-result',receipt}));
|
|
38
|
+
validateNativeUiResult(receipt);const r=select(operation,request,r=>r.execution==='official-ui'&&r.received===1&&r.delivered===0);assert(r,'Native UI result missing request or duplicated');
|
|
39
|
+
const actualRequest=attempts.find(a=>a.execution==='official-ui'&&a.operation===operation&&equal(operation,a.request,r.request.params));
|
|
40
|
+
assert.deepEqual(receipt.officialTarget,actualRequest.wire.officialTarget,'Actual result must belong to the same official host request');
|
|
41
|
+
r.delivered++;r.officialResult=structuredClone(receipt);
|
|
42
|
+
}catch(e){violations.push(e.message);throw e;}},
|
|
43
|
+
recordOfficialRequest(operation,request){try{assert(!closed,'Boundary is closed');const r=select(operation,request,r=>r.execution==='official-scroll'&&r.received===0);assert(r,'Unexpected official scroll request');for(const id of r.after??[]){const prior=rows.find(r=>r.id===id);assert(prior?.delivered===prior.times,'Official scroll before '+id);}r.received++;attempts.push(structuredClone({operation,request,execution:'official-scroll'}));}catch(e){violations.push(e.message);throw e;}},
|
|
44
|
+
recordOfficialCallback(operation,request,callback){try{const r=select(operation,request,r=>r.execution==='official-scroll'&&r.received===1&&r.delivered===0);assert(r,'Official callback without request or duplicate');assert.equal(callback?.kind,'success','Official scroll success required');assert.deepEqual(callback.value,r.outcome.value,'Actual official scroll result');r.delivered++;r.consumed++;r.callbacks.push(structuredClone(callback));}catch(e){violations.push(e.message);throw e;}},
|
|
45
|
+
markDelivered(operation,request){try{const r=select(operation,request,r=>r.delivered<r.received&&effective(r)&&!r.cancelled);assert(r,'Unexpected response delivery');r.delivered++;}catch(e){violations.push(e.message);throw e;}},
|
|
46
|
+
markConsumed(operation,request,callback){try{const r=select(operation,request,r=>r.consumed<r.received&&effective(r)&&!r.cancelled);assert(r,'Unexpected response consumption');const expected=effective(r).kind==='error'||effective(r).value?.$testTransportFailure?'fail':'success';if(r.requireConsumption||callback?.kind)assert.equal(callback?.kind,expected,'Unexpected callback type');r.consumed++;r.callbacks.push(structuredClone(callback??{}));}catch(e){violations.push(e.message);throw e;}},
|
|
47
|
+
releaseBoundary(id){
|
|
48
|
+
try{assert(!closed,'Boundary is closed');const r=rows.find(r=>r.id===id),p=pending.get(id);
|
|
49
|
+
assert(r&&r.outcome.kind==='hold'&&r.outcome.release,'Unknown or unreleasable boundary');
|
|
50
|
+
assert(!r.released&&!r.cancelled,'Boundary already released or cancelled');assert(p&&r.received===1,'Held invocation not received');
|
|
51
|
+
r.released=true;pending.delete(id);p[0].resolve();
|
|
52
|
+
}catch(error){violations.push(error.message);throw error;}
|
|
53
|
+
},
|
|
54
|
+
assertPacketComplete(){assertPacketCompletion({violations,expectations:rows});},
|
|
55
|
+
assertComplete(){assert.deepEqual(violations,[]);for(const r of rows){assert.equal(r.received,r.times,`Missing ${r.id}`);assert.equal(r.delivered,desired(r),`Unexpected delivery state ${r.id}`);if(requiresConsumption(r))assert.equal(r.consumed,desired(r),`Missing public callback ${r.id}`);}},
|
|
56
|
+
assertArtifactComplete(skipped){
|
|
57
|
+
assert(Array.isArray(skipped)&&skipped.length&&new Set(skipped).size===skipped.length,'Explicit unique skipped artifact boundaries required');
|
|
58
|
+
assert(skipped.every(id=>rows.some(r=>r.id===id)),'Unknown skipped artifact boundary');assert.deepEqual(violations,[]);
|
|
59
|
+
for(const r of rows){if(skipped.includes(r.id)){assert.equal(r.received,0,'Artifact must not perform '+r.id);assert.equal(r.delivered,0);assert.equal(r.consumed,0);}else{assert.equal(r.received,r.times,`Missing ${r.id}`);assert.equal(r.delivered,desired(r));if(requiresConsumption(r))assert.equal(r.consumed,desired(r));}}
|
|
60
|
+
return {status:'verified',expectations:rows.map(r=>({id:r.id,status:skipped.includes(r.id)?'not-executed-artifact-only':'completed',received:r.received,delivered:r.delivered,consumed:r.consumed})),violations:[...violations]};
|
|
61
|
+
},
|
|
62
|
+
evidence(){return structuredClone({attempts,violations,expectations:rows});},
|
|
63
|
+
close(){closed=true;for(const {reject,row}of [...pending.values()].flat()){row.cancelled++;reject(Error('Controlled held call cancelled at case cleanup'));}pending.clear();},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
function sameJsonValue(left,right,operation,decimalCreateComparison) {
|
|
3
|
+
try { assert.deepStrictEqual(decimalCreateComparison(operation,left),decimalCreateComparison(operation,right)); return true; } catch { return false; }
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function jsonCopy(value) {
|
|
7
|
+
const encoded = JSON.stringify(value);
|
|
8
|
+
assert.notEqual(encoded, undefined, 'Wire value must be JSON');
|
|
9
|
+
const decoded = JSON.parse(encoded);
|
|
10
|
+
assert.deepStrictEqual(decoded, value, 'Wire value must round-trip without loss');
|
|
11
|
+
return decoded;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Transport adapters must decode their envelope without rewriting business data. */
|
|
15
|
+
export function createRequestPlan(expectations, { purePlatform = false } = {},policy) {
|
|
16
|
+
assert(policy&&typeof policy.validateExpectation==='function','Explicit request schema policy required');
|
|
17
|
+
const {isRetiredOperation,comparison:decimalCreateComparison,gatewayRequestComparison}=policy;
|
|
18
|
+
const ids = new Set();
|
|
19
|
+
const rows = expectations.map((entry) => {
|
|
20
|
+
assert.ok(entry.id && !ids.has(entry.id), 'Expectation IDs must be unique');
|
|
21
|
+
ids.add(entry.id);
|
|
22
|
+
assert.equal(typeof entry.operation, 'string');
|
|
23
|
+
if (purePlatform) assert.ok(!isRetiredOperation(entry.operation), 'Retired operation cannot be expected');
|
|
24
|
+
policy.validateExpectation(entry);
|
|
25
|
+
const row = {
|
|
26
|
+
id: entry.id, operation: entry.operation, request: jsonCopy(entry.request),
|
|
27
|
+
response: jsonCopy(entry.response), after: [...(entry.after ?? [])],
|
|
28
|
+
held: entry.hold === true, status: 'pending', released: false,
|
|
29
|
+
};
|
|
30
|
+
row.delivered=false;row.consumed=false;
|
|
31
|
+
row.delivery=new Promise(resolve=>{row.onDelivery=resolve;});
|
|
32
|
+
row.consumption=new Promise(resolve=>{row.onConsumption=resolve;});
|
|
33
|
+
row.arrival = new Promise((resolve) => { row.onArrival = resolve; });
|
|
34
|
+
row.release = new Promise((resolve) => { row.onRelease = resolve; });
|
|
35
|
+
return row;
|
|
36
|
+
});
|
|
37
|
+
for (const row of rows) for (const id of row.after) {
|
|
38
|
+
assert.ok(ids.has(id) && id !== row.id, 'Invalid request dependency');
|
|
39
|
+
}
|
|
40
|
+
const visited = new Set();
|
|
41
|
+
function visit(row, active = new Set()) {
|
|
42
|
+
assert.ok(!active.has(row.id), 'Cyclic request dependencies');
|
|
43
|
+
if (visited.has(row.id)) return;
|
|
44
|
+
const next = new Set(active).add(row.id);
|
|
45
|
+
for (const id of row.after) visit(rows.find((r) => r.id === id), next);
|
|
46
|
+
visited.add(row.id);
|
|
47
|
+
}
|
|
48
|
+
rows.forEach((row) => visit(row));
|
|
49
|
+
const attempts = [], violations = [];
|
|
50
|
+
let closed = false;
|
|
51
|
+
function get(id) {
|
|
52
|
+
const row = rows.find((r) => r.id === id);
|
|
53
|
+
assert.ok(row, `Unknown expectation: ${id}`);
|
|
54
|
+
return row;
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
async receive(operation, request, wire = null) {
|
|
58
|
+
let row;
|
|
59
|
+
try {
|
|
60
|
+
assert.ok(!closed, 'Request after test boundary closed');
|
|
61
|
+
const actual = jsonCopy(request);
|
|
62
|
+
attempts.push({ operation, request: actual, wire: jsonCopy(wire) });
|
|
63
|
+
if (purePlatform) assert.ok(!isRetiredOperation(operation), `Retired operation forbidden: ${operation}`);
|
|
64
|
+
row = rows.find((r) => r.status === 'pending' && r.operation === operation
|
|
65
|
+
&& (() => { try {
|
|
66
|
+
assert.deepStrictEqual(policy.requestComparison(operation,actual),policy.requestComparison(operation,r.request));
|
|
67
|
+
return true;
|
|
68
|
+
} catch { return false; } })());
|
|
69
|
+
assert.ok(row, `Unexpected, mismatched or duplicate request: ${operation}`);
|
|
70
|
+
for (const id of row.after) assert.equal(get(id).status, 'completed', `Request before ${id} completed`);
|
|
71
|
+
row.actualRequest=actual;
|
|
72
|
+
row.status = 'received';
|
|
73
|
+
row.onArrival();
|
|
74
|
+
if (row.held) await row.release;
|
|
75
|
+
assert.ok(!closed, 'Response cancelled during cleanup');
|
|
76
|
+
row.status = 'completed';
|
|
77
|
+
return jsonCopy(row.response);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (row) row.status = 'failed';
|
|
80
|
+
violations.push(error.message);
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
async waitFor(id, timeoutMs = 5000) {
|
|
85
|
+
let timer;
|
|
86
|
+
try {
|
|
87
|
+
await Promise.race([get(id).arrival, new Promise((_, reject) => {
|
|
88
|
+
timer = setTimeout(() => reject(new Error(`Expected request did not arrive: ${id}`)), timeoutMs);
|
|
89
|
+
})]);
|
|
90
|
+
} finally { clearTimeout(timer); }
|
|
91
|
+
},
|
|
92
|
+
markDelivered(operation,request) {
|
|
93
|
+
const row=rows.find(r=>r.status==='completed'&&!r.delivered&&r.operation===operation&&sameJsonValue(r.actualRequest??r.request,request,operation,decimalCreateComparison));
|
|
94
|
+
assert.ok(row,'Delivery requires a matching completed response');row.delivered=true;row.onDelivery();
|
|
95
|
+
},
|
|
96
|
+
markConsumed(operation,request,callback) {
|
|
97
|
+
const row=rows.find(r=>r.status==='completed'&&!r.consumed&&r.operation===operation&&sameJsonValue(r.actualRequest??r.request,request,operation,decimalCreateComparison));
|
|
98
|
+
assert.ok(row,'Consumption requires a matching completed response');row.consumed=true;if(callback)row.callback=jsonCopy(callback);row.onConsumption();
|
|
99
|
+
},
|
|
100
|
+
async waitForDelivery(id,timeoutMs=10000) {
|
|
101
|
+
let timer;try{await Promise.race([get(id).delivery,new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error(`Response not delivered: ${id}`)),timeoutMs);})]);}finally{clearTimeout(timer);}
|
|
102
|
+
},
|
|
103
|
+
async waitForConsumption(id,timeoutMs=10000) {
|
|
104
|
+
let timer;try{await Promise.race([get(id).consumption,new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error(`Client callback not consumed: ${id}`)),timeoutMs);})]);}finally{clearTimeout(timer);}
|
|
105
|
+
},
|
|
106
|
+
release(id) {
|
|
107
|
+
const row = get(id);
|
|
108
|
+
assert.ok(row.held && row.status === 'received' && !row.released, 'Release requires a received held request');
|
|
109
|
+
row.released = true;
|
|
110
|
+
row.onRelease();
|
|
111
|
+
},
|
|
112
|
+
assertComplete() {
|
|
113
|
+
assert.deepStrictEqual(violations, [], 'Request violations (even if swallowed by product)');
|
|
114
|
+
assert.deepStrictEqual(rows.filter((r) => r.status !== 'completed').map((r) => r.id), [], 'Incomplete requests');
|
|
115
|
+
},
|
|
116
|
+
evidence() {
|
|
117
|
+
return jsonCopy({ attempts, violations, expectations: rows.map(({ id, operation, request, status,delivered,consumed,callback }) => ({ id, operation, request, status,delivered,consumed,...(callback?{callback}:{}) })) });
|
|
118
|
+
},
|
|
119
|
+
close() {
|
|
120
|
+
closed = true;
|
|
121
|
+
for (const row of rows) row.onRelease();
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
|
|
3
|
+
// Observe only the public platform RPC callback. Arguments and results are passed
|
|
4
|
+
// through unchanged; no product module, page method or business state is read.
|
|
5
|
+
export function observePublicRpcCallbacks(officialPlans = []) {
|
|
6
|
+
const correlate=!Array.isArray(officialPlans)&&officialPlans.correlate;
|
|
7
|
+
officialPlans=Array.isArray(officialPlans)?officialPlans:officialPlans.officialPlans;
|
|
8
|
+
const proxies=new WeakMap();let sequence=0,activeCall=null;
|
|
9
|
+
const realm={url:String(globalThis.location?.href??'unknown').replace(/\?[^#]*/g,'?[redacted]'),kind:typeof document==='undefined'?'worker':'window'};
|
|
10
|
+
const trace=event=>{if(!correlate)return;try{Promise.resolve(owner.__billPublicApiTrace(event)).catch(()=>{});}catch{}};
|
|
11
|
+
const owner=globalThis.top??globalThis;
|
|
12
|
+
const defer=globalThis.setTimeout.bind(globalThis);
|
|
13
|
+
const notify=identity=>owner.__billRpcConsumed(identity);
|
|
14
|
+
const wrap=(api,apiPath='my')=>{
|
|
15
|
+
if(!api||typeof api!=='object')return api;
|
|
16
|
+
if(proxies.has(api))return proxies.get(api);
|
|
17
|
+
const proxy=new Proxy(api,{get(target,key){
|
|
18
|
+
const original=Reflect.get(target,key);
|
|
19
|
+
if(key==='ap')return wrap(original,apiPath+'.ap');
|
|
20
|
+
if(typeof original!=='function'||!['call','rpc','showLoading','hideLoading','makePhoneCall','tradePay','getAuthCode','startApp','exitMiniProgram','exitApp','getGradeInfo','getBackgroundFetchData','pushWindow','redirectTo','alert','showToast','getUserInfo','getStorage','setStorage','addToHomeWithGuideDialog','requestSubscribeMessage','pageScrollTo'].includes(key))return original;
|
|
21
|
+
return function(...args){
|
|
22
|
+
const apiName=key==='call'?args[0]:key,options=key==='call'?args[1]:args[0];
|
|
23
|
+
let identity;
|
|
24
|
+
if(apiName==='rpc'&&options?.operationType)identity={operation:options.operationType,request:options.requestData};
|
|
25
|
+
else if(['showLoading','hideLoading','makePhoneCall','tradePay','getAuthCode','startApp','exitMiniProgram','exitApp','getGradeInfo','getBackgroundFetchData','pushWindow','redirectTo','alert','showToast','getUserInfo','getStorage','setStorage','addToHomeWithGuideDialog','requestSubscribeMessage','pageScrollTo'].includes(apiName)){const {success,fail,complete,...request}=options??{};if(request.__appxDomain==='app')delete request.__appxDomain;identity={operation:'jsapi:'+apiName,request};}
|
|
26
|
+
else return original.apply(this===proxy?target:this,args);
|
|
27
|
+
if(correlate){identity.correlation={callId:++sequence,parentCallId:activeCall,realm,apiPath:key==='call'?apiPath+'.call:'+apiName:apiPath+'.'+apiName,optionScope:{appxDomainPresent:Object.hasOwn(options??{},'__appxDomain'),appxDomain:options?.__appxDomain??null},at:Date.now(),sourceStack:String(new Error().stack??'').split('\n').slice(1,9).map(s=>s.replace(/\?[^\s)]*/g,'?[redacted]'))};trace({kind:'public-call',...identity});}
|
|
28
|
+
if(apiName==='pageScrollTo'){
|
|
29
|
+
const same=(a,b)=>JSON.stringify(Object.keys(a).sort().map(k=>[k,a[k]]))===JSON.stringify(Object.keys(b).sort().map(k=>[k,b[k]]));
|
|
30
|
+
const row=officialPlans.find(r=>r.request.operation===identity.operation&&same(r.request.params,identity.request));
|
|
31
|
+
if(!row||row.called){owner.__billOfficialScroll({kind:'violation',message:'Undeclared, wrong or duplicate pageScrollTo'});throw Error('Undeclared, wrong or duplicate pageScrollTo');}
|
|
32
|
+
row.called=true;const started=owner.__billOfficialScroll({kind:'request',at:Date.now(),identity});let seen=false;
|
|
33
|
+
const wrapOfficial=(fn,kind)=>typeof fn!=='function'?fn:function(...values){
|
|
34
|
+
try{return fn.apply(this,values);}finally{
|
|
35
|
+
const receipt={kind:'callback',at:Date.now(),identity:{...identity,callback:{kind,...(values[0]!==undefined?{value:values[0]}:{}),isError:values[0] instanceof Error,errorMessageFromJS:values[0] instanceof Error?values[0].message:null}}};
|
|
36
|
+
if(seen)receipt.kind='violation',receipt.message='Duplicate official scroll callback';seen=true;
|
|
37
|
+
defer(()=>Promise.resolve(started).then(()=>owner.__billOfficialScroll(receipt)),0);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
if(key==='call'){if(args.length>2)args[2]=wrapOfficial(args[2],'success');if(args.length>3)args[3]=wrapOfficial(args[3],'fail');}
|
|
41
|
+
else{args[0]={...options};if(typeof options?.success==='function')args[0].success=wrapOfficial(options.success,'success');if(typeof options?.fail==='function')args[0].fail=wrapOfficial(options.fail,'fail');}
|
|
42
|
+
return original.apply(this===proxy?target:this,args);
|
|
43
|
+
}
|
|
44
|
+
let notified=false;const consumed=()=>{if(!notified){notified=true;defer(()=>notify(identity),0);}};
|
|
45
|
+
const callback=(fn,kind)=>typeof fn!=='function'?fn:function(...values){
|
|
46
|
+
try{return fn.apply(this,values);}finally{
|
|
47
|
+
// Callback Promise continuations run before the following task. Paint is
|
|
48
|
+
// observed in the owning window, since official worker globals omit rAF.
|
|
49
|
+
identity.callback={kind,...(values[0]!==undefined?{value:values[0]}:{}),isError:values[0] instanceof Error,errorMessageFromJS:values[0] instanceof Error?values[0].message:null};
|
|
50
|
+
trace({kind:'public-callback',...identity});
|
|
51
|
+
consumed();
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
if(key!=='call'){
|
|
55
|
+
if(typeof options?.success==='function'||typeof options?.fail==='function'){
|
|
56
|
+
args[0]={...options};if(typeof options.success==='function')args[0].success=callback(options.success,'success');if(typeof options.fail==='function')args[0].fail=callback(options.fail,'fail');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else {if(args.length>2)args[2]=callback(args[2],'success');if(args.length>3)args[3]=callback(args[3],'fail');}
|
|
60
|
+
const previous=activeCall;activeCall=identity.correlation?.callId??previous;
|
|
61
|
+
try{return original.apply(this===proxy?target:this,args);}finally{activeCall=previous;}
|
|
62
|
+
};
|
|
63
|
+
}});
|
|
64
|
+
proxies.set(api,proxy);return proxy;
|
|
65
|
+
};
|
|
66
|
+
const descriptor=Object.getOwnPropertyDescriptor(globalThis,'my');
|
|
67
|
+
if(descriptor&&!descriptor.configurable)throw Error('Public my API cannot be observed');
|
|
68
|
+
let value=descriptor?.value;
|
|
69
|
+
Object.defineProperty(globalThis,'my',{configurable:true,enumerable:true,
|
|
70
|
+
get(){return wrap(descriptor?.get?descriptor.get.call(this):value);},
|
|
71
|
+
set(next){if(descriptor?.set)descriptor.set.call(this,next);else value=next;}});
|
|
72
|
+
}
|
|
73
|
+
export async function installResponseConsumption(page,plan,diagnostics=null){
|
|
74
|
+
const enabled=process.env.MINIFISH_CALLBACK_CORRELATION==='1',events=[];let sequence=0;
|
|
75
|
+
const redact=(value,key='')=>{
|
|
76
|
+
if(/token|password|credential|secret|authorization|cookie|authCode/i.test(key))return '[redacted]';
|
|
77
|
+
if(Array.isArray(value))return value.map(v=>redact(v));
|
|
78
|
+
if(value&&typeof value==='object')return Object.fromEntries(Object.entries(value).map(([k,v])=>[k,redact(v,k)]));
|
|
79
|
+
if(typeof value==='string'&&/url|stack/i.test(key))return value.replace(/\?[^\s)#]*/g,'?[redacted]');
|
|
80
|
+
return value;
|
|
81
|
+
};
|
|
82
|
+
const capture=(kind,value,source)=>{if(!enabled)return null;const event=redact({eventId:++sequence,kind,at:Date.now(),...value,owningFrame:{url:source?.frame?.url?.()??null,name:source?.frame?.name?.()??null}});events.push(event);return event;};
|
|
83
|
+
await page.exposeBinding('__billPublicApiTrace',(source,event)=>{capture(event.kind,event,source);});
|
|
84
|
+
await page.exposeBinding('__billRpcConsumed',(source,identity)=>{
|
|
85
|
+
const event=capture('consumption-notification',{identity},source);
|
|
86
|
+
try{const result=plan.markConsumed(identity.operation,identity.request,identity.callback);if(event)event.accepted=true;return result;}
|
|
87
|
+
catch(error){if(event){event.accepted=false;event.rejection=error.message;}throw error;}
|
|
88
|
+
});
|
|
89
|
+
const official=plan.evidence().expectations.filter(r=>r.execution==='official-scroll');
|
|
90
|
+
await page.exposeBinding('__billOfficialScroll',(_source,receipt)=>{
|
|
91
|
+
diagnostics?.capture('public-scroll-'+receipt.kind,receipt.identity,receipt.at);
|
|
92
|
+
if(receipt.kind==='request')return plan.recordOfficialRequest(receipt.identity.operation,receipt.identity.request);
|
|
93
|
+
if(receipt.kind==='callback')return plan.recordOfficialCallback(receipt.identity.operation,receipt.identity.request,receipt.identity.callback);
|
|
94
|
+
return plan.recordOfficialViolation(receipt.message);
|
|
95
|
+
});
|
|
96
|
+
await page.addInitScript(observePublicRpcCallbacks,{officialPlans:official,correlate:enabled});
|
|
97
|
+
return {transport(wire,frame){capture('transport',{transportId:'transport-'+(sequence+1),wire},{frame});},evidence(){return structuredClone(events);}};
|
|
98
|
+
}
|
|
99
|
+
export async function waitForResponseConsumption(plan,id,timeoutMs=10000,page=null){
|
|
100
|
+
await plan.waitForDelivery(id,timeoutMs);
|
|
101
|
+
await plan.waitForConsumption(id,timeoutMs);
|
|
102
|
+
if(page)await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
|
|
103
|
+
const observation=plan.evidence().expectations.find(row=>row.id===id);
|
|
104
|
+
assert.equal(observation.delivered,true,'Old response must actually be delivered');
|
|
105
|
+
assert.equal(observation.consumed,true,'Old response must reach the public client callback and paint barrier');
|
|
106
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {expect} from '@playwright/test';
|
|
3
|
+
import {platformNavigate} from './navigation.mjs';
|
|
4
|
+
import {localNavigationEvidence} from './local-navigation-evidence.mjs';
|
|
5
|
+
|
|
6
|
+
export function validateLifecycleAction(action,boundaries){
|
|
7
|
+
assert.equal(action.kind,'leave-return');
|
|
8
|
+
assert(typeof action.url==='string'&&/^\/pages\/[\w/-]+(?:\?[^#]*)?$/.test(action.url),'Declared local page route required');
|
|
9
|
+
assert(!/[?&]__appxPageId=/.test(action.url),'Caller cannot allocate official page identity');
|
|
10
|
+
for(const key of ['awayBoundaryIds','returnBoundaryIds']){
|
|
11
|
+
assert(Array.isArray(action[key])&&action[key].length&&new Set(action[key]).size===action[key].length,'Explicit unique '+key);
|
|
12
|
+
for(const id of action[key])assert(boundaries.some(r=>r.id===id),'Declared lifecycle request required');
|
|
13
|
+
}
|
|
14
|
+
assert(!action.awayBoundaryIds.some(id=>action.returnBoundaryIds.includes(id)),'Leaving and returning request batches must be disjoint');
|
|
15
|
+
assert(!Object.hasOwn(action,'target')&&!Object.hasOwn(action,'value'),'Lifecycle is not a target click or injected value');
|
|
16
|
+
}
|
|
17
|
+
export function validateReturnRequests(before,after,ids){
|
|
18
|
+
assert(ids.length&&new Set(ids).size===ids.length,'Unique return request IDs');
|
|
19
|
+
for(const id of ids){const old=before.find(r=>r.id===id),row=after.find(r=>r.id===id);
|
|
20
|
+
assert(old&&row,'Explicit return boundary');assert.equal(old.received,0,'Return batch must not predate leave');
|
|
21
|
+
assert.equal(row.received,row.times,'Every return request must actually happen');assert.equal(row.delivered,row.times,'Return result must actually arrive');
|
|
22
|
+
if(row.requireConsumption)assert.equal(row.consumed,row.times,'Return callback must actually be consumed');
|
|
23
|
+
}
|
|
24
|
+
return ids;
|
|
25
|
+
}
|
|
26
|
+
export async function retainedPageRoundTrip({page,app,boundary,action,queryBoundaryIds}){
|
|
27
|
+
const handle=await app.root.owner().elementHandle();assert(handle,'Current official frame element required');
|
|
28
|
+
const frame=await handle.contentFrame();await handle.dispose();assert(frame&&!frame.isDetached(),'Live current page required');
|
|
29
|
+
const originalUrl=frame.url(),document=await frame.evaluateHandle(()=>document);
|
|
30
|
+
const before=boundary.evidence().expectations;
|
|
31
|
+
try{
|
|
32
|
+
await platformNavigate(page,'navigateTo',action.url);
|
|
33
|
+
assert(!frame.isDetached(),'Leaving must retain the source page instance');
|
|
34
|
+
const wait=async ids=>{for(const id of ids){const row=()=>boundary.evidence().expectations.find(r=>r.id===id);await expect.poll(()=>row().received).toBe(row().times);if(row().outcome.kind!=='hold'){await expect.poll(()=>row().delivered).toBe(row().times);if(row().requireConsumption)await expect.poll(()=>row().consumed).toBe(row().times);}}};
|
|
35
|
+
await wait(action.awayBoundaryIds);
|
|
36
|
+
localNavigationEvidence(page).at(-1).queryBoundaryIds=queryBoundaryIds(action.awayBoundaryIds,boundary.evidence());
|
|
37
|
+
await platformNavigate(page,'navigateBack');
|
|
38
|
+
const currentHandle=await app.root.owner().elementHandle();assert(currentHandle,'Returned current frame required');const current=await currentHandle.contentFrame();await currentHandle.dispose();
|
|
39
|
+
assert.equal(current,frame,'Return must activate the same retained Frame object');assert.equal(frame.url(),originalUrl,'Return must preserve exact public page identity');
|
|
40
|
+
assert.equal(await document.evaluate(doc=>doc===window.document),true,'Return must retain the original document, not reload or reopen it');
|
|
41
|
+
await wait(action.returnBoundaryIds);
|
|
42
|
+
const after=boundary.evidence().expectations;
|
|
43
|
+
const completed=action.returnBoundaryIds.filter(id=>after.find(r=>r.id===id).outcome.kind!=='hold');
|
|
44
|
+
if(completed.length)validateReturnRequests(before,after,completed);
|
|
45
|
+
for(const id of action.returnBoundaryIds){const row=after.find(r=>r.id===id);assert.equal(before.find(r=>r.id===id).received,0,'New return batch required');assert.equal(row.received,row.times);}
|
|
46
|
+
return {url:originalUrl,sameFrame:true,sameDocument:true,awayBoundaryIds:action.awayBoundaryIds,returnBoundaryIds:action.returnBoundaryIds,completed,held:action.returnBoundaryIds.filter(id=>!completed.includes(id))};
|
|
47
|
+
}finally{await document.dispose();}
|
|
48
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { rpcTransportEnvelope } from './native-rpc-failure.mjs';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
// Verified on the official Minifish browser worker, /simulator/mobilegw/rpcProxy.
|
|
5
|
+
export function decodeRpcWire(wire) {
|
|
6
|
+
assert.ok(wire && typeof wire === 'object' && !Array.isArray(wire));
|
|
7
|
+
assert.equal(typeof wire.operationType, 'string');
|
|
8
|
+
assert.ok(wire.operationType.length > 0);
|
|
9
|
+
assert.ok(Array.isArray(wire.requestData), 'requestData must retain its array envelope');
|
|
10
|
+
return { operation: wire.operationType, request: wire.requestData, wire };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function encodeRpcResponse(result) {
|
|
14
|
+
return { success: true, result: {
|
|
15
|
+
headers: { 'request-id': 'minifish-blackbox' },
|
|
16
|
+
data: { resultStatus: 1000, result: JSON.stringify(result) },
|
|
17
|
+
} };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function fulfillRpc(route, plan) {
|
|
21
|
+
const request = route.request();
|
|
22
|
+
const headers = {
|
|
23
|
+
'access-control-allow-origin': request.headers().origin ?? 'null',
|
|
24
|
+
'access-control-allow-credentials': 'true',
|
|
25
|
+
'access-control-allow-headers': request.headers()['access-control-request-headers'] ?? 'content-type',
|
|
26
|
+
'access-control-allow-methods': 'POST, OPTIONS',
|
|
27
|
+
};
|
|
28
|
+
if (request.method() === 'OPTIONS') return route.fulfill({ status: 204, headers });
|
|
29
|
+
assert.equal(request.method(), 'POST');
|
|
30
|
+
const call = decodeRpcWire(request.postDataJSON());
|
|
31
|
+
const result = await plan.receive(call.operation, call.request, call.wire);
|
|
32
|
+
await route.fulfill({ headers, json: rpcTransportEnvelope(result, encodeRpcResponse) });
|
|
33
|
+
plan.markDelivered(call.operation,call.request);
|
|
34
|
+
}
|