snapline-core 0.1.10__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.
- snapline_core-0.1.10/.gitignore +5 -0
- snapline_core-0.1.10/PKG-INFO +12 -0
- snapline_core-0.1.10/examples/basic_usage.py +19 -0
- snapline_core-0.1.10/pyproject.toml +23 -0
- snapline_core-0.1.10/snapline/core/__init__.py +48 -0
- snapline_core-0.1.10/snapline/core/api_config/to_api_request_config.py +42 -0
- snapline_core-0.1.10/snapline/core/cross_system/run_api_to_db.py +63 -0
- snapline_core-0.1.10/snapline/core/cross_system/run_db_to_api.py +65 -0
- snapline_core-0.1.10/snapline/core/db/__init__.py +53 -0
- snapline_core-0.1.10/snapline/core/db/db_connection.py +39 -0
- snapline_core-0.1.10/snapline/core/db/sqlite_connection.py +44 -0
- snapline_core-0.1.10/snapline/core/db_comparison/run_db_comparison.py +36 -0
- snapline_core-0.1.10/snapline/core/reporting/html_reporter.py +147 -0
- snapline_core-0.1.10/snapline/core/reporting/json_reporter.py +9 -0
- snapline_core-0.1.10/snapline/core/reporting/text_reporter.py +38 -0
- snapline_core-0.1.10/snapline/core/reporting/types.py +28 -0
- snapline_core-0.1.10/snapline/core/reporting/write_report.py +58 -0
- snapline_core-0.1.10/snapline/core/test_suite.py +137 -0
- snapline_core-0.1.10/snapline/core/types.py +95 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: snapline-core
|
|
3
|
+
Version: 0.1.10
|
|
4
|
+
Summary: Declarative snapshot and reconciliation testing — Snapline core
|
|
5
|
+
Project-URL: Homepage, https://github.com/vaagatech/snapline-python
|
|
6
|
+
Project-URL: Repository, https://github.com/vaagatech/snapline-python
|
|
7
|
+
Author-email: VaagaTech <info@vaagatech.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: snapline-api-adapters==0.1.10
|
|
11
|
+
Requires-Dist: snapline-auth-adapters==0.1.10
|
|
12
|
+
Requires-Dist: snapline-engine==0.1.10
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Basic Snapline usage example."""
|
|
2
|
+
from snapline.engine import reconcile
|
|
3
|
+
|
|
4
|
+
live = {"email": "alice@example.com", "status": "synced", "pincode": "123456"}
|
|
5
|
+
expected = {"email": "alice@example.com", "status": "synced"}
|
|
6
|
+
|
|
7
|
+
result = reconcile(
|
|
8
|
+
live,
|
|
9
|
+
expected,
|
|
10
|
+
{
|
|
11
|
+
"ignoreFields": ["pincode"],
|
|
12
|
+
"transformations": {
|
|
13
|
+
"status": lambda value, _key=None, _parent=None: str(value).upper(),
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
print("match:", result["match"])
|
|
19
|
+
print("diff:", result["diff"])
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "snapline-core"
|
|
7
|
+
version = "0.1.10"
|
|
8
|
+
description = "Declarative snapshot and reconciliation testing — Snapline core"
|
|
9
|
+
license = "MIT"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
authors = [{ name = "VaagaTech", email = "info@vaagatech.com" }]
|
|
12
|
+
dependencies = [
|
|
13
|
+
"snapline-engine==0.1.10",
|
|
14
|
+
"snapline-api-adapters==0.1.10",
|
|
15
|
+
"snapline-auth-adapters==0.1.10",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Homepage = "https://github.com/vaagatech/snapline-python"
|
|
20
|
+
Repository = "https://github.com/vaagatech/snapline-python"
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["snapline"]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from snapline.api_adapters import api, execute_api, resolve_url
|
|
2
|
+
from snapline.auth_adapters import AuthAdapter, auth
|
|
3
|
+
from snapline.engine import assert_against_file, load_json_file, reconcile
|
|
4
|
+
|
|
5
|
+
from .api_config.to_api_request_config import to_api_request_config
|
|
6
|
+
from .cross_system.run_api_to_db import run_api_to_db
|
|
7
|
+
from .cross_system.run_db_to_api import run_db_to_api
|
|
8
|
+
from .db import (
|
|
9
|
+
SqliteConnection,
|
|
10
|
+
create_sqlite_connection,
|
|
11
|
+
db,
|
|
12
|
+
exec_sqlite_file,
|
|
13
|
+
exec_sqlite_sql,
|
|
14
|
+
seed_db,
|
|
15
|
+
)
|
|
16
|
+
from .db.db_connection import DbConnection
|
|
17
|
+
from .db_comparison.run_db_comparison import run_db_comparison
|
|
18
|
+
from .reporting.write_report import build_report, render_report, write_test_report
|
|
19
|
+
from .test_suite import test_suite
|
|
20
|
+
|
|
21
|
+
execute_api_request = execute_api
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"AuthAdapter",
|
|
25
|
+
"DbConnection",
|
|
26
|
+
"SqliteConnection",
|
|
27
|
+
"api",
|
|
28
|
+
"assert_against_file",
|
|
29
|
+
"auth",
|
|
30
|
+
"build_report",
|
|
31
|
+
"create_sqlite_connection",
|
|
32
|
+
"db",
|
|
33
|
+
"execute_api",
|
|
34
|
+
"execute_api_request",
|
|
35
|
+
"exec_sqlite_file",
|
|
36
|
+
"exec_sqlite_sql",
|
|
37
|
+
"load_json_file",
|
|
38
|
+
"reconcile",
|
|
39
|
+
"render_report",
|
|
40
|
+
"resolve_url",
|
|
41
|
+
"run_api_to_db",
|
|
42
|
+
"run_db_comparison",
|
|
43
|
+
"run_db_to_api",
|
|
44
|
+
"seed_db",
|
|
45
|
+
"test_suite",
|
|
46
|
+
"to_api_request_config",
|
|
47
|
+
"write_test_report",
|
|
48
|
+
]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ..types import ApiFileTestConfig, ApiRequestConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def to_api_request_config(config: ApiFileTestConfig | dict[str, Any]) -> ApiRequestConfig:
|
|
10
|
+
protocol = config.get("protocol")
|
|
11
|
+
|
|
12
|
+
if protocol == "soap":
|
|
13
|
+
return {
|
|
14
|
+
"protocol": "soap",
|
|
15
|
+
"endpoint": config.get("endpoint", "/"),
|
|
16
|
+
"soapAction": config.get("soapAction"),
|
|
17
|
+
"envelope": config.get("envelope"),
|
|
18
|
+
"inputFile": config.get("inputFile"),
|
|
19
|
+
"headers": config.get("headers"),
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if protocol == "graphql":
|
|
23
|
+
return {
|
|
24
|
+
"protocol": "graphql",
|
|
25
|
+
"endpoint": config.get("endpoint", "/graphql"),
|
|
26
|
+
"query": config.get("query"),
|
|
27
|
+
"queryFile": config.get("queryFile"),
|
|
28
|
+
"variables": config.get("variables"),
|
|
29
|
+
"variablesFile": config.get("variablesFile"),
|
|
30
|
+
"inputFile": config.get("inputFile"),
|
|
31
|
+
"dataPath": config.get("dataPath"),
|
|
32
|
+
"headers": config.get("headers"),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
"protocol": "rest",
|
|
37
|
+
"endpoint": config.get("endpoint", "/"),
|
|
38
|
+
"method": config.get("method"),
|
|
39
|
+
"inputFile": config.get("inputFile"),
|
|
40
|
+
"body": config.get("body"),
|
|
41
|
+
"headers": config.get("headers"),
|
|
42
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from snapline.api_adapters import execute_api
|
|
6
|
+
from snapline.engine import reconcile
|
|
7
|
+
|
|
8
|
+
from ..types import ApiToDbConfig, CrossSystemResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def run_api_to_db(
|
|
12
|
+
config: ApiToDbConfig | dict[str, Any],
|
|
13
|
+
auth_headers: dict[str, str] | None = None,
|
|
14
|
+
base_url: str | None = None,
|
|
15
|
+
fetch_impl: Any | None = None,
|
|
16
|
+
) -> CrossSystemResult:
|
|
17
|
+
api_config = dict(config["api"])
|
|
18
|
+
expected_status = api_config.pop("expectedStatus", 200)
|
|
19
|
+
|
|
20
|
+
response = execute_api(
|
|
21
|
+
api_config,
|
|
22
|
+
{
|
|
23
|
+
"baseUrl": base_url,
|
|
24
|
+
"authHeaders": auth_headers or {},
|
|
25
|
+
"fetchImpl": fetch_impl,
|
|
26
|
+
},
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
if response["status"] != expected_status:
|
|
30
|
+
return {
|
|
31
|
+
"match": False,
|
|
32
|
+
"source": response["data"],
|
|
33
|
+
"target": None,
|
|
34
|
+
"diff": {
|
|
35
|
+
"path": "(http)",
|
|
36
|
+
"actual": response["status"],
|
|
37
|
+
"expected": expected_status,
|
|
38
|
+
"message": f"Expected status {expected_status}, got {response['status']}",
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
rows = await config["db"]["db"].query(
|
|
43
|
+
config["db"]["query"],
|
|
44
|
+
config["db"].get("params", {}),
|
|
45
|
+
)
|
|
46
|
+
db_data = rows[0] if rows else None
|
|
47
|
+
|
|
48
|
+
result = reconcile(
|
|
49
|
+
response["data"],
|
|
50
|
+
db_data,
|
|
51
|
+
{
|
|
52
|
+
"ignoreFields": config.get("ignoreFields", []),
|
|
53
|
+
"transformations": config.get("transformations", {}),
|
|
54
|
+
"dataMapping": config.get("dataMapping", {}),
|
|
55
|
+
},
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
"match": result["match"],
|
|
60
|
+
"source": result["processed"],
|
|
61
|
+
"target": result["expected"],
|
|
62
|
+
"diff": result["diff"],
|
|
63
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from snapline.api_adapters import execute_api
|
|
6
|
+
from snapline.engine import reconcile
|
|
7
|
+
|
|
8
|
+
from ..types import CrossSystemResult, DbToApiConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def run_db_to_api(
|
|
12
|
+
config: DbToApiConfig | dict[str, Any],
|
|
13
|
+
auth_headers: dict[str, str] | None = None,
|
|
14
|
+
base_url: str | None = None,
|
|
15
|
+
fetch_impl: Any | None = None,
|
|
16
|
+
) -> CrossSystemResult:
|
|
17
|
+
rows = await config["db"]["db"].query(
|
|
18
|
+
config["db"]["query"],
|
|
19
|
+
config["db"].get("params", {}),
|
|
20
|
+
)
|
|
21
|
+
db_data = rows[0] if rows else None
|
|
22
|
+
|
|
23
|
+
api_config = dict(config["api"])
|
|
24
|
+
expected_status = api_config.pop("expectedStatus", 200)
|
|
25
|
+
input_from_db = config.get("inputFromDb", True)
|
|
26
|
+
|
|
27
|
+
response = execute_api(
|
|
28
|
+
api_config,
|
|
29
|
+
{
|
|
30
|
+
"baseUrl": base_url,
|
|
31
|
+
"authHeaders": auth_headers or {},
|
|
32
|
+
"fetchImpl": fetch_impl,
|
|
33
|
+
"inputFromRow": db_data if input_from_db and db_data else None,
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if response["status"] != expected_status:
|
|
38
|
+
return {
|
|
39
|
+
"match": False,
|
|
40
|
+
"source": db_data,
|
|
41
|
+
"target": response["data"],
|
|
42
|
+
"diff": {
|
|
43
|
+
"path": "(http)",
|
|
44
|
+
"actual": response["status"],
|
|
45
|
+
"expected": expected_status,
|
|
46
|
+
"message": f"Expected status {expected_status}, got {response['status']}",
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
result = reconcile(
|
|
51
|
+
db_data,
|
|
52
|
+
response["data"],
|
|
53
|
+
{
|
|
54
|
+
"ignoreFields": config.get("ignoreFields", []),
|
|
55
|
+
"transformations": config.get("transformations", {}),
|
|
56
|
+
"dataMapping": config.get("dataMapping", {}),
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
"match": result["match"],
|
|
62
|
+
"source": result["processed"],
|
|
63
|
+
"target": result["expected"],
|
|
64
|
+
"diff": result["diff"],
|
|
65
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ..types import DbRow
|
|
6
|
+
from .db_connection import DbConnection
|
|
7
|
+
from .sqlite_connection import (
|
|
8
|
+
SqliteConnection,
|
|
9
|
+
create_sqlite_connection,
|
|
10
|
+
exec_sqlite_file,
|
|
11
|
+
exec_sqlite_sql,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
_db_registry: dict[str, list[DbRow]] = {}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def seed_db(connection_string: str, rows: list[DbRow]) -> None:
|
|
18
|
+
_db_registry[connection_string] = rows
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def create_db_connection(dialect: str, connection_string: str) -> DbConnection:
|
|
22
|
+
seed = _db_registry.get(connection_string, [])
|
|
23
|
+
return DbConnection(dialect, connection_string, seed)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DbFactory:
|
|
27
|
+
@staticmethod
|
|
28
|
+
def postgres(connection_string: str) -> DbConnection:
|
|
29
|
+
return create_db_connection("postgres", connection_string)
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
def mysql(connection_string: str) -> DbConnection:
|
|
33
|
+
return create_db_connection("mysql", connection_string)
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def sqlite(path: str = ":memory:") -> SqliteConnection:
|
|
37
|
+
return create_sqlite_connection(path)
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def seed(connection_string: str, rows: list[DbRow]) -> None:
|
|
41
|
+
seed_db(connection_string, rows)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
db = DbFactory()
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"SqliteConnection",
|
|
48
|
+
"create_sqlite_connection",
|
|
49
|
+
"db",
|
|
50
|
+
"exec_sqlite_file",
|
|
51
|
+
"exec_sqlite_sql",
|
|
52
|
+
"seed_db",
|
|
53
|
+
]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ..types import DbDialect, DbRow
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DbConnection:
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
dialect: DbDialect,
|
|
13
|
+
connection_string: str,
|
|
14
|
+
rows: list[DbRow] | None = None,
|
|
15
|
+
) -> None:
|
|
16
|
+
self.dialect = dialect
|
|
17
|
+
self.connection_string = connection_string
|
|
18
|
+
self._rows = list(rows or [])
|
|
19
|
+
|
|
20
|
+
async def query(self, query: str, params: dict[str, Any] | None = None) -> list[DbRow]:
|
|
21
|
+
normalized = re.sub(r"\s+", " ", query).strip().lower()
|
|
22
|
+
if not normalized.startswith("select"):
|
|
23
|
+
raise ValueError(f"Unsupported query: {query}")
|
|
24
|
+
|
|
25
|
+
results = list(self._rows)
|
|
26
|
+
params = params or {}
|
|
27
|
+
|
|
28
|
+
for key, value in params.items():
|
|
29
|
+
results = [row for row in results if row.get(key) == value]
|
|
30
|
+
|
|
31
|
+
columns_match = re.search(r"select\s+(.+?)\s+from", query, flags=re.IGNORECASE)
|
|
32
|
+
if columns_match and columns_match.group(1).strip() != "*":
|
|
33
|
+
columns = [column.strip() for column in columns_match.group(1).split(",")]
|
|
34
|
+
projected: list[DbRow] = []
|
|
35
|
+
for row in results:
|
|
36
|
+
projected.append({column: row[column] for column in columns if column in row})
|
|
37
|
+
results = projected
|
|
38
|
+
|
|
39
|
+
return results
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import sqlite3
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..types import DbRow
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _normalize_named_params(query: str) -> str:
|
|
12
|
+
return re.sub(r":([a-zA-Z_][a-zA-Z0-9_]*)", r"@\1", query)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SqliteConnection:
|
|
16
|
+
def __init__(self, database: sqlite3.Connection) -> None:
|
|
17
|
+
self._database = database
|
|
18
|
+
|
|
19
|
+
def exec(self, sql: str) -> None:
|
|
20
|
+
self._database.executescript(sql)
|
|
21
|
+
|
|
22
|
+
async def query(self, query: str, params: dict[str, Any] | None = None) -> list[DbRow]:
|
|
23
|
+
normalized = _normalize_named_params(query)
|
|
24
|
+
cursor = self._database.execute(normalized, params or {})
|
|
25
|
+
columns = [description[0] for description in cursor.description or []]
|
|
26
|
+
return [dict(zip(columns, row)) for row in cursor.fetchall()]
|
|
27
|
+
|
|
28
|
+
def close(self) -> None:
|
|
29
|
+
self._database.close()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def create_sqlite_connection(path: str = ":memory:") -> SqliteConnection:
|
|
33
|
+
database = sqlite3.connect(path)
|
|
34
|
+
database.row_factory = sqlite3.Row
|
|
35
|
+
database.execute("PRAGMA foreign_keys = ON")
|
|
36
|
+
return SqliteConnection(database)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def exec_sqlite_sql(connection: SqliteConnection, sql: str) -> None:
|
|
40
|
+
connection.exec(sql)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def exec_sqlite_file(connection: SqliteConnection, file_path: str | Path) -> None:
|
|
44
|
+
connection.exec(Path(file_path).read_text(encoding="utf-8"))
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from snapline.engine import reconcile
|
|
4
|
+
|
|
5
|
+
from ..types import CrossSystemResult, DbComparisonConfig
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def run_db_comparison(db_comparison: DbComparisonConfig | dict) -> CrossSystemResult:
|
|
9
|
+
source_rows = await db_comparison["sourceDb"].query(
|
|
10
|
+
db_comparison["query"],
|
|
11
|
+
db_comparison.get("params", {}),
|
|
12
|
+
)
|
|
13
|
+
target_rows = await db_comparison["targetDb"].query(
|
|
14
|
+
db_comparison["query"],
|
|
15
|
+
db_comparison.get("params", {}),
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
source_data = source_rows[0] if source_rows else None
|
|
19
|
+
target_data = target_rows[0] if target_rows else None
|
|
20
|
+
|
|
21
|
+
result = reconcile(
|
|
22
|
+
source_data,
|
|
23
|
+
target_data,
|
|
24
|
+
{
|
|
25
|
+
"ignoreFields": db_comparison.get("ignoreFields", []),
|
|
26
|
+
"transformations": db_comparison.get("transformations", {}),
|
|
27
|
+
"dataMapping": db_comparison.get("dataMapping", {}),
|
|
28
|
+
},
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
"match": result["match"],
|
|
33
|
+
"source": result["processed"],
|
|
34
|
+
"target": result["expected"],
|
|
35
|
+
"diff": result["diff"],
|
|
36
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import html
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .types import TestRunReport
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _escape(value: str) -> str:
|
|
11
|
+
return html.escape(value)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _render_diff_block(diff: Any) -> str:
|
|
15
|
+
if not diff:
|
|
16
|
+
return ""
|
|
17
|
+
payload = _escape(json.dumps(diff, indent=2))
|
|
18
|
+
return f'<pre class="diff">{payload}</pre>'
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def render_html_report(report: TestRunReport) -> str:
|
|
22
|
+
suite_rows = []
|
|
23
|
+
for suite in report["suites"]:
|
|
24
|
+
step_rows = []
|
|
25
|
+
for step in suite["results"]:
|
|
26
|
+
status_class = "pass" if step["passed"] else "fail"
|
|
27
|
+
message = (
|
|
28
|
+
f'<p class="message">{_escape(step["message"])}</p>'
|
|
29
|
+
if step.get("message")
|
|
30
|
+
else ""
|
|
31
|
+
)
|
|
32
|
+
diff = _render_diff_block(step.get("diff")) if not step["passed"] else ""
|
|
33
|
+
step_rows.append(
|
|
34
|
+
f"""
|
|
35
|
+
<li class="step {status_class}">
|
|
36
|
+
<span class="badge">{"PASS" if step["passed"] else "FAIL"}</span>
|
|
37
|
+
<span class="step-name">{_escape(step["step"])}</span>
|
|
38
|
+
{message}
|
|
39
|
+
{diff}
|
|
40
|
+
</li>"""
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
suite_rows.append(
|
|
44
|
+
f"""
|
|
45
|
+
<section class="suite {"pass" if suite["passed"] else "fail"}">
|
|
46
|
+
<h2>{_escape(suite["name"])}</h2>
|
|
47
|
+
<p class="suite-status">{"PASSED" if suite["passed"] else "FAILED"}</p>
|
|
48
|
+
<ul class="steps">{''.join(step_rows)}</ul>
|
|
49
|
+
</section>"""
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
duration = report["summary"].get("durationMs") or 0
|
|
53
|
+
return f"""<!DOCTYPE html>
|
|
54
|
+
<html lang="en">
|
|
55
|
+
<head>
|
|
56
|
+
<meta charset="utf-8">
|
|
57
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
58
|
+
<title>snapline-engine Test Report</title>
|
|
59
|
+
<style>
|
|
60
|
+
:root {{
|
|
61
|
+
color-scheme: light dark;
|
|
62
|
+
--bg: #0f172a;
|
|
63
|
+
--panel: #111827;
|
|
64
|
+
--text: #e5e7eb;
|
|
65
|
+
--muted: #94a3b8;
|
|
66
|
+
--pass: #16a34a;
|
|
67
|
+
--fail: #dc2626;
|
|
68
|
+
--border: #334155;
|
|
69
|
+
}}
|
|
70
|
+
body {{
|
|
71
|
+
margin: 0;
|
|
72
|
+
font-family: ui-sans-serif, system-ui, sans-serif;
|
|
73
|
+
background: var(--bg);
|
|
74
|
+
color: var(--text);
|
|
75
|
+
line-height: 1.5;
|
|
76
|
+
}}
|
|
77
|
+
main {{ max-width: 960px; margin: 0 auto; padding: 2rem; }}
|
|
78
|
+
h1 {{ margin-top: 0; }}
|
|
79
|
+
.summary {{
|
|
80
|
+
display: grid;
|
|
81
|
+
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
|
82
|
+
gap: 1rem;
|
|
83
|
+
margin: 1.5rem 0 2rem;
|
|
84
|
+
}}
|
|
85
|
+
.metric {{
|
|
86
|
+
background: var(--panel);
|
|
87
|
+
border: 1px solid var(--border);
|
|
88
|
+
border-radius: 12px;
|
|
89
|
+
padding: 1rem;
|
|
90
|
+
}}
|
|
91
|
+
.metric strong {{ display: block; font-size: 1.5rem; }}
|
|
92
|
+
.metric span {{ color: var(--muted); font-size: 0.875rem; }}
|
|
93
|
+
.suite {{
|
|
94
|
+
background: var(--panel);
|
|
95
|
+
border: 1px solid var(--border);
|
|
96
|
+
border-radius: 12px;
|
|
97
|
+
padding: 1rem 1.25rem;
|
|
98
|
+
margin-bottom: 1rem;
|
|
99
|
+
}}
|
|
100
|
+
.suite.pass {{ border-left: 4px solid var(--pass); }}
|
|
101
|
+
.suite.fail {{ border-left: 4px solid var(--fail); }}
|
|
102
|
+
.suite h2 {{ margin: 0 0 0.25rem; font-size: 1.125rem; }}
|
|
103
|
+
.suite-status {{ margin: 0 0 1rem; color: var(--muted); }}
|
|
104
|
+
.steps {{ list-style: none; padding: 0; margin: 0; }}
|
|
105
|
+
.step {{ padding: 0.75rem 0; border-top: 1px solid var(--border); }}
|
|
106
|
+
.badge {{
|
|
107
|
+
display: inline-block;
|
|
108
|
+
min-width: 3rem;
|
|
109
|
+
margin-right: 0.5rem;
|
|
110
|
+
padding: 0.125rem 0.5rem;
|
|
111
|
+
border-radius: 999px;
|
|
112
|
+
font-size: 0.75rem;
|
|
113
|
+
font-weight: 700;
|
|
114
|
+
text-align: center;
|
|
115
|
+
}}
|
|
116
|
+
.step.pass .badge {{ background: rgba(22, 163, 74, 0.2); color: var(--pass); }}
|
|
117
|
+
.step.fail .badge {{ background: rgba(220, 38, 38, 0.2); color: var(--fail); }}
|
|
118
|
+
.message {{ margin: 0.5rem 0 0; color: var(--muted); }}
|
|
119
|
+
.diff {{
|
|
120
|
+
margin: 0.75rem 0 0;
|
|
121
|
+
padding: 0.75rem;
|
|
122
|
+
overflow-x: auto;
|
|
123
|
+
background: #020617;
|
|
124
|
+
border-radius: 8px;
|
|
125
|
+
border: 1px solid var(--border);
|
|
126
|
+
font-size: 0.8125rem;
|
|
127
|
+
}}
|
|
128
|
+
footer {{ margin-top: 2rem; color: var(--muted); font-size: 0.875rem; }}
|
|
129
|
+
</style>
|
|
130
|
+
</head>
|
|
131
|
+
<body>
|
|
132
|
+
<main>
|
|
133
|
+
<h1>snapline-engine Test Report</h1>
|
|
134
|
+
<p>Generated at {_escape(report["generatedAt"])}</p>
|
|
135
|
+
<div class="summary">
|
|
136
|
+
<div class="metric"><strong>{report["summary"]["total"]}</strong><span>Total suites</span></div>
|
|
137
|
+
<div class="metric"><strong>{report["summary"]["passed"]}</strong><span>Passed</span></div>
|
|
138
|
+
<div class="metric"><strong>{report["summary"]["failed"]}</strong><span>Failed</span></div>
|
|
139
|
+
<div class="metric"><strong>{duration}ms</strong><span>Duration</span></div>
|
|
140
|
+
</div>
|
|
141
|
+
{''.join(suite_rows)}
|
|
142
|
+
<footer>
|
|
143
|
+
Upload this file to any CI dashboard, artifact store, or reporting system.
|
|
144
|
+
</footer>
|
|
145
|
+
</main>
|
|
146
|
+
</body>
|
|
147
|
+
</html>"""
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .types import TestRunReport
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _format_diff(diff: Any) -> str:
|
|
10
|
+
if not diff:
|
|
11
|
+
return "none"
|
|
12
|
+
return json.dumps(diff, indent=2).replace("\n", "\n ")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def render_text_report(report: TestRunReport) -> str:
|
|
16
|
+
lines = [
|
|
17
|
+
"snapline-engine — Test Run Report",
|
|
18
|
+
"=====================================",
|
|
19
|
+
f"Generated: {report['generatedAt']}",
|
|
20
|
+
f"Duration: {report['summary'].get('durationMs') or 0}ms",
|
|
21
|
+
"",
|
|
22
|
+
f"Total: {report['summary']['total']}",
|
|
23
|
+
f"Passed: {report['summary']['passed']}",
|
|
24
|
+
f"Failed: {report['summary']['failed']}",
|
|
25
|
+
"",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
for suite in report["suites"]:
|
|
29
|
+
lines.append(f"{'PASS' if suite['passed'] else 'FAIL'} {suite['name']}")
|
|
30
|
+
for step in suite["results"]:
|
|
31
|
+
lines.append(f" {'✓' if step['passed'] else '✗'} {step['step']}")
|
|
32
|
+
if not step["passed"] and step.get("message"):
|
|
33
|
+
lines.append(f" message: {step['message']}")
|
|
34
|
+
if not step["passed"] and step.get("diff"):
|
|
35
|
+
lines.append(f" diff:\n {_format_diff(step['diff'])}")
|
|
36
|
+
lines.append("")
|
|
37
|
+
|
|
38
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
from ..types import TestSuiteResult
|
|
9
|
+
|
|
10
|
+
ReportFormat = Literal["json", "html", "text"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ReportConfig(dict):
|
|
14
|
+
format: ReportFormat
|
|
15
|
+
outputPath: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TestRunReportMeta(dict):
|
|
19
|
+
durationMs: int | None
|
|
20
|
+
environment: dict[str, Any] | None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TestRunReport(dict):
|
|
24
|
+
generatedAt: str
|
|
25
|
+
framework: str
|
|
26
|
+
summary: dict[str, Any]
|
|
27
|
+
environment: dict[str, Any] | None
|
|
28
|
+
suites: list[TestSuiteResult]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ..types import TestSuiteResult
|
|
8
|
+
from .html_reporter import render_html_report
|
|
9
|
+
from .json_reporter import render_json_report
|
|
10
|
+
from .text_reporter import render_text_report
|
|
11
|
+
from .types import ReportConfig, TestRunReport, TestRunReportMeta
|
|
12
|
+
|
|
13
|
+
FRAMEWORK_NAME = "snapline-engine"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_report(
|
|
17
|
+
suites: list[TestSuiteResult],
|
|
18
|
+
meta: TestRunReportMeta | dict[str, Any] | None = None,
|
|
19
|
+
) -> TestRunReport:
|
|
20
|
+
meta = meta or {}
|
|
21
|
+
passed = sum(1 for suite in suites if suite["passed"])
|
|
22
|
+
failed = len(suites) - passed
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
"generatedAt": datetime.now(timezone.utc).isoformat(),
|
|
26
|
+
"framework": FRAMEWORK_NAME,
|
|
27
|
+
"summary": {
|
|
28
|
+
"total": len(suites),
|
|
29
|
+
"passed": passed,
|
|
30
|
+
"failed": failed,
|
|
31
|
+
"durationMs": meta.get("durationMs"),
|
|
32
|
+
},
|
|
33
|
+
"environment": meta.get("environment"),
|
|
34
|
+
"suites": suites,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def render_report(report: TestRunReport, format: str) -> str:
|
|
39
|
+
if format == "json":
|
|
40
|
+
return render_json_report(report)
|
|
41
|
+
if format == "html":
|
|
42
|
+
return render_html_report(report)
|
|
43
|
+
if format == "text":
|
|
44
|
+
return render_text_report(report)
|
|
45
|
+
raise ValueError(f"Unsupported report format: {format}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def write_test_report(
|
|
49
|
+
suites: list[TestSuiteResult],
|
|
50
|
+
config: ReportConfig | dict[str, Any],
|
|
51
|
+
meta: TestRunReportMeta | dict[str, Any] | None = None,
|
|
52
|
+
) -> str:
|
|
53
|
+
report = build_report(suites, meta)
|
|
54
|
+
content = render_report(report, config["format"])
|
|
55
|
+
output_path = Path(config["outputPath"])
|
|
56
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
output_path.write_text(content, encoding="utf-8")
|
|
58
|
+
return str(output_path)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from snapline.api_adapters import execute_api
|
|
7
|
+
from snapline.engine import assert_against_file
|
|
8
|
+
|
|
9
|
+
from .api_config.to_api_request_config import to_api_request_config
|
|
10
|
+
from .cross_system.run_api_to_db import run_api_to_db
|
|
11
|
+
from .cross_system.run_db_to_api import run_db_to_api
|
|
12
|
+
from .db_comparison.run_db_comparison import run_db_comparison
|
|
13
|
+
from .types import TestStepResult, TestSuiteConfig, TestSuiteResult
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _log_step_result(
|
|
17
|
+
label: str,
|
|
18
|
+
match: bool,
|
|
19
|
+
diff: Any,
|
|
20
|
+
on_fail,
|
|
21
|
+
) -> None:
|
|
22
|
+
if match:
|
|
23
|
+
print(f" ✓ {label}")
|
|
24
|
+
else:
|
|
25
|
+
on_fail()
|
|
26
|
+
print(f" ✗ {label}")
|
|
27
|
+
print(f" diff: {json.dumps(diff)}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def test_suite(name: str, config: TestSuiteConfig | dict[str, Any]) -> TestSuiteResult:
|
|
31
|
+
auth_adapter = config.get("auth")
|
|
32
|
+
api = config.get("api")
|
|
33
|
+
db_comparison = config.get("dbComparison")
|
|
34
|
+
api_to_db = config.get("apiToDb")
|
|
35
|
+
db_to_api = config.get("dbToApi")
|
|
36
|
+
base_url = config.get("baseUrl")
|
|
37
|
+
fetch_impl = config.get("fetchImpl")
|
|
38
|
+
|
|
39
|
+
results: list[TestStepResult] = []
|
|
40
|
+
passed = True
|
|
41
|
+
|
|
42
|
+
def fail() -> None:
|
|
43
|
+
nonlocal passed
|
|
44
|
+
passed = False
|
|
45
|
+
|
|
46
|
+
print(f"\n▶ {name}")
|
|
47
|
+
|
|
48
|
+
auth_headers: dict[str, str] = {}
|
|
49
|
+
if auth_adapter:
|
|
50
|
+
auth_result = await auth_adapter.initialize()
|
|
51
|
+
auth_headers = auth_result["headers"]
|
|
52
|
+
results.append(
|
|
53
|
+
{
|
|
54
|
+
"step": "auth",
|
|
55
|
+
"passed": True,
|
|
56
|
+
"token": "[redacted]" if auth_result.get("token") else None,
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
print(" ✓ auth initialized")
|
|
60
|
+
|
|
61
|
+
if api:
|
|
62
|
+
expected_file = api.get("expectedFile")
|
|
63
|
+
ignore_fields = api.get("ignoreFields", [])
|
|
64
|
+
transformations = api.get("transformations", {})
|
|
65
|
+
data_mapping = api.get("dataMapping", {})
|
|
66
|
+
expected_status = api.get("expectedStatus", 200)
|
|
67
|
+
|
|
68
|
+
api_request = to_api_request_config(api)
|
|
69
|
+
response = execute_api(
|
|
70
|
+
api_request,
|
|
71
|
+
{
|
|
72
|
+
"baseUrl": base_url,
|
|
73
|
+
"authHeaders": auth_headers,
|
|
74
|
+
"fetchImpl": fetch_impl,
|
|
75
|
+
},
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if response["status"] != expected_status:
|
|
79
|
+
fail()
|
|
80
|
+
results.append(
|
|
81
|
+
{
|
|
82
|
+
"step": "api",
|
|
83
|
+
"passed": False,
|
|
84
|
+
"message": f"Expected status {expected_status}, got {response['status']}",
|
|
85
|
+
"data": response["data"],
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
print(
|
|
89
|
+
f" ✗ api status mismatch (expected {expected_status}, got {response['status']})"
|
|
90
|
+
)
|
|
91
|
+
elif expected_file:
|
|
92
|
+
assertion = assert_against_file(
|
|
93
|
+
response["data"],
|
|
94
|
+
expected_file,
|
|
95
|
+
{
|
|
96
|
+
"ignoreFields": ignore_fields,
|
|
97
|
+
"transformations": transformations,
|
|
98
|
+
"dataMapping": data_mapping,
|
|
99
|
+
},
|
|
100
|
+
)
|
|
101
|
+
results.append(
|
|
102
|
+
{
|
|
103
|
+
"step": "api-file",
|
|
104
|
+
"passed": assertion["match"],
|
|
105
|
+
"diff": assertion["diff"],
|
|
106
|
+
"processed": assertion["processed"],
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
_log_step_result(
|
|
110
|
+
"api response reconciled with fixture file",
|
|
111
|
+
assertion["match"],
|
|
112
|
+
assertion["diff"],
|
|
113
|
+
fail,
|
|
114
|
+
)
|
|
115
|
+
else:
|
|
116
|
+
results.append({"step": "api", "passed": True, "data": response["data"]})
|
|
117
|
+
print(" ✓ api request completed")
|
|
118
|
+
|
|
119
|
+
if db_comparison:
|
|
120
|
+
db_result = await run_db_comparison(db_comparison)
|
|
121
|
+
results.append({"step": "db-to-db", "passed": db_result["match"], **db_result})
|
|
122
|
+
_log_step_result("db-to-db reconciliation passed", db_result["match"], db_result["diff"], fail)
|
|
123
|
+
|
|
124
|
+
if api_to_db:
|
|
125
|
+
result = await run_api_to_db(api_to_db, auth_headers, base_url, fetch_impl)
|
|
126
|
+
results.append({"step": "api-to-db", "passed": result["match"], **result})
|
|
127
|
+
_log_step_result("api-to-db reconciliation passed", result["match"], result["diff"], fail)
|
|
128
|
+
|
|
129
|
+
if db_to_api:
|
|
130
|
+
result = await run_db_to_api(db_to_api, auth_headers, base_url, fetch_impl)
|
|
131
|
+
results.append({"step": "db-to-api", "passed": result["match"], **result})
|
|
132
|
+
_log_step_result("db-to-api reconciliation passed", result["match"], result["diff"], fail)
|
|
133
|
+
|
|
134
|
+
summary = "PASSED" if passed else "FAILED"
|
|
135
|
+
print(f"\n{'✅' if passed else '❌'} {name}: {summary}\n")
|
|
136
|
+
|
|
137
|
+
return {"name": name, "passed": passed, "results": results}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal, Protocol
|
|
4
|
+
|
|
5
|
+
from snapline.api_adapters.types import ApiRequestConfig
|
|
6
|
+
from snapline.auth_adapters import AuthAdapter
|
|
7
|
+
from snapline.engine.types import DataMappingMap, DiffResult, ReconcileOptions, TransformationMap
|
|
8
|
+
|
|
9
|
+
DbRow = dict[str, Any]
|
|
10
|
+
DbDialect = Literal["postgres", "mysql", "sqlite"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DbConnectionLike(Protocol):
|
|
14
|
+
async def query(self, query: str, params: dict[str, Any] | None = None) -> list[DbRow]: ...
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DbQueryConfig(dict):
|
|
18
|
+
db: DbConnectionLike
|
|
19
|
+
query: str
|
|
20
|
+
params: dict[str, Any] | None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ApiFileTestConfig(ReconcileOptions):
|
|
24
|
+
endpoint: str | None
|
|
25
|
+
method: str | None
|
|
26
|
+
inputFile: str | None
|
|
27
|
+
expectedFile: str | None
|
|
28
|
+
body: Any
|
|
29
|
+
headers: dict[str, str] | None
|
|
30
|
+
expectedStatus: int | None
|
|
31
|
+
protocol: Literal["rest", "soap", "graphql"] | None
|
|
32
|
+
soapAction: str | None
|
|
33
|
+
envelope: str | None
|
|
34
|
+
query: str | None
|
|
35
|
+
queryFile: str | None
|
|
36
|
+
variables: dict[str, Any] | None
|
|
37
|
+
variablesFile: str | None
|
|
38
|
+
dataPath: str | None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class DbComparisonConfig(ReconcileOptions):
|
|
42
|
+
sourceDb: DbConnectionLike
|
|
43
|
+
targetDb: DbConnectionLike
|
|
44
|
+
query: str
|
|
45
|
+
params: dict[str, Any] | None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ApiToDbConfig(ReconcileOptions):
|
|
49
|
+
api: ApiRequestConfig
|
|
50
|
+
db: DbQueryConfig
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class DbToApiConfig(ReconcileOptions):
|
|
54
|
+
db: DbQueryConfig
|
|
55
|
+
api: ApiRequestConfig
|
|
56
|
+
inputFromDb: bool | None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TestSuiteConfig(dict):
|
|
60
|
+
auth: AuthAdapter | None
|
|
61
|
+
api: ApiFileTestConfig | None
|
|
62
|
+
dbComparison: DbComparisonConfig | None
|
|
63
|
+
apiToDb: ApiToDbConfig | None
|
|
64
|
+
dbToApi: DbToApiConfig | None
|
|
65
|
+
baseUrl: str | None
|
|
66
|
+
fetchImpl: Any | None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TestStepResult(dict):
|
|
70
|
+
step: str
|
|
71
|
+
passed: bool
|
|
72
|
+
message: str | None
|
|
73
|
+
data: Any
|
|
74
|
+
diff: DiffResult | None
|
|
75
|
+
processed: Any
|
|
76
|
+
token: str | None
|
|
77
|
+
source: Any
|
|
78
|
+
target: Any
|
|
79
|
+
match: bool | None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TestSuiteResult(dict):
|
|
83
|
+
name: str
|
|
84
|
+
passed: bool
|
|
85
|
+
results: list[TestStepResult]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class CrossSystemResult(dict):
|
|
89
|
+
match: bool
|
|
90
|
+
source: Any
|
|
91
|
+
target: Any
|
|
92
|
+
diff: DiffResult | None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
DbComparisonResult = CrossSystemResult
|