Compare commits
10
Commits
8c6158e8e3
...
502a3e4f2c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
502a3e4f2c | ||
|
|
b57b55f3e1 | ||
|
|
06c662042b | ||
|
|
461669b3fb | ||
|
|
9580a7b2df | ||
|
|
86f9ac5603 | ||
|
|
6a91ce30e3 | ||
|
|
aa3bae9020 | ||
|
|
ba46789226 | ||
|
|
4e494ce4fd |
@@ -0,0 +1,36 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## 1.7.1 - 2026-04-21
|
||||||
|
|
||||||
|
Changes since 1.7.0:
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added MCP tools to upload, list, and fetch scenario files using base64 content payloads.
|
||||||
|
- Added MCP tools to list run artifacts and fetch run artifact content with metadata.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Documented MCP binary content handling for scenario files and run artifacts.
|
||||||
|
|
||||||
|
## 1.7.0 - 2026-04-21
|
||||||
|
|
||||||
|
Changes since 1.6.1:
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added scenario file storage and run artifact support on the backend.
|
||||||
|
- Added file management UI for scenarios and run details, including artifact lists.
|
||||||
|
- Added snippet CRUD tools to the MCP server.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Added dynamic page titles based on the active client content.
|
||||||
|
- Extended the scenarios list to show the latest run status and timestamp.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Exposed file expiry metadata in scenario and run file responses.
|
||||||
|
- Disabled expired download actions in the UI while keeping the action visible.
|
||||||
+1
-4
@@ -90,10 +90,7 @@ const SnippetDetailPage = lazy(() =>
|
|||||||
import('./pages/snippet/SnippetDetailPage').then((m) => ({ default: m.SnippetDetailPage })),
|
import('./pages/snippet/SnippetDetailPage').then((m) => ({ default: m.SnippetDetailPage })),
|
||||||
);
|
);
|
||||||
|
|
||||||
const NAV: (
|
const NAV: ({ path: string; labelKey: string; Icon: LucideIcon } | { separator: true })[] = [
|
||||||
| { path: string; labelKey: string; Icon: LucideIcon }
|
|
||||||
| { separator: true }
|
|
||||||
)[] = [
|
|
||||||
{ path: '/runs', labelKey: 'nav.runs', Icon: Activity },
|
{ path: '/runs', labelKey: 'nav.runs', Icon: Activity },
|
||||||
{ path: '/sessions', labelKey: 'nav.sessions', Icon: Monitor },
|
{ path: '/sessions', labelKey: 'nav.sessions', Icon: Monitor },
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
|
|||||||
+62
-11
@@ -2,6 +2,8 @@ import type {
|
|||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
Credential,
|
Credential,
|
||||||
Environment,
|
Environment,
|
||||||
|
FileMetadata,
|
||||||
|
FileListResponse,
|
||||||
ScenarioCredential,
|
ScenarioCredential,
|
||||||
Session,
|
Session,
|
||||||
Scenario,
|
Scenario,
|
||||||
@@ -23,9 +25,14 @@ const BASE_URL = ((import.meta.env.VITE_API_URL as string | undefined) ?? '/api/
|
|||||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
let res: Response;
|
let res: Response;
|
||||||
try {
|
try {
|
||||||
|
const headers = new Headers(init?.headers);
|
||||||
|
if (!(init?.body instanceof FormData) && !headers.has('Content-Type')) {
|
||||||
|
headers.set('Content-Type', 'application/json');
|
||||||
|
}
|
||||||
|
|
||||||
res = await fetch(`${BASE_URL}${path}`, {
|
res = await fetch(`${BASE_URL}${path}`, {
|
||||||
headers: { 'Content-Type': 'application/json', ...init?.headers },
|
|
||||||
...init,
|
...init,
|
||||||
|
headers,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = (err as Error).message || 'Network request failed';
|
const message = (err as Error).message || 'Network request failed';
|
||||||
@@ -139,7 +146,10 @@ export const environments = {
|
|||||||
body: JSON.stringify({ name, description, data }),
|
body: JSON.stringify({ name, description, data }),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
update(id: string, patch: Partial<Pick<Environment, 'name' | 'description' | 'data'>>): Promise<Environment> {
|
update(
|
||||||
|
id: string,
|
||||||
|
patch: Partial<Pick<Environment, 'name' | 'description' | 'data'>>,
|
||||||
|
): Promise<Environment> {
|
||||||
return request(`/environments/${id}`, {
|
return request(`/environments/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(patch),
|
body: JSON.stringify(patch),
|
||||||
@@ -162,7 +172,12 @@ export const environments = {
|
|||||||
// ── Sessions ──────────────────────────────────────────────────────────────────
|
// ── Sessions ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const sessions = {
|
export const sessions = {
|
||||||
list(page = 1, limit = 50, orderBy = 'id', orderDir: 'ASC' | 'DESC' = 'DESC'): Promise<PaginatedResponse<Session>> {
|
list(
|
||||||
|
page = 1,
|
||||||
|
limit = 50,
|
||||||
|
orderBy = 'id',
|
||||||
|
orderDir: 'ASC' | 'DESC' = 'DESC',
|
||||||
|
): Promise<PaginatedResponse<Session>> {
|
||||||
return request(`/sessions?page=${page}&limit=${limit}&orderBy=${orderBy}&orderDir=${orderDir}`);
|
return request(`/sessions?page=${page}&limit=${limit}&orderBy=${orderBy}&orderDir=${orderDir}`);
|
||||||
},
|
},
|
||||||
get(id: string): Promise<Session> {
|
get(id: string): Promise<Session> {
|
||||||
@@ -182,7 +197,9 @@ export const scenarios = {
|
|||||||
orderBy = 'updatedAt',
|
orderBy = 'updatedAt',
|
||||||
orderDir: 'ASC' | 'DESC' = 'DESC',
|
orderDir: 'ASC' | 'DESC' = 'DESC',
|
||||||
): Promise<PaginatedResponse<Scenario>> {
|
): Promise<PaginatedResponse<Scenario>> {
|
||||||
return request(`/scenarios?page=${page}&limit=${limit}&orderBy=${orderBy}&orderDir=${orderDir}`);
|
return request(
|
||||||
|
`/scenarios?page=${page}&limit=${limit}&orderBy=${orderBy}&orderDir=${orderDir}`,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
get(id: string): Promise<Scenario & { steps: ScenarioStep[] }> {
|
get(id: string): Promise<Scenario & { steps: ScenarioStep[] }> {
|
||||||
return request(`/scenarios/${id}`);
|
return request(`/scenarios/${id}`);
|
||||||
@@ -193,7 +210,10 @@ export const scenarios = {
|
|||||||
body: JSON.stringify({ name, description, environmentId }),
|
body: JSON.stringify({ name, description, environmentId }),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
update(id: string, patch: Partial<Pick<Scenario, 'name' | 'description' | 'environmentId' | 'timeoutSeconds'>>): Promise<Scenario> {
|
update(
|
||||||
|
id: string,
|
||||||
|
patch: Partial<Pick<Scenario, 'name' | 'description' | 'environmentId' | 'timeoutSeconds'>>,
|
||||||
|
): Promise<Scenario> {
|
||||||
return request(`/scenarios/${id}`, {
|
return request(`/scenarios/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(patch),
|
body: JSON.stringify(patch),
|
||||||
@@ -252,11 +272,7 @@ export const runs = {
|
|||||||
status?: string,
|
status?: string,
|
||||||
orderBy = 'createdAt',
|
orderBy = 'createdAt',
|
||||||
orderDir: 'ASC' | 'DESC' = 'DESC',
|
orderDir: 'ASC' | 'DESC' = 'DESC',
|
||||||
): Promise<
|
): Promise<PaginatedResponse<ScenarioRun & { scenario: { id: string; name: string } }>> {
|
||||||
PaginatedResponse<
|
|
||||||
ScenarioRun & { scenario: { id: string; name: string } }
|
|
||||||
>
|
|
||||||
> {
|
|
||||||
const q = new URLSearchParams({ page: String(page), limit: String(limit), orderBy, orderDir });
|
const q = new URLSearchParams({ page: String(page), limit: String(limit), orderBy, orderDir });
|
||||||
if (status) q.set('status', status);
|
if (status) q.set('status', status);
|
||||||
return request(`/scenarios/runs?${q}`);
|
return request(`/scenarios/runs?${q}`);
|
||||||
@@ -268,7 +284,9 @@ export const runs = {
|
|||||||
orderBy = 'createdAt',
|
orderBy = 'createdAt',
|
||||||
orderDir: 'ASC' | 'DESC' = 'DESC',
|
orderDir: 'ASC' | 'DESC' = 'DESC',
|
||||||
): Promise<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[] }>> {
|
): Promise<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[] }>> {
|
||||||
return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}&orderBy=${orderBy}&orderDir=${orderDir}`);
|
return request(
|
||||||
|
`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}&orderBy=${orderBy}&orderDir=${orderDir}`,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
get(scenarioId: string, runId: string, q?: string): Promise<ScenarioRunDetail> {
|
get(scenarioId: string, runId: string, q?: string): Promise<ScenarioRunDetail> {
|
||||||
const qs = q?.trim() ? `?q=${encodeURIComponent(q.trim())}` : '';
|
const qs = q?.trim() ? `?q=${encodeURIComponent(q.trim())}` : '';
|
||||||
@@ -329,3 +347,36 @@ export const steps = {
|
|||||||
return request(`/scenarios/${scenarioId}/steps/${stepId}`, { method: 'DELETE' });
|
return request(`/scenarios/${scenarioId}/steps/${stepId}`, { method: 'DELETE' });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Scenario Files ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const scenarioFiles = {
|
||||||
|
upload(scenarioId: string, file: File, expiresAt?: Date): Promise<FileMetadata> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
if (expiresAt) form.append('expiresAt', expiresAt.toISOString());
|
||||||
|
return request(`/scenarios/${scenarioId}/files`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
list(scenarioId: string, limit = 20, offset = 0): Promise<FileListResponse> {
|
||||||
|
return request(`/scenarios/${scenarioId}/files?limit=${limit}&offset=${offset}`);
|
||||||
|
},
|
||||||
|
/** Returns a URL that can be used as an <a href> for direct download. */
|
||||||
|
contentUrl(scenarioId: string, fileId: string): string {
|
||||||
|
return `${BASE_URL}/scenarios/${scenarioId}/files/${fileId}/content`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Run Files ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const runFiles = {
|
||||||
|
list(scenarioId: string, runId: string, limit = 20, offset = 0): Promise<FileListResponse> {
|
||||||
|
return request(`/scenarios/${scenarioId}/runs/${runId}/files?limit=${limit}&offset=${offset}`);
|
||||||
|
},
|
||||||
|
/** Returns a URL that can be used as an <a href> for direct download. */
|
||||||
|
contentUrl(scenarioId: string, runId: string, fileId: string): string {
|
||||||
|
return `${BASE_URL}/scenarios/${scenarioId}/runs/${runId}/files/${fileId}/content`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ export interface Scenario {
|
|||||||
environment?: Pick<Environment, 'id' | 'name'>;
|
environment?: Pick<Environment, 'id' | 'name'>;
|
||||||
steps?: ScenarioStep[];
|
steps?: ScenarioStep[];
|
||||||
scenarioCredentials?: ScenarioCredential[];
|
scenarioCredentials?: ScenarioCredential[];
|
||||||
|
lastRunStatus?: ScenarioRunStatus | null;
|
||||||
|
lastRunAt?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -141,3 +143,20 @@ export interface ScenarioRunDetail extends ScenarioRun {
|
|||||||
stepRuns: ScenarioRunStep[];
|
stepRuns: ScenarioRunStep[];
|
||||||
logs: ScenarioRunLog[];
|
logs: ScenarioRunLog[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Files ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface FileMetadata {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
sha256: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileListResponse {
|
||||||
|
items: FileMetadata[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
const APP_NAME = 'Liqa';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets document.title to "<title> | Liqa" for the current page.
|
||||||
|
* Pass undefined or empty string to fall back to just "Liqa".
|
||||||
|
*/
|
||||||
|
export function usePageTitle(title: string | null | undefined): void {
|
||||||
|
useEffect(() => {
|
||||||
|
const prev = document.title;
|
||||||
|
document.title = title ? `${title} | ${APP_NAME}` : APP_NAME;
|
||||||
|
return () => {
|
||||||
|
document.title = prev;
|
||||||
|
};
|
||||||
|
}, [title]);
|
||||||
|
}
|
||||||
@@ -127,6 +127,7 @@
|
|||||||
"col_id": "ID",
|
"col_id": "ID",
|
||||||
"col_name": "Name",
|
"col_name": "Name",
|
||||||
"col_description": "Description",
|
"col_description": "Description",
|
||||||
|
"col_last_run": "Last Run",
|
||||||
"col_updated": "Updated",
|
"col_updated": "Updated",
|
||||||
"empty": "No scenarios yet.",
|
"empty": "No scenarios yet.",
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
@@ -294,5 +295,22 @@
|
|||||||
"action_export": "Export",
|
"action_export": "Export",
|
||||||
"action_import": "Import",
|
"action_import": "Import",
|
||||||
"import_error": "Failed to import snippet"
|
"import_error": "Failed to import snippet"
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"title": "Files",
|
||||||
|
"upload": "Upload",
|
||||||
|
"uploadTitle": "Upload File",
|
||||||
|
"uploadSuccess": "File uploaded successfully",
|
||||||
|
"name": "Name",
|
||||||
|
"mimeType": "MIME Type",
|
||||||
|
"size": "Size",
|
||||||
|
"expiresAt": "Expires At",
|
||||||
|
"expiresAtLabel": "Expires At (optional)",
|
||||||
|
"createdAt": "Created",
|
||||||
|
"download": "Download",
|
||||||
|
"expiredTooltip": "File is expired",
|
||||||
|
"empty": "No files uploaded.",
|
||||||
|
"totalCount": "Total: {{count}} file(s)",
|
||||||
|
"artifactsTitle": "Artifacts"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, SubmitEvent } from 'react';
|
import { useState, SubmitEvent } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||||
import { KeyRound, X, Save } from 'lucide-react';
|
import { KeyRound, X, Save } from 'lucide-react';
|
||||||
import { credentials } from '../../api';
|
import { credentials } from '../../api';
|
||||||
import { Breadcrumbs, Button, Card, Input, CodeEditor, useToast } from '../../ui';
|
import { Breadcrumbs, Button, Card, Input, CodeEditor, useToast } from '../../ui';
|
||||||
@@ -8,6 +9,7 @@ import styles from '../Page.module.css';
|
|||||||
|
|
||||||
export function CreateCredentialPage() {
|
export function CreateCredentialPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
usePageTitle(t('credentials.create_title'));
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||||
import { Upload, Pencil, Trash2, KeyRound } from 'lucide-react';
|
import { Upload, Pencil, Trash2, KeyRound } from 'lucide-react';
|
||||||
import { CodeBlock } from '../../ui';
|
import { CodeBlock } from '../../ui';
|
||||||
import { stringify as yamlStringify } from 'yaml';
|
import { stringify as yamlStringify } from 'yaml';
|
||||||
@@ -16,6 +17,7 @@ export function CredentialDetailPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [credential, setCredential] = useState<Credential | null>(null);
|
const [credential, setCredential] = useState<Credential | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
usePageTitle(credential?.name ?? null);
|
||||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||||
import { Settings, Pencil, Trash2, Plus, Download, KeyRound } from 'lucide-react';
|
import { Settings, Pencil, Trash2, Plus, Download, KeyRound } from 'lucide-react';
|
||||||
import { parse as yamlParse } from 'yaml';
|
import { parse as yamlParse } from 'yaml';
|
||||||
import { credentials } from '../../api';
|
import { credentials } from '../../api';
|
||||||
@@ -100,6 +101,7 @@ function AddCredentialCard() {
|
|||||||
|
|
||||||
export function CredentialsPage() {
|
export function CredentialsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
usePageTitle(t('credentials.title'));
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [items, setItems] = useState<Credential[]>([]);
|
const [items, setItems] = useState<Credential[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState, SubmitEvent } from 'react';
|
import { useEffect, useState, SubmitEvent } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||||
import { KeyRound, X, Save } from 'lucide-react';
|
import { KeyRound, X, Save } from 'lucide-react';
|
||||||
import { credentials } from '../../api';
|
import { credentials } from '../../api';
|
||||||
import type { Credential } from '../../api';
|
import type { Credential } from '../../api';
|
||||||
@@ -15,6 +16,11 @@ export function EditCredentialPage() {
|
|||||||
|
|
||||||
const [credential, setCredential] = useState<Credential | null>(null);
|
const [credential, setCredential] = useState<Credential | null>(null);
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
|
usePageTitle(
|
||||||
|
credential
|
||||||
|
? `${t('credentials.edit_title')} — ${credential.name}`
|
||||||
|
: t('credentials.edit_title'),
|
||||||
|
);
|
||||||
const [data, setData] = useState('');
|
const [data, setData] = useState('');
|
||||||
const [nameError, setNameError] = useState('');
|
const [nameError, setNameError] = useState('');
|
||||||
const [dataError, setDataError] = useState('');
|
const [dataError, setDataError] = useState('');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, SubmitEvent } from 'react';
|
import { useState, SubmitEvent } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||||
import { X, Globe, Save } from 'lucide-react';
|
import { X, Globe, Save } from 'lucide-react';
|
||||||
import { environments } from '../../api';
|
import { environments } from '../../api';
|
||||||
import { Breadcrumbs, Button, Card, Input, CodeEditor, useToast } from '../../ui';
|
import { Breadcrumbs, Button, Card, Input, CodeEditor, useToast } from '../../ui';
|
||||||
@@ -8,6 +9,7 @@ import styles from '../Page.module.css';
|
|||||||
|
|
||||||
export function CreateEnvironmentPage() {
|
export function CreateEnvironmentPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
usePageTitle(t('environments.create_title'));
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState, SubmitEvent } from 'react';
|
import { useEffect, useState, SubmitEvent } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||||
import { X, Globe, Save } from 'lucide-react';
|
import { X, Globe, Save } from 'lucide-react';
|
||||||
import { environments } from '../../api';
|
import { environments } from '../../api';
|
||||||
import type { Environment } from '../../api';
|
import type { Environment } from '../../api';
|
||||||
@@ -15,6 +16,9 @@ export function EditEnvironmentPage() {
|
|||||||
|
|
||||||
const [env, setEnv] = useState<Environment | null>(null);
|
const [env, setEnv] = useState<Environment | null>(null);
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
|
usePageTitle(
|
||||||
|
env ? `${t('environments.edit_title')} — ${env.name}` : t('environments.edit_title'),
|
||||||
|
);
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
const [dataJson, setDataJson] = useState('{}');
|
const [dataJson, setDataJson] = useState('{}');
|
||||||
const [nameError, setNameError] = useState('');
|
const [nameError, setNameError] = useState('');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Globe, Pencil, Trash2, Upload } from 'lucide-react';
|
import { Globe, Pencil, Trash2, Upload } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { stringify as yamlStringify } from 'yaml';
|
import { stringify as yamlStringify } from 'yaml';
|
||||||
import type { Environment } from '../../api';
|
import type { Environment } from '../../api';
|
||||||
@@ -25,6 +26,7 @@ export function EnvironmentDetailPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [env, setEnv] = useState<Environment | null>(null);
|
const [env, setEnv] = useState<Environment | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
usePageTitle(env?.name ?? null);
|
||||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const dataEntries = Object.entries(env?.data ?? {}).filter(([, v]) => v);
|
const dataEntries = Object.entries(env?.data ?? {}).filter(([, v]) => v);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||||
import { Settings, ExternalLink, Pencil, Trash2, Plus, Globe, Download } from 'lucide-react';
|
import { Settings, ExternalLink, Pencil, Trash2, Plus, Globe, Download } from 'lucide-react';
|
||||||
import { parse as yamlParse } from 'yaml';
|
import { parse as yamlParse } from 'yaml';
|
||||||
import { stringify as yamlStringify } from 'yaml';
|
import { stringify as yamlStringify } from 'yaml';
|
||||||
@@ -117,6 +118,7 @@ function AddEnvironmentCard() {
|
|||||||
|
|
||||||
export function EnvironmentsPage() {
|
export function EnvironmentsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
usePageTitle(t('environments.title'));
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [items, setItems] = useState<Environment[]>([]);
|
const [items, setItems] = useState<Environment[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||||
import { Activity } from 'lucide-react';
|
import { Activity } from 'lucide-react';
|
||||||
import { runs } from '../../api';
|
import { runs } from '../../api';
|
||||||
import type { ScenarioRun, ScenarioRunStatus } from '../../api';
|
import type { ScenarioRun, ScenarioRunStatus } from '../../api';
|
||||||
@@ -38,6 +39,7 @@ type AllRunRow = ScenarioRun & {
|
|||||||
|
|
||||||
export function AllRunsPage() {
|
export function AllRunsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
usePageTitle(t('runs.title'));
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [sortBy, setSortBy] = useState('createdAt');
|
const [sortBy, setSortBy] = useState('createdAt');
|
||||||
@@ -45,12 +47,7 @@ export function AllRunsPage() {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(20);
|
const [pageSize, setPageSize] = useState(20);
|
||||||
|
|
||||||
const {
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
data,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
refetch,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ['runs', sortBy, sortDir, page, pageSize],
|
queryKey: ['runs', sortBy, sortDir, page, pageSize],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
runs.listAll(page, pageSize, undefined, sortBy, sortDir === 'asc' ? 'ASC' : 'DESC'),
|
runs.listAll(page, pageSize, undefined, sortBy, sortDir === 'asc' ? 'ASC' : 'DESC'),
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
import { useNavigate, useParams, Link } from 'react-router-dom';
|
import { useNavigate, useParams, Link } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { runs } from '../../api';
|
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||||
|
import { runs, runFiles } from '../../api';
|
||||||
import type {
|
import type {
|
||||||
Scenario,
|
Scenario,
|
||||||
ScenarioRunDetail,
|
ScenarioRunDetail,
|
||||||
@@ -10,11 +11,13 @@ import type {
|
|||||||
ScenarioRunStatus,
|
ScenarioRunStatus,
|
||||||
RunStepStatus,
|
RunStepStatus,
|
||||||
LogLevel,
|
LogLevel,
|
||||||
|
FileMetadata,
|
||||||
} from '../../api';
|
} from '../../api';
|
||||||
import { scenarios } from '../../api';
|
import { scenarios } from '../../api';
|
||||||
import {
|
import {
|
||||||
AutoRefreshIndicator,
|
AutoRefreshIndicator,
|
||||||
Badge,
|
Badge,
|
||||||
|
Button,
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
Card,
|
Card,
|
||||||
CodeBlock,
|
CodeBlock,
|
||||||
@@ -27,7 +30,7 @@ import {
|
|||||||
type TableColumn,
|
type TableColumn,
|
||||||
} from '../../ui';
|
} from '../../ui';
|
||||||
import type { BadgeVariant } from '../../ui';
|
import type { BadgeVariant } from '../../ui';
|
||||||
import { ChevronDown, ChevronRight, ClipboardList, Activity } from 'lucide-react';
|
import { ChevronDown, ChevronRight, ClipboardList, Activity, Download } from 'lucide-react';
|
||||||
import styles from '../Page.module.css';
|
import styles from '../Page.module.css';
|
||||||
|
|
||||||
const RUN_STATUS_VARIANT: Record<ScenarioRunStatus, BadgeVariant> = {
|
const RUN_STATUS_VARIANT: Record<ScenarioRunStatus, BadgeVariant> = {
|
||||||
@@ -69,6 +72,7 @@ export function RunDetailPage() {
|
|||||||
const [scenario, setScenario] = useState<Scenario | null>(null);
|
const [scenario, setScenario] = useState<Scenario | null>(null);
|
||||||
const [run, setRun] = useState<ScenarioRunDetail | null>(null);
|
const [run, setRun] = useState<ScenarioRunDetail | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
usePageTitle(scenario ? `${t('runs.title')} — ${scenario.name}` : t('runs.title'));
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [polling, setPolling] = useState(false);
|
const [polling, setPolling] = useState(false);
|
||||||
const [pulseKey, setPulseKey] = useState(0);
|
const [pulseKey, setPulseKey] = useState(0);
|
||||||
@@ -77,6 +81,9 @@ export function RunDetailPage() {
|
|||||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
const [expandAll, setExpandAll] = useState(false);
|
const [expandAll, setExpandAll] = useState(false);
|
||||||
const [expandedStepIds, setExpandedStepIds] = useState<Set<string>>(new Set());
|
const [expandedStepIds, setExpandedStepIds] = useState<Set<string>>(new Set());
|
||||||
|
const [artifacts, setArtifacts] = useState<FileMetadata[]>([]);
|
||||||
|
const [artifactsTotal, setArtifactsTotal] = useState(0);
|
||||||
|
const [artifactsLoading, setArtifactsLoading] = useState(false);
|
||||||
const logSearchRef = useRef('');
|
const logSearchRef = useRef('');
|
||||||
const LOG_PAGE_SIZE = 25;
|
const LOG_PAGE_SIZE = 25;
|
||||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
@@ -89,6 +96,16 @@ export function RunDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFileDownload = (url: string, filename: string) => {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
link.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFileExpired = (file: FileMetadata) =>
|
||||||
|
file.expiresAt != null && new Date(file.expiresAt).getTime() <= Date.now();
|
||||||
|
|
||||||
const manualRefresh = () => {
|
const manualRefresh = () => {
|
||||||
if (!id || !runId) return;
|
if (!id || !runId) return;
|
||||||
runs
|
runs
|
||||||
@@ -101,6 +118,22 @@ export function RunDetailPage() {
|
|||||||
.catch((err: Error) => setError(err.message));
|
.catch((err: Error) => setError(err.message));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadArtifacts = useCallback(async () => {
|
||||||
|
if (!id || !runId) return;
|
||||||
|
setArtifactsLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await runFiles.list(id, runId);
|
||||||
|
setArtifacts(result.items);
|
||||||
|
setArtifactsTotal(result.total);
|
||||||
|
} catch {
|
||||||
|
// Silently fail - artifacts are not critical
|
||||||
|
setArtifacts([]);
|
||||||
|
setArtifactsTotal(0);
|
||||||
|
} finally {
|
||||||
|
setArtifactsLoading(false);
|
||||||
|
}
|
||||||
|
}, [id, runId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
setDebouncedSearch(logSearch);
|
setDebouncedSearch(logSearch);
|
||||||
@@ -127,6 +160,7 @@ export function RunDetailPage() {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setScenario(sc);
|
setScenario(sc);
|
||||||
setRun(r);
|
setRun(r);
|
||||||
|
loadArtifacts();
|
||||||
if (!FINAL.includes(r.status)) {
|
if (!FINAL.includes(r.status)) {
|
||||||
setPolling(true);
|
setPolling(true);
|
||||||
pollRef.current = setInterval(async () => {
|
pollRef.current = setInterval(async () => {
|
||||||
@@ -135,7 +169,10 @@ export function RunDetailPage() {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setRun(updated);
|
setRun(updated);
|
||||||
setPulseKey((k) => k + 1);
|
setPulseKey((k) => k + 1);
|
||||||
if (FINAL.includes(updated.status)) stopPolling();
|
if (FINAL.includes(updated.status)) {
|
||||||
|
stopPolling();
|
||||||
|
await loadArtifacts();
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
}
|
}
|
||||||
@@ -153,7 +190,7 @@ export function RunDetailPage() {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
stopPolling();
|
stopPolling();
|
||||||
};
|
};
|
||||||
}, [id, runId]);
|
}, [id, runId, loadArtifacts]);
|
||||||
|
|
||||||
const stepColumns: TableColumn<ScenarioRunStep>[] = [
|
const stepColumns: TableColumn<ScenarioRunStep>[] = [
|
||||||
{ key: 'order', header: t('runs.step_order'), render: (s) => s.order, width: 50 },
|
{ key: 'order', header: t('runs.step_order'), render: (s) => s.order, width: 50 },
|
||||||
@@ -214,6 +251,7 @@ export function RunDetailPage() {
|
|||||||
),
|
),
|
||||||
render: (s) => {
|
render: (s) => {
|
||||||
const isExpanded = expandAll || expandedStepIds.has(s.id);
|
const isExpanded = expandAll || expandedStepIds.has(s.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
@@ -421,6 +459,68 @@ export function RunDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{(artifactsTotal > 0 || artifactsLoading) && (
|
||||||
|
<div className={styles.stepsSection}>
|
||||||
|
<h2 className={styles.sectionHeading}>{t('files.artifactsTitle')}</h2>
|
||||||
|
<Table<FileMetadata>
|
||||||
|
loading={artifactsLoading}
|
||||||
|
data={artifacts}
|
||||||
|
rowKey={(f) => f.id}
|
||||||
|
columns={
|
||||||
|
[
|
||||||
|
{ key: 'name', header: t('files.name'), render: (f) => f.name },
|
||||||
|
{
|
||||||
|
key: 'mimeType',
|
||||||
|
header: t('files.mimeType'),
|
||||||
|
render: (f) => f.mimeType,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'size',
|
||||||
|
header: t('files.size'),
|
||||||
|
render: (f) => `${(f.size / 1024).toFixed(1)} KB`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'expiresAt',
|
||||||
|
header: t('files.expiresAt'),
|
||||||
|
render: (f) => (f.expiresAt ? <Timestamp value={f.expiresAt} /> : '—'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'createdAt',
|
||||||
|
header: t('files.createdAt'),
|
||||||
|
render: (f) => <Timestamp value={f.createdAt} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'download',
|
||||||
|
header: '',
|
||||||
|
render: (f) => {
|
||||||
|
const expired = isFileExpired(f);
|
||||||
|
const tooltip = expired ? t('files.expiredTooltip') : t('files.download');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span title={tooltip}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
title={tooltip}
|
||||||
|
aria-label={tooltip}
|
||||||
|
disabled={expired}
|
||||||
|
onClick={() =>
|
||||||
|
handleFileDownload(runFiles.contentUrl(id!, runId!, f.id), f.name)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Download size={14} />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] satisfies TableColumn<FileMetadata>[]
|
||||||
|
}
|
||||||
|
emptyMessage={t('files.empty')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||||
import { Play, ClipboardList, Activity } from 'lucide-react';
|
import { Play, ClipboardList, Activity } from 'lucide-react';
|
||||||
import { environments, scenarios, runs } from '../../api';
|
import { environments, scenarios, runs } from '../../api';
|
||||||
import type {
|
import type { Environment, Scenario, ScenarioRun, ScenarioRunStatus } from '../../api';
|
||||||
Environment,
|
|
||||||
Scenario,
|
|
||||||
ScenarioRun,
|
|
||||||
ScenarioRunStatus,
|
|
||||||
} from '../../api';
|
|
||||||
import {
|
import {
|
||||||
AutoRefreshIndicator,
|
AutoRefreshIndicator,
|
||||||
Badge,
|
Badge,
|
||||||
@@ -50,6 +46,7 @@ export function RunsPage() {
|
|||||||
|
|
||||||
const [scenario, setScenario] = useState<Scenario | null>(null);
|
const [scenario, setScenario] = useState<Scenario | null>(null);
|
||||||
const [items, setItems] = useState<RunRow[]>([]);
|
const [items, setItems] = useState<RunRow[]>([]);
|
||||||
|
usePageTitle(scenario ? `${t('runs.title')} — ${scenario.name}` : t('runs.title'));
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [pulseKey, setPulseKey] = useState(0);
|
const [pulseKey, setPulseKey] = useState(0);
|
||||||
@@ -84,16 +81,16 @@ export function RunsPage() {
|
|||||||
scenarios.get(id!),
|
scenarios.get(id!),
|
||||||
runs.list(id!, p, limit, orderBy, orderDir === 'asc' ? 'ASC' : 'DESC'),
|
runs.list(id!, p, limit, orderBy, orderDir === 'asc' ? 'ASC' : 'DESC'),
|
||||||
])
|
])
|
||||||
.then(([sc, res]) => {
|
.then(([sc, res]) => {
|
||||||
setScenario(sc);
|
setScenario(sc);
|
||||||
setItems(res.data);
|
setItems(res.data);
|
||||||
setTotal(res.total);
|
setTotal(res.total);
|
||||||
setError(null);
|
setError(null);
|
||||||
setPulseKey((k) => k + 1);
|
setPulseKey((k) => k + 1);
|
||||||
hasDataRef.current = true;
|
hasDataRef.current = true;
|
||||||
})
|
})
|
||||||
.catch((err: Error) => setError(err.message))
|
.catch((err: Error) => setError(err.message))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
},
|
},
|
||||||
[id],
|
[id],
|
||||||
);
|
);
|
||||||
@@ -107,7 +104,6 @@ export function RunsPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
if (pollRef.current) clearInterval(pollRef.current);
|
if (pollRef.current) clearInterval(pollRef.current);
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
const handleSort = (key: string, dir: SortDirection) => {
|
const handleSort = (key: string, dir: SortDirection) => {
|
||||||
@@ -192,7 +188,12 @@ export function RunsPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<div className={styles.toolbarActions}>
|
<div className={styles.toolbarActions}>
|
||||||
<AutoRefreshIndicator active pulseKey={pulseKey} error={!!error} onClick={() => load(sortBy, sortDir, page, pageSize)} />
|
<AutoRefreshIndicator
|
||||||
|
active
|
||||||
|
pulseKey={pulseKey}
|
||||||
|
error={!!error}
|
||||||
|
onClick={() => load(sortBy, sortDir, page, pageSize)}
|
||||||
|
/>
|
||||||
<Button size="sm" onClick={() => setRunModalOpen(true)}>
|
<Button size="sm" onClick={() => setRunModalOpen(true)}>
|
||||||
<Play size={14} />
|
<Play size={14} />
|
||||||
{t('runs.action_run')}
|
{t('runs.action_run')}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState, SubmitEvent } from 'react';
|
import { useEffect, useState, SubmitEvent } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||||
import { X, Save, ClipboardList } from 'lucide-react';
|
import { X, Save, ClipboardList } from 'lucide-react';
|
||||||
import { scenarios, environments } from '../../api';
|
import { scenarios, environments } from '../../api';
|
||||||
import type { Environment } from '../../api';
|
import type { Environment } from '../../api';
|
||||||
@@ -9,6 +10,7 @@ import styles from '../Page.module.css';
|
|||||||
|
|
||||||
export function CreateScenarioPage() {
|
export function CreateScenarioPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
usePageTitle(t('scenarios.create_title'));
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
@@ -20,7 +22,10 @@ export function CreateScenarioPage() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
environments.list(1, 200).then((r) => setEnvs(r?.data ?? [])).catch(() => {});
|
environments
|
||||||
|
.list(1, 200)
|
||||||
|
.then((r) => setEnvs(r?.data ?? []))
|
||||||
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||||
@@ -31,7 +36,11 @@ export function CreateScenarioPage() {
|
|||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const scenario = await scenarios.create(name.trim(), description.trim() || undefined, environmentId || undefined);
|
const scenario = await scenarios.create(
|
||||||
|
name.trim(),
|
||||||
|
description.trim() || undefined,
|
||||||
|
environmentId || undefined,
|
||||||
|
);
|
||||||
toast.success(t('scenarios.created'));
|
toast.success(t('scenarios.created'));
|
||||||
navigate(`/scenarios/${scenario.id}`);
|
navigate(`/scenarios/${scenario.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState, SubmitEvent } from 'react';
|
import { useEffect, useState, SubmitEvent } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||||
import { X, Save, ClipboardList } from 'lucide-react';
|
import { X, Save, ClipboardList } from 'lucide-react';
|
||||||
import { scenarios, steps } from '../../api';
|
import { scenarios, steps } from '../../api';
|
||||||
import type { Scenario } from '../../api';
|
import type { Scenario } from '../../api';
|
||||||
@@ -14,6 +15,7 @@ export function CreateStepPage() {
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const [scenario, setScenario] = useState<Scenario | null>(null);
|
const [scenario, setScenario] = useState<Scenario | null>(null);
|
||||||
|
usePageTitle(t('steps.create_title'));
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [execCode, setExecCode] = useState('');
|
const [execCode, setExecCode] = useState('');
|
||||||
const [timeoutSeconds, setTimeoutSeconds] = useState<string>('');
|
const [timeoutSeconds, setTimeoutSeconds] = useState<string>('');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState, SubmitEvent } from 'react';
|
import { useEffect, useState, SubmitEvent } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||||
import { X, Save, ClipboardList } from 'lucide-react';
|
import { X, Save, ClipboardList } from 'lucide-react';
|
||||||
import { scenarios, environments } from '../../api';
|
import { scenarios, environments } from '../../api';
|
||||||
import type { Scenario, Environment } from '../../api';
|
import type { Scenario, Environment } from '../../api';
|
||||||
@@ -15,6 +16,9 @@ export function EditScenarioPage() {
|
|||||||
|
|
||||||
const [scenario, setScenario] = useState<Scenario | null>(null);
|
const [scenario, setScenario] = useState<Scenario | null>(null);
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
|
usePageTitle(
|
||||||
|
scenario ? `${t('scenarios.edit_title')} — ${scenario.name}` : t('scenarios.edit_title'),
|
||||||
|
);
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
const [environmentId, setEnvironmentId] = useState<string>('');
|
const [environmentId, setEnvironmentId] = useState<string>('');
|
||||||
const [timeoutSeconds, setTimeoutSeconds] = useState<string>('');
|
const [timeoutSeconds, setTimeoutSeconds] = useState<string>('');
|
||||||
@@ -35,7 +39,10 @@ export function EditScenarioPage() {
|
|||||||
setTimeoutSeconds(data.timeoutSeconds != null ? String(data.timeoutSeconds) : '');
|
setTimeoutSeconds(data.timeoutSeconds != null ? String(data.timeoutSeconds) : '');
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
environments.list(1, 200).then((r) => setEnvs(r?.data ?? [])).catch(() => {});
|
environments
|
||||||
|
.list(1, 200)
|
||||||
|
.then((r) => setEnvs(r?.data ?? []))
|
||||||
|
.catch(() => {});
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState, SubmitEvent } from 'react';
|
import { useEffect, useState, SubmitEvent } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||||
import { X, Save, ClipboardList } from 'lucide-react';
|
import { X, Save, ClipboardList } from 'lucide-react';
|
||||||
import { scenarios, steps } from '../../api';
|
import { scenarios, steps } from '../../api';
|
||||||
import type { Scenario, ScenarioStep } from '../../api';
|
import type { Scenario, ScenarioStep } from '../../api';
|
||||||
@@ -15,6 +16,9 @@ export function EditStepPage() {
|
|||||||
|
|
||||||
const [scenario, setScenario] = useState<Scenario | null>(null);
|
const [scenario, setScenario] = useState<Scenario | null>(null);
|
||||||
const [step, setStep] = useState<ScenarioStep | null>(null);
|
const [step, setStep] = useState<ScenarioStep | null>(null);
|
||||||
|
usePageTitle(
|
||||||
|
step ? t('steps.edit_title', { order: step.order }) : t('steps.edit_title', { order: '' }),
|
||||||
|
);
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [execCode, setExecCode] = useState('');
|
const [execCode, setExecCode] = useState('');
|
||||||
const [timeoutSeconds, setTimeoutSeconds] = useState<string>('');
|
const [timeoutSeconds, setTimeoutSeconds] = useState<string>('');
|
||||||
|
|||||||
@@ -1,22 +1,24 @@
|
|||||||
import { useEffect, useRef, useState, SubmitEvent } from 'react';
|
import { useEffect, useRef, useState, useCallback, SubmitEvent } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||||
import {
|
import {
|
||||||
Play,
|
Play,
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
History,
|
History,
|
||||||
|
Download,
|
||||||
Trash2,
|
Trash2,
|
||||||
Upload,
|
Upload,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { stringify as yamlStringify } from 'yaml';
|
|
||||||
import {
|
import {
|
||||||
environments,
|
environments,
|
||||||
scenarios,
|
scenarios,
|
||||||
steps,
|
steps,
|
||||||
scenarioCredentials,
|
scenarioCredentials,
|
||||||
|
scenarioFiles,
|
||||||
credentials as credentialsApi,
|
credentials as credentialsApi,
|
||||||
} from '../../api';
|
} from '../../api';
|
||||||
import type {
|
import type {
|
||||||
@@ -25,6 +27,7 @@ import type {
|
|||||||
ScenarioCredential,
|
ScenarioCredential,
|
||||||
Credential,
|
Credential,
|
||||||
Environment,
|
Environment,
|
||||||
|
FileMetadata,
|
||||||
} from '../../api';
|
} from '../../api';
|
||||||
import {
|
import {
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
@@ -51,6 +54,7 @@ export function ScenarioDetailPage() {
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [scenario, setScenario] = useState<(Scenario & { steps: ScenarioStep[] }) | null>(null);
|
const [scenario, setScenario] = useState<(Scenario & { steps: ScenarioStep[] }) | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
usePageTitle(scenario?.name ?? null);
|
||||||
|
|
||||||
// Credentials state
|
// Credentials state
|
||||||
const [scenarioCreds, setScenarioCreds] = useState<ScenarioCredential[]>([]);
|
const [scenarioCreds, setScenarioCreds] = useState<ScenarioCredential[]>([]);
|
||||||
@@ -81,6 +85,27 @@ export function ScenarioDetailPage() {
|
|||||||
const [includedCredentialIds, setIncludedCredentialIds] = useState<Set<string>>(new Set());
|
const [includedCredentialIds, setIncludedCredentialIds] = useState<Set<string>>(new Set());
|
||||||
const [isExporting, setIsExporting] = useState(false);
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
|
||||||
|
// Files state
|
||||||
|
const [files, setFiles] = useState<FileMetadata[]>([]);
|
||||||
|
const [filesTotal, setFilesTotal] = useState(0);
|
||||||
|
const [filesLoading, setFilesLoading] = useState(false);
|
||||||
|
const [uploadOpen, setUploadOpen] = useState(false);
|
||||||
|
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
||||||
|
const [uploadExpiresAt, setUploadExpiresAt] = useState('');
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
|
const loadFiles = useCallback(async () => {
|
||||||
|
if (!id) return;
|
||||||
|
setFilesLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await scenarioFiles.list(id);
|
||||||
|
setFiles(result.items);
|
||||||
|
setFilesTotal(result.total);
|
||||||
|
} finally {
|
||||||
|
setFilesLoading(false);
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
void scenarios
|
void scenarios
|
||||||
@@ -90,6 +115,7 @@ export function ScenarioDetailPage() {
|
|||||||
setScenarioCreds(s.scenarioCredentials ?? []);
|
setScenarioCreds(s.scenarioCredentials ?? []);
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
|
void loadFiles();
|
||||||
credentialsApi
|
credentialsApi
|
||||||
.list(1, 200)
|
.list(1, 200)
|
||||||
.then((r) => setAllCredentials(r.data))
|
.then((r) => setAllCredentials(r.data))
|
||||||
@@ -100,7 +126,7 @@ export function ScenarioDetailPage() {
|
|||||||
setEnvs(r.data);
|
setEnvs(r.data);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [id]);
|
}, [id, loadFiles]);
|
||||||
|
|
||||||
// Default selected env to scenario's linked environment (or first env) once both are loaded
|
// Default selected env to scenario's linked environment (or first env) once both are loaded
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -176,6 +202,38 @@ export function ScenarioDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFileDownload = (url: string, filename: string) => {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
link.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFileExpired = (file: FileMetadata) =>
|
||||||
|
file.expiresAt != null && new Date(file.expiresAt).getTime() <= Date.now();
|
||||||
|
|
||||||
|
async function handleUpload(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!id || !uploadFile) return;
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
await scenarioFiles.upload(
|
||||||
|
id,
|
||||||
|
uploadFile,
|
||||||
|
uploadExpiresAt ? new Date(uploadExpiresAt) : undefined,
|
||||||
|
);
|
||||||
|
setUploadOpen(false);
|
||||||
|
setUploadFile(null);
|
||||||
|
setUploadExpiresAt('');
|
||||||
|
await loadFiles();
|
||||||
|
toast.success(t('files.uploadSuccess'));
|
||||||
|
} catch {
|
||||||
|
// error toast already emitted by API client
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleDeleteStep = async (stepId: string) => {
|
const handleDeleteStep = async (stepId: string) => {
|
||||||
await steps.remove(id!, stepId);
|
await steps.remove(id!, stepId);
|
||||||
await reloadScenario();
|
await reloadScenario();
|
||||||
@@ -425,17 +483,22 @@ export function ScenarioDetailPage() {
|
|||||||
{ term: t('scenarios.field_id'), detail: <UuidBadge id={scenario.id} /> },
|
{ term: t('scenarios.field_id'), detail: <UuidBadge id={scenario.id} /> },
|
||||||
{ term: t('scenarios.field_name'), detail: scenario.name },
|
{ term: t('scenarios.field_name'), detail: scenario.name },
|
||||||
...(scenario.environment
|
...(scenario.environment
|
||||||
? [{
|
? [
|
||||||
term: t('scenarios.field_environment'),
|
{
|
||||||
detail: (
|
term: t('scenarios.field_environment'),
|
||||||
<a
|
detail: (
|
||||||
href={`/environments/${scenario.environment.id}`}
|
<a
|
||||||
onClick={(e) => { e.preventDefault(); navigate(`/environments/${scenario.environment!.id}`); }}
|
href={`/environments/${scenario.environment.id}`}
|
||||||
>
|
onClick={(e) => {
|
||||||
{scenario.environment.name}
|
e.preventDefault();
|
||||||
</a>
|
navigate(`/environments/${scenario.environment!.id}`);
|
||||||
),
|
}}
|
||||||
}]
|
>
|
||||||
|
{scenario.environment.name}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
: []),
|
: []),
|
||||||
{
|
{
|
||||||
term: t('scenarios.field_created'),
|
term: t('scenarios.field_created'),
|
||||||
@@ -587,6 +650,101 @@ export function ScenarioDetailPage() {
|
|||||||
<p className={styles.muted}>{t('scenarios.cred_empty')}</p>
|
<p className={styles.muted}>{t('scenarios.cred_empty')}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Files section ───────────────────────────────────────────────── */}
|
||||||
|
<div className={styles.stepsSection}>
|
||||||
|
<div className={styles.sectionToolbar}>
|
||||||
|
<h2 className={styles.sectionHeading}>{t('files.title')}</h2>
|
||||||
|
<Button size="sm" onClick={() => setUploadOpen(true)}>
|
||||||
|
<Upload size={14} /> {t('files.upload')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table<FileMetadata>
|
||||||
|
loading={filesLoading}
|
||||||
|
data={files}
|
||||||
|
rowKey={(f) => f.id}
|
||||||
|
columns={
|
||||||
|
[
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: t('files.name'),
|
||||||
|
render: (f) => f.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'mimeType',
|
||||||
|
header: t('files.mimeType'),
|
||||||
|
render: (f) => f.mimeType,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'size',
|
||||||
|
header: t('files.size'),
|
||||||
|
render: (f) => `${(f.size / 1024).toFixed(1)} KB`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'expiresAt',
|
||||||
|
header: t('files.expiresAt'),
|
||||||
|
render: (f) => (f.expiresAt ? <Timestamp value={f.expiresAt} /> : '—'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'createdAt',
|
||||||
|
header: t('files.createdAt'),
|
||||||
|
render: (f) => <Timestamp value={f.createdAt} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'download',
|
||||||
|
header: '',
|
||||||
|
render: (f) => {
|
||||||
|
const expired = isFileExpired(f);
|
||||||
|
const tooltip = expired ? t('files.expiredTooltip') : t('files.download');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span title={tooltip}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
title={tooltip}
|
||||||
|
aria-label={tooltip}
|
||||||
|
disabled={expired}
|
||||||
|
onClick={() =>
|
||||||
|
handleFileDownload(scenarioFiles.contentUrl(id!, f.id), f.name)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Download size={14} />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] satisfies TableColumn<FileMetadata>[]
|
||||||
|
}
|
||||||
|
emptyMessage={t('files.empty')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Upload modal ─────────────────────────────────────────────── */}
|
||||||
|
<Modal
|
||||||
|
open={uploadOpen}
|
||||||
|
title={t('files.uploadTitle')}
|
||||||
|
onClose={() => setUploadOpen(false)}
|
||||||
|
>
|
||||||
|
<form onSubmit={handleUpload}>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
required
|
||||||
|
onChange={(e) => setUploadFile(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('files.expiresAtLabel')}
|
||||||
|
type="datetime-local"
|
||||||
|
value={uploadExpiresAt}
|
||||||
|
onChange={(e) => setUploadExpiresAt(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button type="submit" loading={uploading} disabled={!uploadFile}>
|
||||||
|
{t('files.upload')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||||
import { Play, Plus, Trash2, Download, ClipboardList } from 'lucide-react';
|
import { Play, Plus, Trash2, Download, ClipboardList } from 'lucide-react';
|
||||||
import { parse as yamlParse } from 'yaml';
|
import { parse as yamlParse } from 'yaml';
|
||||||
import { environments, scenarios } from '../../api';
|
import { environments, scenarios } from '../../api';
|
||||||
import type { Environment, Scenario } from '../../api';
|
import type { Environment, Scenario } from '../../api';
|
||||||
import {
|
import {
|
||||||
|
Badge,
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
Button,
|
Button,
|
||||||
Modal,
|
Modal,
|
||||||
@@ -17,10 +19,27 @@ import {
|
|||||||
UuidBadge,
|
UuidBadge,
|
||||||
useToast,
|
useToast,
|
||||||
} from '../../ui';
|
} from '../../ui';
|
||||||
|
import type { BadgeVariant } from '../../ui';
|
||||||
|
import type { ScenarioRunStatus } from '../../api';
|
||||||
import styles from '../Page.module.css';
|
import styles from '../Page.module.css';
|
||||||
|
|
||||||
|
const LAST_RUN_VARIANT: Record<ScenarioRunStatus, BadgeVariant> = {
|
||||||
|
pending: 'neutral',
|
||||||
|
in_progress: 'info',
|
||||||
|
pass: 'success',
|
||||||
|
fail: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
const LAST_RUN_LABEL: Record<ScenarioRunStatus, string> = {
|
||||||
|
pending: 'Pending',
|
||||||
|
in_progress: 'Running',
|
||||||
|
pass: 'Pass',
|
||||||
|
fail: 'Fail',
|
||||||
|
};
|
||||||
|
|
||||||
export function ScenariosPage() {
|
export function ScenariosPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
usePageTitle(t('scenarios.title'));
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [items, setItems] = useState<Scenario[]>([]);
|
const [items, setItems] = useState<Scenario[]>([]);
|
||||||
@@ -48,20 +67,21 @@ export function ScenariosPage() {
|
|||||||
scenarios.list(p, limit, orderBy, orderDir === 'asc' ? 'ASC' : 'DESC'),
|
scenarios.list(p, limit, orderBy, orderDir === 'asc' ? 'ASC' : 'DESC'),
|
||||||
environments.list(1, 200),
|
environments.list(1, 200),
|
||||||
])
|
])
|
||||||
.then(([scenarioRes, envRes]) => {
|
.then(([scenarioRes, envRes]) => {
|
||||||
setItems(scenarioRes?.data ?? []);
|
setItems(scenarioRes?.data ?? []);
|
||||||
setTotal(scenarioRes?.total ?? 0);
|
setTotal(scenarioRes?.total ?? 0);
|
||||||
setEnvs(envRes?.data ?? []);
|
setEnvs(envRes?.data ?? []);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
toast.error((err as Error).message);
|
toast.error((err as Error).message);
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [toast]);
|
},
|
||||||
|
[toast],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load('updatedAt', 'desc', 1, 10);
|
load('updatedAt', 'desc', 1, 10);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
const handleSort = (key: string, dir: SortDirection) => {
|
const handleSort = (key: string, dir: SortDirection) => {
|
||||||
@@ -135,6 +155,24 @@ export function ScenariosPage() {
|
|||||||
const columns: TableColumn<Scenario>[] = [
|
const columns: TableColumn<Scenario>[] = [
|
||||||
{ key: 'id', header: t('scenarios.col_id'), render: (s) => <UuidBadge id={s.id} />, width: 60 },
|
{ key: 'id', header: t('scenarios.col_id'), render: (s) => <UuidBadge id={s.id} />, width: 60 },
|
||||||
{ key: 'name', header: t('scenarios.col_name'), render: (s) => s.name, sortable: true },
|
{ key: 'name', header: t('scenarios.col_name'), render: (s) => s.name, sortable: true },
|
||||||
|
{
|
||||||
|
key: 'lastRunStatus',
|
||||||
|
header: t('scenarios.col_last_run'),
|
||||||
|
width: 160,
|
||||||
|
render: (s) =>
|
||||||
|
s.lastRunStatus ? (
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ display: 'inline-flex', minWidth: 62 }}>
|
||||||
|
<Badge variant={LAST_RUN_VARIANT[s.lastRunStatus]}>
|
||||||
|
{LAST_RUN_LABEL[s.lastRunStatus]}
|
||||||
|
</Badge>
|
||||||
|
</span>
|
||||||
|
{s.lastRunAt && <Timestamp value={s.lastRunAt} />}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: 'var(--color-text-muted)', fontSize: '0.8em' }}>—</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'updatedAt',
|
key: 'updatedAt',
|
||||||
header: t('scenarios.col_updated'),
|
header: t('scenarios.col_updated'),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||||
import { Trash2, Monitor } from 'lucide-react';
|
import { Trash2, Monitor } from 'lucide-react';
|
||||||
import { sessions } from '../../api';
|
import { sessions } from '../../api';
|
||||||
import type { Session } from '../../api';
|
import type { Session } from '../../api';
|
||||||
@@ -14,6 +15,7 @@ export function SessionDetailPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [session, setSession] = useState<Session | null>(null);
|
const [session, setSession] = useState<Session | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
usePageTitle(session?.sessionName ?? null);
|
||||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||||
import { Trash2, Monitor } from 'lucide-react';
|
import { Trash2, Monitor } from 'lucide-react';
|
||||||
import { sessions } from '../../api';
|
import { sessions } from '../../api';
|
||||||
import type { Session } from '../../api';
|
import type { Session } from '../../api';
|
||||||
@@ -19,6 +20,7 @@ import styles from '../Page.module.css';
|
|||||||
|
|
||||||
export function SessionsPage() {
|
export function SessionsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
usePageTitle(t('sessions.title'));
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [items, setItems] = useState<Session[]>([]);
|
const [items, setItems] = useState<Session[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -34,7 +36,10 @@ export function SessionsPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
sessions
|
sessions
|
||||||
.list(p, limit, orderBy, orderDir === 'asc' ? 'ASC' : 'DESC')
|
.list(p, limit, orderBy, orderDir === 'asc' ? 'ASC' : 'DESC')
|
||||||
.then((res) => { setItems(res.data); setTotal(res.total); })
|
.then((res) => {
|
||||||
|
setItems(res.data);
|
||||||
|
setTotal(res.total);
|
||||||
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -62,7 +67,12 @@ export function SessionsPage() {
|
|||||||
|
|
||||||
const columns: TableColumn<Session>[] = [
|
const columns: TableColumn<Session>[] = [
|
||||||
{ key: 'id', header: t('sessions.col_id'), render: (s) => <UuidBadge id={s.id} />, width: 60 },
|
{ key: 'id', header: t('sessions.col_id'), render: (s) => <UuidBadge id={s.id} />, width: 60 },
|
||||||
{ key: 'sessionName', header: t('sessions.col_name'), sortable: true, render: (s) => s.sessionName },
|
{
|
||||||
|
key: 'sessionName',
|
||||||
|
header: t('sessions.col_name'),
|
||||||
|
sortable: true,
|
||||||
|
render: (s) => s.sessionName,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'status',
|
key: 'status',
|
||||||
header: t('sessions.col_status'),
|
header: t('sessions.col_status'),
|
||||||
@@ -115,7 +125,10 @@ export function SessionsPage() {
|
|||||||
total={total}
|
total={total}
|
||||||
page={page}
|
page={page}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
onPageSizeChange={(s) => { setPageSize(s); setPage(1); }}
|
onPageSizeChange={(s) => {
|
||||||
|
setPageSize(s);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
pageSizeOptions={[10, 25, 50]}
|
pageSizeOptions={[10, 25, 50]}
|
||||||
onRowClick={(s) => navigate(`/sessions/${s.id}`)}
|
onRowClick={(s) => navigate(`/sessions/${s.id}`)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, SubmitEvent } from 'react';
|
import { useState, SubmitEvent } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||||
import { X, Save, Braces } from 'lucide-react';
|
import { X, Save, Braces } from 'lucide-react';
|
||||||
import { snippets } from '../../api';
|
import { snippets } from '../../api';
|
||||||
import { Breadcrumbs, Button, Card, Input, CodeEditor, useToast } from '../../ui';
|
import { Breadcrumbs, Button, Card, Input, CodeEditor, useToast } from '../../ui';
|
||||||
@@ -8,6 +9,7 @@ import styles from '../Page.module.css';
|
|||||||
|
|
||||||
export function CreateSnippetPage() {
|
export function CreateSnippetPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
usePageTitle(t('snippets.create_title'));
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState, SubmitEvent } from 'react';
|
import { useEffect, useState, SubmitEvent } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||||
import { X, Save, Braces } from 'lucide-react';
|
import { X, Save, Braces } from 'lucide-react';
|
||||||
import { snippets } from '../../api';
|
import { snippets } from '../../api';
|
||||||
import type { Snippet } from '../../api';
|
import type { Snippet } from '../../api';
|
||||||
@@ -15,6 +16,9 @@ export function EditSnippetPage() {
|
|||||||
|
|
||||||
const [snippet, setSnippet] = useState<Snippet | null>(null);
|
const [snippet, setSnippet] = useState<Snippet | null>(null);
|
||||||
const [alias, setAlias] = useState('');
|
const [alias, setAlias] = useState('');
|
||||||
|
usePageTitle(
|
||||||
|
snippet ? `${t('snippets.edit_title')} — ${snippet.title}` : t('snippets.edit_title'),
|
||||||
|
);
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
const [code, setCode] = useState('');
|
const [code, setCode] = useState('');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||||
import { Upload, Pencil, Trash2, Braces } from 'lucide-react';
|
import { Upload, Pencil, Trash2, Braces } from 'lucide-react';
|
||||||
import { CodeBlock } from '../../ui';
|
import { CodeBlock } from '../../ui';
|
||||||
import { stringify as yamlStringify } from 'yaml';
|
import { stringify as yamlStringify } from 'yaml';
|
||||||
@@ -24,6 +25,7 @@ export function SnippetDetailPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [snippet, setSnippet] = useState<Snippet | null>(null);
|
const [snippet, setSnippet] = useState<Snippet | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
usePageTitle(snippet?.title ?? null);
|
||||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||||
import { Settings, Pencil, Trash2, Plus, Download, Braces } from 'lucide-react';
|
import { Settings, Pencil, Trash2, Plus, Download, Braces } from 'lucide-react';
|
||||||
import { parse as yamlParse } from 'yaml';
|
import { parse as yamlParse } from 'yaml';
|
||||||
import { snippets } from '../../api';
|
import { snippets } from '../../api';
|
||||||
@@ -114,6 +115,7 @@ function AddSnippetCard() {
|
|||||||
|
|
||||||
export function SnippetsPage() {
|
export function SnippetsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
usePageTitle(t('snippets.title'));
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [items, setItems] = useState<Snippet[]>([]);
|
const [items, setItems] = useState<Snippet[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|||||||
@@ -76,8 +76,7 @@ export function Table<T>({
|
|||||||
|
|
||||||
const handleSortClick = (key: string) => {
|
const handleSortClick = (key: string) => {
|
||||||
if (!onSort) return;
|
if (!onSort) return;
|
||||||
const nextDir: SortDirection =
|
const nextDir: SortDirection = sortBy === key && sortDir === 'asc' ? 'desc' : 'asc';
|
||||||
sortBy === key && sortDir === 'asc' ? 'desc' : 'asc';
|
|
||||||
onSort(key, nextDir);
|
onSort(key, nextDir);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -101,19 +100,12 @@ export function Table<T>({
|
|||||||
<tr>
|
<tr>
|
||||||
{columns.map((col) => {
|
{columns.map((col) => {
|
||||||
const isSorted = col.sortable && sortBy === col.key;
|
const isSorted = col.sortable && sortBy === col.key;
|
||||||
const SortIcon = isSorted
|
const SortIcon = isSorted ? (sortDir === 'asc' ? ArrowUp : ArrowDown) : ArrowUpDown;
|
||||||
? sortDir === 'asc'
|
|
||||||
? ArrowUp
|
|
||||||
: ArrowDown
|
|
||||||
: ArrowUpDown;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<th
|
<th
|
||||||
key={col.key}
|
key={col.key}
|
||||||
className={[
|
className={[styles.th, col.sortable ? styles.thSortable : '']
|
||||||
styles.th,
|
|
||||||
col.sortable ? styles.thSortable : '',
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')}
|
.join(' ')}
|
||||||
style={{
|
style={{
|
||||||
@@ -127,10 +119,7 @@ export function Table<T>({
|
|||||||
{col.sortable && (
|
{col.sortable && (
|
||||||
<SortIcon
|
<SortIcon
|
||||||
size={12}
|
size={12}
|
||||||
className={[
|
className={[styles.sortIcon, isSorted ? styles.sortIconActive : '']
|
||||||
styles.sortIcon,
|
|
||||||
isSorted ? styles.sortIconActive : '',
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')}
|
.join(' ')}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
|||||||
+22
@@ -50,7 +50,29 @@ The server currently registers the following tools.
|
|||||||
| `export_scenario` | Export scenario payload |
|
| `export_scenario` | Export scenario payload |
|
||||||
| `import_scenario` | Import scenario payload |
|
| `import_scenario` | Import scenario payload |
|
||||||
|
|
||||||
|
## Scenario Files
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|---|---|
|
||||||
|
| `upload_scenario_file` | Upload a file to a scenario; accepts optional `expiresAt` (ISO 8601 date string) |
|
||||||
|
| `list_scenario_files` | List scenario files (paginated) |
|
||||||
|
| `get_scenario_file_content` | Retrieve file content as base64 with metadata |
|
||||||
|
|
||||||
|
## Run Artifact Files
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|---|---|
|
||||||
|
| `list_run_files` | List files (artifacts) created during a scenario run (paginated) |
|
||||||
|
| `get_run_file_content` | Retrieve run artifact content as base64 with metadata; run artifacts are read-only and inherit expiry from the run artifact subsystem |
|
||||||
|
|
||||||
|
## Binary Content Handling
|
||||||
|
|
||||||
|
File tools transport binary payloads as base64-encoded strings in a `contentBase64` field with an accompanying `encoding: "base64"` marker in the response. When retrieving file content via `get_scenario_file_content` or `get_run_file_content`, decode the base64 to recover the original bytes.
|
||||||
|
|
||||||
|
Scenario files uploaded via `upload_scenario_file` must have their content pre-encoded as base64. Run artifacts are created implicitly through scenario execution (via `context.downloadFile()` during step execution) and cannot be uploaded via MCP.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Tool IDs and entity IDs are UUIDs.
|
- Tool IDs and entity IDs are UUIDs.
|
||||||
- `create_scenario_step` schema still accepts legacy `type`/`sessionName` fields for compatibility. Current scheduler executes `execCode` plus optional `validateCode` and does not branch by step type.
|
- `create_scenario_step` schema still accepts legacy `type`/`sessionName` fields for compatibility. Current scheduler executes `execCode` plus optional `validateCode` and does not branch by step type.
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "liqa",
|
"name": "liqa",
|
||||||
"version": "1.6.1",
|
"version": "1.7.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "liqa",
|
"name": "liqa",
|
||||||
"version": "1.6.1",
|
"version": "1.7.1",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"server",
|
"server",
|
||||||
"client"
|
"client"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "liqa",
|
"name": "liqa",
|
||||||
"version": "1.6.1",
|
"version": "1.7.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"server",
|
"server",
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import { CredentialEntity } from "./credential/credential.entity";
|
|||||||
import { CredentialModule } from "./credential/credential.module";
|
import { CredentialModule } from "./credential/credential.module";
|
||||||
import { EnvironmentEntity } from "./environment/environment.entity";
|
import { EnvironmentEntity } from "./environment/environment.entity";
|
||||||
import { EnvironmentModule } from "./environment/environment.module";
|
import { EnvironmentModule } from "./environment/environment.module";
|
||||||
|
import { FileEntity } from "./file/file.entity";
|
||||||
|
import { FileModule } from "./file/file.module";
|
||||||
|
import { ScenarioFileEntity } from "./file/scenario-file.entity";
|
||||||
|
import { ScenarioRunFileEntity } from "./file/scenario-run-file.entity";
|
||||||
import { HealthModule } from "./health/health.module";
|
import { HealthModule } from "./health/health.module";
|
||||||
import { McpModule } from "./mcp/mcp.module";
|
import { McpModule } from "./mcp/mcp.module";
|
||||||
import { ScenarioCredentialEntity } from "./scenario/scenario-credential.entity";
|
import { ScenarioCredentialEntity } from "./scenario/scenario-credential.entity";
|
||||||
@@ -45,6 +49,9 @@ import { SnippetModule } from "./snippet/snippet.module";
|
|||||||
ScenarioRunLogEntity,
|
ScenarioRunLogEntity,
|
||||||
ScenarioCredentialEntity,
|
ScenarioCredentialEntity,
|
||||||
SnippetEntity,
|
SnippetEntity,
|
||||||
|
FileEntity,
|
||||||
|
ScenarioFileEntity,
|
||||||
|
ScenarioRunFileEntity,
|
||||||
],
|
],
|
||||||
synchronize: true,
|
synchronize: true,
|
||||||
}),
|
}),
|
||||||
@@ -54,6 +61,7 @@ import { SnippetModule } from "./snippet/snippet.module";
|
|||||||
CredentialModule,
|
CredentialModule,
|
||||||
ScenarioModule,
|
ScenarioModule,
|
||||||
SnippetModule,
|
SnippetModule,
|
||||||
|
FileModule,
|
||||||
McpModule,
|
McpModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ import {
|
|||||||
import { expect as playwrightExpect } from "@playwright/test";
|
import { expect as playwrightExpect } from "@playwright/test";
|
||||||
import { parse } from "acorn";
|
import { parse } from "acorn";
|
||||||
import type { BrowserContext, Page } from "playwright";
|
import type { BrowserContext, Page } from "playwright";
|
||||||
|
import { URL } from "url";
|
||||||
|
import { downloadFile as downloadFileImpl } from "../common/file-downloader";
|
||||||
import { TraceLogger } from "../common/trace-logger";
|
import { TraceLogger } from "../common/trace-logger";
|
||||||
import type { EnvironmentData } from "../environment/environment.entity";
|
import type { EnvironmentData } from "../environment/environment.entity";
|
||||||
|
import type { FileStorageService } from "../file/file-storage.service";
|
||||||
import type { DomNode } from "./dom-helpers";
|
import type { DomNode } from "./dom-helpers";
|
||||||
import { dumpDom } from "./dom-helpers";
|
import { dumpDom } from "./dom-helpers";
|
||||||
|
|
||||||
@@ -41,6 +44,16 @@ export interface ScriptContext {
|
|||||||
error: (...args: unknown[]) => void;
|
error: (...args: unknown[]) => void;
|
||||||
/** Runs a named snippet with the same context, plus any extra positional args. */
|
/** Runs a named snippet with the same context, plus any extra positional args. */
|
||||||
runSnippet: (alias: string, ...args: unknown[]) => Promise<unknown>;
|
runSnippet: (alias: string, ...args: unknown[]) => Promise<unknown>;
|
||||||
|
/** Returns metadata + absolute disk path for files attached to the current scenario. */
|
||||||
|
getScenarioFiles: (opts?: { limit?: number; offset?: number }) => Promise<{
|
||||||
|
items: Array<{ id: string; name: string; mimeType: string; size: number; sha256: string; path: string }>;
|
||||||
|
total: number;
|
||||||
|
}>;
|
||||||
|
/** Downloads a file and creates a run artifact. */
|
||||||
|
downloadFile: (
|
||||||
|
url: string,
|
||||||
|
opts?: { method?: string; headers?: Record<string, string>; body?: string; filename?: string },
|
||||||
|
) => Promise<{ id: string; name: string; mimeType: string; size: number; sha256: string; path: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExecContext {
|
export interface ExecContext {
|
||||||
@@ -53,6 +66,9 @@ export interface ExecContext {
|
|||||||
environment?: EnvironmentData | null;
|
environment?: EnvironmentData | null;
|
||||||
snippets?: Record<string, string> | null;
|
snippets?: Record<string, string> | null;
|
||||||
result?: unknown;
|
result?: unknown;
|
||||||
|
scenarioId?: string;
|
||||||
|
runId?: string;
|
||||||
|
fileService?: FileStorageService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -91,6 +107,9 @@ export class CodeExecutorService {
|
|||||||
environment,
|
environment,
|
||||||
snippets,
|
snippets,
|
||||||
result,
|
result,
|
||||||
|
scenarioId,
|
||||||
|
runId,
|
||||||
|
fileService,
|
||||||
} = ctx;
|
} = ctx;
|
||||||
const scriptLog: ScriptLogger =
|
const scriptLog: ScriptLogger =
|
||||||
log ?? ((level, msg) => this.logger[level](msg));
|
log ?? ((level, msg) => this.logger[level](msg));
|
||||||
@@ -158,6 +177,58 @@ export class CodeExecutorService {
|
|||||||
);
|
);
|
||||||
return snippetFn(scriptContext, fakeConsole, args, playwrightExpect);
|
return snippetFn(scriptContext, fakeConsole, args, playwrightExpect);
|
||||||
},
|
},
|
||||||
|
getScenarioFiles: async (opts?: { limit?: number; offset?: number }) => {
|
||||||
|
if (!fileService || !scenarioId) {
|
||||||
|
throw new Error("File service not available in this context");
|
||||||
|
}
|
||||||
|
const result = await fileService.listScenarioFiles(
|
||||||
|
scenarioId,
|
||||||
|
opts?.limit,
|
||||||
|
opts?.offset,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
items: result.items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
mimeType: item.mimeType,
|
||||||
|
size: item.size,
|
||||||
|
sha256: item.sha256,
|
||||||
|
path: item.path,
|
||||||
|
})),
|
||||||
|
total: result.total,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
downloadFile: async (
|
||||||
|
url: string,
|
||||||
|
opts?: { method?: string; headers?: Record<string, string>; body?: string; filename?: string },
|
||||||
|
) => {
|
||||||
|
if (!fileService || !scenarioId || !runId) {
|
||||||
|
throw new Error("File service or run context not available");
|
||||||
|
}
|
||||||
|
// Download file
|
||||||
|
const buffer = await downloadFileImpl(url, opts);
|
||||||
|
|
||||||
|
// Determine filename and mime type
|
||||||
|
const filename = opts?.filename ?? new URL(url).pathname.split("/").pop() ?? "download";
|
||||||
|
const mimeType = this.getMimeType(filename);
|
||||||
|
|
||||||
|
// Create run artifact with mapping
|
||||||
|
const savedFile = await fileService.createAndSaveRunArtifact(
|
||||||
|
runId,
|
||||||
|
buffer,
|
||||||
|
filename,
|
||||||
|
mimeType,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: savedFile.id,
|
||||||
|
name: savedFile.originalName,
|
||||||
|
mimeType: savedFile.mimeType,
|
||||||
|
size: savedFile.size,
|
||||||
|
sha256: savedFile.sha256,
|
||||||
|
path: fileService.getAbsolutePath(savedFile.filePath),
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// `console`, `result`, and `expect` are injected as named parameters so
|
// `console`, `result`, and `expect` are injected as named parameters so
|
||||||
@@ -184,4 +255,26 @@ export class CodeExecutorService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infers MIME type from filename.
|
||||||
|
*/
|
||||||
|
private getMimeType(filename: string): string {
|
||||||
|
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
||||||
|
const mimeTypes: Record<string, string> = {
|
||||||
|
pdf: "application/pdf",
|
||||||
|
doc: "application/msword",
|
||||||
|
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
xls: "application/vnd.ms-excel",
|
||||||
|
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
jpg: "image/jpeg",
|
||||||
|
jpeg: "image/jpeg",
|
||||||
|
png: "image/png",
|
||||||
|
gif: "image/gif",
|
||||||
|
csv: "text/csv",
|
||||||
|
txt: "text/plain",
|
||||||
|
zip: "application/zip",
|
||||||
|
};
|
||||||
|
return mimeTypes[ext] ?? "application/octet-stream";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { BrowserContext, Page } from "playwright";
|
import type { BrowserContext, Page } from "playwright";
|
||||||
import type { EnvironmentData } from "../environment/environment.entity";
|
import type { EnvironmentData } from "../environment/environment.entity";
|
||||||
|
import type { FileStorageService } from "../file/file-storage.service";
|
||||||
import type { ExecContext, ScriptLogger } from "./code-executor.service";
|
import type { ExecContext, ScriptLogger } from "./code-executor.service";
|
||||||
|
|
||||||
export class ExecContextBuilder {
|
export class ExecContextBuilder {
|
||||||
@@ -50,6 +51,21 @@ export class ExecContextBuilder {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scenarioId(scenarioId: string): this {
|
||||||
|
this.ctx.scenarioId = scenarioId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
runId(runId: string): this {
|
||||||
|
this.ctx.runId = runId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
fileService(fileService: FileStorageService): this {
|
||||||
|
this.ctx.fileService = fileService;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
build(): ExecContext {
|
build(): ExecContext {
|
||||||
if (!this.ctx.page) throw new Error("ExecContextBuilder: page is required");
|
if (!this.ctx.page) throw new Error("ExecContextBuilder: page is required");
|
||||||
if (!this.ctx.browser)
|
if (!this.ctx.browser)
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import * as https from "https";
|
||||||
|
import * as http from "http";
|
||||||
|
import { URL } from "url";
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB
|
||||||
|
const TIMEOUT_MS = 30000; // 30 seconds
|
||||||
|
|
||||||
|
export async function downloadFile(
|
||||||
|
urlStr: string,
|
||||||
|
options?: {
|
||||||
|
method?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: string;
|
||||||
|
},
|
||||||
|
): Promise<Buffer> {
|
||||||
|
const url = new URL(urlStr);
|
||||||
|
const protocol = url.protocol === "https:" ? https : http;
|
||||||
|
const method = options?.method ?? "GET";
|
||||||
|
const headers = options?.headers ?? {};
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = protocol.request(
|
||||||
|
{
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port,
|
||||||
|
path: url.pathname + url.search,
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
timeout: TIMEOUT_MS,
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
// Follow redirects
|
||||||
|
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||||
|
downloadFile(res.headers.location, options).then(resolve).catch(reject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.statusCode && res.statusCode !== 200) {
|
||||||
|
reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let totalSize = 0;
|
||||||
|
|
||||||
|
res.on("data", (chunk: Buffer) => {
|
||||||
|
totalSize += chunk.length;
|
||||||
|
if (totalSize > MAX_FILE_SIZE) {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error(`File exceeds maximum size of ${MAX_FILE_SIZE} bytes`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on("end", () => {
|
||||||
|
resolve(Buffer.concat(chunks));
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on("error", reject);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
req.on("timeout", () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error("Download timeout"));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on("error", reject);
|
||||||
|
|
||||||
|
if (options?.body && (method === "POST" || method === "PUT")) {
|
||||||
|
req.write(options.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -31,6 +31,16 @@ export class AppConfig {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
SESSION_DELETE_CLOSED_DAYS: number = 7;
|
SESSION_DELETE_CLOSED_DAYS: number = 7;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
FILES_DIR: string = "data/files";
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
FILE_RUN_EXPIRATION_DAYS: number = 7;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
FILE_CLEANUP_CRON: string = "0 3 * * *";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateAppConfig(config: Record<string, unknown>): AppConfig {
|
export function validateAppConfig(config: Record<string, unknown>): AppConfig {
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import { Injectable, OnModuleInit } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { SchedulerRegistry } from "@nestjs/schedule";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import * as crypto from "crypto";
|
||||||
|
import * as fs from "fs/promises";
|
||||||
|
import * as path from "path";
|
||||||
|
import { CronJob } from "cron";
|
||||||
|
import { Repository } from "typeorm";
|
||||||
|
import { TraceLogger } from "../common/trace-logger";
|
||||||
|
import { AppConfig } from "../config/app.config";
|
||||||
|
import { FileEntity } from "./file.entity";
|
||||||
|
import { ScenarioFileEntity } from "./scenario-file.entity";
|
||||||
|
import { ScenarioRunFileEntity } from "./scenario-run-file.entity";
|
||||||
|
|
||||||
|
export interface FileMetadata {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
sha256: string;
|
||||||
|
expiresAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FileStorageService implements OnModuleInit {
|
||||||
|
private static readonly DEFAULT_PAGE_SIZE = 20;
|
||||||
|
private readonly logger = new TraceLogger(FileStorageService.name);
|
||||||
|
private readonly filesDir: string;
|
||||||
|
private readonly fileRunExpirationDays: number;
|
||||||
|
private readonly fileCleanupCron: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(FileEntity)
|
||||||
|
readonly fileRepo: Repository<FileEntity>,
|
||||||
|
@InjectRepository(ScenarioFileEntity)
|
||||||
|
private readonly scenarioFileRepo: Repository<ScenarioFileEntity>,
|
||||||
|
@InjectRepository(ScenarioRunFileEntity)
|
||||||
|
private readonly scenarioRunFileRepo: Repository<ScenarioRunFileEntity>,
|
||||||
|
configService: ConfigService<AppConfig, true>,
|
||||||
|
private readonly schedulerRegistry: SchedulerRegistry,
|
||||||
|
) {
|
||||||
|
this.filesDir = configService.get("FILES_DIR");
|
||||||
|
this.fileRunExpirationDays = configService.get("FILE_RUN_EXPIRATION_DAYS");
|
||||||
|
this.fileCleanupCron = configService.get("FILE_CLEANUP_CRON");
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
// Ensure the files directory exists
|
||||||
|
try {
|
||||||
|
await fs.mkdir(this.filesDir, { recursive: true });
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Failed to create FILES_DIR ${this.filesDir}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register cleanup cron job dynamically so the schedule is runtime-configurable
|
||||||
|
const job = new CronJob(this.fileCleanupCron, () => {
|
||||||
|
void this.cleanupExpiredFiles();
|
||||||
|
});
|
||||||
|
this.schedulerRegistry.addCronJob("file-cleanup", job);
|
||||||
|
job.start();
|
||||||
|
this.logger.debug(`File cleanup cron registered: ${this.fileCleanupCron}`);
|
||||||
|
|
||||||
|
// Run cleanup at startup to catch any backlog
|
||||||
|
await this.cleanupExpiredFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves a file to disk with sharded directory structure.
|
||||||
|
* Returns the FileEntity (unsaved) with computed filePath.
|
||||||
|
*/
|
||||||
|
async saveFile(
|
||||||
|
buffer: Buffer,
|
||||||
|
originalName: string,
|
||||||
|
mimeType: string,
|
||||||
|
expiresAt?: Date,
|
||||||
|
): Promise<FileEntity> {
|
||||||
|
const fileId = crypto.randomUUID();
|
||||||
|
const sha256 = crypto.createHash("sha256").update(buffer).digest("hex");
|
||||||
|
const shardPath = this.getShardedPath(fileId);
|
||||||
|
const fullPath = path.join(this.filesDir, shardPath);
|
||||||
|
|
||||||
|
// Create sharded directory
|
||||||
|
const dirPath = path.dirname(fullPath);
|
||||||
|
await fs.mkdir(dirPath, { recursive: true });
|
||||||
|
|
||||||
|
// Write file to disk and persist entity
|
||||||
|
await fs.writeFile(fullPath, buffer);
|
||||||
|
|
||||||
|
const file = this.fileRepo.create({
|
||||||
|
id: fileId,
|
||||||
|
originalName,
|
||||||
|
mimeType,
|
||||||
|
size: buffer.length,
|
||||||
|
sha256,
|
||||||
|
filePath: shardPath,
|
||||||
|
expiresAt: expiresAt ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.fileRepo.save(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the sharded path for a file ID: xx/xxxx/uuid
|
||||||
|
*/
|
||||||
|
private getShardedPath(uuid: string): string {
|
||||||
|
return `${uuid.substring(0, 2)}/${uuid.substring(0, 4)}/${uuid}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns absolute disk path for a file.
|
||||||
|
*/
|
||||||
|
getAbsolutePath(filePath: string): string {
|
||||||
|
return path.join(this.filesDir, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists scenario files with pagination.
|
||||||
|
*/
|
||||||
|
async listScenarioFiles(
|
||||||
|
scenarioId: string,
|
||||||
|
limit?: number,
|
||||||
|
offset?: number,
|
||||||
|
): Promise<{ items: FileMetadata[]; total: number }> {
|
||||||
|
const query = this.scenarioFileRepo
|
||||||
|
.createQueryBuilder("sf")
|
||||||
|
.innerJoinAndSelect("sf.file", "f")
|
||||||
|
.where("sf.scenarioId = :scenarioId", { scenarioId });
|
||||||
|
|
||||||
|
const total = await query.getCount();
|
||||||
|
|
||||||
|
const items = await query
|
||||||
|
.orderBy("sf.createdAt", "DESC")
|
||||||
|
.skip(offset ?? 0)
|
||||||
|
.take(limit ?? FileStorageService.DEFAULT_PAGE_SIZE)
|
||||||
|
.getMany();
|
||||||
|
return {
|
||||||
|
items: items.map((sf) => this.mapFileEntityToMetadata(sf.file)),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists run artifact files with pagination.
|
||||||
|
*/
|
||||||
|
async listRunFiles(
|
||||||
|
runId: string,
|
||||||
|
limit?: number,
|
||||||
|
offset?: number,
|
||||||
|
): Promise<{ items: FileMetadata[]; total: number }> {
|
||||||
|
const query = this.scenarioRunFileRepo
|
||||||
|
.createQueryBuilder("srf")
|
||||||
|
.innerJoinAndSelect("srf.file", "f")
|
||||||
|
.where("srf.runId = :runId", { runId });
|
||||||
|
|
||||||
|
const total = await query.getCount();
|
||||||
|
|
||||||
|
const items = await query
|
||||||
|
.orderBy("srf.createdAt", "DESC")
|
||||||
|
.skip(offset ?? 0)
|
||||||
|
.take(limit ?? FileStorageService.DEFAULT_PAGE_SIZE)
|
||||||
|
.getMany();
|
||||||
|
return {
|
||||||
|
items: items.map((srf) => this.mapFileEntityToMetadata(srf.file)),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a file entity by ID.
|
||||||
|
*/
|
||||||
|
async getFile(fileId: string): Promise<FileEntity | null> {
|
||||||
|
return this.fileRepo.findOneBy({ id: fileId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a file and checks for orphaned FileEntity rows.
|
||||||
|
* If a FileEntity has no remaining mappings, it is deleted along with its physical file.
|
||||||
|
*/
|
||||||
|
async deleteFile(fileId: string): Promise<void> {
|
||||||
|
const file = await this.fileRepo.findOneBy({ id: fileId });
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// Check if this file still has any mappings
|
||||||
|
const scenarioCount = await this.scenarioFileRepo.countBy({ fileId });
|
||||||
|
const runCount = await this.scenarioRunFileRepo.countBy({ fileId });
|
||||||
|
|
||||||
|
// If no mappings remain, delete the file and its physical copy
|
||||||
|
if (scenarioCount === 0 && runCount === 0) {
|
||||||
|
const fullPath = this.getAbsolutePath(file.filePath);
|
||||||
|
try {
|
||||||
|
await fs.unlink(fullPath);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to delete physical file ${fullPath}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.fileRepo.delete(fileId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a run artifact file entry with auto-expiration.
|
||||||
|
*/
|
||||||
|
async createRunArtifact(buffer: Buffer, originalName: string, mimeType: string): Promise<FileEntity> {
|
||||||
|
const now = new Date();
|
||||||
|
const expiresAt = new Date(now.getTime() + this.fileRunExpirationDays * 24 * 60 * 60 * 1000);
|
||||||
|
return this.saveFile(buffer, originalName, mimeType, expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a run artifact with file and mapping in the database.
|
||||||
|
*/
|
||||||
|
async createAndSaveRunArtifact(
|
||||||
|
runId: string,
|
||||||
|
buffer: Buffer,
|
||||||
|
originalName: string,
|
||||||
|
mimeType: string,
|
||||||
|
): Promise<FileEntity> {
|
||||||
|
const savedFile = await this.createRunArtifact(buffer, originalName, mimeType);
|
||||||
|
|
||||||
|
// Create run file mapping
|
||||||
|
const runFile = this.scenarioRunFileRepo.create({
|
||||||
|
runId,
|
||||||
|
fileId: savedFile.id,
|
||||||
|
});
|
||||||
|
await this.scenarioRunFileRepo.save(runFile);
|
||||||
|
|
||||||
|
return savedFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup job: deletes expired FileEntity rows and orphaned physical files.
|
||||||
|
* Runs via @Cron scheduler and at module init.
|
||||||
|
*/
|
||||||
|
async cleanupExpiredFiles(): Promise<void> {
|
||||||
|
const now = new Date();
|
||||||
|
const expiredFiles = await this.fileRepo
|
||||||
|
.createQueryBuilder("file")
|
||||||
|
.where("file.expiresAt IS NOT NULL")
|
||||||
|
.andWhere("file.expiresAt <= :now", { now })
|
||||||
|
.setParameters({ now })
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
if (expiredFiles.length === 0) return;
|
||||||
|
|
||||||
|
this.logger.debug(`Cleaning up ${expiredFiles.length} expired files`);
|
||||||
|
|
||||||
|
for (const file of expiredFiles) {
|
||||||
|
try {
|
||||||
|
// Delete associated mapping rows first (cascade will be handled, but we delete manually to ensure orphan check works)
|
||||||
|
await this.scenarioFileRepo.delete({ fileId: file.id });
|
||||||
|
await this.scenarioRunFileRepo.delete({ fileId: file.id });
|
||||||
|
|
||||||
|
// Delete the file entity and physical file
|
||||||
|
await this.deleteFile(file.id);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to clean up expired file ${file.id}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps FileEntity to FileMetadata with absolute path.
|
||||||
|
*/
|
||||||
|
private mapFileEntityToMetadata(file: FileEntity): FileMetadata {
|
||||||
|
return {
|
||||||
|
id: file.id,
|
||||||
|
name: file.originalName,
|
||||||
|
mimeType: file.mimeType,
|
||||||
|
size: file.size,
|
||||||
|
sha256: file.sha256,
|
||||||
|
expiresAt: file.expiresAt,
|
||||||
|
createdAt: file.createdAt,
|
||||||
|
path: this.getAbsolutePath(file.filePath),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from "typeorm";
|
||||||
|
|
||||||
|
@Entity("files")
|
||||||
|
export class FileEntity {
|
||||||
|
@PrimaryGeneratedColumn("uuid")
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
originalName: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
mimeType: string;
|
||||||
|
|
||||||
|
@Column({ type: "integer" })
|
||||||
|
size: number;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
sha256: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
filePath: string;
|
||||||
|
|
||||||
|
@Column({ type: "datetime", nullable: true })
|
||||||
|
expiresAt: Date | null;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
import { FileEntity } from "./file.entity";
|
||||||
|
import { FileStorageService } from "./file-storage.service";
|
||||||
|
import { ScenarioFileEntity } from "./scenario-file.entity";
|
||||||
|
import { ScenarioRunFileEntity } from "./scenario-run-file.entity";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([FileEntity, ScenarioFileEntity, ScenarioRunFileEntity])],
|
||||||
|
providers: [FileStorageService],
|
||||||
|
exports: [FileStorageService],
|
||||||
|
})
|
||||||
|
export class FileModule {}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
} from "typeorm";
|
||||||
|
import { ScenarioEntity } from "../scenario/scenario.entity";
|
||||||
|
import { FileEntity } from "./file.entity";
|
||||||
|
|
||||||
|
@Entity("scenario_files")
|
||||||
|
export class ScenarioFileEntity {
|
||||||
|
@PrimaryGeneratedColumn("uuid")
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
scenarioId: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
fileId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => ScenarioEntity, { onDelete: "CASCADE" })
|
||||||
|
@JoinColumn({ name: "scenarioId" })
|
||||||
|
scenario: ScenarioEntity;
|
||||||
|
|
||||||
|
@ManyToOne(() => FileEntity, { onDelete: "CASCADE" })
|
||||||
|
@JoinColumn({ name: "fileId" })
|
||||||
|
file: FileEntity;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
} from "typeorm";
|
||||||
|
import { ScenarioRunEntity } from "../scenario/scenario-run.entity";
|
||||||
|
import { FileEntity } from "./file.entity";
|
||||||
|
|
||||||
|
@Entity("scenario_run_files")
|
||||||
|
export class ScenarioRunFileEntity {
|
||||||
|
@PrimaryGeneratedColumn("uuid")
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
runId: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
fileId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => ScenarioRunEntity, { onDelete: "CASCADE" })
|
||||||
|
@JoinColumn({ name: "runId" })
|
||||||
|
run: ScenarioRunEntity;
|
||||||
|
|
||||||
|
@ManyToOne(() => FileEntity, { onDelete: "CASCADE" })
|
||||||
|
@JoinColumn({ name: "fileId" })
|
||||||
|
file: FileEntity;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
@@ -10,6 +10,21 @@ import { HttpExceptionFilter } from "./filters/http-exception.filter";
|
|||||||
import { LoggingInterceptor } from "./interceptors/logging.interceptor";
|
import { LoggingInterceptor } from "./interceptors/logging.interceptor";
|
||||||
import { TraceInterceptor } from "./interceptors/trace.interceptor";
|
import { TraceInterceptor } from "./interceptors/trace.interceptor";
|
||||||
|
|
||||||
|
// Guard against uncaught exceptions thrown inside Playwright event listener
|
||||||
|
// callbacks (e.g. a bad waitForURL predicate). Without this, Node.js v15+
|
||||||
|
// crashes the process on any unhandled rejection or uncaught exception.
|
||||||
|
const processLogger = new TraceLogger("Process");
|
||||||
|
process.on("uncaughtException", (err) => {
|
||||||
|
processLogger.error(`Uncaught exception (process kept alive): ${err.message}`, err.stack);
|
||||||
|
});
|
||||||
|
process.on("unhandledRejection", (reason) => {
|
||||||
|
processLogger.error(
|
||||||
|
`Unhandled promise rejection (process kept alive): ${
|
||||||
|
reason instanceof Error ? reason.message : String(reason)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const API_PREFIX = "api/v1";
|
const API_PREFIX = "api/v1";
|
||||||
const logger = new TraceLogger("Bootstrap");
|
const logger = new TraceLogger("Bootstrap");
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { CredentialModule } from "../credential/credential.module";
|
|||||||
import { EnvironmentModule } from "../environment/environment.module";
|
import { EnvironmentModule } from "../environment/environment.module";
|
||||||
import { ScenarioModule } from "../scenario/scenario.module";
|
import { ScenarioModule } from "../scenario/scenario.module";
|
||||||
import { SessionModule } from "../session/session.module";
|
import { SessionModule } from "../session/session.module";
|
||||||
|
import { SnippetModule } from "../snippet/snippet.module";
|
||||||
import { McpController } from "./mcp.controller";
|
import { McpController } from "./mcp.controller";
|
||||||
import { McpService } from "./mcp.service";
|
import { McpService } from "./mcp.service";
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ import { McpService } from "./mcp.service";
|
|||||||
BrowserModule,
|
BrowserModule,
|
||||||
CodeExecutorModule,
|
CodeExecutorModule,
|
||||||
ScenarioModule,
|
ScenarioModule,
|
||||||
|
SnippetModule,
|
||||||
],
|
],
|
||||||
controllers: [McpController],
|
controllers: [McpController],
|
||||||
providers: [McpService],
|
providers: [McpService],
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { CredentialService } from "../credential/credential.service";
|
|||||||
import { BrowserService } from "../browser/browser.service";
|
import { BrowserService } from "../browser/browser.service";
|
||||||
import { CodeExecutorService } from "../code-executor/code-executor.service";
|
import { CodeExecutorService } from "../code-executor/code-executor.service";
|
||||||
import { ScenarioService } from "../scenario/scenario.service";
|
import { ScenarioService } from "../scenario/scenario.service";
|
||||||
|
import { SnippetService } from "../snippet/snippet.service";
|
||||||
|
|
||||||
import pkg from "../../package.json";
|
import pkg from "../../package.json";
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ export class McpService {
|
|||||||
private readonly browserService: BrowserService,
|
private readonly browserService: BrowserService,
|
||||||
private readonly codeExecutor: CodeExecutorService,
|
private readonly codeExecutor: CodeExecutorService,
|
||||||
private readonly scenarioService: ScenarioService,
|
private readonly scenarioService: ScenarioService,
|
||||||
|
private readonly snippetService: SnippetService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private createServer(): McpServer {
|
private createServer(): McpServer {
|
||||||
@@ -1060,6 +1062,429 @@ export class McpService {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Scenario Files ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"upload_scenario_file",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Upload a file to a scenario. Content must be base64-encoded. Returns file metadata.",
|
||||||
|
inputSchema: {
|
||||||
|
scenarioId: z.uuid().describe("Scenario ID"),
|
||||||
|
name: z.string().describe("File name"),
|
||||||
|
contentBase64: z
|
||||||
|
.string()
|
||||||
|
.describe("Base64-encoded file content"),
|
||||||
|
mimeType: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("MIME type (e.g., text/plain, application/json)"),
|
||||||
|
expiresAt: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("ISO 8601 expiration date"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ scenarioId, name, contentBase64, mimeType, expiresAt }) => {
|
||||||
|
try {
|
||||||
|
// Validate base64 format
|
||||||
|
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(contentBase64)) {
|
||||||
|
return {
|
||||||
|
isError: true,
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: "Invalid base64 encoding",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode base64 to buffer
|
||||||
|
let buffer: Buffer;
|
||||||
|
try {
|
||||||
|
buffer = Buffer.from(contentBase64, "base64");
|
||||||
|
// Verify the base64 can be re-encoded to match the original
|
||||||
|
if (buffer.toString("base64") !== contentBase64) {
|
||||||
|
return {
|
||||||
|
isError: true,
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: "Invalid base64 encoding",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
isError: true,
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: "Invalid base64 encoding",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a mock Multer file object for the service
|
||||||
|
const file = {
|
||||||
|
buffer,
|
||||||
|
originalname: name,
|
||||||
|
mimetype: mimeType || "application/octet-stream",
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
// Call the service method
|
||||||
|
const result = await this.scenarioService.uploadFile(
|
||||||
|
scenarioId,
|
||||||
|
file,
|
||||||
|
expiresAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
isError: true,
|
||||||
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"list_scenario_files",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"List files uploaded to a scenario (paginated)",
|
||||||
|
inputSchema: {
|
||||||
|
scenarioId: z.uuid().describe("Scenario ID"),
|
||||||
|
limit: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.optional()
|
||||||
|
.describe("Items per page (default 20)"),
|
||||||
|
offset: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
|
.optional()
|
||||||
|
.describe("Skip count (default 0)"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ scenarioId, limit, offset }) => {
|
||||||
|
try {
|
||||||
|
const result = await this.scenarioService.listScenarioFiles(
|
||||||
|
scenarioId,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
isError: true,
|
||||||
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"get_scenario_file_content",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Get a scenario file's content as base64 with metadata",
|
||||||
|
inputSchema: {
|
||||||
|
scenarioId: z.uuid().describe("Scenario ID"),
|
||||||
|
fileId: z.uuid().describe("File ID"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ scenarioId, fileId }) => {
|
||||||
|
try {
|
||||||
|
const { file, contentBuffer } =
|
||||||
|
await this.scenarioService.getScenarioFileContentAsBuffer(
|
||||||
|
scenarioId,
|
||||||
|
fileId,
|
||||||
|
);
|
||||||
|
const contentBase64 = contentBuffer.toString("base64");
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: JSON.stringify({
|
||||||
|
file,
|
||||||
|
contentBase64,
|
||||||
|
encoding: "base64",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
isError: true,
|
||||||
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Run Artifact Files ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"list_run_files",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"List files (artifacts) created during a scenario run (paginated)",
|
||||||
|
inputSchema: {
|
||||||
|
scenarioId: z.uuid().describe("Scenario ID"),
|
||||||
|
runId: z.uuid().describe("Run ID"),
|
||||||
|
limit: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.optional()
|
||||||
|
.describe("Items per page (default 20)"),
|
||||||
|
offset: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
|
.optional()
|
||||||
|
.describe("Skip count (default 0)"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ scenarioId, runId, limit, offset }) => {
|
||||||
|
try {
|
||||||
|
const result = await this.scenarioService.listRunFiles(
|
||||||
|
scenarioId,
|
||||||
|
runId,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
isError: true,
|
||||||
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"get_run_file_content",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Get a run artifact file's content as base64 with metadata",
|
||||||
|
inputSchema: {
|
||||||
|
scenarioId: z.uuid().describe("Scenario ID"),
|
||||||
|
runId: z.uuid().describe("Run ID"),
|
||||||
|
fileId: z.uuid().describe("File ID"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ scenarioId, runId, fileId }) => {
|
||||||
|
try {
|
||||||
|
const { file, contentBuffer } =
|
||||||
|
await this.scenarioService.getRunFileContentAsBuffer(
|
||||||
|
scenarioId,
|
||||||
|
runId,
|
||||||
|
fileId,
|
||||||
|
);
|
||||||
|
const contentBase64 = contentBuffer.toString("base64");
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: JSON.stringify({
|
||||||
|
file,
|
||||||
|
contentBase64,
|
||||||
|
encoding: "base64",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
isError: true,
|
||||||
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Snippets ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"list_snippets",
|
||||||
|
{
|
||||||
|
description: "List all snippets (paginated)",
|
||||||
|
inputSchema: {
|
||||||
|
page: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.optional()
|
||||||
|
.describe("Page number (default 1)"),
|
||||||
|
limit: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.optional()
|
||||||
|
.describe("Items per page (default 50)"),
|
||||||
|
orderBy: z
|
||||||
|
.enum(["id", "alias", "title", "createdAt", "updatedAt"])
|
||||||
|
.optional()
|
||||||
|
.describe("Field to order by (default id)"),
|
||||||
|
orderDir: z
|
||||||
|
.enum(["ASC", "DESC"])
|
||||||
|
.optional()
|
||||||
|
.describe("Sort direction (default ASC)"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ page, limit, orderBy, orderDir }) => {
|
||||||
|
const result = await this.snippetService.findAll({
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
orderBy,
|
||||||
|
orderDir,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"get_snippet",
|
||||||
|
{
|
||||||
|
description: "Get a snippet by ID",
|
||||||
|
inputSchema: {
|
||||||
|
id: z.uuid().describe("Snippet ID"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ id }) => {
|
||||||
|
try {
|
||||||
|
const snippet = await this.snippetService.findOne(id);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: JSON.stringify(snippet) }],
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
isError: true,
|
||||||
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"create_snippet",
|
||||||
|
{
|
||||||
|
description: "Create a new reusable code snippet",
|
||||||
|
inputSchema: {
|
||||||
|
alias: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"Unique identifier used to invoke the snippet via context.runSnippet(alias, ...args)",
|
||||||
|
),
|
||||||
|
title: z.string().describe("Human-readable title"),
|
||||||
|
description: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Optional markdown description"),
|
||||||
|
code: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"Async JavaScript body. Receives the same context as exec steps plus any positional ...args.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ alias, title, description, code }) => {
|
||||||
|
try {
|
||||||
|
const snippet = await this.snippetService.create({
|
||||||
|
alias,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
code,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: JSON.stringify(snippet) }],
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
isError: true,
|
||||||
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"update_snippet",
|
||||||
|
{
|
||||||
|
description: "Update an existing snippet",
|
||||||
|
inputSchema: {
|
||||||
|
id: z.uuid().describe("Snippet ID to update"),
|
||||||
|
alias: z.string().optional().describe("New alias"),
|
||||||
|
title: z.string().optional().describe("New title"),
|
||||||
|
description: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("New markdown description"),
|
||||||
|
code: z.string().optional().describe("New code body"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ id, alias, title, description, code }) => {
|
||||||
|
try {
|
||||||
|
const snippet = await this.snippetService.update(id, {
|
||||||
|
alias,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
code,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: JSON.stringify(snippet) }],
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
isError: true,
|
||||||
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"delete_snippet",
|
||||||
|
{
|
||||||
|
description: "Delete a snippet by ID",
|
||||||
|
inputSchema: {
|
||||||
|
id: z.uuid().describe("Snippet ID to delete"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ id }) => {
|
||||||
|
try {
|
||||||
|
await this.snippetService.remove(id);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: "text" as const, text: `Snippet ${id} deleted` },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
isError: true,
|
||||||
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// ── Transport ─────────────────────────────────────────────────────────────
|
// ── Transport ─────────────────────────────────────────────────────────────
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Effect } from "effect";
|
|||||||
import type { Browser, BrowserContext, Page } from "playwright";
|
import type { Browser, BrowserContext, Page } from "playwright";
|
||||||
import { chromium } from "playwright";
|
import { chromium } from "playwright";
|
||||||
import { Repository } from "typeorm";
|
import { Repository } from "typeorm";
|
||||||
|
import { FileStorageService } from "../file/file-storage.service";
|
||||||
import type { ScriptLogger } from "../code-executor/code-executor.service";
|
import type { ScriptLogger } from "../code-executor/code-executor.service";
|
||||||
import { CodeExecutorService } from "../code-executor/code-executor.service";
|
import { CodeExecutorService } from "../code-executor/code-executor.service";
|
||||||
import { ExecContextBuilder } from "../code-executor/exec-context.builder";
|
import { ExecContextBuilder } from "../code-executor/exec-context.builder";
|
||||||
@@ -43,6 +44,8 @@ export class ScenarioSchedulerService implements OnModuleInit {
|
|||||||
private readonly runEnvironments = new Map<string, EnvironmentData>();
|
private readonly runEnvironments = new Map<string, EnvironmentData>();
|
||||||
// Cache scenario-level timeout (seconds) per run
|
// Cache scenario-level timeout (seconds) per run
|
||||||
private readonly runScenarioTimeouts = new Map<string, number | null>();
|
private readonly runScenarioTimeouts = new Map<string, number | null>();
|
||||||
|
// Cache scenarioId per run
|
||||||
|
private readonly runScenarioIds = new Map<string, string>();
|
||||||
|
|
||||||
private static readonly DEFAULT_SCENARIO_TIMEOUT_SEC = 600;
|
private static readonly DEFAULT_SCENARIO_TIMEOUT_SEC = 600;
|
||||||
private static readonly DEFAULT_STEP_TIMEOUT_SEC = 60;
|
private static readonly DEFAULT_STEP_TIMEOUT_SEC = 60;
|
||||||
@@ -62,6 +65,7 @@ export class ScenarioSchedulerService implements OnModuleInit {
|
|||||||
private readonly snippetService: SnippetService,
|
private readonly snippetService: SnippetService,
|
||||||
private readonly sessionService: SessionService,
|
private readonly sessionService: SessionService,
|
||||||
private readonly sessionContextService: SessionContextService,
|
private readonly sessionContextService: SessionContextService,
|
||||||
|
private readonly fileStorageService: FileStorageService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
async onModuleInit(): Promise<void> {
|
||||||
@@ -198,6 +202,7 @@ export class ScenarioSchedulerService implements OnModuleInit {
|
|||||||
.findOne(run.scenarioId)
|
.findOne(run.scenarioId)
|
||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
this.runScenarioTimeouts.set(run.id, scenario?.timeoutSeconds ?? null);
|
this.runScenarioTimeouts.set(run.id, scenario?.timeoutSeconds ?? null);
|
||||||
|
this.runScenarioIds.set(run.id, run.scenarioId);
|
||||||
const traceId = crypto.randomUUID();
|
const traceId = crypto.randomUUID();
|
||||||
void traceStorage.run({ traceId }, () =>
|
void traceStorage.run({ traceId }, () =>
|
||||||
this.processRunToCompletion(run.id),
|
this.processRunToCompletion(run.id),
|
||||||
@@ -210,6 +215,7 @@ export class ScenarioSchedulerService implements OnModuleInit {
|
|||||||
this.runSnippets.delete(run.id);
|
this.runSnippets.delete(run.id);
|
||||||
this.runEnvironments.delete(run.id);
|
this.runEnvironments.delete(run.id);
|
||||||
this.runScenarioTimeouts.delete(run.id);
|
this.runScenarioTimeouts.delete(run.id);
|
||||||
|
this.runScenarioIds.delete(run.id);
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Run #${run.id}: setup failed — ${String(err)}`,
|
`Run #${run.id}: setup failed — ${String(err)}`,
|
||||||
);
|
);
|
||||||
@@ -272,6 +278,7 @@ export class ScenarioSchedulerService implements OnModuleInit {
|
|||||||
this.runSnippets.delete(runId);
|
this.runSnippets.delete(runId);
|
||||||
this.runEnvironments.delete(runId);
|
this.runEnvironments.delete(runId);
|
||||||
this.runScenarioTimeouts.delete(runId);
|
this.runScenarioTimeouts.delete(runId);
|
||||||
|
this.runScenarioIds.delete(runId);
|
||||||
await this.maybePreserveSession(runId);
|
await this.maybePreserveSession(runId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,6 +356,7 @@ export class ScenarioSchedulerService implements OnModuleInit {
|
|||||||
const creds = this.runCredentials.get(stepRun.runId);
|
const creds = this.runCredentials.get(stepRun.runId);
|
||||||
const snips = this.runSnippets.get(stepRun.runId);
|
const snips = this.runSnippets.get(stepRun.runId);
|
||||||
const env = this.runEnvironments.get(stepRun.runId);
|
const env = this.runEnvironments.get(stepRun.runId);
|
||||||
|
const scenarioId = this.runScenarioIds.get(stepRun.runId);
|
||||||
const execCtx = new ExecContextBuilder()
|
const execCtx = new ExecContextBuilder()
|
||||||
.page(page)
|
.page(page)
|
||||||
.browser(context)
|
.browser(context)
|
||||||
@@ -358,6 +366,9 @@ export class ScenarioSchedulerService implements OnModuleInit {
|
|||||||
.credentials(creds)
|
.credentials(creds)
|
||||||
.environment(env)
|
.environment(env)
|
||||||
.snippets(snips)
|
.snippets(snips)
|
||||||
|
.scenarioId(scenarioId)
|
||||||
|
.runId(stepRun.runId)
|
||||||
|
.fileService(this.fileStorageService)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
const scenarioTimeoutSec = this.runScenarioTimeouts.get(stepRun.runId) ?? null;
|
const scenarioTimeoutSec = this.runScenarioTimeouts.get(stepRun.runId) ?? null;
|
||||||
@@ -367,18 +378,26 @@ export class ScenarioSchedulerService implements OnModuleInit {
|
|||||||
ScenarioSchedulerService.DEFAULT_STEP_TIMEOUT_SEC;
|
ScenarioSchedulerService.DEFAULT_STEP_TIMEOUT_SEC;
|
||||||
const stepTimeoutMs = stepTimeoutSec * 1000;
|
const stepTimeoutMs = stepTimeoutSec * 1000;
|
||||||
|
|
||||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||||
setTimeout(
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
timeoutId = setTimeout(
|
||||||
() => reject(new Error(`Step timed out after ${stepTimeoutSec}s`)),
|
() => reject(new Error(`Step timed out after ${stepTimeoutSec}s`)),
|
||||||
stepTimeoutMs,
|
stepTimeoutMs,
|
||||||
),
|
);
|
||||||
);
|
});
|
||||||
const { result: execOutput } = await Promise.race([
|
try {
|
||||||
this.codeExecutor.execute(execCtx),
|
const { result: execOutput } = await Promise.race([
|
||||||
timeoutPromise,
|
this.codeExecutor.execute(execCtx),
|
||||||
]);
|
timeoutPromise,
|
||||||
|
]);
|
||||||
await this.passStepRun(stepRun, null, execOutput);
|
await this.passStepRun(stepRun, null, execOutput);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as Error).message ?? String(err);
|
||||||
|
this.logger.error(`StepRun #${stepRun.id} threw: ${msg}`);
|
||||||
|
await this.failStepRun(stepRun, msg);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = (err as Error).message ?? String(err);
|
const msg = (err as Error).message ?? String(err);
|
||||||
this.logger.error(`StepRun #${stepRun.id} threw: ${msg}`);
|
this.logger.error(`StepRun #${stepRun.id} threw: ${msg}`);
|
||||||
|
|||||||
@@ -10,9 +10,13 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
Res,
|
Res,
|
||||||
|
UseInterceptors,
|
||||||
|
UploadedFile,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
import { FileInterceptor } from "@nestjs/platform-express";
|
||||||
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
||||||
import { Response } from "express";
|
import { Response } from "express";
|
||||||
|
import type { File as MulterFile } from "multer";
|
||||||
import { stringify as yamlStringify } from "yaml";
|
import { stringify as yamlStringify } from "yaml";
|
||||||
import { AddScenarioCredentialDto } from "./dto/add-scenario-credential.dto";
|
import { AddScenarioCredentialDto } from "./dto/add-scenario-credential.dto";
|
||||||
import { CreateScenarioRunDto } from "./dto/create-scenario-run.dto";
|
import { CreateScenarioRunDto } from "./dto/create-scenario-run.dto";
|
||||||
@@ -270,4 +274,78 @@ export class ScenarioController {
|
|||||||
) {
|
) {
|
||||||
return this.scenarioService.waitForRun(id, runId);
|
return this.scenarioService.waitForRun(id, runId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Files ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Post(":id/files")
|
||||||
|
@UseInterceptors(FileInterceptor("file"))
|
||||||
|
@ApiOperation({ summary: "Upload a file to a scenario" })
|
||||||
|
@ApiResponse({ status: 201, description: "File uploaded" })
|
||||||
|
@ApiResponse({ status: 404, description: "Scenario not found" })
|
||||||
|
uploadFile(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@UploadedFile() file: MulterFile,
|
||||||
|
@Body() body: { expiresAt?: string },
|
||||||
|
) {
|
||||||
|
return this.scenarioService.uploadFile(id, file, body.expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":id/files")
|
||||||
|
@ApiOperation({ summary: "List files attached to a scenario" })
|
||||||
|
@ApiResponse({ status: 200 })
|
||||||
|
@ApiResponse({ status: 404, description: "Scenario not found" })
|
||||||
|
listScenarioFiles(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Query("limit") limit?: string,
|
||||||
|
@Query("offset") offset?: string,
|
||||||
|
) {
|
||||||
|
return this.scenarioService.listScenarioFiles(
|
||||||
|
id,
|
||||||
|
limit ? parseInt(limit, 10) : undefined,
|
||||||
|
offset ? parseInt(offset, 10) : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":id/files/:fileId/content")
|
||||||
|
@ApiOperation({ summary: "Download file content" })
|
||||||
|
@ApiResponse({ status: 200, description: "File content" })
|
||||||
|
@ApiResponse({ status: 404, description: "Scenario or file not found" })
|
||||||
|
async getScenarioFileContent(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Param("fileId", ParseUUIDPipe) fileId: string,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
return this.scenarioService.getScenarioFileContent(id, fileId, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":id/runs/:runId/files")
|
||||||
|
@ApiOperation({ summary: "List files created during a run" })
|
||||||
|
@ApiResponse({ status: 200 })
|
||||||
|
@ApiResponse({ status: 404, description: "Scenario or run not found" })
|
||||||
|
listRunFiles(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Param("runId", ParseUUIDPipe) runId: string,
|
||||||
|
@Query("limit") limit?: string,
|
||||||
|
@Query("offset") offset?: string,
|
||||||
|
) {
|
||||||
|
return this.scenarioService.listRunFiles(
|
||||||
|
id,
|
||||||
|
runId,
|
||||||
|
limit ? parseInt(limit, 10) : undefined,
|
||||||
|
offset ? parseInt(offset, 10) : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":id/runs/:runId/files/:fileId/content")
|
||||||
|
@ApiOperation({ summary: "Download run artifact file content" })
|
||||||
|
@ApiResponse({ status: 200, description: "File content" })
|
||||||
|
@ApiResponse({ status: 404, description: "Scenario, run, or file not found" })
|
||||||
|
async getRunFileContent(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Param("runId", ParseUUIDPipe) runId: string,
|
||||||
|
@Param("fileId", ParseUUIDPipe) fileId: string,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
return this.scenarioService.getRunFileContent(id, runId, fileId, res);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { CodeExecutorModule } from "../code-executor/code-executor.module";
|
|||||||
import { CredentialEntity } from "../credential/credential.entity";
|
import { CredentialEntity } from "../credential/credential.entity";
|
||||||
import { EnvironmentEntity } from "../environment/environment.entity";
|
import { EnvironmentEntity } from "../environment/environment.entity";
|
||||||
import { EnvironmentModule } from "../environment/environment.module";
|
import { EnvironmentModule } from "../environment/environment.module";
|
||||||
|
import { FileModule } from "../file/file.module";
|
||||||
|
import { ScenarioFileEntity } from "../file/scenario-file.entity";
|
||||||
|
import { ScenarioRunFileEntity } from "../file/scenario-run-file.entity";
|
||||||
import { SessionModule } from "../session/session.module";
|
import { SessionModule } from "../session/session.module";
|
||||||
import { SnippetModule } from "../snippet/snippet.module";
|
import { SnippetModule } from "../snippet/snippet.module";
|
||||||
import { ScenarioCredentialEntity } from "./scenario-credential.entity";
|
import { ScenarioCredentialEntity } from "./scenario-credential.entity";
|
||||||
@@ -27,11 +30,14 @@ import { ScenarioService } from "./scenario.service";
|
|||||||
ScenarioCredentialEntity,
|
ScenarioCredentialEntity,
|
||||||
CredentialEntity,
|
CredentialEntity,
|
||||||
EnvironmentEntity,
|
EnvironmentEntity,
|
||||||
|
ScenarioFileEntity,
|
||||||
|
ScenarioRunFileEntity,
|
||||||
]),
|
]),
|
||||||
CodeExecutorModule,
|
CodeExecutorModule,
|
||||||
SessionModule,
|
SessionModule,
|
||||||
EnvironmentModule,
|
EnvironmentModule,
|
||||||
SnippetModule,
|
SnippetModule,
|
||||||
|
FileModule,
|
||||||
],
|
],
|
||||||
controllers: [ScenarioController],
|
controllers: [ScenarioController],
|
||||||
providers: [ScenarioService, ScenarioSchedulerService],
|
providers: [ScenarioService, ScenarioSchedulerService],
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { InjectRepository } from "@nestjs/typeorm";
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import * as fs from "fs/promises";
|
||||||
|
import * as path from "path";
|
||||||
import { Like, Repository } from "typeorm";
|
import { Like, Repository } from "typeorm";
|
||||||
import {
|
import {
|
||||||
PaginatedResult,
|
PaginatedResult,
|
||||||
@@ -12,6 +14,9 @@ import {
|
|||||||
} from "../common/dto/pagination.dto";
|
} from "../common/dto/pagination.dto";
|
||||||
import { CredentialEntity } from "../credential/credential.entity";
|
import { CredentialEntity } from "../credential/credential.entity";
|
||||||
import { EnvironmentEntity } from "../environment/environment.entity";
|
import { EnvironmentEntity } from "../environment/environment.entity";
|
||||||
|
import { FileStorageService } from "../file/file-storage.service";
|
||||||
|
import { ScenarioFileEntity } from "../file/scenario-file.entity";
|
||||||
|
import { ScenarioRunFileEntity } from "../file/scenario-run-file.entity";
|
||||||
import { AddScenarioCredentialDto } from "./dto/add-scenario-credential.dto";
|
import { AddScenarioCredentialDto } from "./dto/add-scenario-credential.dto";
|
||||||
import { CreateScenarioStepDto } from "./dto/create-scenario-step.dto";
|
import { CreateScenarioStepDto } from "./dto/create-scenario-step.dto";
|
||||||
import { CreateScenarioDto } from "./dto/create-scenario.dto";
|
import { CreateScenarioDto } from "./dto/create-scenario.dto";
|
||||||
@@ -30,6 +35,8 @@ import { ScenarioRunStepEntity } from "./scenario-run-step.entity";
|
|||||||
import { ScenarioRunEntity } from "./scenario-run.entity";
|
import { ScenarioRunEntity } from "./scenario-run.entity";
|
||||||
import { ScenarioStepEntity } from "./scenario-step.entity";
|
import { ScenarioStepEntity } from "./scenario-step.entity";
|
||||||
import { ScenarioEntity } from "./scenario.entity";
|
import { ScenarioEntity } from "./scenario.entity";
|
||||||
|
import { Response } from "express";
|
||||||
|
import type { File as MulterFile } from "multer";
|
||||||
|
|
||||||
export { PaginatedResult } from "../common/dto/pagination.dto";
|
export { PaginatedResult } from "../common/dto/pagination.dto";
|
||||||
export type ScenarioOrderBy = "id" | "name" | "createdAt" | "updatedAt";
|
export type ScenarioOrderBy = "id" | "name" | "createdAt" | "updatedAt";
|
||||||
@@ -53,6 +60,11 @@ export class ScenarioService {
|
|||||||
private readonly credentialRepo: Repository<CredentialEntity>,
|
private readonly credentialRepo: Repository<CredentialEntity>,
|
||||||
@InjectRepository(EnvironmentEntity)
|
@InjectRepository(EnvironmentEntity)
|
||||||
private readonly environmentRepo: Repository<EnvironmentEntity>,
|
private readonly environmentRepo: Repository<EnvironmentEntity>,
|
||||||
|
@InjectRepository(ScenarioFileEntity)
|
||||||
|
private readonly scenarioFileRepo: Repository<ScenarioFileEntity>,
|
||||||
|
@InjectRepository(ScenarioRunFileEntity)
|
||||||
|
private readonly scenarioRunFileRepo: Repository<ScenarioRunFileEntity>,
|
||||||
|
private readonly fileStorageService: FileStorageService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ── Scenarios ─────────────────────────────────────────────────────────────
|
// ── Scenarios ─────────────────────────────────────────────────────────────
|
||||||
@@ -63,7 +75,7 @@ export class ScenarioService {
|
|||||||
|
|
||||||
async findAll(
|
async findAll(
|
||||||
query: PaginationQueryDto<ScenarioOrderBy>,
|
query: PaginationQueryDto<ScenarioOrderBy>,
|
||||||
): Promise<PaginatedResult<ScenarioEntity>> {
|
): Promise<PaginatedResult<ScenarioEntity & { lastRunStatus: string | null; lastRunAt: string | null }>> {
|
||||||
const page = query.page ?? 1;
|
const page = query.page ?? 1;
|
||||||
const limit = query.limit ?? 20;
|
const limit = query.limit ?? 20;
|
||||||
const orderBy = query.orderBy ?? "id";
|
const orderBy = query.orderBy ?? "id";
|
||||||
@@ -73,7 +85,39 @@ export class ScenarioService {
|
|||||||
skip: (page - 1) * limit,
|
skip: (page - 1) * limit,
|
||||||
take: limit,
|
take: limit,
|
||||||
});
|
});
|
||||||
return { data, total, page, limit };
|
|
||||||
|
let lastRunByScenario = new Map<string, { status: string; createdAt: Date }>();
|
||||||
|
if (data.length > 0) {
|
||||||
|
const ids = data.map((s) => s.id);
|
||||||
|
const latestRuns = await this.runRepo
|
||||||
|
.createQueryBuilder("r")
|
||||||
|
.select(["r.scenarioId", "r.status", "r.createdAt"])
|
||||||
|
.where("r.scenarioId IN (:...ids)", { ids })
|
||||||
|
.andWhere((qb) => {
|
||||||
|
const sub = qb
|
||||||
|
.subQuery()
|
||||||
|
.select("MAX(r2.createdAt)")
|
||||||
|
.from(ScenarioRunEntity, "r2")
|
||||||
|
.where("r2.scenarioId = r.scenarioId")
|
||||||
|
.getQuery();
|
||||||
|
return `r.createdAt = (${sub})`;
|
||||||
|
})
|
||||||
|
.getMany();
|
||||||
|
lastRunByScenario = new Map(
|
||||||
|
latestRuns.map((r) => [r.scenarioId, { status: r.status, createdAt: r.createdAt }]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: data.map((s) => ({
|
||||||
|
...s,
|
||||||
|
lastRunStatus: lastRunByScenario.get(s.id)?.status ?? null,
|
||||||
|
lastRunAt: lastRunByScenario.get(s.id)?.createdAt?.toISOString() ?? null,
|
||||||
|
})),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: string): Promise<ScenarioEntity> {
|
async findOne(id: string): Promise<ScenarioEntity> {
|
||||||
@@ -619,4 +663,262 @@ export class ScenarioService {
|
|||||||
}
|
}
|
||||||
return this.findOne(scenario.id);
|
return this.findOne(scenario.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Files ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async uploadFile(
|
||||||
|
scenarioId: string,
|
||||||
|
file: MulterFile,
|
||||||
|
expiresAtStr?: string,
|
||||||
|
): Promise<any> {
|
||||||
|
// Verify scenario exists
|
||||||
|
await this.findOne(scenarioId);
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
throw new BadRequestException("No file provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse optional expiresAt
|
||||||
|
let expiresAt: Date | undefined = undefined;
|
||||||
|
if (expiresAtStr) {
|
||||||
|
expiresAt = new Date(expiresAtStr);
|
||||||
|
if (isNaN(expiresAt.getTime())) {
|
||||||
|
throw new BadRequestException("Invalid expiresAt date");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save file to disk and database
|
||||||
|
const savedFile = await this.fileStorageService.saveFile(
|
||||||
|
file.buffer,
|
||||||
|
file.originalname,
|
||||||
|
file.mimetype,
|
||||||
|
expiresAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create scenario file mapping
|
||||||
|
const scenarioFile = this.scenarioFileRepo.create({
|
||||||
|
scenarioId,
|
||||||
|
fileId: savedFile.id,
|
||||||
|
});
|
||||||
|
await this.scenarioFileRepo.save(scenarioFile);
|
||||||
|
|
||||||
|
// Return file metadata
|
||||||
|
return {
|
||||||
|
id: savedFile.id,
|
||||||
|
name: savedFile.originalName,
|
||||||
|
mimeType: savedFile.mimeType,
|
||||||
|
size: savedFile.size,
|
||||||
|
expiresAt: savedFile.expiresAt,
|
||||||
|
createdAt: savedFile.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listScenarioFiles(
|
||||||
|
scenarioId: string,
|
||||||
|
limit?: number,
|
||||||
|
offset?: number,
|
||||||
|
): Promise<any> {
|
||||||
|
// Verify scenario exists
|
||||||
|
await this.findOne(scenarioId);
|
||||||
|
|
||||||
|
const result = await this.fileStorageService.listScenarioFiles(
|
||||||
|
scenarioId,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: result.items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
mimeType: item.mimeType,
|
||||||
|
size: item.size,
|
||||||
|
sha256: item.sha256,
|
||||||
|
expiresAt: item.expiresAt,
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
})),
|
||||||
|
total: result.total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getScenarioFileContent(
|
||||||
|
scenarioId: string,
|
||||||
|
fileId: string,
|
||||||
|
res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
// Verify scenario exists
|
||||||
|
await this.findOne(scenarioId);
|
||||||
|
|
||||||
|
// Check if file is linked to this scenario
|
||||||
|
const link = await this.scenarioFileRepo.findOne({
|
||||||
|
where: { scenarioId, fileId },
|
||||||
|
relations: ["file"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!link) {
|
||||||
|
throw new NotFoundException(`File ${fileId} not found in scenario ${scenarioId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = link.file;
|
||||||
|
const fullPath = path.resolve(this.fileStorageService.getAbsolutePath(file.filePath));
|
||||||
|
|
||||||
|
res.setHeader("Content-Type", file.mimeType);
|
||||||
|
res.setHeader("Content-Disposition", `inline; filename="${file.originalName}"`);
|
||||||
|
res.setHeader("Content-Length", file.size);
|
||||||
|
|
||||||
|
res.sendFile(fullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get scenario file content as a buffer (for MCP use).
|
||||||
|
* Returns metadata plus raw bytes without streaming to Response.
|
||||||
|
*/
|
||||||
|
async getScenarioFileContentAsBuffer(
|
||||||
|
scenarioId: string,
|
||||||
|
fileId: string,
|
||||||
|
): Promise<{ file: { id: string; name: string; mimeType: string; size: number; sha256: string; expiresAt: Date | null; createdAt: Date }; contentBuffer: Buffer }> {
|
||||||
|
// Verify scenario exists
|
||||||
|
await this.findOne(scenarioId);
|
||||||
|
|
||||||
|
// Check if file is linked to this scenario
|
||||||
|
const link = await this.scenarioFileRepo.findOne({
|
||||||
|
where: { scenarioId, fileId },
|
||||||
|
relations: ["file"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!link) {
|
||||||
|
throw new NotFoundException(`File ${fileId} not found in scenario ${scenarioId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = link.file;
|
||||||
|
const fullPath = path.resolve(this.fileStorageService.getAbsolutePath(file.filePath));
|
||||||
|
|
||||||
|
// Read file content as buffer
|
||||||
|
const contentBuffer = await fs.readFile(fullPath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
file: {
|
||||||
|
id: file.id,
|
||||||
|
name: file.originalName,
|
||||||
|
mimeType: file.mimeType,
|
||||||
|
size: file.size,
|
||||||
|
sha256: file.sha256,
|
||||||
|
expiresAt: file.expiresAt,
|
||||||
|
createdAt: file.createdAt,
|
||||||
|
},
|
||||||
|
contentBuffer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRunFiles(
|
||||||
|
scenarioId: string,
|
||||||
|
runId: string,
|
||||||
|
limit?: number,
|
||||||
|
offset?: number,
|
||||||
|
): Promise<any> {
|
||||||
|
// Verify scenario and run exist
|
||||||
|
await this.findOne(scenarioId);
|
||||||
|
const run = await this.runRepo.findOneBy({ id: runId, scenarioId });
|
||||||
|
if (!run) {
|
||||||
|
throw new NotFoundException(`Run ${runId} not found in scenario ${scenarioId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.fileStorageService.listRunFiles(
|
||||||
|
runId,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: result.items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
mimeType: item.mimeType,
|
||||||
|
size: item.size,
|
||||||
|
sha256: item.sha256,
|
||||||
|
expiresAt: item.expiresAt,
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
})),
|
||||||
|
total: result.total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRunFileContent(
|
||||||
|
scenarioId: string,
|
||||||
|
runId: string,
|
||||||
|
fileId: string,
|
||||||
|
res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
// Verify scenario and run exist
|
||||||
|
await this.findOne(scenarioId);
|
||||||
|
const run = await this.runRepo.findOneBy({ id: runId, scenarioId });
|
||||||
|
if (!run) {
|
||||||
|
throw new NotFoundException(`Run ${runId} not found in scenario ${scenarioId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file is linked to this run
|
||||||
|
const link = await this.scenarioRunFileRepo.findOne({
|
||||||
|
where: { runId, fileId },
|
||||||
|
relations: ["file"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!link) {
|
||||||
|
throw new NotFoundException(`File ${fileId} not found in run ${runId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = link.file;
|
||||||
|
const fullPath = path.resolve(this.fileStorageService.getAbsolutePath(file.filePath));
|
||||||
|
|
||||||
|
res.setHeader("Content-Type", file.mimeType);
|
||||||
|
res.setHeader("Content-Disposition", `inline; filename="${file.originalName}"`);
|
||||||
|
res.setHeader("Content-Length", file.size);
|
||||||
|
|
||||||
|
res.sendFile(fullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get run artifact file content as a buffer (for MCP use).
|
||||||
|
* Returns metadata plus raw bytes without streaming to Response.
|
||||||
|
*/
|
||||||
|
async getRunFileContentAsBuffer(
|
||||||
|
scenarioId: string,
|
||||||
|
runId: string,
|
||||||
|
fileId: string,
|
||||||
|
): Promise<{ file: { id: string; name: string; mimeType: string; size: number; sha256: string; expiresAt: Date | null; createdAt: Date }; contentBuffer: Buffer }> {
|
||||||
|
// Verify scenario and run exist
|
||||||
|
await this.findOne(scenarioId);
|
||||||
|
const run = await this.runRepo.findOneBy({ id: runId, scenarioId });
|
||||||
|
if (!run) {
|
||||||
|
throw new NotFoundException(`Run ${runId} not found in scenario ${scenarioId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file is linked to this run
|
||||||
|
const link = await this.scenarioRunFileRepo.findOne({
|
||||||
|
where: { runId, fileId },
|
||||||
|
relations: ["file"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!link) {
|
||||||
|
throw new NotFoundException(`File ${fileId} not found in run ${runId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = link.file;
|
||||||
|
const fullPath = path.resolve(this.fileStorageService.getAbsolutePath(file.filePath));
|
||||||
|
|
||||||
|
// Read file content as buffer
|
||||||
|
const contentBuffer = await fs.readFile(fullPath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
file: {
|
||||||
|
id: file.id,
|
||||||
|
name: file.originalName,
|
||||||
|
mimeType: file.mimeType,
|
||||||
|
size: file.size,
|
||||||
|
sha256: file.sha256,
|
||||||
|
expiresAt: file.expiresAt,
|
||||||
|
createdAt: file.createdAt,
|
||||||
|
},
|
||||||
|
contentBuffer,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ import { HealthController } from "../src/health/health.controller";
|
|||||||
import { HttpExceptionFilter } from "../src/filters/http-exception.filter";
|
import { HttpExceptionFilter } from "../src/filters/http-exception.filter";
|
||||||
import { LoggingInterceptor } from "../src/interceptors/logging.interceptor";
|
import { LoggingInterceptor } from "../src/interceptors/logging.interceptor";
|
||||||
import { validateAppConfig } from "../src/config/app.config";
|
import { validateAppConfig } from "../src/config/app.config";
|
||||||
|
import { FileEntity } from "../src/file/file.entity";
|
||||||
|
import { ScenarioFileEntity } from "../src/file/scenario-file.entity";
|
||||||
|
import { ScenarioRunFileEntity } from "../src/file/scenario-run-file.entity";
|
||||||
|
|
||||||
export async function buildTestApp(): Promise<INestApplication> {
|
export async function buildTestApp(): Promise<INestApplication> {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
@@ -71,6 +74,9 @@ export async function buildTestApp(): Promise<INestApplication> {
|
|||||||
ScenarioCredentialEntity,
|
ScenarioCredentialEntity,
|
||||||
CredentialEntity,
|
CredentialEntity,
|
||||||
SnippetEntity,
|
SnippetEntity,
|
||||||
|
FileEntity,
|
||||||
|
ScenarioFileEntity,
|
||||||
|
ScenarioRunFileEntity,
|
||||||
],
|
],
|
||||||
synchronize: true,
|
synchronize: true,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,419 @@
|
|||||||
|
import { INestApplication } from "@nestjs/common";
|
||||||
|
import { getRepositoryToken } from "@nestjs/typeorm";
|
||||||
|
import * as http from "http";
|
||||||
|
import * as fs from "fs/promises";
|
||||||
|
import { Repository } from "typeorm";
|
||||||
|
import { buildTestApp } from "./app.harness";
|
||||||
|
import { FileStorageService } from "../src/file/file-storage.service";
|
||||||
|
import { FileEntity } from "../src/file/file.entity";
|
||||||
|
import { ScenarioFileEntity } from "../src/file/scenario-file.entity";
|
||||||
|
import { ScenarioRunFileEntity } from "../src/file/scenario-run-file.entity";
|
||||||
|
import { ScenarioEntity } from "../src/scenario/scenario.entity";
|
||||||
|
import { ScenarioStepEntity } from "../src/scenario/scenario-step.entity";
|
||||||
|
import { ScenarioRunEntity } from "../src/scenario/scenario-run.entity";
|
||||||
|
import { ScenarioRunStepEntity } from "../src/scenario/scenario-run-step.entity";
|
||||||
|
import { ScenarioSchedulerService } from "../src/scenario/scenario-scheduler.service";
|
||||||
|
|
||||||
|
describe("Exec Context File Methods Integration Tests", () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let fileStorageService: FileStorageService;
|
||||||
|
let scheduler: ScenarioSchedulerService;
|
||||||
|
let fileRepo: Repository<FileEntity>;
|
||||||
|
let scenarioFileRepo: Repository<ScenarioFileEntity>;
|
||||||
|
let scenarioRunFileRepo: Repository<ScenarioRunFileEntity>;
|
||||||
|
let scenarioRepo: Repository<ScenarioEntity>;
|
||||||
|
let stepRepo: Repository<ScenarioStepEntity>;
|
||||||
|
let runRepo: Repository<ScenarioRunEntity>;
|
||||||
|
let runStepRepo: Repository<ScenarioRunStepEntity>;
|
||||||
|
let filesDir: string;
|
||||||
|
let testServer: http.Server;
|
||||||
|
let testServerUrl: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = await buildTestApp();
|
||||||
|
fileStorageService = app.get(FileStorageService);
|
||||||
|
scheduler = app.get(ScenarioSchedulerService);
|
||||||
|
fileRepo = app.get<Repository<FileEntity>>(getRepositoryToken(FileEntity));
|
||||||
|
scenarioFileRepo = app.get<Repository<ScenarioFileEntity>>(
|
||||||
|
getRepositoryToken(ScenarioFileEntity),
|
||||||
|
);
|
||||||
|
scenarioRunFileRepo = app.get<Repository<ScenarioRunFileEntity>>(
|
||||||
|
getRepositoryToken(ScenarioRunFileEntity),
|
||||||
|
);
|
||||||
|
scenarioRepo = app.get<Repository<ScenarioEntity>>(
|
||||||
|
getRepositoryToken(ScenarioEntity),
|
||||||
|
);
|
||||||
|
stepRepo = app.get<Repository<ScenarioStepEntity>>(
|
||||||
|
getRepositoryToken(ScenarioStepEntity),
|
||||||
|
);
|
||||||
|
runRepo = app.get<Repository<ScenarioRunEntity>>(
|
||||||
|
getRepositoryToken(ScenarioRunEntity),
|
||||||
|
);
|
||||||
|
runStepRepo = app.get<Repository<ScenarioRunStepEntity>>(
|
||||||
|
getRepositoryToken(ScenarioRunStepEntity),
|
||||||
|
);
|
||||||
|
|
||||||
|
filesDir = fileStorageService.getAbsolutePath("");
|
||||||
|
|
||||||
|
// Start a test HTTP server for downloadFile tests
|
||||||
|
testServer = await createTestServer();
|
||||||
|
testServerUrl = `http://localhost:${(testServer.address() as any).port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Close the test server
|
||||||
|
if (testServer) {
|
||||||
|
testServer.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up files directory
|
||||||
|
try {
|
||||||
|
await fs.rm(filesDir, { recursive: true, force: true });
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore errors if directory doesn't exist
|
||||||
|
}
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function createTestServer(): Promise<http.Server> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
if (req.url === "/test-file.bin") {
|
||||||
|
res.writeHead(200, { "Content-Type": "application/octet-stream" });
|
||||||
|
res.end(Buffer.from("test downloaded file content"));
|
||||||
|
} else if (req.url === "/test.pdf") {
|
||||||
|
res.writeHead(200, { "Content-Type": "application/pdf" });
|
||||||
|
res.end(Buffer.from("fake pdf content"));
|
||||||
|
} else {
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(0, "localhost", () => {
|
||||||
|
resolve(server);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createScenario(name = "test-scenario") {
|
||||||
|
const scenario = await scenarioRepo.save(
|
||||||
|
scenarioRepo.create({ name }),
|
||||||
|
);
|
||||||
|
return scenario;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createStep(
|
||||||
|
scenarioId: string,
|
||||||
|
execCode: string,
|
||||||
|
order = 0,
|
||||||
|
): Promise<ScenarioStepEntity> {
|
||||||
|
const step = await stepRepo.save(
|
||||||
|
stepRepo.create({
|
||||||
|
scenarioId,
|
||||||
|
order,
|
||||||
|
execCode,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRun(scenarioId: string) {
|
||||||
|
const run = await runRepo.save(
|
||||||
|
runRepo.create({ scenarioId, status: "pending" }),
|
||||||
|
);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRunStep(
|
||||||
|
runId: string,
|
||||||
|
scenarioStepId: string,
|
||||||
|
order = 0,
|
||||||
|
) {
|
||||||
|
const runStep = await runStepRepo.save(
|
||||||
|
runStepRepo.create({
|
||||||
|
runId,
|
||||||
|
scenarioStepId,
|
||||||
|
order,
|
||||||
|
status: "pending",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return runStep;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForRunCompletion(
|
||||||
|
runId: string,
|
||||||
|
maxAttempts = 50,
|
||||||
|
): Promise<ScenarioRunEntity> {
|
||||||
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
|
// Run scheduler pickup once
|
||||||
|
await scheduler.pickUpPendingRuns();
|
||||||
|
|
||||||
|
// Wait a bit for execution
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
const run = await runRepo.findOneBy({ id: runId });
|
||||||
|
if (run && (run.status === "pass" || run.status === "fail")) {
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Run ${runId} did not complete within timeout`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── context.getScenarioFiles() ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("context.getScenarioFiles()", () => {
|
||||||
|
it("returns scenario files during a run", async () => {
|
||||||
|
const scenario = await createScenario("get-scenario-files-test");
|
||||||
|
|
||||||
|
// Upload a file to the scenario
|
||||||
|
const fileBuffer = Buffer.from("scenario file content");
|
||||||
|
const uploadedFile = await fileStorageService.saveFile(
|
||||||
|
fileBuffer,
|
||||||
|
"test-file.txt",
|
||||||
|
"text/plain",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create scenario file mapping
|
||||||
|
await scenarioFileRepo.save(
|
||||||
|
scenarioFileRepo.create({
|
||||||
|
scenarioId: scenario.id,
|
||||||
|
fileId: uploadedFile.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a step that calls getScenarioFiles
|
||||||
|
const step = await createStep(
|
||||||
|
scenario.id,
|
||||||
|
"const files = await context.getScenarioFiles(); return files;",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a run and run step
|
||||||
|
const run = await createRun(scenario.id);
|
||||||
|
await createRunStep(run.id, step.id);
|
||||||
|
|
||||||
|
// Wait for execution
|
||||||
|
const completedRun = await waitForRunCompletion(run.id);
|
||||||
|
|
||||||
|
// Verify run completed successfully
|
||||||
|
expect(completedRun.status).toBe("pass");
|
||||||
|
|
||||||
|
// Get the step run to check output
|
||||||
|
const runStep = await runStepRepo.findOne({
|
||||||
|
where: { runId: run.id },
|
||||||
|
relations: ["scenarioStep"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runStep).toBeDefined();
|
||||||
|
expect(runStep!.output).toBeDefined();
|
||||||
|
|
||||||
|
const output = runStep!.output ? JSON.parse(runStep!.output) : null;
|
||||||
|
expect(output).toBeDefined();
|
||||||
|
expect(output.items).toBeDefined();
|
||||||
|
expect(Array.isArray(output.items)).toBe(true);
|
||||||
|
expect(output.total).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
// Find the uploaded file in the output
|
||||||
|
const foundFile = output.items.find((f: any) => f.id === uploadedFile.id);
|
||||||
|
expect(foundFile).toBeDefined();
|
||||||
|
expect(foundFile.name).toBe("test-file.txt");
|
||||||
|
expect(foundFile.mimeType).toBe("text/plain");
|
||||||
|
expect(foundFile.size).toBe(fileBuffer.length);
|
||||||
|
expect(foundFile.sha256).toBeDefined();
|
||||||
|
expect(foundFile.path).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty list when no files are attached to scenario", async () => {
|
||||||
|
const scenario = await createScenario("no-files-test");
|
||||||
|
|
||||||
|
// Create a step that calls getScenarioFiles
|
||||||
|
const step = await createStep(
|
||||||
|
scenario.id,
|
||||||
|
"const files = await context.getScenarioFiles(); return files;",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a run and run step
|
||||||
|
const run = await createRun(scenario.id);
|
||||||
|
await createRunStep(run.id, step.id);
|
||||||
|
|
||||||
|
// Wait for execution
|
||||||
|
const completedRun = await waitForRunCompletion(run.id);
|
||||||
|
|
||||||
|
expect(completedRun.status).toBe("pass");
|
||||||
|
|
||||||
|
const runStep = await runStepRepo.findOne({
|
||||||
|
where: { runId: run.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = runStep!.output ? JSON.parse(runStep!.output) : null;
|
||||||
|
expect(output).toBeDefined();
|
||||||
|
expect(output.items).toEqual([]);
|
||||||
|
expect(output.total).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── context.downloadFile() ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("context.downloadFile()", () => {
|
||||||
|
it("downloads a file and creates a run artifact", async () => {
|
||||||
|
const scenario = await createScenario("download-file-test");
|
||||||
|
const downloadUrl = `${testServerUrl}/test.pdf`;
|
||||||
|
|
||||||
|
// Create a step that downloads a file
|
||||||
|
const step = await createStep(
|
||||||
|
scenario.id,
|
||||||
|
`const result = await context.downloadFile('${downloadUrl}'); return result;`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a run and run step
|
||||||
|
const run = await createRun(scenario.id);
|
||||||
|
await createRunStep(run.id, step.id);
|
||||||
|
|
||||||
|
// Wait for execution
|
||||||
|
const completedRun = await waitForRunCompletion(run.id);
|
||||||
|
|
||||||
|
expect(completedRun.status).toBe("pass");
|
||||||
|
|
||||||
|
// Get the step run to check output
|
||||||
|
const runStep = await runStepRepo.findOne({
|
||||||
|
where: { runId: run.id },
|
||||||
|
relations: ["scenarioStep"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = runStep!.output ? JSON.parse(runStep!.output) : null;
|
||||||
|
expect(output).toBeDefined();
|
||||||
|
expect(output.id).toBeDefined();
|
||||||
|
expect(output.name).toBeDefined();
|
||||||
|
expect(output.mimeType).toBe("application/pdf");
|
||||||
|
expect(output.size).toBeGreaterThan(0);
|
||||||
|
expect(output.sha256).toBeDefined();
|
||||||
|
expect(output.path).toBeDefined();
|
||||||
|
|
||||||
|
// Verify the file was created as a run artifact
|
||||||
|
const runFileMapping = await scenarioRunFileRepo.findOne({
|
||||||
|
where: { runId: run.id, fileId: output.id },
|
||||||
|
});
|
||||||
|
expect(runFileMapping).toBeDefined();
|
||||||
|
|
||||||
|
// Verify the file exists on disk
|
||||||
|
const file = await fileRepo.findOneBy({ id: output.id });
|
||||||
|
expect(file).toBeDefined();
|
||||||
|
const fullPath = fileStorageService.getAbsolutePath(file!.filePath);
|
||||||
|
await fs.access(fullPath);
|
||||||
|
|
||||||
|
// Verify file content matches what was downloaded
|
||||||
|
const diskContent = await fs.readFile(fullPath);
|
||||||
|
expect(diskContent).toEqual(Buffer.from("fake pdf content"));
|
||||||
|
|
||||||
|
// Verify the file has an expiresAt date set
|
||||||
|
expect(file!.expiresAt).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("downloads file with custom filename", async () => {
|
||||||
|
const scenario = await createScenario("download-custom-filename-test");
|
||||||
|
const downloadUrl = `${testServerUrl}/test-file.bin`;
|
||||||
|
|
||||||
|
// Create a step that downloads a file with a custom filename
|
||||||
|
const step = await createStep(
|
||||||
|
scenario.id,
|
||||||
|
`const result = await context.downloadFile('${downloadUrl}', { filename: 'custom.dat' }); return result;`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a run and run step
|
||||||
|
const run = await createRun(scenario.id);
|
||||||
|
await createRunStep(run.id, step.id);
|
||||||
|
|
||||||
|
// Wait for execution
|
||||||
|
const completedRun = await waitForRunCompletion(run.id);
|
||||||
|
|
||||||
|
expect(completedRun.status).toBe("pass");
|
||||||
|
|
||||||
|
const runStep = await runStepRepo.findOne({
|
||||||
|
where: { runId: run.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = runStep!.output ? JSON.parse(runStep!.output) : null;
|
||||||
|
expect(output.name).toBe("custom.dat");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates ScenarioRunFileEntity mapping after download", async () => {
|
||||||
|
const scenario = await createScenario("download-mapping-test");
|
||||||
|
const downloadUrl = `${testServerUrl}/test.pdf`;
|
||||||
|
|
||||||
|
// Create a step
|
||||||
|
const step = await createStep(
|
||||||
|
scenario.id,
|
||||||
|
`const result = await context.downloadFile('${downloadUrl}'); return result;`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a run and run step
|
||||||
|
const run = await createRun(scenario.id);
|
||||||
|
await createRunStep(run.id, step.id);
|
||||||
|
|
||||||
|
// Wait for execution
|
||||||
|
await waitForRunCompletion(run.id);
|
||||||
|
|
||||||
|
// Verify that a ScenarioRunFileEntity was created
|
||||||
|
const runFileCount = await scenarioRunFileRepo.count({
|
||||||
|
where: { runId: run.id },
|
||||||
|
});
|
||||||
|
expect(runFileCount).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
// Verify the mapping links to the correct run
|
||||||
|
const mappings = await scenarioRunFileRepo.find({
|
||||||
|
where: { runId: run.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mappings.length).toBeGreaterThanOrEqual(1);
|
||||||
|
for (const mapping of mappings) {
|
||||||
|
expect(mapping.runId).toBe(run.id);
|
||||||
|
expect(mapping.fileId).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("file returned by downloadFile has correct metadata", async () => {
|
||||||
|
const scenario = await createScenario("download-metadata-test");
|
||||||
|
const downloadUrl = `${testServerUrl}/test.pdf`;
|
||||||
|
|
||||||
|
// Create a step
|
||||||
|
const step = await createStep(
|
||||||
|
scenario.id,
|
||||||
|
`const result = await context.downloadFile('${downloadUrl}');
|
||||||
|
return {
|
||||||
|
hasId: result.id !== undefined,
|
||||||
|
hasName: result.name !== undefined,
|
||||||
|
hasMimeType: result.mimeType !== undefined,
|
||||||
|
hasSize: result.size > 0,
|
||||||
|
hasSha256: result.sha256 !== undefined,
|
||||||
|
hasPath: result.path !== undefined
|
||||||
|
};`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a run and run step
|
||||||
|
const run = await createRun(scenario.id);
|
||||||
|
await createRunStep(run.id, step.id);
|
||||||
|
|
||||||
|
// Wait for execution
|
||||||
|
const completedRun = await waitForRunCompletion(run.id);
|
||||||
|
|
||||||
|
expect(completedRun.status).toBe("pass");
|
||||||
|
|
||||||
|
const runStep = await runStepRepo.findOne({
|
||||||
|
where: { runId: run.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = runStep!.output ? JSON.parse(runStep!.output) : null;
|
||||||
|
expect(output).toBeDefined();
|
||||||
|
expect(output.hasId).toBe(true);
|
||||||
|
expect(output.hasName).toBe(true);
|
||||||
|
expect(output.hasMimeType).toBe(true);
|
||||||
|
expect(output.hasSize).toBe(true);
|
||||||
|
expect(output.hasSha256).toBe(true);
|
||||||
|
expect(output.hasPath).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
import { INestApplication } from "@nestjs/common";
|
||||||
|
import { getRepositoryToken } from "@nestjs/typeorm";
|
||||||
|
import request from "supertest";
|
||||||
|
import * as fs from "fs/promises";
|
||||||
|
import { Repository } from "typeorm";
|
||||||
|
import { buildTestApp } from "./app.harness";
|
||||||
|
import { FileStorageService } from "../src/file/file-storage.service";
|
||||||
|
import { FileEntity } from "../src/file/file.entity";
|
||||||
|
import { ScenarioEntity } from "../src/scenario/scenario.entity";
|
||||||
|
import { ScenarioRunEntity } from "../src/scenario/scenario-run.entity";
|
||||||
|
|
||||||
|
describe("File Controller Integration Tests", () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let fileStorageService: FileStorageService;
|
||||||
|
let fileRepo: Repository<FileEntity>;
|
||||||
|
let scenarioRepo: Repository<ScenarioEntity>;
|
||||||
|
let runRepo: Repository<ScenarioRunEntity>;
|
||||||
|
let filesDir: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = await buildTestApp();
|
||||||
|
fileStorageService = app.get(FileStorageService);
|
||||||
|
fileRepo = app.get<Repository<FileEntity>>(getRepositoryToken(FileEntity));
|
||||||
|
scenarioRepo = app.get<Repository<ScenarioEntity>>(
|
||||||
|
getRepositoryToken(ScenarioEntity),
|
||||||
|
);
|
||||||
|
runRepo = app.get<Repository<ScenarioRunEntity>>(
|
||||||
|
getRepositoryToken(ScenarioRunEntity),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get the files directory from the service
|
||||||
|
filesDir = fileStorageService.getAbsolutePath("");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Clean up files directory
|
||||||
|
try {
|
||||||
|
await fs.rm(filesDir, { recursive: true, force: true });
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore errors if directory doesn't exist
|
||||||
|
}
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function createScenario(name = "test-scenario") {
|
||||||
|
const scenario = await scenarioRepo.save(
|
||||||
|
scenarioRepo.create({ name }),
|
||||||
|
);
|
||||||
|
return scenario;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRun(scenarioId: string) {
|
||||||
|
const run = await runRepo.save(
|
||||||
|
runRepo.create({ scenarioId, status: "pending" }),
|
||||||
|
);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /scenarios/:id/files ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("POST /scenarios/:id/files", () => {
|
||||||
|
it("uploads a file and returns 201 with metadata", async () => {
|
||||||
|
const scenario = await createScenario("upload-test");
|
||||||
|
const fileBuffer = Buffer.from("test file content");
|
||||||
|
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.post(`/scenarios/${scenario.id}/files`)
|
||||||
|
.attach("file", fileBuffer, "test.txt")
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(res.body.id).toBeDefined();
|
||||||
|
expect(res.body.name).toBe("test.txt");
|
||||||
|
expect(res.body.mimeType).toBe("text/plain");
|
||||||
|
expect(res.body.size).toBe(fileBuffer.length);
|
||||||
|
expect(res.body.createdAt).toBeDefined();
|
||||||
|
|
||||||
|
// Verify file exists on disk
|
||||||
|
const savedFile = await fileRepo.findOneBy({ id: res.body.id });
|
||||||
|
expect(savedFile).toBeDefined();
|
||||||
|
const fullPath = fileStorageService.getAbsolutePath(savedFile!.filePath);
|
||||||
|
await fs.access(fullPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uploads a file with expiresAt and includes it in response", async () => {
|
||||||
|
const scenario = await createScenario("upload-expires-test");
|
||||||
|
const fileBuffer = Buffer.from("expires file");
|
||||||
|
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.post(`/scenarios/${scenario.id}/files`)
|
||||||
|
.attach("file", fileBuffer, "expires.txt")
|
||||||
|
.field("expiresAt", expiresAt)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(res.body.expiresAt).toBeDefined();
|
||||||
|
// Verify expiresAt is close to the one we sent
|
||||||
|
const returnedExpires = new Date(res.body.expiresAt);
|
||||||
|
const sentExpires = new Date(expiresAt);
|
||||||
|
expect(Math.abs(returnedExpires.getTime() - sentExpires.getTime())).toBeLessThan(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when scenario does not exist", async () => {
|
||||||
|
const nonExistentId = "00000000-0000-0000-0000-000000000000";
|
||||||
|
const fileBuffer = Buffer.from("test");
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/scenarios/${nonExistentId}/files`)
|
||||||
|
.attach("file", fileBuffer, "test.txt")
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when no file is provided", async () => {
|
||||||
|
const scenario = await createScenario("no-file-test");
|
||||||
|
|
||||||
|
// Send a request with no file
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.post(`/scenarios/${scenario.id}/files`)
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
// Should either be 400 or 422 depending on validation
|
||||||
|
expect([400, 422]).toContain(res.status);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /scenarios/:id/files ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("GET /scenarios/:id/files", () => {
|
||||||
|
it("lists files attached to a scenario", async () => {
|
||||||
|
const scenario = await createScenario("list-test");
|
||||||
|
const fileBuffer = Buffer.from("list test file");
|
||||||
|
|
||||||
|
// Upload a file
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/scenarios/${scenario.id}/files`)
|
||||||
|
.attach("file", fileBuffer, "listed.txt")
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.get(`/scenarios/${scenario.id}/files`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(res.body.items).toBeDefined();
|
||||||
|
expect(Array.isArray(res.body.items)).toBe(true);
|
||||||
|
expect(res.body.items.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(res.body.total).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
const file = res.body.items.find((f: any) => f.name === "listed.txt");
|
||||||
|
expect(file).toBeDefined();
|
||||||
|
expect(file.id).toBeDefined();
|
||||||
|
expect(file.mimeType).toBe("text/plain");
|
||||||
|
expect(file.size).toBe(fileBuffer.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects limit and offset pagination", async () => {
|
||||||
|
const scenario = await createScenario("pagination-test");
|
||||||
|
|
||||||
|
// Upload 3 files
|
||||||
|
for (let i = 1; i <= 3; i++) {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/scenarios/${scenario.id}/files`)
|
||||||
|
.attach("file", Buffer.from(`file ${i}`), `file${i}.txt`)
|
||||||
|
.expect(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
// List with limit=2, offset=0
|
||||||
|
const page1 = await request(app.getHttpServer())
|
||||||
|
.get(`/scenarios/${scenario.id}/files`)
|
||||||
|
.query({ limit: 2, offset: 0 })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(page1.body.items).toHaveLength(2);
|
||||||
|
expect(page1.body.total).toBe(3);
|
||||||
|
|
||||||
|
// List with limit=2, offset=2
|
||||||
|
const page2 = await request(app.getHttpServer())
|
||||||
|
.get(`/scenarios/${scenario.id}/files`)
|
||||||
|
.query({ limit: 2, offset: 2 })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(page2.body.items.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when scenario does not exist", async () => {
|
||||||
|
const nonExistentId = "00000000-0000-0000-0000-000000000000";
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get(`/scenarios/${nonExistentId}/files`)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /scenarios/:id/files/:fileId/content ───────────────────────────────
|
||||||
|
|
||||||
|
describe("GET /scenarios/:id/files/:fileId/content", () => {
|
||||||
|
it("downloads file content with correct bytes and Content-Type header", async () => {
|
||||||
|
const scenario = await createScenario("download-test");
|
||||||
|
const fileBuffer = Buffer.from("download test content");
|
||||||
|
|
||||||
|
// Upload the file
|
||||||
|
const uploadRes = await request(app.getHttpServer())
|
||||||
|
.post(`/scenarios/${scenario.id}/files`)
|
||||||
|
.attach("file", fileBuffer, "download.txt")
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const fileId = uploadRes.body.id;
|
||||||
|
|
||||||
|
// Download content
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.get(`/scenarios/${scenario.id}/files/${fileId}/content`)
|
||||||
|
.buffer(true)
|
||||||
|
.parse((res, callback) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
res.on("end", () => callback(null, Buffer.concat(chunks)));
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
// The response body should contain the file bytes
|
||||||
|
expect((res.body as Buffer).toString()).toBe(fileBuffer.toString());
|
||||||
|
expect(res.headers["content-type"]).toContain("text/plain");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when file does not exist", async () => {
|
||||||
|
const scenario = await createScenario("not-found-test");
|
||||||
|
const nonExistentFileId = "00000000-0000-0000-0000-000000000000";
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get(`/scenarios/${scenario.id}/files/${nonExistentFileId}/content`)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when file is not linked to scenario", async () => {
|
||||||
|
const scenario1 = await createScenario("scenario-1");
|
||||||
|
const scenario2 = await createScenario("scenario-2");
|
||||||
|
|
||||||
|
// Upload file to scenario1
|
||||||
|
const uploadRes = await request(app.getHttpServer())
|
||||||
|
.post(`/scenarios/${scenario1.id}/files`)
|
||||||
|
.attach("file", Buffer.from("content"), "file.txt")
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const fileId = uploadRes.body.id;
|
||||||
|
|
||||||
|
// Try to access from scenario2
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get(`/scenarios/${scenario2.id}/files/${fileId}/content`)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /scenarios/:id/runs/:runId/files ───────────────────────────────────
|
||||||
|
|
||||||
|
describe("GET /scenarios/:id/runs/:runId/files", () => {
|
||||||
|
it("lists files created during a run", async () => {
|
||||||
|
const scenario = await createScenario("run-files-list-test");
|
||||||
|
const run = await createRun(scenario.id);
|
||||||
|
|
||||||
|
// Create a run artifact file
|
||||||
|
const fileBuffer = Buffer.from("run artifact content");
|
||||||
|
const savedFile = await fileStorageService.createAndSaveRunArtifact(
|
||||||
|
run.id,
|
||||||
|
fileBuffer,
|
||||||
|
"artifact.txt",
|
||||||
|
"text/plain",
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.get(`/scenarios/${scenario.id}/runs/${run.id}/files`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(res.body.items).toBeDefined();
|
||||||
|
expect(Array.isArray(res.body.items)).toBe(true);
|
||||||
|
expect(res.body.total).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
const artifact = res.body.items.find((f: any) => f.id === savedFile.id);
|
||||||
|
expect(artifact).toBeDefined();
|
||||||
|
expect(artifact.name).toBe("artifact.txt");
|
||||||
|
expect(artifact.size).toBe(fileBuffer.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when run does not exist", async () => {
|
||||||
|
const scenario = await createScenario("run-not-found-test");
|
||||||
|
const nonExistentRunId = "00000000-0000-0000-0000-000000000000";
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get(`/scenarios/${scenario.id}/runs/${nonExistentRunId}/files`)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /scenarios/:id/runs/:runId/files/:fileId/content ──────────────────
|
||||||
|
|
||||||
|
describe("GET /scenarios/:id/runs/:runId/files/:fileId/content", () => {
|
||||||
|
it("downloads run artifact content with correct bytes", async () => {
|
||||||
|
const scenario = await createScenario("run-artifact-download-test");
|
||||||
|
const run = await createRun(scenario.id);
|
||||||
|
|
||||||
|
// Create a run artifact
|
||||||
|
const fileBuffer = Buffer.from("run artifact bytes");
|
||||||
|
const savedFile = await fileStorageService.createAndSaveRunArtifact(
|
||||||
|
run.id,
|
||||||
|
fileBuffer,
|
||||||
|
"run-artifact.bin",
|
||||||
|
"application/octet-stream",
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.get(
|
||||||
|
`/scenarios/${scenario.id}/runs/${run.id}/files/${savedFile.id}/content`,
|
||||||
|
)
|
||||||
|
.buffer(true)
|
||||||
|
.parse((res, callback) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
res.on("end", () => callback(null, Buffer.concat(chunks)));
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect((res.body as Buffer).toString()).toBe(fileBuffer.toString());
|
||||||
|
expect(res.headers["content-type"]).toContain("application/octet-stream");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when artifact file does not exist", async () => {
|
||||||
|
const scenario = await createScenario("artifact-not-found");
|
||||||
|
const run = await createRun(scenario.id);
|
||||||
|
const nonExistentFileId = "00000000-0000-0000-0000-000000000000";
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get(
|
||||||
|
`/scenarios/${scenario.id}/runs/${run.id}/files/${nonExistentFileId}/content`,
|
||||||
|
)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── File cleanup ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("File cleanup", () => {
|
||||||
|
it("removes expired files from disk and database", async () => {
|
||||||
|
const scenario = await createScenario("cleanup-test");
|
||||||
|
|
||||||
|
// Upload a file with a past expiration date
|
||||||
|
const fileBuffer = Buffer.from("to be cleaned up");
|
||||||
|
const savedFile = await fileStorageService.saveFile(
|
||||||
|
fileBuffer,
|
||||||
|
"cleanup.txt",
|
||||||
|
"text/plain",
|
||||||
|
new Date(Date.now() - 60 * 60 * 1000), // 1 hour ago
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify file exists
|
||||||
|
const fullPath = fileStorageService.getAbsolutePath(savedFile.filePath);
|
||||||
|
await fs.access(fullPath);
|
||||||
|
|
||||||
|
// Run cleanup
|
||||||
|
await fileStorageService.cleanupExpiredFiles();
|
||||||
|
|
||||||
|
// Verify file is deleted from database
|
||||||
|
const deleted = await fileRepo.findOneBy({ id: savedFile.id });
|
||||||
|
expect(deleted).toBeNull();
|
||||||
|
|
||||||
|
// Verify file is deleted from disk
|
||||||
|
try {
|
||||||
|
await fs.access(fullPath);
|
||||||
|
fail("File should have been deleted");
|
||||||
|
} catch (err) {
|
||||||
|
// Expected: file not found
|
||||||
|
expect((err as any).code).toBe("ENOENT");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not delete files with future expiration dates", async () => {
|
||||||
|
const scenario = await createScenario("no-cleanup-test");
|
||||||
|
|
||||||
|
// Upload a file with a future expiration date
|
||||||
|
const fileBuffer = Buffer.from("should not be cleaned");
|
||||||
|
const futureDate = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||||
|
const savedFile = await fileStorageService.saveFile(
|
||||||
|
fileBuffer,
|
||||||
|
"keep.txt",
|
||||||
|
"text/plain",
|
||||||
|
futureDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Run cleanup
|
||||||
|
await fileStorageService.cleanupExpiredFiles();
|
||||||
|
|
||||||
|
// Verify file still exists in database
|
||||||
|
const kept = await fileRepo.findOneBy({ id: savedFile.id });
|
||||||
|
expect(kept).toBeDefined();
|
||||||
|
|
||||||
|
// Verify file still exists on disk
|
||||||
|
const fullPath = fileStorageService.getAbsolutePath(savedFile.filePath);
|
||||||
|
await fs.access(fullPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not delete files without expiration dates", async () => {
|
||||||
|
const scenario = await createScenario("no-expire-test");
|
||||||
|
|
||||||
|
// Upload a file without expiration
|
||||||
|
const fileBuffer = Buffer.from("permanent file");
|
||||||
|
const savedFile = await fileStorageService.saveFile(
|
||||||
|
fileBuffer,
|
||||||
|
"permanent.txt",
|
||||||
|
"text/plain",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Run cleanup
|
||||||
|
await fileStorageService.cleanupExpiredFiles();
|
||||||
|
|
||||||
|
// Verify file still exists
|
||||||
|
const kept = await fileRepo.findOneBy({ id: savedFile.id });
|
||||||
|
expect(kept).toBeDefined();
|
||||||
|
|
||||||
|
const fullPath = fileStorageService.getAbsolutePath(savedFile.filePath);
|
||||||
|
await fs.access(fullPath);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
import { INestApplication } from "@nestjs/common";
|
import { INestApplication } from "@nestjs/common";
|
||||||
|
import { getRepositoryToken } from "@nestjs/typeorm";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
|
import * as http from "http";
|
||||||
|
import { Repository } from "typeorm";
|
||||||
import { buildTestApp } from "./app.harness";
|
import { buildTestApp } from "./app.harness";
|
||||||
|
import { ScenarioEntity } from "../src/scenario/scenario.entity";
|
||||||
|
import { ScenarioStepEntity } from "../src/scenario/scenario-step.entity";
|
||||||
|
import { ScenarioRunEntity } from "../src/scenario/scenario-run.entity";
|
||||||
|
import { ScenarioRunStepEntity } from "../src/scenario/scenario-run-step.entity";
|
||||||
|
import { ScenarioSchedulerService } from "../src/scenario/scenario-scheduler.service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MCP controller integration tests.
|
* MCP controller integration tests.
|
||||||
@@ -16,15 +24,65 @@ import { buildTestApp } from "./app.harness";
|
|||||||
*/
|
*/
|
||||||
describe("McpController", () => {
|
describe("McpController", () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
|
let testServer: http.Server;
|
||||||
|
let testServerUrl: string;
|
||||||
|
let scheduler: ScenarioSchedulerService;
|
||||||
|
let scenarioRepo: Repository<ScenarioEntity>;
|
||||||
|
let stepRepo: Repository<ScenarioStepEntity>;
|
||||||
|
let runRepo: Repository<ScenarioRunEntity>;
|
||||||
|
let runStepRepo: Repository<ScenarioRunStepEntity>;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = await buildTestApp();
|
app = await buildTestApp();
|
||||||
|
scheduler = app.get(ScenarioSchedulerService);
|
||||||
|
scenarioRepo = app.get<Repository<ScenarioEntity>>(
|
||||||
|
getRepositoryToken(ScenarioEntity),
|
||||||
|
);
|
||||||
|
stepRepo = app.get<Repository<ScenarioStepEntity>>(
|
||||||
|
getRepositoryToken(ScenarioStepEntity),
|
||||||
|
);
|
||||||
|
runRepo = app.get<Repository<ScenarioRunEntity>>(
|
||||||
|
getRepositoryToken(ScenarioRunEntity),
|
||||||
|
);
|
||||||
|
runStepRepo = app.get<Repository<ScenarioRunStepEntity>>(
|
||||||
|
getRepositoryToken(ScenarioRunStepEntity),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start a test HTTP server for downloadFile tests
|
||||||
|
testServer = await createTestServer();
|
||||||
|
testServerUrl = `http://localhost:${(testServer.address() as any).port}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
if (testServer) {
|
||||||
|
testServer.close();
|
||||||
|
}
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function createTestServer(): Promise<http.Server> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
if (req.url === "/test-file.bin") {
|
||||||
|
res.writeHead(200, { "Content-Type": "application/octet-stream" });
|
||||||
|
res.end(Buffer.from("test downloaded file content"));
|
||||||
|
} else if (req.url === "/test.pdf") {
|
||||||
|
res.writeHead(200, { "Content-Type": "application/pdf" });
|
||||||
|
res.end(Buffer.from("fake pdf content"));
|
||||||
|
} else {
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(0, "localhost", () => {
|
||||||
|
resolve(server);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Parse the JSON-RPC payload from an SSE response body. */
|
/** Parse the JSON-RPC payload from an SSE response body. */
|
||||||
function parseSse(text: string): Record<string, unknown> {
|
function parseSse(text: string): Record<string, unknown> {
|
||||||
const match = text.match(/^data:\s*(.+)$/m);
|
const match = text.match(/^data:\s*(.+)$/m);
|
||||||
@@ -47,6 +105,49 @@ describe("McpController", () => {
|
|||||||
return { status: res.status, rpc: parseSse(res.text) };
|
return { status: res.status, rpc: parseSse(res.text) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createScenario(name: string) {
|
||||||
|
return scenarioRepo.save(scenarioRepo.create({ name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createStep(scenarioId: string, execCode: string, order = 0) {
|
||||||
|
return stepRepo.save(
|
||||||
|
stepRepo.create({
|
||||||
|
scenarioId,
|
||||||
|
order,
|
||||||
|
execCode,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRun(scenarioId: string) {
|
||||||
|
return runRepo.save(runRepo.create({ scenarioId, status: "pending" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRunStep(runId: string, scenarioStepId: string, order = 0) {
|
||||||
|
return runStepRepo.save(
|
||||||
|
runStepRepo.create({
|
||||||
|
runId,
|
||||||
|
scenarioStepId,
|
||||||
|
order,
|
||||||
|
status: "pending",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForRunCompletion(runId: string, maxAttempts = 50) {
|
||||||
|
for (let index = 0; index < maxAttempts; index += 1) {
|
||||||
|
await scheduler.pickUpPendingRuns();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
const run = await runRepo.findOneBy({ id: runId });
|
||||||
|
if (run && (run.status === "pass" || run.status === "fail")) {
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Run ${runId} did not complete within timeout`);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Connectivity ───────────────────────────────────────────────────────────
|
// ── Connectivity ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("POST /mcp — connectivity", () => {
|
describe("POST /mcp — connectivity", () => {
|
||||||
@@ -284,4 +385,462 @@ describe("McpController", () => {
|
|||||||
expect(cleared.timeoutSeconds).toBeNull();
|
expect(cleared.timeoutSeconds).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Scenario File Tools ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("upload_scenario_file", () => {
|
||||||
|
it("uploads a small text file via MCP and returns metadata", async () => {
|
||||||
|
const scRpc = (
|
||||||
|
await mcpCall("create_scenario", { name: "mcp-file-upload-test" })
|
||||||
|
).rpc;
|
||||||
|
const sc = JSON.parse(
|
||||||
|
(scRpc.result as { content: { text: string }[] }).content[0].text,
|
||||||
|
) as { id: string };
|
||||||
|
|
||||||
|
const content = "Hello, MCP!";
|
||||||
|
const contentBase64 = Buffer.from(content).toString("base64");
|
||||||
|
|
||||||
|
const { status, rpc } = await mcpCall("upload_scenario_file", {
|
||||||
|
scenarioId: sc.id,
|
||||||
|
name: "test.txt",
|
||||||
|
contentBase64,
|
||||||
|
mimeType: "text/plain",
|
||||||
|
});
|
||||||
|
expect(status).toBe(200);
|
||||||
|
const result = rpc.result as { content: { text: string }[] };
|
||||||
|
const uploaded = JSON.parse(result.content[0].text) as {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
expect(uploaded.name).toBe("test.txt");
|
||||||
|
expect(uploaded.mimeType).toBe("text/plain");
|
||||||
|
expect(uploaded.size).toBe(content.length);
|
||||||
|
expect(typeof uploaded.id).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns MCP error for invalid base64", async () => {
|
||||||
|
const scRpc = (
|
||||||
|
await mcpCall("create_scenario", {
|
||||||
|
name: "mcp-file-bad-base64-test",
|
||||||
|
})
|
||||||
|
).rpc;
|
||||||
|
const sc = JSON.parse(
|
||||||
|
(scRpc.result as { content: { text: string }[] }).content[0].text,
|
||||||
|
) as { id: string };
|
||||||
|
|
||||||
|
const { status, rpc } = await mcpCall("upload_scenario_file", {
|
||||||
|
scenarioId: sc.id,
|
||||||
|
name: "test.txt",
|
||||||
|
contentBase64: "!@#$%^&*()",
|
||||||
|
});
|
||||||
|
expect(status).toBe(200);
|
||||||
|
const result = rpc.result as { isError: boolean };
|
||||||
|
expect(result.isError).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("list_scenario_files", () => {
|
||||||
|
it("returns uploaded files with metadata", async () => {
|
||||||
|
const scRpc = (
|
||||||
|
await mcpCall("create_scenario", { name: "mcp-file-list-test" })
|
||||||
|
).rpc;
|
||||||
|
const sc = JSON.parse(
|
||||||
|
(scRpc.result as { content: { text: string }[] }).content[0].text,
|
||||||
|
) as { id: string };
|
||||||
|
|
||||||
|
// Upload a file
|
||||||
|
const content = "Test content";
|
||||||
|
const contentBase64 = Buffer.from(content).toString("base64");
|
||||||
|
await mcpCall("upload_scenario_file", {
|
||||||
|
scenarioId: sc.id,
|
||||||
|
name: "test.txt",
|
||||||
|
contentBase64,
|
||||||
|
});
|
||||||
|
|
||||||
|
// List files
|
||||||
|
const { status, rpc } = await mcpCall("list_scenario_files", {
|
||||||
|
scenarioId: sc.id,
|
||||||
|
});
|
||||||
|
expect(status).toBe(200);
|
||||||
|
const result = rpc.result as { content: { text: string }[] };
|
||||||
|
const list = JSON.parse(result.content[0].text) as {
|
||||||
|
items: Array<{ id: string; name: string }>;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
expect(list.items.length).toBeGreaterThan(0);
|
||||||
|
expect(list.items[0].name).toBe("test.txt");
|
||||||
|
expect(typeof list.total).toBe("number");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns MCP error for non-existent scenario", async () => {
|
||||||
|
const { status, rpc } = await mcpCall("list_scenario_files", {
|
||||||
|
scenarioId: "00000000-0000-0000-0000-000000000000",
|
||||||
|
});
|
||||||
|
expect(status).toBe(200);
|
||||||
|
const result = rpc.result as { isError: boolean };
|
||||||
|
expect(result.isError).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("get_scenario_file_content", () => {
|
||||||
|
it("retrieves file content as base64 with metadata", async () => {
|
||||||
|
const scRpc = (
|
||||||
|
await mcpCall("create_scenario", { name: "mcp-file-content-test" })
|
||||||
|
).rpc;
|
||||||
|
const sc = JSON.parse(
|
||||||
|
(scRpc.result as { content: { text: string }[] }).content[0].text,
|
||||||
|
) as { id: string };
|
||||||
|
|
||||||
|
// Upload a file
|
||||||
|
const originalContent = "Hello, this is test content!";
|
||||||
|
const contentBase64 = Buffer.from(originalContent).toString("base64");
|
||||||
|
const uploadRpc = (
|
||||||
|
await mcpCall("upload_scenario_file", {
|
||||||
|
scenarioId: sc.id,
|
||||||
|
name: "content-test.txt",
|
||||||
|
contentBase64,
|
||||||
|
mimeType: "text/plain",
|
||||||
|
})
|
||||||
|
).rpc;
|
||||||
|
const uploaded = JSON.parse(
|
||||||
|
(uploadRpc.result as { content: { text: string }[] }).content[0].text,
|
||||||
|
) as { id: string };
|
||||||
|
|
||||||
|
// Retrieve content
|
||||||
|
const { status, rpc } = await mcpCall("get_scenario_file_content", {
|
||||||
|
scenarioId: sc.id,
|
||||||
|
fileId: uploaded.id,
|
||||||
|
});
|
||||||
|
expect(status).toBe(200);
|
||||||
|
const result = rpc.result as { content: { text: string }[] };
|
||||||
|
const response = JSON.parse(result.content[0].text) as {
|
||||||
|
file: { id: string; name: string; size: number };
|
||||||
|
contentBase64: string;
|
||||||
|
encoding: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verify roundtrip
|
||||||
|
const retrievedContent = Buffer.from(response.contentBase64, "base64").toString(
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
expect(retrievedContent).toBe(originalContent);
|
||||||
|
expect(response.file.name).toBe("content-test.txt");
|
||||||
|
expect(response.file.size).toBe(originalContent.length);
|
||||||
|
expect(response.encoding).toBe("base64");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns MCP error for unknown file ID", async () => {
|
||||||
|
const scRpc = (
|
||||||
|
await mcpCall("create_scenario", {
|
||||||
|
name: "mcp-file-unknown-id-test",
|
||||||
|
})
|
||||||
|
).rpc;
|
||||||
|
const sc = JSON.parse(
|
||||||
|
(scRpc.result as { content: { text: string }[] }).content[0].text,
|
||||||
|
) as { id: string };
|
||||||
|
|
||||||
|
const { status, rpc } = await mcpCall("get_scenario_file_content", {
|
||||||
|
scenarioId: sc.id,
|
||||||
|
fileId: "00000000-0000-0000-0000-000000000000",
|
||||||
|
});
|
||||||
|
expect(status).toBe(200);
|
||||||
|
const result = rpc.result as { isError: boolean };
|
||||||
|
expect(result.isError).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Run Artifact Tools ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function createRunArtifactFixture(name: string) {
|
||||||
|
const scenario = await createScenario(name);
|
||||||
|
const step = await createStep(
|
||||||
|
scenario.id,
|
||||||
|
`return await context.downloadFile('${testServerUrl}/test-file.bin', { filename: 'artifact.bin' });`,
|
||||||
|
);
|
||||||
|
const run = await createRun(scenario.id);
|
||||||
|
await createRunStep(run.id, step.id);
|
||||||
|
|
||||||
|
const completedRun = await waitForRunCompletion(run.id);
|
||||||
|
expect(completedRun.status).toBe("pass");
|
||||||
|
|
||||||
|
return { sc: scenario, run: completedRun };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("list_run_files", () => {
|
||||||
|
it("returns artifacts created during a run with expiry metadata", async () => {
|
||||||
|
const { sc, run } = await createRunArtifactFixture("mcp-run-files-test");
|
||||||
|
|
||||||
|
const { status, rpc } = await mcpCall("list_run_files", {
|
||||||
|
scenarioId: sc.id,
|
||||||
|
runId: run.id,
|
||||||
|
});
|
||||||
|
expect(status).toBe(200);
|
||||||
|
const result = rpc.result as { content: { text: string }[] };
|
||||||
|
const list = JSON.parse(result.content[0].text) as {
|
||||||
|
items: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
mimeType: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
}>;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(list.total).toBe(1);
|
||||||
|
expect(list.items).toHaveLength(1);
|
||||||
|
expect(list.items[0].name).toBe("artifact.bin");
|
||||||
|
expect(list.items[0].mimeType).toBe("application/octet-stream");
|
||||||
|
expect(list.items[0].expiresAt).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns MCP error for non-existent scenario", async () => {
|
||||||
|
const { status, rpc } = await mcpCall("list_run_files", {
|
||||||
|
scenarioId: "00000000-0000-0000-0000-000000000000",
|
||||||
|
runId: "00000000-0000-0000-0000-000000000000",
|
||||||
|
});
|
||||||
|
expect(status).toBe(200);
|
||||||
|
const result = rpc.result as { isError: boolean };
|
||||||
|
expect(result.isError).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("get_run_file_content", () => {
|
||||||
|
it("returns run artifact bytes as base64 for a valid file", async () => {
|
||||||
|
const { sc, run } = await createRunArtifactFixture("mcp-run-file-content-test");
|
||||||
|
|
||||||
|
const listRpc = (
|
||||||
|
await mcpCall("list_run_files", {
|
||||||
|
scenarioId: sc.id,
|
||||||
|
runId: run.id,
|
||||||
|
})
|
||||||
|
).rpc;
|
||||||
|
const list = JSON.parse(
|
||||||
|
(listRpc.result as { content: { text: string }[] }).content[0].text,
|
||||||
|
) as {
|
||||||
|
items: Array<{ id: string; name: string; mimeType: string }>;
|
||||||
|
};
|
||||||
|
const file = list.items[0];
|
||||||
|
|
||||||
|
const { status, rpc } = await mcpCall("get_run_file_content", {
|
||||||
|
scenarioId: sc.id,
|
||||||
|
runId: run.id,
|
||||||
|
fileId: file.id,
|
||||||
|
});
|
||||||
|
expect(status).toBe(200);
|
||||||
|
const result = rpc.result as { content: { text: string }[] };
|
||||||
|
const response = JSON.parse(result.content[0].text) as {
|
||||||
|
file: { id: string; name: string; mimeType: string; expiresAt: string | null };
|
||||||
|
contentBase64: string;
|
||||||
|
encoding: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(response.file.id).toBe(file.id);
|
||||||
|
expect(response.file.name).toBe("artifact.bin");
|
||||||
|
expect(response.file.mimeType).toBe("application/octet-stream");
|
||||||
|
expect(response.file.expiresAt).not.toBeNull();
|
||||||
|
expect(response.encoding).toBe("base64");
|
||||||
|
expect(Buffer.from(response.contentBase64, "base64").toString("utf8")).toBe(
|
||||||
|
"test downloaded file content",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns MCP error for mismatched run and file ids", async () => {
|
||||||
|
const { sc: firstScenario, run: firstRun } = await createRunArtifactFixture(
|
||||||
|
"mcp-run-file-mismatch-a",
|
||||||
|
);
|
||||||
|
const { sc: secondScenario, run: secondRun } = await createRunArtifactFixture(
|
||||||
|
"mcp-run-file-mismatch-b",
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstListRpc = (
|
||||||
|
await mcpCall("list_run_files", {
|
||||||
|
scenarioId: firstScenario.id,
|
||||||
|
runId: firstRun.id,
|
||||||
|
})
|
||||||
|
).rpc;
|
||||||
|
const firstList = JSON.parse(
|
||||||
|
(firstListRpc.result as { content: { text: string }[] }).content[0].text,
|
||||||
|
) as { items: Array<{ id: string }> };
|
||||||
|
|
||||||
|
const { status, rpc } = await mcpCall("get_run_file_content", {
|
||||||
|
scenarioId: secondScenario.id,
|
||||||
|
runId: secondRun.id,
|
||||||
|
fileId: firstList.items[0].id,
|
||||||
|
});
|
||||||
|
expect(status).toBe(200);
|
||||||
|
const result = rpc.result as { isError: boolean };
|
||||||
|
expect(result.isError).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Snippet CRUD tools ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("list_snippets", () => {
|
||||||
|
it("returns a paginated result with a data array", async () => {
|
||||||
|
const { status, rpc } = await mcpCall("list_snippets");
|
||||||
|
expect(status).toBe(200);
|
||||||
|
const result = rpc.result as { content: { text: string }[] };
|
||||||
|
const body = JSON.parse(result.content[0].text) as {
|
||||||
|
data: unknown[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
expect(Array.isArray(body.data)).toBe(true);
|
||||||
|
expect(typeof body.total).toBe("number");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("create_snippet", () => {
|
||||||
|
it("creates a snippet and returns it with alias and title", async () => {
|
||||||
|
const { status, rpc } = await mcpCall("create_snippet", {
|
||||||
|
alias: "mcp-test-snippet",
|
||||||
|
title: "MCP Test Snippet",
|
||||||
|
description: "Created by integration test",
|
||||||
|
code: "await page.click('#btn');",
|
||||||
|
});
|
||||||
|
expect(status).toBe(200);
|
||||||
|
const result = rpc.result as { content: { text: string }[] };
|
||||||
|
const created = JSON.parse(result.content[0].text) as {
|
||||||
|
id: string;
|
||||||
|
alias: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
expect(created.alias).toBe("mcp-test-snippet");
|
||||||
|
expect(created.title).toBe("MCP Test Snippet");
|
||||||
|
expect(created.description).toBe("Created by integration test");
|
||||||
|
expect(created.code).toBe("await page.click('#btn');");
|
||||||
|
expect(typeof created.id).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an MCP error when alias already exists", async () => {
|
||||||
|
await mcpCall("create_snippet", {
|
||||||
|
alias: "mcp-duplicate-snippet",
|
||||||
|
title: "First",
|
||||||
|
code: "return 1;",
|
||||||
|
});
|
||||||
|
const { status, rpc } = await mcpCall("create_snippet", {
|
||||||
|
alias: "mcp-duplicate-snippet",
|
||||||
|
title: "Second",
|
||||||
|
code: "return 2;",
|
||||||
|
});
|
||||||
|
expect(status).toBe(200);
|
||||||
|
const result = rpc.result as { isError: boolean };
|
||||||
|
expect(result.isError).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("get_snippet", () => {
|
||||||
|
it("returns the created snippet by id", async () => {
|
||||||
|
const createRpc = (
|
||||||
|
await mcpCall("create_snippet", {
|
||||||
|
alias: "mcp-get-snippet",
|
||||||
|
title: "Get Me",
|
||||||
|
code: "return 42;",
|
||||||
|
})
|
||||||
|
).rpc;
|
||||||
|
const created = JSON.parse(
|
||||||
|
(createRpc.result as { content: { text: string }[] }).content[0].text,
|
||||||
|
) as { id: string };
|
||||||
|
|
||||||
|
const { status, rpc } = await mcpCall("get_snippet", { id: created.id });
|
||||||
|
expect(status).toBe(200);
|
||||||
|
const result = rpc.result as { content: { text: string }[] };
|
||||||
|
const fetched = JSON.parse(result.content[0].text) as {
|
||||||
|
id: string;
|
||||||
|
alias: string;
|
||||||
|
};
|
||||||
|
expect(fetched.id).toBe(created.id);
|
||||||
|
expect(fetched.alias).toBe("mcp-get-snippet");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an MCP error for a non-existent snippet id", async () => {
|
||||||
|
const { status, rpc } = await mcpCall("get_snippet", {
|
||||||
|
id: "00000000-0000-0000-0000-000000000000",
|
||||||
|
});
|
||||||
|
expect(status).toBe(200);
|
||||||
|
const result = rpc.result as { isError: boolean };
|
||||||
|
expect(result.isError).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("update_snippet", () => {
|
||||||
|
it("updates alias, title, and code of an existing snippet", async () => {
|
||||||
|
const createRpc = (
|
||||||
|
await mcpCall("create_snippet", {
|
||||||
|
alias: "mcp-upd-snippet-orig",
|
||||||
|
title: "Original Title",
|
||||||
|
code: "return 1;",
|
||||||
|
})
|
||||||
|
).rpc;
|
||||||
|
const created = JSON.parse(
|
||||||
|
(createRpc.result as { content: { text: string }[] }).content[0].text,
|
||||||
|
) as { id: string };
|
||||||
|
|
||||||
|
const { status, rpc } = await mcpCall("update_snippet", {
|
||||||
|
id: created.id,
|
||||||
|
alias: "mcp-upd-snippet-new",
|
||||||
|
title: "Updated Title",
|
||||||
|
code: "return 2;",
|
||||||
|
});
|
||||||
|
expect(status).toBe(200);
|
||||||
|
const result = rpc.result as { content: { text: string }[] };
|
||||||
|
const updated = JSON.parse(result.content[0].text) as {
|
||||||
|
alias: string;
|
||||||
|
title: string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
expect(updated.alias).toBe("mcp-upd-snippet-new");
|
||||||
|
expect(updated.title).toBe("Updated Title");
|
||||||
|
expect(updated.code).toBe("return 2;");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an MCP error for a non-existent snippet id", async () => {
|
||||||
|
const { status, rpc } = await mcpCall("update_snippet", {
|
||||||
|
id: "00000000-0000-0000-0000-000000000000",
|
||||||
|
title: "Ghost",
|
||||||
|
});
|
||||||
|
expect(status).toBe(200);
|
||||||
|
const result = rpc.result as { isError: boolean };
|
||||||
|
expect(result.isError).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("delete_snippet", () => {
|
||||||
|
it("deletes an existing snippet and confirms deletion", async () => {
|
||||||
|
const createRpc = (
|
||||||
|
await mcpCall("create_snippet", {
|
||||||
|
alias: "mcp-del-snippet",
|
||||||
|
title: "Delete Me",
|
||||||
|
code: "return 0;",
|
||||||
|
})
|
||||||
|
).rpc;
|
||||||
|
const created = JSON.parse(
|
||||||
|
(createRpc.result as { content: { text: string }[] }).content[0].text,
|
||||||
|
) as { id: string };
|
||||||
|
|
||||||
|
const { status, rpc } = await mcpCall("delete_snippet", {
|
||||||
|
id: created.id,
|
||||||
|
});
|
||||||
|
expect(status).toBe(200);
|
||||||
|
const result = rpc.result as { content: { text: string }[] };
|
||||||
|
expect(result.content[0].text).toContain(created.id);
|
||||||
|
|
||||||
|
// confirm gone
|
||||||
|
const getResult = (await mcpCall("get_snippet", { id: created.id }))
|
||||||
|
.rpc.result as { isError: boolean };
|
||||||
|
expect(getResult.isError).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an MCP error for a non-existent snippet id", async () => {
|
||||||
|
const { status, rpc } = await mcpCall("delete_snippet", {
|
||||||
|
id: "00000000-0000-0000-0000-000000000000",
|
||||||
|
});
|
||||||
|
expect(status).toBe(200);
|
||||||
|
const result = rpc.result as { isError: boolean };
|
||||||
|
expect(result.isError).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -171,6 +171,35 @@ describe("ScenarioController", () => {
|
|||||||
.get("/scenarios?orderDir=SIDEWAYS")
|
.get("/scenarios?orderDir=SIDEWAYS")
|
||||||
.expect(400);
|
.expect(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns lastRunStatus and lastRunAt as null when scenario has no runs", async () => {
|
||||||
|
await createScenario("no-runs-sc");
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.get("/scenarios?orderBy=name&orderDir=ASC")
|
||||||
|
.expect(200);
|
||||||
|
const item = res.body.data.find(
|
||||||
|
(s: { name: string }) => s.name === "no-runs-sc",
|
||||||
|
);
|
||||||
|
expect(item).toBeDefined();
|
||||||
|
expect(item.lastRunStatus).toBeNull();
|
||||||
|
expect(item.lastRunAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns lastRunStatus and lastRunAt reflecting the most recent run", async () => {
|
||||||
|
const sc = await createScenario("last-run-status-sc");
|
||||||
|
await createRun(sc.id);
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.get("/scenarios?orderBy=name&orderDir=ASC")
|
||||||
|
.expect(200);
|
||||||
|
const item = res.body.data.find(
|
||||||
|
(s: { name: string }) => s.name === "last-run-status-sc",
|
||||||
|
);
|
||||||
|
expect(item).toBeDefined();
|
||||||
|
expect(["pending", "in_progress", "pass", "fail"]).toContain(
|
||||||
|
item.lastRunStatus,
|
||||||
|
);
|
||||||
|
expect(typeof item.lastRunAt).toBe("string");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── GET /scenarios/:id ─────────────────────────────────────────────────────
|
// ── GET /scenarios/:id ─────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user