jevqa 0.1.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.
- jevqa/__init__.py +2 -0
- jevqa/absence.py +129 -0
- jevqa/checklist_prompt.json +40 -0
- jevqa/cli.py +51 -0
- jevqa/config.py +58 -0
- jevqa/llm.py +38 -0
- jevqa/oracles.json +7 -0
- jevqa/report.py +84 -0
- jevqa/telemetry.py +20 -0
- jevqa/tester.py +623 -0
- jevqa-0.1.0.dist-info/METADATA +113 -0
- jevqa-0.1.0.dist-info/RECORD +15 -0
- jevqa-0.1.0.dist-info/WHEEL +4 -0
- jevqa-0.1.0.dist-info/entry_points.txt +2 -0
- jevqa-0.1.0.dist-info/licenses/LICENSE +9 -0
jevqa/__init__.py
ADDED
jevqa/absence.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Bounded UI crawl and spec-grounded absence probes; --self-test runs offline checks."""
|
|
2
|
+
import argparse, json, os, re
|
|
3
|
+
from .llm import claude_json
|
|
4
|
+
from urllib.parse import urlparse
|
|
5
|
+
import requests
|
|
6
|
+
from playwright.sync_api import Error, sync_playwright
|
|
7
|
+
|
|
8
|
+
JEV_URL = "https://api.typesafe.ai/v1/systemone"
|
|
9
|
+
CONTROLS = 'a,button,[role=button],[role=tab],[role=switch],[role=combobox],[role=menuitem]'
|
|
10
|
+
|
|
11
|
+
def _snapshot(page):
|
|
12
|
+
return page.evaluate("""sel => {
|
|
13
|
+
const visible = e => !!(e.getClientRects().length && getComputedStyle(e).visibility !== 'hidden');
|
|
14
|
+
const label = e => ((e.labels?.[0]?.innerText) || e.getAttribute('aria-label') || e.innerText || e.placeholder || e.title || e.name || '').trim();
|
|
15
|
+
return {url:location.href, text:document.body.innerText,
|
|
16
|
+
controls:[...document.querySelectorAll(sel)].filter(visible).map(label).filter(Boolean),
|
|
17
|
+
fields:[...document.querySelectorAll('input:not([type=hidden]),select,textarea')].filter(visible).map(label).filter(Boolean)};
|
|
18
|
+
}""", CONTROLS)
|
|
19
|
+
|
|
20
|
+
def crawl(page, base, max_pages=8):
|
|
21
|
+
"""Visit same-origin nav links, first detail, and role/mode states (including same URL)."""
|
|
22
|
+
origin = urlparse(base)[:2]
|
|
23
|
+
queue, visited, pages = [base], set(), []
|
|
24
|
+
def capture():
|
|
25
|
+
snap = _snapshot(page)
|
|
26
|
+
if snap not in pages and len(pages) < max_pages: pages.append(snap)
|
|
27
|
+
links = page.locator('nav a[href],header a[href],[role=navigation] a[href],main a[href]').evaluate_all('(els) => els.map(e => e.href)')
|
|
28
|
+
for link in links:
|
|
29
|
+
link = link.split('#')[0]
|
|
30
|
+
if urlparse(link)[:2] == origin and link not in visited and link not in queue: queue.append(link)
|
|
31
|
+
while queue and len(pages) < max_pages:
|
|
32
|
+
url = queue.pop(0)
|
|
33
|
+
if url in visited: continue
|
|
34
|
+
visited.add(url)
|
|
35
|
+
page.goto(url, wait_until='domcontentloaded', timeout=15000); page.wait_for_timeout(400)
|
|
36
|
+
capture()
|
|
37
|
+
actions = page.locator(CONTROLS).filter(has_text=re.compile(r'^(switch|view as|.*mode\b)|^(owner|traveler|organizer|visitor|employer|seeker|admin)$', re.I))
|
|
38
|
+
names = actions.all_text_contents() + page.get_by_role('tab').all_text_contents()
|
|
39
|
+
detail = page.locator('article a[href],[class*=card] a[href],a[href]').filter(has_text=re.compile(r'details|view|read more', re.I)).first
|
|
40
|
+
if not detail.count(): detail = page.get_by_role('button', name=re.compile(r'^\d+ comments?$|view details|read more', re.I)).first
|
|
41
|
+
for name in [None] + list(dict.fromkeys(n.strip() for n in names if n.strip())):
|
|
42
|
+
if len(pages) >= max_pages: break
|
|
43
|
+
try:
|
|
44
|
+
target = detail if name is None else page.locator(CONTROLS).filter(has_text=re.compile('^' + re.escape(name) + '$')).first
|
|
45
|
+
if not target.count(): continue
|
|
46
|
+
href = target.get_attribute('href')
|
|
47
|
+
if href and not href.startswith(('/', '#')) and urlparse(href)[:2] != origin: continue
|
|
48
|
+
target.click(timeout=1500); page.wait_for_timeout(300); capture()
|
|
49
|
+
except Error:
|
|
50
|
+
pass # ponytail: one attempt per target; failed interactions need a deeper crawler.
|
|
51
|
+
finally:
|
|
52
|
+
page.goto(url, wait_until='domcontentloaded', timeout=15000); page.wait_for_timeout(200)
|
|
53
|
+
return pages
|
|
54
|
+
|
|
55
|
+
def _plan(spec, pages):
|
|
56
|
+
prompt = f'''Derive absence probes from this specification, NOT from controls already present.
|
|
57
|
+
SPEC: {spec}
|
|
58
|
+
PAGES: {json.dumps(pages, ensure_ascii=False)}
|
|
59
|
+
Return ONLY a JSON list of {{page, verb, expected_control, kind, match_terms, source_quote}}.
|
|
60
|
+
page is the zero-based PAGES index (states can share URLs). kind is control or content.
|
|
61
|
+
source_quote MUST be an exact spec excerpt requiring the feature. No inferred CRUD or extra filters.
|
|
62
|
+
Include missing features. Split alternative statuses and filter dimensions into separate probes.
|
|
63
|
+
A reopened state does not prove a different status exists. An authenticated page need not show signup.
|
|
64
|
+
Control match_terms are literal synonyms, excluding generic words like button or filter.
|
|
65
|
+
For concrete content use kind=content and match_terms with a narrowly scoped Python regex proving
|
|
66
|
+
that content (e.g. currency plus numeric amount for an actual price, not a dollar tier alone).
|
|
67
|
+
Only target relevant states. Skip requirements needing an action result not available in this crawl.'''
|
|
68
|
+
plan = claude_json(prompt, model='sonnet')
|
|
69
|
+
if not isinstance(plan, list): raise ValueError('Sonnet must return a list')
|
|
70
|
+
for p in plan:
|
|
71
|
+
if not isinstance(p, dict) or not all(k in p for k in ('page','verb','expected_control','kind','match_terms','source_quote')): raise ValueError('Invalid probe schema')
|
|
72
|
+
if type(p['page']) is not int or not 0 <= p['page'] < len(pages): raise ValueError('Invalid page index')
|
|
73
|
+
if p['kind'] not in ('control', 'content') or not isinstance(p['match_terms'], list) or not p['match_terms']: raise ValueError('Invalid probe kind/terms')
|
|
74
|
+
if not all(isinstance(x, str) and x.strip() for x in [p['verb'], p['expected_control'], p['source_quote'], *p['match_terms']]): raise ValueError('Empty probe text')
|
|
75
|
+
return [p for p in plan if p['source_quote'] in spec]
|
|
76
|
+
|
|
77
|
+
def _missing(p, page):
|
|
78
|
+
if p['kind'] == 'content': return not any(re.search(t, page['text'], re.I) for t in p['match_terms'])
|
|
79
|
+
return not any(re.search(r'(?<!\w)' + re.escape(t) + r'(?!\w)', label, re.I)
|
|
80
|
+
for t in p['match_terms'] for label in page['controls'] + page['fields'])
|
|
81
|
+
|
|
82
|
+
def probe(spec, pages):
|
|
83
|
+
"""One Sonnet plan, deterministic absence checks, one Jev question per candidate."""
|
|
84
|
+
if not pages: raise ValueError('No pages crawled; absence cannot be established')
|
|
85
|
+
plan, findings, seen = _plan(spec, pages), [], set()
|
|
86
|
+
for p in plan:
|
|
87
|
+
page = pages[p['page']]
|
|
88
|
+
key = (p['page'], p['expected_control'].casefold())
|
|
89
|
+
if key in seen or not _missing(p, page): continue
|
|
90
|
+
seen.add(key)
|
|
91
|
+
response = requests.post(JEV_URL, headers={'Authorization': f"Bearer {os.environ['TYPESAFE_API_KEY']}"},
|
|
92
|
+
json={'model': 'jev-latest', 'state': {'requirement': p['source_quote'], 'expected_control': p['expected_control'], **page},
|
|
93
|
+
'questions': {'feature_missing': {'type':'noul', 'instructions':
|
|
94
|
+
'Is the required control/content absent in this captured state? Allow equivalent labels. '
|
|
95
|
+
'Do not infer absence from an unperformed action, unavailable role or closed dialog.'}}}, timeout=30)
|
|
96
|
+
response.raise_for_status()
|
|
97
|
+
score = float(response.json()['answers']['feature_missing']['noul'])
|
|
98
|
+
if not 0 <= score <= 1: raise ValueError('Invalid Jev confidence')
|
|
99
|
+
if score >= .6: # ponytail: 0.61 = gold 07-16 (No Longer Relevant); recalibrate if fp rises
|
|
100
|
+
findings.append({'url':page['url'], 'verb':p['verb'], 'expected_control':p['expected_control'],
|
|
101
|
+
'evidence':json.dumps({'page':p['page'], 'requirement':p['source_quote'], 'missing_terms':p['match_terms'],
|
|
102
|
+
'controls':page['controls'], 'fields':page['fields'], 'text':page['text']}, ensure_ascii=False),
|
|
103
|
+
'confidence':round(score, 2)})
|
|
104
|
+
return findings
|
|
105
|
+
|
|
106
|
+
def _self_test():
|
|
107
|
+
page = {'text':'Price $$$', 'controls':['Reopen', 'Credit'], 'fields':['Search posts...']}
|
|
108
|
+
p = {'kind':'control', 'match_terms':['Edit']}
|
|
109
|
+
assert _missing(p, page) # Credit must not match Edit.
|
|
110
|
+
assert not _missing({**p, 'match_terms':['Search']}, page)
|
|
111
|
+
assert _missing({**p, 'match_terms':['No Longer Relevant']}, page)
|
|
112
|
+
p = {'kind':'content', 'match_terms':[r'\$\s*\d+']}
|
|
113
|
+
assert _missing(p, page)
|
|
114
|
+
assert not _missing(p, {**page, 'text':'Price $120'})
|
|
115
|
+
print('absence self-test: PASS')
|
|
116
|
+
|
|
117
|
+
def main():
|
|
118
|
+
parser = argparse.ArgumentParser()
|
|
119
|
+
parser.add_argument('--base'); parser.add_argument('--goal'); parser.add_argument('--self-test', action='store_true')
|
|
120
|
+
args = parser.parse_args()
|
|
121
|
+
if args.self_test: return _self_test()
|
|
122
|
+
if not args.base or not args.goal: parser.error('--base and --goal are required')
|
|
123
|
+
with sync_playwright() as pw:
|
|
124
|
+
browser = pw.chromium.launch(headless=True)
|
|
125
|
+
try: findings = probe(args.goal, crawl(browser.new_page(), args.base))
|
|
126
|
+
finally: browser.close()
|
|
127
|
+
print(json.dumps(findings, ensure_ascii=False))
|
|
128
|
+
|
|
129
|
+
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"instructions": "You are writing the QA checklist for a spec\u2192checklist generator being trained on synthetic CRUD-app data. Given a one-paragraph product spec, output 15-30 checklist items \u2014 the exact list a meticulous manual QA engineer would run against a live build of this spec.\n\nRules for each item:\n- One observable behavior per item: a concrete user action + the exact, visible result that must follow (state changed, value displayed, error shown, nothing else affected).\n- Never invent a feature, field, role, or workflow the spec doesn't state or clearly imply. If the spec doesn't mention permissions, don't test permissions.\n- Write like a human QA engineer filing a ticket, not like a requirements doc: plain, specific, occasionally blunt (\"X does not happen\" rather than \"X should occur\").\n\nCover the full surface, in roughly this order:\n1. **Entity lifecycle** \u2014 for every entity the spec implies (not just the primary one): create with valid data \u2192 appears immediately in the right list/view with the right fields; edit \u2192 updates in place, no duplicate; delete \u2192 disappears everywhere, including from any total/summary/report that referenced it.\n2. **Validation & boundaries** \u2014 required fields, invalid values (empty, negative, zero, duplicate), and what visible error appears; don't just assert \"an error occurs,\" assert the record is NOT saved.\n3. **Relationships between entities** \u2014 attaching/detaching one entity to another, cascade behavior on delete (orphaned data), and whether a newly created sub-entity becomes selectable elsewhere immediately.\n4. **List operations** \u2014 filter (single and combined), sort, search, clear filters, and counts (item count matches rows shown).\n5. **Derived data integrity** \u2014 sums, totals, charts, forecasts, reports: verify they match the underlying records and update live when a source record changes.\n6. **Cross-view consistency & navigation** \u2014 moving between list/detail/report views without losing filters or data, detail view shows the full record.\n7. **Roles/permissions** \u2014 only if the spec explicitly names more than one role; test that the restricted action is blocked/hidden for the wrong role and available for the right one.\n8. **Feedback** \u2014 confirmations on save/delete, and that failed actions show specific (not generic) errors.\n\nPrioritize items that exercise an edit operation, a delete-cascade, a confirmation message, or a derived total \u2014 these are the categories most likely to be wrong in a real implementation, so a checklist that skips them isn't doing its job. Vary phrasing and structure across items so the list doesn't read as a template filled in per-entity.",
|
|
3
|
+
"demos": [
|
|
4
|
+
{
|
|
5
|
+
"spec": "I want a community forum where users can register, create profiles with a short bio and interests, start and reply to discussion threads in different categories, and upvote or like helpful posts. People should be able to search for threads by keywords, filter by category, and see a list of trending or most active discussions over a recent period.",
|
|
6
|
+
"expectations": [
|
|
7
|
+
"New user can register with email/password and the account is created successfully",
|
|
8
|
+
"Registering with an already-used email shows a clear error and does not create a duplicate account",
|
|
9
|
+
"Registering with missing required fields (email/password) is blocked with a validation error",
|
|
10
|
+
"User can log in with valid credentials and reach their authenticated view",
|
|
11
|
+
"User can log out and is returned to a logged-out state",
|
|
12
|
+
"User can create/edit a profile with a short bio and a list of interests, and the saved values persist after page reload",
|
|
13
|
+
"Profile bio has an enforced length limit and rejects/trims text beyond it",
|
|
14
|
+
"User can start a new discussion thread by selecting a category, entering a title and body",
|
|
15
|
+
"A newly created thread appears in the correct category's thread list with correct title, author, and timestamp",
|
|
16
|
+
"Attempting to create a thread without a title, body, or category is blocked with a validation error",
|
|
17
|
+
"User can reply to an existing thread and the reply appears in the thread in correct chronological order",
|
|
18
|
+
"Reply count/timestamp on the thread list updates after a new reply is posted",
|
|
19
|
+
"User can upvote/like a post or reply once, and the like count increments by exactly one",
|
|
20
|
+
"User cannot upvote the same post multiple times (duplicate vote is prevented or toggles off)",
|
|
21
|
+
"Upvote counts are visible on both the thread list and the thread detail view",
|
|
22
|
+
"User can search threads by keyword and results include threads whose title or body match the keyword",
|
|
23
|
+
"Searching with a keyword that matches nothing returns an empty state, not an error",
|
|
24
|
+
"User can filter the thread list by category and only threads in that category are shown",
|
|
25
|
+
"Clearing the category filter returns the full unfiltered thread list",
|
|
26
|
+
"Trending/most-active list shows threads ranked by activity (replies/upvotes) within the stated recent period",
|
|
27
|
+
"Threads outside the trending period's window do not appear in the trending list even if highly active historically",
|
|
28
|
+
"Clicking a thread in the trending list navigates to that thread's full detail view",
|
|
29
|
+
"Navigating from the thread list to a thread detail page shows the full original post plus all replies",
|
|
30
|
+
"Navigating from a user's profile to their created threads (if listed) shows only threads authored by that user",
|
|
31
|
+
"Editing a thread or reply the user authored saves the updated content and displays it correctly afterward",
|
|
32
|
+
"Deleting a thread or reply the user authored removes it from the thread list/detail view",
|
|
33
|
+
"A logged-out user attempting to create a thread, reply, or upvote is prompted to register/log in instead",
|
|
34
|
+
"Category list/navigation shows all defined categories and selecting one shows only threads in that category",
|
|
35
|
+
"Submitting a thread or reply shows a confirmation (visual feedback) that the action succeeded",
|
|
36
|
+
"Invalid actions (e.g., empty search, malformed input) show a clear inline error rather than a silent failure or crash"
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
}
|
jevqa/cli.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""jevqa run <url> --spec <file>: autonomous pre-QA bug hunt, report.md + screenshots in --out."""
|
|
2
|
+
import argparse, os, sys, time
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
GOAL = ("Explore this app like a manual tester hunting for bugs; try to complete every feature end to end "
|
|
6
|
+
"(fill whole forms and submit, open details, use filters). The app was built from this spec: {spec}")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def ensure_browser():
|
|
10
|
+
"""First run: fetch Chromium for Playwright (~150 MB, once per machine)."""
|
|
11
|
+
import subprocess
|
|
12
|
+
from playwright.sync_api import sync_playwright
|
|
13
|
+
with sync_playwright() as p:
|
|
14
|
+
if Path(p.chromium.executable_path).exists(): return
|
|
15
|
+
print("jevqa: downloading Chromium for Playwright (once)...")
|
|
16
|
+
subprocess.run([sys.executable, "-m", "playwright", "install", "chromium"], check=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main(argv=None):
|
|
20
|
+
ap = argparse.ArgumentParser(prog="jevqa", description="Jev-guided monkey tester: finds missing features, validation gaps and dead controls before QA does.")
|
|
21
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
22
|
+
r = sub.add_parser("run", help="test one deployed app")
|
|
23
|
+
r.add_argument("url")
|
|
24
|
+
r.add_argument("--spec", required=True, help="spec / README / PRD text file the app was built from")
|
|
25
|
+
r.add_argument("--steps", type=int, default=50, help="action budget (default 50 ≈ 5 min, ~$0.35)")
|
|
26
|
+
r.add_argument("--out", default="jevqa-runs")
|
|
27
|
+
r.add_argument("--headed", action="store_true")
|
|
28
|
+
c = sub.add_parser("config", help="set or show API keys (stored in ~/.config/jevqa/config.json)")
|
|
29
|
+
c.add_argument("--reset", action="store_true", help="forget saved keys and ask again")
|
|
30
|
+
a = ap.parse_args(argv)
|
|
31
|
+
from . import config
|
|
32
|
+
if a.cmd == "config":
|
|
33
|
+
if a.reset and config.PATH.exists(): config.PATH.unlink()
|
|
34
|
+
config.ensure(); config.show(); return
|
|
35
|
+
config.ensure()
|
|
36
|
+
ensure_browser()
|
|
37
|
+
from . import tester, report, telemetry
|
|
38
|
+
telemetry.track("run_started", steps=a.steps, backend="cli" if not os.environ.get("ANTHROPIC_API_KEY") else "api")
|
|
39
|
+
spec = Path(a.spec).read_text()
|
|
40
|
+
argv = ["--base", a.url, "--goal", GOAL.format(spec=spec), "--steps", str(a.steps), "--out", a.out] + (["--headed"] if a.headed else [])
|
|
41
|
+
t0 = time.time()
|
|
42
|
+
run = tester.main(argv)
|
|
43
|
+
path, n, usd = report.write_markdown(run, a.url)
|
|
44
|
+
print(f"\n{n} findings -> {path} ({time.time() - t0:.0f}s, ${usd:.2f})")
|
|
45
|
+
telemetry.track("run_finished", steps=a.steps, findings=n, secs=round(time.time() - t0), usd=round(usd, 2))
|
|
46
|
+
if os.environ.get("GITHUB_OUTPUT"):
|
|
47
|
+
with open(os.environ["GITHUB_OUTPUT"], "a") as fh: fh.write(f"report={path}\nfindings={n}\nrun_dir={run}\n")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
main()
|
jevqa/config.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Keys live in ~/.config/jevqa/config.json (env vars override). `ensure()` runs before every `jevqa run`:
|
|
2
|
+
all set -> continue silently; something missing -> ask interactively (or fail with instructions when there is no TTY, e.g. CI)."""
|
|
3
|
+
import getpass, json, os, shutil, sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
PATH = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "jevqa" / "config.json"
|
|
7
|
+
KEYS = {"TYPESAFE_API_KEY": "TypeSafe (Jev) API key — https://console.typesafe.ai",
|
|
8
|
+
"ANTHROPIC_API_KEY": "Anthropic API key — https://console.anthropic.com (or leave empty to use the Claude Code CLI)"}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load():
|
|
12
|
+
try: return json.loads(PATH.read_text())
|
|
13
|
+
except (OSError, ValueError): return {}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def save(cfg):
|
|
17
|
+
PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
PATH.write_text(json.dumps(cfg, indent=1)); PATH.chmod(0o600)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _ask(key, cfg):
|
|
22
|
+
print(f"\n{key} is not set. {KEYS[key]}")
|
|
23
|
+
if key == "ANTHROPIC_API_KEY" and shutil.which("claude"):
|
|
24
|
+
print(" 1) enter an Anthropic API key (recommended, ~$0.25 per app)\n 2) use the logged-in Claude Code CLI (billed to your subscription)\n 3) quit")
|
|
25
|
+
c = input("choice [1]: ").strip() or "1"
|
|
26
|
+
if c == "2": cfg["CLAUDE_BACKEND"] = "cli"; return True
|
|
27
|
+
if c != "1": return False
|
|
28
|
+
v = getpass.getpass(f"{key}: ").strip()
|
|
29
|
+
if not v: return False
|
|
30
|
+
cfg[key] = v; return True
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def ensure():
|
|
34
|
+
cfg = load()
|
|
35
|
+
for k, v in cfg.items():
|
|
36
|
+
if k in KEYS and v: os.environ.setdefault(k, v)
|
|
37
|
+
missing = [k for k in KEYS if not os.environ.get(k)]
|
|
38
|
+
if "ANTHROPIC_API_KEY" in missing and cfg.get("CLAUDE_BACKEND") == "cli" and shutil.which("claude"): missing.remove("ANTHROPIC_API_KEY")
|
|
39
|
+
if not missing: return
|
|
40
|
+
if not sys.stdin.isatty():
|
|
41
|
+
sys.exit("missing: " + ", ".join(missing) + f". Set them as env vars / secrets, or run `jevqa config` once on a machine with a terminal (stored in {PATH}).")
|
|
42
|
+
from . import telemetry; telemetry.track("keys_prompted", missing=missing)
|
|
43
|
+
changed = False
|
|
44
|
+
for k in missing:
|
|
45
|
+
if not _ask(k, cfg): sys.exit("cannot run without it")
|
|
46
|
+
changed = True
|
|
47
|
+
if changed and (input(f"save to {PATH}? [Y/n]: ").strip().lower() or "y") == "y": save(cfg)
|
|
48
|
+
for k in KEYS:
|
|
49
|
+
if cfg.get(k): os.environ.setdefault(k, cfg[k])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def show():
|
|
53
|
+
cfg = load()
|
|
54
|
+
for k in KEYS:
|
|
55
|
+
src = "env" if os.environ.get(k) else ("config" if cfg.get(k) else "-")
|
|
56
|
+
print(f"{k:20s} {src}")
|
|
57
|
+
print(f"{'claude backend':20s} {'cli' if cfg.get('CLAUDE_BACKEND') == 'cli' and not os.environ.get('ANTHROPIC_API_KEY') else 'api'}")
|
|
58
|
+
print(f"file: {PATH}")
|
jevqa/llm.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Claude calls via the Anthropic SDK (replaces `claude -p` CLI): JSON in, parsed JSON out, cost tracked in USAGE."""
|
|
2
|
+
import json, os, re, subprocess
|
|
3
|
+
|
|
4
|
+
MODELS = {"sonnet": "claude-sonnet-5", "haiku": "claude-haiku-4-5-20251001"}
|
|
5
|
+
PRICE = {"sonnet": (3.0, 15.0), "haiku": (1.0, 5.0)} # $/MTok in, out
|
|
6
|
+
USAGE = {"claude_usd": 0.0, "claude_calls": 0}
|
|
7
|
+
_client = None
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _parse(body):
|
|
11
|
+
fence = re.search(r"```(?:json)?\s*(.*?)```", body, re.S)
|
|
12
|
+
for cand in ([fence.group(1)] if fence else []) + [body] + [m.group() for m in re.finditer(r"[\[{].*[\]}]", body, re.S)]:
|
|
13
|
+
try: return json.loads(cand)
|
|
14
|
+
except ValueError: pass
|
|
15
|
+
return None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _cli(prompt, model):
|
|
19
|
+
"""Fallback when ANTHROPIC_API_KEY is absent: the user's logged-in Claude Code CLI (subscription billing)."""
|
|
20
|
+
out = subprocess.run(["claude", "-p", "--output-format", "json", "--model", model], input=prompt, text=True, capture_output=True).stdout
|
|
21
|
+
try: res = json.loads(out)
|
|
22
|
+
except ValueError: return None
|
|
23
|
+
if not isinstance(res, dict): return None
|
|
24
|
+
USAGE["claude_usd"] += res.get("total_cost_usd", 0); USAGE["claude_calls"] += 1
|
|
25
|
+
return _parse(res.get("result", ""))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def claude_json(prompt, model="haiku", max_tokens=8000):
|
|
29
|
+
global _client
|
|
30
|
+
if not os.environ.get("ANTHROPIC_API_KEY"): return _cli(prompt, model)
|
|
31
|
+
if _client is None:
|
|
32
|
+
import anthropic
|
|
33
|
+
_client = anthropic.Anthropic() # ANTHROPIC_API_KEY from env
|
|
34
|
+
r = _client.messages.create(model=os.environ.get(f"JEVQA_{model.upper()}", MODELS[model]), max_tokens=max_tokens,
|
|
35
|
+
messages=[{"role": "user", "content": prompt}])
|
|
36
|
+
pi, po = PRICE[model]
|
|
37
|
+
USAGE["claude_usd"] += r.usage.input_tokens / 1e6 * pi + r.usage.output_tokens / 1e6 * po; USAGE["claude_calls"] += 1
|
|
38
|
+
return _parse("".join(b.text for b in r.content if b.type == "text"))
|
jevqa/oracles.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"error_shown": "Does `after.text` show an error, stack trace, or failure notice NOT in `before.text` that is NOT a normal client-side validation message correctly describing the user's own incomplete/invalid input (e.g. 'Last Name is required' after submitting with that field empty is expected, not a bug)?",
|
|
3
|
+
"action_ignored": "Given `action` and the change from `before.text` to `after.text`, did the UI fail to react as the action's label promises, EXCLUDING: actions on external/social links (Facebook, LinkedIn, X/Twitter) opening a new tab; 'Add to cart'/'Remove' clicks whose only unmet expectation is cart badge count (already tracked); AND text-input actions where `after.text`'s field value snapshot cannot be trusted to reflect live typed state (only flag if a DIFFERENT, unexpected value appears in that field, not merely an unchanged/empty one)?",
|
|
4
|
+
"inconsistent_state": "Does `after.text` contain data that contradicts itself or the action (e.g. wrong item shown, counts that do not match the visible list, wrong page for the action)?",
|
|
5
|
+
"broken_content": "Does `after.text` show placeholder text, missing labels, untranslated keys, or broken layout that is NOT simply the tester's own typed input being echoed back, and NOT normal menu/nav items (e.g. 'Dynamic Catalog', 'Lazy Load', 'Spinner', 'Slider') that are legitimate labels rather than garbled output?",
|
|
6
|
+
"dead_end": "Is the user now stuck: no visible way to continue or go back to the main flow from `after.text`?"
|
|
7
|
+
}
|
jevqa/report.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Deterministic bug-report sentences per finding (the benchmark's REPORTS=template stage) + markdown writer."""
|
|
2
|
+
import hashlib, json, os, requests
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
WHAT = {"zeros": "a chart, total or summary shows zero/empty values although data exists", "count_mismatch": "a counter or badge does not match the number of visible items",
|
|
6
|
+
"wrong_page": "the app shows the wrong page or view for the action", "wrong_item": "a different item than the one acted on is shown or changed", "empty_list": "a list that should contain items is empty",
|
|
7
|
+
"placeholder": "placeholder, garbled or untranslated text is visible", "duplicate": "a duplicate entry or record appeared", "stale": "old values remain after the update/delete",
|
|
8
|
+
"missing_feedback": "no confirmation or feedback appeared after the action", "missing_control": "a control or content the spec requires on this page is absent", "other": "something else"}
|
|
9
|
+
VAGUE = {"initial_broken", "inconsistent_state", "broken_content", "expectation_violated", "page_incomplete"}
|
|
10
|
+
|
|
11
|
+
def what_is_wrong(f, cache):
|
|
12
|
+
"""Post-hoc Jev choice on the stored evidence: name the concrete defect for vague flags."""
|
|
13
|
+
ev = f.get("evidence") or {}
|
|
14
|
+
if not (set(f.get("flags") or []) & VAGUE) or not (ev.get("after") or {}).get("text"): return None
|
|
15
|
+
key = hashlib.md5((f.get("action", "") + ev["after"]["text"][:1500]).encode()).hexdigest()
|
|
16
|
+
if key not in cache:
|
|
17
|
+
st = {"action": f.get("action"), "flags": f.get("flags"), "expectation": f.get("expectation"), "before": (ev.get("before") or {}).get("text", "")[:1500], "after": ev["after"]["text"][:2500], "diff": f.get("diff")}
|
|
18
|
+
r = requests.post("https://api.typesafe.ai/v1/systemone", headers={"Authorization": f"Bearer {os.environ['TYPESAFE_API_KEY']}"}, timeout=30,
|
|
19
|
+
json={"model": "jev-latest", "state": st, "questions": {"what": {"type": "choice", "criteria": WHAT,
|
|
20
|
+
"instructions": "An oracle flagged this state with `flags`. Judging `after` (and `diff`, `expectation`), which single description names what is CONCRETELY wrong on the page?"}}})
|
|
21
|
+
r.raise_for_status(); a = r.json()["answers"]["what"]
|
|
22
|
+
cache[key] = a["choice"] if a["confidence"] >= 0.3 and a["choice"] != "other" else None
|
|
23
|
+
return cache[key]
|
|
24
|
+
|
|
25
|
+
def template_report(f, cache):
|
|
26
|
+
ev, d = f.get("evidence") or {}, f.get("diff") or {}
|
|
27
|
+
PHRASE = {"action_ignored": "clicking/using it had no visible effect (the UI did not react as the control's label promises)", "invalid_accepted": "an INVALID value was accepted without any validation error", "valid_rejected": "a valid submission was rejected or ignored", "form_values_mangled": "after submitting, the form shows values that are neither the defaults nor what was entered (silently normalized instead of validated)", "detail_mismatch": "the opened detail view shows a value contradicting the one in the clicked card/link", "cap_below_capacity": "the +/- control stops at a limit lower than the availability/capacity the page states", "permission_leak": "a control/content that the spec reserves for another role is visible on this screen", "duplicate_banner": "the success/confirmation message appears twice (the effect fired more than once)",
|
|
28
|
+
"error_shown": "an error message or failure notice appeared", "inconsistent_state": "the page then showed self-contradictory data (counts, items or page not matching the action)", "broken_content": "the page showed placeholder/garbled/missing content",
|
|
29
|
+
"dead_end": "the user was left with no way to continue", "expectation_violated": "the spec expectation for this step was NOT met", "initial_broken": "the initial page renders wrong seed data/summary", "page_incomplete": "the page lacks content/controls the spec requires here",
|
|
30
|
+
"role_leak": "a control reserved for another role is visible", "filter_unchanged": "the list did not change after the filter/search/sort", "filter_inconsistent": "items inconsistent with the selected filter remained", "value_not_propagated": "the submitted record did not appear where it should",
|
|
31
|
+
"effect_magnitude": "one click changed a counter by 2 or more (double-fire)", "dead_link": "the link led nowhere (URL and page unchanged)", "blank_page": "the page went blank", "js_exception": "a JavaScript exception was thrown", "http_error": "an HTTP error response occurred", "expectation_unreachable": "the feature the spec requires could not be found anywhere"}
|
|
32
|
+
flags = f.get("flags") or []
|
|
33
|
+
if "expectation_unreachable" in flags:
|
|
34
|
+
return f'BUG (missing feature): the spec requires "{f.get("expectation")}", but no control, page or content implementing it exists in the app — the tester searched for it over several steps (last page {f.get("page")}) and found nothing to click, fill or read for it.'
|
|
35
|
+
what = what_is_wrong(f, cache)
|
|
36
|
+
out = f'BUG: on {f.get("page")}, after "{f.get("action")}", ' + "; ".join(PHRASE.get(k, k) for k in flags) + (f' — concretely: {WHAT[what]}' if what else "") + "."
|
|
37
|
+
if f.get("repeated"): out += f' The control was pressed {f["repeated"]["presses"]} times in a row and the value stopped changing (see counters below).'
|
|
38
|
+
if f.get("expectation"): out += f' Expected (spec): {f["expectation"]}.'
|
|
39
|
+
d = f.get("diff") or {}
|
|
40
|
+
if d: out += f' Actual: url_changed={d.get("url_changed")}; text that appeared: {json.dumps(d.get("added_lines", [])[:6], ensure_ascii=False)[:400]}; counters: {d.get("counters_changed")}.'
|
|
41
|
+
out += f' Details: the tester did: {f.get("action")}.'
|
|
42
|
+
if f.get("values"): out += f' Submitted values: {json.dumps(f["values"], ensure_ascii=False)[:400]}.'
|
|
43
|
+
if f.get("rule"): out += f' The payload deliberately violated the rule: {f["rule"]}.'
|
|
44
|
+
if f.get("expectation"): out += f' Spec expectation for this step: {f["expectation"]}.'
|
|
45
|
+
out += f' Oracle verdict: {", ".join(f.get("flags") or [])}.'
|
|
46
|
+
if d: out += f' Observed: url_changed={d.get("url_changed")}; added text: {json.dumps(d.get("added_lines", [])[:6], ensure_ascii=False)[:400]}; counters: {d.get("counters_changed")}.'
|
|
47
|
+
else: out += f' Visible text after: {(ev.get("after") or {}).get("text", "")[:300]!r}.'
|
|
48
|
+
for k in ("console_errors", "js_exceptions", "http_errors"):
|
|
49
|
+
if ev.get(k): out += f' {k}: {json.dumps(ev[k])[:200]}.'
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def reports_for(rep):
|
|
56
|
+
"""[(finding, sentence)] for a finished run: replay filter, absence/page errors verbatim, template for the rest."""
|
|
57
|
+
cache, out = {}, []
|
|
58
|
+
for f in rep["findings"]:
|
|
59
|
+
if f.get("reproduced") is False and not f.get("deterministic"): continue # verify-by-replay: drop non-reproducible
|
|
60
|
+
kind = f.get("kind")
|
|
61
|
+
if (f.get("report") and f.get("how") == "absence") or kind in ("pageerror", "blank_page"):
|
|
62
|
+
out.append((f, f.get("report") or f.get("message") or f.get("action") or kind))
|
|
63
|
+
else:
|
|
64
|
+
out.append((f, template_report(f, cache)))
|
|
65
|
+
return out
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def write_markdown(run, url):
|
|
69
|
+
rep = json.load(open(run / "findings.json"))
|
|
70
|
+
items = reports_for(rep)
|
|
71
|
+
usd = rep["jev"]["input_tokens"] / 1e6 * 0.042 + rep["jev"].get("claude_usd", 0)
|
|
72
|
+
lines = [f"# jevqa report: {url}", "",
|
|
73
|
+
f"{len(items)} findings · {rep['steps']} steps · {rep['states']} screens · {rep['secs']:.0f}s · ${usd:.2f} (Jev {rep['jev']['calls']} calls, Claude {rep['jev'].get('claude_calls', 0)})", "",
|
|
74
|
+
f"Expectations exercised without violation: {len(rep.get('subgoals_done', []))}/{len(rep.get('expectations', []))}. "
|
|
75
|
+
"Expect roughly 1 in 8 findings to be a confirmed defect; the rest are noise or defects outside the spec. Triage top to bottom.", ""]
|
|
76
|
+
order = {"absence": 0, "init": 1, "probe": 2, "step": 3}
|
|
77
|
+
items.sort(key=lambda t: order.get((t[0].get("how") or "").split("(")[0], 9))
|
|
78
|
+
for i, (f, text) in enumerate(items, 1):
|
|
79
|
+
flags = ", ".join(f.get("flags") or [])
|
|
80
|
+
lines += [f"## {i}. {flags or f.get('kind') or 'finding'}", "", text, ""]
|
|
81
|
+
shot = run / "flagged" / f"{f.get('step', -1):03d}.png"
|
|
82
|
+
if shot.exists(): lines += [f"", ""]
|
|
83
|
+
(run / "report.md").write_text("\n".join(lines))
|
|
84
|
+
return run / "report.md", len(items), usd
|
jevqa/telemetry.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Anonymous usage events to PostHog. Off with JEVQA_TELEMETRY=0. Never sends URLs, specs or report text."""
|
|
2
|
+
import hashlib, json, os, platform, uuid
|
|
3
|
+
from . import __version__
|
|
4
|
+
|
|
5
|
+
KEY = os.environ.get("JEVQA_POSTHOG_KEY", "phc_pNFJdSzEDFo7XZSom3bvsxLtivVzJqQRNPzVT2qs5ree") # public write-only project key
|
|
6
|
+
HOST = "https://eu.i.posthog.com"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _id():
|
|
10
|
+
try: return hashlib.sha256(f"{uuid.getnode()}{platform.node()}".encode()).hexdigest()[:16]
|
|
11
|
+
except Exception: return "anon"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def track(event, **props):
|
|
15
|
+
if not KEY or os.environ.get("JEVQA_TELEMETRY", "1") == "0" or os.environ.get("CI") and os.environ.get("JEVQA_TELEMETRY") != "1": return
|
|
16
|
+
try:
|
|
17
|
+
import requests
|
|
18
|
+
requests.post(f"{HOST}/capture/", timeout=3, json={"api_key": KEY, "event": event, "distinct_id": _id(),
|
|
19
|
+
"properties": {"version": __version__, "os": platform.system(), "ci": bool(os.environ.get("GITHUB_ACTIONS")), **props}})
|
|
20
|
+
except Exception: pass
|
jevqa/tester.py
ADDED
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
"""Jev-guided monkey tester, v8.
|
|
2
|
+
|
|
3
|
+
Layers: code (abstract state graph, cheap oracles, recording, replay) -> Jev (action choice, sub-goal check, oracles)
|
|
4
|
+
-> Claude, sparse (Sonnet checklist of expectations once per app, Haiku payload pair once per form) -> Claude report (bench/run.py)
|
|
5
|
+
Run: uv run python monkey.py --steps 50 --base http://localhost:6100 --goal "<spec>"
|
|
6
|
+
"""
|
|
7
|
+
import argparse, hashlib, json, os, random, re, time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
from playwright.sync_api import sync_playwright
|
|
12
|
+
|
|
13
|
+
JEV_URL = "https://api.typesafe.ai/v1/systemone"
|
|
14
|
+
KEY = os.environ.get("TYPESAFE_API_KEY", "")
|
|
15
|
+
SAUCE = "https://www.saucedemo.com"
|
|
16
|
+
BASE = SAUCE # overridden by --base
|
|
17
|
+
MAX_CANDIDATES = 40
|
|
18
|
+
EPSILON = 0.15 # share of random side-steps
|
|
19
|
+
FLAG_THRESHOLD = 0.7 # noul above this = suspicious state
|
|
20
|
+
MAX_RULES = 8 # ponytail: boundary submits per form; raise if forms with >8 typed fields matter
|
|
21
|
+
URL_CAP = 8 # consecutive steps on one path before forcing an exit to an unvisited path
|
|
22
|
+
SEL = ('a[href], button, input, select, textarea, [role=button], [role=option], [role=menuitem], [role=tab], '
|
|
23
|
+
'[role=checkbox], [role=switch], [role=slider], [onclick]')
|
|
24
|
+
USAGE = {"input_tokens": 0, "calls": 0}
|
|
25
|
+
ORACLES = json.load(open(Path(__file__).parent / "oracles.json"))
|
|
26
|
+
from . import absence
|
|
27
|
+
FORM_CACHE = {}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------- models ----------------
|
|
31
|
+
def jev(state, questions):
|
|
32
|
+
for attempt in range(3):
|
|
33
|
+
try:
|
|
34
|
+
r = requests.post(JEV_URL, headers={"Authorization": f"Bearer {KEY}"},
|
|
35
|
+
json={"model": "jev-latest", "state": state, "questions": questions}, timeout=30)
|
|
36
|
+
r.raise_for_status(); break
|
|
37
|
+
except requests.RequestException:
|
|
38
|
+
if attempt == 2: raise
|
|
39
|
+
time.sleep(2 * (attempt + 1))
|
|
40
|
+
d = r.json(); USAGE["input_tokens"] += d.get("usage", {}).get("input_tokens", 0); USAGE["calls"] += 1
|
|
41
|
+
return d["answers"]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
from .llm import claude_json, USAGE as CLAUDE_USAGE
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------- page model ----------------
|
|
48
|
+
def snapshot(page):
|
|
49
|
+
text = page.inner_text("body")[:6000]
|
|
50
|
+
fields = page.evaluate("""() => [...document.querySelectorAll('input:not([type=hidden]), select, textarea')]
|
|
51
|
+
.map(e => `[field ${e.placeholder || e.name || e.id || e.type}='${e.value}']`).join(' ')""")
|
|
52
|
+
text += "\n" + fields
|
|
53
|
+
data = page.evaluate("""(sel) => {
|
|
54
|
+
const fieldTag = t => ['input', 'textarea', 'select'].includes(t);
|
|
55
|
+
const DATE = /select (a )?date|pick a date|choose (a )?date|dd[/]mm|mm[/]dd|yyyy|calendar/i;
|
|
56
|
+
const widget = e => e.getAttribute('role') === 'combobox' ? 'combobox' : e.getAttribute('role') === 'slider' ? 'slider'
|
|
57
|
+
: ['checkbox', 'switch'].includes(e.getAttribute('role')) ? 'checkbox'
|
|
58
|
+
: (e.tagName === 'BUTTON' && (DATE.test(e.innerText) || e.querySelector('svg.lucide-calendar, svg.lucide-calendar-days'))) ? 'date' : null;
|
|
59
|
+
const SUBMIT = /save|creat|submit|add|updat|sign|log ?in|regist|confirm|apply|send|book|place|pay/i;
|
|
60
|
+
const nFields = el => el.querySelectorAll('input:not([type=hidden]):not([type=checkbox]):not([type=radio]), select, textarea, [role=combobox], [role=slider]').length;
|
|
61
|
+
const hasSubmit = el => [...el.querySelectorAll('button')].some(b => b.type === 'submit' || SUBMIT.test(b.innerText));
|
|
62
|
+
// smallest ancestor holding >=2 fields and a submit-like button = the form, whatever markup the app used
|
|
63
|
+
const formOf = e => { let c = e.parentElement; while (c && c !== document.body && !(nFields(c) >= 2 && hasSubmit(c))) c = c.parentElement; return c && c !== document.body ? c : null; };
|
|
64
|
+
const containers = new Map();
|
|
65
|
+
const els = [...document.querySelectorAll(sel)].map((e, i) => [e, i]).filter(([e]) => {
|
|
66
|
+
const r = e.getBoundingClientRect();
|
|
67
|
+
if (!(r.width > 0 && r.height > 0) || e.disabled) return false;
|
|
68
|
+
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
|
|
69
|
+
if (cy < 0 || cy > innerHeight) return true; // off-screen centre: Playwright scrolls to it; overlay check impossible
|
|
70
|
+
const hit = document.elementFromPoint(cx, cy);
|
|
71
|
+
return hit && (hit === e || e.contains(hit));
|
|
72
|
+
}).map(([e, i]) => {
|
|
73
|
+
const tag = e.tagName.toLowerCase();
|
|
74
|
+
const box = e.closest('[class*=item], li, tr, form, section, article');
|
|
75
|
+
const name = box && box !== e ? box.querySelector('[class*=name], [class*=title], h1, h2, h3, h4') : null;
|
|
76
|
+
const near = e.closest('div, li, td');
|
|
77
|
+
const label = (e.labels && e.labels[0] ? e.labels[0].innerText : '') || (near && near.querySelector('label') ? near.querySelector('label').innerText : '');
|
|
78
|
+
const w = widget(e);
|
|
79
|
+
const cont = fieldTag(tag) || tag === 'button' || w ? formOf(e) : null;
|
|
80
|
+
let cid = null;
|
|
81
|
+
if (cont) { if (!containers.has(cont)) containers.set(cont, containers.size); cid = containers.get(cont); }
|
|
82
|
+
const svg = e.querySelector('svg');
|
|
83
|
+
return {
|
|
84
|
+
i, tag, type: w || e.type || '', widget: w || undefined, cid, href: tag === 'a' ? e.getAttribute('href') : null,
|
|
85
|
+
text: (fieldTag(tag) || w ? (label || e.placeholder || e.getAttribute('aria-label') || e.name || e.id || e.value)
|
|
86
|
+
: (e.innerText || e.value || e.getAttribute('aria-label') || e.title || e.name || e.id
|
|
87
|
+
|| (svg ? svg.getAttribute('class') : '') || '')).trim().slice(0, 60),
|
|
88
|
+
ctx: name ? name.innerText.trim().slice(0, 40) : '',
|
|
89
|
+
attrs: fieldTag(tag) ? {required: e.required || undefined, min: e.min || undefined, max: e.max || undefined,
|
|
90
|
+
placeholder: e.placeholder || undefined,
|
|
91
|
+
options: tag === 'select' ? [...e.options].map(o => o.text).slice(0, 12) : undefined} : undefined
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
const forms = {};
|
|
95
|
+
for (const [cont, cid] of containers) {
|
|
96
|
+
const f = els.filter(x => x.cid === cid && (fieldTag(x.tag) || x.widget) && !['checkbox', 'radio', 'submit', 'button', 'file'].includes(x.type));
|
|
97
|
+
const btns = els.filter(x => x.cid === cid && x.tag === 'button' && !x.widget);
|
|
98
|
+
const btn = btns.find(x => x.type === 'submit' && !/cancel|close/i.test(x.text)) || btns.find(x => SUBMIT.test(x.text));
|
|
99
|
+
if (f.length >= 2) {
|
|
100
|
+
const h = cont.querySelector('h1, h2, h3, h4, [class*=title]');
|
|
101
|
+
forms[cid] = {cid, name: (h ? h.innerText : (btn ? btn.text : 'form')).trim().slice(0, 50), fields: f.map(x => x.i), submit: btn ? btn.i : null};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return {els, forms};
|
|
105
|
+
}""", SEL)
|
|
106
|
+
return {"url": page.url.replace(BASE, ""), "title": page.title(), "text": text, "elements": data["els"], "forms": data["forms"]}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def norm(s): return re.sub(r"\d+", "#", s or "")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def counters(text): return set(re.findall(r"[A-Za-z][A-Za-z ]{2,30}[:#]?\s*\(?\d+\)?", text)) # "Total Events 3", "Upcoming (0)"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def counter_map(text): return dict(re.findall(r"([A-Za-z][A-Za-z ]{2,30}?)\s*[:#]?\s*\(?\$?(-?[\d,]+(?:\.\d+)?)k?\)?(?=\s|$)", text))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def diff(before, after):
|
|
119
|
+
"""Code-computed change between two snapshots: what the oracle points at instead of two text blobs (v9)."""
|
|
120
|
+
b, a = before["text"].splitlines(), after["text"].splitlines()
|
|
121
|
+
bs, as_ = set(b), set(a); cb, ca = counter_map(before["text"]), counter_map(after["text"])
|
|
122
|
+
d = {"url_changed": before["url"] != after["url"],
|
|
123
|
+
"added_lines": [x for x in a if x.strip() and x not in bs][:30], "removed_lines": [x for x in b if x.strip() and x not in as_][:30],
|
|
124
|
+
"counters_changed": {k: [cb[k], ca[k]] for k in cb if k in ca and cb[k] != ca[k]}}
|
|
125
|
+
d["changed"] = d["url_changed"] or bool(d["added_lines"]) or bool(d["removed_lines"]) or bool(d["counters_changed"])
|
|
126
|
+
return d
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def state_sig(snap):
|
|
130
|
+
"""Abstract state: normalized path + set of interactive elements; free text ignored."""
|
|
131
|
+
path = norm(snap["url"].split("?")[0])
|
|
132
|
+
els = sorted({f'{e["tag"]}:{e["type"]}:{norm(e["text"])[:30]}' for e in snap["elements"]})[:80]
|
|
133
|
+
return hashlib.md5((path + "|" + "|".join(els)).encode()).hexdigest()[:10]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def locator(page, idx): return page.locator(SEL).nth(idx)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def describe(el):
|
|
140
|
+
ctx = f' ({el["ctx"]})' if el["ctx"] and el["ctx"] not in el["text"] else ""
|
|
141
|
+
return f'{el["tag"]}{"[" + el["type"] + "]" if el["type"] else ""} "{el["text"]}"{ctx}'
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def login(page, user):
|
|
145
|
+
page.goto(BASE); page.fill("#user-name", user); page.fill("#password", "secret_sauce"); page.click("#login-button")
|
|
146
|
+
page.wait_for_load_state("networkidle")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ---------------- actions ----------------
|
|
150
|
+
def drive(page, loc, kind, value=None, past=False):
|
|
151
|
+
"""Radix/shadcn widgets: combobox -> option (by text, else first enabled); date trigger -> enabled day after today (before, when past);
|
|
152
|
+
slider -> ArrowRight x3; role checkbox/switch -> click. Returns the option/day text picked."""
|
|
153
|
+
if kind == "combobox":
|
|
154
|
+
loc.click(timeout=3000); page.wait_for_timeout(300)
|
|
155
|
+
opts = page.locator("[role=option]:not([aria-disabled=true]):not([data-disabled])")
|
|
156
|
+
pick = opts.filter(has_text=str(value)) if value else opts
|
|
157
|
+
pick = pick.first if pick.count() else opts.first
|
|
158
|
+
text = pick.inner_text(timeout=1500); pick.click(timeout=1500); return text
|
|
159
|
+
if kind == "date":
|
|
160
|
+
loc.click(timeout=3000); page.wait_for_timeout(300)
|
|
161
|
+
if past:
|
|
162
|
+
page.locator("button[name=previous-month]").first.click(timeout=1500); page.wait_for_timeout(200)
|
|
163
|
+
days = page.locator("[role=grid] [role=gridcell]:not([disabled]):not([aria-disabled=true])")
|
|
164
|
+
n = days.count(); text = None
|
|
165
|
+
if n:
|
|
166
|
+
day = days.nth(0 if past else min(n - 1, 3)) # earliest enabled day when past is wanted; a few days after the first enabled otherwise
|
|
167
|
+
text = day.inner_text(timeout=1500); day.click(timeout=1500); page.wait_for_timeout(200)
|
|
168
|
+
if page.locator("[role=grid]").count(): loc.click(timeout=1500) # popover still open: toggle it shut
|
|
169
|
+
return text
|
|
170
|
+
if kind == "slider":
|
|
171
|
+
loc.focus(timeout=3000)
|
|
172
|
+
for _ in range(3): page.keyboard.press("ArrowRight")
|
|
173
|
+
return "+3"
|
|
174
|
+
loc.click(timeout=3000); return "toggled" # role checkbox / switch
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def act(page, el, value=None):
|
|
178
|
+
"""One primitive action; returns the value typed (for replay)."""
|
|
179
|
+
loc = locator(page, el["i"])
|
|
180
|
+
if el.get("widget"): value = drive(page, loc, el["widget"], value)
|
|
181
|
+
elif el["tag"] == "textarea" or (el["tag"] == "input" and el["type"] in ("text", "email", "password", "search", "url", "tel", "number", "date", "")):
|
|
182
|
+
if value is None:
|
|
183
|
+
value = {"number": "12", "date": "2026-10-01", "email": "qa@example.com"}.get(el["type"]) or random.choice(["Test", "", "12345", "<script>x</script>", "Іван"])
|
|
184
|
+
if el["type"] == "number": value = re.sub(r"[^\d.-]", "", value) or "1"
|
|
185
|
+
loc.fill(value, timeout=3000)
|
|
186
|
+
elif el["tag"] == "select":
|
|
187
|
+
loc.select_option(index=1)
|
|
188
|
+
else:
|
|
189
|
+
loc.click(timeout=3000)
|
|
190
|
+
page.wait_for_timeout(600)
|
|
191
|
+
return value
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def form_payloads(snap, form, spec):
|
|
195
|
+
fields = [e for e in snap["elements"] if e["i"] in form["fields"]]
|
|
196
|
+
key = hashlib.md5((norm(snap["url"]) + form["name"] + "|".join(f["text"] for f in fields)).encode()).hexdigest()[:10]
|
|
197
|
+
if key not in FORM_CACHE:
|
|
198
|
+
meta = [{"label": f["text"], "type": f["type"] or f["tag"], **{k: v for k, v in (f["attrs"] or {}).items() if v}} for f in fields]
|
|
199
|
+
FORM_CACHE[key] = claude_json(f"""A web app was built from this spec:
|
|
200
|
+
{spec}
|
|
201
|
+
|
|
202
|
+
Today is {time.strftime("%Y-%m-%d")}. A tester is on page {snap["url"]} at the form "{form["name"]}" with these fields:
|
|
203
|
+
{json.dumps(meta, ensure_ascii=False)}
|
|
204
|
+
|
|
205
|
+
Produce test payloads. Values must be strings; for select fields use one of the listed options verbatim; dates as YYYY-MM-DD; numbers as plain digits.
|
|
206
|
+
Return ONLY JSON: {{"valid": {{"<label>": "<realistic valid value>", ...all fields...}},
|
|
207
|
+
"invalid": {{"field": "<label>", "value": "<value violating a business rule or boundary implied by the spec/field (e.g. 0 or -1 for a price/quantity/count/budget, rating 7 on a 0-5 scale, a past date where the future is required or a far-future date where the past is required, a range whose start is after its end, malformed email, 300-char name)>", "rule": "<the rule it violates, one sentence>"}}}}""") or {}
|
|
208
|
+
if not isinstance(FORM_CACHE[key], dict) or not isinstance(FORM_CACHE[key].get("valid"), dict): FORM_CACHE[key] = {}
|
|
209
|
+
return fields, FORM_CACHE[key]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def boundary_rules(fields, valid):
|
|
213
|
+
"""Rule-based boundary matrix per field type, no LLM: [(label, value, rule, past)]."""
|
|
214
|
+
def kind(f):
|
|
215
|
+
typ, lab = (f["type"] or "").lower(), f["text"].lower()
|
|
216
|
+
if typ == "number" or re.search(r"budget|guest|price|quantity|qty|amount|count|salary|capacity|party|size|rating|age", lab): return "number"
|
|
217
|
+
if typ == "date" or f.get("widget") == "date" or "date" in lab: return "date"
|
|
218
|
+
if typ == "email" or "email" in lab: return "email"
|
|
219
|
+
if typ == "tel" or "phone" in lab: return "phone"
|
|
220
|
+
return None
|
|
221
|
+
today = time.time(); rules = []
|
|
222
|
+
for f in fields:
|
|
223
|
+
lab, k = f["text"], kind(f)
|
|
224
|
+
if k == "number": rules += [(lab, "0", f"{lab} must be a positive number, 0 is not allowed", False), (lab, "-1", f"{lab} cannot be negative", False)]
|
|
225
|
+
elif k == "date":
|
|
226
|
+
rules.append((lab, time.strftime("%Y-%m-%d", time.localtime(today - 86400)), f"{lab} cannot be in the past (yesterday)", True))
|
|
227
|
+
if not f.get("widget"): rules.append((lab, time.strftime("%Y-%m-%d", time.localtime(today + 50 * 365 * 86400)), f"{lab} must be realistic, 50 years ahead is invalid", False))
|
|
228
|
+
elif k == "email": rules.append((lab, "notanemail", f"{lab} must be a valid email address", False))
|
|
229
|
+
elif k == "phone": rules.append((lab, "abc", f"{lab} must be a valid phone number", False))
|
|
230
|
+
labs = [f["text"] for f in fields]
|
|
231
|
+
for s in labs: # range: start > end, first matching partner
|
|
232
|
+
e = next((x for x in labs if x != s and re.search(r"end|\bto\b|max", x, re.I)), None) if re.search(r"start|from|min", s, re.I) else None
|
|
233
|
+
if e: rules.append(((s, e), ("2099-12-31", "2000-01-01") if "date" in s.lower() else ("999999", "1"), f"{s} must not be greater than {e}", False)); break
|
|
234
|
+
return rules[:MAX_RULES]
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def run_form(page, snap, form, spec, opener=None):
|
|
238
|
+
"""Fill the form once per boundary rule (a validated form stays open), then all-valid, then the same valid payload again (duplicate). Returns [(phase, values, rule, after)]."""
|
|
239
|
+
fields, pay = form_payloads(snap, form, spec)
|
|
240
|
+
if not pay.get("valid"): return []
|
|
241
|
+
by_label = {f["text"]: f for f in fields}
|
|
242
|
+
|
|
243
|
+
def fill(values, past=False):
|
|
244
|
+
for label, v in values.items():
|
|
245
|
+
f = by_label.get(label)
|
|
246
|
+
if not f: continue
|
|
247
|
+
try:
|
|
248
|
+
loc = locator(page, f["i"])
|
|
249
|
+
if f.get("widget"): drive(page, loc, f["widget"], v, past=past and f["widget"] == "date")
|
|
250
|
+
elif f["tag"] == "select":
|
|
251
|
+
try: loc.select_option(label=str(v), timeout=1500)
|
|
252
|
+
except Exception: loc.select_option(index=1, timeout=1500)
|
|
253
|
+
else: loc.fill(str(v), timeout=1500)
|
|
254
|
+
except Exception: pass
|
|
255
|
+
for cb in [e for e in snap["elements"] if e["cid"] == form.get("cid") and e["type"] == "checkbox"]:
|
|
256
|
+
try: locator(page, cb["i"]).check(timeout=1000)
|
|
257
|
+
except Exception: pass
|
|
258
|
+
|
|
259
|
+
def complete_required():
|
|
260
|
+
"""Native validation blocks submit silently: fill every EMPTY :invalid field (the deliberately invalid one has a value and is left alone)."""
|
|
261
|
+
inv = page.locator(":invalid"); n = 0
|
|
262
|
+
for k in range(min(inv.count(), 12)):
|
|
263
|
+
e = inv.nth(k)
|
|
264
|
+
try:
|
|
265
|
+
tag, typ, val = e.evaluate("e => [e.tagName.toLowerCase(), e.type || '', e.value]")
|
|
266
|
+
if val: continue
|
|
267
|
+
if tag == "select": e.select_option(index=1, timeout=1000)
|
|
268
|
+
elif typ == "checkbox": e.check(timeout=1000)
|
|
269
|
+
else: e.fill({"number": "1", "date": time.strftime("%Y-%m-%d", time.localtime(time.time() + 30 * 86400)), "email": "qa@example.com", "tel": "+14155550123", "url": "https://example.com"}.get(typ, "Test value"), timeout=1000)
|
|
270
|
+
n += 1
|
|
271
|
+
except Exception: pass
|
|
272
|
+
return n
|
|
273
|
+
|
|
274
|
+
def submit():
|
|
275
|
+
for _ in range(2):
|
|
276
|
+
pre = snapshot(page)
|
|
277
|
+
try:
|
|
278
|
+
if form["submit"] is not None: locator(page, form["submit"]).click(timeout=3000)
|
|
279
|
+
else: page.keyboard.press("Enter")
|
|
280
|
+
except Exception: pass
|
|
281
|
+
page.wait_for_timeout(900)
|
|
282
|
+
if page.url.replace(BASE, "") != snap["url"] or not complete_required(): break # navigated, or nothing left to complete
|
|
283
|
+
after = snapshot(page)
|
|
284
|
+
after["blocked"] = after["url"] == pre["url"] and after["text"] == pre["text"] and counters(after["text"]) == counters(pre["text"])
|
|
285
|
+
return after
|
|
286
|
+
|
|
287
|
+
phases = []
|
|
288
|
+
def closed(): return len({f["text"] for f in fields} & {e["text"] for e in phases[-1][3]["elements"]}) < 2 # form gone (dialog closed / navigated)
|
|
289
|
+
def reopen():
|
|
290
|
+
"""Click the element that made the form appear (dialog forms close on accept); rebind to the fresh snapshot."""
|
|
291
|
+
nonlocal snap, form, fields, by_label
|
|
292
|
+
cur = snapshot(page)
|
|
293
|
+
names = ([opener["text"]] if opener and opener.get("text") else []) + \
|
|
294
|
+
[e["text"] for e in cur["elements"] if e["tag"] == "button" and re.search(r"edit|update|modify|details|add|new|create|start", e["text"], re.I)][:3]
|
|
295
|
+
for name in names:
|
|
296
|
+
try: page.get_by_text(name, exact=True).first.click(timeout=2000); page.wait_for_timeout(700)
|
|
297
|
+
except Exception: continue
|
|
298
|
+
s2 = snapshot(page); f2 = next((f for f in s2["forms"].values() if f["name"] == form["name"]), None)
|
|
299
|
+
if f2:
|
|
300
|
+
snap, form = s2, f2; fields = [e for e in s2["elements"] if e["i"] in f2["fields"]]; by_label = {f["text"]: f for f in fields}
|
|
301
|
+
return True
|
|
302
|
+
return False
|
|
303
|
+
for label, value, rule, past in boundary_rules(fields, pay["valid"]):
|
|
304
|
+
values = {**pay["valid"], **(dict(zip(label, value)) if isinstance(label, tuple) else {label: value})}
|
|
305
|
+
fill(values, past=past); phases.append(("invalid", values, rule, submit()))
|
|
306
|
+
if closed() and not reopen(): return phases
|
|
307
|
+
fill(pay["valid"]); phases.append(("valid", pay["valid"], "", submit()))
|
|
308
|
+
if not phases[-1][3]["blocked"] and (not closed() or reopen()):
|
|
309
|
+
fill(pay["valid"]); phases.append(("invalid", pay["valid"], "duplicate: an identical record was just created, a second identical submit must be rejected", submit()))
|
|
310
|
+
return phases
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# ---------------- oracles ----------------
|
|
314
|
+
def cheap_oracles(el, before, after, http_err, js_err):
|
|
315
|
+
hits = {}
|
|
316
|
+
if http_err: hits["http_error"] = 1.0
|
|
317
|
+
if js_err: hits["js_exception"] = 1.0
|
|
318
|
+
if len(after["text"].strip()) < 20: hits["blank_page"] = 1.0
|
|
319
|
+
href = el.get("href") or ""
|
|
320
|
+
if el["tag"] == "a" and href.startswith("/") and href.split("?")[0] != before["url"].split("?")[0] \
|
|
321
|
+
and after["url"] == before["url"] and after["text"] == before["text"]:
|
|
322
|
+
hits["dead_link"] = 1.0
|
|
323
|
+
return hits
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def jev_oracles(oracle_state, extra=None):
|
|
327
|
+
qs = {k: {"type": "noul", "instructions": v} for k, v in ORACLES.items()} | (extra or {})
|
|
328
|
+
return {k: round(v["noul"], 2) for k, v in jev(oracle_state, qs).items()}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# ---------------- expectations (checklist generated once per app; drives both the chooser and the oracle) ----------------
|
|
332
|
+
TUNED = Path(__file__).parent / "checklist_prompt.json" # DSPy/MIPROv2-optimized instruction (+demo), see bench/dspy_checklist.py
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def checklist(spec):
|
|
336
|
+
"""Once per app: expectations + concrete UI steps to exercise each (Sonnet). Steps are matched to real elements by Jev at run time."""
|
|
337
|
+
head = ("Write the test checklist a meticulous manual QA engineer would verify: 15-30 items. Cover every feature end to end (create -> appears "
|
|
338
|
+
"in the right place with the right data -> edit -> delete), business rules/boundaries implied by the spec, navigation, and feedback. Do not invent features.")
|
|
339
|
+
if TUNED.exists():
|
|
340
|
+
t = json.load(open(TUNED)); head = t["instructions"]
|
|
341
|
+
if t["demos"]: head += "\n\nExample of the expected quality for another spec:\nSpec: " + t["demos"][0]["spec"] + "\nChecklist: " + json.dumps(t["demos"][0]["expectations"], ensure_ascii=False)
|
|
342
|
+
res = claude_json(f"""{head}
|
|
343
|
+
|
|
344
|
+
Now the spec under test:
|
|
345
|
+
{spec}
|
|
346
|
+
|
|
347
|
+
Each item = ONE observable expected behaviour plus the concrete UI steps to exercise it, starting from the app's home page.
|
|
348
|
+
Cover EVERY user role or mode the spec names (the customer/visitor side AND the admin/owner/organizer side); when the app switches roles via a toggle or link, include that switch in the steps.
|
|
349
|
+
For every feature the spec names, include one item whose expectation is that the control for it EXISTS on the relevant page (delete/edit buttons, filters, history views), so missing features are caught.
|
|
350
|
+
Steps are short imperative UI actions with concrete values, e.g. "click 'Add Product'", "fill 'Price' with '-5'", "select 'Fitness' in 'Category'",
|
|
351
|
+
"fill the form and submit", "open the first product". 2-6 steps each; the LAST step is the action whose result proves or breaks the expectation.
|
|
352
|
+
Return ONLY JSON: [{{"expect": "<what must be visible after the last step>", "steps": ["...", "..."]}}, ...]""", model="sonnet")
|
|
353
|
+
if isinstance(res, dict): res = next((v for v in res.values() if isinstance(v, list)), [])
|
|
354
|
+
out = []
|
|
355
|
+
for x in res or []:
|
|
356
|
+
if isinstance(x, dict) and isinstance(x.get("expect"), str):
|
|
357
|
+
out.append({"expect": x["expect"], "steps": [t for t in (x.get("steps") or []) if isinstance(t, str)][:6]})
|
|
358
|
+
elif isinstance(x, str): out.append({"expect": x, "steps": []})
|
|
359
|
+
return out[:30]
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def step_value(step):
|
|
363
|
+
m = re.search(r"""(?:with|to|=|:)\s*['"“]([^'"”]+)['"”]""", step)
|
|
364
|
+
return m.group(1) if m else None
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# ---------------- main ----------------
|
|
368
|
+
def main(argv=None):
|
|
369
|
+
ap = argparse.ArgumentParser()
|
|
370
|
+
ap.add_argument("--steps", type=int, default=30)
|
|
371
|
+
ap.add_argument("--user", default="problem_user") # saucedemo: standard_user | problem_user | error_user
|
|
372
|
+
ap.add_argument("--headed", action="store_true")
|
|
373
|
+
ap.add_argument("--base", default=SAUCE)
|
|
374
|
+
ap.add_argument("--goal", default="Explore this e-commerce app like a manual tester hunting for bugs: prefer actions that progress a purchase flow, touch untested features, or submit forms. Avoid logout.")
|
|
375
|
+
ap.add_argument("--out", default="runs")
|
|
376
|
+
a = ap.parse_args(argv)
|
|
377
|
+
global BASE; BASE = a.base.rstrip("/")
|
|
378
|
+
random.seed(hashlib.md5(a.goal.encode()).hexdigest()) # same app -> same random side-steps; re-runs differ only where the app or Jev does
|
|
379
|
+
home = BASE + ("/inventory.html" if BASE == SAUCE else "/")
|
|
380
|
+
|
|
381
|
+
t0 = time.time()
|
|
382
|
+
run = Path(a.out) / time.strftime("%Y%m%d-%H%M%S")
|
|
383
|
+
(run / "flagged").mkdir(parents=True)
|
|
384
|
+
log = open(run / "steps.jsonl", "w")
|
|
385
|
+
G = {} # abstract state -> action -> {"n", "ineff"}
|
|
386
|
+
visited, flagged_pairs, findings_raw = {}, set(), []
|
|
387
|
+
scen = checklist(a.goal) # [{expect, steps}]
|
|
388
|
+
if scen: # triage: exercise the expectations most likely to be broken first (Jev score, one call)
|
|
389
|
+
risk = jev({"spec": a.goal, "expectations": {f"E{i}": x["expect"] for i, x in enumerate(scen)}},
|
|
390
|
+
{f"E{i}": {"type": "score", "instructions": f"How likely is expectation `expectations.E{i}` to be VIOLATED in an AI-generated (vibe-coded) implementation of `spec`? Multi-step flows, edits/deletes propagating to lists and totals, business-rule validation and cross-page consistency break most often; static display and simple navigation rarely do.",
|
|
391
|
+
"criteria": ["almost certainly works", "probably works", "coin flip", "probably broken", "almost certainly broken"]} for i in range(len(scen))})
|
|
392
|
+
scen = [x for _, x in sorted(zip([-risk[f"E{i}"]["score"] for i in range(len(scen))], scen), key=lambda t: t[0])]
|
|
393
|
+
subgoals, done, checked = [x["expect"] for x in scen], [], set() # done = exercised w/o violation; checked = judged either way
|
|
394
|
+
steps_of = {x["expect"]: x["steps"] for x in scen}
|
|
395
|
+
NEG = [t for t in subgoals if re.search(r"not (be )?(shown|visible|available|usable|displayed|offered|allowed|permitted)|cannot|must not|hidden|is refused|or is refused|only the (poster|author|owner|creator|admin)|only (an? )?(admin|owner|author)", t, re.I) and not re.search(r"\bexists?\b", t, re.I)] # negative items: absence there is a pass (v14 strict regex; \bno\b matched "No Longer Relevant")
|
|
396
|
+
seen_lines = set() # every screen line seen in the run: a control named by a stalled scenario that never appeared anywhere = missing feature (v14 absence path)
|
|
397
|
+
print(f" checklist: {len(subgoals)} expectations, {sum(len(x['steps']) for x in scen)} steps")
|
|
398
|
+
E = {f"E{i}": t for i, t in enumerate(subgoals)}
|
|
399
|
+
sg_steps = sg_step_i = sg_budget = streak = 0; streak_path = None; seen_paths = set(); stalled = []; done_tuples = set(); opener = None
|
|
400
|
+
console, failed, http_err, js_err = [], [], [], []
|
|
401
|
+
|
|
402
|
+
def record(step, how, action, before, after, probs, hits, extra, page):
|
|
403
|
+
key = (state_sig(before), action)
|
|
404
|
+
if hits and key in flagged_pairs: hits = {}
|
|
405
|
+
if hits: flagged_pairs.add(key)
|
|
406
|
+
rec = {"step": step, "state": key[0], "how": how, "action": action, "from": before["url"], "to": after["url"],
|
|
407
|
+
"oracles": probs, "flags": list(hits), **extra}
|
|
408
|
+
log.write(json.dumps(rec, ensure_ascii=False) + "\n"); log.flush()
|
|
409
|
+
mark = " <-- FLAG " + ",".join(hits) if hits else ""
|
|
410
|
+
print(f'{step:03d} {how:11s} {action[:45]:45s} {before["url"][-22:]:>22s} -> {after["url"][-22:]:<22s}{mark}')
|
|
411
|
+
if hits:
|
|
412
|
+
page.screenshot(path=run / "flagged" / f"{step:03d}.png")
|
|
413
|
+
findings_raw.append({**rec, "evidence": {"action": action, "before": {"url": before["url"], "text": before["text"][:2000]},
|
|
414
|
+
"after": {"url": after["url"], "text": after["text"][:3000]},
|
|
415
|
+
"console_errors": console[:5], "failed_requests": failed[:5], "http_errors": http_err[:5],
|
|
416
|
+
"js_exceptions": js_err[:3], **extra}})
|
|
417
|
+
|
|
418
|
+
with sync_playwright() as p:
|
|
419
|
+
browser = p.chromium.launch(headless=not a.headed)
|
|
420
|
+
page = browser.new_page()
|
|
421
|
+
page.on("console", lambda m: console.append(m.text[:200]) if m.type == "error" else None)
|
|
422
|
+
page.on("pageerror", lambda e: js_err.append(str(e)[:200]))
|
|
423
|
+
page.on("requestfailed", lambda r: failed.append(r.url[:200]))
|
|
424
|
+
page.on("response", lambda r: http_err.append(f"{r.status} {r.url[:150]}")
|
|
425
|
+
if r.status >= 400 and r.request.resource_type in ("xhr", "fetch", "document") else None)
|
|
426
|
+
if BASE == SAUCE: login(page, a.user)
|
|
427
|
+
else: page.goto(home); page.wait_for_load_state("networkidle")
|
|
428
|
+
|
|
429
|
+
step, absent = 0, []
|
|
430
|
+
if absence and BASE != SAUCE: # crawl nav/detail/role states once, one step charged per page; absences become deterministic findings
|
|
431
|
+
try:
|
|
432
|
+
pages = absence.crawl(page, home); absent = absence.probe(a.goal + "\n" + "\n".join(subgoals), pages) # checklist expectations are a second quote source (spec rarely names delete/edit)
|
|
433
|
+
for pg in pages:
|
|
434
|
+
log.write(json.dumps({"step": step, "state": "", "how": "crawl", "action": f'crawl {pg["url"].replace(BASE, "")}', "from": pg["url"].replace(BASE, ""), "to": pg["url"].replace(BASE, ""), "oracles": {}, "flags": []}) + "\n"); step += 1
|
|
435
|
+
print(f' absence: {len(pages)} pages crawled ({step} steps charged), {len(absent)} absent: {[x["expected_control"] for x in absent]}')
|
|
436
|
+
except Exception as e: print(f" absence failed: {str(e)[:120]}")
|
|
437
|
+
page.goto(home); page.wait_for_load_state("networkidle")
|
|
438
|
+
init = snapshot(page); blank = {"url": "", "text": "", "elements": []}
|
|
439
|
+
probs = jev_oracles({"action": "open the app's initial page (fresh load, no interaction yet)", "before": blank, "after": {"url": init["url"], "text": init["text"][:3000]}, "spec": a.goal},
|
|
440
|
+
{"initial_broken": {"type": "noul", "instructions": "Judging `after.text` (the first page of an app described by `spec`): does it show clearly wrong seed data or rendering, e.g. a chart/summary whose values are all 0 or $0 while lists on the same page hold items, NaN/undefined/null, or totals contradicting listed rows? Answer no for a merely empty app with no data anywhere."}})
|
|
441
|
+
hits = {k: v for k, v in probs.items() if k in ("initial_broken", "broken_content", "inconsistent_state") and v >= FLAG_THRESHOLD}
|
|
442
|
+
record(step, "init", "initial page", blank | {"url": init["url"]}, init, probs, hits, {"value": None, "expectation": None}, page); step += 1
|
|
443
|
+
while step < a.steps:
|
|
444
|
+
if not page.url.startswith(BASE): page.goto(home)
|
|
445
|
+
if BASE == SAUCE and "user-name" in page.content() and page.url.rstrip("/") == BASE: login(page, a.user)
|
|
446
|
+
before = snapshot(page); seen_lines.update(before["text"].splitlines())
|
|
447
|
+
sig = state_sig(before)
|
|
448
|
+
visited[sig] = visited.get(sig, 0) + 1
|
|
449
|
+
mem = G.setdefault(sig, {})
|
|
450
|
+
|
|
451
|
+
# --- current target: next expectation not yet exercised, round-robin with patience ---
|
|
452
|
+
pending = [t for t in subgoals if t not in checked]
|
|
453
|
+
if pending and sg_steps >= sg_budget: # overran its share: stalled, never retried
|
|
454
|
+
if sg_steps: stalled.append(pending[0]); checked.add(pending[0]); pending.pop(0)
|
|
455
|
+
sg_steps = sg_step_i = 0
|
|
456
|
+
if pending: sg_budget = max(len(steps_of.get(pending[0], [])), (a.steps - step) // len(pending)) # ponytail: floor(remaining/pending) alone is 1 step for 30 items at budget 50; an item at least gets its own scenario length
|
|
457
|
+
subgoal = pending[0] if pending else None
|
|
458
|
+
steps = steps_of.get(subgoal, []) if subgoal else []
|
|
459
|
+
scen_step = steps[sg_step_i] if sg_step_i < len(steps) else None
|
|
460
|
+
final_step = scen_step is not None and sg_step_i == len(steps) - 1
|
|
461
|
+
|
|
462
|
+
# --- candidates: primitives + whole-form macros, ranked by graph memory (fresh > useful > any) ---
|
|
463
|
+
cands = [(describe(e), ("el", e)) for e in before["elements"][:MAX_CANDIDATES]]
|
|
464
|
+
cands += [(f'form "{f["name"]}" (fill all {len(f["fields"])} fields and submit)', ("form", f)) for f in before["forms"].values()]
|
|
465
|
+
if not cands:
|
|
466
|
+
page.keyboard.press("Escape"); page.wait_for_timeout(300)
|
|
467
|
+
if not snapshot(page)["elements"]: page.goto(home)
|
|
468
|
+
continue
|
|
469
|
+
path = norm(before["url"].split("?")[0])
|
|
470
|
+
cands = [c for c in cands if (path, c[0]) not in done_tuples or c[1][1].get("tag") in ("input", "textarea")] or cands # never redo an identical (route, action) unless typed values differ
|
|
471
|
+
fresh = [c for c in cands if c[0] not in mem]
|
|
472
|
+
useful = [c for c in cands if c[0] in mem and not mem[c[0]]["ineff"]]
|
|
473
|
+
pool = fresh or useful or cands
|
|
474
|
+
picked = None
|
|
475
|
+
seen_paths.add(path)
|
|
476
|
+
streak = streak + 1 if path == streak_path else 1; streak_path = path
|
|
477
|
+
if streak > URL_CAP: # trapped on one page: leave via a link to a path never visited
|
|
478
|
+
exits = [c for c in cands if c[1][0] == "el" and (c[1][1].get("href") or "").startswith("/") and norm(c[1][1]["href"].split("?")[0]) not in seen_paths]
|
|
479
|
+
if exits: picked = random.choice(exits); how = "escape"
|
|
480
|
+
else: page.keyboard.press("Escape"); page.goto(home); streak = 0; continue
|
|
481
|
+
if scen_step and not picked:
|
|
482
|
+
wide = [(describe(e), ("el", e)) for e in before["elements"][:120]] + cands[len(cands) - len(before["forms"]):]
|
|
483
|
+
crit = {f"e{i}": c[0] for i, c in enumerate(wide)} | {"none": "no candidate performs this step"}
|
|
484
|
+
ans = jev({"page": before["url"], "visible_text": before["text"][:2500], "scenario_step": scen_step, "expectation": subgoal},
|
|
485
|
+
{"el": {"type": "choice", "criteria": crit, "instructions": "Which candidate element performs `scenario_step` on this page? Choose none if no candidate does."}})["el"]
|
|
486
|
+
sg_step_i += 1 # advance either way: a missing step is skipped, not retried
|
|
487
|
+
best, p_best = max(((k, v) for k, v in ans["probabilities"].items() if k != "none"), key=lambda kv: kv[1], default=("none", 0))
|
|
488
|
+
if best != "none" and p_best >= 0.25:
|
|
489
|
+
picked = wide[int(best[1:])]; how = f'step({p_best:.2f})'
|
|
490
|
+
elif os.environ.get("MONKEY_DEBUG"):
|
|
491
|
+
print(f' step miss: "{scen_step[:60]}" ({ans["confidence"]:.2f}) on {before["url"]}')
|
|
492
|
+
if picked:
|
|
493
|
+
desc, (kind, obj) = picked
|
|
494
|
+
elif not pending and random.random() < EPSILON: # side-steps only once the queue is empty
|
|
495
|
+
desc, (kind, obj) = random.choice(pool); how = "random"
|
|
496
|
+
else:
|
|
497
|
+
ans = jev({"page": before["url"], "visible_text": before["text"][:2500], "visits_to_this_state": visited[sig],
|
|
498
|
+
"goal": a.goal, "current_subgoal": subgoal or "explore untested features"},
|
|
499
|
+
{"next": {"type": "choice", "criteria": {f"e{i}": c[0] for i, c in enumerate(pool)},
|
|
500
|
+
"instructions": "Which element should the tester interact with next to make progress toward `current_subgoal` (and the overall `goal`)? Prefer a whole-form action when a form is the way forward."}})["next"]
|
|
501
|
+
desc, (kind, obj) = pool[int(ans["choice"][1:])]; how = f'jev({ans["confidence"]:.2f})'
|
|
502
|
+
mem.setdefault(desc, {"n": 0, "ineff": False}); mem[desc]["n"] += 1
|
|
503
|
+
console.clear(); failed.clear(); http_err.clear(); js_err.clear()
|
|
504
|
+
def judge(st, extra_q, dead_click=False):
|
|
505
|
+
"""Which expectation does this step exercise (Jev choice, or the scenario's own on its final step), is it violated (Jev noul), plus generic + extra oracles."""
|
|
506
|
+
exp = subgoal if (final_step and picked) else None
|
|
507
|
+
if E and not exp:
|
|
508
|
+
rel = jev({k: v for k, v in st.items() if k != "current_subgoal"},
|
|
509
|
+
{"rel": {"type": "choice", "criteria": E | {"none": "no listed expectation is exercised or checkable from this action and its result"},
|
|
510
|
+
"instructions": "Which expectation does this action and its visible result exercise (make checkable), if any?"}})["rel"]
|
|
511
|
+
if rel["choice"] != "none" and rel["confidence"] >= 0.4: exp = E[rel["choice"]]
|
|
512
|
+
q = dict(extra_q)
|
|
513
|
+
if exp:
|
|
514
|
+
st = st | {"expectation": exp}
|
|
515
|
+
q["expectation_violated"] = {"type": "noul", "instructions": "`expectation` states what must be visible after this kind of action. Judging only `before` -> `after` for `action`, is the expectation clearly VIOLATED (the required result did not appear, or something contradicting it appeared)? Answer no if the action does not fully exercise it yet or the result matches."}
|
|
516
|
+
q["expectation_verified"] = {"type": "noul", "instructions": "Judging only `before` -> `after` for `action`, did the result required by `expectation` clearly APPEAR, so the expectation is now positively confirmed? Answer no if the action merely navigated, opened a form, or changed nothing relevant."}
|
|
517
|
+
probs = jev_oracles(st, q)
|
|
518
|
+
strong = {k for k in probs if k in extra_q or k == "expectation_violated"} | ({"action_ignored"} if dead_click else set())
|
|
519
|
+
hits = {k: v for k, v in probs.items() if k != "expectation_verified" and v >= FLAG_THRESHOLD and (k in strong or probs.get("expectation_violated", 0) >= FLAG_THRESHOLD)}
|
|
520
|
+
if exp:
|
|
521
|
+
verified = probs.get("expectation_verified", 0) >= 0.6 and st.get("diff", {}).get("changed", True)
|
|
522
|
+
if verified or "expectation_violated" in hits: checked.add(exp) # resolved either way; otherwise it stays pending
|
|
523
|
+
if verified and "expectation_violated" not in hits and exp not in done: done.append(exp)
|
|
524
|
+
return probs, hits, exp
|
|
525
|
+
|
|
526
|
+
if kind == "form":
|
|
527
|
+
phases = run_form(page, before, obj, a.goal, opener)
|
|
528
|
+
mem[desc]["ineff"] = True # one-shot: same payloads would be replayed
|
|
529
|
+
if not phases: step += 1; continue
|
|
530
|
+
prev = before
|
|
531
|
+
for phase, values, rule, after in phases:
|
|
532
|
+
st = {"action": f"submitted form {obj['name']} with {phase} values", "submitted_values": values, "violated_rule": rule or None,
|
|
533
|
+
"before": {"url": prev["url"], "text": prev["text"][:2000]}, "after": {"url": after["url"], "text": after["text"][:3000]}, "diff": diff(prev, after),
|
|
534
|
+
"console_errors": console[:5], "http_errors": http_err[:5], "current_subgoal": subgoal}
|
|
535
|
+
q = {"invalid_accepted": {"type": "noul", "instructions": "The submission deliberately violated `violated_rule`. Did the app ACCEPT it anyway (success message, item created/updated, form closed, new data visible) instead of showing a validation error and keeping the form open?"}} if phase == "invalid" else \
|
|
536
|
+
{"valid_rejected": {"type": "noul", "instructions": "All `submitted_values` were valid and realistic. Did the app REJECT or ignore the submission (validation error shown, form still open unchanged, no success/confirmation, data not appearing) instead of accepting it?"}}
|
|
537
|
+
if after.get("blocked"): # nothing changed at all: browser/app validation swallowed the submit
|
|
538
|
+
probs, hits, exp = {}, {}, None; st["form_blocked"] = True
|
|
539
|
+
else:
|
|
540
|
+
probs, hits, exp = judge(st, q)
|
|
541
|
+
hits |= cheap_oracles({"tag": "form"}, prev, after, http_err, js_err)
|
|
542
|
+
record(step, how, f"{desc} [{phase}]", prev, after, probs, hits, {"values": values, "rule": rule, "expectation": exp, "form_blocked": after.get("blocked", False), "diff": st["diff"]}, page)
|
|
543
|
+
prev = after; step += 1; sg_steps += 1
|
|
544
|
+
done_tuples.add((path, desc))
|
|
545
|
+
continue
|
|
546
|
+
|
|
547
|
+
el = obj
|
|
548
|
+
try:
|
|
549
|
+
value = act(page, el, step_value(scen_step) if picked and scen_step else None)
|
|
550
|
+
except Exception as e:
|
|
551
|
+
log.write(json.dumps({"step": step, "action": desc, "error": str(e)[:200]}) + "\n")
|
|
552
|
+
print(f'{step:03d} {how:11s} {desc[:45]:45s} ERR {str(e).splitlines()[0][:50]}'); mem[desc]["ineff"] = True; step += 1; continue
|
|
553
|
+
after = snapshot(page); seen_lines.update(after["text"].splitlines())
|
|
554
|
+
if {f["name"] for f in after["forms"].values()} - {f["name"] for f in before["forms"].values()}: opener = el # this click opened a form
|
|
555
|
+
if state_sig(after) == sig and after["text"] == before["text"]: mem[desc]["ineff"] = True
|
|
556
|
+
st = {"action": desc, "before": {"url": before["url"], "text": before["text"][:2000]}, "after": {"url": after["url"], "text": after["text"][:3000]}, "diff": diff(before, after),
|
|
557
|
+
"console_errors": console[:5], "failed_requests": failed[:5], "http_errors": http_err[:5], "current_subgoal": subgoal}
|
|
558
|
+
dead_click = mem[desc]["ineff"] and el["tag"] in ("button", "a") # code-observed: click changed nothing at all
|
|
559
|
+
probs, hits, exp = judge(st, {}, dead_click)
|
|
560
|
+
hits |= cheap_oracles(el, before, after, http_err, js_err)
|
|
561
|
+
record(step, how, desc, before, after, probs, hits, {"value": value, "expectation": exp, "diff": st["diff"]}, page)
|
|
562
|
+
done_tuples.add((path, desc, value)); step += 1; sg_steps += 1
|
|
563
|
+
|
|
564
|
+
# --- absence (v14): a stalled scenario whose click step names a control that never appeared on any screen = the feature is missing (deterministic, no judge) ---
|
|
565
|
+
seen = "\n".join(seen_lines); last = snapshot(page)
|
|
566
|
+
def present(name): return all(re.search(rf"(?<![A-Za-z]){re.escape(w)}(?![A-Za-z])", seen, re.I) for w in re.findall(r"[A-Za-z][A-Za-z-]+", name)) if re.findall(r"[A-Za-z][A-Za-z-]+", name) else True
|
|
567
|
+
reported = {x["expected_control"].casefold() for x in absent} # dedupe: one report per control name, none if v9's probe already reported it
|
|
568
|
+
for item in stalled:
|
|
569
|
+
if item in NEG or any(json.loads(x["evidence"])["requirement"] in item for x in absent): continue # negative items: absence is a pass; v9 probe already fired for this expectation
|
|
570
|
+
names = [n for st_ in steps_of.get(item, []) if re.match(r"\s*(click|press|tap|open)\b", st_, re.I) for n in re.findall(r"""['"‘“]([^'"’”]{1,40})['"’”]""", st_)]
|
|
571
|
+
missing = [n for n in names if not present(n) and n.casefold() not in reported]
|
|
572
|
+
if missing:
|
|
573
|
+
reported.add(missing[0].casefold())
|
|
574
|
+
record(step, "absence", f"searched every screen for a '{missing[0]}' control", last, last, {}, {"expectation_unreachable": 1.0}, {"value": None, "expectation": item, "deterministic": True, "missing_controls": missing}, page); step += 1
|
|
575
|
+
|
|
576
|
+
# --- verify by replay: redo each flagged primitive action from its page; forms are stateful, left as reproduced=None ---
|
|
577
|
+
findings, seen = [], set()
|
|
578
|
+
for f in findings_raw:
|
|
579
|
+
key = (f["action"].split(" (")[0].split(" [")[0], f["from"], ",".join(f["flags"]))
|
|
580
|
+
if key in seen: continue
|
|
581
|
+
seen.add(key)
|
|
582
|
+
f = {**f, "id": len(findings), "page": f["from"], "reproduced": None,
|
|
583
|
+
"count": sum(1 for g in findings_raw if (g["action"].split(" (")[0].split(" [")[0], g["from"], ",".join(g["flags"])) == key)}
|
|
584
|
+
if not f["action"].startswith("form ") and f.get("how") != "absence" and len(findings) < 12:
|
|
585
|
+
try:
|
|
586
|
+
page.goto(BASE + f["from"]); page.wait_for_load_state("networkidle"); page.wait_for_timeout(400)
|
|
587
|
+
snap = snapshot(page)
|
|
588
|
+
el = next(e for e in snap["elements"] if describe(e) == f["action"])
|
|
589
|
+
console.clear(); failed.clear(); http_err.clear(); js_err.clear()
|
|
590
|
+
act(page, el, f.get("value")); after = snapshot(page)
|
|
591
|
+
st = {"action": f["action"], "before": {"url": snap["url"], "text": snap["text"][:2000]}, "after": {"url": after["url"], "text": after["text"][:3000]},
|
|
592
|
+
"console_errors": console[:5], "failed_requests": failed[:5], "http_errors": http_err[:5]}
|
|
593
|
+
if f.get("expectation"):
|
|
594
|
+
st["expectation"] = f["expectation"]
|
|
595
|
+
probs = jev_oracles(st, {"expectation_violated": {"type": "noul", "instructions": "`expectation` states what must be visible after this kind of action. Judging only `before` -> `after` for `action`, is the expectation clearly VIOLATED?"}})
|
|
596
|
+
else: probs = jev_oracles(st)
|
|
597
|
+
again = {k for k, v in probs.items() if v >= FLAG_THRESHOLD} | set(cheap_oracles(el, snap, after, http_err, js_err))
|
|
598
|
+
f["reproduced"] = bool(again & set(f["flags"]))
|
|
599
|
+
except Exception as e:
|
|
600
|
+
f["replay_error"] = str(e)[:120]
|
|
601
|
+
f["action"] = key[0]
|
|
602
|
+
findings.append(f)
|
|
603
|
+
absent = list({x["expected_control"].casefold(): x for x in reversed(absent)}.values())[::-1] # one feature_missing report per control name across pages (first page wins)
|
|
604
|
+
for x in absent: # deterministic: no replay, no Sonnet needed (`report` is the sentence to pass through)
|
|
605
|
+
ev = json.loads(x["evidence"]); url = x["url"].replace(BASE, "")
|
|
606
|
+
findings.append({"id": len(findings), "step": -1, "state": "", "how": "absence", "action": f'absence probe: {x["verb"]}', "page": url, "from": url, "to": url,
|
|
607
|
+
"oracles": {"feature_missing": x["confidence"]}, "flags": ["feature_missing"], "reproduced": True, "count": 1, "expectation": ev["requirement"],
|
|
608
|
+
"report": f'On {url} the spec requires "{ev["requirement"]}" but no {x["expected_control"]} control or content exists (looked for {", ".join(ev["missing_terms"])} among {len(ev["controls"])} controls and {len(ev["fields"])} fields).',
|
|
609
|
+
"evidence": {"action": x["verb"], "before": {"url": url, "text": ""}, "after": {"url": url, "text": ev["text"][:3000]}, "console_errors": [], "failed_requests": [], "http_errors": [], "js_exceptions": [],
|
|
610
|
+
"controls": ev["controls"], "fields": ev["fields"], "missing_terms": ev["missing_terms"]}})
|
|
611
|
+
browser.close()
|
|
612
|
+
|
|
613
|
+
print(f"\n{a.steps} steps, {len(visited)} distinct states, {len(findings_raw)} flagged, {len(done)}/{len(subgoals)} expectations exercised -> {run}/flagged/")
|
|
614
|
+
for f in findings:
|
|
615
|
+
print(f' {f["action"][:40]:40s} @ {f["page"][-24:]:24s} -> {",".join(f["flags"])[:35]:35s} x{f["count"]} reproduced={f["reproduced"]}')
|
|
616
|
+
json.dump({"run": str(run), "steps": a.steps, "user": a.user, "states": len(visited), "flagged_steps": len(findings_raw),
|
|
617
|
+
"subgoals_done": done, "stalled": stalled, "expectations": subgoals, "checked": sorted(checked), "secs": round(time.time() - t0, 1), "jev": USAGE | CLAUDE_USAGE, "findings": findings},
|
|
618
|
+
open(run / "findings.json", "w"), indent=1, ensure_ascii=False)
|
|
619
|
+
return run
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
if __name__ == "__main__":
|
|
623
|
+
main()
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: jevqa
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Autonomous pre-QA bug hunter for web apps: Jev (TypeSafe) picks actions and judges screens, Claude reads the spec once.
|
|
5
|
+
Author: Dmytro Tolok
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: jev,monkey-testing,playwright,qa,testing,typesafe
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Requires-Dist: anthropic>=0.40
|
|
11
|
+
Requires-Dist: playwright>=1.63
|
|
12
|
+
Requires-Dist: requests>=2.32
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# jevqa
|
|
16
|
+
|
|
17
|
+
**Five minutes and 35 cents before you show anyone your app.**
|
|
18
|
+
|
|
19
|
+
Point it at a deployed web app and the README it was built from. It explores the app on its own, then writes `report.md`: the missing delete button, the date field that accepts 1823, the Save that saves nothing, each with a screenshot.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
uvx jevqa run http://localhost:3000 --spec README.md
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+

|
|
26
|
+
|
|
27
|
+
No selectors. No test scripts. Nothing to maintain. The first run asks for two API keys and remembers them.
|
|
28
|
+
|
|
29
|
+
→ **[See a real report](examples/blog-app/report.md)** from a blog app: 7 findings in 269 seconds, two of them confirmed bugs (no way to edit posts, no way to publish).
|
|
30
|
+
|
|
31
|
+
## Why it is cheap
|
|
32
|
+
|
|
33
|
+
Every decision on every screen is a typed judgment from [Jev](https://typesafe.ai), TypeSafe's System One model: a probability or a choice, not generated text, at $0.042 per million tokens. Jev picks the next action and answers a fixed set of oracle questions about each screen. Claude reads your spec once to write the checklist. A full run is 5–6 minutes and $0.29–0.41.
|
|
34
|
+
|
|
35
|
+
The same job done by a Claude Opus 5 agent with Playwright MCP: $3.20 per app, and it found fewer known bugs. [Benchmark →](BENCHMARK.md)
|
|
36
|
+
|
|
37
|
+
## What it finds
|
|
38
|
+
|
|
39
|
+
Measured on 20 [WebTestBench](https://github.com/friedrichor/WebTestBench) apps with 107 known bugs: 22% of them, at 5–6 minutes and $0.29–0.41 per app. In order of how often it finds them:
|
|
40
|
+
|
|
41
|
+
1. **Missing or unreachable features** — no edit/delete/search/sort control, a role that can only view. Half of everything it finds, and the most reliable half.
|
|
42
|
+
2. **Validation gaps** — past dates accepted, bad phone numbers, duplicates, start after end.
|
|
43
|
+
3. **Dead controls** — a click that does nothing, "Save" with no effect, a list that does not update after submit.
|
|
44
|
+
4. **Data lost after reload.**
|
|
45
|
+
5. **Broken first render** — charts of zeros, page errors on entry.
|
|
46
|
+
|
|
47
|
+
## What it will NOT find
|
|
48
|
+
|
|
49
|
+
- **Interaction quality**: sliders, drag and drop, animation, keyboard flows. 2 of 15 known interaction bugs.
|
|
50
|
+
- **Wrong numbers and wrong content**: a total that is off by one, a stat that lies. It sees text, not truth.
|
|
51
|
+
- **Permissions and ownership**: user A editing user B's data. It rarely switches roles deep enough.
|
|
52
|
+
- **Long multi-step flows**: checkout, onboarding, anything past 5–6 dependent steps in a 50-action budget.
|
|
53
|
+
- **Anything your spec does not mention.** The checklist comes from the spec; a thin README gives a thin run.
|
|
54
|
+
|
|
55
|
+
**About 1 in 8 findings is a confirmed defect.** The rest is noise or defects the spec does not name. We publish that number because a tool that hides it is lying to you. Read the report top to bottom: missing features come first and are the most reliable; skim the rest in ninety seconds. This is a pre-QA smoke bot. It clears the obvious defects before QA starts; it does not replace QA on UX or business logic.
|
|
56
|
+
|
|
57
|
+
## GitHub Action
|
|
58
|
+
|
|
59
|
+
Run it on every preview deployment:
|
|
60
|
+
|
|
61
|
+
```yaml
|
|
62
|
+
- uses: Todmy/jevqa@main
|
|
63
|
+
with:
|
|
64
|
+
url: ${{ steps.deploy.outputs.preview_url }}
|
|
65
|
+
spec: docs/PRD.md
|
|
66
|
+
env:
|
|
67
|
+
TYPESAFE_API_KEY: ${{ secrets.TYPESAFE_API_KEY }}
|
|
68
|
+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The report lands in the job summary and as a workflow artifact. Outputs: `report` (path), `findings` (count).
|
|
72
|
+
|
|
73
|
+
## Setup
|
|
74
|
+
|
|
75
|
+
Two keys, asked for on first run and stored in `~/.config/jevqa/config.json` (env vars override, which is what CI uses):
|
|
76
|
+
|
|
77
|
+
- **TypeSafe** — [console.typesafe.ai](https://console.typesafe.ai). Jev does the exploring and judging: ~$0.05–0.10 per app.
|
|
78
|
+
- **Claude** — an [Anthropic API key](https://console.anthropic.com), or pick "use the Claude Code CLI" if you have it logged in. Three calls per app: ~$0.25.
|
|
79
|
+
|
|
80
|
+
Chromium for Playwright is downloaded automatically on the first run (~150 MB, once per machine). `jevqa config` shows or resets the keys.
|
|
81
|
+
|
|
82
|
+
| flag | default | |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| `--spec FILE` | required | spec, PRD or README the app was built from |
|
|
85
|
+
| `--steps N` | 50 | action budget; time and cost scale linearly |
|
|
86
|
+
| `--out DIR` | `jevqa-runs` | where runs are written |
|
|
87
|
+
| `--headed` | off | watch the browser |
|
|
88
|
+
|
|
89
|
+
Models: Claude Sonnet 5 for the checklist, Haiku 4.5 for form payloads; override with `JEVQA_SONNET` / `JEVQA_HAIKU`.
|
|
90
|
+
|
|
91
|
+
## How it works
|
|
92
|
+
|
|
93
|
+
1. Claude turns the spec into 15–30 expectations, each with concrete UI steps.
|
|
94
|
+
2. A bounded crawl visits nav, detail and role states. Spec-quoted controls that appear on no screen become **missing feature** findings.
|
|
95
|
+
3. The action loop follows scenario steps where Jev can match them to a real element, otherwise explores, and submits boundary payloads (negative numbers, past dates, reversed ranges, duplicates) on every form.
|
|
96
|
+
4. After each action Jev answers the oracle questions on the before/after text. Flags are verified by replay; non-reproducible ones are dropped.
|
|
97
|
+
5. Each finding becomes one deterministic sentence with Expected / Actual and a screenshot.
|
|
98
|
+
|
|
99
|
+
## Hosted version?
|
|
100
|
+
|
|
101
|
+
Today jevqa is a local CLI and an Action, bring your own keys. If you would pay ~$29/month for a GitHub App that comments on pull requests, keeps run history and needs no keys, **[👍 this issue](../../issues/1)**. We build it when enough people do.
|
|
102
|
+
|
|
103
|
+
## Telemetry
|
|
104
|
+
|
|
105
|
+
Anonymous counts only (version, step budget, number of findings, seconds, cost estimate). Never the URL, the spec or the report. Off with `JEVQA_TELEMETRY=0`; off by default in CI.
|
|
106
|
+
|
|
107
|
+
## Add your tester to the benchmark
|
|
108
|
+
|
|
109
|
+
[BENCHMARK.md](BENCHMARK.md) has the methodology, the leaderboard and how to submit. Nobody else in this category publishes recall. Prove us wrong.
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
|
|
113
|
+
MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
jevqa/__init__.py,sha256=rMj39g8wcLsxFHmg2hVfeuXwVHBGOCCJKrQ81hVEOJo,72
|
|
2
|
+
jevqa/absence.py,sha256=gn7t3wy56bPQF9chlKiqdmkOT_ZITvTUr7j98fqdgUE,8369
|
|
3
|
+
jevqa/checklist_prompt.json,sha256=VZcTtY896nP70KQnocKdLTlTqLzc-dmXPcPkMPm8068,6183
|
|
4
|
+
jevqa/cli.py,sha256=iZ1sboNSFAldTspk6RnsxU_RtvujnCw5n2m0NrnT2qg,2697
|
|
5
|
+
jevqa/config.py,sha256=wlDZG_y0anSskuOOEiwmj3ntWHy8IuFMeQw3eX6K_Rk,2634
|
|
6
|
+
jevqa/llm.py,sha256=RUqf4hLSQoUsmCoAnqCgaOgQPv3qYzzNXdI6P_oWmGo,1884
|
|
7
|
+
jevqa/oracles.json,sha256=1VRLeLBL0JN5Vf0lBVz88Uk8rwmUdwIYlhB--d2T5zY,1494
|
|
8
|
+
jevqa/report.py,sha256=uM414_1nVivn0R_4siZbU22PwAXhYOI09UZq-PlsKRA,8304
|
|
9
|
+
jevqa/telemetry.py,sha256=M-S4A-3hH6v3l7nHtH5qI0Wnpg_hw1c6g5i0e-4RvbU,969
|
|
10
|
+
jevqa/tester.py,sha256=CxR1HF3_CJPq7IyBxUBjAybHZwRZIiUhWd9o_8TNvgM,46646
|
|
11
|
+
jevqa-0.1.0.dist-info/METADATA,sha256=CRrtE4Jjl0jWtLkO1VexiGdyxXQ1jjGleLOzrml5aD8,6116
|
|
12
|
+
jevqa-0.1.0.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
|
|
13
|
+
jevqa-0.1.0.dist-info/entry_points.txt,sha256=aEzqWF1eBitBomA9notQ9fI8M5rqeh-HvpUuSP4xJ98,41
|
|
14
|
+
jevqa-0.1.0.dist-info/licenses/LICENSE,sha256=vIBbMpdaIFywhOoy5y_2C_RErodO_NMfhNI1vwaVrvo,1069
|
|
15
|
+
jevqa-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dmytro Tolok
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|