Skip to content

Commit 8fa8283

Browse files
committed
Update PR
* Convert to a python harness
1 parent ced784d commit 8fa8283

10 files changed

Lines changed: 440 additions & 102 deletions

File tree

.github/workflows/e2e-personal.yaml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ jobs:
2323
run: |
2424
dnf -y update
2525
dnf -y group install development-tools
26-
dnf -y install ldc libcurl-devel sqlite-devel dbus-devel jq
26+
dnf -y install python3 ldc libcurl-devel sqlite-devel dbus-devel jq
2727
2828
- name: Build + local install prefix
2929
run: |
@@ -57,7 +57,7 @@ jobs:
5757
E2E_TARGET: personal
5858
RUN_ID: ${{ github.run_id }}
5959
run: |
60-
bash ci/e2e/run.sh
60+
python3 ci/e2e/run.py
6161
6262
- name: Upload E2E artefacts
6363
if: always()
@@ -75,7 +75,6 @@ jobs:
7575
steps:
7676
- uses: actions/checkout@v4
7777

78-
# Download the artifact produced by the e2e_personal job
7978
- name: Download artefact
8079
uses: actions/download-artifact@v4
8180
with:
@@ -115,6 +114,7 @@ jobs:
115114
echo "md<<EOF" >> "$GITHUB_OUTPUT"
116115
echo -e "$md" >> "$GITHUB_OUTPUT"
117116
echo "EOF" >> "$GITHUB_OUTPUT"
117+
118118
- name: Find PR associated with this commit
119119
id: pr
120120
uses: actions/github-script@v7
@@ -164,4 +164,4 @@ jobs:
164164
await github.rest.issues.createComment({
165165
owner, repo, issue_number, body
166166
});
167-
}
167+
}

