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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/package.json +15 -4
  2. package/tools/adapters/islands/trigger_source.py +53 -0
  3. package/tools/cli/backend.mjs +24 -0
  4. package/tools/cli/main.py +38 -0
  5. package/tools/cli/minifish.mjs +38 -0
  6. package/tools/cli/minifish.py +52 -0
  7. package/tools/core/package-identity.mjs +23 -0
  8. package/tools/core/package_identity.py +58 -0
  9. package/tools/core/semantic_identity.py +149 -0
  10. package/tools/core/test_semantic_identity.py +136 -0
  11. package/tools/docs/README.md +43 -0
  12. package/tools/docs/backend-testing.md +36 -0
  13. package/tools/docs/iteration.md +34 -0
  14. package/tools/docs/minifish-testing.md +32 -0
  15. package/tools/docs/semantic-identity.md +61 -0
  16. package/tools/modules/backend/actions.mjs +10 -0
  17. package/tools/modules/backend/assert.mjs +20 -0
  18. package/tools/modules/backend/context-receiver.py +30 -0
  19. package/tools/modules/backend/facility.test.mjs +44 -0
  20. package/tools/modules/backend/grouped_context.py +100 -0
  21. package/tools/modules/backend/grpc.mjs +57 -0
  22. package/tools/modules/backend/index.mjs +9 -0
  23. package/tools/modules/backend/input-tree.mjs +14 -0
  24. package/tools/modules/backend/logs.mjs +24 -0
  25. package/tools/modules/backend/logs.test.mjs +28 -0
  26. package/tools/modules/backend/metadata.mjs +66 -0
  27. package/tools/modules/backend/package-lock.json +362 -0
  28. package/tools/modules/backend/package.json +15 -0
  29. package/tools/modules/backend/paths.mjs +11 -0
  30. package/tools/modules/backend/product.mjs +48 -0
  31. package/tools/modules/backend/rejection-logs.mjs +33 -0
  32. package/tools/modules/backend/report.mjs +13 -0
  33. package/tools/modules/backend/report.test.mjs +13 -0
  34. package/tools/modules/backend/run.mjs +50 -0
  35. package/tools/modules/backend/util.mjs +24 -0
  36. package/tools/modules/minifish/checks/guard.test.mjs +13 -0
  37. package/tools/modules/minifish/checks/package.test.mjs +23 -0
  38. package/tools/modules/minifish/compiler/build-case.mjs +39 -0
  39. package/tools/modules/minifish/compiler/case-plan.mjs +175 -0
  40. package/tools/modules/minifish/compiler/config.mjs +29 -0
  41. package/tools/modules/minifish/compiler/observation-module-name.mjs +2 -0
  42. package/tools/modules/minifish/compiler/package-runner.mjs +43 -0
  43. package/tools/modules/minifish/compiler/semantic-module-gate.mjs +27 -0
  44. package/tools/modules/minifish/compiler/tool-identity.mjs +14 -0
  45. package/tools/modules/minifish/harness/batch_cases.py +91 -0
  46. package/tools/modules/minifish/harness/batch_plugin_groups.py +56 -0
  47. package/tools/modules/minifish/harness/batch_report.py +104 -0
  48. package/tools/modules/minifish/harness/browser.mjs +146 -0
  49. package/tools/modules/minifish/harness/build-plugin-artifact.mjs +25 -0
  50. package/tools/modules/minifish/harness/capture_contract.py +78 -0
  51. package/tools/modules/minifish/harness/carousel-observation.mjs +62 -0
  52. package/tools/modules/minifish/harness/carriers.mjs +49 -0
  53. package/tools/modules/minifish/harness/check-independent-batch.py +84 -0
  54. package/tools/modules/minifish/harness/check-runner-isolation.py +102 -0
  55. package/tools/modules/minifish/harness/component_runtime.py +125 -0
  56. package/tools/modules/minifish/harness/contract-value.mjs +32 -0
  57. package/tools/modules/minifish/harness/deferred-visual.mjs +21 -0
  58. package/tools/modules/minifish/harness/device-prepare.mjs +26 -0
  59. package/tools/modules/minifish/harness/execution-evidence.mjs +37 -0
  60. package/tools/modules/minifish/harness/foreground.mjs +20 -0
  61. package/tools/modules/minifish/harness/header-layout.mjs +47 -0
  62. package/tools/modules/minifish/harness/held-snapshot.mjs +67 -0
  63. package/tools/modules/minifish/harness/host-device-fixture.mjs +28 -0
  64. package/tools/modules/minifish/harness/host-language.mjs +73 -0
  65. package/tools/modules/minifish/harness/host-native-ui.mjs +39 -0
  66. package/tools/modules/minifish/harness/host-navigation.mjs +96 -0
  67. package/tools/modules/minifish/harness/image-loading.mjs +35 -0
  68. package/tools/modules/minifish/harness/independent-case-runner.mjs +175 -0
  69. package/tools/modules/minifish/harness/independent-visual.mjs +107 -0
  70. package/tools/modules/minifish/harness/inline-svg.mjs +32 -0
  71. package/tools/modules/minifish/harness/input-lifecycle-evidence.mjs +16 -0
  72. package/tools/modules/minifish/harness/jsapi.mjs +92 -0
  73. package/tools/modules/minifish/harness/loading-input.mjs +52 -0
  74. package/tools/modules/minifish/harness/loading-observation.mjs +25 -0
  75. package/tools/modules/minifish/harness/local-navigation-evidence.mjs +82 -0
  76. package/tools/modules/minifish/harness/miniapp-screenshot.mjs +38 -0
  77. package/tools/modules/minifish/harness/native-dom-sampling.mjs +40 -0
  78. package/tools/modules/minifish/harness/native-enabled.mjs +11 -0
  79. package/tools/modules/minifish/harness/native-rpc-failure.mjs +11 -0
  80. package/tools/modules/minifish/harness/native-state.mjs +14 -0
  81. package/tools/modules/minifish/harness/native-ui-contract.mjs +29 -0
  82. package/tools/modules/minifish/harness/navigation.mjs +19 -0
  83. package/tools/modules/minifish/harness/network_isolation.py +134 -0
  84. package/tools/modules/minifish/harness/painted-source-image.mjs +54 -0
  85. package/tools/modules/minifish/harness/playwright.mjs +1 -0
  86. package/tools/modules/minifish/harness/plugin-events.mjs +16 -0
  87. package/tools/modules/minifish/harness/plugin-package.mjs +23 -0
  88. package/tools/modules/minifish/harness/plugin_artifact.py +23 -0
  89. package/tools/modules/minifish/harness/product_identity.py +8 -0
  90. package/tools/modules/minifish/harness/project_config.py +14 -0
  91. package/tools/modules/minifish/harness/prototype-visual.mjs +123 -0
  92. package/tools/modules/minifish/harness/region-order.mjs +16 -0
  93. package/tools/modules/minifish/harness/renderer-image-evidence.mjs +32 -0
  94. package/tools/modules/minifish/harness/request-guard.mjs +65 -0
  95. package/tools/modules/minifish/harness/request-plan.mjs +124 -0
  96. package/tools/modules/minifish/harness/response-consumption.mjs +106 -0
  97. package/tools/modules/minifish/harness/retained-page-lifecycle.mjs +48 -0
  98. package/tools/modules/minifish/harness/rpc-wire.mjs +34 -0
  99. package/tools/modules/minifish/harness/run-batch.py +133 -0
  100. package/tools/modules/minifish/harness/run-platform.py +293 -0
  101. package/tools/modules/minifish/harness/runtime_isolation.py +71 -0
  102. package/tools/modules/minifish/harness/scroll-container-observation.mjs +40 -0
  103. package/tools/modules/minifish/harness/semantic-anchor.mjs +139 -0
  104. package/tools/modules/minifish/harness/semantic-observation.mjs +197 -0
  105. package/tools/modules/minifish/harness/skeleton-appearance.mjs +49 -0
  106. package/tools/modules/minifish/harness/target-actions.mjs +120 -0
  107. package/tools/modules/minifish/harness/test_batch_concurrency.py +125 -0
  108. package/tools/modules/minifish/harness/test_batch_plugin_groups.py +97 -0
  109. package/tools/modules/minifish/harness/test_batch_runner.py +107 -0
  110. package/tools/modules/minifish/harness/test_common_report.py +47 -0
  111. package/tools/modules/minifish/harness/test_held_capture_contract.py +50 -0
  112. package/tools/modules/minifish/harness/test_pty_tail.py +39 -0
  113. package/tools/modules/minifish/harness/test_runtime_dependencies.py +49 -0
  114. package/tools/modules/minifish/harness/zero-business-plan.mjs +19 -0
  115. package/tools/modules/minifish/identity.py +6 -0
  116. package/tools/modules/minifish/package-lock.json +74 -0
  117. package/tools/modules/minifish/package.json +10 -0
  118. package/tools/modules/minifish/sdk/package-lock.json +57745 -0
  119. package/tools/modules/minifish/sdk/package.json +13 -0
  120. package/tools/modules/workflow/diff_review.py +74 -0
  121. package/tools/modules/workflow/iteration.py +175 -0
  122. package/README.md +0 -3
