synapse-hub 2.6.0 → 3.0.0
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.
- package/.agents/agents/failure-analyst.md +4 -0
- package/.agents/agents/live-explorer.md +2 -0
- package/.agents/agents/orchestrator.md +16 -0
- package/.agents/agents/pom-crafter.md +10 -0
- package/.agents/agents/quality-gatekeeper.md +5 -0
- package/.agents/agents/test-runner.md +8 -0
- package/.agents/rules/git-push-protocol.md +1 -1
- package/.agents/rules/graphify.md +2 -0
- package/.agents/rules/mandatory-playwright-live.md +9 -9
- package/.agents/rules/mobile_appium_rules.md +8 -1
- package/.agents/skills/actor-qa-swarm/SKILL.md +77 -0
- package/.agents/skills/actor-qa-swarm/examples/broker_sharding_sample.json +12 -0
- package/.agents/skills/agentless-qa-repair/SKILL.md +69 -0
- package/.agents/skills/agentless-qa-repair/examples/healing_report_sample.json +17 -0
- package/.agents/skills/api-spec-to-e2e-matrix/SKILL.md +1 -1
- package/.agents/skills/clean-pom-crafter/SKILL.md +2 -2
- package/.agents/skills/db-state-verifier-and-seeder/SKILL.md +4 -4
- package/.agents/skills/event-stream-qa-orchestrator/SKILL.md +79 -0
- package/.agents/skills/event-stream-qa-orchestrator/examples/timeline_sample.jsonl +2 -0
- package/.agents/skills/failure-autopsy-and-healing/SKILL.md +2 -2
- package/.agents/skills/failure-autopsy-and-healing/scripts/triage_failures.sh +6 -21
- package/.agents/skills/katalon-cli-runner/SKILL.md +1 -1
- package/.agents/skills/lean-impact-scoreboard/SKILL.md +1 -1
- package/.agents/skills/mipro-prompt-compiler/SKILL.md +59 -0
- package/.agents/skills/mipro-prompt-compiler/examples/compiled_module_sample.json +22 -0
- package/.agents/skills/qa-aci-interface/SKILL.md +67 -0
- package/.agents/skills/qa-aci-interface/examples/surgical_edit_sample.json +12 -0
- package/.agents/skills/qa-memory-os/SKILL.md +80 -0
- package/.agents/skills/qa-memory-os/examples/archival_memory_sample.json +21 -0
- package/.agents/skills/repo-quality-audit/SKILL.md +1 -1
- package/.agents/skills/review-spec/SKILL.md +60 -0
- package/.agents/skills/review-spec/examples/sample_spec_review.md +33 -0
- package/.agents/skills/senior-code-review/SKILL.md +1 -1
- package/.agents/skills/synthetic-test-data-generator/SKILL.md +2 -2
- package/.agents/skills/technical-debt-harvester/SKILL.md +1 -1
- package/.agents/skills/tot-exploratory-testing/SKILL.md +59 -0
- package/.agents/skills/tot-exploratory-testing/examples/tot_graph_sample.json +35 -0
- package/.agents/skills/wcag-audit-patterns/SKILL.md +2 -2
- package/.agents/skills/web-agent-gym-benchmarking/SKILL.md +62 -0
- package/.agents/skills/web-agent-gym-benchmarking/examples/benchmark_report_sample.json +22 -0
- package/CLAUDE.md +2 -2
- package/README.en.md +70 -291
- package/README.md +68 -303
- package/docs/ARCHITECTURE.md +54 -8
- package/docs/CONTRIBUTING.md +1 -1
- package/docs/QUALITY_GATES.md +113 -0
- package/docs/research/EXTERNAL_AGENT_SKILLS_SURVEY.md +1062 -0
- package/package.json +1 -1
- package/scripts/package_dist.py +2 -1
- package/skills_manifest.json +92 -11
- package/synapse.config.json +1 -1
- package/templates/ai_bridges/CLAUDE.md +2 -2
- package/templates/ai_bridges/copilot-instructions.md +2 -2
- package/tools/TOOLS_RUNBOOK.md +1 -1
- package/tools/__init__.py +18 -1
- package/tools/_openapi_parser.py +158 -0
- package/tools/agent_canary.py +42 -30
- package/tools/agentless_patcher.py +417 -0
- package/tools/analyze_project.py +12 -11
- package/tools/api_drift_sentinel.py +4 -4
- package/tools/api_fuzzer.py +8 -8
- package/tools/auto_heal.py +23 -5
- package/tools/auto_heal_reflexion.py +2 -2
- package/tools/autogen_qa_broker.py +318 -0
- package/tools/browser_helpers.js +2 -2
- package/tools/browsergym_eval.py +236 -0
- package/tools/bug_reporter.py +11 -13
- package/tools/bump_version.py +75 -5
- package/tools/cache_resolver.py +78 -83
- package/tools/check_env.py +9 -1
- package/tools/check_version_sync.py +40 -3
- package/tools/ci_healing_hook.py +5 -7
- package/tools/cli.py +5 -7
- package/tools/config_decoupler.py +2 -3
- package/tools/core/__init__.py +52 -0
- package/tools/count_tokens.py +22 -20
- package/tools/dashboard_generator.py +628 -999
- package/tools/db_sandbox.py +69 -53
- package/tools/debt_harvester.py +3 -3
- package/tools/distill_log.py +3 -3
- package/tools/dspy_qa_signatures.py +3 -4
- package/tools/export_postman_collection.py +8 -8
- package/tools/flaky_detector.py +2 -2
- package/tools/fuzz_payloads.py +5 -5
- package/tools/generate_allure_report.py +1 -1
- package/tools/generate_jmx_plan.py +4 -4
- package/tools/gherkin_suite.py +1 -1
- package/tools/git_pre_commit_guard.py +2 -2
- package/tools/goldfish_review.py +2 -2
- package/tools/heal/__init__.py +28 -0
- package/tools/heal_pr_automator.py +29 -4
- package/tools/heuristic_locator_matcher.py +2 -2
- package/tools/infer_linter.py +8 -8
- package/tools/interview_story.py +0 -1
- package/tools/jtl_analyzer.py +11 -11
- package/tools/lint/__init__.py +30 -0
- package/tools/lint_ai_docs.py +42 -0
- package/tools/lookup_tc.py +1 -1
- package/tools/matrix_sharder.py +15 -13
- package/tools/mcp/__init__.py +16 -0
- package/tools/mcp_excel_server.py +2 -2
- package/tools/mcp_server.py +3 -4
- package/tools/md_to_xlsx.py +26 -26
- package/tools/micro_mutator.py +11 -12
- package/tools/mipro_assertion_tuner.py +207 -0
- package/tools/network_mock_shield.py +27 -27
- package/tools/newman_analyzer.py +2 -3
- package/tools/omni_emitter.py +5 -8
- package/tools/omni_parser.py +5 -5
- package/tools/omni_transpiler.py +5 -5
- package/tools/output_formatter.py +19 -0
- package/tools/perf/__init__.py +24 -0
- package/tools/pinpoint_failure.py +13 -1
- package/tools/project_state.py +4 -2
- package/tools/qa_event_stream.py +336 -0
- package/tools/repo_map.py +8 -8
- package/tools/report/__init__.py +32 -0
- package/tools/reversible_sandbox.py +5 -2
- package/tools/safety_firewall.py +2 -2
- package/tools/senior_code_review.py +2 -2
- package/tools/setup_project.py +10 -12
- package/tools/skill_collision_guard.py +3 -3
- package/tools/skill_vault.py +12 -3
- package/tools/skillspector.py +5 -7
- package/tools/smart_visual_masker.py +4 -6
- package/tools/spec_drift_sentinel.py +1 -2
- package/tools/swe_qa_aci.py +369 -0
- package/tools/synapse_qa_bench.py +3 -3
- package/tools/sync_agent_rules.py +18 -4
- package/tools/telemetry_correlator.py +3 -2
- package/tools/test_memory_os.py +419 -0
- package/tools/textgrad_mutation_optimizer.py +3 -3
- package/tools/tms_sync_bridge.py +5 -5
- package/tools/tot_exploratory_tester.py +239 -0
- package/tools/traffic_session_converter.py +9 -9
- package/tools/universal_ir.py +2 -3
- package/tools/update_sentinel.py +6 -9
- package/tools/verify_all.py +10 -0
- package/tools/visual_diff_canvas.py +1 -2
- package/tools/visual_som_helper.py +15 -13
- package/tools/watch_mode.py +1 -1
- package/tools/wcag_audit.py +22 -1
- package/tools/welcome_page.py +5 -6
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 Thế Vị (@TeeVee998). All rights reserved.
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
# Project: Synapse-Hub (https://github.com/TeeVee998/Synapse-Hub)
|
|
5
|
+
|
|
6
|
+
"""
|
|
7
|
+
Tree/Graph-of-Thoughts Exploratory Testing Engine (Princeton ToT & ETH GoT Pattern)
|
|
8
|
+
----------------------------------------------------------------------------------
|
|
9
|
+
Expands linear user stories into deliberative non-linear exploration trees:
|
|
10
|
+
1. Multi-Branch Deliberation: Generates Happy Path, Boundary/Race, and Negative/Chaos branches.
|
|
11
|
+
2. Risk-Scored Beam Search: Evaluates thought states and prunes low-risk tautological paths.
|
|
12
|
+
3. Clean POM Synthesis: Produces structured test scenarios ready for Clean POM Page Classes.
|
|
13
|
+
4. Multi-Hypothesis RCA: Diagnoses test failures across competing failure hypotheses (DOM_TIMEOUT, ASYNC_RACE, DB_LOCK, BACKEND_500).
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
python tools/tot_exploratory_tester.py expand-story --story-file docs/stories/US_PAYMENT.md --max-depth 3 --beam-width 3
|
|
17
|
+
python tools/tot_exploratory_tester.py rca-hypotheses --error-log reports/failed.log
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
import re
|
|
23
|
+
import json
|
|
24
|
+
import argparse
|
|
25
|
+
from typing import Dict, Any, List, Optional, cast
|
|
26
|
+
|
|
27
|
+
# Enforce UTF-8 stdout encoding
|
|
28
|
+
if hasattr(sys.stdout, 'reconfigure'):
|
|
29
|
+
try:
|
|
30
|
+
sys.stdout.reconfigure(encoding='utf-8')
|
|
31
|
+
sys.stderr.reconfigure(encoding='utf-8')
|
|
32
|
+
except Exception:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TotExploratoryTester:
|
|
37
|
+
"""Tree-of-Thoughts Engine for Exploratory QA and Root Cause Analysis."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, repo_root: Optional[str] = None):
|
|
40
|
+
self.repo_root = repo_root or os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
41
|
+
|
|
42
|
+
def expand_story(self, story_content: str, max_depth: int = 3, beam_width: int = 3) -> Dict[str, Any]:
|
|
43
|
+
"""Decomposes story into a 3-branch Tree of Thoughts with risk scores."""
|
|
44
|
+
# Extract title and criteria
|
|
45
|
+
lines = [line.strip() for line in story_content.splitlines() if line.strip()]
|
|
46
|
+
title = lines[0] if lines else "Untitled User Story"
|
|
47
|
+
if title.startswith("#"):
|
|
48
|
+
title = title.lstrip("#").strip()
|
|
49
|
+
|
|
50
|
+
# Extract keywords
|
|
51
|
+
keywords = re.findall(r'\b[a-zA-Z]{4,}\b', story_content.lower())
|
|
52
|
+
is_checkout = any(k in keywords for k in ["cart", "checkout", "pay", "order", "price"])
|
|
53
|
+
is_auth = any(k in keywords for k in ["login", "auth", "token", "password", "sso"])
|
|
54
|
+
|
|
55
|
+
context_domain = "FINANCIAL_CHECKOUT" if is_checkout else ("AUTH_SESSION" if is_auth else "GENERAL_CRUD")
|
|
56
|
+
|
|
57
|
+
# 3 Branches:
|
|
58
|
+
# 1. Happy Path
|
|
59
|
+
# 2. Boundary / Session Interruption / Race Condition
|
|
60
|
+
# 3. Negative / Chaos / Backend Fault
|
|
61
|
+
branches = [
|
|
62
|
+
{
|
|
63
|
+
"branch_id": "BRANCH_HAPPY_PATH",
|
|
64
|
+
"name": "Standard Happy Business Path",
|
|
65
|
+
"risk_score": 0.25,
|
|
66
|
+
"thoughts": [
|
|
67
|
+
{"step": 1, "thought": "Navigate to initial entity view and ensure clean pre-conditions."},
|
|
68
|
+
{"step": 2, "thought": "Populate valid payload attributes and trigger submit action."},
|
|
69
|
+
{"step": 3, "thought": "Verify success confirmation modal and assert database record creation."}
|
|
70
|
+
]
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"branch_id": "BRANCH_BOUNDARY_RACE",
|
|
74
|
+
"name": "Boundary, Session Interruption & Concurrency Path",
|
|
75
|
+
"risk_score": 0.88,
|
|
76
|
+
"thoughts": [
|
|
77
|
+
{"step": 1, "thought": "Simulate session expiration or token invalidation mid-flow."},
|
|
78
|
+
{"step": 2, "thought": "Perform rapid double-click on submission action to probe race condition."},
|
|
79
|
+
{"step": 3, "thought": "Assert idempotency: strictly 1 transaction created, duplicate blocked gracefully."}
|
|
80
|
+
]
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"branch_id": "BRANCH_NEGATIVE_CHAOS",
|
|
84
|
+
"name": "Negative Error Handling & Chaos Fault Path",
|
|
85
|
+
"risk_score": 0.72,
|
|
86
|
+
"thoughts": [
|
|
87
|
+
{"step": 1, "thought": "Inject malformed/boundary input payload exceeding maximum character limits."},
|
|
88
|
+
{"step": 2, "thought": "Simulate backend 500 error or database connection timeout via route stub."},
|
|
89
|
+
{"step": 3, "thought": "Assert user-friendly error toast, zero data corruption, and retry availability."}
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
# Beam search selection: sort by risk score descending and take beam_width
|
|
95
|
+
sorted_branches = sorted(branches, key=lambda b: cast(float, b["risk_score"]), reverse=True)
|
|
96
|
+
selected_branches = sorted_branches[:beam_width]
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
"status": "PASS",
|
|
100
|
+
"story_title": title,
|
|
101
|
+
"domain": context_domain,
|
|
102
|
+
"max_depth": max_depth,
|
|
103
|
+
"beam_width": beam_width,
|
|
104
|
+
"total_branches_explored": len(branches),
|
|
105
|
+
"highest_risk_branch": selected_branches[0]["branch_id"],
|
|
106
|
+
"branches": selected_branches,
|
|
107
|
+
"clean_pom_recommendations": [
|
|
108
|
+
f"Implement {context_domain}Page extending BasePage",
|
|
109
|
+
"Encapsulate double-click debounce in clickSubmitSafe()",
|
|
110
|
+
"Assert boundary toast in verifyErrorMessageDisplayed()"
|
|
111
|
+
]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
def rca_hypotheses(self, error_log: str, hypotheses: Optional[List[str]] = None) -> Dict[str, Any]:
|
|
115
|
+
"""Performs multi-hypothesis Root Cause Analysis on test failures."""
|
|
116
|
+
if not hypotheses:
|
|
117
|
+
hypotheses = ["DOM_TIMEOUT", "ASYNC_RACE", "DB_LOCK", "BACKEND_500", "STALE_SELECTOR"]
|
|
118
|
+
|
|
119
|
+
log_lower = error_log.lower()
|
|
120
|
+
scored_hypotheses = []
|
|
121
|
+
|
|
122
|
+
for hyp in hypotheses:
|
|
123
|
+
score = 0.0
|
|
124
|
+
evidence = []
|
|
125
|
+
|
|
126
|
+
if hyp == "DOM_TIMEOUT":
|
|
127
|
+
if "timed out" in log_lower or "waitfortimeout" in log_lower or "5000ms" in log_lower:
|
|
128
|
+
score += 0.85
|
|
129
|
+
evidence.append("Found timeout or waiting duration signature in log")
|
|
130
|
+
if "element is not visible" in log_lower or "tobevisible" in log_lower:
|
|
131
|
+
score += 0.10
|
|
132
|
+
evidence.append("Element visibility expectation failed")
|
|
133
|
+
|
|
134
|
+
elif hyp == "ASYNC_RACE":
|
|
135
|
+
if "click intercepted" in log_lower or "animation" in log_lower or "detached" in log_lower:
|
|
136
|
+
score += 0.90
|
|
137
|
+
evidence.append("Overlay or detachment signature points to async race condition")
|
|
138
|
+
if "networkidle" in log_lower or "pending" in log_lower:
|
|
139
|
+
score += 0.15
|
|
140
|
+
evidence.append("Pending network requests active during action")
|
|
141
|
+
|
|
142
|
+
elif hyp == "DB_LOCK":
|
|
143
|
+
if "deadlock" in log_lower or "lock" in log_lower or "sql" in log_lower or "unique constraint" in log_lower:
|
|
144
|
+
score += 0.88
|
|
145
|
+
evidence.append("Database lock or constraint collision detected in trace")
|
|
146
|
+
|
|
147
|
+
elif hyp == "BACKEND_500":
|
|
148
|
+
if re.search(r'\b500\b', log_lower) and not re.search(r'\b500\d+ms\b', log_lower) and ("http" in log_lower or "status" in log_lower or "server error" in log_lower or "internal" in log_lower):
|
|
149
|
+
score += 0.95
|
|
150
|
+
evidence.append("HTTP 500 status code explicitly logged")
|
|
151
|
+
elif "internal server error" in log_lower:
|
|
152
|
+
score += 0.95
|
|
153
|
+
evidence.append("Internal Server Error explicitly logged")
|
|
154
|
+
|
|
155
|
+
elif hyp == "STALE_SELECTOR":
|
|
156
|
+
if "locator" in log_lower or "selector" in log_lower or "xpath" in log_lower:
|
|
157
|
+
score += 0.70
|
|
158
|
+
evidence.append("Locator or selector syntax mentioned in failure message")
|
|
159
|
+
|
|
160
|
+
# Baseline confidence
|
|
161
|
+
if not evidence:
|
|
162
|
+
score = 0.10
|
|
163
|
+
evidence.append("No direct signature found; baseline background likelihood")
|
|
164
|
+
|
|
165
|
+
scored_hypotheses.append({
|
|
166
|
+
"hypothesis": hyp,
|
|
167
|
+
"confidence_score": round(min(1.0, score), 3),
|
|
168
|
+
"evidence": evidence
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
scored_hypotheses.sort(key=lambda h: cast(float, h["confidence_score"]), reverse=True)
|
|
172
|
+
winner = scored_hypotheses[0]
|
|
173
|
+
|
|
174
|
+
recommendations = {
|
|
175
|
+
"DOM_TIMEOUT": "Replace hardcoded delay with page.waitForLoadState('networkidle') or element.waitFor({state: 'visible'})",
|
|
176
|
+
"ASYNC_RACE": "Use clickSafe() helper with overlay detachment waiting or dispatchEvent('click')",
|
|
177
|
+
"DB_LOCK": "Randomize entity code prefix via DataGenerator.uniqueCode('AUTO_') and enforce teardown cleanup",
|
|
178
|
+
"BACKEND_500": "Inspect backend server logs for uncaught 500 NullPointerException or DB connection pool exhaustion",
|
|
179
|
+
"STALE_SELECTOR": "Run tools/agentless_patcher.py heal-failure to update stale attribute to ARIA role"
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
"status": "PASS",
|
|
184
|
+
"primary_root_cause": winner["hypothesis"],
|
|
185
|
+
"confidence": winner["confidence_score"],
|
|
186
|
+
"recommended_fix": recommendations.get(str(winner["hypothesis"]), "Review stacktrace with team"),
|
|
187
|
+
"ranked_hypotheses": scored_hypotheses
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def main():
|
|
192
|
+
parser = argparse.ArgumentParser(description="Tree-of-Thoughts Exploratory Testing Engine")
|
|
193
|
+
subparsers = parser.add_subparsers(dest="subcommand", help="Subcommand to execute")
|
|
194
|
+
|
|
195
|
+
# expand-story
|
|
196
|
+
exp_p = subparsers.add_parser("expand-story", help="Expand User Story into Tree of Thoughts")
|
|
197
|
+
exp_p.add_argument("--story-file", required=True, help="Path to story markdown file or text string")
|
|
198
|
+
exp_p.add_argument("--max-depth", type=int, default=3, help="Max thought tree depth")
|
|
199
|
+
exp_p.add_argument("--beam-width", type=int, default=3, help="Beam search width")
|
|
200
|
+
exp_p.add_argument("--out-graph", help="Optional path to output graph JSON")
|
|
201
|
+
|
|
202
|
+
# rca-hypotheses
|
|
203
|
+
rca_p = subparsers.add_parser("rca-hypotheses", help="Diagnose failure via multi-hypothesis RCA")
|
|
204
|
+
rca_p.add_argument("--error-log", required=True, help="Path to error log or log string")
|
|
205
|
+
rca_p.add_argument("--hypotheses", help="Comma-separated hypotheses list")
|
|
206
|
+
|
|
207
|
+
args = parser.parse_args()
|
|
208
|
+
tester = TotExploratoryTester()
|
|
209
|
+
|
|
210
|
+
if args.subcommand == "expand-story":
|
|
211
|
+
content = args.story_file
|
|
212
|
+
if os.path.exists(args.story_file):
|
|
213
|
+
with open(args.story_file, "r", encoding="utf-8", errors="replace") as f:
|
|
214
|
+
content = f.read()
|
|
215
|
+
res = tester.expand_story(content, max_depth=args.max_depth, beam_width=args.beam_width)
|
|
216
|
+
if args.out_graph:
|
|
217
|
+
os.makedirs(os.path.dirname(os.path.abspath(args.out_graph)), exist_ok=True)
|
|
218
|
+
with open(args.out_graph, "w", encoding="utf-8") as f:
|
|
219
|
+
json.dump(res, f, indent=2)
|
|
220
|
+
print(json.dumps(res, indent=2))
|
|
221
|
+
sys.exit(0)
|
|
222
|
+
|
|
223
|
+
elif args.subcommand == "rca-hypotheses":
|
|
224
|
+
log_content = args.error_log
|
|
225
|
+
if os.path.exists(args.error_log):
|
|
226
|
+
with open(args.error_log, "r", encoding="utf-8", errors="replace") as f:
|
|
227
|
+
log_content = f.read()
|
|
228
|
+
hyp_list = [h.strip() for h in args.hypotheses.split(",")] if args.hypotheses else None
|
|
229
|
+
res = tester.rca_hypotheses(error_log=log_content, hypotheses=hyp_list)
|
|
230
|
+
print(json.dumps(res, indent=2))
|
|
231
|
+
sys.exit(0)
|
|
232
|
+
|
|
233
|
+
else:
|
|
234
|
+
parser.print_help()
|
|
235
|
+
sys.exit(1)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
if __name__ == "__main__":
|
|
239
|
+
main()
|
|
@@ -23,7 +23,7 @@ import re
|
|
|
23
23
|
import sys
|
|
24
24
|
import json
|
|
25
25
|
import argparse
|
|
26
|
-
from typing import Dict, Any, List
|
|
26
|
+
from typing import Dict, Any, List
|
|
27
27
|
from urllib.parse import urlparse
|
|
28
28
|
|
|
29
29
|
# Enforce UTF-8 stdout encoding for Windows console compatibility
|
|
@@ -153,14 +153,14 @@ class TrafficConverter:
|
|
|
153
153
|
lines = [
|
|
154
154
|
"import { test, expect } from '@playwright/test';",
|
|
155
155
|
"",
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
156
|
+
"/**",
|
|
157
|
+
" * Automated Replay of Production Traffic Session",
|
|
158
|
+
" * Generated by traffic_session_converter.py (agent-skills Shift-Right Engine)",
|
|
159
159
|
f" * Total Actions: {len(requests)} verified network interactions",
|
|
160
|
-
|
|
160
|
+
" */",
|
|
161
161
|
f"test.describe('{test_name}', () => {{",
|
|
162
|
-
|
|
163
|
-
|
|
162
|
+
" test('replays production user journey with PII masking', async ({ page, request }) => {",
|
|
163
|
+
" // 1. Base navigation",
|
|
164
164
|
f" await page.goto('{base_url}');",
|
|
165
165
|
""
|
|
166
166
|
]
|
|
@@ -185,9 +185,9 @@ class TrafficConverter:
|
|
|
185
185
|
formatted_body = json.dumps(body_str)
|
|
186
186
|
|
|
187
187
|
lines.append(f" const resp{step_num} = await request.{method.lower()}('{url}', {{")
|
|
188
|
-
lines.append(
|
|
188
|
+
lines.append(" headers: { 'Content-Type': 'application/json' },")
|
|
189
189
|
lines.append(f" data: {formatted_body}")
|
|
190
|
-
lines.append(
|
|
190
|
+
lines.append(" });")
|
|
191
191
|
lines.append(f" expect(resp{step_num}.status()).toBe({status});")
|
|
192
192
|
else:
|
|
193
193
|
lines.append(f" const resp{step_num} = await request.get('{url}');")
|
package/tools/universal_ir.py
CHANGED
|
@@ -9,8 +9,9 @@ whether the target is Playwright, Selenium, Cypress, Katalon, or Appium.
|
|
|
9
9
|
|
|
10
10
|
from enum import Enum
|
|
11
11
|
from typing import Dict, List, Optional, Any
|
|
12
|
-
from dataclasses import dataclass, field
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
13
|
import json
|
|
14
|
+
import re
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
class ActionType(str, Enum):
|
|
@@ -345,8 +346,6 @@ class ProjectIR:
|
|
|
345
346
|
return cls.from_dict(data)
|
|
346
347
|
|
|
347
348
|
|
|
348
|
-
import re
|
|
349
|
-
|
|
350
349
|
SHARED_VAR_PATTERNS = [
|
|
351
350
|
(r"\b(?:created_id|order_id|user_id|item_id|customer_id|invoice_id)\b", DataDependencyType.ID),
|
|
352
351
|
(r"\b(?:auth_token|bearer_token|access_token|jwt_token|token)\b", DataDependencyType.AUTH_TOKEN),
|
package/tools/update_sentinel.py
CHANGED
|
@@ -65,7 +65,7 @@ def get_local_version():
|
|
|
65
65
|
try:
|
|
66
66
|
with open(pkg_file, "r", encoding="utf-8") as f:
|
|
67
67
|
data = json.load(f)
|
|
68
|
-
return data.get("version", "
|
|
68
|
+
return data.get("version", "3.0.0")
|
|
69
69
|
except (OSError, json.JSONDecodeError):
|
|
70
70
|
data = {}
|
|
71
71
|
|
|
@@ -74,11 +74,11 @@ def get_local_version():
|
|
|
74
74
|
try:
|
|
75
75
|
with open(manifest_file, "r", encoding="utf-8") as f:
|
|
76
76
|
data = json.load(f)
|
|
77
|
-
return data.get("version", "
|
|
77
|
+
return data.get("version", "3.0.0")
|
|
78
78
|
except (OSError, json.JSONDecodeError):
|
|
79
79
|
data = {}
|
|
80
80
|
|
|
81
|
-
return "
|
|
81
|
+
return "3.0.0"
|
|
82
82
|
|
|
83
83
|
|
|
84
84
|
def fetch_remote_version():
|
|
@@ -259,7 +259,6 @@ def perform_update():
|
|
|
259
259
|
git_dir = os.path.join(HUB_ROOT, ".git")
|
|
260
260
|
is_git_repo = os.path.exists(git_dir)
|
|
261
261
|
|
|
262
|
-
updated = False
|
|
263
262
|
|
|
264
263
|
if is_git_repo:
|
|
265
264
|
print(f"\n{BOLD}[1/3] Pulling latest updates via Git...{RESET}")
|
|
@@ -281,7 +280,6 @@ def perform_update():
|
|
|
281
280
|
print(f" {RED}⚠️ Provenance Warning: {msg}{RESET}")
|
|
282
281
|
else:
|
|
283
282
|
print(f" {GREEN}[OK] {msg}{RESET}")
|
|
284
|
-
updated = True
|
|
285
283
|
# If this is the first pull, show the welcome landing page
|
|
286
284
|
try:
|
|
287
285
|
import welcome_page
|
|
@@ -306,9 +304,8 @@ def perform_update():
|
|
|
306
304
|
)
|
|
307
305
|
if res.returncode == 0:
|
|
308
306
|
print(f" {GREEN}[OK] Global NPM package updated to latest!{RESET}")
|
|
309
|
-
updated = True
|
|
310
307
|
except (subprocess.SubprocessError, OSError):
|
|
311
|
-
|
|
308
|
+
_ = False
|
|
312
309
|
|
|
313
310
|
# 2. Check Global Plugin
|
|
314
311
|
if os.path.exists(GLOBAL_PLUGIN_DIR) and GLOBAL_PLUGIN_DIR.lower() != HUB_ROOT.lower():
|
|
@@ -319,7 +316,7 @@ def perform_update():
|
|
|
319
316
|
subprocess.run(["git", "pull", "--ff-only"], cwd=GLOBAL_PLUGIN_DIR, stdout=subprocess.DEVNULL, timeout=30.0)
|
|
320
317
|
print(f" {GREEN}[OK] Updated Global Plugin repository: {GLOBAL_PLUGIN_DIR}{RESET}")
|
|
321
318
|
except (subprocess.SubprocessError, OSError):
|
|
322
|
-
|
|
319
|
+
_ = False
|
|
323
320
|
else:
|
|
324
321
|
# Refresh metadata files in global plugin
|
|
325
322
|
try:
|
|
@@ -331,7 +328,7 @@ def perform_update():
|
|
|
331
328
|
shutil.copy2(src_f, dst_f)
|
|
332
329
|
print(f" {GREEN}[OK] Refreshed metadata & LICENSE in: {GLOBAL_PLUGIN_DIR}{RESET}")
|
|
333
330
|
except OSError:
|
|
334
|
-
|
|
331
|
+
_ = False
|
|
335
332
|
|
|
336
333
|
# 3. Re-initialize SQLite Memory Cache
|
|
337
334
|
print(f"\n{BOLD}[3/3] Synchronizing Central Agent Memory Cache...{RESET}")
|
package/tools/verify_all.py
CHANGED
|
@@ -136,6 +136,7 @@ def resolve_target_repo(cli_target=None, cli_repo=None):
|
|
|
136
136
|
|
|
137
137
|
|
|
138
138
|
def gate_0_author_integrity(repo_root, mode="AUTO"):
|
|
139
|
+
"""Gate 0: Verifies author attribution and MIT license provenance across source manifests."""
|
|
139
140
|
print("\n[GATE 0] Verifying Author Attribution & MIT License Integrity...")
|
|
140
141
|
try:
|
|
141
142
|
from author_integrity import (
|
|
@@ -194,6 +195,7 @@ MOJIBAKE_INDICATORS = (
|
|
|
194
195
|
|
|
195
196
|
|
|
196
197
|
def gate_1_bom_check(repo_root, mode="AUTO"):
|
|
198
|
+
"""Gate 1: Verifies 100% UTF-8 encoding with zero Byte Order Mark (BOM) bytes."""
|
|
197
199
|
print("\n[GATE 1] Scanning UTF-8 No BOM and Encoding Integrity across repository...")
|
|
198
200
|
ignored_dirs = {".git", ".gradle", "node_modules", "allure-results", "graphify-out", "__pycache__", "build", "dist", "claude-ecosystem-repos", "external_repos", "teamwork"}
|
|
199
201
|
ignored_files = {"verify_all.py", "test_tools.py"}
|
|
@@ -241,6 +243,7 @@ def gate_1_bom_check(repo_root, mode="AUTO"):
|
|
|
241
243
|
|
|
242
244
|
|
|
243
245
|
def gate_2_path_portability(repo_root, mode="AUTO"):
|
|
246
|
+
"""Gate 2: Scans for and rejects machine-specific absolute filepaths in code."""
|
|
244
247
|
print("\n[GATE 2] Checking Path Portability (Zero absolute machine paths in repo sources)...")
|
|
245
248
|
ignored_dirs = {
|
|
246
249
|
".git", ".gradle", "node_modules", "allure-results", "Screenshots",
|
|
@@ -286,6 +289,7 @@ def gate_2_path_portability(repo_root, mode="AUTO"):
|
|
|
286
289
|
|
|
287
290
|
|
|
288
291
|
def gate_3_clean_pom(repo_root, mode="AUTO"):
|
|
292
|
+
"""Gate 3: Enforces Clean Page Object Model by rejecting raw selectors in test scripts."""
|
|
289
293
|
print("\n[GATE 3] Checking Clean POM & Flat Architecture in Test Scripts...")
|
|
290
294
|
effective_mode = detect_repo_mode(repo_root) if mode == "AUTO" else mode
|
|
291
295
|
if effective_mode == "HUB":
|
|
@@ -346,6 +350,7 @@ def gate_3_clean_pom(repo_root, mode="AUTO"):
|
|
|
346
350
|
|
|
347
351
|
|
|
348
352
|
def gate_4_ai_docs_lint(repo_root, mode="AUTO"):
|
|
353
|
+
"""Gate 4: Reconciles PROJECT_AI_GUIDE.md against physical AST to prevent hallucinated classes."""
|
|
349
354
|
print("\n[GATE 4] Running AI Documentation & Artifact Linter...")
|
|
350
355
|
linter_script = os.path.join(SCRIPT_DIR, "lint_ai_docs.py")
|
|
351
356
|
if not os.path.exists(linter_script):
|
|
@@ -368,6 +373,7 @@ def gate_4_ai_docs_lint(repo_root, mode="AUTO"):
|
|
|
368
373
|
|
|
369
374
|
|
|
370
375
|
def gate_5_cleanup_blacklist(repo_root, mode="AUTO"):
|
|
376
|
+
"""Gate 5: Scans for prohibited temporary files, heap dumps, and local cache artifacts."""
|
|
371
377
|
print("\n[GATE 5] Checking Temporary File Blacklist...")
|
|
372
378
|
blacklist_patterns = [
|
|
373
379
|
os.path.join(repo_root, "Reports", "batch*.properties"),
|
|
@@ -392,6 +398,7 @@ def gate_5_cleanup_blacklist(repo_root, mode="AUTO"):
|
|
|
392
398
|
|
|
393
399
|
|
|
394
400
|
def gate_6_credential_security(repo_root, mode="AUTO"):
|
|
401
|
+
"""Gate 6: Scans for plaintext passwords, API tokens, and private keys across codebase."""
|
|
395
402
|
print("\n[GATE 6] Checking Credential Security across repository...")
|
|
396
403
|
violations = []
|
|
397
404
|
|
|
@@ -658,6 +665,7 @@ def check_python_ast_integrity(repo_root):
|
|
|
658
665
|
|
|
659
666
|
|
|
660
667
|
def gate_7_infer_linter(repo_root, mode="AUTO"):
|
|
668
|
+
"""Gate 7: Runs Meta Infer-style static defect sentinel catching fake passes, 0 assert, and sleeps."""
|
|
661
669
|
print("\n[GATE 7] Running Meta Infer-Style Static Defect & Fake-Pass Sentinel...")
|
|
662
670
|
effective_mode = detect_repo_mode(repo_root) if mode == "AUTO" else mode
|
|
663
671
|
|
|
@@ -737,6 +745,7 @@ def gate_7_infer_linter(repo_root, mode="AUTO"):
|
|
|
737
745
|
|
|
738
746
|
|
|
739
747
|
def gate_8_micro_mutator(repo_root, mode="AUTO", test_cmd=""):
|
|
748
|
+
"""Gate 8: Runs syntactic mutation testing and verifies assertion sensitivity score >= 80%."""
|
|
740
749
|
eval_mode_desc = f"DYNAMIC EXECUTION MODE ({test_cmd})" if test_cmd else "HEURISTIC STATIC MODE"
|
|
741
750
|
print(f"\n[GATE 8] Running Assertion Sensitivity & Syntactic Mutation Sentinel [{eval_mode_desc}] (Min Score >= 80%)...")
|
|
742
751
|
scripts_dir = os.path.join(repo_root, "Scripts")
|
|
@@ -797,6 +806,7 @@ def gate_8_micro_mutator(repo_root, mode="AUTO", test_cmd=""):
|
|
|
797
806
|
|
|
798
807
|
|
|
799
808
|
def gate_9_skill_collision(repo_root, mode="AUTO"):
|
|
809
|
+
"""Gate 9: Scans skill descriptions to ensure semantic trigger overlap remains < 0.75."""
|
|
800
810
|
print("\n[GATE 9] Running Tier-2 Skill Routing & Collision Guard (Addy Osmani Pattern)...")
|
|
801
811
|
guard_script = os.path.join(SCRIPT_DIR, "skill_collision_guard.py")
|
|
802
812
|
if not os.path.exists(guard_script):
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
# -*- coding: utf-8 -*-
|
|
3
|
-
from __future__ import annotations
|
|
4
3
|
"""
|
|
5
4
|
Enterprise Canvas Visual Diff & Regression Checker
|
|
6
5
|
===================================================
|
|
@@ -17,11 +16,11 @@ Usage:
|
|
|
17
16
|
python3 tools/visual_diff_canvas.py compare baseline.png current.png --diff diff.png --threshold 0.02
|
|
18
17
|
python3 tools/visual_diff_canvas.py check-blank current.png
|
|
19
18
|
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
20
|
|
|
21
21
|
import os
|
|
22
22
|
import sys
|
|
23
23
|
import argparse
|
|
24
|
-
from pathlib import Path
|
|
25
24
|
|
|
26
25
|
try:
|
|
27
26
|
from PIL import Image, ImageChops, ImageStat
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
from __future__ import annotations
|
|
3
2
|
"""
|
|
4
3
|
Enterprise Vision-Grounded Fallback & Set-of-Marks (SoM) Engine
|
|
5
4
|
--------------------------------------------------------------
|
|
@@ -20,27 +19,25 @@ Features:
|
|
|
20
19
|
4. Compact Vision Prompt Formatter:
|
|
21
20
|
- Formats token-efficient briefs for Multimodal Vision Models (Gemini / Claude / GPT-4o).
|
|
22
21
|
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
23
|
|
|
24
24
|
import os
|
|
25
25
|
import sys
|
|
26
26
|
import json
|
|
27
27
|
import sqlite3
|
|
28
28
|
import argparse
|
|
29
|
-
from typing import Dict, Any, List, Optional, Tuple
|
|
29
|
+
from typing import Dict, Any, List, Optional, Tuple
|
|
30
30
|
try:
|
|
31
31
|
from PIL import Image, ImageDraw, ImageFont, ImageFilter
|
|
32
32
|
HAS_PIL = True
|
|
33
33
|
except ImportError:
|
|
34
|
-
Image = None
|
|
35
|
-
ImageDraw = None
|
|
36
|
-
ImageFont = None
|
|
37
|
-
ImageFilter = None
|
|
34
|
+
Image = None # type: ignore[assignment]
|
|
35
|
+
ImageDraw = None # type: ignore[assignment]
|
|
36
|
+
ImageFont = None # type: ignore[assignment]
|
|
37
|
+
ImageFilter = None # type: ignore[assignment]
|
|
38
38
|
HAS_PIL = False
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
ImageSourceType = Union[Image.Image, str]
|
|
42
|
-
else:
|
|
43
|
-
ImageSourceType = Any
|
|
40
|
+
ImageSourceType = Any
|
|
44
41
|
|
|
45
42
|
# Enforce UTF-8 stdout encoding for Windows console compatibility
|
|
46
43
|
if hasattr(sys.stdout, 'reconfigure'):
|
|
@@ -74,12 +71,12 @@ def compute_visual_hash(image_source: ImageSourceType, bbox: Optional[List[int]]
|
|
|
74
71
|
if isinstance(image_source, str):
|
|
75
72
|
if not os.path.exists(image_source):
|
|
76
73
|
raise FileNotFoundError(f"Image not found: {image_source}")
|
|
77
|
-
img = Image.open(image_source)
|
|
74
|
+
img: Any = Image.open(image_source)
|
|
78
75
|
else:
|
|
79
76
|
img = image_source
|
|
80
77
|
|
|
81
78
|
if bbox and len(bbox) == 4:
|
|
82
|
-
img = img.crop(bbox)
|
|
79
|
+
img = img.crop(tuple(bbox))
|
|
83
80
|
|
|
84
81
|
# Resize to (hash_size + 1, hash_size) in grayscale
|
|
85
82
|
resized = img.convert("L").resize((hash_size + 1, hash_size), Image.Resampling.LANCZOS)
|
|
@@ -266,6 +263,8 @@ class VisualSoMHelper:
|
|
|
266
263
|
edges = img.filter(ImageFilter.FIND_EDGES)
|
|
267
264
|
edges = edges.point(lambda p: 255 if p > edge_threshold else 0)
|
|
268
265
|
pixels = edges.load()
|
|
266
|
+
if pixels is None:
|
|
267
|
+
return []
|
|
269
268
|
|
|
270
269
|
# Identify active cells with high edge density
|
|
271
270
|
active_cells = set()
|
|
@@ -274,7 +273,10 @@ class VisualSoMHelper:
|
|
|
274
273
|
count = 0
|
|
275
274
|
for cy in range(y, min(h, y + stride)):
|
|
276
275
|
for cx in range(x, min(w, x + stride)):
|
|
277
|
-
|
|
276
|
+
px_val = pixels[cx, cy]
|
|
277
|
+
if isinstance(px_val, (int, float)) and px_val > 0:
|
|
278
|
+
count += 1
|
|
279
|
+
elif isinstance(px_val, tuple) and px_val and px_val[0] > 0:
|
|
278
280
|
count += 1
|
|
279
281
|
if count >= min_pixels_per_cell:
|
|
280
282
|
active_cells.add((x // stride, y // stride))
|
package/tools/watch_mode.py
CHANGED
|
@@ -156,7 +156,7 @@ def run_incremental_audit(file_path: str, target_dir: str) -> Dict[str, Any]:
|
|
|
156
156
|
"""Performs fast sub-100ms audit on a single changed file."""
|
|
157
157
|
start_t = time.perf_counter()
|
|
158
158
|
rel_path = os.path.relpath(file_path, target_dir)
|
|
159
|
-
results = {
|
|
159
|
+
results: Dict[str, Any] = {
|
|
160
160
|
"file": rel_path,
|
|
161
161
|
"bom_pass": True,
|
|
162
162
|
"path_pass": True,
|
package/tools/wcag_audit.py
CHANGED
|
@@ -126,6 +126,18 @@ class WCAGAuditor(HTMLParser):
|
|
|
126
126
|
|
|
127
127
|
|
|
128
128
|
def run_audit(target, is_url=False):
|
|
129
|
+
"""Executes a WCAG 2.2 accessibility audit against an HTML file or live web URL.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
target: Filepath or URL pointing to HTML content.
|
|
133
|
+
is_url: True if target should be fetched over HTTP/HTTPS.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
The populated WCAGAuditor instance containing violations and stats.
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
ValueError: If an insecure or invalid URL scheme is passed.
|
|
140
|
+
"""
|
|
129
141
|
auditor = WCAGAuditor()
|
|
130
142
|
if is_url:
|
|
131
143
|
parsed = urllib.parse.urlparse(target)
|
|
@@ -143,6 +155,15 @@ def run_audit(target, is_url=False):
|
|
|
143
155
|
|
|
144
156
|
|
|
145
157
|
def format_markdown_report(target, auditor):
|
|
158
|
+
"""Generates an executive WCAG 2.2 audit markdown report.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
target: Target URL or file audited.
|
|
162
|
+
auditor: Populated WCAGAuditor instance.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Markdown string containing compliance score, grade, and violation details.
|
|
166
|
+
"""
|
|
146
167
|
violations = auditor.violations
|
|
147
168
|
stats = {
|
|
148
169
|
"elements_scanned": auditor.elements_scanned,
|
|
@@ -158,7 +179,7 @@ def format_markdown_report(target, auditor):
|
|
|
158
179
|
grade = "PASS" if score >= 80 else "NEEDS REMEDIATION"
|
|
159
180
|
|
|
160
181
|
out = [
|
|
161
|
-
|
|
182
|
+
"# WCAG 2.2 Accessibility Audit Report",
|
|
162
183
|
f"**Target:** `{target}`",
|
|
163
184
|
f"**Elements Scanned:** {stats['elements_scanned']}",
|
|
164
185
|
f"**Violations Found:** {stats['violations_found']}",
|
package/tools/welcome_page.py
CHANGED
|
@@ -28,7 +28,6 @@ import html as html_lib
|
|
|
28
28
|
import argparse
|
|
29
29
|
import webbrowser
|
|
30
30
|
from datetime import datetime
|
|
31
|
-
from pathlib import Path
|
|
32
31
|
from typing import Dict, Any, Optional
|
|
33
32
|
|
|
34
33
|
# Enforce UTF-8 stdout encoding for Windows console compatibility
|
|
@@ -48,7 +47,7 @@ def get_hub_metadata() -> Dict[str, Any]:
|
|
|
48
47
|
"""Retrieves version and catalog count from skills_manifest.json."""
|
|
49
48
|
manifest_path = os.path.join(HUB_ROOT, "skills_manifest.json")
|
|
50
49
|
meta = {
|
|
51
|
-
"version": "
|
|
50
|
+
"version": "3.0.0",
|
|
52
51
|
"totalSkills": 42,
|
|
53
52
|
"author": "Thế Vị (@TeeVee998)",
|
|
54
53
|
"repo": "https://github.com/TeeVee998/Synapse-Hub"
|
|
@@ -80,10 +79,10 @@ def generate_welcome_html(target_repo: Optional[str] = None, stack_info: Optiona
|
|
|
80
79
|
"""Renders the standalone interactive HTML5 landing page with bilingual language switcher."""
|
|
81
80
|
meta = get_hub_metadata()
|
|
82
81
|
now_str = datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
83
|
-
|
|
82
|
+
|
|
84
83
|
is_external_target = bool(target_repo and not is_hub_repo(target_repo) and stack_info)
|
|
85
84
|
if is_external_target:
|
|
86
|
-
raw_target_name = (stack_info.get("name") if stack_info else "") or os.path.basename(os.path.abspath(target_repo))
|
|
85
|
+
raw_target_name = (stack_info.get("name") if stack_info else "") or os.path.basename(os.path.abspath(target_repo or ""))
|
|
87
86
|
raw_framework = (stack_info.get("framework") if stack_info else "") or "Automated QA"
|
|
88
87
|
target_name = html_lib.escape(str(raw_target_name))
|
|
89
88
|
framework_name = html_lib.escape(str(raw_framework))
|
|
@@ -248,7 +247,7 @@ def generate_welcome_page(target_repo: Optional[str] = None, output_path: Option
|
|
|
248
247
|
if open_browser:
|
|
249
248
|
opened = open_welcome_page(out_file)
|
|
250
249
|
if opened:
|
|
251
|
-
print(
|
|
250
|
+
print(" 🚀 Đã mở Visual Landing Page trong trình duyệt của bạn.")
|
|
252
251
|
return out_file
|
|
253
252
|
|
|
254
253
|
|
|
@@ -284,7 +283,7 @@ def main():
|
|
|
284
283
|
mark_first_run_completed(target_dir)
|
|
285
284
|
|
|
286
285
|
print(f"✅ Landing page generated successfully at:\n file://{out_path}")
|
|
287
|
-
print(
|
|
286
|
+
print("👉 You can re-open it anytime with: npx synapse-hub welcome")
|
|
288
287
|
|
|
289
288
|
|
|
290
289
|
if __name__ == "__main__":
|