Files
2026-06-24 20:05:36 +05:30

532 lines
21 KiB
Python

#!/usr/bin/env python3
"""SQLite-first UAT/VAPT result database helper.
No third-party Python packages are required. It can:
- create schema
- import generated Playwright matrix as test_cases
- create a test run
- record every Playwright test result immediately
- finish a run and update counts
- export latest run to JSON and a simple XLSX workbook
"""
import argparse
import csv
import datetime as dt
import html
import json
import os
import re
import sqlite3
import sys
import zipfile
from pathlib import Path
DB_DEFAULT = os.environ.get("SQLITE_RESULTS_DB", "results/uat_vapt_results.sqlite")
MATRIX_DEFAULT = os.environ.get("TEST_MATRIX_JSON", "data/generated-test-matrix.json")
EXTRA_CASES_DEFAULT = os.environ.get("EXTRA_TEST_CASES_JSON", "data/v2_5_test_cases.json,data/v2_5_1_test_cases.json")
def now_iso():
return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def connect(db_path: str):
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=30000")
return conn
def init_db(conn):
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS test_cases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id TEXT,
variant_id TEXT UNIQUE,
sheet TEXT,
module TEXT,
role TEXT,
scenario TEXT,
steps TEXT,
expected TEXT,
priority TEXT,
type TEXT,
route TEXT,
variant_name TEXT,
variant_type TEXT,
automation TEXT,
manual_reason TEXT,
is_active INTEGER DEFAULT 1,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS test_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_name TEXT,
base_url TEXT,
status TEXT,
started_at TEXT DEFAULT CURRENT_TIMESTAMP,
finished_at TEXT,
total_cases INTEGER DEFAULT 0,
executed_count INTEGER DEFAULT 0,
passed_count INTEGER DEFAULT 0,
failed_count INTEGER DEFAULT 0,
skipped_count INTEGER DEFAULT 0,
timedout_count INTEGER DEFAULT 0,
interrupted_count INTEGER DEFAULT 0,
not_run_count INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS test_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL,
variant_id TEXT,
test_title TEXT,
title_path TEXT,
project_name TEXT,
file TEXT,
line INTEGER,
column INTEGER,
status TEXT,
expected_status TEXT,
duration_ms INTEGER,
retry INTEGER DEFAULT 0,
error_message TEXT,
error_stack TEXT,
stdout TEXT,
stderr TEXT,
started_at TEXT,
finished_at TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(run_id) REFERENCES test_runs(id),
FOREIGN KEY(variant_id) REFERENCES test_cases(variant_id)
);
CREATE TABLE IF NOT EXISTS test_attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
result_id INTEGER NOT NULL,
run_id INTEGER NOT NULL,
variant_id TEXT,
attachment_name TEXT,
content_type TEXT,
file_path TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(result_id) REFERENCES test_results(id),
FOREIGN KEY(run_id) REFERENCES test_runs(id)
);
CREATE INDEX IF NOT EXISTS idx_test_cases_variant_id ON test_cases(variant_id);
CREATE INDEX IF NOT EXISTS idx_test_results_run_id ON test_results(run_id);
CREATE INDEX IF NOT EXISTS idx_test_results_variant_id ON test_results(variant_id);
CREATE INDEX IF NOT EXISTS idx_test_results_status ON test_results(status);
CREATE INDEX IF NOT EXISTS idx_test_attachments_run_id ON test_attachments(run_id);
"""
)
conn.commit()
def norm(v):
return "" if v is None else str(v)
def _load_case_rows(path: str):
if not path or not Path(path).exists():
return []
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def import_cases(conn, matrix_path: str):
"""Import primary generated matrix plus optional v2.5/additional test-case JSON.
matrix_path may be a comma-separated list. In addition, EXTRA_TEST_CASES_JSON
defaults to data/v2_5_test_cases.json when present.
"""
init_db(conn)
paths = []
for part in str(matrix_path or "").split(','):
part = part.strip()
if part:
paths.append(part)
extra = EXTRA_CASES_DEFAULT
for part in str(extra or "").split(','):
part = part.strip()
if part and part not in paths:
paths.append(part)
sql = """
INSERT INTO test_cases (
source_id, variant_id, sheet, module, role, scenario, steps, expected,
priority, type, route, variant_name, variant_type, automation, manual_reason,
is_active, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)
ON CONFLICT(variant_id) DO UPDATE SET
source_id=excluded.source_id,
sheet=excluded.sheet,
module=excluded.module,
role=excluded.role,
scenario=excluded.scenario,
steps=excluded.steps,
expected=excluded.expected,
priority=excluded.priority,
type=excluded.type,
route=excluded.route,
variant_name=excluded.variant_name,
variant_type=excluded.variant_type,
automation=excluded.automation,
manual_reason=excluded.manual_reason,
is_active=1,
updated_at=excluded.updated_at
"""
ts = now_iso()
count = 0
for path in paths:
rows = _load_case_rows(path)
for r in rows:
variant_id = r.get("variantId") or r.get("variant_id") or ""
if not variant_id:
continue
conn.execute(sql, (
norm(r.get("sourceId") or r.get("source_id")),
norm(variant_id),
norm(r.get("sheet")),
norm(r.get("module")),
norm(r.get("role")),
norm(r.get("scenario")),
norm(r.get("steps")),
norm(r.get("expected")),
norm(r.get("priority")),
norm(r.get("type")),
norm(r.get("route")),
norm(r.get("variantName") or r.get("variant_name")),
norm(r.get("variantType") or r.get("variant_type")),
norm(r.get("automation")),
norm(r.get("manualReason") or r.get("manual_reason")),
ts,
))
count += 1
conn.commit()
return count
def begin_run(conn, run_name: str, base_url: str):
init_db(conn)
total_cases = conn.execute("SELECT COUNT(*) AS c FROM test_cases WHERE is_active=1").fetchone()["c"]
cur = conn.execute(
"INSERT INTO test_runs (run_name, base_url, status, started_at, total_cases) VALUES (?, ?, ?, ?, ?)",
(run_name, base_url, "running", now_iso(), total_cases),
)
conn.commit()
return cur.lastrowid
def record_result(conn, record: dict):
init_db(conn)
run_id = int(record.get("run_id") or record.get("runId") or 0)
if not run_id:
raise ValueError("run_id is required")
variant_id = record.get("variant_id") or record.get("variantId") or extract_variant_id(" ".join(record.get("title_path") or record.get("titlePath") or []))
if isinstance(record.get("title_path"), list):
title_path = json.dumps(record.get("title_path"), ensure_ascii=False)
elif isinstance(record.get("titlePath"), list):
title_path = json.dumps(record.get("titlePath"), ensure_ascii=False)
else:
title_path = norm(record.get("title_path") or record.get("titlePath"))
err = record.get("error") or {}
if isinstance(err, str):
error_message, error_stack = err, ""
else:
error_message = norm(err.get("message"))
error_stack = norm(err.get("stack"))
loc = record.get("location") or {}
cur = conn.execute(
"""
INSERT INTO test_results (
run_id, variant_id, test_title, title_path, project_name, file, line, column,
status, expected_status, duration_ms, retry, error_message, error_stack,
stdout, stderr, started_at, finished_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
run_id,
norm(variant_id),
norm(record.get("title") or record.get("test_title")),
title_path,
norm(record.get("project_name") or record.get("projectName")),
norm(loc.get("file") or record.get("file")),
int(loc.get("line") or record.get("line") or 0),
int(loc.get("column") or record.get("column") or 0),
norm(record.get("status")),
norm(record.get("expected_status") or record.get("expectedStatus")),
int(record.get("duration_ms") or record.get("duration") or 0),
int(record.get("retry") or 0),
error_message,
error_stack,
norm(record.get("stdout")),
norm(record.get("stderr")),
norm(record.get("started_at") or record.get("startedAt")),
norm(record.get("finished_at") or record.get("finishedAt") or now_iso()),
),
)
result_id = cur.lastrowid
for a in record.get("attachments") or []:
conn.execute(
"""
INSERT INTO test_attachments (result_id, run_id, variant_id, attachment_name, content_type, file_path)
VALUES (?, ?, ?, ?, ?, ?)
""",
(result_id, run_id, norm(variant_id), norm(a.get("name")), norm(a.get("contentType") or a.get("content_type")), norm(a.get("path") or a.get("file_path"))),
)
conn.commit()
return result_id
def finish_run(conn, run_id: int, status: str):
init_db(conn)
rows = conn.execute("SELECT status, COUNT(*) AS c FROM test_results WHERE run_id=? GROUP BY status", (run_id,)).fetchall()
counts = {r["status"]: r["c"] for r in rows}
executed = sum(counts.values())
total_cases = conn.execute("SELECT COUNT(*) AS c FROM test_cases WHERE is_active=1").fetchone()["c"]
not_run = max(total_cases - executed, 0)
conn.execute(
"""
UPDATE test_runs SET
status=?, finished_at=?, total_cases=?, executed_count=?, passed_count=?, failed_count=?,
skipped_count=?, timedout_count=?, interrupted_count=?, not_run_count=?
WHERE id=?
""",
(
status,
now_iso(),
total_cases,
executed,
counts.get("passed", 0),
counts.get("failed", 0),
counts.get("skipped", 0),
counts.get("timedOut", 0) + counts.get("timedout", 0),
counts.get("interrupted", 0),
not_run,
run_id,
),
)
conn.commit()
return counts
def latest_run_id(conn):
row = conn.execute("SELECT id FROM test_runs ORDER BY id DESC LIMIT 1").fetchone()
return row["id"] if row else None
def merged_rows(conn, run_id: int):
# Use the latest result per variant_id within the run. Retries become later rows; last row wins.
sql = """
WITH latest AS (
SELECT tr.*
FROM test_results tr
JOIN (
SELECT variant_id, MAX(id) AS max_id
FROM test_results
WHERE run_id=?
GROUP BY variant_id
) x ON x.max_id = tr.id
)
SELECT
tc.source_id AS sourceId,
tc.sheet,
tc.module,
tc.role,
tc.scenario,
tc.steps,
tc.expected,
tc.priority,
tc.type,
tc.route,
tc.variant_id AS variantId,
tc.variant_name AS variantName,
tc.variant_type AS variantType,
tc.automation,
COALESCE(latest.status, CASE WHEN tc.automation='manual' THEN 'manual' ELSE 'not_run' END) AS result,
latest.duration_ms AS durationMs,
latest.retry,
latest.error_message AS errorMessage,
latest.started_at AS startedAt,
latest.finished_at AS finishedAt
FROM test_cases tc
LEFT JOIN latest ON latest.variant_id = tc.variant_id
WHERE tc.is_active=1
ORDER BY tc.sheet, tc.source_id, tc.variant_id
"""
return [dict(r) for r in conn.execute(sql, (run_id,)).fetchall()]
def export_merged_json(conn, run_id: int, output_path: str):
rows = merged_rows(conn, run_id)
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
json.dump(rows, f, indent=2, ensure_ascii=False)
return len(rows)
def export_csv(conn, run_id: int, output_path: str):
rows = merged_rows(conn, run_id)
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w", newline="", encoding="utf-8") as f:
if rows:
w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
w.writeheader()
w.writerows(rows)
return len(rows)
def xlsx_col(n):
s = ""
while n:
n, r = divmod(n - 1, 26)
s = chr(65 + r) + s
return s
def sheet_xml(rows):
out = ['<?xml version="1.0" encoding="UTF-8" standalone="yes"?>', '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>']
for r_idx, row in enumerate(rows, start=1):
out.append(f'<row r="{r_idx}">')
for c_idx, val in enumerate(row, start=1):
ref = f"{xlsx_col(c_idx)}{r_idx}"
if val is None:
val = ""
# Keep everything as inline string for maximum compatibility with no sharedStrings table.
txt = html.escape(str(val), quote=False)
out.append(f'<c r="{ref}" t="inlineStr"><is><t>{txt}</t></is></c>')
out.append('</row>')
out.append('</sheetData></worksheet>')
return "".join(out)
def write_xlsx(path_out: str, sheets: dict):
Path(path_out).parent.mkdir(parents=True, exist_ok=True)
names = list(sheets.keys())
with zipfile.ZipFile(path_out, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr("[Content_Types].xml", """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
""" + "".join(f'<Override PartName="/xl/worksheets/sheet{i}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' for i in range(1, len(names)+1)) + "</Types>")
z.writestr("_rels/.rels", """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
</Relationships>""")
z.writestr("xl/workbook.xml", """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>""" + "".join(f'<sheet name="{html.escape(name[:31])}" sheetId="{i}" r:id="rId{i}"/>' for i, name in enumerate(names, start=1)) + "</sheets></workbook>")
z.writestr("xl/_rels/workbook.xml.rels", """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">""" + "".join(f'<Relationship Id="rId{i}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet{i}.xml"/>' for i in range(1, len(names)+1)) + "</Relationships>")
for i, name in enumerate(names, start=1):
z.writestr(f"xl/worksheets/sheet{i}.xml", sheet_xml(sheets[name]))
def rows_to_aoa(rows):
if not rows:
return []
headers = list(rows[0].keys())
return [headers] + [[r.get(h, "") for h in headers] for r in rows]
def export_xlsx(conn, run_id: int, output_path: str):
run = conn.execute("SELECT * FROM test_runs WHERE id=?", (run_id,)).fetchone()
summary = conn.execute("SELECT status, COUNT(*) AS count FROM test_results WHERE run_id=? GROUP BY status", (run_id,)).fetchall()
results = merged_rows(conn, run_id)
attachments = conn.execute(
"""
SELECT tr.variant_id, tr.test_title, ta.attachment_name, ta.content_type, ta.file_path
FROM test_attachments ta
JOIN test_results tr ON tr.id = ta.result_id
WHERE ta.run_id=?
ORDER BY tr.variant_id, ta.attachment_name
""",
(run_id,),
).fetchall()
failures = [r for r in results if r.get("result") not in ("passed", "manual", "skipped")]
sheets = {
"Run": rows_to_aoa([dict(run)] if run else []),
"Summary": rows_to_aoa([dict(r) for r in summary]),
"Failures": rows_to_aoa(failures),
"Results": rows_to_aoa(results),
"Attachments": rows_to_aoa([dict(r) for r in attachments]),
}
write_xlsx(output_path, sheets)
return len(results)
def extract_variant_id(text: str):
text = text or ""
m = re.search(r"\[([^\]]+)\]", text)
if m:
return m.group(1)
# Fallback for legacy/custom tests whose title starts with an ID.
m = re.search(r"\b((?:V25-)?[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)+)\b", text)
return m.group(1) if m else ""
def main():
p = argparse.ArgumentParser()
p.add_argument("command", choices=["init", "import-cases", "begin-run", "record-result", "finish-run", "export-json", "export-csv", "export-xlsx", "summary"])
p.add_argument("--db", default=DB_DEFAULT)
p.add_argument("--matrix", default=MATRIX_DEFAULT)
p.add_argument("--run-id", type=int)
p.add_argument("--run-name", default=os.environ.get("TEST_RUN_NAME") or f"Run {now_iso()}")
p.add_argument("--base-url", default=os.environ.get("BASE_URL", ""))
p.add_argument("--status", default="completed")
p.add_argument("--output", default="")
args = p.parse_args()
conn = connect(args.db)
try:
if args.command == "init":
init_db(conn)
print(json.dumps({"ok": True, "db": args.db}))
elif args.command == "import-cases":
count = import_cases(conn, args.matrix)
print(json.dumps({"ok": True, "imported": count, "db": args.db}))
elif args.command == "begin-run":
run_id = begin_run(conn, args.run_name, args.base_url)
print(json.dumps({"ok": True, "run_id": run_id}))
elif args.command == "record-result":
record = json.load(sys.stdin)
result_id = record_result(conn, record)
print(json.dumps({"ok": True, "result_id": result_id}))
elif args.command == "finish-run":
run_id = args.run_id or latest_run_id(conn)
counts = finish_run(conn, run_id, args.status)
print(json.dumps({"ok": True, "run_id": run_id, "counts": counts}))
elif args.command == "export-json":
run_id = args.run_id or latest_run_id(conn)
output = args.output or "results/merged-results.json"
count = export_merged_json(conn, run_id, output)
print(json.dumps({"ok": True, "run_id": run_id, "rows": count, "output": output}))
elif args.command == "export-csv":
run_id = args.run_id or latest_run_id(conn)
output = args.output or "results/UAT_VAPT_SQLite_Export.csv"
count = export_csv(conn, run_id, output)
print(json.dumps({"ok": True, "run_id": run_id, "rows": count, "output": output}))
elif args.command == "export-xlsx":
run_id = args.run_id or latest_run_id(conn)
output = args.output or "results/UAT_VAPT_SQLite_Export.xlsx"
count = export_xlsx(conn, run_id, output)
print(json.dumps({"ok": True, "run_id": run_id, "rows": count, "output": output}))
elif args.command == "summary":
run_id = args.run_id or latest_run_id(conn)
rows = conn.execute("SELECT status, COUNT(*) AS count FROM test_results WHERE run_id=? GROUP BY status", (run_id,)).fetchall()
print(json.dumps({r["status"]: r["count"] for r in rows}, indent=2))
finally:
conn.close()
if __name__ == "__main__":
main()