@@ -0,0 +1,139 @@
1
+ import {expect} from '@playwright/test';
2
+ // Platform-only adapter. Case definitions use source paths, never this DOM API.
3
+ export const anchorAdapterVersion = 'minifish-2.40.1-appxng-dataset-v2';
4
+
5
+ export function anchorSelector(path) {
6
+ if (typeof path !== 'string' || !path.trim() || /\u0000|[\uD800-\uDFFF]/u.test(path)) {
7
+ throw new Error('Invalid semantic anchor path');
8
+ }
9
+ // Hex escapes work for quotes, slashes, newlines and non-ASCII characters alike.
10
+ const escaped = Array.from(path, c => `\\${c.codePointAt(0).toString(16)} `).join('');
11
+ return `[data-anchor-id="${escaped}"]`;
12
+ }
13
+
14
+ /** Serialized into the actual business frame; never the React simulator shell. */
15
+ export function synchronizeNativeAnchors(root) {
16
+ const doc = root.ownerDocument;
17
+ const win = doc.defaultView;
18
+ const key = '__seeleSemanticAnchorsV2';
19
+ const state = win[key] ?? (win[key] = {owned: new Map(), instances: new Map()});
20
+ if (state.error) throw new Error(state.error);
21
+ state.instances ??= new Map();
22
+ const attr = 'data-anchor-id';
23
+ const instanceAttr = 'data-anchor-instance';
24
+ const releaseInstance = node => {
25
+ const previous = state.instances.get(node);
26
+ if (previous === undefined) return;
27
+ if (node.getAttribute(instanceAttr) !== previous) throw Error('Source instance modified outside adapter');
28
+ node.removeAttribute(instanceAttr);
29
+ state.instances.delete(node);
30
+ };
31
+ let synchronized = 0;
32
+ let recognized = false;
33
+ for (const node of doc.querySelectorAll('*')) {
34
+ if (Object.prototype.hasOwnProperty.call(node, '$$dataset')) recognized = true;
35
+ const data = node.$$dataset;
36
+ const marker = data && Object.prototype.hasOwnProperty.call(data, 'anchorId') ? data.anchorId : undefined;
37
+ const previous = state.owned.get(node);
38
+ if (typeof marker === 'string' && marker.length) {
39
+ const actual = node.getAttribute(attr);
40
+ if (actual !== null && actual !== marker && actual !== previous) {
41
+ throw new Error(`Conflicting semantic anchor: ${marker}`);
42
+ }
43
+ if (actual !== marker) {
44
+ node.setAttribute(attr, marker);
45
+ state.owned.set(node, marker);
46
+ } else if (previous !== undefined) state.owned.set(node, marker);
47
+ const instance=data?.anchorInstance;
48
+ if(instance!==undefined&&instance!==null&&instance!==''){
49
+ if(!/^(0|[1-9]\d*)$/.test(String(instance))||!Number.isSafeInteger(Number(instance)))throw Error('Invalid declared source instance');
50
+ const declared=String(instance), actualInstance=node.getAttribute(instanceAttr), previousInstance=state.instances.get(node);
51
+ if(actualInstance!==null&&actualInstance!==declared&&actualInstance!==previousInstance)throw Error('Conflicting source instance');
52
+ if(actualInstance!==declared){node.setAttribute(instanceAttr,declared);state.instances.set(node,declared);}
53
+ else if(previousInstance!==undefined)state.instances.set(node,declared);
54
+ }else releaseInstance(node);
55
+ synchronized++;
56
+ } else if (previous !== undefined) {
57
+ if (node.getAttribute(attr) !== previous) throw new Error('Semantic anchor modified outside adapter');
58
+ node.removeAttribute(attr);releaseInstance(node);
59
+ state.owned.delete(node);
60
+ }
61
+ }
62
+ for (const [node, marker] of state.owned) {
63
+ if (!node.isConnected) {
64
+ if (node.getAttribute(attr) !== marker)throw Error('Semantic anchor modified outside adapter');
65
+ node.removeAttribute(attr);
66
+ state.owned.delete(node);
67
+ }
68
+ }
69
+ for(const node of state.instances.keys())if(!node.isConnected || !node.$$dataset?.anchorId)releaseInstance(node);
70
+ if (recognized) state.capabilityConfirmed = true;
71
+ if (!state.capabilityConfirmed) throw new Error('Unsupported business renderer: AppX-NG dataset capability absent');
72
+ if (!state.timer && typeof win.setInterval === 'function') {
73
+ state.timer = win.setInterval(() => {
74
+ try { synchronizeNativeAnchors(root); }
75
+ catch (error) { state.error = error.message; win.clearInterval(state.timer); }
76
+ }, 50);
77
+ }
78
+ return {adapter: 'minifish-2.40.1-appxng-dataset-v2', synchronized};
79
+ }
80
+
81
+ export async function synchronizeAnchors(root) {
82
+ await expect.poll(() => root.locator(':root').evaluate(el => {
83
+ const doc=el.ownerDocument;
84
+ return !!doc.defaultView.__seeleSemanticAnchorsV2?.capabilityConfirmed || Array.from(doc.querySelectorAll('*')).some(n => Object.prototype.hasOwnProperty.call(n, '$$dataset'));
85
+ }), {timeout:10000,message:'Wait for actual AppX-NG business renderer capability; unsupported renderers fail'}).toBe(true);
86
+ return root.locator(':root').evaluate(synchronizeNativeAnchors);
87
+ }
88
+
89
+ // Call on every observation attempt, including polling, so a dataset-only update
90
+ // cannot leave the previously synchronized identity in use.
91
+ export async function resolveAnchor(root, path, {allowAbsent = false, instance} = {}) {
92
+ await synchronizeAnchors(root);
93
+ const locator = semanticLocator(root, path, {instance});
94
+ const count = await locator.count();
95
+ if (count > 1 || (!allowAbsent && count !== 1)) {
96
+ throw new Error(`Semantic anchor must be unique: ${path}; found ${count}`);
97
+ }
98
+ return locator;
99
+ }
100
+
101
+ export function semanticLocator(root, path, {instance} = {}) {
102
+ let selector=anchorSelector(path);
103
+ if(instance!==undefined){validateSemanticInstance(instance);selector+='[data-anchor-instance="'+instance+'"]';}
104
+ return root.locator(selector);
105
+ }
106
+
107
+ export async function closeAnchors(root) {
108
+ await root.locator(':root').evaluate(el => {
109
+ const win = el.ownerDocument.defaultView, state = win.__seeleSemanticAnchorsV2;
110
+ if (!state) return;
111
+ win.clearInterval(state.timer);
112
+ for (const [node, marker] of state.owned) if (node.getAttribute('data-anchor-id') === marker) {node.removeAttribute('data-anchor-id');}
113
+ for (const [node, instance] of state.instances ?? []) {
114
+ if(node.getAttribute('data-anchor-instance') !== instance)throw Error('Source instance modified outside adapter');
115
+ node.removeAttribute('data-anchor-instance');
116
+ }
117
+ delete win.__seeleSemanticAnchorsV2;
118
+ if (state.error) throw Error(state.error);
119
+ });
120
+ }
121
+
122
+ // Read the same public renderer dataset used by synchronizeNativeAnchors,
123
+ // without changing attributes or waiting for its timer during held input.
124
+ export function readNativeSemanticIdentity(node){
125
+ if(Object.prototype.hasOwnProperty.call(node,'$$dataset')){
126
+ const data=node.$$dataset;
127
+ return {path:typeof data?.anchorId==='string'&&data.anchorId?data.anchorId:null,instance:data?.anchorInstance===undefined||data?.anchorInstance===null||data?.anchorInstance===''?null:String(data.anchorInstance)};
128
+ }
129
+ return {path:node.getAttribute('data-anchor-id'),instance:node.getAttribute('data-anchor-instance')};
130
+ }
131
+
132
+ // Undefined is an omitted constraint, never a request for an uninstanced node.
133
+ export function validateSemanticInstance(instance){
134
+ if(instance!==undefined&&(!Number.isSafeInteger(instance)||instance<0))throw Error('Invalid source instance');
135
+ return instance;
136
+ }
137
+ export function matchesNativeSemanticIdentity(identity,path,instance){
138
+ return identity.path===path&&(instance===undefined||identity.instance===String(instance));
139
+ }
@@ -0,0 +1,197 @@
1
+ import {sampleNativeTextVisibility,sampleNativeStyle,sampleNativePressed} from './native-dom-sampling.mjs';
2
+ import {near,geometryNear} from './independent-visual.mjs';
3
+ // Public platform-only read/expect surface. No business selectors or expectations.
4
+ import assert from 'node:assert/strict';
5
+ import {expect} from '@playwright/test';
6
+ export {expect};
7
+ // Share the existing strict native editor resolver used by public input actions.
8
+ import {editableCarrier} from './target-actions.mjs';
9
+ export {editableCarrier};
10
+ import {semanticLocator} from './semantic-anchor.mjs';
11
+ export {semanticLocator};
12
+ export function semanticCollection(root,paths){
13
+ if(!Array.isArray(paths)||!paths.length||new Set(paths).size!==paths.length)throw Error('Collection requires distinct declared source paths');
14
+ return paths.map(path=>semanticLocator(root,path)).reduce((collection,node)=>collection.or(node));
15
+ }
16
+ export function nativePart(node,{role,index}={}){
17
+ const roles={text:'.a-text',img:'[role="img"]',button:'[role="button"]',separator:'[role="separator"]',presentation:'[role="presentation"]'};
18
+ if(!roles[role])throw Error('Unknown native part role');
19
+ const part=node.locator(roles[role]);return index===undefined?part:part.nth(index);
20
+ }
21
+ export async function nativeAttribute(node,name){return node.getAttribute(name);}
22
+ // Editor-only attribute observation; preserve the semantic wrapper for geometry.
23
+ export async function nativeEditableAttribute(node,name,{timeout=10000}={}){
24
+ assert(['maxlength','placeholder'].includes(name),'Only declared editable maxlength/placeholder attributes');
25
+ const editor=await editableCarrier(node,timeout);
26
+ let value=await editor.getAttribute(name);
27
+ if(name==='placeholder'&&value===null){
28
+ // Public SDK input renders its placeholder as a sibling entity, not an HTML attribute.
29
+ value=await editor.evaluate(input=>{
30
+ const wrapper=input.closest('.a-input'),wrap=input.parentElement;
31
+ if(!wrapper||!wrap?.classList.contains('a-input-wrap')||input.value!=='')return null;
32
+ const nodes=[...wrap.children].filter(e=>e.classList.contains('a-input-placeholder'));
33
+ if(nodes.length!==1)return null;
34
+ const node=nodes[0],leaves=node.querySelectorAll('span');if(leaves.length!==1)return null;
35
+ for(let e=leaves[0];e;e=e.parentElement){const s=getComputedStyle(e);if(s.display==='none'||s.visibility!=='visible'||Number(s.opacity)===0)return null;if(e===wrapper)break;}
36
+ const box=leaves[0].getBoundingClientRect();if(box.width<=0||box.height<=0)return null;
37
+ return leaves[0].innerText||null;
38
+ });
39
+ }
40
+ assert(value!==null,'Native editable attribute is missing: '+name);
41
+ return value;
42
+ }
43
+ export async function nativeStyle(node,keys,pseudo){return node.evaluate(sampleNativeStyle,{keys,pseudo});}
44
+ export async function nativePaint(node){return node.evaluate(e=>{let opacity=1;for(let n=e;n;n=n.parentElement)opacity*=Number(getComputedStyle(n).opacity);const r=e.getBoundingClientRect(),hit=document.elementFromPoint(r.x+r.width/2,r.y+r.height/2);return {opacity,hit:!!hit&&(hit===e||e.contains(hit))};});}
45
+ export async function nativeBorder(node){return node.evaluate(e=>{const s=getComputedStyle(e);return {width:parseFloat(s.borderTopWidth),style:s.borderTopStyle,color:s.borderTopColor,dpr:devicePixelRatio};});}
46
+ export async function nativeDivider(node){const s=await nativeStyle(node,['content','height','borderLeftWidth','borderLeftColor'],'::before');return {content:s.content,height:parseFloat(s.height),width:parseFloat(s.borderLeftWidth),color:s.borderLeftColor};}
47
+ export async function nativeConnector(node){const part=nativePart(node,{role:'presentation'});if(await part.count()>1)throw Error('Duplicate native decoration');return nativeStyle(await part.count()?part:node,['content','left','top','width','height','backgroundColor'],await part.count()?undefined:'::after');}
48
+ export function nativeParent(node){return node.locator('..');}
49
+
50
+ export async function resolveSemanticCollection(root,paths){
51
+ for(const path of paths)assert((await semanticLocator(root,path).count())<=1,'Duplicate declared collection member');
52
+ return semanticCollection(root,paths);
53
+ }
54
+ export async function assertSourceInstances(node,indices){
55
+ await expect(node).toHaveCount(indices.length);
56
+ for(const [i,index]of indices.entries()){
57
+ assert(Number.isSafeInteger(index)&&index>=0,'Invalid declared source order');
58
+ assert.equal(await node.nth(i).getAttribute('data-anchor-instance'),String(index),'Rendered collection source order');
59
+ }
60
+ }
61
+ export async function nativePressed(node){return node.evaluate(sampleNativePressed);}
62
+
63
+ export function nativeNext(node){return node.locator('xpath=following-sibling::*[1]');}
64
+
65
+ export function validateNativeTextStyles(leaves,wanted,u=1){
66
+ assert(leaves.length,'Actual owned native text leaf required');
67
+ for(const leaf of leaves)for(const [key,value]of Object.entries(wanted??{})){
68
+ if(typeof value==='number'&&key!=='fontWeight')near(parseFloat(leaf[key]),value*u,'leaf '+key);
69
+ else assert.equal(key==='fontWeight'?Number(leaf[key]):leaf[key],value,'leaf '+key);
70
+ }
71
+ }
72
+ // Read actual text paint boundaries; a visible-overflow source box is not a clip.
73
+ export async function observeNativeTextVisibility(node,{selfOverflow='contain',sourceTarget,expected}={}){
74
+ assert(['contain','visible'].includes(selfOverflow),'Unknown source text display policy');
75
+ const evidence=await node.evaluate(sampleNativeTextVisibility);
76
+ return validateNativeTextVisibility(evidence,{selfOverflow,sourceTarget,expected});
77
+ }
78
+ export function validateNativeTextVisibility(evidence,{selfOverflow='contain',sourceTarget,expected}={}){
79
+ assert(['contain','visible'].includes(selfOverflow),'Unknown source text display policy');
80
+ try {
81
+ if(selfOverflow==='visible'){assert.equal(evidence.overflowX,'visible','Source permits visible self overflow');assert.equal(evidence.overflowY,'visible','Source permits visible self overflow');assert.equal(evidence.textOverflow,'clip','Source text-overflow clip');}
82
+ for(const entry of evidence.entries){assert(entry.visible&&entry.opacity>0,'Text must actually paint');const bounds=[...entry.clips,{...evidence.viewport,clipX:true,clipY:true},...(selfOverflow==='contain'?[{...evidence.box,clipX:true,clipY:true}]:[])];
83
+ for(const b of bounds){const r=entry.range;if(b.clipX)assert(r.x>=b.x-.5&&r.x+r.width<=b.x+b.width+.5,'Actual text horizontal clipping');if(b.clipY)assert(r.y>=b.y-.5&&r.y+r.height<=b.y+b.height+.5,'Actual text vertical clipping');}
84
+ }
85
+ return evidence;
86
+ }catch(error){error.message+='\nNative text evidence: '+JSON.stringify(evidence);throw error;}
87
+ }
88
+
89
+ // Official SDK checkbox: role carrier owns one direct typed checkbox input.
90
+ // Read state from the real native input and aria, never a business class/prop.
91
+ export async function nativeCheckbox(node){
92
+ const carrier=node.locator('[role="checkbox"]');
93
+ const selfRole=await node.getAttribute('role');
94
+ const circle=selfRole==='checkbox'?node:carrier;
95
+ await expect(circle).toHaveCount(1);
96
+ const state=await circle.evaluate(el=>{
97
+ const inputs=[...el.children].filter(n=>n.tagName==='INPUT'&&n.type==='checkbox');
98
+ assertNative(inputs.length===1,'Checkbox must own one direct native input');
99
+ const input=inputs[0],aria=el.getAttribute('aria-checked');
100
+ assertNative(aria==='true'||aria==='false','Native checkbox aria state');
101
+ assertNative(input.checked===(aria==='true'),'Native input and aria must agree');
102
+ const paths=[...el.querySelectorAll('svg path')].map(p=>({fill:getComputedStyle(p).fill,stroke:getComputedStyle(p).stroke,d:p.getAttribute('d')}));
103
+ return {checked:input.checked,enabled:!input.disabled,aria,paths};
104
+ function assertNative(ok,message){if(!ok)throw Error(message);}
105
+ });
106
+ return {circle,state};
107
+ }
108
+
109
+ export async function assertNativeOwnership(parent,child,{instance}={}){
110
+ await expect(parent).toHaveCount(1);await expect(child).toHaveCount(1);
111
+ const parentHandle=await parent.elementHandle();
112
+ try{assert(await child.evaluate((el,p)=>p!==el&&p.contains(el),parentHandle),'Actual native child must belong to selected source parent');}
113
+ finally{await parentHandle.dispose();}
114
+ if(instance!==undefined){assert.equal(await parent.getAttribute('data-anchor-instance'),String(instance));assert.equal(await child.getAttribute('data-anchor-instance'),String(instance));}
115
+ }
116
+
117
+ // A same-document native renderer reference, not mathematical DPR flooring.
118
+ // This observes CSS used border geometry; it does not certify raster screenshots.
119
+ export async function nativeBorderReference(node,{width,designWidth=375,unit='scaled',side='top',style='solid',color}={}){
120
+ assert(Number.isFinite(width)&&width>0,'Positive declared source border');assert(['scaled','px'].includes(unit));assert(['top','right','bottom','left'].includes(side));assert(['solid','dashed'].includes(style));
121
+ return node.evaluate((el,spec)=>{
122
+ const cssWidth=spec.width*(spec.unit==='px'?1:innerWidth/spec.designWidth);
123
+ const reference=el.ownerDocument.createElement('div');
124
+ reference.setAttribute('aria-hidden','true');
125
+ reference.style.cssText='all:initial;position:fixed;left:0;top:0;display:block;width:10px;height:10px;box-sizing:content-box;pointer-events:none;z-index:2147483647;background:transparent;';
126
+ const cap=spec.side[0].toUpperCase()+spec.side.slice(1);
127
+ reference.style['border'+cap]=cssWidth+'px '+spec.style+' '+spec.color;
128
+ el.ownerDocument.body.append(reference);
129
+ try{const s=getComputedStyle(reference),b=reference.getBoundingClientRect(),used=parseFloat(s['border'+cap+'Width']);
130
+ if(!(used>0&&b.width>0&&b.height>0&&devicePixelRatio>0))throw Error('Native reference must have nonzero actual used border geometry');
131
+ return {width:used,style:s['border'+cap+'Style'],color:s['border'+cap+'Color'],dpr:devicePixelRatio,requestedCssWidth:cssWidth,side:spec.side,frame:location.href,referenceBox:{width:b.width,height:b.height},level:'same-frame-native-css-used-border'};
132
+ }finally{reference.remove();}
133
+ },{width,designWidth,unit,side,style,color});
134
+ }
135
+
136
+ // Finite native topology of a selected source card/header. No business classes,
137
+ // visible-text filtering, props, or fabricated semantic anchors.
138
+ export async function nativeVoucherParts(node,kind){
139
+ assert(['card','sheet'].includes(kind),'Finite native voucher part scope');
140
+ const views=node.locator(':scope > .a-view');await expect(views).toHaveCount(2);
141
+ if(kind==='sheet')return {header:views.nth(0),list:views.nth(1)};
142
+ const value=views.nth(0),copy=views.nth(1),currency=value.locator(':scope > .a-text').nth(1),watermark=node.locator(':scope > .a-text[aria-hidden="true"]');
143
+ const texts=value.locator(':scope > .a-text');const count=await texts.count();assert([2,3].includes(count),'Exact native value/currency/optional restriction topology');
144
+ assert((await watermark.count())<=1,'Duplicate native watermark');
145
+ return {value,copy,currency,watermark,valueTextCount:count};
146
+ }
147
+
148
+ // Resolve an inner content group by ownership and painted-child structure,
149
+ // never by expected text, color, dimensions, class or prototype HTML tags.
150
+ export async function nativeInnerBox(node){
151
+ const evidence=await node.evaluate(root=>{
152
+ const rect=e=>{const b=e.getBoundingClientRect();return {x:b.x,y:b.y,width:b.width,height:b.height};};
153
+ const visible=e=>{const b=rect(e);if(!b.width||!b.height)return false;for(let p=e;p;p=p.parentElement){const s=getComputedStyle(p);if(s.display==='none'||s.visibility!=='visible'||Number(s.opacity)===0)return false;if(p===root)break;}return true;};
154
+ const text=[];const walker=document.createTreeWalker(root,NodeFilter.SHOW_TEXT);
155
+ for(let n=walker.nextNode();n;n=walker.nextNode())if(n.textContent.trim()&&visible(n.parentElement))text.push(n);
156
+ if(!text.length)throw Error('Inner box requires painted text');
157
+ let box;
158
+ for(let p=text[0].parentElement;p&&p!==root;p=p.parentElement){
159
+ if(!text.every(n=>p.contains(n)))continue;
160
+ const children=[...p.children].filter(visible);
161
+ if(children.length===2&&children.filter(c=>text.some(n=>c.contains(n))).length===1){box=p;break;}
162
+ }
163
+ if(!box)throw Error('Source-owned inner content box missing; root cannot substitute');
164
+ // Painted rectangles remain evidence. Layout uses fractional used border sizes
165
+ // and removes each child's own transform in the inner box's logical frame.
166
+ const borderSize=e=>{
167
+ const s=getComputedStyle(e),n=k=>{const v=Number.parseFloat(s[k]);if(!Number.isFinite(v))throw Error('Unresolved public layout dimension: '+k);return v;};
168
+ return {width:n('width')+(s.boxSizing==='border-box'?0:n('paddingLeft')+n('paddingRight')+n('borderLeftWidth')+n('borderRightWidth')),height:n('height')+(s.boxSizing==='border-box'?0:n('paddingTop')+n('paddingBottom')+n('borderTopWidth')+n('borderBottomWidth'))};
169
+ };
170
+ const matrix=e=>{
171
+ const s=getComputedStyle(e);
172
+ if((s.rotate&&s.rotate!=='none')||(s.scale&&s.scale!=='none')||(s.translate&&s.translate!=='none')||(s.perspective&&s.perspective!=='none')||(s.zoom&&Number(s.zoom)!==1))throw Error('Unsupported independent public layout transform');
173
+ const m=new DOMMatrixReadOnly(s.transform==='none'?undefined:s.transform);
174
+ if(!m.is2D)throw Error('Unsupported 3D public layout transform');
175
+ return m;
176
+ };
177
+ let scaleX=1,scaleY=1;
178
+ for(let p=box;p;p=p.parentElement){const m=matrix(p);if(Math.abs(m.b)>1e-10||Math.abs(m.c)>1e-10||m.a<=0||m.d<=0)throw Error('Unsupported non-axis public layout ancestor transform');scaleX*=m.a;scaleY*=m.d;}
179
+ const paintedBox=rect(box),size=borderSize(box);
180
+ if(Math.abs(paintedBox.width/scaleX-size.width)>.25||Math.abs(paintedBox.height/scaleY-size.height)>.25)throw Error('Public layout frame dimension mismatch');
181
+ const childNodes=[...box.children].filter(visible),children=childNodes.map(rect);
182
+ const layoutChildren=childNodes.map(e=>{
183
+ const size=borderSize(e),m=matrix(e),origin=getComputedStyle(e).transformOrigin.split(' ').map(Number.parseFloat),painted=rect(e);
184
+ if(!origin.every(Number.isFinite))throw Error('Unresolved public transform origin');
185
+ const corners=[[0,0],[size.width,0],[0,size.height],[size.width,size.height]].map(([x,y])=>({x:m.a*(x-origin[0])+m.c*(y-origin[1])+m.e+origin[0],y:m.b*(x-origin[0])+m.d*(y-origin[1])+m.f+origin[1]}));
186
+ const xs=corners.map(p=>p.x),ys=corners.map(p=>p.y),minX=Math.min(...xs),minY=Math.min(...ys);
187
+ if(Math.abs(painted.width/scaleX-(Math.max(...xs)-minX))>.25||Math.abs(painted.height/scaleY-(Math.max(...ys)-minY))>.25)throw Error('Public transformed child dimension mismatch');
188
+ return {x:(painted.x-paintedBox.x)/scaleX-minX,y:(painted.y-paintedBox.y)/scaleY-minY,...size};
189
+ });
190
+ const layout={coordinateSpace:'inner-border-box-css-px',box:{x:0,y:0,...size},children:layoutChildren};
191
+ const s=getComputedStyle(box);
192
+ return {ownership:'descendant-of-semantic-carrier',box:paintedBox,children,layout,styles:Object.fromEntries(['backgroundColor','paddingTop','paddingRight','paddingBottom','paddingLeft','borderRadius','gap','display','flexDirection','alignItems'].map(k=>[k,s[k]]))};
193
+ });
194
+ assert.equal(evidence.ownership,'descendant-of-semantic-carrier');return evidence;
195
+ }
196
+
197
+ export async function sampleTextVisibility(node){return node.evaluate(sampleNativeTextVisibility);}
@@ -0,0 +1,49 @@
1
+ import assert from 'node:assert/strict';
2
+ import {expect} from '@playwright/test';
3
+ import {nativeMetrics,near,geometryNear} from './independent-visual.mjs';
4
+
5
+ export function assertSkeletonReceipt(records,m,a) {
6
+ const receipts=records.filter(r=>r.url===a.expected&&r.frame===m.frame&&r.frameId===m.frameId);
7
+ assert(receipts.some(r=>r.status===200&&r.bytes>0&&r.sha256&&r.decoded?.width===a.naturalWidth&&r.decoded?.height===a.naturalHeight&&!r.decodeError),'Same renderer/frame successful original bytes decode to source dimensions');
8
+ }
9
+ export function assertSkeletonSource(m,a) {
10
+ if(m.image.styles.backgroundImage!=='none')assert.equal(m.image.styles.backgroundImage,`url("${a.expected}")`,'Exact sole painted background source');
11
+ else assert.equal(m.painting.src,a.expected,'Actual image carrier source; hidden descendants cannot substitute');
12
+ }
13
+ export async function skeletonMetrics(locator,region) {
14
+ const image=await nativeMetrics(locator),container=await nativeMetrics(region);
15
+ const painting=await locator.evaluate(el=>{const s=getComputedStyle(el);return {backgroundSize:s.backgroundSize,backgroundPosition:s.backgroundPosition,backgroundRepeat:s.backgroundRepeat,objectFit:s.objectFit,objectPosition:s.objectPosition,tag:el.tagName,src:el.currentSrc||el.src||el.getAttribute('src')};});
16
+ const clipping=await region.evaluate(el=>{const s=getComputedStyle(el);return {overflowX:s.overflowX,overflowY:s.overflowY,scrollWidth:el.scrollWidth,clientWidth:el.clientWidth};});
17
+ return {image,container,painting,clipping};
18
+ }
19
+ export function assertSkeletonGeometry(m,a) {
20
+ const {image:i,container:c,painting:p,clipping:k}=m;
21
+ geometryNear(i.viewport.width,a.logicalWidth,'logical width');geometryNear(i.viewport.height,a.logicalHeight,'business viewport');
22
+ geometryNear(c.box.x,0,'region left');geometryNear(c.box.y,0,'region top');geometryNear(c.box.width,i.viewport.width,'region width');geometryNear(c.box.height,i.viewport.height,'region full business height');
23
+ geometryNear(i.box.x,c.box.x,'image left');geometryNear(i.box.y,c.box.y,'image top');geometryNear(i.box.width,c.box.width,'fill width');geometryNear(i.box.height,c.box.width*a.naturalHeight/a.naturalWidth,'source aspect ratio');
24
+ assert.equal(c.styles.backgroundColor,a.backgroundColor,'loading background');
25
+ assert(['hidden','clip'].includes(k.overflowX)&&['hidden','clip'].includes(k.overflowY),'Source overflow clips both axes');
26
+ assert(k.scrollWidth<=k.clientWidth,'No horizontal overflow');
27
+ if(i.styles.backgroundImage!=='none') {
28
+ assertSkeletonSource(m,a);
29
+ assert(['100% 100%','100% auto','contain','cover'].includes(p.backgroundSize),'Native background fit preserves source aspect ratio in equal-ratio carrier');
30
+ assert(['0% 0%','0px 0px','50% 50%'].includes(p.backgroundPosition),'Native background position matches full carrier');
31
+ assert.equal(p.backgroundRepeat,'no-repeat','No repeated image');
32
+ } else {
33
+ assert.equal(p.src,a.expected,'Actual native image source');
34
+ assert(['fill','contain','cover'].includes(p.objectFit),'Image painting fits equal-ratio carrier');
35
+ assert(['50% 50%','0% 0%','0px 0px'].includes(p.objectPosition),'Native image position');
36
+ }
37
+ }
38
+ export async function assertSkeletonAppearance(locator,region,a,images) {
39
+ await expect(locator).toHaveCount(1);await expect(region.getByRole('img')).toHaveCount(1);await expect(locator).toBeVisible();await expect(locator).toHaveRole('img');
40
+ if(a.kind==='skeleton-content')await expect(locator).toHaveAccessibleName(a.name,{exact:true});
41
+ const m=await skeletonMetrics(locator,region);
42
+ const handle=await locator.elementHandle();try{m.image.frameId=images.frameId(await handle.ownerFrame());}finally{await handle.dispose();}
43
+ if(a.kind==='skeleton-content') {
44
+ assertSkeletonSource(m,a);
45
+ await expect.poll(()=>{try{assertSkeletonReceipt(images.records,m.image,a);return true;}catch{return false;}},{message:'Actual renderer receipt and original response decode'}).toBe(true);
46
+ assertSkeletonReceipt(images.records,m.image,a);
47
+ }else assertSkeletonGeometry(m,a);
48
+ return m;
49
+ }
@@ -0,0 +1,120 @@
1
+ import assert from 'node:assert/strict';
2
+ import {validateLifecycleAction} from './retained-page-lifecycle.mjs';
3
+ import {expect} from '@playwright/test';
4
+ import {waitNativeEnabled} from './native-enabled.mjs';
5
+ import {waitForNativeInput} from './loading-input.mjs';
6
+ export function validateTargetActions(before=[],after=[],boundaries=[]) {
7
+ assert(Array.isArray(before)&&Array.isArray(after),'Target actions must be arrays');
8
+ const ids=new Set(),released=new Set();let pressed=null;
9
+ for(const [phase,items] of [['before',before],['after',after]])for(const a of items){
10
+ assert(a && typeof a.id==='string' && a.id.trim(),'Action id required');assert(!ids.has(a.id),'Duplicate target action id');ids.add(a.id);
11
+ assert(['click','press','release','wait-state','input','focus','blur','clear','release-boundary','leave-return'].includes(a.kind),'Unknown target action kind');
12
+ for(const key of ['selector','locator','evaluate','getByText','getByRole','xpath'])assert(!Object.hasOwn(a,key),'Forbidden DOM bypass: '+key);
13
+ if(a.timeout!==undefined)assert(Number.isInteger(a.timeout)&&a.timeout>0&&a.timeout<=45000,'Invalid action timeout');
14
+ if(a.kind==='leave-return'){assert.equal(phase,'before');assert.equal(pressed,null,'Cannot leave with held input');validateLifecycleAction(a,boundaries);continue;}
15
+ if(a.kind==='release-boundary'){
16
+ assert(!Object.hasOwn(a,'target')&&!Object.hasOwn(a,'value'),'Boundary release only names its declared boundary');
17
+ const b=boundaries.find(b=>b.id===a.boundaryId);assert(b&&b.outcome.kind==='hold'&&b.outcome.release,'Declared releasable held boundary required');
18
+ assert(!released.has(a.boundaryId),'Duplicate boundary release');released.add(a.boundaryId);continue;
19
+ }
20
+ assert(typeof a.target==='string' && a.target.trim(),'Semantic target required');
21
+ if(a.kind==='input')assert.equal(typeof a.value,'string','Input requires raw string value');
22
+ else assert(!Object.hasOwn(a,'value'),'Only input accepts value');
23
+ if(phase==='after')assert.equal(a.kind,'release','Only normal release follows target assertions');
24
+ if(a.kind==='wait-state')assert(['visible','hidden','enabled','disabled'].includes(a.state),'Unknown semantic state');
25
+ if(a.timeout!==undefined)assert(Number.isInteger(a.timeout)&&a.timeout>0&&a.timeout<=45000,'Invalid action timeout');
26
+ if(a.boundaryId)assert(boundaries.some(b=>b.id===a.boundaryId),'Unknown action boundary');
27
+ if(a.kind==='press'){assert.equal(pressed,null,'Press must release before next press');pressed={target:a.target,instance:a.instance};assert(!a.boundaryId,'Navigation belongs to release, not press');}
28
+ if(a.kind==='release'){assert.deepEqual({target:a.target,instance:a.instance},pressed,'Release must match a press');pressed=null;}
29
+ }
30
+ assert.equal(pressed,null,'Press requires declared release');
31
+ }
32
+ export function targetActions({page,locate,synchronize,boundary,receipts=[],observeInput,beforePress}) {
33
+ let held=null;
34
+ async function waitBoundary(id,timeout){
35
+ const row=()=>boundary.evidence().expectations.find(r=>r.id===id);
36
+ assert(row(),'Declared boundary required');await expect.poll(()=>row().received,{timeout}).toBe(row().times);
37
+ if(row().outcome.kind!=='hold'||row().released){
38
+ await expect.poll(()=>row().delivered,{timeout}).toBe(row().times);
39
+ if(row().requireConsumption)await expect.poll(()=>row().consumed,{timeout}).toBe(row().times);
40
+ }
41
+ }
42
+ async function release(){assert(held,'No pressed input');await page.mouse.up();held=null;}
43
+ const pendingReleases=new Map();
44
+ return {
45
+ async releaseHeldSnapshot(action){
46
+ // No synchronization, DOM sampling, screenshots or boundary wait before native up.
47
+ assert.equal(action.kind,'release');assert.equal(held?.target,action.target);assert.equal(held?.instance,action.instance);
48
+ const receipt={id:action.id,kind:action.kind,target:action.target,status:'released-awaiting-boundary',protocol:'immediate-native-up-before-node-validation'};
49
+ receipts.push(receipt);await release();pendingReleases.set(action.id,receipt);return receipt;
50
+ },
51
+ async completeHeldRelease(action){
52
+ const receipt=pendingReleases.get(action.id);assert(receipt,'Original immediate release required');
53
+ try{if(action.boundaryId)await waitBoundary(action.boundaryId,action.timeout??10000);receipt.status='passed';}
54
+ catch(error){receipt.status='failed';receipt.error=error.message;throw error;}
55
+ finally{pendingReleases.delete(action.id);}
56
+ },
57
+ async run(action){
58
+ const timeout=action.timeout??10000;
59
+ const receipt={id:action.id,kind:action.kind,target:action.target,status:'running'};receipts.push(receipt);
60
+ try{
61
+ await synchronize?.();
62
+ if(action.kind==='release-boundary'){
63
+ await boundary.releaseBoundary(action.boundaryId);await waitBoundary(action.boundaryId,timeout);receipt.status='passed';return;
64
+ }
65
+ const locator=locate(action.target,action.instance);
66
+ if(action.kind==='wait-state'){
67
+ if(action.state==='hidden'){
68
+ await expect.poll(async()=>{const n=await locator.count();assert(n<=1,'Duplicate semantic target');return n===0||!await locator.isVisible();},{timeout}).toBe(true);
69
+ }else {await expect(locator).toHaveCount(1,{timeout});
70
+ if(action.state==='visible')await expect(locator).toBeVisible({timeout});
71
+ else await waitNativeEnabled(locator,action.state==='enabled',{timeout});}
72
+ }else if(action.kind==='release'){
73
+ await observeInput?.('before-release',action);
74
+ assert.equal(held?.target,action.target,'Release target mismatch');assert.equal(held?.instance,action.instance,'Release source instance mismatch');await release();await observeInput?.('after-release',action);
75
+ }else {
76
+ await expect(locator).toHaveCount(1,{timeout});await expect(locator).toBeVisible({timeout});await waitNativeEnabled(locator,true,{timeout});
77
+ await locator.scrollIntoViewIfNeeded({timeout});await waitForNativeInput(page,locator,{timeout});
78
+ if(['input','focus','blur','clear'].includes(action.kind)){
79
+ assert(!held,'Cannot edit while pressed');
80
+ const native=await editableCarrier(locator,timeout);
81
+ receipt.before=await editableState(native);
82
+ if(action.kind==='input')await native.fill(action.value,{timeout});
83
+ else if(action.kind==='clear')await native.fill('',{timeout});
84
+ else if(action.kind==='focus')await native.focus({timeout});
85
+ else {
86
+ // A browser Tab transfers focus through the real native keyboard path.
87
+ await native.focus({timeout});await native.press('Tab',{timeout});
88
+ await expect(native).not.toBeFocused({timeout});
89
+ }
90
+ receipt.after=await editableState(native);
91
+ }else if(action.kind==='click'){assert(!held,'Cannot click while pressed');await locator.click({timeout});}
92
+ else {assert.equal(action.kind,'press');assert(!held,'Already pressed');await beforePress?.();const box=await locator.boundingBox();assert(box,'Press target requires native box');
93
+ const x=box.x+box.width/2,y=box.y+box.height/2;await page.mouse.move(x,y);held={target:action.target,instance:action.instance,x,y,pageUrl:page.url(),frameUrl:await locator.evaluate(()=>location.href)};receipt.page={url:held.pageUrl,frameUrl:held.frameUrl};await page.mouse.down();receipt.point={x,y};await observeInput?.('after-press',action);}
94
+ }
95
+ if(action.boundaryId)await waitBoundary(action.boundaryId,timeout);
96
+ receipt.status='passed';
97
+ }catch(error){receipt.status='failed';receipt.error=error.message;throw error;}
98
+ },
99
+ async cleanup(){if(held){const target=held.target;await release();receipts.push({id:'$cleanup-release',kind:'release',target,status:'passed'});}},
100
+ async cancelForArtifact(clip){
101
+ assert(held,'Artifact cancellation requires actual held input');
102
+ const viewport=page.viewportSize(),candidates=[{x:0,y:0},{x:viewport.width-1,y:viewport.height-1}],point=candidates.find(p=>p.x<clip.x||p.y<clip.y||p.x>=clip.x+clip.width||p.y>=clip.y+clip.height);
103
+ assert(point,'A point outside the captured device is required for native cancellation');
104
+ const target=held.target;await page.mouse.move(point.x,point.y);await release();
105
+ receipts.push({id:'$artifact-cancel',kind:'cancel',target,point,protocol:'native-move-outside-device-then-up',status:'passed'});
106
+ },
107
+ };
108
+ }
109
+
110
+ // Resolve only the anchored native editor or its unique native descendant.
111
+ // Case data cannot supply CSS, JS, or an alternative candidate policy.
112
+ export async function editableCarrier(locator,timeout=10000){
113
+ const own=await locator.evaluate(el=>el.matches('input:not([type=hidden]),textarea,[contenteditable=true]'));
114
+ const native=own?locator:locator.locator('input:not([type=hidden]),textarea,[contenteditable=true]');
115
+ await expect(native).toHaveCount(1,{timeout});await expect(native).toBeVisible({timeout});
116
+ await expect(native).toBeEditable({timeout});return native;
117
+ }
118
+ async function editableState(locator){
119
+ return locator.evaluate(el=>({value:'value' in el?el.value:el.textContent,focused:el.ownerDocument.activeElement===el}));
120
+ }
@@ -0,0 +1,125 @@
1
+ """Concurrency controls use stub runtimes, never business coverage or SDK startup."""
2
+ import importlib.util
3
+ import json
4
+ from pathlib import Path
5
+ import signal
6
+ import sys
7
+ import tempfile
8
+ import threading
9
+ import unittest
10
+ from unittest.mock import patch
11
+
12
+ spec = importlib.util.spec_from_file_location('batch', Path(__file__).with_name('run-batch.py'))
13
+ batch = importlib.util.module_from_spec(spec)
14
+ spec.loader.exec_module(batch)
15
+
16
+
17
+ class StubWorkers:
18
+ """Only replace worker subprocesses; execute the real scheduler and certification."""
19
+ def __init__(self, expected):
20
+ self.barrier = threading.Barrier(expected)
21
+ self.lock = threading.Lock()
22
+ self.active = self.peak = self.launched = 0
23
+
24
+ def __call__(self, command, **kwargs):
25
+ owner = self
26
+ with self.lock:
27
+ self.active += 1
28
+ self.launched += 1
29
+ self.peak = max(self.peak, self.active)
30
+ number = self.launched
31
+ class Process:
32
+ def poll(self): return None
33
+ def send_signal(self, signum): pass
34
+ def wait(self):
35
+ if number <= owner.barrier.parties: owner.barrier.wait(timeout=5)
36
+ rows = json.loads(Path(command[command.index('--independent-case-list')+1]).read_text())
37
+ out = Path(command[command.index('--report-dir')+1]); out.mkdir()
38
+ (out/'batch-results.json').write_text(json.dumps([dict(row, status='passed') for row in rows]))
39
+ (out/'lifecycle.json').write_text(json.dumps(dict(inputsVerified=True, children=[dict(returncode=0)], control='stub-runtime')))
40
+ (out/'network-lifecycle.json').write_text(json.dumps(dict(namespaceCleanup=dict(remaining=[]), children=[dict(returncode=0)], control='stub-runtime')))
41
+ with owner.lock: owner.active -= 1
42
+ return 0
43
+ return Process()
44
+
45
+
46
+ def fixture(root, count):
47
+ project = root/'project'; project.mkdir()
48
+ (project/'plugins').mkdir()
49
+ config = project/'config.json'
50
+ config.write_text(json.dumps(dict(schemaVersion=1, repositoryRoot='.', pageRoute='stub/page', pluginSource='plugins', localComponentPackages=[])))
51
+ product = root/'product'; product.mkdir(); (product/'source').write_text('stub product')
52
+ sdk = root/'sdk/node_modules/.bin'; sdk.mkdir(parents=True); (sdk/'minifish2').write_text('stub')
53
+ rows = []
54
+ for i in range(count):
55
+ package = root/f'case-{i}'
56
+ (package/'node_modules/.bin').mkdir(parents=True)
57
+ for name in ('playwright.config.mjs','package.json','package-lock.json','node_modules/.bin/playwright'):
58
+ (package/name).write_text('{}')
59
+ (package/'case.spec.mjs').write_text('\nexport const plan = {};\ntest(plan.case.id,')
60
+ rows.append(dict(id=str(i), package=str(package)))
61
+ manifest = root/'manifest.json'; manifest.write_text(json.dumps(rows))
62
+ return ['--project-root',str(project),'--config',str(config),'--manifest',str(manifest),
63
+ '--product',str(product),'--node-modules',str(sdk.parents[0]),'--report-dir',str(root/'report')]
64
+
65
+
66
+ class ConcurrencyControls(unittest.TestCase):
67
+ def execute(self, options, count, expected, reason=None, plugins=False):
68
+ with tempfile.TemporaryDirectory() as tmp:
69
+ root = Path(tmp); argv = fixture(root,count)+options
70
+ if plugins:
71
+ mapping = {str(i): str(root / ('artifact-a' if i < count//2 else 'artifact-b')) for i in range(count)}
72
+ path=root/'plugins.json'; path.write_text(json.dumps(mapping))
73
+ argv += ['--plugin-artifact-map',str(path)]
74
+ stub = StubWorkers(expected)
75
+ old = {sig: signal.getsignal(sig) for sig in (signal.SIGINT, signal.SIGTERM)}
76
+ try:
77
+ with patch.object(sys,'argv',['run-batch.py',*argv]), patch.object(batch.subprocess,'Popen',stub), patch('batch_plugin_groups.validate_plugin_artifact',return_value={'plan':{}}):
78
+ self.assertEqual(batch.main(),0)
79
+ finally:
80
+ for sig,handler in old.items(): signal.signal(sig,handler)
81
+ self.assertEqual(stub.peak,expected)
82
+ self.assertEqual(stub.active,0)
83
+ inp=json.loads((root/'report/batch-input.json').read_text())
84
+ summary=json.loads((root/'report/batch-summary.json').read_text())
85
+ policy=summary['concurrencyPolicy']
86
+ self.assertEqual(policy,inp['concurrencyPolicy'])
87
+ self.assertEqual(policy['defaultWorkers'],8)
88
+ self.assertEqual(policy['actualConcurrency'],expected)
89
+ self.assertEqual(summary['concurrency'],expected)
90
+ self.assertEqual(policy['workersReason'],reason)
91
+ self.assertEqual(sum(policy['shardSizes']),count)
92
+ self.assertEqual(stub.launched,len(policy['shardSizes']))
93
+ self.assertIn('requestedWorkers',(root/'report/index.html').read_text())
94
+ return policy
95
+
96
+ def test_default_and_explicit_eight_equivalent(self):
97
+ default=self.execute([],17,8)
98
+ self.assertEqual(default,self.execute(['--workers','8'],17,8))
99
+ self.assertEqual(default['requestedWorkers'],8)
100
+ self.assertEqual(default['shardSizes'],[3,2,2,2,2,2,2,2])
101
+
102
+ def test_plugin_tail_sizes_preserved(self):
103
+ policy=self.execute([],21,8,plugins=True)
104
+ self.assertEqual(policy['shardSizes'],[3,3,2,2,3,3,3,2])
105
+
106
+ def test_few_tasks_cap(self):
107
+ self.assertEqual(self.execute([],3,3)['requestedWorkers'],8)
108
+
109
+ def test_reason_recorded_for_resource_overrides(self):
110
+ for value,reason in [(1,'capacity: single worker'),(4,'resource limit: four slots'),(9,'stub-only resource experiment')]:
111
+ with self.subTest(value=value):
112
+ policy=self.execute(['--workers',str(value),'--workers-reason',' '+reason+' '],18,value,reason)
113
+ self.assertEqual(policy['requestedWorkers'],value)
114
+
115
+ def test_invalid_overrides_launch_no_workers_or_read_inputs(self):
116
+ for options in [['--workers','4'],['--workers','4','--workers-reason',' '],['--workers','0'],['--workers','17'],['--workers','-1'],['--workers','bogus']]:
117
+ with self.subTest(options=options), tempfile.TemporaryDirectory() as tmp:
118
+ root=Path(tmp); argv=fixture(root,1)+options
119
+ with patch.object(sys,'argv',['run-batch.py',*argv]), patch.object(batch.subprocess,'Popen') as launch, patch.object(batch,'read_cases') as read:
120
+ with self.assertRaises(SystemExit) as error: batch.main()
121
+ self.assertEqual(error.exception.code,2)
122
+ launch.assert_not_called(); read.assert_not_called()
123
+ self.assertFalse((root/'report').exists())
124
+
125
+ if __name__ == '__main__': unittest.main()