@biggora/claude-plugins 1.0.0 โ 1.1.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/.claude/settings.local.json +13 -0
- package/CLAUDE.md +55 -0
- package/LICENSE +1 -1
- package/README.md +208 -39
- package/bin/cli.js +39 -0
- package/package.json +30 -17
- package/registry/registry.json +166 -1
- package/registry/schema.json +10 -0
- package/src/commands/skills/add.js +194 -0
- package/src/commands/skills/list.js +52 -0
- package/src/commands/skills/remove.js +27 -0
- package/src/commands/skills/update.js +74 -0
- package/src/config.js +5 -0
- package/src/skills/codex-cli/SKILL.md +265 -0
- package/src/skills/commafeed-api/SKILL.md +1012 -0
- package/src/skills/gemini-cli/SKILL.md +379 -0
- package/src/skills/gemini-cli/references/commands.md +145 -0
- package/src/skills/gemini-cli/references/configuration.md +182 -0
- package/src/skills/gemini-cli/references/headless-and-scripting.md +181 -0
- package/src/skills/gemini-cli/references/mcp-and-extensions.md +254 -0
- package/src/skills/n8n-api/SKILL.md +623 -0
- package/src/skills/notebook-lm/SKILL.md +217 -0
- package/src/skills/notebook-lm/references/artifact-options.md +168 -0
- package/src/skills/notebook-lm/references/auth.md +58 -0
- package/src/skills/notebook-lm/references/workflows.md +144 -0
- package/src/skills/screen-recording/SKILL.md +309 -0
- package/src/skills/screen-recording/references/approach1-programmatic.md +311 -0
- package/src/skills/screen-recording/references/approach2-xvfb.md +232 -0
- package/src/skills/screen-recording/references/design-patterns.md +168 -0
- package/src/skills/test-mobile-app/SKILL.md +212 -0
- package/src/skills/test-mobile-app/references/report-template.md +95 -0
- package/src/skills/test-mobile-app/references/setup-appium.md +154 -0
- package/src/skills/test-mobile-app/scripts/analyze_apk.py +164 -0
- package/src/skills/test-mobile-app/scripts/check_environment.py +116 -0
- package/src/skills/test-mobile-app/scripts/generate_report.py +250 -0
- package/src/skills/test-mobile-app/scripts/run_tests.py +326 -0
- package/src/skills/test-web-ui/SKILL.md +232 -0
- package/src/skills/test-web-ui/references/test_case_schema.md +102 -0
- package/src/skills/test-web-ui/scripts/discover.py +176 -0
- package/src/skills/test-web-ui/scripts/generate_report.py +237 -0
- package/src/skills/test-web-ui/scripts/run_tests.py +296 -0
- package/src/skills/text-to-speech/SKILL.md +236 -0
- package/src/skills/text-to-speech/references/espeak-cli.md +277 -0
- package/src/skills/text-to-speech/references/kokoro-onnx.md +124 -0
- package/src/skills/text-to-speech/references/online-engines.md +128 -0
- package/src/skills/text-to-speech/references/pyttsx3-espeak.md +143 -0
- package/src/skills/tm-search/SKILL.md +240 -0
- package/src/skills/tm-search/references/field-guide.md +79 -0
- package/src/skills/tm-search/references/scraping-fallback.md +140 -0
- package/src/skills/tm-search/scripts/tm_search.py +375 -0
- package/src/skills/wp-rest-api/SKILL.md +114 -0
- package/src/skills/wp-rest-api/references/authentication.md +18 -0
- package/src/skills/wp-rest-api/references/custom-content-types.md +20 -0
- package/src/skills/wp-rest-api/references/discovery-and-params.md +20 -0
- package/src/skills/wp-rest-api/references/responses-and-fields.md +30 -0
- package/src/skills/wp-rest-api/references/routes-and-endpoints.md +36 -0
- package/src/skills/wp-rest-api/references/schema.md +22 -0
- package/src/skills/youtube-search/SKILL.md +412 -0
- package/src/skills/youtube-search/references/parsing-examples.md +159 -0
- package/src/skills/youtube-search/references/youtube-api-quota.md +85 -0
- package/src/skills/youtube-thumbnail/SKILL.md +1060 -0
- package/tests/commands/info.test.js +49 -0
- package/tests/commands/install.test.js +36 -0
- package/tests/commands/list.test.js +66 -0
- package/tests/commands/publish.test.js +182 -0
- package/tests/commands/search.test.js +45 -0
- package/tests/commands/uninstall.test.js +29 -0
- package/tests/commands/update.test.js +59 -0
- package/tests/functional/skills-lifecycle.test.js +293 -0
- package/tests/helpers/fixtures.js +63 -0
- package/tests/integration/cli.test.js +83 -0
- package/tests/skills/add.test.js +138 -0
- package/tests/skills/list.test.js +63 -0
- package/tests/skills/remove.test.js +38 -0
- package/tests/skills/update.test.js +60 -0
- package/tests/unit/config.test.js +31 -0
- package/tests/unit/registry.test.js +79 -0
- package/tests/unit/utils.test.js +150 -0
- package/tests/validation/registry-schema.test.js +112 -0
- package/tests/validation/skills-validation.test.js +96 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Web Tester - Site Discovery
|
|
4
|
+
Auto-crawls a website to discover pages, forms, links, and interactive elements.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
import os
|
|
10
|
+
import argparse
|
|
11
|
+
from urllib.parse import urljoin, urlparse
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from playwright.sync_api import sync_playwright
|
|
15
|
+
except ImportError:
|
|
16
|
+
print("ERROR: playwright not installed.")
|
|
17
|
+
sys.exit(1)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def discover_site(url, max_pages=10):
|
|
21
|
+
"""Crawl site and return structured discovery data."""
|
|
22
|
+
parsed = urlparse(url)
|
|
23
|
+
base_domain = f"{parsed.scheme}://{parsed.netloc}"
|
|
24
|
+
|
|
25
|
+
visited = set()
|
|
26
|
+
to_visit = [url]
|
|
27
|
+
pages = []
|
|
28
|
+
|
|
29
|
+
with sync_playwright() as p:
|
|
30
|
+
browser = p.chromium.launch(args=['--no-sandbox', '--disable-dev-shm-usage'])
|
|
31
|
+
context = browser.new_context(viewport={'width': 1280, 'height': 800})
|
|
32
|
+
page = context.new_page()
|
|
33
|
+
|
|
34
|
+
while to_visit and len(visited) < max_pages:
|
|
35
|
+
current_url = to_visit.pop(0)
|
|
36
|
+
if current_url in visited:
|
|
37
|
+
continue
|
|
38
|
+
visited.add(current_url)
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
page.goto(current_url, wait_until='domcontentloaded', timeout=15000)
|
|
42
|
+
page.wait_for_load_state('networkidle', timeout=8000)
|
|
43
|
+
except Exception as e:
|
|
44
|
+
print(f" SKIP {current_url}: {e}")
|
|
45
|
+
continue
|
|
46
|
+
|
|
47
|
+
page_data = {
|
|
48
|
+
'url': current_url,
|
|
49
|
+
'title': page.title(),
|
|
50
|
+
'headings': [],
|
|
51
|
+
'links': [],
|
|
52
|
+
'forms': [],
|
|
53
|
+
'buttons': [],
|
|
54
|
+
'images': [],
|
|
55
|
+
'nav_items': [],
|
|
56
|
+
'console_errors': [],
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# Collect console errors
|
|
60
|
+
console_errors = []
|
|
61
|
+
page.on('console', lambda msg: console_errors.append(msg.text) if msg.type == 'error' else None)
|
|
62
|
+
|
|
63
|
+
# Headings
|
|
64
|
+
for tag in ['h1', 'h2', 'h3']:
|
|
65
|
+
elements = page.locator(tag).all()
|
|
66
|
+
for el in elements[:5]:
|
|
67
|
+
try:
|
|
68
|
+
text = el.text_content().strip()
|
|
69
|
+
if text:
|
|
70
|
+
page_data['headings'].append({'tag': tag, 'text': text[:100]})
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
# Navigation
|
|
75
|
+
nav_els = page.locator('nav a, header a, [role=navigation] a').all()
|
|
76
|
+
for el in nav_els[:20]:
|
|
77
|
+
try:
|
|
78
|
+
text = el.text_content().strip()
|
|
79
|
+
href = el.get_attribute('href') or ''
|
|
80
|
+
if text and href:
|
|
81
|
+
page_data['nav_items'].append({'text': text, 'href': href})
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
# All links
|
|
86
|
+
links = page.locator('a[href]').all()
|
|
87
|
+
for link in links[:50]:
|
|
88
|
+
try:
|
|
89
|
+
href = link.get_attribute('href') or ''
|
|
90
|
+
text = link.text_content().strip()
|
|
91
|
+
if href and not href.startswith('#') and not href.startswith('javascript'):
|
|
92
|
+
full_url = urljoin(current_url, href)
|
|
93
|
+
page_data['links'].append({'text': text[:60], 'href': full_url})
|
|
94
|
+
if base_domain in full_url and full_url not in visited:
|
|
95
|
+
to_visit.append(full_url)
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
# Forms
|
|
100
|
+
forms = page.locator('form').all()
|
|
101
|
+
for form in forms:
|
|
102
|
+
try:
|
|
103
|
+
inputs = form.locator('input, textarea, select').all()
|
|
104
|
+
buttons = form.locator('button, input[type=submit]').all()
|
|
105
|
+
form_data = {
|
|
106
|
+
'action': form.get_attribute('action') or '',
|
|
107
|
+
'method': form.get_attribute('method') or 'GET',
|
|
108
|
+
'inputs': [],
|
|
109
|
+
'submit_buttons': [],
|
|
110
|
+
}
|
|
111
|
+
for inp in inputs[:10]:
|
|
112
|
+
inp_type = inp.get_attribute('type') or 'text'
|
|
113
|
+
inp_name = inp.get_attribute('name') or inp.get_attribute('id') or ''
|
|
114
|
+
inp_placeholder = inp.get_attribute('placeholder') or ''
|
|
115
|
+
form_data['inputs'].append({
|
|
116
|
+
'type': inp_type,
|
|
117
|
+
'name': inp_name,
|
|
118
|
+
'placeholder': inp_placeholder,
|
|
119
|
+
})
|
|
120
|
+
for btn in buttons[:3]:
|
|
121
|
+
btn_text = btn.text_content().strip() or btn.get_attribute('value') or ''
|
|
122
|
+
form_data['submit_buttons'].append(btn_text)
|
|
123
|
+
page_data['forms'].append(form_data)
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
# Buttons
|
|
128
|
+
buttons = page.locator('button:visible, [role=button]:visible').all()
|
|
129
|
+
for btn in buttons[:15]:
|
|
130
|
+
try:
|
|
131
|
+
text = btn.text_content().strip()
|
|
132
|
+
if text:
|
|
133
|
+
page_data['buttons'].append(text[:60])
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
# Images
|
|
138
|
+
images = page.locator('img').all()
|
|
139
|
+
broken = 0
|
|
140
|
+
for img in images:
|
|
141
|
+
try:
|
|
142
|
+
natural_w = img.evaluate('el => el.naturalWidth')
|
|
143
|
+
if natural_w == 0:
|
|
144
|
+
broken += 1
|
|
145
|
+
except Exception:
|
|
146
|
+
pass
|
|
147
|
+
page_data['images'] = {'total': len(images), 'broken': broken}
|
|
148
|
+
|
|
149
|
+
page_data['console_errors'] = console_errors[:5]
|
|
150
|
+
pages.append(page_data)
|
|
151
|
+
print(f" โ Discovered: {current_url} ({page.title()})")
|
|
152
|
+
|
|
153
|
+
browser.close()
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
'base_url': url,
|
|
157
|
+
'pages_found': len(pages),
|
|
158
|
+
'pages': pages,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
if __name__ == '__main__':
|
|
163
|
+
parser = argparse.ArgumentParser()
|
|
164
|
+
parser.add_argument('--url', required=True)
|
|
165
|
+
parser.add_argument('--max-pages', type=int, default=10)
|
|
166
|
+
parser.add_argument('--output', default='/home/claude/discovery.json')
|
|
167
|
+
args = parser.parse_args()
|
|
168
|
+
|
|
169
|
+
print(f"\nDiscovering: {args.url}")
|
|
170
|
+
result = discover_site(args.url, args.max_pages)
|
|
171
|
+
|
|
172
|
+
with open(args.output, 'w', encoding='utf-8') as f:
|
|
173
|
+
json.dump(result, f, ensure_ascii=False, indent=2)
|
|
174
|
+
|
|
175
|
+
print(f"\nDiscovery complete: {result['pages_found']} pages")
|
|
176
|
+
print(f"Saved to: {args.output}")
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Web Tester - HTML Report Generator
|
|
4
|
+
Produces a self-contained HTML QA report from test results.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import base64
|
|
11
|
+
import argparse
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_screenshot_b64(path):
|
|
17
|
+
if not path or not os.path.exists(path):
|
|
18
|
+
return None
|
|
19
|
+
with open(path, 'rb') as f:
|
|
20
|
+
return base64.b64encode(f.read()).decode('utf-8')
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def status_badge(status):
|
|
24
|
+
colors = {'PASS': '#22c55e', 'FAIL': '#ef4444', 'SKIP': '#f59e0b', 'WARN': '#f97316'}
|
|
25
|
+
color = colors.get(status, '#6b7280')
|
|
26
|
+
return f'<span style="background:{color};color:#fff;padding:2px 10px;border-radius:4px;font-size:12px;font-weight:700">{status}</span>'
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def generate_report(results_path, screenshots_dir, output_path):
|
|
30
|
+
with open(results_path, 'r', encoding='utf-8') as f:
|
|
31
|
+
data = json.load(f)
|
|
32
|
+
|
|
33
|
+
meta = data['meta']
|
|
34
|
+
results = data['results']
|
|
35
|
+
|
|
36
|
+
total = meta.get('total', len(results))
|
|
37
|
+
passed = meta.get('passed', 0)
|
|
38
|
+
failed = meta.get('failed', 0)
|
|
39
|
+
skipped = meta.get('skipped', 0)
|
|
40
|
+
pass_rate = meta.get('pass_rate', 0)
|
|
41
|
+
url = meta.get('url', 'N/A')
|
|
42
|
+
run_at = meta.get('run_at', datetime.now().isoformat())
|
|
43
|
+
site_name = meta.get('site_name', url)
|
|
44
|
+
|
|
45
|
+
# Status color for header
|
|
46
|
+
rate_color = '#22c55e' if pass_rate >= 80 else '#f97316' if pass_rate >= 50 else '#ef4444'
|
|
47
|
+
|
|
48
|
+
# Build test rows
|
|
49
|
+
rows_html = ''
|
|
50
|
+
screenshots_html = ''
|
|
51
|
+
|
|
52
|
+
for r in results:
|
|
53
|
+
status = r.get('status', 'UNKNOWN')
|
|
54
|
+
errors = r.get('errors', [])
|
|
55
|
+
warnings = r.get('warnings', [])
|
|
56
|
+
console_errors = r.get('console_errors', [])
|
|
57
|
+
duration = r.get('duration_ms', 0)
|
|
58
|
+
assertions = r.get('assertions_total', 0)
|
|
59
|
+
assertions_passed = r.get('assertions_passed', 0)
|
|
60
|
+
|
|
61
|
+
error_text = '<br>'.join(f'โ {e}' for e in errors)
|
|
62
|
+
warning_text = '<br>'.join(f'โ ๏ธ {w}' for w in warnings)
|
|
63
|
+
detail_text = error_text + ('<br>' if error_text and warning_text else '') + warning_text
|
|
64
|
+
if not detail_text:
|
|
65
|
+
detail_text = 'โ'
|
|
66
|
+
|
|
67
|
+
rows_html += f"""
|
|
68
|
+
<tr>
|
|
69
|
+
<td style="font-family:monospace;font-size:12px;color:#64748b">{r.get('id','')}</td>
|
|
70
|
+
<td style="font-weight:500">{r.get('name','')}</td>
|
|
71
|
+
<td style="text-align:center">{status_badge(status)}</td>
|
|
72
|
+
<td style="text-align:center;color:#64748b;font-size:13px">{assertions_passed}/{assertions}</td>
|
|
73
|
+
<td style="text-align:center;color:#64748b;font-size:13px">{duration}ms</td>
|
|
74
|
+
<td style="font-size:12px;color:#64748b;max-width:300px">{detail_text}</td>
|
|
75
|
+
</tr>
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
# Screenshots section
|
|
79
|
+
before_b64 = load_screenshot_b64(r.get('screenshot_before'))
|
|
80
|
+
after_b64 = load_screenshot_b64(r.get('screenshot_after'))
|
|
81
|
+
|
|
82
|
+
if before_b64 or after_b64:
|
|
83
|
+
bg = '#fef2f2' if status == 'FAIL' else '#f0fdf4'
|
|
84
|
+
screenshots_html += f"""
|
|
85
|
+
<div style="border:1px solid #e2e8f0;border-radius:8px;padding:16px;margin-bottom:16px;background:{bg}">
|
|
86
|
+
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
|
|
87
|
+
{status_badge(status)}
|
|
88
|
+
<strong style="font-size:14px">{r.get('id','')} โ {r.get('name','')}</strong>
|
|
89
|
+
</div>
|
|
90
|
+
"""
|
|
91
|
+
if errors:
|
|
92
|
+
screenshots_html += f'<div style="color:#ef4444;font-size:12px;margin-bottom:10px">' + '<br>'.join(errors) + '</div>'
|
|
93
|
+
|
|
94
|
+
screenshots_html += '<div style="display:flex;gap:16px;flex-wrap:wrap">'
|
|
95
|
+
if before_b64:
|
|
96
|
+
screenshots_html += f"""
|
|
97
|
+
<div>
|
|
98
|
+
<div style="font-size:11px;color:#64748b;margin-bottom:4px;text-transform:uppercase;letter-spacing:0.5px">Before</div>
|
|
99
|
+
<img src="data:image/png;base64,{before_b64}" style="max-width:580px;width:100%;border:1px solid #e2e8f0;border-radius:4px">
|
|
100
|
+
</div>"""
|
|
101
|
+
if after_b64:
|
|
102
|
+
screenshots_html += f"""
|
|
103
|
+
<div>
|
|
104
|
+
<div style="font-size:11px;color:#64748b;margin-bottom:4px;text-transform:uppercase;letter-spacing:0.5px">After interaction</div>
|
|
105
|
+
<img src="data:image/png;base64,{after_b64}" style="max-width:580px;width:100%;border:1px solid #e2e8f0;border-radius:4px">
|
|
106
|
+
</div>"""
|
|
107
|
+
screenshots_html += '</div></div>'
|
|
108
|
+
|
|
109
|
+
# Recommendations
|
|
110
|
+
recs = []
|
|
111
|
+
if failed > 0:
|
|
112
|
+
failed_names = [r.get('name','') for r in results if r.get('status') == 'FAIL']
|
|
113
|
+
recs.append(f"๐ด Fix {failed} failing test(s): {', '.join(failed_names[:3])}{'...' if len(failed_names) > 3 else ''}")
|
|
114
|
+
console_err_total = sum(len(r.get('console_errors', [])) for r in results)
|
|
115
|
+
if console_err_total > 0:
|
|
116
|
+
recs.append(f"๐ก Resolve {console_err_total} JavaScript console error(s) to improve reliability")
|
|
117
|
+
broken_img_tests = [r for r in results if any('broken image' in w.lower() for w in r.get('warnings', []))]
|
|
118
|
+
if broken_img_tests:
|
|
119
|
+
recs.append("๐ก Fix broken/unloaded images detected on the page")
|
|
120
|
+
alt_warnings = [r for r in results if any('alt text' in w.lower() for w in r.get('warnings', []))]
|
|
121
|
+
if alt_warnings:
|
|
122
|
+
recs.append("๐ก Add missing alt text to images for accessibility compliance")
|
|
123
|
+
if not recs:
|
|
124
|
+
recs.append("โ
All critical checks passed. Site appears to be functioning correctly.")
|
|
125
|
+
|
|
126
|
+
recs_html = ''.join(f'<li style="margin-bottom:8px">{r}</li>' for r in recs)
|
|
127
|
+
|
|
128
|
+
html = f"""<!DOCTYPE html>
|
|
129
|
+
<html lang="en">
|
|
130
|
+
<head>
|
|
131
|
+
<meta charset="UTF-8">
|
|
132
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
133
|
+
<title>QA Report โ {site_name}</title>
|
|
134
|
+
<style>
|
|
135
|
+
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
|
136
|
+
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f8fafc; color: #1e293b; }}
|
|
137
|
+
.header {{ background: linear-gradient(135deg, #1e293b 0%, #334155 100%); color: white; padding: 40px; }}
|
|
138
|
+
.header h1 {{ font-size: 28px; font-weight: 700; margin-bottom: 4px; }}
|
|
139
|
+
.header p {{ color: #94a3b8; font-size: 14px; }}
|
|
140
|
+
.container {{ max-width: 1200px; margin: 0 auto; padding: 32px 24px; }}
|
|
141
|
+
.stats {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px; margin-bottom: 32px; }}
|
|
142
|
+
.stat {{ background: white; border-radius: 12px; padding: 20px; text-align: center; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }}
|
|
143
|
+
.stat .num {{ font-size: 36px; font-weight: 800; }}
|
|
144
|
+
.stat .label {{ font-size: 13px; color: #64748b; margin-top: 4px; }}
|
|
145
|
+
.section {{ background: white; border-radius: 12px; padding: 24px; margin-bottom: 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }}
|
|
146
|
+
.section h2 {{ font-size: 18px; font-weight: 600; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid #f1f5f9; }}
|
|
147
|
+
table {{ width: 100%; border-collapse: collapse; font-size: 14px; }}
|
|
148
|
+
th {{ background: #f8fafc; text-align: left; padding: 10px 12px; color: #64748b; font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; }}
|
|
149
|
+
td {{ padding: 12px; border-top: 1px solid #f1f5f9; vertical-align: top; }}
|
|
150
|
+
tr:hover td {{ background: #f8fafc; }}
|
|
151
|
+
.progress-bar {{ height: 8px; background: #e2e8f0; border-radius: 4px; overflow: hidden; margin-top: 8px; }}
|
|
152
|
+
.progress-fill {{ height: 100%; background: {rate_color}; border-radius: 4px; width: {pass_rate}%; }}
|
|
153
|
+
</style>
|
|
154
|
+
</head>
|
|
155
|
+
<body>
|
|
156
|
+
|
|
157
|
+
<div class="header">
|
|
158
|
+
<h1>๐งช QA Test Report</h1>
|
|
159
|
+
<p>{site_name} | {run_at[:19].replace('T', ' ')} | {url}</p>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<div class="container">
|
|
163
|
+
|
|
164
|
+
<div class="stats">
|
|
165
|
+
<div class="stat">
|
|
166
|
+
<div class="num" style="color:{rate_color}">{pass_rate}%</div>
|
|
167
|
+
<div class="label">Pass Rate</div>
|
|
168
|
+
<div class="progress-bar"><div class="progress-fill"></div></div>
|
|
169
|
+
</div>
|
|
170
|
+
<div class="stat">
|
|
171
|
+
<div class="num">{total}</div>
|
|
172
|
+
<div class="label">Total Tests</div>
|
|
173
|
+
</div>
|
|
174
|
+
<div class="stat">
|
|
175
|
+
<div class="num" style="color:#22c55e">{passed}</div>
|
|
176
|
+
<div class="label">Passed</div>
|
|
177
|
+
</div>
|
|
178
|
+
<div class="stat">
|
|
179
|
+
<div class="num" style="color:#ef4444">{failed}</div>
|
|
180
|
+
<div class="label">Failed</div>
|
|
181
|
+
</div>
|
|
182
|
+
<div class="stat">
|
|
183
|
+
<div class="num" style="color:#f59e0b">{skipped}</div>
|
|
184
|
+
<div class="label">Skipped</div>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
<div class="section">
|
|
189
|
+
<h2>๐ Test Results</h2>
|
|
190
|
+
<table>
|
|
191
|
+
<thead>
|
|
192
|
+
<tr>
|
|
193
|
+
<th>ID</th>
|
|
194
|
+
<th>Test Case</th>
|
|
195
|
+
<th>Status</th>
|
|
196
|
+
<th>Assertions</th>
|
|
197
|
+
<th>Duration</th>
|
|
198
|
+
<th>Details</th>
|
|
199
|
+
</tr>
|
|
200
|
+
</thead>
|
|
201
|
+
<tbody>
|
|
202
|
+
{rows_html}
|
|
203
|
+
</tbody>
|
|
204
|
+
</table>
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
<div class="section">
|
|
208
|
+
<h2>๐ก Recommendations</h2>
|
|
209
|
+
<ul style="list-style:none;padding:0">
|
|
210
|
+
{recs_html}
|
|
211
|
+
</ul>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
<div class="section">
|
|
215
|
+
<h2>๐ธ Screenshots</h2>
|
|
216
|
+
{screenshots_html if screenshots_html else '<p style="color:#64748b">No screenshots captured.</p>'}
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
</div>
|
|
220
|
+
</body>
|
|
221
|
+
</html>"""
|
|
222
|
+
|
|
223
|
+
os.makedirs(os.path.dirname(output_path) if os.path.dirname(output_path) else '.', exist_ok=True)
|
|
224
|
+
with open(output_path, 'w', encoding='utf-8') as f:
|
|
225
|
+
f.write(html)
|
|
226
|
+
|
|
227
|
+
print(f"โ
Report saved: {output_path}")
|
|
228
|
+
return output_path
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
if __name__ == '__main__':
|
|
232
|
+
parser = argparse.ArgumentParser()
|
|
233
|
+
parser.add_argument('--results', required=True)
|
|
234
|
+
parser.add_argument('--screenshots', default='')
|
|
235
|
+
parser.add_argument('--output', required=True)
|
|
236
|
+
args = parser.parse_args()
|
|
237
|
+
generate_report(args.results, args.screenshots, args.output)
|