|
124 | 124 | </style> |
125 | 125 | """) |
126 | 126 |
|
| 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 | + |
127 | 157 | OUTPUT_DIR = os.getenv("CRAWLER_OUTPUT_DIR", "output") |
128 | 158 |
|
129 | 159 | STATUS_STYLE = { |
@@ -400,8 +430,10 @@ def on_progress(visited, total, current_url): |
400 | 430 | result = asyncio.run( |
401 | 431 | crawler.crawl_and_process(max_pages=int(max_pages), headless=True, progress_cb=on_progress) |
402 | 432 | ) |
| 433 | + result["app_id"] = app_id.strip() or "crawl" |
403 | 434 | st.session_state.result = result |
404 | 435 | st.session_state.error = None |
| 436 | + st.session_state.execution_results = [] |
405 | 437 | summary = result["crawl_summary"] |
406 | 438 | st.session_state.history.append({**summary, "app_id": app_id.strip() or "crawl", "_full_result": result}) |
407 | 439 | dots_slot.empty() |
@@ -587,64 +619,195 @@ def on_progress(visited, total, current_url): |
587 | 619 | with tab_execution: |
588 | 620 | test_cases = result.get("test_cases", []) |
589 | 621 | 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 | + } |
592 | 627 | 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)) |
597 | 636 | ec3.metric("Passed", passed) |
| 637 | + ec4.metric("Needs review", failed + errors) |
| 638 | + |
598 | 639 | 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 | + ) |
603 | 672 | 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 [] |
606 | 682 | else: |
607 | 683 | 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 | + ) |
623 | 729 |
|
624 | 730 | with tab_regression: |
625 | 731 | regression = result.get("regression", {}) |
626 | 732 | 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:") |
630 | 742 | 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.") |
648 | 811 |
|
649 | 812 | with tab_report: |
650 | 813 | if rag["markdown_path"] and os.path.exists(rag["markdown_path"]): |
|
0 commit comments