Skip to content

Commit a96a864

Browse files
Asaif AliAsaif Ali
authored andcommitted
Redesigning the UI
1 parent ae91bee commit a96a864

2 files changed

Lines changed: 251 additions & 64 deletions

File tree

app.py

Lines changed: 210 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,36 @@
124124
</style>
125125
""")
126126

127+
st.html("""
128+
<style>
129+
.qa-suite-summary { display:flex; align-items:center; gap:8px; padding:10px 14px; border:1px solid rgba(100,120,150,.22); border-radius:12px; background:rgba(100,120,150,.06); }
130+
.qa-suite-dot { width:8px; height:8px; border-radius:50%; background:#14b8a6; box-shadow:0 0 0 4px rgba(20,184,166,.12); }
131+
.qa-section-label { margin:22px 0 10px; font-size:.72rem; letter-spacing:.18em; font-weight:800; opacity:.68; }
132+
.qa-result-pill { display:inline-flex; align-items:center; justify-content:center; padding:5px 10px; border-radius:999px; font-size:.72rem; font-weight:800; letter-spacing:.05em; }
133+
.qa-status-pass { color:#16a34a; background:rgba(22,163,74,.12); border:1px solid rgba(22,163,74,.24); }
134+
.qa-status-fail { color:#d97706; background:rgba(217,119,6,.12); border:1px solid rgba(217,119,6,.24); }
135+
.qa-status-error { color:#dc2626; background:rgba(220,38,38,.12); border:1px solid rgba(220,38,38,.24); }
136+
.qa-status-neutral { color:#64748b; background:rgba(100,116,139,.12); border:1px solid rgba(100,116,139,.24); }
137+
.qa-actual { margin:12px 0; padding:12px 14px; border-radius:10px; background:rgba(100,116,139,.06); border:1px solid rgba(100,116,139,.12); }
138+
.regression-hero { display:flex; justify-content:space-between; align-items:center; gap:18px; padding:20px 22px; border:1px solid rgba(20,184,166,.25); border-radius:16px; background:linear-gradient(135deg, rgba(20,184,166,.08), rgba(59,130,246,.06)); margin:8px 0 18px; }
139+
.regression-hero h3 { margin:.15rem 0 .3rem; }
140+
.regression-hero p { margin:0; opacity:.74; }
141+
.regression-state { white-space:nowrap; border:1px solid rgba(20,184,166,.28); color:#0f766e; background:rgba(20,184,166,.08); padding:7px 10px; border-radius:999px; font-size:.7rem; font-weight:800; letter-spacing:.08em; }
142+
.regression-clean,.regression-warning { display:flex; gap:10px; align-items:center; padding:12px 14px; border-radius:12px; margin:14px 0; }
143+
.regression-clean { border:1px solid rgba(22,163,74,.2); background:rgba(22,163,74,.08); }
144+
.regression-warning { border:1px solid rgba(217,119,6,.22); background:rgba(217,119,6,.08); }
145+
.regression-clean span,.regression-warning span { opacity:.72; }
146+
@media (max-width: 800px) { .regression-hero { flex-direction:column; align-items:flex-start; } }
147+
148+
/* QA multiselect tokens follow the product palette instead of Streamlit error red */
149+
div[data-baseweb="select"] span[data-baseweb="tag"] {
150+
background: rgba(20,184,166,.14) !important;
151+
border: 1px solid rgba(20,184,166,.28) !important;
152+
color: inherit !important;
153+
}
154+
</style>
155+
""")
156+
127157
OUTPUT_DIR = os.getenv("CRAWLER_OUTPUT_DIR", "output")
128158

129159
STATUS_STYLE = {
@@ -400,8 +430,10 @@ def on_progress(visited, total, current_url):
400430
result = asyncio.run(
401431
crawler.crawl_and_process(max_pages=int(max_pages), headless=True, progress_cb=on_progress)
402432
)
433+
result["app_id"] = app_id.strip() or "crawl"
403434
st.session_state.result = result
404435
st.session_state.error = None
436+
st.session_state.execution_results = []
405437
summary = result["crawl_summary"]
406438
st.session_state.history.append({**summary, "app_id": app_id.strip() or "crawl", "_full_result": result})
407439
dots_slot.empty()
@@ -587,64 +619,195 @@ def on_progress(visited, total, current_url):
587619
with tab_execution:
588620
test_cases = result.get("test_cases", [])
589621
st.subheader(":material/play_circle: Safe QA execution", anchor=False)
590-
st.caption("Runs only non-destructive checks such as page structure, form rendering, input discovery, navigation-link presence, and accessibility checks. It does not submit forms or activate arbitrary buttons.")
591-
safe_titles = {"Verify form renders correctly", "Verify input controls", "Verify navigation links", "Verify page structure", "Review missing heading structure", "Review accessibility findings"}
622+
st.caption("Run only non-destructive browser checks discovered by WebQA. Forms are not submitted and arbitrary buttons are never activated.")
623+
safe_titles = {
624+
"Verify form renders correctly", "Verify input controls", "Verify navigation links",
625+
"Verify page structure", "Review missing heading structure", "Review accessibility findings",
626+
}
592627
safe_cases = [c for c in test_cases if c.get("title") in safe_titles]
593-
ec1, ec2, ec3 = st.columns(3)
594-
ec1.metric("Executable safe tests", len(safe_cases))
595-
ec2.metric("Executed", len(st.session_state.execution_results))
596-
passed = sum(r.get("status") == "PASSED" for r in st.session_state.execution_results)
628+
results = st.session_state.execution_results or []
629+
passed = sum(r.get("status") == "PASSED" for r in results)
630+
failed = sum(r.get("status") == "FAILED" for r in results)
631+
errors = sum(r.get("status") == "ERROR" for r in results)
632+
633+
ec1, ec2, ec3, ec4 = st.columns(4)
634+
ec1.metric("Available", len(safe_cases))
635+
ec2.metric("Executed", len(results))
597636
ec3.metric("Passed", passed)
637+
ec4.metric("Needs review", failed + errors)
638+
598639
if safe_cases:
599-
selected_ids = st.multiselect("Select safe tests", [f"{c['id']}{c['title']}" for c in safe_cases], default=[f"{c['id']}{c['title']}" for c in safe_cases[:10]])
600-
if st.button("Run selected safe tests", icon=":material/play_arrow:", type="primary"):
601-
selected = {item.split(" — ", 1)[0] for item in selected_ids}
602-
chosen = [c for c in safe_cases if c["id"] in selected]
640+
labels = [f"{idx} · {case['title']} · {case.get('url', summary['base_url'])}" for idx, case in enumerate(safe_cases)]
641+
default_labels = labels[:10]
642+
selected_labels = st.multiselect(
643+
"Safe test suite",
644+
labels,
645+
default=default_labels,
646+
placeholder="Choose non-destructive checks…",
647+
help="Only checks marked safe by WebQA are executable. Selecting a row identifies the exact test case even when IDs repeat across pages.",
648+
)
649+
selected_indexes = {int(label.split(" · ", 1)[0]) for label in selected_labels}
650+
chosen = [case for idx, case in enumerate(safe_cases) if idx in selected_indexes]
651+
652+
action_col, info_col = st.columns([1, 2])
653+
with action_col:
654+
run_clicked = st.button("Run selected safe tests", icon=":material/play_arrow:", type="primary", width="stretch", disabled=not chosen)
655+
with info_col:
656+
st.markdown(
657+
f'<div class="qa-suite-summary"><span class="qa-suite-dot"></span><strong>{len(chosen)} checks selected</strong><span>·</span><span>non-destructive only</span><span>·</span><span>Playwright</span></div>',
658+
unsafe_allow_html=True,
659+
)
660+
661+
if run_clicked:
662+
# Stay on the Execution tab after the action and render the live results
663+
# in the same Streamlit run. A rerun here can reset st.tabs() to the
664+
# first tab, which makes it look like the execution view disappeared.
665+
st.html(
666+
"""<div id=\"qa-execution-live\" style=\"display:flex;align-items:center;gap:.6rem;padding:.72rem .9rem;margin:.7rem 0 1rem;border:1px solid rgba(34,197,94,.28);background:rgba(34,197,94,.08);border-radius:12px;\">"
667+
<span style=\"width:9px;height:9px;border-radius:999px;background:#22c55e;box-shadow:0 0 0 5px rgba(34,197,94,.12);animation:dotPulse 1.1s ease-in-out infinite;\"></span>"
668+
<strong>Tests running</strong>"
669+
<span style=\"opacity:.72;\">Safe, non-destructive browser checks are executing now.</span>"
670+
</div>"""
671+
)
603672
crawler = WebCrawler(url=summary["base_url"], app_id="qa_execution", output_dir=OUTPUT_DIR)
604-
with st.spinner("Executing safe QA checks with Playwright..."):
605-
st.session_state.execution_results = asyncio.run(crawler.execute_safe_qa_tests(chosen, headless=True, max_tests=len(chosen)))
673+
status = st.status("Running safe QA checks…", expanded=True)
674+
status.write(f"Executing {len(chosen)} selected checks with Playwright")
675+
with status:
676+
st.session_state.execution_results = asyncio.run(
677+
crawler.execute_safe_qa_tests(chosen, headless=True, max_tests=len(chosen))
678+
)
679+
status.update(label="Safe QA execution complete", state="complete")
680+
st.toast("Safe QA execution complete", icon=":material/task_alt:")
681+
results = st.session_state.execution_results or []
606682
else:
607683
st.info("No non-destructive automated checks are available for this crawl.", icon=":material/info:")
608-
if st.session_state.execution_results:
609-
execution_df = pd.DataFrame(st.session_state.execution_results)
610-
st.dataframe(execution_df[["id", "title", "status", "duration_ms", "actual_result"]], width="stretch", hide_index=True)
611-
for execution in st.session_state.execution_results:
612-
icon = ":material/check_circle:" if execution["status"] == "PASSED" else ":material/error:"
613-
with st.expander(f"{icon} {execution['id']}{execution['title']}{execution['status']}"):
614-
st.write(execution.get("actual_result", ""))
615-
st.caption(f"Duration: {execution.get('duration_ms', 0)} ms · URL: {execution.get('url', '')}")
616-
if execution.get("evidence"):
617-
st.markdown("**Evidence**")
618-
for item in execution["evidence"]:
619-
st.markdown(f"- {item}")
620-
screenshot = execution.get("screenshot_path", "")
621-
if screenshot and os.path.exists(screenshot):
622-
st.image(screenshot, caption="Execution evidence", width="stretch")
684+
685+
if results:
686+
st.markdown('<div class="qa-section-label">EXECUTION RESULTS</div>', unsafe_allow_html=True)
687+
for idx, execution in enumerate(results):
688+
status_value = execution.get("status", "UNKNOWN")
689+
status_class = {
690+
"PASSED": "qa-status-pass",
691+
"FAILED": "qa-status-fail",
692+
"ERROR": "qa-status-error",
693+
}.get(status_value, "qa-status-neutral")
694+
icon = {
695+
"PASSED": ":material/check_circle:",
696+
"FAILED": ":material/warning:",
697+
"ERROR": ":material/error:",
698+
}.get(status_value, ":material/help:")
699+
duration = execution.get("duration_ms", 0)
700+
with st.container(border=True):
701+
top_left, top_mid, top_right = st.columns([4, 1, 1], vertical_alignment="center")
702+
with top_left:
703+
st.markdown(f"**{execution.get('id', 'TEST')} · {execution.get('title', 'Safe QA check')}**")
704+
st.caption(execution.get("url", summary["base_url"]))
705+
with top_mid:
706+
st.markdown(f'<span class="qa-result-pill {status_class}">{status_value}</span>', unsafe_allow_html=True)
707+
with top_right:
708+
st.caption(f"{duration} ms")
709+
st.markdown(f"<div class=\"qa-actual\"><strong>Observed:</strong> {execution.get('actual_result', 'No result returned.')}</div>", unsafe_allow_html=True)
710+
meta_cols = st.columns(3)
711+
meta_cols[0].caption(f"Console errors · {sum('Console errors:' in x and not x.endswith(': 0') for x in execution.get('evidence', []))}")
712+
meta_cols[1].caption(f"Evidence items · {len(execution.get('evidence', []))}")
713+
meta_cols[2].caption("Safe · no destructive actions")
714+
with st.expander("Evidence & screenshot"):
715+
if execution.get("evidence"):
716+
for item in execution["evidence"]:
717+
st.markdown(f"- {item}")
718+
screenshot = execution.get("screenshot_path", "")
719+
if screenshot and os.path.exists(screenshot):
720+
st.image(screenshot, caption="Execution evidence", width="stretch")
721+
722+
st.download_button(
723+
"Download execution results",
724+
data=json.dumps(results, indent=2),
725+
file_name="webqa_safe_execution_results.json",
726+
mime="application/json",
727+
icon=":material/download:",
728+
)
623729

624730
with tab_regression:
625731
regression = result.get("regression", {})
626732
st.subheader(":material/compare_arrows: Crawl regression", anchor=False)
627-
st.caption("Compares page structure, forms, interactions, links, status, and QA risk with the previous crawl baseline. Content is not stored in the baseline.")
628-
if not regression.get("available"):
629-
st.info(regression.get("message", "Baseline created for future comparisons."), icon=":material/schedule:")
733+
st.caption("Compare the current crawl against a retained structural baseline. The baseline contains page shape, forms, links, interactions, status and QA signals — never page content.")
734+
735+
if regression.get("baseline_created"):
736+
st.markdown(
737+
'<div class="regression-hero"><div><div class="premium-kicker">BASELINE ESTABLISHED</div><h3>This crawl is now your reference point.</h3><p>Run the same crawl again later to detect structural drift, new pages, removed pages, or changed QA signals.</p></div><span class="regression-state">READY FOR COMPARISON</span></div>',
738+
unsafe_allow_html=True,
739+
)
740+
elif not regression.get("available"):
741+
st.info(regression.get("message", "No baseline is available yet."), icon=":material/schedule:")
630742
else:
631-
rc1, rc2, rc3 = st.columns(3)
632-
rc1.metric("Added pages", len(regression.get("added", [])))
633-
rc2.metric("Removed pages", len(regression.get("removed", [])))
634-
rc3.metric("Changed pages", len(regression.get("changed", [])))
635-
if regression.get("added"):
636-
st.markdown("### Added pages")
637-
for item in regression["added"]:
638-
st.markdown(f"- `{item}`")
639-
if regression.get("removed"):
640-
st.markdown("### Removed pages")
641-
for item in regression["removed"]:
642-
st.markdown(f"- `{item}`")
643-
if regression.get("changed"):
644-
st.markdown("### Changed pages")
645-
st.dataframe(regression["changed"], width="stretch", hide_index=True)
646-
if not any(regression.get(k) for k in ("added", "removed", "changed")):
647-
st.success("No structural regression detected compared with the previous baseline.", icon=":material/check_circle:")
743+
added = regression.get("added", [])
744+
removed = regression.get("removed", [])
745+
changed = regression.get("changed", [])
746+
changed_count = len(added) + len(removed) + len(changed)
747+
rc1, rc2, rc3, rc4 = st.columns(4)
748+
rc1.metric("Added", len(added))
749+
rc2.metric("Removed", len(removed))
750+
rc3.metric("Changed", len(changed))
751+
rc4.metric("Regression score", "Clean" if changed_count == 0 else f"{changed_count} change{'s' if changed_count != 1 else ''}")
752+
753+
if changed_count == 0:
754+
st.markdown('<div class="regression-clean"><strong>✓ No structural regression detected.</strong><span>The current crawl matches the stored baseline across tracked signals.</span></div>', unsafe_allow_html=True)
755+
else:
756+
st.markdown('<div class="regression-warning"><strong>Changes detected.</strong><span>Review the affected pages below before updating the baseline.</span></div>', unsafe_allow_html=True)
757+
758+
if added:
759+
with st.expander(f"Added pages · {len(added)}", expanded=True):
760+
for item in added:
761+
st.markdown(f"- `{item}`")
762+
if removed:
763+
with st.expander(f"Removed pages · {len(removed)}", expanded=True):
764+
for item in removed:
765+
st.markdown(f"- `{item}`")
766+
if changed:
767+
with st.expander(f"Changed pages · {len(changed)}", expanded=True):
768+
st.dataframe(changed, width="stretch", hide_index=True)
769+
770+
st.divider()
771+
col_update, col_help = st.columns([1, 2], vertical_alignment="center")
772+
with col_update:
773+
if st.button("Update baseline", icon=":material/bookmark_add:", type="primary", width="stretch"):
774+
# Rebuild the baseline directly from the current result without a second crawl.
775+
snapshot = {
776+
"base_url": summary["base_url"],
777+
"pages": {
778+
page["url"]: {
779+
"title": page.get("title", ""),
780+
"status": page.get("status", "").split(":", 1)[0],
781+
"form_count": len(page.get("forms", [])),
782+
"interactive_element_count": len(page.get("interactive_elements", [])),
783+
"heading_count": len(page.get("headings", [])),
784+
"link_count": page.get("link_count", 0),
785+
"qa_risk_level": page.get("qa_risk_level", "Low"),
786+
"accessibility_finding_count": len(page.get("accessibility_findings", [])),
787+
"api_request_count": len(page.get("api_requests", [])),
788+
}
789+
for page in result.get("pages", [])
790+
},
791+
}
792+
baseline_app_id = result.get("app_id", "crawl")
793+
baseline_path = os.path.join(OUTPUT_DIR, "reports", f"{baseline_app_id}_baseline.json")
794+
os.makedirs(os.path.dirname(baseline_path), exist_ok=True)
795+
with open(baseline_path, "w", encoding="utf-8") as handle:
796+
json.dump(snapshot, handle, indent=2)
797+
result["regression"] = {
798+
"available": True,
799+
"baseline_created": False,
800+
"baseline_updated": True,
801+
"baseline_pages": len(snapshot.get("pages", {})),
802+
"current_pages": len(snapshot.get("pages", {})),
803+
"message": "Current crawl is now the stored baseline.",
804+
"added": [], "removed": [], "changed": [],
805+
}
806+
st.session_state.result = result
807+
st.success("Baseline updated to the current crawl.", icon=":material/bookmark_added:")
808+
st.rerun()
809+
with col_help:
810+
st.caption("Keep the current baseline when investigating drift. Update it only after you have reviewed the detected changes.")
648811

649812
with tab_report:
650813
if rag["markdown_path"] and os.path.exists(rag["markdown_path"]):

0 commit comments

Comments
 (0)