flakeiq 1.0.11
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/LICENSE +21 -0
- package/README.md +423 -0
- package/bin/flakeiq.js +173 -0
- package/lib/classify.js +38 -0
- package/lib/config.js +51 -0
- package/lib/seed.js +30 -0
- package/lib/serve.js +59 -0
- package/lib/setup.js +79 -0
- package/lib/utils.js +40 -0
- package/package.json +55 -0
- package/python/classify.py +191 -0
- package/python/dashboard.py +321 -0
- package/python/requirements.txt +2 -0
- package/python/seed.py +256 -0
- package/python/web/static/app.js +296 -0
- package/python/web/static/index.html +43 -0
- package/python/web/static/style.css +46 -0
- package/reporter/flake-reporter.js +93 -0
- package/reporter/index.js +1 -0
- package/scripts/postinstall.js +27 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import json
|
|
3
|
+
import sqlite3
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import argparse
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from datetime import datetime, timedelta
|
|
9
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
DB_PATH = os.environ.get("FLAKE_DB", "flake.db")
|
|
13
|
+
PORT = int(os.environ.get("FLAKE_PORT", "8080"))
|
|
14
|
+
STATIC_DIR = Path(__file__).parent / "web" / "static"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Handler(BaseHTTPRequestHandler):
|
|
18
|
+
def do_GET(self):
|
|
19
|
+
parsed = urlparse(self.path)
|
|
20
|
+
path = parsed.path
|
|
21
|
+
if path in ("/", "/index.html"):
|
|
22
|
+
self._serve_file("index.html", "text/html")
|
|
23
|
+
return
|
|
24
|
+
if path.startswith("/static/"):
|
|
25
|
+
fname = path[len("/static/"):]
|
|
26
|
+
content_types = {".html": "text/html", ".css": "text/css", ".js": "application/javascript"}
|
|
27
|
+
ext = Path(fname).suffix
|
|
28
|
+
ct = content_types.get(ext, "application/octet-stream")
|
|
29
|
+
self._serve_file(fname, ct)
|
|
30
|
+
return
|
|
31
|
+
handlers = {
|
|
32
|
+
"/api/stats": self.api_stats,
|
|
33
|
+
"/api/flake-rate": self.api_flake_rate,
|
|
34
|
+
"/api/breakdown": self.api_breakdown,
|
|
35
|
+
"/api/heatmap": self.api_heatmap,
|
|
36
|
+
"/api/top-flakes": self.api_top_flakes,
|
|
37
|
+
"/api/devices": self.api_devices,
|
|
38
|
+
"/api/by-action": self.api_by_action,
|
|
39
|
+
"/api/by-platform": self.api_by_platform,
|
|
40
|
+
"/api/volume": self.api_volume,
|
|
41
|
+
"/api/duration-dist": self.api_duration_dist,
|
|
42
|
+
"/api/classification-trend": self.api_classification_trend,
|
|
43
|
+
"/api/sessions": self.api_sessions,
|
|
44
|
+
"/api/latest-session": self.api_latest_session,
|
|
45
|
+
}
|
|
46
|
+
h = handlers.get(path)
|
|
47
|
+
if h:
|
|
48
|
+
h()
|
|
49
|
+
else:
|
|
50
|
+
self.send_response(404)
|
|
51
|
+
self.end_headers()
|
|
52
|
+
|
|
53
|
+
def _serve_file(self, filename, content_type):
|
|
54
|
+
filepath = STATIC_DIR / filename
|
|
55
|
+
if not filepath.exists():
|
|
56
|
+
self.send_response(404)
|
|
57
|
+
self.end_headers()
|
|
58
|
+
return
|
|
59
|
+
self.send_response(200)
|
|
60
|
+
self.send_header("Content-Type", f"{content_type}; charset=utf-8")
|
|
61
|
+
self.send_header("Cache-Control", "no-cache")
|
|
62
|
+
self.end_headers()
|
|
63
|
+
self.wfile.write(filepath.read_bytes())
|
|
64
|
+
|
|
65
|
+
def _json(self, data):
|
|
66
|
+
self.send_response(200)
|
|
67
|
+
self.send_header("Content-Type", "application/json")
|
|
68
|
+
self.send_header("Cache-Control", "no-cache")
|
|
69
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
70
|
+
self.end_headers()
|
|
71
|
+
self.wfile.write(json.dumps(data).encode())
|
|
72
|
+
|
|
73
|
+
def _query(self, sql, params=()):
|
|
74
|
+
db = sqlite3.connect(DB_PATH)
|
|
75
|
+
db.row_factory = sqlite3.Row
|
|
76
|
+
try:
|
|
77
|
+
return [dict(r) for r in db.execute(sql, params).fetchall()]
|
|
78
|
+
finally:
|
|
79
|
+
db.close()
|
|
80
|
+
|
|
81
|
+
def _scalar(self, sql, params=()):
|
|
82
|
+
rows = self._query(sql, params)
|
|
83
|
+
if rows:
|
|
84
|
+
return list(rows[0].values())[0]
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
def api_stats(self):
|
|
88
|
+
rows = self._query("""
|
|
89
|
+
SELECT
|
|
90
|
+
COUNT(*) as total_runs,
|
|
91
|
+
SUM(CASE WHEN result != 'passed' THEN 1 ELSE 0 END) as fail_count,
|
|
92
|
+
SUM(CASE WHEN classification IS NOT NULL THEN 1 ELSE 0 END) as classified_count,
|
|
93
|
+
SUM(CASE WHEN classification = 'REAL_BUG' THEN 1 ELSE 0 END) as real_bug_count
|
|
94
|
+
FROM test_runs
|
|
95
|
+
""")
|
|
96
|
+
s = rows[0]
|
|
97
|
+
s["flake_rate"] = round(s["fail_count"] / s["total_runs"] * 100, 1) if s["total_runs"] else 0
|
|
98
|
+
avg_dur = self._scalar("SELECT CAST(ROUND(AVG(duration_ms / 1000.0)) AS INTEGER) FROM test_runs")
|
|
99
|
+
s["avg_duration"] = avg_dur or 0
|
|
100
|
+
dr = self._query("SELECT MIN(DATE(run_at)) as first, MAX(DATE(run_at)) as last FROM test_runs")
|
|
101
|
+
if dr and dr[0]["first"] and dr[0]["last"]:
|
|
102
|
+
s["date_range"] = f"{dr[0]['first']} to {dr[0]['last']}"
|
|
103
|
+
else:
|
|
104
|
+
s["date_range"] = ""
|
|
105
|
+
self._json(s)
|
|
106
|
+
|
|
107
|
+
def api_flake_rate(self):
|
|
108
|
+
rows = self._query("""
|
|
109
|
+
SELECT DATE(run_at) as day,
|
|
110
|
+
ROUND(SUM(CASE WHEN result != 'passed' THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 1) as rate
|
|
111
|
+
FROM test_runs
|
|
112
|
+
GROUP BY day ORDER BY day LIMIT 60
|
|
113
|
+
""")
|
|
114
|
+
self._json(rows)
|
|
115
|
+
|
|
116
|
+
def api_breakdown(self):
|
|
117
|
+
rows = self._query("""
|
|
118
|
+
SELECT COALESCE(classification, 'UNCLASSIFIED') as classification, COUNT(*) as count
|
|
119
|
+
FROM test_runs WHERE result != 'passed'
|
|
120
|
+
GROUP BY classification ORDER BY count DESC
|
|
121
|
+
""")
|
|
122
|
+
self._json(rows)
|
|
123
|
+
|
|
124
|
+
def api_heatmap(self):
|
|
125
|
+
rows = self._query("""
|
|
126
|
+
SELECT DATE(run_at) as day, screen_name as screen,
|
|
127
|
+
ROUND(SUM(CASE WHEN result != 'passed' THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 1) as rate
|
|
128
|
+
FROM test_runs
|
|
129
|
+
WHERE screen_name IS NOT NULL AND screen_name != ''
|
|
130
|
+
GROUP BY day, screen
|
|
131
|
+
HAVING COUNT(*) >= 2
|
|
132
|
+
ORDER BY day, screen
|
|
133
|
+
""")
|
|
134
|
+
self._json(rows)
|
|
135
|
+
|
|
136
|
+
def api_top_flakes(self):
|
|
137
|
+
rows = self._query("""
|
|
138
|
+
SELECT test_name, platform, screen_name, COUNT(*) as total,
|
|
139
|
+
SUM(CASE WHEN result != 'passed' THEN 1 ELSE 0 END) as fail_count,
|
|
140
|
+
(SELECT classification FROM test_runs t2
|
|
141
|
+
WHERE t2.test_name = t1.test_name AND t2.result != 'passed'
|
|
142
|
+
GROUP BY classification ORDER BY COUNT(*) DESC LIMIT 1
|
|
143
|
+
) as common_classification
|
|
144
|
+
FROM test_runs t1
|
|
145
|
+
GROUP BY test_name, platform
|
|
146
|
+
HAVING total >= 2
|
|
147
|
+
ORDER BY fail_count * 1.0 / total DESC
|
|
148
|
+
LIMIT 20
|
|
149
|
+
""")
|
|
150
|
+
self._json(rows)
|
|
151
|
+
|
|
152
|
+
def api_devices(self):
|
|
153
|
+
rows = self._query("""
|
|
154
|
+
SELECT device_id, platform, COUNT(*) as total,
|
|
155
|
+
SUM(CASE WHEN result != 'passed' THEN 1 ELSE 0 END) as fail_count,
|
|
156
|
+
(SELECT classification FROM test_runs t2
|
|
157
|
+
WHERE t2.device_id = t1.device_id AND t2.result != 'passed'
|
|
158
|
+
GROUP BY classification ORDER BY COUNT(*) DESC LIMIT 1
|
|
159
|
+
) as common_classification
|
|
160
|
+
FROM test_runs t1
|
|
161
|
+
GROUP BY device_id
|
|
162
|
+
HAVING total >= 2
|
|
163
|
+
ORDER BY fail_count * 1.0 / total DESC
|
|
164
|
+
""")
|
|
165
|
+
self._json(rows)
|
|
166
|
+
|
|
167
|
+
def api_by_action(self):
|
|
168
|
+
rows = self._query("""
|
|
169
|
+
SELECT action_type,
|
|
170
|
+
COUNT(*) as total,
|
|
171
|
+
SUM(CASE WHEN result != 'passed' THEN 1 ELSE 0 END) as fail_count,
|
|
172
|
+
ROUND(SUM(CASE WHEN result != 'passed' THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 1) as rate
|
|
173
|
+
FROM test_runs
|
|
174
|
+
WHERE action_type IS NOT NULL AND action_type != ''
|
|
175
|
+
GROUP BY action_type
|
|
176
|
+
ORDER BY rate DESC
|
|
177
|
+
""")
|
|
178
|
+
self._json(rows)
|
|
179
|
+
|
|
180
|
+
def api_by_platform(self):
|
|
181
|
+
rows = self._query("""
|
|
182
|
+
SELECT platform,
|
|
183
|
+
COUNT(*) as total,
|
|
184
|
+
SUM(CASE WHEN result = 'passed' THEN 1 ELSE 0 END) as pass_count,
|
|
185
|
+
SUM(CASE WHEN result != 'passed' THEN 1 ELSE 0 END) as fail_count,
|
|
186
|
+
ROUND(SUM(CASE WHEN result != 'passed' THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 1) as fail_rate
|
|
187
|
+
FROM test_runs
|
|
188
|
+
WHERE platform IS NOT NULL AND platform != ''
|
|
189
|
+
GROUP BY platform
|
|
190
|
+
ORDER BY fail_rate DESC
|
|
191
|
+
""")
|
|
192
|
+
self._json(rows)
|
|
193
|
+
|
|
194
|
+
def api_volume(self):
|
|
195
|
+
rows = self._query("""
|
|
196
|
+
SELECT DATE(run_at) as day,
|
|
197
|
+
SUM(CASE WHEN result = 'passed' THEN 1 ELSE 0 END) as pass_count,
|
|
198
|
+
SUM(CASE WHEN result != 'passed' THEN 1 ELSE 0 END) as fail_count
|
|
199
|
+
FROM test_runs
|
|
200
|
+
GROUP BY day ORDER BY day
|
|
201
|
+
""")
|
|
202
|
+
self._json(rows)
|
|
203
|
+
|
|
204
|
+
def api_duration_dist(self):
|
|
205
|
+
rows = self._query("""
|
|
206
|
+
SELECT
|
|
207
|
+
CASE
|
|
208
|
+
WHEN duration_ms < 5000 THEN '0-5s'
|
|
209
|
+
WHEN duration_ms < 15000 THEN '5-15s'
|
|
210
|
+
WHEN duration_ms < 30000 THEN '15-30s'
|
|
211
|
+
WHEN duration_ms < 60000 THEN '30-60s'
|
|
212
|
+
WHEN duration_ms < 120000 THEN '60-120s'
|
|
213
|
+
ELSE '120s+'
|
|
214
|
+
END as bucket,
|
|
215
|
+
COUNT(*) as count
|
|
216
|
+
FROM test_runs
|
|
217
|
+
WHERE result != 'passed'
|
|
218
|
+
GROUP BY bucket
|
|
219
|
+
ORDER BY MIN(duration_ms)
|
|
220
|
+
""")
|
|
221
|
+
self._json(rows)
|
|
222
|
+
|
|
223
|
+
def api_classification_trend(self):
|
|
224
|
+
rows = self._query("""
|
|
225
|
+
SELECT DATE(run_at) as day, classification, COUNT(*) as count
|
|
226
|
+
FROM test_runs
|
|
227
|
+
WHERE result != 'passed' AND classification IS NOT NULL
|
|
228
|
+
GROUP BY day, classification
|
|
229
|
+
ORDER BY day
|
|
230
|
+
""")
|
|
231
|
+
self._json(rows)
|
|
232
|
+
|
|
233
|
+
def api_sessions(self):
|
|
234
|
+
rows = self._query("""
|
|
235
|
+
SELECT session_id, platform, device_id,
|
|
236
|
+
COUNT(*) as total_runs,
|
|
237
|
+
SUM(CASE WHEN result = 'passed' THEN 1 ELSE 0 END) as passed,
|
|
238
|
+
SUM(CASE WHEN result != 'passed' THEN 1 ELSE 0 END) as failed,
|
|
239
|
+
CAST(ROUND(AVG(duration_ms / 1000.0)) AS INTEGER) as avg_duration_s,
|
|
240
|
+
MIN(run_at) as first_run,
|
|
241
|
+
MAX(run_at) as last_run
|
|
242
|
+
FROM test_runs
|
|
243
|
+
WHERE session_id IS NOT NULL AND session_id != ''
|
|
244
|
+
GROUP BY session_id
|
|
245
|
+
ORDER BY last_run DESC
|
|
246
|
+
LIMIT 20
|
|
247
|
+
""")
|
|
248
|
+
self._json(rows)
|
|
249
|
+
|
|
250
|
+
def api_latest_session(self):
|
|
251
|
+
session = self._query("""
|
|
252
|
+
SELECT session_id, platform, device_id,
|
|
253
|
+
COUNT(*) as total_runs,
|
|
254
|
+
SUM(CASE WHEN result = 'passed' THEN 1 ELSE 0 END) as passed,
|
|
255
|
+
SUM(CASE WHEN result != 'passed' THEN 1 ELSE 0 END) as failed,
|
|
256
|
+
CAST(ROUND(AVG(duration_ms / 1000.0)) AS INTEGER) as avg_duration_s
|
|
257
|
+
FROM test_runs
|
|
258
|
+
WHERE session_id IS NOT NULL AND session_id != ''
|
|
259
|
+
GROUP BY session_id
|
|
260
|
+
ORDER BY MAX(run_at) DESC
|
|
261
|
+
LIMIT 1
|
|
262
|
+
""")
|
|
263
|
+
if not session:
|
|
264
|
+
self._json({"total_runs": 0, "passed": 0, "failed": 0})
|
|
265
|
+
return
|
|
266
|
+
s = session[0]
|
|
267
|
+
tests = self._query("""
|
|
268
|
+
SELECT test_name, result, duration_ms, platform, screen_name, classification
|
|
269
|
+
FROM test_runs
|
|
270
|
+
WHERE session_id = ?
|
|
271
|
+
ORDER BY run_at
|
|
272
|
+
""", (s["session_id"],))
|
|
273
|
+
s["tests"] = tests
|
|
274
|
+
self._json(s)
|
|
275
|
+
|
|
276
|
+
def log_message(self, format, *args):
|
|
277
|
+
pass
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def main():
|
|
281
|
+
global DB_PATH, PORT
|
|
282
|
+
parser = argparse.ArgumentParser(description="FlakeIQ dashboard")
|
|
283
|
+
parser.add_argument("--db", default=None, help="SQLite database path")
|
|
284
|
+
parser.add_argument("--port", type=int, default=8080, help="HTTP port")
|
|
285
|
+
parser.add_argument("--host", default="127.0.0.1", help="Bind address")
|
|
286
|
+
parser.add_argument("--seed", action="store_true", help="Use the seed database (flake-seed.db) instead of real data")
|
|
287
|
+
parser.add_argument("--open", action="store_true", help="Open browser on start")
|
|
288
|
+
args = parser.parse_args()
|
|
289
|
+
if args.seed:
|
|
290
|
+
seed_path = os.path.join(os.path.dirname(__file__), "flake-seed.db")
|
|
291
|
+
if os.path.exists(seed_path):
|
|
292
|
+
DB_PATH = seed_path
|
|
293
|
+
print(f"Using seed database: {seed_path}")
|
|
294
|
+
else:
|
|
295
|
+
print("Seed database not found. Run 'python3 seed.py' first.")
|
|
296
|
+
sys.exit(1)
|
|
297
|
+
elif args.db:
|
|
298
|
+
DB_PATH = args.db
|
|
299
|
+
PORT = args.port
|
|
300
|
+
os.environ["FLAKE_DB"] = DB_PATH
|
|
301
|
+
|
|
302
|
+
if not os.path.exists(DB_PATH):
|
|
303
|
+
print(f"Database not found: {DB_PATH}")
|
|
304
|
+
print("Run 'python3 classify.py flake-results.jsonl' or 'python3 seed.py' first.")
|
|
305
|
+
sys.exit(1)
|
|
306
|
+
|
|
307
|
+
server = HTTPServer((args.host, PORT), Handler)
|
|
308
|
+
url = f"http://{args.host}:{PORT}"
|
|
309
|
+
print(f"FlakeIQ dashboard at {url}")
|
|
310
|
+
if args.open:
|
|
311
|
+
import webbrowser
|
|
312
|
+
webbrowser.open(url)
|
|
313
|
+
try:
|
|
314
|
+
server.serve_forever()
|
|
315
|
+
except KeyboardInterrupt:
|
|
316
|
+
print("\nShutting down.")
|
|
317
|
+
server.server_close()
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
if __name__ == "__main__":
|
|
321
|
+
main()
|
package/python/seed.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import sqlite3
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import random
|
|
6
|
+
import sys
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
|
|
9
|
+
DB_PATH = os.environ.get("FLAKE_DB", "flake.db")
|
|
10
|
+
random.seed(42)
|
|
11
|
+
|
|
12
|
+
SCREENS = ["login", "profile", "form", "list", "alert", "animation", "calendar", "gesture", "media", "signature", "home"]
|
|
13
|
+
DEVICES = [
|
|
14
|
+
("Medium_Phone_API_36.0", "android"),
|
|
15
|
+
("emulator-5554", "android"),
|
|
16
|
+
("iPhone_16_Pro_iOS_18", "ios"),
|
|
17
|
+
("iPhone_15_iOS_17", "ios"),
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
TESTS = [
|
|
21
|
+
# (test_file, test_name, screen)
|
|
22
|
+
("tests/example.spec.ts", "login flow - fill form and submit", "login"),
|
|
23
|
+
("tests/example.spec.ts", "login flow - invalid credentials shows error", "login"),
|
|
24
|
+
("tests/profile.spec.ts", "edit profile - change name and save", "profile"),
|
|
25
|
+
("tests/profile.spec.ts", "toggle notification switches", "profile"),
|
|
26
|
+
("tests/profile.spec.ts", "logout from profile screen", "profile"),
|
|
27
|
+
("tests/forms.spec.ts", "fill all form fields and submit", "form"),
|
|
28
|
+
("tests/forms.spec.ts", "form validation shows errors", "form"),
|
|
29
|
+
("tests/lists.spec.ts", "scroll through list items", "list"),
|
|
30
|
+
("tests/lists.spec.ts", "pull to refresh list", "list"),
|
|
31
|
+
("tests/alerts.spec.ts", "show and dismiss alert dialog", "alert"),
|
|
32
|
+
("tests/alerts.spec.ts", "alert with multiple buttons", "alert"),
|
|
33
|
+
("tests/animation.spec.ts", "animated element appears", "animation"),
|
|
34
|
+
("tests/calendar.spec.ts", "select date from calendar", "calendar"),
|
|
35
|
+
("tests/gestures.spec.ts", "swipe between tabs", "gesture"),
|
|
36
|
+
("tests/media.spec.ts", "pick image from gallery", "media"),
|
|
37
|
+
("tests/signature.spec.ts", "draw and save signature", "signature"),
|
|
38
|
+
("tests/home.spec.ts", "home screen loads correctly", "home"),
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
ACTION_TEMPLATES = {
|
|
42
|
+
"login": [
|
|
43
|
+
["tap(#emailInput)", "fill(#emailInput)", "tap(#passwordInput)", "fill(#passwordInput)", "tap(#loginButton)"],
|
|
44
|
+
["tap(#emailInput)", "fill(#emailInput)", "tap(#passwordInput)", "fill(#passwordInput)", "tap(#loginButton)", "waitFor(#homeScreen)"],
|
|
45
|
+
["tap(#emailInput)", "fill(#emailInput)", "tap(#passwordInput)", "fill(#passwordInput)", "tap(#loginButton)", "waitForText(Invalid)"],
|
|
46
|
+
],
|
|
47
|
+
"profile": [
|
|
48
|
+
["tap(#navProfile)", "waitFor(#profileScreen)", "tap(#editButton)", "fill(#nameInput)", "tap(#saveButton)", "waitForText(Profile updated)"],
|
|
49
|
+
["tap(#navProfile)", "waitFor(#profileScreen)", "swipe('up')", "tap(#notificationsSwitch)", "waitForText(Saved)"],
|
|
50
|
+
["tap(#navProfile)", "waitFor(#profileScreen)", "swipe('up')", "tap(#logoutButton)", "waitFor(#loginScreen)"],
|
|
51
|
+
],
|
|
52
|
+
"form": [
|
|
53
|
+
["tap(#navFormControls)", "waitFor(#formScreen)", "fill(#textInput)", "fill(#emailField)", "fill(#phoneField)", "tap(#submitButton)"],
|
|
54
|
+
["tap(#navFormControls)", "waitFor(#formScreen)", "tap(#submitButton)", "waitForText(required)"],
|
|
55
|
+
],
|
|
56
|
+
"list": [
|
|
57
|
+
["tap(#navListsDemo)", "waitFor(#listScreen)", "swipe('up')", "swipe('up')", "tap(#listItem_5)"],
|
|
58
|
+
["tap(#navListsDemo)", "waitFor(#listScreen)", "swipe('down')", "waitForText(Item 1)"],
|
|
59
|
+
],
|
|
60
|
+
"alert": [
|
|
61
|
+
["tap(#navAlertsDemo)", "waitFor(#alertScreen)", "tap(#showAlertButton)", "waitForText(OK)", "tap(#okButton)"],
|
|
62
|
+
["tap(#navAlertsDemo)", "waitFor(#alertScreen)", "tap(#multiButtonAlert)", "tap(#cancelButton)"],
|
|
63
|
+
],
|
|
64
|
+
"animation": [
|
|
65
|
+
["tap(#navAnimationDemo)", "waitFor(#animationScreen)", "tap(#startAnimation)", "waitForText(Animation complete)"],
|
|
66
|
+
],
|
|
67
|
+
"calendar": [
|
|
68
|
+
["tap(#navCalendarDemo)", "waitFor(#calendarScreen)", "tap(#datePicker)", "tap(#day15)", "tap(#confirmButton)"],
|
|
69
|
+
],
|
|
70
|
+
"gesture": [
|
|
71
|
+
["tap(#navGesturesDemo)", "waitFor(#gestureScreen)", "swipe('left')", "waitForText(Tab 2)"],
|
|
72
|
+
],
|
|
73
|
+
"media": [
|
|
74
|
+
["tap(#navMediaDemo)", "waitFor(#mediaScreen)", "tap(#pickImageButton)", "waitForText(Image selected)"],
|
|
75
|
+
],
|
|
76
|
+
"signature": [
|
|
77
|
+
["tap(#navSignatureDemo)", "waitFor(#signatureScreen)", "draw(#canvas)", "tap(#saveSignature)", "waitForText(Signature saved)"],
|
|
78
|
+
],
|
|
79
|
+
"home": [
|
|
80
|
+
["waitFor(#homeScreen)", "waitForText(Welcome)", "verify card list visible"],
|
|
81
|
+
],
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
ERROR_TEMPLATES = {
|
|
85
|
+
"passed": (None, None),
|
|
86
|
+
"timedOut": [
|
|
87
|
+
("actionTimeout 20000ms exceeded", "TIMEOUT_FLAKE", "action timed out waiting for element to appear, likely slow render"),
|
|
88
|
+
("Timeout 30000ms exceeded while waiting for #element", "TIMEOUT_FLAKE", "element took longer than expected to become visible"),
|
|
89
|
+
("Test timeout of 120000ms exceeded", "TIMEOUT_FLAKE", "full test timeout exceeded, possible memory pressure slow-down"),
|
|
90
|
+
],
|
|
91
|
+
"failed": [
|
|
92
|
+
("ExpectError: Expected 1, but received 0", "LOCATOR_FLAKE", "element not found in tree, race condition on navigation"),
|
|
93
|
+
("ExpectError: Expected value to be truthy, but received false", "REAL_BUG", "UI state did not match expected value, possible regression"),
|
|
94
|
+
("Error: element is not visible", "LOCATOR_FLAKE", "element found but not interactable, viewport calculation mismatch"),
|
|
95
|
+
("Error: Method not found", "DEVICE_FLAKE", "device connection lost during action, RPC failure"),
|
|
96
|
+
("Error: Could not verify foreground activity", "DEVICE_FLAKE", "ANR dialog or system overlay blocking the app"),
|
|
97
|
+
("Error: fill failed - element detached from DOM", "LOCATOR_FLAKE", "stale element reference after navigation"),
|
|
98
|
+
("ExpectError: Expected 'Profile updated' but received 'Error saving'", "REAL_BUG", "app returned error state instead of success"),
|
|
99
|
+
],
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def generate_last_actions(screen: str, result: str) -> list[str]:
|
|
104
|
+
templates = ACTION_TEMPLATES.get(screen, [["tap(#generic)", "waitFor(#generic)"]])
|
|
105
|
+
base = random.choice(templates)
|
|
106
|
+
if result == "passed":
|
|
107
|
+
return base
|
|
108
|
+
# For failures, return a prefix of actions (the last few before crash)
|
|
109
|
+
cut = random.randint(1, len(base))
|
|
110
|
+
return base[:cut]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def generate_run_at(base_date: datetime, day_offset: int) -> str:
|
|
114
|
+
day = base_date + timedelta(days=day_offset)
|
|
115
|
+
hour = random.randint(7, 22)
|
|
116
|
+
minute = random.randint(0, 59)
|
|
117
|
+
second = random.randint(0, 59)
|
|
118
|
+
return day.replace(hour=hour, minute=minute, second=second).isoformat()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def generate_failure_rate(screen: str, platform: str) -> float:
|
|
122
|
+
rates = {
|
|
123
|
+
"login": {"android": 0.15, "ios": 0.05},
|
|
124
|
+
"profile": {"android": 0.12, "ios": 0.04},
|
|
125
|
+
"form": {"android": 0.08, "ios": 0.03},
|
|
126
|
+
"list": {"android": 0.20, "ios": 0.06},
|
|
127
|
+
"alert": {"android": 0.10, "ios": 0.04},
|
|
128
|
+
"animation": {"android": 0.18, "ios": 0.07},
|
|
129
|
+
"calendar": {"android": 0.08, "ios": 0.03},
|
|
130
|
+
"gesture": {"android": 0.22, "ios": 0.08},
|
|
131
|
+
"media": {"android": 0.25, "ios": 0.10},
|
|
132
|
+
"signature": {"android": 0.15, "ios": 0.06},
|
|
133
|
+
"home": {"android": 0.05, "ios": 0.02},
|
|
134
|
+
}
|
|
135
|
+
return rates.get(screen, {}).get(platform, 0.10)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def seed_database(db_path: str, num_days: int = 30, runs_per_day: int = 5):
|
|
139
|
+
db = sqlite3.connect(db_path)
|
|
140
|
+
db.executescript("""
|
|
141
|
+
DROP TABLE IF EXISTS test_runs;
|
|
142
|
+
CREATE TABLE test_runs (
|
|
143
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
144
|
+
test_file TEXT,
|
|
145
|
+
test_name TEXT,
|
|
146
|
+
platform TEXT,
|
|
147
|
+
device_id TEXT,
|
|
148
|
+
duration_ms INTEGER,
|
|
149
|
+
result TEXT,
|
|
150
|
+
error_message TEXT,
|
|
151
|
+
last_actions TEXT,
|
|
152
|
+
classification TEXT,
|
|
153
|
+
classification_reason TEXT,
|
|
154
|
+
screen_name TEXT,
|
|
155
|
+
action_type TEXT,
|
|
156
|
+
session_id TEXT,
|
|
157
|
+
run_at TEXT
|
|
158
|
+
);
|
|
159
|
+
CREATE INDEX IF NOT EXISTS idx_run_at ON test_runs(run_at);
|
|
160
|
+
CREATE INDEX IF NOT EXISTS idx_screen ON test_runs(screen_name);
|
|
161
|
+
CREATE INDEX IF NOT EXISTS idx_result ON test_runs(result);
|
|
162
|
+
CREATE INDEX IF NOT EXISTS idx_platform ON test_runs(platform);
|
|
163
|
+
""")
|
|
164
|
+
cursor = db.cursor()
|
|
165
|
+
|
|
166
|
+
base_date = datetime.now() - timedelta(days=num_days)
|
|
167
|
+
total = 0
|
|
168
|
+
passed = 0
|
|
169
|
+
failed = 0
|
|
170
|
+
|
|
171
|
+
for day in range(num_days):
|
|
172
|
+
# run all test files on each day, some multiple times
|
|
173
|
+
for test_file, test_name, screen in TESTS:
|
|
174
|
+
for platform_id in [0, 1]: # 0=android, 1=ios
|
|
175
|
+
device_id, platform = DEVICES[platform_id]
|
|
176
|
+
failure_rate = generate_failure_rate(screen, platform)
|
|
177
|
+
|
|
178
|
+
for run_num in range(runs_per_day):
|
|
179
|
+
# Determine result
|
|
180
|
+
is_flake = random.random() < failure_rate
|
|
181
|
+
|
|
182
|
+
if is_flake:
|
|
183
|
+
# For flakes, sometimes retry succeeds
|
|
184
|
+
is_retry = random.random() < 0.4
|
|
185
|
+
if is_retry:
|
|
186
|
+
# retry passed after initial failure
|
|
187
|
+
result = "passed"
|
|
188
|
+
error_msg = None
|
|
189
|
+
classification = None
|
|
190
|
+
classification_reason = None
|
|
191
|
+
passed += 1
|
|
192
|
+
else:
|
|
193
|
+
result = random.choice(["timedOut", "failed"])
|
|
194
|
+
failed += 1
|
|
195
|
+
else:
|
|
196
|
+
result = "passed"
|
|
197
|
+
error_msg = None
|
|
198
|
+
classification = None
|
|
199
|
+
classification_reason = None
|
|
200
|
+
passed += 1
|
|
201
|
+
|
|
202
|
+
# Determine duration
|
|
203
|
+
if result == "timedOut":
|
|
204
|
+
duration_ms = random.randint(60000, 180000)
|
|
205
|
+
elif result == "failed":
|
|
206
|
+
duration_ms = random.randint(10000, 55000)
|
|
207
|
+
else:
|
|
208
|
+
duration_ms = random.randint(3000, 25000)
|
|
209
|
+
|
|
210
|
+
# Error message and classification
|
|
211
|
+
if result == "passed":
|
|
212
|
+
error_msg = ""
|
|
213
|
+
classification = None
|
|
214
|
+
classification_reason = None
|
|
215
|
+
last_actions = generate_last_actions(screen, "passed")
|
|
216
|
+
else:
|
|
217
|
+
error_opts = ERROR_TEMPLATES[result]
|
|
218
|
+
error_msg, classification, classification_reason = random.choice(error_opts)
|
|
219
|
+
last_actions = generate_last_actions(screen, "failed")
|
|
220
|
+
|
|
221
|
+
# Determine action_type from last actions
|
|
222
|
+
action_type = "other"
|
|
223
|
+
for action in reversed(last_actions):
|
|
224
|
+
al = action.lower()
|
|
225
|
+
if "fill" in al: action_type = "fill"; break
|
|
226
|
+
if "tap" in al or "click" in al: action_type = "tap"; break
|
|
227
|
+
if "swipe" in al or "scroll" in al: action_type = "swipe"; break
|
|
228
|
+
if "scrollintoview" in al: action_type = "scrollIntoView"; break
|
|
229
|
+
if "press" in al: action_type = "press"; break
|
|
230
|
+
|
|
231
|
+
cursor.execute(
|
|
232
|
+
"""INSERT INTO test_runs
|
|
233
|
+
(test_file, test_name, platform, device_id, duration_ms, result,
|
|
234
|
+
error_message, last_actions, classification, classification_reason,
|
|
235
|
+
screen_name, action_type, session_id, run_at)
|
|
236
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
237
|
+
(
|
|
238
|
+
test_file, test_name, platform, device_id,
|
|
239
|
+
duration_ms, result, error_msg,
|
|
240
|
+
json.dumps(last_actions), classification,
|
|
241
|
+
classification_reason, screen, action_type,
|
|
242
|
+
f"{device_id}_{day}",
|
|
243
|
+
generate_run_at(base_date, day),
|
|
244
|
+
),
|
|
245
|
+
)
|
|
246
|
+
total += 1
|
|
247
|
+
|
|
248
|
+
db.commit()
|
|
249
|
+
db.close()
|
|
250
|
+
print(f"Seeded {total} test runs ({passed} passed, {failed} failed)")
|
|
251
|
+
print(f"Overall pass rate: {passed/total*100:.1f}%")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
if __name__ == "__main__":
|
|
255
|
+
path = sys.argv[1] if len(sys.argv) > 1 else DB_PATH
|
|
256
|
+
seed_database(path)
|