@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,52 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {expect} from '@playwright/test';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
// Only call after the case's original readiness checks, immediately before input.
|
|
8
|
+
// Resolve the active SDK frame afresh on every poll; never dismiss its UI.
|
|
9
|
+
export function validateNativeInputEvidence(e){
|
|
10
|
+
assert(e.business.visible&&e.business.hit,'Actual business-frame target or descendant must receive visible input');
|
|
11
|
+
for(const row of e.parents)assert(row.visible&&row.hit,'Every parent frame must hit the corresponding iframe');
|
|
12
|
+
return e;
|
|
13
|
+
}
|
|
14
|
+
export async function nativeInputEvidence(target){
|
|
15
|
+
const handle=await target.elementHandle();if(!handle)throw Error('Native input target absent');
|
|
16
|
+
try{
|
|
17
|
+
let frame=await handle.ownerFrame();if(!frame)throw Error('Business frame unavailable');
|
|
18
|
+
const business=await handle.evaluate(el=>{const r=el.getBoundingClientRect(),point={x:r.x+r.width/2,y:r.y+r.height/2};let visible=r.width>0&&r.height>0&&point.x>=0&&point.y>=0&&point.x<innerWidth&&point.y<innerHeight;for(let p=el;p;p=p.parentElement){const s=getComputedStyle(p);visible&&=s.display!=='none'&&s.visibility==='visible'&&Number(s.opacity)>0;}const hit=document.elementFromPoint(point.x,point.y);return {point,viewport:{width:innerWidth,height:innerHeight},visible,hit:!!hit&&(hit===el||el.contains(hit)),frame:location.href};});
|
|
19
|
+
const parents=[];let projection=business;
|
|
20
|
+
while(frame.parentFrame()){
|
|
21
|
+
const iframe=await frame.frameElement();try{
|
|
22
|
+
projection=await iframe.evaluate((el,child)=>{const r=el.getBoundingClientRect(),sx=r.width/el.offsetWidth,sy=r.height/el.offsetHeight;const point={x:r.x+(el.clientLeft+child.point.x*el.clientWidth/child.viewport.width)*sx,y:r.y+(el.clientTop+child.point.y*el.clientHeight/child.viewport.height)*sy};let visible=Number.isFinite(point.x)&&Number.isFinite(point.y)&&r.width>0&&r.height>0&&point.x>=0&&point.y>=0&&point.x<innerWidth&&point.y<innerHeight;for(let p=el;p;p=p.parentElement){const s=getComputedStyle(p);visible&&=s.display!=='none'&&s.visibility==='visible'&&Number(s.opacity)>0;}return {point,viewport:{width:innerWidth,height:innerHeight},visible,hit:document.elementFromPoint(point.x,point.y)===el,frame:location.href};},projection);
|
|
23
|
+
parents.push(projection);
|
|
24
|
+
}finally{await iframe.dispose();}
|
|
25
|
+
frame=frame.parentFrame();
|
|
26
|
+
}
|
|
27
|
+
return {business,parents};
|
|
28
|
+
}finally{await handle.dispose();}
|
|
29
|
+
}
|
|
30
|
+
export async function waitForNativeInput(page,target,{timeout=5000}={}) {
|
|
31
|
+
await expect.poll(async()=>{
|
|
32
|
+
try{validateNativeInputEvidence(await nativeInputEvidence(target));return 'ready';}
|
|
33
|
+
catch(error){return error.message;}
|
|
34
|
+
},{timeout,intervals:[16,32,64,100],message:'Native input must hit target in business frame and its iframe in every parent frame'}).toBe('ready');
|
|
35
|
+
const out=process.env.MINIFISH_LOADING_DIAGNOSTICS_DIR;
|
|
36
|
+
if(out){
|
|
37
|
+
const binding='nativeInput'+crypto.randomUUID().replaceAll('-','');
|
|
38
|
+
await fs.mkdir(out,{recursive:true});
|
|
39
|
+
await page.exposeBinding(binding,async(source,event)=>{
|
|
40
|
+
await fs.appendFile(path.join(out,'native-input-events.jsonl'),JSON.stringify({...event,frame:source.frame.url()})+'\n');
|
|
41
|
+
});
|
|
42
|
+
await target.evaluate((node,binding)=>{
|
|
43
|
+
for(const type of ['pointerdown','mousedown','click','dblclick']){
|
|
44
|
+
const observe=event=>{
|
|
45
|
+
if(!node.contains(event.target))return;
|
|
46
|
+
window[binding]({at:Date.now(),type,trusted:event.isTrusted,target:event.target?.outerHTML?.slice(0,800),x:event.clientX,y:event.clientY});
|
|
47
|
+
};
|
|
48
|
+
window.addEventListener(type,observe,{capture:true});
|
|
49
|
+
}
|
|
50
|
+
},binding);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
// Optional read-only observation. No API override, DOM mutation or synthetic input.
|
|
4
|
+
export async function installLoadingObservation(page){
|
|
5
|
+
const out=process.env.MINIFISH_LOADING_DIAGNOSTICS_DIR;
|
|
6
|
+
if(!out)return null;
|
|
7
|
+
const events=[],context=page.context();
|
|
8
|
+
const watch=p=>p.on('console',message=>{const text=message.text();if(text.startsWith('LOADING-OBS ')){try{events.push(JSON.parse(text.slice(12)));}catch{events.push({invalid:text});}}});
|
|
9
|
+
context.on('page',watch);watch(page);
|
|
10
|
+
await context.addInitScript(()=>{
|
|
11
|
+
const emit=value=>console.log('LOADING-OBS '+JSON.stringify({at:Date.now(),frame:location.href,dpr:devicePixelRatio,...value}));
|
|
12
|
+
for(const type of ['pointerdown','mousedown','click'])addEventListener(type,event=>{
|
|
13
|
+
const n=event.target;emit({kind:'input',type,x:event.clientX,y:event.clientY,target:n?.outerHTML?.slice(0,800)});
|
|
14
|
+
},true);
|
|
15
|
+
let previous='';
|
|
16
|
+
const observe=()=>{
|
|
17
|
+
const nodes=[...document.querySelectorAll('.adm-toast-loading')].map(n=>{const b=n.getBoundingClientRect();return {html:n.outerHTML.slice(0,600),width:b.width,height:b.height,left:b.left,top:b.top,visible:getComputedStyle(n).visibility,ancestor:n.closest('.adm-toast-wrap')?.outerHTML.slice(0,900)};});
|
|
18
|
+
const signature=JSON.stringify(nodes.map(({html,ancestor,...g})=>g));
|
|
19
|
+
if(signature!==previous){previous=signature;emit({kind:'loading-carriers',nodes});}
|
|
20
|
+
};
|
|
21
|
+
new MutationObserver(observe).observe(document,{childList:true,subtree:true});
|
|
22
|
+
addEventListener('DOMContentLoaded',observe,{once:true});
|
|
23
|
+
});
|
|
24
|
+
return {async save(){await fs.mkdir(out,{recursive:true});await fs.writeFile(path.join(out,'loading-input-observation.json'),JSON.stringify(events,null,2));context.off('page',watch);}};
|
|
25
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {expect} from '@playwright/test';
|
|
3
|
+
import {businessJsapiOption} from './jsapi.mjs';
|
|
4
|
+
const registrations=new WeakMap(),contexts=new WeakMap();
|
|
5
|
+
const copy=value=>JSON.parse(JSON.stringify(value));
|
|
6
|
+
const pairs=params=>[...params].map(pair=>JSON.stringify(pair)).sort();
|
|
7
|
+
function localUrl(raw,prefix){
|
|
8
|
+
assert.ok(typeof raw==='string'&&raw.startsWith(prefix)&&!raw.startsWith(prefix+'/'),'Exact local URL prefix required');
|
|
9
|
+
const rest=raw.slice(prefix.length);assert.ok(!rest.includes('#')&&!rest.includes('://'),'Local URL cannot contain host or fragment');
|
|
10
|
+
const path=rest.split('?')[0];assert.match(path,/^[\w/-]+$/);assert.ok(!path.startsWith('/'));
|
|
11
|
+
const parsed=new URL('/'+rest,'http://local.invalid');assert.equal(parsed.pathname.slice(1),path);
|
|
12
|
+
return {path,params:parsed.searchParams};
|
|
13
|
+
}
|
|
14
|
+
export function observeLocalNavigationCandidate(page,wire){
|
|
15
|
+
const target=wire.officialTarget;
|
|
16
|
+
if(target?.pageUrl&&target?.trAppId&&typeof target.trPageId==='string'&&target.contextType?.type==='app'&&target.contextType?.value==='mainApp')contexts.set(page,copy(target));
|
|
17
|
+
}
|
|
18
|
+
export function beginLocalNavigation(page,method,url){
|
|
19
|
+
if(method!=='navigateTo')return null;
|
|
20
|
+
const parsed=localUrl(url,'/');assert.equal(parsed.params.has('__appxPageId'),false,'Caller cannot supply reserved page ID');
|
|
21
|
+
const rows=registrations.get(page)??[];assert.equal(rows.some(row=>!row.closed),false,'Concurrent local registration rejected');
|
|
22
|
+
const source=contexts.get(page);assert.ok(source,'Previously observed official source context required');
|
|
23
|
+
assert.ok(page.frames().some(frame=>{try{const u=new URL(frame.url());return u.pathname+u.hash===source.pageUrl;}catch{return false;}}),'Source context must match a live public frame');
|
|
24
|
+
const record={method,url,path:parsed.path,query:pairs(parsed.params),source:copy(source),sourceFrames:page.frames().map(f=>f.url()),state:'registered',closed:false,consumed:false,candidates:[],officialCallback:null};
|
|
25
|
+
rows.push(record);registrations.set(page,rows);return record;
|
|
26
|
+
}
|
|
27
|
+
export function delegateLocalNavigation(page,wire){
|
|
28
|
+
if(wire.api!=='pushWindow')return false;
|
|
29
|
+
const option=businessJsapiOption(wire.option);
|
|
30
|
+
if(typeof option?.url!=='string'||!option.url.startsWith('#'))return false;
|
|
31
|
+
const record=(registrations.get(page)??[]).at(-1);
|
|
32
|
+
assert.ok(record&&!record.closed&&record.state==='registered','Local navigation needs one active unconsumed registration');
|
|
33
|
+
record.candidates.push(copy(wire));
|
|
34
|
+
assert.deepEqual(wire.officialTarget,record.source,'Official source context must exactly match registration');
|
|
35
|
+
assert.deepEqual(Object.keys(option).sort(),['launchParamsTag','url'],'Unexpected navigation option');
|
|
36
|
+
assert.equal(option.launchParamsTag,record.path);
|
|
37
|
+
const parsed=localUrl(option.url,'#');assert.equal(parsed.path,record.path);
|
|
38
|
+
const ids=parsed.params.getAll('__appxPageId');assert.equal(ids.length,1,'Exactly one runtime page ID required');
|
|
39
|
+
assert.match(ids[0],/^[1-9]\d*$/);assert.ok(Number.isSafeInteger(Number(ids[0])));
|
|
40
|
+
parsed.params.delete('__appxPageId');assert.deepEqual(pairs(parsed.params),record.query,'All business query entries must match');
|
|
41
|
+
record.runtimePageId=ids[0];record.runtimeIdentityQualification='official generated field, not independently preallocated';record.state='delegated';return true;
|
|
42
|
+
}
|
|
43
|
+
export async function finishLocalNavigation(page,record,outcome){
|
|
44
|
+
if(!record)return;
|
|
45
|
+
record.officialCallback=outcome;
|
|
46
|
+
try{
|
|
47
|
+
assert.equal(outcome.status,'success');assert.equal(record.state,'delegated','No matching official bridge delegation');
|
|
48
|
+
await expect.poll(async()=>{
|
|
49
|
+
for(const frame of page.frames()){
|
|
50
|
+
try{
|
|
51
|
+
const u=new URL(frame.url());if(!u.hash.startsWith('#'))continue;
|
|
52
|
+
const parsed=localUrl(u.hash,'#');if(parsed.path!==record.path)continue;
|
|
53
|
+
const ids=parsed.params.getAll('__appxPageId');if(ids.length>1)continue;
|
|
54
|
+
parsed.params.delete('__appxPageId');if(JSON.stringify(pairs(parsed.params))!==JSON.stringify(record.query))continue;
|
|
55
|
+
if(ids.length)assert.equal(ids[0],record.runtimePageId,'Public destination runtime ID');
|
|
56
|
+
if(!await frame.evaluate(()=>document.readyState==='complete'))continue;
|
|
57
|
+
const element=await frame.frameElement();const visible=await element.isVisible();await element.dispose();if(!visible)continue;
|
|
58
|
+
record.destination={url:frame.url(),publicPageId:ids[0]??null,ready:true};return true;
|
|
59
|
+
}catch(error){if(error.name==='AssertionError')throw error;}
|
|
60
|
+
}return false;
|
|
61
|
+
},{timeout:10000,intervals:[16,32,64,100],message:'Real official destination frame must be ready'}).toBe(true);
|
|
62
|
+
record.destinationFrames=page.frames().map(f=>f.url());record.state='target-ready';
|
|
63
|
+
}finally{record.closed=true;}
|
|
64
|
+
}
|
|
65
|
+
export function failLocalNavigation(record,error){if(record){record.closed=true;record.failure=error.message;}}
|
|
66
|
+
export function assertLocalNavigationComplete(page,evidence,queryExpectations){
|
|
67
|
+
for(const row of registrations.get(page)??[]){
|
|
68
|
+
assert.equal(row.closed,true,'Registration must close');assert.equal(row.state,'target-ready');
|
|
69
|
+
const queryRows=queryExpectations(row,evidence);
|
|
70
|
+
row.preparatoryQueryIds=queryRows.map(x=>x.id);row.consumed=true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export function localNavigationEvidence(page){return registrations.get(page)??[];}
|
|
74
|
+
|
|
75
|
+
export async function observeLocalNavigationReturn(page){
|
|
76
|
+
const record=(registrations.get(page)??[]).at(-1);if(!record)return;
|
|
77
|
+
assert.equal(record.state,'target-ready');
|
|
78
|
+
const active='.simulator > .tr-page:not(:has(iframe.tr-page-iframe-animation-out)):not(:has(~ .tr-page:not(:has(iframe.tr-page-iframe-animation-out))))';
|
|
79
|
+
const root=page.frameLocator('iframe[name="lyra-integration-web-sdk"]').locator(active+' iframe[name="/'+record.path+'"]:not(.tr-page-iframe-animation-out):visible').contentFrame().locator('html');
|
|
80
|
+
await expect.poll(async()=>root.evaluate(()=>({url:location.pathname+location.hash,ready:document.readyState})),{timeout:10000,intervals:[16,32,64,100]}).toEqual({url:record.source.pageUrl,ready:'complete'});
|
|
81
|
+
record.returnLifecycle={activeUrl:record.source.pageUrl,ready:true,frames:page.frames().map(frame=>frame.url())};
|
|
82
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {expect} from '@playwright/test';
|
|
4
|
+
import {activeShellSelector}from './browser.mjs';
|
|
5
|
+
export function validateCaptureClip(box,viewport){
|
|
6
|
+
for(const key of ['x','y','width','height'])assert(Number.isFinite(box?.[key]),'Finite host screenshot bounds required');
|
|
7
|
+
assert(box.width>0&&box.height>0,'Positive host screenshot area');
|
|
8
|
+
const clip={x:Math.floor(box.x),y:Math.floor(box.y),width:Math.ceil(box.x+box.width)-Math.floor(box.x),height:Math.ceil(box.y+box.height)-Math.floor(box.y)};
|
|
9
|
+
assert(clip.x>=0&&clip.y>=0&&clip.x+clip.width<=viewport.width&&clip.y+clip.height<=viewport.height,'Entire host device must be inside screenshot viewport; capture never scrolls');return clip;
|
|
10
|
+
}
|
|
11
|
+
export async function observeMiniappGeometry(page){
|
|
12
|
+
const host=page.frameLocator('iframe[name="lyra-integration-web-sdk"]'),simulator=host.locator('.simulator');
|
|
13
|
+
await expect(simulator).toHaveCount(1);await expect(simulator).toBeVisible();
|
|
14
|
+
const geometry=await simulator.evaluate(node=>{const box=node.getBoundingClientRect();return {width:box.width,height:box.height,logicalWidth:node.offsetWidth,logicalHeight:node.offsetHeight,displayScaleX:box.width/node.offsetWidth,displayScaleY:box.height/node.offsetHeight,dpr:devicePixelRatio};});
|
|
15
|
+
expect(geometry.logicalWidth).toBe(402);expect(geometry.logicalHeight).toBe(874);
|
|
16
|
+
expect(geometry.width).toBeGreaterThan(0);expect(geometry.height).toBeGreaterThan(0);
|
|
17
|
+
const navigation=host.locator(activeShellSelector+' .lyra-page-title .titlebar');await expect(navigation).toHaveCount(1);await expect(navigation).toBeVisible();
|
|
18
|
+
return {...geometry,navigation:await navigation.boundingBox()};
|
|
19
|
+
}
|
|
20
|
+
export async function prepareMiniappCapture(page){
|
|
21
|
+
const geometry=await observeMiniappGeometry(page),simulator=page.frameLocator('iframe[name="lyra-integration-web-sdk"]').locator('.simulator');
|
|
22
|
+
const box=await simulator.boundingBox(),clip=validateCaptureClip(box,page.viewportSize()),nav=geometry.navigation;
|
|
23
|
+
assert(nav&&nav.x>=clip.x&&nav.y>=clip.y&&nav.x+nav.width<=clip.x+clip.width&&nav.y+nav.height<=clip.y+clip.height,'Actual SDK navigation must be inside captured device');
|
|
24
|
+
const dpr=await page.evaluate(()=>devicePixelRatio);
|
|
25
|
+
return {geometry,clip,dpr,preparedAt:new Date().toISOString()};
|
|
26
|
+
}
|
|
27
|
+
export async function captureMiniappScreenshot(page,info,phase,prepared){
|
|
28
|
+
const own=!prepared;prepared??=await prepareMiniappCapture(page);
|
|
29
|
+
const {geometry,clip,dpr,preparedAt}=prepared;
|
|
30
|
+
const evidence={phase,capturedAt:new Date().toISOString(),method:'read-only-page-clip-preflight-v3',preparedAt,preflightBeforeHeld:!own,logicalDevice:{width:402,height:874},geometry,clip,dpr,file:`miniapp-${phase}.png`};
|
|
31
|
+
// Public page capture never scrolls to an element or releases input.
|
|
32
|
+
// An artifact remains held until bytes are captured; formal behavior never waits.
|
|
33
|
+
const started=Date.now(),bytes=await page.screenshot({type:'png',clip,scale:'device',caret:'initial',animations:'allow'});
|
|
34
|
+
evidence.captureDurationMs=Date.now()-started;evidence.captureFinishedAt=new Date().toISOString();
|
|
35
|
+
assert.equal(bytes.readUInt32BE(16),Math.round(clip.width*dpr),'Screenshot device pixel width');assert.equal(bytes.readUInt32BE(20),Math.round(clip.height*dpr),'Screenshot device pixel height');
|
|
36
|
+
await fs.writeFile(info.outputPath(evidence.file),bytes);await fs.writeFile(info.outputPath(`miniapp-${phase}.json`),JSON.stringify(evidence,null,2));return evidence;
|
|
37
|
+
|
|
38
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Existing public browser collectors, extracted without changing measured fields.
|
|
2
|
+
export function sampleNativeMetrics(el){
|
|
3
|
+
const s=getComputedStyle(el),r=el.getBoundingClientRect();
|
|
4
|
+
const styles=Object.fromEntries(['fontFamily','fontSize','fontWeight','lineHeight','color','backgroundColor','borderRadius','paddingLeft','paddingRight','paddingTop','paddingBottom','marginTop','marginBottom','marginLeft','marginRight','backgroundImage','backgroundSize','backgroundRepeat','backgroundPosition','objectFit'].map(k=>[k,s[k]]));
|
|
5
|
+
const texts=[],textStyles=[];
|
|
6
|
+
function visit(node){if(node.nodeType===3 && node.textContent.trim()){const range=document.createRange();range.selectNodeContents(node);const ts=getComputedStyle(node.parentElement);textStyles.push(Object.fromEntries(['fontFamily','fontSize','fontWeight','lineHeight','color'].map(k=>[k,ts[k]])));for(const b of range.getClientRects())if(b.width&&b.height)texts.push({x:b.x,y:b.y,width:b.width,height:b.height});}else{for(const c of node.childNodes)visit(c);if(node.shadowRoot)visit(node.shadowRoot);}}
|
|
7
|
+
visit(el);
|
|
8
|
+
const clipping=[];for(let n=el.parentElement;n;n=n.parentElement){const cs=getComputedStyle(n);if(['hidden','clip','auto','scroll'].some(v=>cs.overflowX===v||cs.overflowY===v)){const b=n.getBoundingClientRect();clipping.push({x:b.x,y:b.y,width:b.width,height:b.height});}}
|
|
9
|
+
const elements=[el,...el.querySelectorAll('*')];
|
|
10
|
+
const paintFor=n=>{
|
|
11
|
+
const ps=getComputedStyle(n),pr=n.getBoundingClientRect();let visible=true,opacity=1,owned=true;const clips=[];
|
|
12
|
+
for(let at=n;at;at=at.parentElement){const st=getComputedStyle(at);visible&&=st.display!=='none'&&st.visibility==='visible';opacity*=Number(st.opacity);if(at!==n){const b=at.getBoundingClientRect(),fractionX=['hidden','clip'].includes(st.overflowX),fractionY=['hidden','clip'].includes(st.overflowY),left=parseFloat(st.borderLeftWidth)||0,right=parseFloat(st.borderRightWidth)||0,top=parseFloat(st.borderTopWidth)||0,bottom=parseFloat(st.borderBottomWidth)||0;clips.push({x:b.x+(fractionX?left:at.clientLeft),y:b.y+(fractionY?top:at.clientTop),width:fractionX?b.width-left-right:at.clientWidth,height:fractionY?b.height-top-bottom:at.clientHeight,clipX:/hidden|clip|scroll|auto/.test(st.overflowX),clipY:/hidden|clip|scroll|auto/.test(st.overflowY)});}}
|
|
13
|
+
for(let at=n;at&&at!==el;at=at.parentElement)if(at.getAttribute('data-anchor-id'))owned=false;
|
|
14
|
+
return {paintIndex:elements.indexOf(n),owned,visible:visible&&opacity>0,frame:location.href,viewport:{width:innerWidth,height:innerHeight},box:{x:pr.x,y:pr.y,width:pr.width,height:pr.height},clipping:clips,styles:{objectFit:ps.objectFit,backgroundImage:ps.backgroundImage,backgroundSize:ps.backgroundSize,backgroundRepeat:ps.backgroundRepeat,backgroundPosition:ps.backgroundPosition},images:[{src:n.currentSrc||n.src||n.getAttribute('src')}]};
|
|
15
|
+
};
|
|
16
|
+
const imagePaints=elements.filter(n=>n.tagName==='IMG'||(n.getAttribute('role')==='img'&&getComputedStyle(n).backgroundImage!=='none')).map(paintFor);
|
|
17
|
+
const imagePaintInventory=elements.filter(n=>n.tagName==='IMG'||getComputedStyle(n).backgroundImage!=='none').map(paintFor);
|
|
18
|
+
return {imagePaints,imagePaintInventory,scrollWidth:el.scrollWidth,clientWidth:el.clientWidth,textStyles,clipping,viewport:{width:innerWidth,height:innerHeight},box:{x:r.x,y:r.y,width:r.width,height:r.height},styles,texts,frame:location.href,images:[el,...el.querySelectorAll('img')].map(n=>({src:n.currentSrc||n.src||n.getAttribute('src')}))};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function sampleNativeTextVisibility(el){
|
|
22
|
+
const box=n=>{const r=n.getBoundingClientRect();return {x:r.x,y:r.y,width:r.width,height:r.height};};
|
|
23
|
+
const entries=[];const walk=n=>{if(n.nodeType===3&&n.textContent.trim()){
|
|
24
|
+
const range=document.createRange();range.selectNodeContents(n);let opacity=1,visible=true;const clips=[];
|
|
25
|
+
for(let p=n.parentElement;p;p=p.parentElement){const s=getComputedStyle(p);opacity*=Number(s.opacity);visible&&=s.visibility==='visible'&&s.display!=='none';const x=['hidden','clip','auto','scroll'].includes(s.overflowX),y=['hidden','clip','auto','scroll'].includes(s.overflowY);if(x||y){const b=box(p);clips.push({x:b.x+p.clientLeft,y:b.y+p.clientTop,width:p.clientWidth,height:p.clientHeight,clipX:x,clipY:y,self:p===el,ancestor:p!==el&&!el.contains(p),overflowX:s.overflowX,overflowY:s.overflowY,textOverflow:s.textOverflow,whiteSpace:s.whiteSpace,textAlign:s.textAlign,direction:s.direction,maxWidth:s.maxWidth,lineHeight:s.lineHeight});}}
|
|
26
|
+
const leaf=getComputedStyle(n.parentElement),styles=Object.fromEntries(['fontFamily','fontSize','fontWeight','lineHeight','color'].map(k=>[k,leaf[k]]));let owned=true;for(let p=n.parentElement;p&&p!==el;p=p.parentElement)if(p.hasAttribute('data-anchor-id'))owned=false;
|
|
27
|
+
for(const r of range.getClientRects())if(r.width&&r.height)entries.push({styles,owned,range:{x:r.x,y:r.y,width:r.width,height:r.height},opacity,visible,clips});
|
|
28
|
+
}else for(const child of n.childNodes)walk(child);};walk(el);
|
|
29
|
+
const s=getComputedStyle(el);return {rawText:el.textContent,anchorId:el.getAttribute('data-anchor-id'),whiteSpace:s.whiteSpace,textAlign:s.textAlign,direction:s.direction,maxWidth:s.maxWidth,lineHeight:s.lineHeight,box:box(el),overflowX:s.overflowX,overflowY:s.overflowY,textOverflow:s.textOverflow,viewport:{x:0,y:0,width:innerWidth,height:innerHeight},entries};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function sampleNativeStyle(el,{keys,pseudo}){return Object.fromEntries(keys.map(k=>[k,getComputedStyle(el,pseudo||null)[k]]));}
|
|
33
|
+
export function sampleNativePressed(el){for(let n=el;n;n=n.parentElement)if(n.matches(':active'))return true;return false;}
|
|
34
|
+
// Finite native DIV/SPAN/A carriers used by the held source contract. Other
|
|
35
|
+
// renderer topologies must declare their state contract before native pressing.
|
|
36
|
+
export function sampleHeldNativeState(el){
|
|
37
|
+
const s=getComputedStyle(el),b=el.getBoundingClientRect();
|
|
38
|
+
if(!['DIV','SPAN','A'].includes(el.tagName)||s.display==='contents')throw Error('Unsupported held native carrier topology');
|
|
39
|
+
return {visible:el.checkVisibility()&&s.visibility==='visible'&&b.width>0&&b.height>0,enabled:!el.closest('.a-button-disabled,[aria-disabled="true"],[disabled]:not([disabled="false"])')};
|
|
40
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import {expect} from '@playwright/test';
|
|
2
|
+
// Observed official Minifish 2.40.1 button projection is a[role=button].
|
|
3
|
+
// disabled=true projects a-button-disabled, without native disabled/aria-disabled.
|
|
4
|
+
// Read only public carrier semantics; never assume an HTML button tag or mutate it.
|
|
5
|
+
export async function isNativeEnabled(locator){
|
|
6
|
+
const disabled=await locator.evaluate(node=>Boolean(node.closest('.a-button-disabled,[aria-disabled="true"],[disabled]:not([disabled="false"])')));
|
|
7
|
+
return !disabled && await locator.isEnabled();
|
|
8
|
+
}
|
|
9
|
+
export async function waitNativeEnabled(locator,enabled,{timeout=10000}={}){
|
|
10
|
+
await expect.poll(()=>isNativeEnabled(locator),{timeout,message:'Public native carrier enabled state'}).toBe(enabled);
|
|
11
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Explicit native RPC failure callback; unlike success:false business data,
|
|
2
|
+
// this travels through the official RPC transport envelope.
|
|
3
|
+
export function rpcTransportFailure(error = 10001, errorMessage = 'mock network unavailable') {
|
|
4
|
+
return { $testTransportFailure: { error, errorMessage } };
|
|
5
|
+
}
|
|
6
|
+
export function rpcTransportEnvelope(result, encodeSuccess) {
|
|
7
|
+
if (!Object.hasOwn(result ?? {}, '$testTransportFailure')) return encodeSuccess(result);
|
|
8
|
+
const {error,errorMessage}=result.$testTransportFailure;
|
|
9
|
+
// Official SDK maps unsuccessful proxy code/message to native error -1.
|
|
10
|
+
return { success: false, code: error, message: errorMessage };
|
|
11
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {semanticLocator} from './semantic-anchor.mjs';
|
|
3
|
+
import {expect} from '@playwright/test';
|
|
4
|
+
|
|
5
|
+
export async function assertNativeState(locator, assertion) {
|
|
6
|
+
if (assertion.kind === 'hidden') {
|
|
7
|
+
assert((await locator.count()) <= 1, 'Duplicate hidden semantic target');
|
|
8
|
+
// Enumerate all carriers: absence is allowed, any visible duplicate fails.
|
|
9
|
+
for (let i=0; i<await locator.count(); i++) await expect(locator.nth(i)).toBeHidden();
|
|
10
|
+
} else if (assertion.kind === 'visible') {
|
|
11
|
+
await expect(locator).toHaveCount(assertion.count);
|
|
12
|
+
await expect(locator).toBeVisible();
|
|
13
|
+
} else throw Error(`Unsupported state assertion: ${assertion.kind}`);
|
|
14
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
export const nativeUiApis=['alert','showLoading','hideLoading','showToast','addToHomeWithGuideDialog'];
|
|
3
|
+
export const nativeUiResultPath='/__bill_tests__/native-ui-result';
|
|
4
|
+
export function validateNativeUiBoundary(row){
|
|
5
|
+
if(row.execution!=='official-ui')return false;
|
|
6
|
+
const api=row.request.operation.slice(6),p=row.request.params;
|
|
7
|
+
assert.equal(row.request.operation,'jsapi:'+api);assert(nativeUiApis.includes(api),'Finite official UI API list');
|
|
8
|
+
assert(p&&typeof p==='object'&&!Array.isArray(p),'UI object parameters');
|
|
9
|
+
const keys={alert:['title','content','buttonText'],showLoading:['content'],hideLoading:[],showToast:['content','type','delay','duration'],addToHomeWithGuideDialog:['appId','eventId']}[api];
|
|
10
|
+
assert(Object.keys(p).every(k=>keys.includes(k)),'Unexpected native UI parameter');
|
|
11
|
+
if(api==='alert'){assert.deepEqual(Object.keys(p).sort(),keys.slice().sort());for(const k of keys)assert(typeof p[k]==='string'&&p[k].length,'Nonempty alert field');}
|
|
12
|
+
if(api==='showLoading'&&p.content!==undefined)assert.equal(typeof p.content,'string');
|
|
13
|
+
if(api==='showToast'){assert(typeof p.content==='string'&&p.content.length);if(p.type!==undefined)assert(['success','fail','exception','none'].includes(p.type));for(const k of ['delay','duration'])if(p[k]!==undefined)assert(Number.isInteger(p[k])&&p[k]>=0);}
|
|
14
|
+
if(api==='addToHomeWithGuideDialog')assert.deepEqual(p,{appId:'20000193',eventId:'SHJF-HHBD'});
|
|
15
|
+
assert.deepEqual(row.outcome,{kind:'response',value:{}},'Official UI has no synthetic response');
|
|
16
|
+
assert.equal(row.times,1,'One exact official invocation per row');assert.equal(row.fixtureType,undefined,'Official UI cannot carry a Mock fixture type');
|
|
17
|
+
assert.equal(typeof row.requireConsumption,'boolean','Declare actual callback obligation');
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
export function validateNativeUiResult(receipt){
|
|
21
|
+
assert(receipt&&typeof receipt.api==='string'&&nativeUiApis.includes(receipt.api),'Actual official UI result required');
|
|
22
|
+
assert.equal(typeof receipt.resultPresent,'boolean');
|
|
23
|
+
if(receipt.resultPresent){
|
|
24
|
+
const v=receipt.result;assert(v===null||typeof v==='object','Native UI result must be a public object or null');
|
|
25
|
+
if(v?.error!==undefined)assert.equal(v.error,0,'Official UI failure cannot count as successful delegation');
|
|
26
|
+
if(v?.success!==undefined)assert.equal(v.success,true,'Official UI unsuccessful result');
|
|
27
|
+
}
|
|
28
|
+
return receipt;
|
|
29
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import {beginLocalNavigation,finishLocalNavigation,failLocalNavigation,observeLocalNavigationReturn} from './local-navigation-evidence.mjs';
|
|
2
|
+
// Official public navigation; never calls product modules or page methods.
|
|
3
|
+
export async function platformNavigate(page, method, url) {
|
|
4
|
+
if (!['navigateTo','redirectTo','navigateBack','startPullDownRefresh'].includes(method)) throw Error('Unsupported public navigation');
|
|
5
|
+
const registration=beginLocalNavigation(page,method,url);
|
|
6
|
+
try {await page.evaluate(async ({method,url}) => {
|
|
7
|
+
function find(window) {
|
|
8
|
+
try {
|
|
9
|
+
if (typeof window.my?.[method] === 'function') return window.my;
|
|
10
|
+
for (let i=0;i<window.frames.length;i++) { const api=find(window.frames[i]); if(api)return api; }
|
|
11
|
+
} catch {}
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
const api=find(window);
|
|
15
|
+
if(!api)throw Error(`Official public my.${method} unavailable`);
|
|
16
|
+
await new Promise((resolve,reject)=>api[method]({...(['navigateTo','redirectTo'].includes(method)?{url}:method==='navigateBack'?{delta:1}:{}),success:resolve,fail:reject}));
|
|
17
|
+
}, {method,url});await finishLocalNavigation(page,registration,{status:'success'});if(method==='navigateBack')await observeLocalNavigationReturn(page);
|
|
18
|
+
}catch(error){failLocalNavigation(registration,error);throw error;}
|
|
19
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""One user/net namespace and userspace uplink per runner, never a shared lock."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import select
|
|
6
|
+
import signal
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def add_network_arguments(parser):
|
|
13
|
+
parser.add_argument('--slirp4netns', type=Path, default=os.environ.get('MINIFISH_SLIRP4NETNS'), help='Explicit userspace network binary (required)')
|
|
14
|
+
parser.add_argument('--slirp-library-path', type=Path, default=os.environ.get('MINIFISH_SLIRP_LIBRARY_PATH'), help='Optional private dynamic library directory')
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def active_namespace(work):
|
|
18
|
+
metadata = os.environ.get('MINIFISH_RUN_NETWORK')
|
|
19
|
+
if not metadata:
|
|
20
|
+
return False
|
|
21
|
+
data = json.loads(metadata)
|
|
22
|
+
if data['reportDir'] != str(work) or data['netns'] != os.readlink('/proc/self/ns/net'):
|
|
23
|
+
raise RuntimeError('Run namespace metadata does not match current process')
|
|
24
|
+
return True
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def namespace_members(inode, exclude=()):
|
|
28
|
+
members = []
|
|
29
|
+
for proc in Path('/proc').glob('[0-9]*'):
|
|
30
|
+
try:
|
|
31
|
+
if int(proc.name) in exclude:
|
|
32
|
+
continue
|
|
33
|
+
fields = (proc / 'stat').read_text().rsplit(')', 1)[1].split()
|
|
34
|
+
if fields[0] != 'Z' and os.readlink(proc / 'ns/net') == inode:
|
|
35
|
+
members.append(int(proc.name))
|
|
36
|
+
except (FileNotFoundError, ProcessLookupError):
|
|
37
|
+
pass
|
|
38
|
+
return members
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def reap_namespace_members(inode, keeper_pid):
|
|
42
|
+
# SDK services may detach into new groups (e.g. NeoDebug Agent Hub).
|
|
43
|
+
# Namespace identity, not command-name matching, bounds cleanup to this run.
|
|
44
|
+
found = set(namespace_members(inode, (keeper_pid,)))
|
|
45
|
+
for sig, budget in ((signal.SIGTERM, 1), (signal.SIGKILL, 1)):
|
|
46
|
+
for pid in namespace_members(inode, (keeper_pid,)):
|
|
47
|
+
found.add(pid)
|
|
48
|
+
try: os.kill(pid, sig)
|
|
49
|
+
except ProcessLookupError: pass
|
|
50
|
+
deadline = time.monotonic() + budget
|
|
51
|
+
while namespace_members(inode, (keeper_pid,)) and time.monotonic() < deadline:
|
|
52
|
+
time.sleep(0.05)
|
|
53
|
+
remaining = namespace_members(inode, (keeper_pid,))
|
|
54
|
+
return {'detachedPids': sorted(found), 'remaining': remaining}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def entry(work, args):
|
|
58
|
+
"""Re-exec the whole runner inside its namespace, then reap all auxiliaries."""
|
|
59
|
+
if active_namespace(work):
|
|
60
|
+
return
|
|
61
|
+
if not args.slirp4netns or not args.slirp4netns.is_file():
|
|
62
|
+
raise RuntimeError('Set --slirp4netns or MINIFISH_SLIRP4NETNS; host-network fallback is forbidden')
|
|
63
|
+
children = []
|
|
64
|
+
child = None
|
|
65
|
+
ready_read = ready_write = None
|
|
66
|
+
info = {'reportDir': str(work), 'startedAt': time.time()}
|
|
67
|
+
handlers = {}
|
|
68
|
+
def interrupted(signum, frame):
|
|
69
|
+
if child is not None and child.poll() is None:
|
|
70
|
+
os.killpg(child.pid, signum)
|
|
71
|
+
return # Keep the keeper/uplink alive until the inner runner has reaped its groups.
|
|
72
|
+
raise InterruptedError(f'Network runner interrupted by {signum}')
|
|
73
|
+
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
74
|
+
handlers[sig] = signal.signal(sig, interrupted)
|
|
75
|
+
try:
|
|
76
|
+
with (work / 'network.log').open('wb') as log:
|
|
77
|
+
keeper = subprocess.Popen(['unshare', '--user', '--map-root-user', '--net', 'sh', '-ec',
|
|
78
|
+
'ip link set lo up; echo $$; exec sleep infinity'], stdout=subprocess.PIPE, stderr=log, start_new_session=True)
|
|
79
|
+
children.append(keeper)
|
|
80
|
+
if not select.select([keeper.stdout], [], [], 15)[0]:
|
|
81
|
+
raise TimeoutError('Namespace keeper did not become ready')
|
|
82
|
+
line = keeper.stdout.readline()
|
|
83
|
+
if not line:
|
|
84
|
+
raise RuntimeError('Namespace creation failed; see network.log')
|
|
85
|
+
pid = int(line)
|
|
86
|
+
info.update(keeperPid=pid, netns=os.readlink(f'/proc/{pid}/ns/net'), userns=os.readlink(f'/proc/{pid}/ns/user'))
|
|
87
|
+
ready_read, ready_write = os.pipe()
|
|
88
|
+
uplink_env = dict(os.environ)
|
|
89
|
+
if args.slirp_library_path:
|
|
90
|
+
uplink_env['LD_LIBRARY_PATH'] = str(args.slirp_library_path.resolve())
|
|
91
|
+
slirp_argv = [str(args.slirp4netns.resolve()), '-c', '--disable-host-loopback', '-r', str(ready_write), str(pid), 'tap0']
|
|
92
|
+
slirp = subprocess.Popen(slirp_argv, env=uplink_env, pass_fds=[ready_write], stdout=log, stderr=log, start_new_session=True)
|
|
93
|
+
children.append(slirp)
|
|
94
|
+
os.close(ready_write); ready_write = None
|
|
95
|
+
if not select.select([ready_read], [], [], 15)[0] or not os.read(ready_read, 1):
|
|
96
|
+
raise RuntimeError('slirp network readiness failed; see network.log')
|
|
97
|
+
os.close(ready_read); ready_read = None
|
|
98
|
+
info.update(slirpPid=slirp.pid, slirpArgv=slirp_argv, readyAt=time.time())
|
|
99
|
+
(work / 'network.json').write_text(json.dumps(info, indent=2))
|
|
100
|
+
env = dict(os.environ, MINIFISH_RUN_NETWORK=json.dumps(info))
|
|
101
|
+
argv = ['nsenter', '--target', str(pid), '--user', '--net', '--', sys.executable, str(Path(sys.argv[0]).resolve()), *sys.argv[1:]]
|
|
102
|
+
# Outer entry generated a report dir when no explicit one was supplied.
|
|
103
|
+
if not args.report_dir:
|
|
104
|
+
argv += ['--report-dir', str(work)]
|
|
105
|
+
child = subprocess.Popen(argv, env=env, start_new_session=True)
|
|
106
|
+
children.append(child)
|
|
107
|
+
code = child.wait()
|
|
108
|
+
finally:
|
|
109
|
+
for sig in handlers:
|
|
110
|
+
signal.signal(sig, signal.SIG_IGN)
|
|
111
|
+
detached = reap_namespace_members(info['netns'], info['keeperPid']) if 'netns' in info else {'detachedPids': [], 'remaining': []}
|
|
112
|
+
for proc in reversed(children):
|
|
113
|
+
try: os.killpg(proc.pid, signal.SIGTERM)
|
|
114
|
+
except ProcessLookupError: pass
|
|
115
|
+
for proc in reversed(children):
|
|
116
|
+
try: proc.wait(timeout=5)
|
|
117
|
+
except subprocess.TimeoutExpired: pass
|
|
118
|
+
try: os.killpg(proc.pid, signal.SIGKILL)
|
|
119
|
+
except ProcessLookupError: pass
|
|
120
|
+
proc.wait()
|
|
121
|
+
for fd in (ready_read, ready_write):
|
|
122
|
+
if fd is not None: os.close(fd)
|
|
123
|
+
if children and children[0].stdout: children[0].stdout.close()
|
|
124
|
+
(work / 'network-lifecycle.json').write_text(json.dumps({'finishedAt': time.time(), 'network': info,
|
|
125
|
+
'namespaceCleanup': detached, 'children': [{'pid': p.pid, 'returncode': p.returncode} for p in children], 'cleanup': 'only this run network/runner process groups terminated and reaped'}, indent=2))
|
|
126
|
+
for sig, handler in handlers.items(): signal.signal(sig, handler)
|
|
127
|
+
if detached['remaining']:
|
|
128
|
+
raise RuntimeError('Run namespace has live detached services after cleanup')
|
|
129
|
+
raise SystemExit(code)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def namespace_command(runtime):
|
|
133
|
+
network = runtime['network']
|
|
134
|
+
return ['nsenter', '--target', str(network['keeperPid']), '--user', '--net', '--']
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {expect} from '@playwright/test';
|
|
2
|
+
import {rendererImageEvidence,captureRendererImageEvidence} from './renderer-image-evidence.mjs';
|
|
3
|
+
export {registerRendererImageEvidence} from './renderer-image-evidence.mjs';
|
|
4
|
+
// HTML and native backgrounds are platform adapters, not required product DOM shapes.
|
|
5
|
+
async function decodedHTMLImage(image,url,timeout){
|
|
6
|
+
await expect(image).toHaveCount(1,{timeout});await expect(image).toBeVisible({timeout});
|
|
7
|
+
const absolute=await image.evaluate((node,value)=>new URL(value,node.ownerDocument.baseURI).href,url);
|
|
8
|
+
await expect.poll(()=>image.evaluate(async(node,expected)=>{
|
|
9
|
+
try{await node.decode();}catch{return false;}
|
|
10
|
+
return node.currentSrc===expected&&node.complete&&node.naturalWidth>0&&node.naturalHeight>0;
|
|
11
|
+
},absolute),{timeout}).toBe(true);
|
|
12
|
+
return image.evaluate(node=>({src:node.currentSrc,width:node.naturalWidth,height:node.naturalHeight}));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Public rendered carrier only: no DOM mutation or out-of-carrier image probes.
|
|
16
|
+
export async function paintedSourceImage(host,url,{timeout=5000}={}){
|
|
17
|
+
await expect(host).toHaveCount(1,{timeout});await expect(host).toBeVisible({timeout});
|
|
18
|
+
// Observe content through the scroll container before testing viewport clipping.
|
|
19
|
+
await host.scrollIntoViewIfNeeded({timeout});
|
|
20
|
+
const state=await host.evaluate(node=>{
|
|
21
|
+
const style=getComputedStyle(node),box=node.getBoundingClientRect();
|
|
22
|
+
let opacity=1,left=Math.max(0,box.left),top=Math.max(0,box.top),right=Math.min(innerWidth,box.right),bottom=Math.min(innerHeight,box.bottom);
|
|
23
|
+
for(let p=node;p;p=p.parentElement){
|
|
24
|
+
const css=getComputedStyle(p);opacity*=Number(css.opacity);
|
|
25
|
+
if(p!==node){const clip=p.getBoundingClientRect();
|
|
26
|
+
if(/hidden|clip|scroll|auto/.test(css.overflowX)){left=Math.max(left,clip.left);right=Math.min(right,clip.right);}
|
|
27
|
+
if(/hidden|clip|scroll|auto/.test(css.overflowY)){top=Math.max(top,clip.top);bottom=Math.min(bottom,clip.bottom);}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return {tag:node.tagName,opacity,width:box.width,height:box.height,paintWidth:right-left,paintHeight:bottom-top,background:style.backgroundImage,size:style.backgroundSize};
|
|
31
|
+
});
|
|
32
|
+
expect(state.opacity).toBeGreaterThan(0);expect(state.width).toBeGreaterThan(0);expect(state.height).toBeGreaterThan(0);
|
|
33
|
+
expect(state.paintWidth).toBeGreaterThan(0);expect(state.paintHeight).toBeGreaterThan(0);
|
|
34
|
+
if(state.tag==='IMG'){const resource=await decodedHTMLImage(host,url,timeout);return {...state,mode:'html',resource};}
|
|
35
|
+
await expect(host).toHaveAttribute('role','img',{timeout});
|
|
36
|
+
if(state.background==='none'){
|
|
37
|
+
const resource=await decodedHTMLImage(host.locator('img'),url,timeout);
|
|
38
|
+
return {...state,mode:'html-in-semantic-carrier',resource};
|
|
39
|
+
}
|
|
40
|
+
// Compare the complete computed value, rejecting multiple layers and gradients.
|
|
41
|
+
const absolute=await host.evaluate((node,url)=>new URL(url,node.ownerDocument.baseURI).href,url);
|
|
42
|
+
expect(state.background).toBe(`url(${JSON.stringify(absolute)})`);
|
|
43
|
+
expect(state.size.split(/\s+/).some(value=>/^0(?:px|%)?$/.test(value))).toBe(false);
|
|
44
|
+
// Wait for actual response-byte decoding before observing the carrier resource.
|
|
45
|
+
const frame=await host.evaluate(node=>node.ownerDocument.location.href);
|
|
46
|
+
let record;
|
|
47
|
+
await expect.poll(async()=>{
|
|
48
|
+
await captureRendererImageEvidence(host.page());
|
|
49
|
+
record=rendererImageEvidence(host.page())?.find(r=>r.url===absolute&&r.frame===frame&&r.status>=200&&r.status<300&&r.decoded?.width>0&&r.decoded?.height>0);
|
|
50
|
+
return Boolean(record);
|
|
51
|
+
},{timeout,message:'Actual renderer response with decodable bytes before assertion probes: '+absolute}).toBe(true);
|
|
52
|
+
const resource={src:absolute,width:record.decoded.width,height:record.decoded.height,evidence:'same-frame-renderer-response-bytes',sha256:record.sha256};
|
|
53
|
+
return {...state,mode:'native-background',resource};
|
|
54
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '@playwright/test';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
export function pluginEventLedger(plan,validatePluginEvents){
|
|
3
|
+
assert.equal(typeof validatePluginEvents,'function','Explicit plugin schema policy required');
|
|
4
|
+
validatePluginEvents(plan);const rows=[],violations=[];
|
|
5
|
+
return {receive(receipt){
|
|
6
|
+
try{const i=plan.instances.find(i=>i.id===receipt.instanceId);assert(i,'Wrong plugin instance');
|
|
7
|
+
assert.deepEqual(receipt.props,i.props,'Plugin actual props mismatch');
|
|
8
|
+
assert(['mounted','unmounted','event','callback'].includes(receipt.kind),'Unexpected plugin callback failure');
|
|
9
|
+
if(receipt.kind==='mounted'){assert(!rows.some(r=>r.instanceId===i.id&&r.kind==='mounted'),'Duplicate plugin mount');}
|
|
10
|
+
else if(receipt.kind!=='unmounted'){const e=i.events.find(e=>e.id===receipt.eventId);assert(e,'Unknown plugin event id');assert.equal(receipt.name,e.name);if(receipt.kind==='event'){assert(Number.isFinite(receipt.startedAt)&&receipt.at>=receipt.startedAt+e.delayMs,'Event before declared mount delay');if(rows.some(r=>r.instanceId===i.id&&r.kind==='unmounted'))assert(e.afterUnmount===true,'Unplanned event after unmount');}assert.deepEqual(receipt.payload,e.payload);assert(rows.some(r=>r.instanceId===i.id&&r.kind==='mounted'),'Event before real mount');
|
|
11
|
+
assert(!rows.some(r=>r.instanceId===i.id&&r.eventId===e.id&&r.kind===receipt.kind),'Duplicate plugin event receipt');
|
|
12
|
+
if(receipt.kind==='callback'&&Object.hasOwn(e,'callbackResponse'))assert.deepEqual(receipt.value,e.callbackResponse,'Public callback response mismatch');}
|
|
13
|
+
rows.push(structuredClone(receipt));
|
|
14
|
+
}catch(e){violations.push(e.message);throw e;}
|
|
15
|
+
},assertComplete(){assert.deepEqual(violations,[]);for(const i of plan.instances){assert(rows.some(r=>r.kind==='mounted'&&r.instanceId===i.id),'Missing real plugin mount');for(const e of i.events){for(const kind of ['event','callback'])assert(rows.some(r=>r.kind===kind&&r.instanceId===i.id&&r.eventId===e.id),'Missing plugin '+kind);}}},evidence(){return structuredClone({plan,rows,violations});}};
|
|
16
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
export function pluginPackagePlan({appAddress,appId,provider,version='*',packageBaseUrl,unavailable=false}) {
|
|
3
|
+
const base=new URL(packageBaseUrl);
|
|
4
|
+
assert.ok(['http:','https:'].includes(base.protocol));
|
|
5
|
+
assert.ok(['127.0.0.1','localhost','[::1]'].includes(base.hostname),'Plugin must be local compiled test resource');
|
|
6
|
+
assert.ok(base.pathname.endsWith('/'));
|
|
7
|
+
const expected={method:'getPkgInfo',params:[{address:appAddress.replace(/\/$/,'')+'/',appId},{provider,version}]};
|
|
8
|
+
const calls=[],violations=[],resources=[];
|
|
9
|
+
return {
|
|
10
|
+
calls,violations,resources,base:base.href,
|
|
11
|
+
handle(request){calls.push(structuredClone(request));try{
|
|
12
|
+
assert.deepEqual(request,expected,'Unexpected plugin discovery fields or values');
|
|
13
|
+
assert.equal(calls.length,1,'Duplicate plugin discovery');
|
|
14
|
+
return unavailable?{error:1,message:'controlled package unavailable'}:{error:0,data:{version:'dev',cdnBaseUrl:base.href}};
|
|
15
|
+
}catch(error){violations.push(error.message);throw error;}},
|
|
16
|
+
observe(response){if(response.ok()&&response.url().startsWith(base.href))resources.push(new URL(response.url()).pathname.split('/').at(-1));},
|
|
17
|
+
assertComplete({requireResources=!unavailable}={}){
|
|
18
|
+
assert.deepEqual(violations,[],'Plugin discovery violations');assert.equal(calls.length,1,'Missing plugin discovery');
|
|
19
|
+
if(requireResources)for(const name of ['appConfig.json','index.worker.js','index.js'])assert.ok(resources.includes(name),`Real plugin asset not loaded: ${name}`);
|
|
20
|
+
},
|
|
21
|
+
evidence(){return {calls,violations,resources,packageBaseUrl:base.href,unavailable};},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Preflight compiled test plugin identity; no automatic compilation or fallback."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def validate_plugin_artifact(artifact, source):
|
|
8
|
+
artifact, source = Path(artifact).resolve(), Path(source).resolve()
|
|
9
|
+
manifest = json.loads((artifact / 'plugin-artifact.json').read_text())
|
|
10
|
+
if manifest.get('schemaVersion') != 1 or not manifest.get('plan', {}).get('instances'):
|
|
11
|
+
raise ValueError('Explicit compiled plugin plan required')
|
|
12
|
+
expected = {str(p.relative_to(source)): hashlib.sha256(p.read_bytes()).hexdigest()
|
|
13
|
+
for p in sorted(source.rglob('*')) if p.is_file()}
|
|
14
|
+
if manifest.get('sourceHashes') != expected:
|
|
15
|
+
raise ValueError('Compiled plugin does not match frozen carrier source')
|
|
16
|
+
files = manifest.get('artifacts', {})
|
|
17
|
+
if not {'appConfig.json', 'index.js', 'index.worker.js'} <= files.keys():
|
|
18
|
+
raise ValueError('Incomplete official plugin artifacts')
|
|
19
|
+
for name, sha in files.items():
|
|
20
|
+
p = (artifact / name).resolve()
|
|
21
|
+
if artifact not in p.parents or not p.is_file() or hashlib.sha256(p.read_bytes()).hexdigest() != sha:
|
|
22
|
+
raise ValueError('Missing or changed compiled plugin artifact: ' + name)
|
|
23
|
+
return manifest
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Content identity of the actual copied product, independent of branch labels."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def product_identity(files):
|
|
7
|
+
encoded = json.dumps(files, sort_keys=True, ensure_ascii=False, separators=(',', ':')).encode()
|
|
8
|
+
return {'sha256': hashlib.sha256(encoded).hexdigest(), 'kind': 'product-source-content'}
|