ci/e2e/framework/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""
2+
E2E framework package for GitHub Actions based validation.
3+
"""

ci/e2e/framework/base.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from __future__ import annotations
2+
3+
from abc import ABC, abstractmethod
4+
5+
from framework.context import E2EContext
6+
from framework.result import TestResult
7+
8+
9+
class E2ETestCase(ABC):
10+
"""
11+
Base class for all E2E test cases.
12+
"""
13+
14+
case_id: str = ""
15+
name: str = ""
16+
description: str = ""
17+
18+
@abstractmethod
19+
def run(self, context: E2EContext) -> TestResult:
20+
"""
21+
Execute the test case and return a structured TestResult.
22+
"""
23+
raise NotImplementedError

ci/e2e/framework/context.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
from __future__ import annotations
2+
3+
import os
4+
from dataclasses import dataclass
5+
from pathlib import Path
6+
7+
from framework.utils import ensure_directory, timestamp_now, write_text_file_append
8+
9+
10+
@dataclass
11+
class E2EContext:
12+
"""
13+
Runtime context for the E2E framework.
14+
"""
15+
16+
onedrive_bin: str
17+
e2e_target: str
18+
run_id: str
19+
20+
repo_root: Path
21+
out_dir: Path
22+
logs_dir: Path
23+
state_dir: Path
24+
work_root: Path
25+
26+
@classmethod
27+
def from_environment(cls) -> "E2EContext":
28+
onedrive_bin = os.environ.get("ONEDRIVE_BIN", "").strip()
29+
e2e_target = os.environ.get("E2E_TARGET", "").strip()
30+
run_id = os.environ.get("RUN_ID", "").strip()
31+
32+
if not onedrive_bin:
33+
raise RuntimeError("Environment variable ONEDRIVE_BIN must be set")
34+
if not e2e_target:
35+
raise RuntimeError("Environment variable E2E_TARGET must be set")
36+
if not run_id:
37+
raise RuntimeError("Environment variable RUN_ID must be set")
38+
39+
repo_root = Path.cwd()
40+
out_dir = repo_root / "ci" / "e2e" / "out"
41+
logs_dir = out_dir / "logs"
42+
state_dir = out_dir / "state"
43+
44+
runner_temp = os.environ.get("RUNNER_TEMP", "/tmp").strip()
45+
work_root = Path(runner_temp) / f"onedrive-e2e-{e2e_target}"
46+
47+
return cls(
48+
onedrive_bin=onedrive_bin,
49+
e2e_target=e2e_target,
50+
run_id=run_id,
51+
repo_root=repo_root,
52+
out_dir=out_dir,
53+
logs_dir=logs_dir,
54+
state_dir=state_dir,
55+
work_root=work_root,
56+
)
57+
58+
@property
59+
def master_log_file(self) -> Path:
60+
return self.out_dir / "run.log"
61+
62+
def log(self, message: str) -> None:
63+
ensure_directory(self.out_dir)
64+
line = f"[{timestamp_now()}] {message}\n"
65+
print(line, end="")
66+
write_text_file_append(self.master_log_file, line)

ci/e2e/framework/result.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass, field
4+
5+
6+
@dataclass
7+
class TestResult:
8+
"""
9+
Structured test result returned by each test case.
10+
"""
11+
12+
case_id: str
13+
name: str
14+
status: str
15+
reason: str = ""
16+
artifacts: list[str] = field(default_factory=list)
17+
details: dict = field(default_factory=dict)
18+
19+
@staticmethod
20+
def pass_result(
21+
case_id: str,
22+
name: str,
23+
artifacts: list[str] | None = None,
24+
details: dict | None = None,
25+
) -> "TestResult":
26+
return TestResult(
27+
case_id=case_id,
28+
name=name,
29+
status="pass",
30+
reason="",
31+
artifacts=artifacts or [],
32+
details=details or {},
33+
)
34+
35+
@staticmethod
36+
def fail_result(
37+
case_id: str,
38+
name: str,
39+
reason: str,
40+
artifacts: list[str] | None = None,
41+
details: dict | None = None,
42+
) -> "TestResult":
43+
return TestResult(
44+
case_id=case_id,
45+
name=name,
46+
status="fail",
47+
reason=reason,
48+
artifacts=artifacts or [],
49+
details=details or {},
50+
)

ci/e2e/framework/utils.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
from __future__ import annotations
2+
3+
import os
4+
import shutil
5+
import subprocess
6+
from dataclasses import dataclass
7+
from datetime import datetime, timezone
8+
from pathlib import Path
9+
10+
11+
@dataclass
12+
class CommandResult:
13+
command: list[str]
14+
returncode: int
15+
stdout: str
16+
stderr: str
17+
18+
@property
19+
def ok(self) -> bool:
20+
return self.returncode == 0
21+
22+
23+
def timestamp_now() -> str:
24+
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
25+
26+
27+
def ensure_directory(path: Path) -> None:
28+
path.mkdir(parents=True, exist_ok=True)
29+
30+
31+
def reset_directory(path: Path) -> None:
32+
if path.exists():
33+
shutil.rmtree(path)
34+
path.mkdir(parents=True, exist_ok=True)
35+
36+
37+
def write_text_file(path: Path, content: str) -> None:
38+
ensure_directory(path.parent)
39+
path.write_text(content, encoding="utf-8")
40+
41+
42+
def write_text_file_append(path: Path, content: str) -> None:
43+
ensure_directory(path.parent)
44+
with path.open("a", encoding="utf-8") as fp:
45+
fp.write(content)
46+
47+
48+
def run_command(
49+
command: list[str],
50+
cwd: Path | None = None,
51+
env: dict[str, str] | None = None,
52+
) -> CommandResult:
53+
merged_env = os.environ.copy()
54+
if env:
55+
merged_env.update(env)
56+
57+
completed = subprocess.run(
58+
command,
59+
cwd=str(cwd) if cwd else None,
60+
env=merged_env,
61+
stdout=subprocess.PIPE,
62+
stderr=subprocess.PIPE,
63+
text=True,
64+
encoding="utf-8",
65+
errors="replace",
66+
check=False,
67+
)
68+
69+
return CommandResult(
70+
command=command,
71+
returncode=completed.returncode,
72+
stdout=completed.stdout,
73+
stderr=completed.stderr,
74+
)
75+
76+
77+
def command_to_string(command: list[str]) -> str:
78+
return " ".join(command)

ci/e2e/run.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
#!/usr/bin/env python3
2+
from __future__ import annotations
3+
4+
import json
5+
import sys
6+
import traceback
7+
from pathlib import Path
8+
9+
from framework.context import E2EContext
10+
from framework.result import TestResult
11+
from framework.utils import ensure_directory, write_text_file
12+
from testcases.tc0001_basic_resync import TestCase0001BasicResync
13+
14+
15+
def build_test_suite() -> list:
16+
"""
17+
Return the ordered list of E2E test cases to execute.
18+
19+
Add future test cases here in the required execution order.
20+
"""
21+
return [
22+
TestCase0001BasicResync(),
23+
]
24+
25+
26+
def result_to_actions_case(result: TestResult) -> dict:
27+
"""
28+
Convert the internal TestResult into the JSON structure expected by the
29+
GitHub Actions workflow summary/reporting logic.
30+
"""
31+
output = {
32+
"id": result.case_id,
33+
"name": result.name,
34+
"status": result.status,
35+
}
36+
37+
if result.reason:
38+
output["reason"] = result.reason
39+
40+
if result.artifacts:
41+
output["artifacts"] = result.artifacts
42+
43+
if result.details:
44+
output["details"] = result.details
45+
46+
return output
47+
48+
49+
def main() -> int:
50+
context = E2EContext.from_environment()
51+
ensure_directory(context.out_dir)
52+
ensure_directory(context.logs_dir)
53+
ensure_directory(context.state_dir)
54+
ensure_directory(context.work_root)
55+
56+
context.log(
57+
f"Initialising E2E framework for target='{context.e2e_target}', "
58+
f"run_id='{context.run_id}'"
59+
)
60+
61+
cases = []
62+
failed = False
63+
64+
for testcase in build_test_suite():
65+
context.log(f"Starting test case {testcase.case_id}: {testcase.name}")
66+
67+
try:
68+
result = testcase.run(context)
69+
70+
if result.case_id != testcase.case_id:
71+
raise RuntimeError(
72+
f"Test case returned mismatched case_id: "
73+
f"expected '{testcase.case_id}', got '{result.case_id}'"
74+
)
75+
76+
cases.append(result_to_actions_case(result))
77+
78+
if result.status != "pass":
79+
failed = True
80+
context.log(
81+
f"Test case {testcase.case_id} FAILED: {result.reason or 'no reason provided'}"
82+
)
83+
else:
84+
context.log(f"Test case {testcase.case_id} PASSED")
85+
86+
except Exception as exc:
87+
failed = True
88+
tb = traceback.format_exc()
89+
90+
context.log(f"Unhandled exception in test case {testcase.case_id}: {exc}")
91+
context.log(tb)
92+
93+
error_log = context.logs_dir / f"{testcase.case_id}_exception.log"
94+
write_text_file(error_log, tb)
95+
96+
failure_result = TestResult(
97+
case_id=testcase.case_id,
98+
name=testcase.name,
99+
status="fail",
100+
reason=f"Unhandled exception: {exc}",
101+
artifacts=[str(error_log)],
102+
details={
103+
"exception_type": type(exc).__name__,
104+
},
105+
)
106+
cases.append(result_to_actions_case(failure_result))
107+
108+
results = {
109+
"target": context.e2e_target,
110+
"run_id": context.run_id,
111+
"cases": cases,
112+
}
113+
114+
results_file = context.out_dir / "results.json"
115+
results_json = json.dumps(results, indent=2, sort_keys=False)
116+
write_text_file(results_file, results_json)
117+
118+
context.log(f"Wrote results to {results_file}")
119+
120+
return 1 if failed else 0
121+
122+
123+
if __name__ == "__main__":
124+
sys.exit(main())

0 commit comments

Comments
 (0)