testwins 0.4.0__py3-none-any.whl

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 (78) hide show
  1. testwins/__init__.py +2 -0
  2. testwins/__main__.py +2 -0
  3. testwins/api_contracts.py +145 -0
  4. testwins/baseline.py +57 -0
  5. testwins/browser.py +87 -0
  6. testwins/cli.py +149 -0
  7. testwins/collector.js +154 -0
  8. testwins/config.py +174 -0
  9. testwins/contracts.py +69 -0
  10. testwins/cv.py +138 -0
  11. testwins/detectors.py +133 -0
  12. testwins/downloads.py +46 -0
  13. testwins/drafting.py +49 -0
  14. testwins/events.py +68 -0
  15. testwins/gate.py +48 -0
  16. testwins/input.py +55 -0
  17. testwins/integrations/__init__.py +1 -0
  18. testwins/integrations/desktop.py +62 -0
  19. testwins/integrations/terminal.py +52 -0
  20. testwins/integrations/testql.py +71 -0
  21. testwins/live/__init__.py +1 -0
  22. testwins/live/augmentation.py +88 -0
  23. testwins/live/catalog.json +1271 -0
  24. testwins/live/cli.py +95 -0
  25. testwins/live/config.py +206 -0
  26. testwins/live/dashboard.html +21 -0
  27. testwins/live/diagnosis.py +99 -0
  28. testwins/live/export.py +53 -0
  29. testwins/live/native.py +74 -0
  30. testwins/live/observer.js +11 -0
  31. testwins/live/policy.py +63 -0
  32. testwins/live/provenance.py +42 -0
  33. testwins/live/runtime.py +197 -0
  34. testwins/live/scanner.py +181 -0
  35. testwins/live/server.py +68 -0
  36. testwins/live/store.py +146 -0
  37. testwins/llm.py +213 -0
  38. testwins/model.py +27 -0
  39. testwins/performance.py +20 -0
  40. testwins/proposals.py +99 -0
  41. testwins/pytest_plugin.py +38 -0
  42. testwins/reporting.py +186 -0
  43. testwins/resources/clonerd/.gitignore +6 -0
  44. testwins/resources/clonerd/Makefile +29 -0
  45. testwins/resources/clonerd/README.md +81 -0
  46. testwins/resources/clonerd/api/mutations.review.yaml +15 -0
  47. testwins/resources/clonerd/api/readonly.yaml +14 -0
  48. testwins/resources/clonerd/audit/large.yaml +60 -0
  49. testwins/resources/clonerd/audit/mobile.yaml +54 -0
  50. testwins/resources/clonerd/audit/performance.yaml +44 -0
  51. testwins/resources/clonerd/inventory.json +91 -0
  52. testwins/resources/clonerd/journeys/developer.desktop.yaml +68 -0
  53. testwins/resources/clonerd/journeys/download.review.yaml +55 -0
  54. testwins/resources/clonerd/journeys/personas.large.yaml +182 -0
  55. testwins/resources/clonerd/journeys/personas.mobile.yaml +176 -0
  56. testwins/resources/clonerd/personas.suite.yaml +12 -0
  57. testwins/resources/clonerd/scope.json +25 -0
  58. testwins/resources/clonerd/testwins.watch.yaml +80 -0
  59. testwins/resources/clonerd/tools/manage.py +110 -0
  60. testwins/resources/minimal/README.md +13 -0
  61. testwins/resources/minimal/audit.yaml +17 -0
  62. testwins/resources/minimal/shell.oql +4 -0
  63. testwins/runner.py +361 -0
  64. testwins/sessions.py +59 -0
  65. testwins/staging.py +39 -0
  66. testwins/suite.py +48 -0
  67. testwins/task_reporting.py +23 -0
  68. testwins/task_worker.py +35 -0
  69. testwins/tasks.py +134 -0
  70. testwins/util.py +81 -0
  71. testwins/verification.py +23 -0
  72. testwins-0.4.0.dist-info/METADATA +223 -0
  73. testwins-0.4.0.dist-info/RECORD +78 -0
  74. testwins-0.4.0.dist-info/WHEEL +5 -0
  75. testwins-0.4.0.dist-info/entry_points.txt +2 -0
  76. testwins-0.4.0.dist-info/licenses/LICENSE +21 -0
  77. testwins-0.4.0.dist-info/licenses/THIRD_PARTY.md +21 -0
  78. testwins-0.4.0.dist-info/top_level.txt +1 -0
