scriptgini 1.2.1__tar.gz → 1.3.0__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.
- {scriptgini-1.2.1 → scriptgini-1.3.0}/PKG-INFO +4 -1
- {scriptgini-1.2.1 → scriptgini-1.3.0}/README.md +3 -0
- scriptgini-1.3.0/app/__init__.py +3 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/main.py +2 -1
- scriptgini-1.3.0/app/models/execution_job.py +68 -0
- scriptgini-1.3.0/app/routers/execution.py +165 -0
- scriptgini-1.3.0/app/schemas/execution.py +31 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/tasks.py +53 -5
- {scriptgini-1.2.1 → scriptgini-1.3.0}/pyproject.toml +1 -1
- {scriptgini-1.2.1 → scriptgini-1.3.0}/scriptgini.egg-info/PKG-INFO +4 -1
- {scriptgini-1.2.1 → scriptgini-1.3.0}/scriptgini.egg-info/SOURCES.txt +5 -1
- scriptgini-1.3.0/tests/test_sprint3_execution.py +659 -0
- scriptgini-1.2.1/app/__init__.py +0 -3
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/agents/__init__.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/agents/prompts.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/agents/script_gini_agent.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/cache.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/celery_app.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/config.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/database.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/llm/__init__.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/llm/provider.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/models/__init__.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/models/api_key.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/models/bulk_job.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/models/generated_script.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/models/membership.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/models/organization.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/models/project.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/models/script_run.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/models/test_case.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/models/user.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/routers/__init__.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/routers/analytics.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/routers/api_key.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/routers/auth.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/routers/bulk_jobs.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/routers/demo.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/routers/organizations.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/routers/projects.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/routers/scripts.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/routers/test_cases.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/schemas/__init__.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/schemas/analytics.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/schemas/api_key.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/schemas/auth.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/schemas/bulk_job.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/schemas/generated_script.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/schemas/membership.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/schemas/organization.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/schemas/project.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/schemas/test_case.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/services/api_key.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/services/auth.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/services/auth_dependencies.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/services/git_export.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/app/services/rbac.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/scriptgini.egg-info/dependency_links.txt +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/scriptgini.egg-info/top_level.txt +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/setup.cfg +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/tests/test_api.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/tests/test_auth.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/tests/test_coverage.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/tests/test_infra_services_coverage.py +0 -0
- {scriptgini-1.2.1 → scriptgini-1.3.0}/tests/test_sprint2_rbac.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scriptgini
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0
|
|
4
4
|
Summary: Agentic AI system that converts functional test cases into automation test scripts.
|
|
5
5
|
Author: ScriptGini Team
|
|
6
6
|
License: Proprietary
|
|
@@ -16,6 +16,8 @@ Description-Content-Type: text/markdown
|
|
|
16
16
|
|
|
17
17
|
> **Enterprise-grade Agentic AI system that converts functional test cases into high-quality, review-ready automation test scripts.**
|
|
18
18
|
|
|
19
|
+
Current release: v1.3.0 (Sprint 3)
|
|
20
|
+
|
|
19
21
|
---
|
|
20
22
|
|
|
21
23
|
## What is ScriptGini?
|
|
@@ -33,6 +35,7 @@ ScriptGini is an AI-powered test automation engine built for Quality Engineering
|
|
|
33
35
|
- **Project & AUT management** — Store multiple projects, each with its own base URL and defaults
|
|
34
36
|
- **Full test case history** — Every generated script is stored in SQLite with status and token usage
|
|
35
37
|
- **Execution history persistence** — Every run is stored in `script_runs` with stdout/stderr, exit code, and duration
|
|
38
|
+
- **Durable execution APIs** — Async execution jobs with run/status/abort endpoints and idempotency-key support
|
|
36
39
|
- **Hardened execution sandbox** — Script runs use isolated Python mode, static safety validation, and restricted environment variables
|
|
37
40
|
- **Bulk job orchestration** — Project-level bulk generate and bulk run with pollable job status
|
|
38
41
|
- **Run analytics dashboard** — Project-level pass/fail/timeout metrics and recent failure feed
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
> **Enterprise-grade Agentic AI system that converts functional test cases into high-quality, review-ready automation test scripts.**
|
|
4
4
|
|
|
5
|
+
Current release: v1.3.0 (Sprint 3)
|
|
6
|
+
|
|
5
7
|
---
|
|
6
8
|
|
|
7
9
|
## What is ScriptGini?
|
|
@@ -19,6 +21,7 @@ ScriptGini is an AI-powered test automation engine built for Quality Engineering
|
|
|
19
21
|
- **Project & AUT management** — Store multiple projects, each with its own base URL and defaults
|
|
20
22
|
- **Full test case history** — Every generated script is stored in SQLite with status and token usage
|
|
21
23
|
- **Execution history persistence** — Every run is stored in `script_runs` with stdout/stderr, exit code, and duration
|
|
24
|
+
- **Durable execution APIs** — Async execution jobs with run/status/abort endpoints and idempotency-key support
|
|
22
25
|
- **Hardened execution sandbox** — Script runs use isolated Python mode, static safety validation, and restricted environment variables
|
|
23
26
|
- **Bulk job orchestration** — Project-level bulk generate and bulk run with pollable job status
|
|
24
27
|
- **Run analytics dashboard** — Project-level pass/fail/timeout metrics and recent failure feed
|
|
@@ -10,7 +10,7 @@ from fastapi.staticfiles import StaticFiles
|
|
|
10
10
|
from app import __version__
|
|
11
11
|
from app.config import settings
|
|
12
12
|
from app.llm.provider import get_llm_diagnostics
|
|
13
|
-
from app.routers import projects, test_cases, scripts, bulk_jobs, analytics, demo, auth, api_key, organizations
|
|
13
|
+
from app.routers import projects, test_cases, scripts, bulk_jobs, analytics, demo, auth, api_key, organizations, execution
|
|
14
14
|
|
|
15
15
|
logging.basicConfig(level=logging.DEBUG if settings.DEBUG else logging.INFO)
|
|
16
16
|
logger = logging.getLogger(__name__)
|
|
@@ -58,6 +58,7 @@ app.include_router(demo.router, prefix="/api/v1")
|
|
|
58
58
|
app.include_router(auth.router, prefix="/api/v1")
|
|
59
59
|
app.include_router(api_key.router, prefix="/api/v1")
|
|
60
60
|
app.include_router(organizations.router, prefix="/api/v1")
|
|
61
|
+
app.include_router(execution.router, prefix="/api/v1")
|
|
61
62
|
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
|
62
63
|
|
|
63
64
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import enum
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import DateTime, Enum as SAEnum, ForeignKey, Integer, JSON, String, Text
|
|
5
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
6
|
+
|
|
7
|
+
from app.database import Base
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ExecutionJobStatus(str, enum.Enum):
|
|
11
|
+
pending = "pending"
|
|
12
|
+
running = "running"
|
|
13
|
+
completed = "completed"
|
|
14
|
+
failed = "failed"
|
|
15
|
+
cancelled = "cancelled"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_ALLOWED_TRANSITIONS: dict[ExecutionJobStatus, set[ExecutionJobStatus]] = {
|
|
19
|
+
ExecutionJobStatus.pending: {
|
|
20
|
+
ExecutionJobStatus.running,
|
|
21
|
+
ExecutionJobStatus.completed,
|
|
22
|
+
ExecutionJobStatus.failed,
|
|
23
|
+
ExecutionJobStatus.cancelled,
|
|
24
|
+
},
|
|
25
|
+
ExecutionJobStatus.running: {
|
|
26
|
+
ExecutionJobStatus.completed,
|
|
27
|
+
ExecutionJobStatus.failed,
|
|
28
|
+
ExecutionJobStatus.cancelled,
|
|
29
|
+
},
|
|
30
|
+
ExecutionJobStatus.completed: set(),
|
|
31
|
+
ExecutionJobStatus.failed: set(),
|
|
32
|
+
ExecutionJobStatus.cancelled: set(),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ExecutionJob(Base):
|
|
37
|
+
__tablename__ = "execution_jobs"
|
|
38
|
+
|
|
39
|
+
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
|
40
|
+
project_id: Mapped[int] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True)
|
|
41
|
+
script_id: Mapped[int] = mapped_column(ForeignKey("generated_scripts.id", ondelete="CASCADE"), nullable=False, index=True)
|
|
42
|
+
test_case_id: Mapped[int] = mapped_column(ForeignKey("test_cases.id", ondelete="CASCADE"), nullable=False, index=True)
|
|
43
|
+
created_by_user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
|
44
|
+
status: Mapped[ExecutionJobStatus] = mapped_column(
|
|
45
|
+
SAEnum(ExecutionJobStatus), nullable=False, default=ExecutionJobStatus.pending
|
|
46
|
+
)
|
|
47
|
+
idempotency_key: Mapped[str | None] = mapped_column(String(255), nullable=True, unique=True, index=True)
|
|
48
|
+
celery_task_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
|
49
|
+
request_payload: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
|
50
|
+
result_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
51
|
+
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
52
|
+
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
53
|
+
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
54
|
+
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
55
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
56
|
+
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
|
57
|
+
)
|
|
58
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
59
|
+
DateTime(timezone=True),
|
|
60
|
+
default=lambda: datetime.now(timezone.utc),
|
|
61
|
+
onupdate=lambda: datetime.now(timezone.utc),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def can_transition(current: ExecutionJobStatus, new_status: ExecutionJobStatus) -> bool:
|
|
66
|
+
if current == new_status:
|
|
67
|
+
return True
|
|
68
|
+
return new_status in _ALLOWED_TRANSITIONS[current]
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
|
4
|
+
from sqlalchemy.orm import Session
|
|
5
|
+
|
|
6
|
+
from app.celery_app import celery_app
|
|
7
|
+
from app.database import get_db
|
|
8
|
+
from app.models.execution_job import ExecutionJob, ExecutionJobStatus, can_transition
|
|
9
|
+
from app.models.generated_script import GeneratedScript, ScriptStatus
|
|
10
|
+
from app.schemas.execution import ExecutionJobResponse, ExecutionRunRequest
|
|
11
|
+
from app.services import rbac as rbac_service
|
|
12
|
+
from app.services.auth_dependencies import require_auth_with_scopes
|
|
13
|
+
from app.tasks import execute_script
|
|
14
|
+
|
|
15
|
+
router = APIRouter(prefix="/execution", tags=["Execution"])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _get_job_or_404(job_id: int, db: Session) -> ExecutionJob:
|
|
19
|
+
job = db.query(ExecutionJob).filter(ExecutionJob.id == job_id).first()
|
|
20
|
+
if not job:
|
|
21
|
+
raise HTTPException(status_code=404, detail="Execution job not found")
|
|
22
|
+
return job
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _ensure_project_access(db: Session, project_id: int, user_id: int, require_manager: bool = False) -> None:
|
|
26
|
+
rbac_service.ensure_project_exists(db, project_id)
|
|
27
|
+
existing_members = rbac_service.list_project_members(db, project_id)
|
|
28
|
+
if not existing_members:
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
membership = rbac_service.get_user_project_membership(db, project_id, user_id)
|
|
32
|
+
if membership is None or membership.role not in rbac_service.READ_ROLES:
|
|
33
|
+
raise HTTPException(status_code=403, detail="Insufficient project role")
|
|
34
|
+
|
|
35
|
+
if require_manager and membership.role not in rbac_service.MANAGER_ROLES:
|
|
36
|
+
raise HTTPException(status_code=403, detail="Insufficient project role")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _apply_status_from_celery(job: ExecutionJob, db: Session) -> None:
|
|
40
|
+
if not job.celery_task_id or job.status not in {ExecutionJobStatus.pending, ExecutionJobStatus.running}:
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
task_result = celery_app.AsyncResult(job.celery_task_id)
|
|
44
|
+
state = task_result.state
|
|
45
|
+
|
|
46
|
+
if state == "PENDING":
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
if state == "STARTED" and can_transition(job.status, ExecutionJobStatus.running):
|
|
50
|
+
job.status = ExecutionJobStatus.running
|
|
51
|
+
if job.started_at is None:
|
|
52
|
+
job.started_at = datetime.now(timezone.utc)
|
|
53
|
+
db.commit()
|
|
54
|
+
db.refresh(job)
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
if state == "SUCCESS" and can_transition(job.status, ExecutionJobStatus.completed):
|
|
58
|
+
job.status = ExecutionJobStatus.completed
|
|
59
|
+
job.completed_at = datetime.now(timezone.utc)
|
|
60
|
+
if job.started_at is None:
|
|
61
|
+
job.started_at = job.completed_at
|
|
62
|
+
result = task_result.result
|
|
63
|
+
job.result_payload = result if isinstance(result, dict) else {"result": str(result)}
|
|
64
|
+
db.commit()
|
|
65
|
+
db.refresh(job)
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
if state == "REVOKED" and can_transition(job.status, ExecutionJobStatus.cancelled):
|
|
69
|
+
job.status = ExecutionJobStatus.cancelled
|
|
70
|
+
job.cancelled_at = datetime.now(timezone.utc)
|
|
71
|
+
db.commit()
|
|
72
|
+
db.refresh(job)
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
if state == "FAILURE" and can_transition(job.status, ExecutionJobStatus.failed):
|
|
76
|
+
job.status = ExecutionJobStatus.failed
|
|
77
|
+
job.completed_at = datetime.now(timezone.utc)
|
|
78
|
+
job.error_message = str(task_result.result)
|
|
79
|
+
db.commit()
|
|
80
|
+
db.refresh(job)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _enqueue_execution_task(script_id: int, execution_env: dict[str, str], job_id: int) -> str:
|
|
84
|
+
async_result = execute_script.delay(script_id, execution_env, job_id)
|
|
85
|
+
return async_result.id
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@router.post("/run", response_model=ExecutionJobResponse, status_code=status.HTTP_202_ACCEPTED)
|
|
89
|
+
def run_execution_job(
|
|
90
|
+
payload: ExecutionRunRequest,
|
|
91
|
+
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
|
|
92
|
+
current_user=Depends(require_auth_with_scopes({"execution:write"})),
|
|
93
|
+
db: Session = Depends(get_db),
|
|
94
|
+
):
|
|
95
|
+
script = db.query(GeneratedScript).filter(GeneratedScript.id == payload.script_id).first()
|
|
96
|
+
if not script:
|
|
97
|
+
raise HTTPException(status_code=404, detail="Script not found")
|
|
98
|
+
if script.status != ScriptStatus.completed or not script.script_content:
|
|
99
|
+
raise HTTPException(status_code=400, detail="Script is not ready to run")
|
|
100
|
+
|
|
101
|
+
_ensure_project_access(db, script.project_id, current_user.id)
|
|
102
|
+
|
|
103
|
+
if idempotency_key:
|
|
104
|
+
existing_job = (
|
|
105
|
+
db.query(ExecutionJob)
|
|
106
|
+
.filter(ExecutionJob.idempotency_key == idempotency_key, ExecutionJob.created_by_user_id == current_user.id)
|
|
107
|
+
.first()
|
|
108
|
+
)
|
|
109
|
+
if existing_job:
|
|
110
|
+
_ensure_project_access(db, existing_job.project_id, current_user.id)
|
|
111
|
+
return existing_job
|
|
112
|
+
|
|
113
|
+
job = ExecutionJob(
|
|
114
|
+
project_id=script.project_id,
|
|
115
|
+
script_id=script.id,
|
|
116
|
+
test_case_id=script.test_case_id,
|
|
117
|
+
created_by_user_id=current_user.id,
|
|
118
|
+
status=ExecutionJobStatus.pending,
|
|
119
|
+
idempotency_key=idempotency_key,
|
|
120
|
+
request_payload=payload.model_dump(),
|
|
121
|
+
)
|
|
122
|
+
db.add(job)
|
|
123
|
+
db.commit()
|
|
124
|
+
db.refresh(job)
|
|
125
|
+
|
|
126
|
+
job.celery_task_id = _enqueue_execution_task(script.id, payload.execution_env, job.id)
|
|
127
|
+
db.commit()
|
|
128
|
+
db.refresh(job)
|
|
129
|
+
return job
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@router.get("/status/{job_id}", response_model=ExecutionJobResponse)
|
|
133
|
+
def get_execution_job_status(
|
|
134
|
+
job_id: int,
|
|
135
|
+
current_user=Depends(require_auth_with_scopes({"execution:read"})),
|
|
136
|
+
db: Session = Depends(get_db),
|
|
137
|
+
):
|
|
138
|
+
job = _get_job_or_404(job_id, db)
|
|
139
|
+
_ensure_project_access(db, job.project_id, current_user.id)
|
|
140
|
+
_apply_status_from_celery(job, db)
|
|
141
|
+
return job
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@router.post("/abort/{job_id}", response_model=ExecutionJobResponse)
|
|
145
|
+
def abort_execution_job(
|
|
146
|
+
job_id: int,
|
|
147
|
+
current_user=Depends(require_auth_with_scopes({"execution:write"})),
|
|
148
|
+
db: Session = Depends(get_db),
|
|
149
|
+
):
|
|
150
|
+
job = _get_job_or_404(job_id, db)
|
|
151
|
+
_ensure_project_access(db, job.project_id, current_user.id, require_manager=True)
|
|
152
|
+
|
|
153
|
+
if job.status in {ExecutionJobStatus.completed, ExecutionJobStatus.failed, ExecutionJobStatus.cancelled}:
|
|
154
|
+
return job
|
|
155
|
+
|
|
156
|
+
if job.celery_task_id:
|
|
157
|
+
celery_app.control.revoke(job.celery_task_id, terminate=True)
|
|
158
|
+
|
|
159
|
+
if can_transition(job.status, ExecutionJobStatus.cancelled):
|
|
160
|
+
job.status = ExecutionJobStatus.cancelled
|
|
161
|
+
job.cancelled_at = datetime.now(timezone.utc)
|
|
162
|
+
db.commit()
|
|
163
|
+
db.refresh(job)
|
|
164
|
+
|
|
165
|
+
return job
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
from app.models.execution_job import ExecutionJobStatus
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ExecutionRunRequest(BaseModel):
|
|
9
|
+
script_id: int = Field(..., gt=0)
|
|
10
|
+
execution_env: dict[str, str] = Field(default_factory=dict)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ExecutionJobResponse(BaseModel):
|
|
14
|
+
id: int
|
|
15
|
+
project_id: int
|
|
16
|
+
script_id: int
|
|
17
|
+
test_case_id: int
|
|
18
|
+
created_by_user_id: int
|
|
19
|
+
status: ExecutionJobStatus
|
|
20
|
+
idempotency_key: str | None
|
|
21
|
+
celery_task_id: str | None
|
|
22
|
+
request_payload: dict
|
|
23
|
+
result_payload: dict | None
|
|
24
|
+
error_message: str | None
|
|
25
|
+
started_at: datetime | None
|
|
26
|
+
completed_at: datetime | None
|
|
27
|
+
cancelled_at: datetime | None
|
|
28
|
+
created_at: datetime
|
|
29
|
+
updated_at: datetime
|
|
30
|
+
|
|
31
|
+
model_config = {"from_attributes": True}
|
|
@@ -5,11 +5,13 @@ These tasks are executed by Celery workers and should not block the API.
|
|
|
5
5
|
Examples: Script generation, test execution, file processing, etc.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
import logging
|
|
10
|
+
|
|
8
11
|
from celery import shared_task
|
|
9
|
-
from sqlalchemy.orm import Session
|
|
10
|
-
from app.database import SessionLocal
|
|
11
12
|
from app.config import settings
|
|
12
|
-
import
|
|
13
|
+
from app.database import SessionLocal
|
|
14
|
+
from app.models.execution_job import ExecutionJob, ExecutionJobStatus, can_transition
|
|
13
15
|
|
|
14
16
|
logger = logging.getLogger(__name__)
|
|
15
17
|
|
|
@@ -46,8 +48,40 @@ def generate_test_script(self, test_case_id: int, project_id: int):
|
|
|
46
48
|
db.close()
|
|
47
49
|
|
|
48
50
|
|
|
51
|
+
def _sync_execution_job_state(
|
|
52
|
+
db,
|
|
53
|
+
execution_job_id: int | None,
|
|
54
|
+
status: ExecutionJobStatus,
|
|
55
|
+
*,
|
|
56
|
+
result_payload: dict | None = None,
|
|
57
|
+
error_message: str | None = None,
|
|
58
|
+
):
|
|
59
|
+
if execution_job_id is None:
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
job = db.query(ExecutionJob).filter(ExecutionJob.id == execution_job_id).first()
|
|
63
|
+
if not job or not can_transition(job.status, status):
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
now = datetime.now(timezone.utc)
|
|
67
|
+
job.status = status
|
|
68
|
+
if status == ExecutionJobStatus.running and job.started_at is None:
|
|
69
|
+
job.started_at = now
|
|
70
|
+
if status in {ExecutionJobStatus.completed, ExecutionJobStatus.failed}:
|
|
71
|
+
if job.started_at is None:
|
|
72
|
+
job.started_at = now
|
|
73
|
+
job.completed_at = now
|
|
74
|
+
if status == ExecutionJobStatus.cancelled:
|
|
75
|
+
job.cancelled_at = now
|
|
76
|
+
if result_payload is not None:
|
|
77
|
+
job.result_payload = result_payload
|
|
78
|
+
if error_message is not None:
|
|
79
|
+
job.error_message = error_message
|
|
80
|
+
db.commit()
|
|
81
|
+
|
|
82
|
+
|
|
49
83
|
@shared_task(bind=True, max_retries=settings.CELERY_MAX_RETRIES)
|
|
50
|
-
def execute_script(self, script_id: int, execution_env: dict = None):
|
|
84
|
+
def execute_script(self, script_id: int, execution_env: dict | None = None, execution_job_id: int | None = None):
|
|
51
85
|
"""
|
|
52
86
|
Async task to execute a test script (Playwright/Selenium).
|
|
53
87
|
|
|
@@ -62,6 +96,7 @@ def execute_script(self, script_id: int, execution_env: dict = None):
|
|
|
62
96
|
try:
|
|
63
97
|
execution_env = execution_env or {}
|
|
64
98
|
logger.info(f"Executing script {script_id} with env: {execution_env}")
|
|
99
|
+
_sync_execution_job_state(db, execution_job_id, ExecutionJobStatus.running)
|
|
65
100
|
|
|
66
101
|
# TODO: Implement actual script execution logic
|
|
67
102
|
# This would:
|
|
@@ -71,14 +106,27 @@ def execute_script(self, script_id: int, execution_env: dict = None):
|
|
|
71
106
|
# 4. Capture results and logs
|
|
72
107
|
# 5. Store execution record in database
|
|
73
108
|
|
|
74
|
-
|
|
109
|
+
result = {
|
|
75
110
|
"status": "success",
|
|
76
111
|
"script_id": script_id,
|
|
77
112
|
"execution_id": None, # Will be populated after execution
|
|
78
113
|
"duration_seconds": 0,
|
|
79
114
|
}
|
|
115
|
+
_sync_execution_job_state(
|
|
116
|
+
db,
|
|
117
|
+
execution_job_id,
|
|
118
|
+
ExecutionJobStatus.completed,
|
|
119
|
+
result_payload=result,
|
|
120
|
+
)
|
|
121
|
+
return result
|
|
80
122
|
except Exception as exc:
|
|
81
123
|
logger.error(f"Error executing script: {exc}")
|
|
124
|
+
_sync_execution_job_state(
|
|
125
|
+
db,
|
|
126
|
+
execution_job_id,
|
|
127
|
+
ExecutionJobStatus.failed,
|
|
128
|
+
error_message=str(exc),
|
|
129
|
+
)
|
|
82
130
|
raise self.retry(exc=exc, countdown=5 ** self.request.retries)
|
|
83
131
|
finally:
|
|
84
132
|
db.close()
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "scriptgini"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.3.0"
|
|
8
8
|
description = "Agentic AI system that converts functional test cases into automation test scripts."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scriptgini
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0
|
|
4
4
|
Summary: Agentic AI system that converts functional test cases into automation test scripts.
|
|
5
5
|
Author: ScriptGini Team
|
|
6
6
|
License: Proprietary
|
|
@@ -16,6 +16,8 @@ Description-Content-Type: text/markdown
|
|
|
16
16
|
|
|
17
17
|
> **Enterprise-grade Agentic AI system that converts functional test cases into high-quality, review-ready automation test scripts.**
|
|
18
18
|
|
|
19
|
+
Current release: v1.3.0 (Sprint 3)
|
|
20
|
+
|
|
19
21
|
---
|
|
20
22
|
|
|
21
23
|
## What is ScriptGini?
|
|
@@ -33,6 +35,7 @@ ScriptGini is an AI-powered test automation engine built for Quality Engineering
|
|
|
33
35
|
- **Project & AUT management** — Store multiple projects, each with its own base URL and defaults
|
|
34
36
|
- **Full test case history** — Every generated script is stored in SQLite with status and token usage
|
|
35
37
|
- **Execution history persistence** — Every run is stored in `script_runs` with stdout/stderr, exit code, and duration
|
|
38
|
+
- **Durable execution APIs** — Async execution jobs with run/status/abort endpoints and idempotency-key support
|
|
36
39
|
- **Hardened execution sandbox** — Script runs use isolated Python mode, static safety validation, and restricted environment variables
|
|
37
40
|
- **Bulk job orchestration** — Project-level bulk generate and bulk run with pollable job status
|
|
38
41
|
- **Run analytics dashboard** — Project-level pass/fail/timeout metrics and recent failure feed
|
|
@@ -15,6 +15,7 @@ app/llm/provider.py
|
|
|
15
15
|
app/models/__init__.py
|
|
16
16
|
app/models/api_key.py
|
|
17
17
|
app/models/bulk_job.py
|
|
18
|
+
app/models/execution_job.py
|
|
18
19
|
app/models/generated_script.py
|
|
19
20
|
app/models/membership.py
|
|
20
21
|
app/models/organization.py
|
|
@@ -28,6 +29,7 @@ app/routers/api_key.py
|
|
|
28
29
|
app/routers/auth.py
|
|
29
30
|
app/routers/bulk_jobs.py
|
|
30
31
|
app/routers/demo.py
|
|
32
|
+
app/routers/execution.py
|
|
31
33
|
app/routers/organizations.py
|
|
32
34
|
app/routers/projects.py
|
|
33
35
|
app/routers/scripts.py
|
|
@@ -37,6 +39,7 @@ app/schemas/analytics.py
|
|
|
37
39
|
app/schemas/api_key.py
|
|
38
40
|
app/schemas/auth.py
|
|
39
41
|
app/schemas/bulk_job.py
|
|
42
|
+
app/schemas/execution.py
|
|
40
43
|
app/schemas/generated_script.py
|
|
41
44
|
app/schemas/membership.py
|
|
42
45
|
app/schemas/organization.py
|
|
@@ -55,4 +58,5 @@ tests/test_api.py
|
|
|
55
58
|
tests/test_auth.py
|
|
56
59
|
tests/test_coverage.py
|
|
57
60
|
tests/test_infra_services_coverage.py
|
|
58
|
-
tests/test_sprint2_rbac.py
|
|
61
|
+
tests/test_sprint2_rbac.py
|
|
62
|
+
tests/test_sprint3_execution.py
|