qa-helper-cli 0.1.0__tar.gz

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.
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.4
2
+ Name: qa-helper-cli
3
+ Version: 0.1.0
4
+ Summary: Execute QA test cases with AI-powered browser automation
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: httpx>=0.24
8
+ Requires-Dist: python-dotenv>=1.0
9
+ Requires-Dist: browser-use>=0.1
10
+
11
+ # qa-helper-cli
12
+
13
+ Execute AI-generated test cases against your app using browser automation.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install qa-helper-cli
19
+ playwright install chromium
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ```bash
25
+ # Authenticate with your QA Helper account
26
+ qa-helper login
27
+
28
+ # List all TCs in a markdown file (no login needed)
29
+ qa-helper testcases.md --list
30
+
31
+ # Run a specific TC
32
+ qa-helper testcases.md --tc TC-001
33
+
34
+ # Run multiple TCs
35
+ qa-helper testcases.md --tc TC-001 TC-003
36
+
37
+ # Run all TCs
38
+ qa-helper testcases.md
39
+
40
+ # Run against a specific app URL
41
+ qa-helper testcases.md --url https://staging.myapp.com
42
+
43
+ # Run with visible browser
44
+ qa-helper testcases.md --headed
45
+
46
+ # Clear credentials
47
+ qa-helper logout
48
+ ```
49
+
50
+ ## Requirements
51
+
52
+ - Python 3.10+
53
+ - A QA Helper account at [qa-helper.xyz](https://www.qa-helper.xyz)
@@ -0,0 +1,43 @@
1
+ # qa-helper-cli
2
+
3
+ Execute AI-generated test cases against your app using browser automation.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install qa-helper-cli
9
+ playwright install chromium
10
+ ```
11
+
12
+ ## Usage
13
+
14
+ ```bash
15
+ # Authenticate with your QA Helper account
16
+ qa-helper login
17
+
18
+ # List all TCs in a markdown file (no login needed)
19
+ qa-helper testcases.md --list
20
+
21
+ # Run a specific TC
22
+ qa-helper testcases.md --tc TC-001
23
+
24
+ # Run multiple TCs
25
+ qa-helper testcases.md --tc TC-001 TC-003
26
+
27
+ # Run all TCs
28
+ qa-helper testcases.md
29
+
30
+ # Run against a specific app URL
31
+ qa-helper testcases.md --url https://staging.myapp.com
32
+
33
+ # Run with visible browser
34
+ qa-helper testcases.md --headed
35
+
36
+ # Clear credentials
37
+ qa-helper logout
38
+ ```
39
+
40
+ ## Requirements
41
+
42
+ - Python 3.10+
43
+ - A QA Helper account at [qa-helper.xyz](https://www.qa-helper.xyz)
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "qa-helper-cli"
7
+ version = "0.1.0"
8
+ description = "Execute QA test cases with AI-powered browser automation"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "httpx>=0.24",
13
+ "python-dotenv>=1.0",
14
+ "browser-use>=0.1",
15
+ ]
16
+
17
+ [project.scripts]
18
+ qa-helper = "qa_helper.cli:main"
19
+
20
+ [tool.setuptools.packages.find]
21
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """QA Helper CLI — Execute test cases with AI-powered browser automation."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,84 @@
1
+ """Auth module — device code login flow."""
2
+
3
+ import asyncio
4
+ import sys
5
+ import webbrowser
6
+
7
+ import httpx
8
+
9
+ from .config import load_config, save_config
10
+
11
+ SERVER_URL = "https://www.qa-helper.xyz"
12
+ POLL_INTERVAL = 2 # seconds between polls
13
+ POLL_TIMEOUT = 300 # 5 minutes max wait
14
+
15
+
16
+ async def login() -> None:
17
+ """Start device code login flow.
18
+
19
+ Opens browser to authenticate with the QA Helper server.
20
+ Saves access token to ~/.qa-helper/config.json on success.
21
+ """
22
+ async with httpx.AsyncClient(timeout=15.0) as client:
23
+ response = await client.post(f"{SERVER_URL}/api/cli/auth/start")
24
+
25
+ if response.status_code != 200:
26
+ print(f"Error: Could not start login ({response.status_code}). Is the server reachable?", file=sys.stderr)
27
+ sys.exit(1)
28
+
29
+ data = response.json()
30
+ device_code = data["device_code"]
31
+ login_url = data["login_url"]
32
+
33
+ print(f"\nOpening browser for login...")
34
+ print(f"If it didn't open, visit: {login_url}\n")
35
+ webbrowser.open(login_url)
36
+
37
+ print("Waiting for authentication", end="", flush=True)
38
+
39
+ elapsed = 0
40
+ while elapsed < POLL_TIMEOUT:
41
+ await asyncio.sleep(POLL_INTERVAL)
42
+ elapsed += POLL_INTERVAL
43
+ print(".", end="", flush=True)
44
+
45
+ async with httpx.AsyncClient(timeout=15.0) as client:
46
+ poll = await client.post(
47
+ f"{SERVER_URL}/api/cli/auth/poll",
48
+ json={"device_code": device_code},
49
+ )
50
+
51
+ if poll.status_code != 200:
52
+ continue
53
+
54
+ result = poll.json()
55
+ status = result.get("status")
56
+
57
+ if status == "confirmed":
58
+ access_token = result["access_token"]
59
+ config = load_config()
60
+ config["server_url"] = SERVER_URL
61
+ config["access_token"] = access_token
62
+ save_config(config)
63
+ print(f"\nLogged in successfully.\n")
64
+ return
65
+
66
+ if status == "expired":
67
+ print(f"\nLogin timed out. Run 'qa-helper login' to try again.", file=sys.stderr)
68
+ sys.exit(1)
69
+
70
+ # status == "pending" — keep polling
71
+
72
+ print(f"\nLogin timed out after {POLL_TIMEOUT // 60} minutes.", file=sys.stderr)
73
+ sys.exit(1)
74
+
75
+
76
+ def logout() -> None:
77
+ """Clear saved access token from config."""
78
+ config = load_config()
79
+ if not config.get("access_token"):
80
+ print("Not logged in.")
81
+ return
82
+ config.pop("access_token", None)
83
+ save_config(config)
84
+ print("Logged out.")
@@ -0,0 +1,65 @@
1
+ """Browser-use CLI wrapper — thin shell around browser-use subprocess commands."""
2
+
3
+ import subprocess
4
+
5
+
6
+ class BrowserUseCLI:
7
+ """Wrapper around browser-use CLI commands."""
8
+
9
+ def __init__(self, venv_path: str = ".venv", headed: bool = False):
10
+ self.venv_path = venv_path
11
+ self.headed = headed
12
+ self.activate_cmd = f"source {venv_path}/bin/activate && "
13
+
14
+ def run(self, command: str, capture_output: bool = True) -> dict:
15
+ """Execute a browser-use command via subprocess."""
16
+ headed_flag = "--headed " if self.headed else ""
17
+ full_cmd = f"{self.activate_cmd} browser-use {headed_flag}{command}"
18
+ result = subprocess.run(
19
+ full_cmd,
20
+ shell=True,
21
+ capture_output=capture_output,
22
+ text=True,
23
+ timeout=30,
24
+ )
25
+
26
+ return {
27
+ "command": command,
28
+ "exit_code": result.returncode,
29
+ "stdout": result.stdout.strip() if result.stdout else "",
30
+ "stderr": result.stderr.strip() if result.stderr else "",
31
+ }
32
+
33
+ def open(self, url: str) -> dict:
34
+ """Open URL in browser."""
35
+ return self.run(f"open {url}")
36
+
37
+ def state(self) -> dict:
38
+ """Get current page state."""
39
+ return self.run("state")
40
+
41
+ def click(self, index: int) -> dict:
42
+ """Click element by index."""
43
+ return self.run(f"click {index}")
44
+
45
+ def input(self, index: int, text: str) -> dict:
46
+ """Input text into element."""
47
+ # Escape special shell characters in text
48
+ escaped_text = text.replace('"', '\\"').replace("'", "\\'").replace("`", "\\`")
49
+ return self.run(f'input {index} "{escaped_text}"')
50
+
51
+ def upload(self, index: int, file_path: str) -> dict:
52
+ """Upload file to input element."""
53
+ return self.run(f'upload {index} "{file_path}"')
54
+
55
+ def scroll_down(self) -> dict:
56
+ """Scroll page down."""
57
+ return self.run("scroll down")
58
+
59
+ def screenshot(self, path: str) -> dict:
60
+ """Take screenshot."""
61
+ return self.run(f"screenshot {path}")
62
+
63
+ def close(self) -> dict:
64
+ """Close browser."""
65
+ return self.run("close")
@@ -0,0 +1,189 @@
1
+ """CLI entry point — argparse-based command dispatcher for qa-helper."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import json
6
+ import os
7
+ import sys
8
+ from datetime import datetime
9
+
10
+ from .parser import parse_tcs_from_markdown
11
+ from .llm import NotLoggedInError
12
+
13
+
14
+ def cmd_list(md_file: str) -> None:
15
+ """Parse and print all TCs from a markdown file."""
16
+ if not os.path.exists(md_file):
17
+ print(f"Error: File not found: {md_file}", file=sys.stderr)
18
+ sys.exit(1)
19
+
20
+ tcs = parse_tcs_from_markdown(md_file)
21
+ if not tcs:
22
+ print(f"No TCs found in {md_file}")
23
+ sys.exit(0)
24
+
25
+ print(f"\nFound {len(tcs)} test cases in {md_file}\n")
26
+ for tc in tcs:
27
+ print(f" {tc['id']:8s} [{tc['priority']:6s}] {tc['category']:20s} {tc['scenario']}")
28
+ print()
29
+
30
+
31
+ async def cmd_execute(md_file: str, tc_ids: list[str] | None, app_url: str, headed: bool, venv: str) -> None:
32
+ """Execute test cases from a markdown file."""
33
+ from .config import load_config
34
+ from .executor import execute_tc
35
+ from .interpreter import StepInterpreter
36
+ from .report import generate_html_report
37
+
38
+ if not os.path.exists(md_file):
39
+ print(f"Error: File not found: {md_file}", file=sys.stderr)
40
+ sys.exit(1)
41
+
42
+ config = load_config()
43
+
44
+ if not config.get("access_token"):
45
+ print("Error: Not logged in. Run: qa-helper login", file=sys.stderr)
46
+ sys.exit(1)
47
+
48
+ all_tcs = parse_tcs_from_markdown(md_file)
49
+ if not all_tcs:
50
+ print(f"No TCs found in {md_file}")
51
+ sys.exit(1)
52
+
53
+ print(f"\nFound {len(all_tcs)} TCs in {md_file}")
54
+
55
+ if tc_ids:
56
+ ids_upper = [t.upper() for t in tc_ids]
57
+ tcs = [tc for tc in all_tcs if tc["id"] in ids_upper]
58
+ missing = set(ids_upper) - {tc["id"] for tc in tcs}
59
+ if missing:
60
+ print(f"Error: TCs not found: {', '.join(sorted(missing))}", file=sys.stderr)
61
+ sys.exit(1)
62
+ else:
63
+ tcs = all_tcs
64
+
65
+ interpreter = StepInterpreter(headed=headed, venv_path=venv, config=config)
66
+
67
+ run_ts = datetime.now().strftime('%Y%m%d_%H%M%S')
68
+ output_dir = os.path.join("qa_run_result", f"run_{run_ts}")
69
+ screenshots_dir = os.path.join(output_dir, "screenshots")
70
+ os.makedirs(screenshots_dir, exist_ok=True)
71
+
72
+ print(f"\nApp URL: {app_url}")
73
+ print(f"TCs: {', '.join(tc['id'] for tc in tcs)}")
74
+ print(f"Screenshots: {screenshots_dir}/")
75
+ print(f"{'=' * 60}")
76
+
77
+ results = []
78
+ for tc in tcs:
79
+ result = await execute_tc(tc, app_url, interpreter, screenshots_dir)
80
+ results.append(result)
81
+
82
+ interpreter.browser.close()
83
+
84
+ passed = sum(1 for r in results if r["status"] == "PASS")
85
+ healed = sum(1 for r in results if r["status"] == "HEALED")
86
+ failed = sum(1 for r in results if r["status"] in ("FAIL", "ERROR"))
87
+
88
+ print(f"\n{'=' * 60}")
89
+ print(" SUMMARY")
90
+ print(f"{'=' * 60}")
91
+ for r in results:
92
+ icon = {"PASS": "PASS", "HEALED": "HEALED", "FAIL": "FAIL"}.get(r["status"], "?")
93
+ print(f" [{icon}] {r['tc_id']:8s} {r['scenario'][:50]}")
94
+ print(f"\n {passed} Pass {healed} Healed {failed} Fail")
95
+ print(f"{'=' * 60}")
96
+
97
+ now = datetime.now().isoformat()
98
+
99
+ merged_selectors: dict = {}
100
+ for r in results:
101
+ for page_path, elements in r.get("selector_map", {}).items():
102
+ if page_path not in merged_selectors:
103
+ merged_selectors[page_path] = []
104
+ merged_selectors[page_path].extend(elements)
105
+
106
+ selectors_file = os.path.join(output_dir, "selectors.json")
107
+ with open(selectors_file, "w") as f:
108
+ json.dump({"app_url": app_url, "timestamp": now, "pages": merged_selectors}, f, indent=2)
109
+
110
+ json_report = os.path.join(output_dir, f"tc_report_{run_ts}.json")
111
+ with open(json_report, "w") as f:
112
+ json.dump({
113
+ "timestamp": now,
114
+ "source_file": md_file,
115
+ "app_url": app_url,
116
+ "summary": {"passed": passed, "healed": healed, "failed": failed},
117
+ "results": results,
118
+ }, f, indent=2)
119
+
120
+ html_report = os.path.join(output_dir, f"tc_report_{run_ts}.html")
121
+ html = generate_html_report(results, {
122
+ "timestamp": now,
123
+ "source_file": md_file,
124
+ "passed": passed,
125
+ "healed": healed,
126
+ "failed": failed,
127
+ })
128
+ with open(html_report, "w") as f:
129
+ f.write(html)
130
+
131
+ print(f"\n JSON report: {json_report}")
132
+ print(f" HTML report: {html_report} <- open this in a browser")
133
+ print(f" Selectors: {selectors_file}")
134
+
135
+
136
+ def main() -> None:
137
+ """Main CLI entry point."""
138
+ # Detect subcommand before argparse to avoid conflict with md_file positional
139
+ if len(sys.argv) >= 2 and sys.argv[1] == "login":
140
+ from . import auth
141
+ try:
142
+ asyncio.run(auth.login())
143
+ except NotImplementedError as e:
144
+ print(f"Not yet available: {e}", file=sys.stderr)
145
+ sys.exit(1)
146
+ return
147
+
148
+ if len(sys.argv) >= 2 and sys.argv[1] == "logout":
149
+ from . import auth
150
+ try:
151
+ auth.logout()
152
+ except NotImplementedError as e:
153
+ print(f"Not yet available: {e}", file=sys.stderr)
154
+ sys.exit(1)
155
+ return
156
+
157
+ parser = argparse.ArgumentParser(
158
+ prog="qa-helper",
159
+ description="Execute QA test cases with AI-powered browser automation",
160
+ usage="qa-helper [login|logout] | qa-helper <file.md> [--list] [--tc TC-001 ...] [--url URL] [--headed]",
161
+ )
162
+ parser.add_argument("md_file", help="Path to markdown file containing test cases")
163
+ parser.add_argument("--list", action="store_true", help="List TCs and exit (no server required)")
164
+ parser.add_argument("--tc", nargs="*", dest="tc_ids", metavar="TC_ID", help="Specific TC IDs to run (e.g. TC-001 TC-003)")
165
+ parser.add_argument("--url", default="http://localhost:3000", metavar="URL", help="App URL to test against (default: http://localhost:3000)")
166
+ parser.add_argument("--headed", action="store_true", help="Run browser in visible window")
167
+ parser.add_argument("--venv", default=".venv", metavar="PATH", help="Virtual environment path for browser-use (default: .venv)")
168
+
169
+ args = parser.parse_args()
170
+
171
+ if args.list:
172
+ cmd_list(args.md_file)
173
+ return
174
+
175
+ try:
176
+ asyncio.run(cmd_execute(
177
+ md_file=args.md_file,
178
+ tc_ids=args.tc_ids,
179
+ app_url=args.url,
180
+ headed=args.headed,
181
+ venv=args.venv,
182
+ ))
183
+ except NotLoggedInError as e:
184
+ print(f"\nError: {e}", file=sys.stderr)
185
+ sys.exit(1)
186
+
187
+
188
+ if __name__ == "__main__":
189
+ main()
@@ -0,0 +1,30 @@
1
+ """Config management — read/write ~/.qa-helper/config.json."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+
7
+
8
+ def get_config_path() -> Path:
9
+ """Return path to config file."""
10
+ return Path.home() / ".qa-helper" / "config.json"
11
+
12
+
13
+ def load_config() -> dict:
14
+ """Load config from ~/.qa-helper/config.json. Returns empty dict if not found."""
15
+ path = get_config_path()
16
+ if not path.exists():
17
+ return {}
18
+ try:
19
+ with open(path) as f:
20
+ return json.load(f)
21
+ except (json.JSONDecodeError, OSError):
22
+ return {}
23
+
24
+
25
+ def save_config(config: dict) -> None:
26
+ """Write config to ~/.qa-helper/config.json, creating directory if needed."""
27
+ path = get_config_path()
28
+ path.parent.mkdir(parents=True, exist_ok=True)
29
+ with open(path, "w") as f:
30
+ json.dump(config, f, indent=2)