testwins/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """Testwins: evidence-first UI, shell and desktop verification."""
2
+ __version__ = "0.4.0"
testwins/__main__.py ADDED
@@ -0,0 +1,2 @@
1
+ from .cli import main
2
+ raise SystemExit(main())
@@ -0,0 +1,145 @@
1
+ """Opt-in HTTP contracts, separate from the rendered-only GUI diagnosis and device matrix."""
2
+ from __future__ import annotations
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import re
7
+ import time
8
+ import urllib.error
9
+ import urllib.request
10
+ import uuid
11
+ from pathlib import Path
12
+ from urllib.parse import urljoin
13
+ from xml.etree import ElementTree as ET
14
+ import yaml
15
+ from .util import atomic_json, origin, file_digest, utc_now
16
+
17
+
18
+ class NoRedirect(urllib.request.HTTPRedirectHandler):
19
+ def redirect_request(self,req,fp,code,msg,headers,newurl):return None
20
+
21
+
22
+ def pointer(data, path: str):
23
+ if path=='':return data
24
+ if not path.startswith('/'):raise ValueError('JSON pointer must start with /')
25
+ for token in path[1:].split('/'):
26
+ token=token.replace('~1','/').replace('~0','~')
27
+ if isinstance(data,list):
28
+ if not re.fullmatch(r'0|[1-9][0-9]*',token):raise KeyError(token)
29
+ data=data[int(token)]
30
+ else:data=data[token]
31
+ return data
32
+
33
+
34
+ def load(path: Path) -> dict:
35
+ if path.stat().st_size>2_000_000:raise ValueError('API config too large')
36
+ cfg=yaml.safe_load(path.read_text('utf-8'))
37
+ allowed={'schema','id','base_url','timeout_seconds','max_body_bytes','headers','header_env','requests'}
38
+ if not isinstance(cfg,dict) or set(cfg)-allowed or cfg.get('schema')!='testwins.api/v1':raise ValueError('Invalid API contract')
39
+ if not re.fullmatch(r'[a-z][a-z0-9_-]{0,63}',cfg.get('id','')):raise ValueError('Invalid API id')
40
+ base=origin(cfg['base_url'])
41
+ cfg.setdefault('timeout_seconds',10);cfg.setdefault('max_body_bytes',1_000_000)
42
+ for key,low,high in [('timeout_seconds',1,60),('max_body_bytes',1,10_000_000)]:
43
+ if type(cfg[key]) is not int or not low<=cfg[key]<=high:raise ValueError('Invalid '+key)
44
+ for key in ('headers','header_env'):
45
+ cfg.setdefault(key,{})
46
+ if not isinstance(cfg[key],dict):raise ValueError('Invalid '+key)
47
+ for name,value in cfg[key].items():
48
+ if not re.fullmatch(r'[A-Za-z0-9-]+',name) or not isinstance(value,str) or '\n' in value or '\r' in value:
49
+ raise ValueError('Invalid header')
50
+ if name.lower() in ('host','content-length','transfer-encoding','connection'):raise ValueError('Transport headers are managed by the client')
51
+ if key=='header_env' and not re.fullmatch('[A-Z_][A-Z0-9_]*',value):raise ValueError('Invalid environment variable')
52
+ requests=cfg.get('requests')
53
+ if not isinstance(requests,list) or not 1<=len(requests)<=50:raise ValueError('API suite requires 1..50 requests')
54
+ names=set()
55
+ for req in requests:
56
+ if not isinstance(req,dict) or set(req)-{'id','path','method','body','allow_mutation','expect'}:raise ValueError('Invalid request')
57
+ name=req.get('id','')
58
+ if not re.fullmatch(r'[a-z][a-z0-9_-]{0,63}',name) or name in names:raise ValueError('Invalid/duplicate request id')
59
+ names.add(name)
60
+ if not isinstance(req.get('path'),str) or origin(urljoin(cfg['base_url'],req['path']))!=base:raise ValueError('API request leaves the configured origin')
61
+ req.setdefault('method','GET')
62
+ if req['method'] not in {'GET','HEAD','POST','PUT','PATCH','DELETE'}:raise ValueError('Unsupported HTTP method')
63
+ if req['method'] not in ('GET','HEAD') and req.get('allow_mutation') is not True:raise ValueError('Mutation requires explicit allow_mutation')
64
+ if req['method'] in ('GET','HEAD') and 'body' in req:raise ValueError('GET/HEAD body forbidden')
65
+ if len(json.dumps(req.get('body'),allow_nan=False))>1_000_000:raise ValueError('Request body too large')
66
+ exp=req.get('expect',{})
67
+ if not isinstance(exp,dict) or set(exp)-{'status','json_equals','headers','json_min_length','json_any'}:raise ValueError('Invalid API expectation')
68
+ if type(exp.get('status')) is not int or not 100<=exp['status']<=599:raise ValueError('Explicit expected status required')
69
+ if not isinstance(exp.get('json_equals',{}),dict):raise ValueError('json_equals must be a map of JSON pointers')
70
+ for key in exp.get('json_equals',{}):
71
+ if not isinstance(key,str) or key and not key.startswith('/'):raise ValueError('Invalid JSON pointer')
72
+ if not isinstance(exp.get('headers',{}),dict) or not all(isinstance(k,str) and isinstance(v,str) for k,v in exp.get('headers',{}).items()):raise ValueError('Invalid expected headers')
73
+ if not isinstance(exp.get('json_min_length',{}),dict):raise ValueError('json_min_length must be a map')
74
+ for key,value in exp.get('json_min_length',{}).items():
75
+ if not isinstance(key,str) or not key.startswith('/') or type(value) is not int or not 0<=value<=100000:raise ValueError('Invalid minimum JSON length')
76
+ if not isinstance(exp.get('json_any',[]),list):raise ValueError('json_any must be a list')
77
+ for rule in exp.get('json_any',[]):
78
+ if not isinstance(rule,dict) or set(rule)!={'path','field','contains'} or not all(isinstance(v,str) for v in rule.values()) or not rule['path'].startswith('/'):
79
+ raise ValueError('Invalid JSON array predicate')
80
+ return cfg
81
+
82
+
83
+ def run(path: Path, output: Path, *, approve_mutations: bool=False) -> Path:
84
+ cfg=load(path)
85
+ if any(r['method'] not in ('GET','HEAD') for r in cfg['requests']) and not approve_mutations:
86
+ raise ValueError('This API suite can mutate state. Review it and explicitly use --approve-mutations against disposable data.')
87
+ headers=dict(cfg['headers'])
88
+ for key,env in cfg['header_env'].items():
89
+ value=os.environ.get(env)
90
+ if not value or '\r' in value or '\n' in value:raise ValueError('Missing or invalid API header environment variable: '+env)
91
+ headers[key]=value
92
+ root=output/('api-'+utc_now().replace(':','').replace('.','-')+'-'+uuid.uuid4().hex[:8])
93
+ root.mkdir(parents=True,exist_ok=False)
94
+ report={'schema':'testwins.api-report/v1','id':cfg['id'],'scope':'explicit API contracts, once per request; not GUI or device testing',
95
+ 'created':utc_now(),'config_sha256':file_digest(path),'status':'incomplete',
96
+ 'plan':[r['id'] for r in cfg['requests']],'checks':[{'id':r['id'],'status':'not_run'} for r in cfg['requests']],
97
+ 'privacy':'No request headers, bodies, response bodies or raw exceptions retained'}
98
+ atomic_json(root/'report.json',report)
99
+ opener=urllib.request.build_opener(urllib.request.ProxyHandler({}),NoRedirect())
100
+ for req,check in zip(cfg['requests'],report['checks']):
101
+ started=time.monotonic()
102
+ response=None
103
+ try:
104
+ body=None if 'body' not in req else json.dumps(req['body'],allow_nan=False).encode()
105
+ req_headers=dict(headers)
106
+ if body is not None:req_headers.setdefault('Content-Type','application/json')
107
+ request=urllib.request.Request(urljoin(cfg['base_url'],req['path']),data=body,headers=req_headers,method=req['method'])
108
+ try:response=opener.open(request,timeout=cfg['timeout_seconds'])
109
+ except urllib.error.HTTPError as exc:response=exc
110
+ raw=response.read(cfg['max_body_bytes']+1)
111
+ if len(raw)>cfg['max_body_bytes']:raise ValueError('Response capture limit exceeded')
112
+ check.update(response_status=response.code,response_bytes=len(raw),response_sha256=hashlib.sha256(raw).hexdigest())
113
+ reasons=[];exp=req['expect']
114
+ if response.code!=exp['status']:reasons.append('status_mismatch')
115
+ if any(response.headers.get(k)!=v for k,v in exp.get('headers',{}).items()):reasons.append('header_mismatch')
116
+ if any(exp.get(k) for k in ('json_equals','json_min_length','json_any')):
117
+ try:
118
+ payload=json.loads(raw)
119
+ for key,value in exp.get('json_equals',{}).items():
120
+ actual=pointer(payload,key)
121
+ if json.dumps(actual,sort_keys=True)!=json.dumps(value,sort_keys=True):reasons.append('json_mismatch')
122
+ for key,value in exp.get('json_min_length',{}).items():
123
+ actual=pointer(payload,key)
124
+ if not isinstance(actual,(list,dict,str)) or len(actual)<value:reasons.append('json_length')
125
+ for rule in exp.get('json_any',[]):
126
+ actual=pointer(payload,rule['path'])
127
+ if not isinstance(actual,list) or not any(isinstance(x,dict) and isinstance(x.get(rule['field']),str) and rule['contains'] in x[rule['field']] for x in actual):reasons.append('json_array_predicate')
128
+ except (ValueError,KeyError,IndexError,TypeError):reasons.append('json_missing_or_invalid')
129
+ check.update(status='failed' if reasons else 'passed',reasons=sorted(set(reasons)))
130
+ except Exception as exc:
131
+ check.update(status='incomplete',exception_type=type(exc).__name__)
132
+ finally:
133
+ if response is not None:response.close()
134
+ check['duration_ms']=round((time.monotonic()-started)*1000,2)
135
+ atomic_json(root/'report.json',report)
136
+ statuses={c['status'] for c in report['checks']}
137
+ report['status']='incomplete' if statuses-{'passed','failed'} else 'failed' if 'failed' in statuses else 'passed'
138
+ report['exit_code']={'passed':0,'failed':1,'incomplete':2}[report['status']]
139
+ atomic_json(root/'report.json',report)
140
+ suite=ET.Element('testsuite',name='testwins.api',tests=str(len(report['checks'])),failures=str(sum(c['status']=='failed' for c in report['checks'])),errors=str(sum(c['status']=='incomplete' for c in report['checks'])))
141
+ for c in report['checks']:
142
+ test=ET.SubElement(suite,'testcase',name=c['id'],classname='api.contract')
143
+ if c['status']!='passed':ET.SubElement(test,'failure' if c['status']=='failed' else 'error',message=c['status'])
144
+ ET.ElementTree(suite).write(root/'junit.xml',encoding='utf-8',xml_declaration=True)
145
+ return root
testwins/baseline.py ADDED
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+ import json
3
+ import shutil
4
+ from pathlib import Path
5
+ from PIL import Image, ImageChops, ImageDraw
6
+ from .util import atomic_json, digest, confined
7
+ from .model import Finding
8
+
9
+
10
+ def baseline_key(meta: dict) -> str:
11
+ return digest({k:meta[k] for k in ("project","browser","device","stage","viewport_profile","locale","timezone","color_scheme")})
12
+
13
+
14
+ def compare(current: Path, reference: Path, diff_path: Path, color_delta: int=24) -> dict:
15
+ with Image.open(current) as a0, Image.open(reference) as b0:
16
+ a,b=a0.convert("RGB"),b0.convert("RGB")
17
+ if a.size!=b.size:return {"ratio":1.0,"dimensions_changed":True,"current":list(a.size),"baseline":list(b.size)}
18
+ diff=ImageChops.difference(a,b)
19
+ channels=diff.split();maximum=ImageChops.lighter(ImageChops.lighter(channels[0],channels[1]),channels[2])
20
+ mask=maximum.point(lambda p:255 if p>color_delta else 0)
21
+ hist=mask.histogram();changed=hist[255];total=a.width*a.height
22
+ overlay=b.copy();overlay.paste((255,32,100),(0,0,a.width,a.height),mask)
23
+ diff_path.parent.mkdir(parents=True,exist_ok=True);overlay.save(diff_path)
24
+ return {"ratio":changed/total,"changed_pixels":changed,"pixels":total,"dimensions_changed":False}
25
+
26
+
27
+ def assess(image: Path, meta: dict, cfg: dict, dest: Path) -> tuple[list[dict],str]:
28
+ directory=cfg["baseline"]["directory"]
29
+ if not directory:return [],"disabled"
30
+ root=Path(directory);key=baseline_key(meta);ref=root/(key+".png");md=root/(key+".json")
31
+ if not ref.exists() or not md.exists():return [],"missing"
32
+ previous=json.loads(md.read_text())
33
+ # Engine version changes invalidate a pixel baseline, rather than create hundreds of bogus bugs.
34
+ if previous.get("browser_version")!=meta.get("browser_version"):return [],"incompatible_browser_version"
35
+ if previous.get("render_environment")!=meta.get("render_environment"):return [],"incompatible_render_environment"
36
+ res=compare(image,ref,dest,cfg["baseline"]["color_delta"])
37
+ if res["ratio"]<=cfg["baseline"]["threshold"]:return [],"matched"
38
+ candidate=not cfg["baseline"]["fail_on_difference"]
39
+ f=Finding("TW-VISUAL-REGRESSION","Zmiana względem zatwierdzonego obrazu",
40
+ f"Różnica przekracza próg: {res['ratio']:.3%}. Zmiana obrazu sama nie dowodzi błędu.",
41
+ ["viewport"],[],"normal",.70 if candidate else .98,candidate,res)
42
+ return [f.to_dict()],"different"
43
+
44
+
45
+ def approve(run: Path, destination: Path, *, authorized: bool) -> int:
46
+ if not authorized:raise ValueError("baseline approval requires --approve")
47
+ report=json.loads((run/"report.json").read_text())
48
+ if report["coverage"]["state"]!="complete":raise ValueError("cannot approve an incomplete matrix")
49
+ destination.mkdir(parents=True,exist_ok=True);done=set()
50
+ for s in report["snapshots"]:
51
+ if not s["stable"]:continue
52
+ key=baseline_key(s["meta"])
53
+ if key in done:continue
54
+ done.add(key)
55
+ shutil.copy2(confined(run,s["image"]),destination/(key+".png"))
56
+ atomic_json(destination/(key+".json"),s["meta"])
57
+ return len(done)
testwins/browser.py ADDED
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+ import asyncio
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ import tempfile
7
+ from contextlib import asynccontextmanager
8
+ from pathlib import Path
9
+ from typing import AsyncIterator, Any
10
+
11
+ CDP_BROWSERS={"chromium","chrome","edge","brave"}
12
+ CANDIDATES={"chrome":["google-chrome","google-chrome-stable"],
13
+ "edge":["microsoft-edge","microsoft-edge-stable"],"brave":["brave-browser"]}
14
+
15
+
16
+ def browser_env() -> dict[str,str]:
17
+ # Do not hand local LLM, cloud or repository credentials to browser children.
18
+ allowed={"PATH","HOME","DISPLAY","LANG","LC_ALL","XDG_RUNTIME_DIR","DBUS_SESSION_BUS_ADDRESS",
19
+ "LD_LIBRARY_PATH","FONTCONFIG_PATH","PLAYWRIGHT_BROWSERS_PATH","TMPDIR"}
20
+ return {k:v for k,v in os.environ.items() if k in allowed}
21
+
22
+
23
+ @asynccontextmanager
24
+ async def launch(pw: Any, name: str, cfg: dict) -> AsyncIterator[tuple[Any,str]]:
25
+ browser=None; process=None; profile=None; logfile=None; external=False
26
+ try:
27
+ if name in CDP_BROWSERS:
28
+ endpoint=cfg["cdp_endpoints"].get(name)
29
+ if endpoint:
30
+ # Only operator-supplied endpoints; never reuse the host user's profile automatically.
31
+ external=True
32
+ else:
33
+ exe=cfg["executables"].get(name)
34
+ if not exe:
35
+ exe=pw.chromium.executable_path if name=="chromium" else next((shutil.which(x) for x in CANDIDATES[name] if shutil.which(x)),None)
36
+ if not exe or not Path(exe).exists():raise RuntimeError(f"browser unavailable: {name}")
37
+ profile=Path(tempfile.mkdtemp(prefix="testwins-"+name+"-"))
38
+ logfile=(profile/"launcher.log").open("wb")
39
+ args=[exe,"--remote-debugging-port=0","--remote-debugging-address=127.0.0.1",
40
+ "--user-data-dir="+str(profile),"--no-first-run","--no-default-browser-check",
41
+ "--disable-background-networking","--disable-component-update","--disable-sync",
42
+ "--password-store=basic","--use-mock-keychain","--enable-automation",
43
+ "--window-size=1500,1000"]
44
+ if cfg["headless"]:args.append("--headless=new")
45
+ if not cfg["sandbox"]:args.append("--no-sandbox")
46
+ args.append("about:blank")
47
+ process=subprocess.Popen(args,stdout=logfile,stderr=subprocess.STDOUT,env=browser_env(),start_new_session=True)
48
+ active=profile/"DevToolsActivePort"
49
+ for _ in range(150):
50
+ if process.poll() is not None:raise RuntimeError(f"{name} exited before CDP became available (check sandbox/platform prerequisites)")
51
+ if active.exists() and len(active.read_text().splitlines())>=2:break
52
+ await asyncio.sleep(.1)
53
+ else:raise TimeoutError(f"CDP startup timeout: {name}")
54
+ port=int(active.read_text().splitlines()[0]);endpoint=f"http://127.0.0.1:{port}"
55
+ browser=await pw.chromium.connect_over_cdp(endpoint,timeout=15000)
56
+ yield browser,"cdp"
57
+ else:
58
+ bt=getattr(pw,name)
59
+ kwargs={"headless":cfg["headless"],"env":browser_env()}
60
+ if cfg["executables"].get(name):kwargs["executable_path"]=cfg["executables"][name]
61
+ browser=await bt.launch(**kwargs)
62
+ yield browser,"playwright"
63
+ finally:
64
+ if browser:
65
+ try:await browser.close()
66
+ except Exception:pass
67
+ if process:
68
+ if process.poll() is None:
69
+ process.terminate()
70
+ try:await asyncio.to_thread(process.wait,5)
71
+ except subprocess.TimeoutExpired:process.kill();await asyncio.to_thread(process.wait)
72
+ if logfile:logfile.close()
73
+ if profile:shutil.rmtree(profile,ignore_errors=True)
74
+
75
+
76
+ def context_options(cfg: dict, device: dict, name: str, scene: dict | None = None) -> dict:
77
+ # Firefox does not implement Playwright's is_mobile. Its mobile cell is honestly viewport+touch.
78
+ result={"viewport":{"width":device["width"],"height":device["height"]},
79
+ "device_scale_factor":device["dpr"],"has_touch":device["touch"],
80
+ "locale":cfg["locale"],"timezone_id":cfg["timezone"],
81
+ "color_scheme":cfg["color_scheme"],"reduced_motion":"reduce",
82
+ "service_workers":"block","accept_downloads":cfg.get("downloads",{}).get("enabled",False)}
83
+ if name!="firefox":result["is_mobile"]=device["mobile"]
84
+ from .sessions import state_for
85
+ state=state_for(cfg,scene)
86
+ if state is not None:result["storage_state"]=state
87
+ return result
testwins/cli.py ADDED
@@ -0,0 +1,149 @@
1
+ from __future__ import annotations
2
+ import argparse
3
+ import asyncio
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+ from .config import load, MATRICES
8
+ from .util import atomic_json
9
+
10
+
11
+ def parser() -> argparse.ArgumentParser:
12
+ p=argparse.ArgumentParser(prog='testwins',description='Rendered-UI audit lab: screenshots, DOM geometry and explicit UI contracts')
13
+ from . import __version__
14
+ p.add_argument('--version',action='version',version='testwins '+__version__)
15
+ sub=p.add_subparsers(dest='command',required=True)
16
+ r=sub.add_parser('run');r.add_argument('--config',type=Path,default=Path('configs/audit.yaml'))
17
+ r.add_argument('--url');r.add_argument('--matrix',choices=MATRICES);r.add_argument('--output',type=Path,default=Path('artifacts'))
18
+ r.add_argument('--headless',action='store_true',default=None);r.add_argument('--chromium-executable')
19
+ r.add_argument('--no-axe',action='store_true',help='Explicitly remove axe checks from this audit scope')
20
+ v=sub.add_parser('validate');v.add_argument('--config',type=Path,required=True)
21
+ v=sub.add_parser('verify');v.add_argument('run',type=Path)
22
+ b=sub.add_parser('baseline');b.add_argument('run',type=Path);b.add_argument('--directory',type=Path,required=True);b.add_argument('--approve',action='store_true')
23
+ t=sub.add_parser('publish');t.add_argument('proposals',type=Path);t.add_argument('--project',type=Path,required=True)
24
+ t.add_argument('--apply',action='store_true');t.add_argument('--include-candidates',action='store_true');t.add_argument('--ready',action='store_true');t.add_argument('--limit',type=int,default=25);t.add_argument('--offset',type=int,default=0)
25
+ t.add_argument('--receipt',type=Path,help='Write publication receipt outside the immutable input run')
26
+ sub.add_parser('doctor')
27
+ x=sub.add_parser('api',help='Separate explicit HTTP contracts; not a browser matrix')
28
+ x.add_argument('--config',type=Path,required=True);x.add_argument('--output',type=Path,default=Path('artifacts/api'));x.add_argument('--approve-mutations',action='store_true')
29
+ g=sub.add_parser('gate',help='Require every planned assertion; no confidence-based bypass')
30
+ g.add_argument('report',type=Path,help='Run directory (also checks integrity) or report.json')
31
+ g=sub.add_parser('auth-save',help='Open an isolated browser; save private state after a signed-in selector appears')
32
+ g.add_argument('--config',type=Path,required=True);g.add_argument('--output',type=Path,required=True)
33
+ g.add_argument('--ready-selector',required=True);g.add_argument('--timeout-ms',type=int,default=120000)
34
+
35
+ i=sub.add_parser('init');i.add_argument('directory',type=Path);i.add_argument('--profile',choices=['minimal','clonerd'],default='minimal')
36
+ for command in ('task','scenario','suite'):
37
+ t=sub.add_parser(command)
38
+ if command=='scenario':
39
+ t.add_argument('files',nargs='+');t.add_argument('--dry-run',action='store_true');t.add_argument('--url',default='http://sut:8080')
40
+ else:t.add_argument('--file',type=Path,required=True)
41
+ t.add_argument('--project',type=Path,default=Path('.'));t.add_argument('--output',type=Path,default=Path('artifacts/tasks'))
42
+ t.add_argument('--trusted-local',action='store_true',help='Explicitly execute reviewed scenarios on this host, without Docker isolation')
43
+ t.add_argument('--image',default='testwins-worker:local');t.add_argument('--network',default='none')
44
+ v=sub.add_parser('vision');v.add_argument('source',type=Path);v.add_argument('--output',type=Path,required=True)
45
+ v.add_argument('--env',type=Path,default=Path('.env'));v.add_argument('--goal',default='');v.add_argument('--before',type=Path);v.add_argument('--regions-json',type=Path)
46
+ c=sub.add_parser('cv');c.add_argument('image',type=Path);c.add_argument('--output',type=Path,required=True)
47
+ c.add_argument('--backend',choices=['opencv','yolo','regions'],default='opencv');c.add_argument('--weights',type=Path)
48
+ c.add_argument('--weights-sha256');c.add_argument('--trust-model',action='store_true');c.add_argument('--regions-json',type=Path)
49
+ d=sub.add_parser('draft');d.add_argument('image',type=Path);d.add_argument('--snapshot',type=Path,required=True)
50
+ d.add_argument('--goal',required=True);d.add_argument('--url',required=True);d.add_argument('--output',type=Path,required=True);d.add_argument('--env',type=Path,default=Path('.env'))
51
+ from .live.cli import register
52
+ register(sub)
53
+ return p
54
+
55
+
56
+ def main(argv: list[str] | None=None) -> int:
57
+ p=parser();a=p.parse_args(argv)
58
+ try:
59
+ if a.command in {'watch','live-status','live-serve','live-export','capacity','diagnose','catalog'}:
60
+ from .live.cli import dispatch
61
+ return dispatch(a)
62
+ if a.command=='api':
63
+ from .api_contracts import run
64
+ root=run(a.config,a.output,approve_mutations=a.approve_mutations)
65
+ report=json.loads((root/'report.json').read_text())
66
+ print(json.dumps({'directory':str(root),'status':report['status'],'checks':len(report['checks'])}));return report['exit_code']
67
+ if a.command=='gate':
68
+ from .gate import evaluate
69
+ if a.report.is_dir():
70
+ from .verification import verify_run
71
+ verify_run(a.report);path=a.report/'report.json'
72
+ else:path=a.report
73
+ result=evaluate(json.loads(path.read_text('utf-8')))
74
+ print(json.dumps(result,indent=2));return result['exit_code']
75
+ if a.command=='auth-save':
76
+ from .sessions import capture_session
77
+ if not 1000<=a.timeout_ms<=600000:raise ValueError('timeout-ms must be 1000..600000')
78
+ asyncio.run(capture_session(load(a.config),a.output,a.ready_selector,a.timeout_ms))
79
+ print(json.dumps({'saved':True,'private_file':str(a.output),'included_in_reports':False}));return 0
80
+ if a.command=='doctor':
81
+ import importlib.metadata, importlib.util, shutil
82
+ result={'package':'testwins','python':sys.version.split()[0], 'docker':shutil.which('docker'),'packages':{}}
83
+ for name in ('testwins','testql','playwright','litellm','opencv-python-headless','ultralytics','mss','pyautogui','pexpect'):
84
+ try:result['packages'][name]=importlib.metadata.version(name)
85
+ except importlib.metadata.PackageNotFoundError:result['packages'][name]=None
86
+ try:
87
+ from testql.verification import VerificationRequest,run_verification
88
+ result['testql_public_api']=True
89
+ except ImportError:result['testql_public_api']=False
90
+ result['note']='Optional backends are reported individually; missing SDK never becomes an executed test.'
91
+ print(json.dumps(result,indent=2));return 0
92
+ if a.command=='init':
93
+ import shutil
94
+ from importlib.resources import files
95
+ if a.directory.exists():raise ValueError('Choose a new directory')
96
+ source=files('testwins').joinpath('resources',a.profile)
97
+ shutil.copytree(str(source),a.directory)
98
+ print(str(a.directory));return 0
99
+ if a.command in ('task','scenario','suite'):
100
+ from .tasks import load_task,run_task,exit_code
101
+ if a.command=='suite':
102
+ from .suite import run_suite
103
+ root=run_suite(a.file,a.output,trusted_local=a.trusted_local,image=a.image,network=a.network)
104
+ result=json.loads((root/'suite.json').read_text())
105
+ else:
106
+ task=load_task(a.file) if a.command=='task' else {'schema':'testwins.task/v1','id':'scenario','backend':'testql','files':a.files,'url':a.url,'dry_run':a.dry_run}
107
+ root=run_task(task,a.project,a.output,trusted_local=a.trusted_local,image=a.image,network=a.network)
108
+ result=json.loads((root/'result.json').read_text())
109
+ print(json.dumps({'directory':str(root),'result':result},ensure_ascii=False,indent=2));return exit_code(result)
110
+ if a.command=='cv':
111
+ from .cv import analyze
112
+ result=analyze(a.image,a.output,backend=a.backend,weights=a.weights,trust_model=a.trust_model,expected_sha256=a.weights_sha256,regions_file=a.regions_json)
113
+ print(json.dumps({'output':str(a.output),'regions':len(result['regions']),'defects':0}));return 0
114
+ if a.command=='vision':
115
+ from .llm import review,env_file
116
+ root=review(a.source,a.output,env_file(a.env),goal=a.goal,before=a.before,regions_file=a.regions_json)
117
+ result=json.loads((root/'vision.json').read_text());print(json.dumps({'output':str(root),'status':result['status'],'calls':result['calls']}))
118
+ return 2 if result['status']=='incomplete' else 0
119
+ if a.command=='draft':
120
+ from .llm import Client,Settings,env_file
121
+ from .drafting import draft
122
+ root=draft(a.image,a.snapshot,a.goal,a.url,a.output,Client(Settings.from_env(env_file(a.env))))
123
+ print(json.dumps({'output':str(root),'executed':False,'review_required':True}));return 0
124
+ if a.command=='run':
125
+ from .runner import run
126
+ c=load(a.config,base_url=a.url,matrix=a.matrix,headless=a.headless)
127
+ if a.chromium_executable:c['executables']['chromium']=a.chromium_executable
128
+ if a.no_axe:c['axe']['enabled']=False
129
+ root=asyncio.run(run(c,a.output));r=json.loads((root/'report.json').read_text('utf-8'))
130
+ print(json.dumps({'run':str(root),'coverage':r['coverage'],'summary':r['summary']},ensure_ascii=False,indent=2))
131
+ return r['gate']['exit_code']
132
+ if a.command=='validate':
133
+ load(a.config);result={'valid':True,'config':str(a.config)}
134
+ elif a.command=='verify':
135
+ from .verification import verify_run
136
+ result=verify_run(a.run)
137
+ elif a.command=='baseline':
138
+ from .baseline import approve
139
+ result={'approved_baselines':approve(a.run,a.directory,authorized=a.approve)}
140
+ elif a.command=='publish':
141
+ from .proposals import publish
142
+ result=publish(a.proposals,a.project,apply=a.apply,include_candidates=a.include_candidates,ready=a.ready,limit=a.limit,offset=a.offset)
143
+ if a.receipt:atomic_json(a.receipt,result)
144
+ print(json.dumps(result,ensure_ascii=False,indent=2));return 0
145
+ except (ValueError,KeyError,TypeError,OSError,ImportError,RuntimeError) as exc:
146
+ # Config/SDK errors, not raw application console output.
147
+ print(f'testwins: {type(exc).__name__}: {exc}',file=sys.stderr);return 2
148
+
149
+ if __name__=='__main__':raise SystemExit(main())
testwins/collector.js ADDED
@@ -0,0 +1,154 @@
1
+ /* All inputs are rendered DOM/layout. No application source, network log or private JS state. */
2
+ (opts) => {
3
+ const started = performance.now();
4
+ const nodes = [], texts = [], masks = [], gaps = [], links = [];
5
+ const roots = [document]; const seen = new Set();
6
+ let truncated = false;
7
+ const visual=window.visualViewport,ox=visual?.offsetLeft||0,oy=visual?.offsetTop||0;
8
+ const rect = r => ({x:r.x-ox, y:r.y-oy, width:r.width, height:r.height});
9
+ const intersect = (a,b) => {const x=Math.max(a.x,b.x),y=Math.max(a.y,b.y);
10
+ return {x,y,width:Math.max(0,Math.min(a.x+a.width,b.x+b.width)-x),height:Math.max(0,Math.min(a.y+a.height,b.y+b.height)-y)};};
11
+ const vp = {x:0,y:0,width:opts.capture_viewport?.width||visual?.width||innerWidth,height:opts.capture_viewport?.height||visual?.height||innerHeight};
12
+ const parent = el => el.parentElement || (el.getRootNode() instanceof ShadowRoot ? el.getRootNode().host : null);
13
+ const contains = (a,b) => {for(let e=b;e;e=parent(e)) if(e===a) return true; return false;};
14
+ const pathCache = new WeakMap();
15
+ const path = el => {
16
+ if(!el || el.nodeType!==1) return '';
17
+ if(pathCache.has(el)) return pathCache.get(el);
18
+ const root=el.getRootNode();
19
+ const prefix=root instanceof ShadowRoot ? path(root.host)+' >>> ' : '';
20
+ let p;
21
+ if(el.id && root.querySelectorAll('#'+CSS.escape(el.id)).length===1) p='#'+CSS.escape(el.id);
22
+ else if(el.hasAttribute('data-testid')) p='[data-testid='+JSON.stringify(el.getAttribute('data-testid'))+']';
23
+ else {
24
+ const parts=[]; let e=el;
25
+ while(e && parts.length<12){
26
+ let part=e.localName;
27
+ if(e.parentElement) part+=':nth-of-type('+(Array.from(e.parentElement.children).filter(x=>x.localName===e.localName).indexOf(e)+1)+')';
28
+ parts.unshift(part); if(e===root.documentElement) break; e=e.parentElement;
29
+ } p=parts.join(' > ');
30
+ }
31
+ p=prefix+p;pathCache.set(el,p);return p;
32
+ };
33
+ const isPrivate=el=>{for(let e=el;e;e=parent(e)){
34
+ if(opts.mask_selectors.some(s=>e.matches(s))) return true;
35
+ }return false;};
36
+ const hit=(x,y)=>{let e=document.elementFromPoint(x+ox,y+oy),old=null;
37
+ while(e && e!==old && e.shadowRoot){old=e;e=e.shadowRoot.elementFromPoint(x+ox,y+oy)||e;}return e;};
38
+ for(let ri=0;ri<roots.length;ri++) {
39
+ const root=roots[ri];
40
+ for(const el of root.querySelectorAll('*')) {
41
+ if(seen.has(el))continue;seen.add(el);
42
+ if(seen.size>opts.max_elements*4){truncated=true;break;}
43
+ if(el.shadowRoot) roots.push(el.shadowRoot);
44
+ if(nodes.length>=opts.max_elements){truncated=true;break;}
45
+ const cs=getComputedStyle(el), r=rect(el.getBoundingClientRect());
46
+ if(cs.display==='none'||cs.visibility!=='visible'||+cs.opacity===0||r.width<=0||r.height<=0)continue;
47
+ let hidden=false, clip={...vp}, intentional=false;
48
+ const ancestors=[];
49
+ for(let p=parent(el);p;p=parent(p)){
50
+ const pc=getComputedStyle(p);ancestors.push(path(p));
51
+ if(+pc.opacity===0||pc.visibility!=='visible'||pc.contentVisibility==='hidden'){hidden=true;break;}
52
+ // Closed <details> can expose descendant Range rects that are not painted.
53
+ // Only its first summary subtree remains rendered; never diagnose hidden answers.
54
+ if(p.localName==='details'&&!p.open){
55
+ const summary=Array.from(p.children).find(c=>c.localName==='summary');
56
+ if(!summary||!contains(summary,el)){hidden=true;break;}
57
+ }
58
+ const pr=rect(p.getBoundingClientRect());
59
+ const c={x:pr.x+p.clientLeft,y:pr.y+p.clientTop,width:p.clientWidth,height:p.clientHeight};
60
+ if(['hidden','clip','scroll','auto'].includes(pc.overflowX)){
61
+ const next=intersect(clip,{x:c.x,y:clip.y,width:c.width,height:clip.height});clip.x=next.x;clip.width=next.width;
62
+ }
63
+ if(['hidden','clip','scroll','auto'].includes(pc.overflowY)){
64
+ const next=intersect(clip,{x:clip.x,y:c.y,width:clip.width,height:c.height});clip.y=next.y;clip.height=next.height;
65
+ }
66
+ if(pc.textOverflow==='ellipsis'||parseInt(pc.webkitLineClamp)>0)intentional=true;
67
+ }
68
+ if(hidden)continue;
69
+ const privateNode=isPrivate(el), visibleRect=intersect(r,clip), selector=path(el);
70
+ const interactive=el.matches('button,a[href],input:not([type=hidden]),select,textarea,[role=button],[role=link],[tabindex]');
71
+ const n={selector,parent:path(parent(el)),ancestors,tag:el.localName,rect:r,visibleRect,
72
+ display:cs.display,position:cs.position,transform:cs.transform,overflowX:cs.overflowX,overflowY:cs.overflowY,
73
+ clientWidth:el.clientWidth,clientHeight:el.clientHeight,scrollWidth:el.scrollWidth,scrollHeight:el.scrollHeight,
74
+ textOverflow:cs.textOverflow,lineClamp:cs.webkitLineClamp,interactive,
75
+ private:privateNode,disabled:!!el.disabled,inert:el.closest('[inert]')!==null,
76
+ fontSize:parseFloat(cs.fontSize),alignItems:cs.alignItems,flexDirection:cs.flexDirection,
77
+ role:el.getAttribute('role'),name:privateNode?'[REDACTED]':(el.getAttribute('aria-label')||el.getAttribute('alt')||'').slice(0,160),
78
+ brokenImage:el instanceof HTMLImageElement && !!el.currentSrc && el.complete && el.naturalWidth===0,
79
+ occluded:0,hitSamples:0,covering:[],
80
+ focused:el===document.activeElement || el===el.getRootNode().activeElement,
81
+ ariaHiddenAncestor:!!el.closest('[aria-hidden="true"]'),
82
+ css:Object.fromEntries(['minWidth','maxWidth','minHeight','maxHeight','boxSizing','whiteSpace','overflowWrap',
83
+ 'wordBreak','lineHeight','fontFamily','zIndex','opacity','transform','pointerEvents','isolation',
84
+ 'flexShrink','flexGrow','gap','marginTop','marginRight','marginBottom','marginLeft',
85
+ 'paddingTop','paddingRight','paddingBottom','paddingLeft','color','backgroundColor'].map(k=>[k,cs[k]]))};
86
+ if(privateNode && visibleRect.width && visibleRect.height) masks.push(visibleRect);
87
+ if(interactive && !n.disabled && !n.inert && cs.pointerEvents!=='none' && visibleRect.width>2 && visibleRect.height>2){
88
+ for(const [fx,fy] of [[.5,.5],[.2,.2],[.8,.2],[.2,.8],[.8,.8]]){
89
+ const x=visibleRect.x+visibleRect.width*fx,y=visibleRect.y+visibleRect.height*fy;
90
+ const top=hit(x,y);n.hitSamples++;
91
+ if(top && !contains(el,top) && !contains(top,el)){n.occluded++;n.covering.push(path(top));}
92
+ }
93
+ n.covering=Array.from(new Set(n.covering));
94
+ }
95
+ if(el.localName==='a'&&el.href&&visibleRect.width>0&&visibleRect.height>0) links.push(el.href);
96
+ if(el.localName==='iframe'&&visibleRect.width>0&&visibleRect.height>0)
97
+ gaps.push({kind:'iframe',selector,reason:'Frame interior is not inspected by the DOM collector; screenshot only.'});
98
+ if(el.localName==='canvas'&&visibleRect.width>0&&visibleRect.height>0)
99
+ gaps.push({kind:'canvas',selector,reason:'Canvas contents require screenshot interpretation or explicit UI assertions.'});
100
+ nodes.push(n);
101
+ if(privateNode)continue;
102
+ // Direct text nodes only: parent/child DOM rectangles are not mistaken for text collisions.
103
+ for(const child of el.childNodes){
104
+ if(child.nodeType!==Node.TEXT_NODE||!child.textContent.trim())continue;
105
+ const range=document.createRange();range.selectNodeContents(child);
106
+ let ownClip={...clip};
107
+ const border={x:r.x+el.clientLeft,y:r.y+el.clientTop,width:el.clientWidth,height:el.clientHeight};
108
+ const clipX=['hidden','clip'].includes(cs.overflowX),clipY=['hidden','clip'].includes(cs.overflowY);
109
+ if(clipX){const v=intersect(ownClip,{x:border.x,y:ownClip.y,width:border.width,height:ownClip.height});ownClip.x=v.x;ownClip.width=v.width;}
110
+ if(clipY){const v=intersect(ownClip,{x:ownClip.x,y:border.y,width:ownClip.width,height:border.height});ownClip.y=v.y;ownClip.height=v.height;}
111
+ for(const tr of range.getClientRects()){
112
+ if(texts.length>=opts.max_text_rects){truncated=true;break;}
113
+ const rr=rect(tr),vr=intersect(rr,ownClip);
114
+ if(rr.width<1||rr.height<1||vr.width<1||vr.height<1)continue;
115
+ let ancestorClipX=false,ancestorClipY=false;
116
+ for(let p=parent(el);p;p=parent(p)){const pc=getComputedStyle(p),pr=rect(p.getBoundingClientRect());
117
+ const cx=pr.x+p.clientLeft,cy=pr.y+p.clientTop;
118
+ if(['hidden','clip'].includes(pc.overflowX)&&(rr.x<cx-2||rr.x+rr.width>cx+p.clientWidth+2))ancestorClipX=true;
119
+ if(['hidden','clip'].includes(pc.overflowY)&&(rr.y<cy-2||rr.y+rr.height>cy+p.clientHeight+2))ancestorClipY=true;
120
+ }
121
+ texts.push({selector,ancestors,rect:rr,visibleRect:vr,text:child.textContent.trim().slice(0,160),
122
+ intentional: intentional||cs.textOverflow==='ellipsis'||parseInt(cs.webkitLineClamp)>0,
123
+ // Clip by viewport is navigation, not a defect. Only local CSS clipping is evaluated below.
124
+ localClipX:ancestorClipX || (clipX && (rr.x<border.x-1||rr.x+rr.width>border.x+border.width+1)),
125
+ localClipY:ancestorClipY || (clipY && (rr.y<border.y-1||rr.y+rr.height>border.y+border.height+1)),
126
+ transformed:cs.transform!=='none',fontSize:parseFloat(cs.fontSize)});
127
+ }
128
+ }
129
+ }
130
+ }
131
+ const alignments=opts.alignment.map(a=>({id:a.id,edge:a.edge,tolerance:a.tolerance_px,
132
+ nodes:Array.from(document.querySelectorAll(a.selector)).map(el=>({selector:path(el),rect:rect(el.getBoundingClientRect())})).filter(n=>n.rect.width&&n.rect.height)}));
133
+ const clone=document.documentElement.cloneNode(true);
134
+ // A non-executable semantic serialization, not original HTML/JS source.
135
+ for(const el of clone.querySelectorAll('script,style,link,iframe,object,embed,base,meta,template')) el.remove();
136
+ for(const el of [clone,...clone.querySelectorAll('*')]){
137
+ if(opts.mask_selectors.some(s=>el.matches(s))) {el.textContent='[REDACTED]';el.removeAttribute('value');}
138
+ for(const attr of Array.from(el.attributes)){
139
+ const k=attr.name;
140
+ if(!['id','class','role','type','alt','title','data-testid'].includes(k) && !k.startsWith('aria-')) el.removeAttribute(k);
141
+ else if(/token|secret|password|bearer/i.test(attr.value)&&k!=='type')el.setAttribute(k,'[REDACTED]');
142
+ }
143
+ }
144
+ let html='<!doctype html>\n'+clone.outerHTML;
145
+ if(new TextEncoder().encode(html).length>opts.max_html_bytes){html=new TextDecoder().decode(new TextEncoder().encode(html).slice(0,opts.max_html_bytes));truncated=true;}
146
+ return {schema:'testwins.snapshot/v1',viewport:{width:vp.width,height:vp.height},
147
+ visualViewport:{width:visual?.width||innerWidth,height:visual?.height||innerHeight,offsetX:ox,offsetY:oy,scale:visual?.scale||1},
148
+ layoutViewport:{width:innerWidth,height:innerHeight,rootWidth:document.documentElement.clientWidth,rootHeight:document.documentElement.clientHeight},
149
+ screen:{width:screen.width,height:screen.height},dpr:devicePixelRatio,
150
+ scroll:{x:scrollX+ox,y:scrollY+oy,layoutX:scrollX,layoutY:scrollY},document:{width:document.documentElement.scrollWidth,height:document.documentElement.scrollHeight},
151
+ hasViewportMeta:!!document.querySelector('meta[name=viewport]'),fontsStatus:document.fonts.status,
152
+ title:document.title.slice(0,200),nodes,texts,masks,alignments,links:Array.from(new Set(links)).slice(0,200),
153
+ gaps,truncated,html,elapsedMs:Math.round(performance.now()-started)};
154
+ }