Compare commits
10
Commits
| 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 })),
|
||||
);
|
||||
|
||||
const NAV: (
|
||||
| { path: string; labelKey: string; Icon: LucideIcon }
|
||||
| { separator: true }
|
||||
)[] = [
|
||||
const NAV: ({ path: string; labelKey: string; Icon: LucideIcon } | { separator: true })[] = [
|
||||
{ path: '/runs', labelKey: 'nav.runs', Icon: Activity },
|
||||
{ path: '/sessions', labelKey: 'nav.sessions', Icon: Monitor },
|
||||
{ separator: true },
|
||||
|
||||
+62
-11
@@ -2,6 +2,8 @@ import type {
|
||||
PaginatedResponse,
|
||||
Credential,
|
||||
Environment,
|
||||
FileMetadata,
|
||||
FileListResponse,
|
||||
ScenarioCredential,
|
||||
Session,
|
||||
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> {
|
||||
let res: Response;
|
||||
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}`, {
|
||||
headers: { 'Content-Type': 'application/json', ...init?.headers },
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = (err as Error).message || 'Network request failed';
|
||||
@@ -139,7 +146,10 @@ export const environments = {
|
||||
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}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(patch),
|
||||
@@ -162,7 +172,12 @@ export const environments = {
|
||||
// ── 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}`);
|
||||
},
|
||||
get(id: string): Promise<Session> {
|
||||
@@ -182,7 +197,9 @@ export const scenarios = {
|
||||
orderBy = 'updatedAt',
|
||||
orderDir: 'ASC' | 'DESC' = 'DESC',
|
||||
): 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[] }> {
|
||||
return request(`/scenarios/${id}`);
|
||||
@@ -193,7 +210,10 @@ export const scenarios = {
|
||||
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}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(patch),
|
||||
@@ -252,11 +272,7 @@ export const runs = {
|
||||
status?: string,
|
||||
orderBy = 'createdAt',
|
||||
orderDir: 'ASC' | 'DESC' = 'DESC',
|
||||
): Promise<
|
||||
PaginatedResponse<
|
||||
ScenarioRun & { scenario: { id: string; name: string } }
|
||||
>
|
||||
> {
|
||||
): Promise<PaginatedResponse<ScenarioRun & { scenario: { id: string; name: string } }>> {
|
||||
const q = new URLSearchParams({ page: String(page), limit: String(limit), orderBy, orderDir });
|
||||
if (status) q.set('status', status);
|
||||
return request(`/scenarios/runs?${q}`);
|
||||
@@ -268,7 +284,9 @@ export const runs = {
|
||||
orderBy = 'createdAt',
|
||||
orderDir: 'ASC' | 'DESC' = 'DESC',
|
||||
): 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> {
|
||||
const qs = q?.trim() ? `?q=${encodeURIComponent(q.trim())}` : '';
|
||||
@@ -329,3 +347,36 @@ export const steps = {
|
||||
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'>;
|
||||
steps?: ScenarioStep[];
|
||||
scenarioCredentials?: ScenarioCredential[];
|
||||
lastRunStatus?: ScenarioRunStatus | null;
|
||||
lastRunAt?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -141,3 +143,20 @@ export interface ScenarioRunDetail extends ScenarioRun {
|
||||
stepRuns: ScenarioRunStep[];
|
||||
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_name": "Name",
|
||||
"col_description": "Description",
|
||||
"col_last_run": "Last Run",
|
||||
"col_updated": "Updated",
|
||||
"empty": "No scenarios yet.",
|
||||
"loading": "Loading…",
|
||||
@@ -294,5 +295,22 @@
|
||||
"action_export": "Export",
|
||||
"action_import": "Import",
|
||||
"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 { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { KeyRound, X, Save } from 'lucide-react';
|
||||
import { credentials } from '../../api';
|
||||
import { Breadcrumbs, Button, Card, Input, CodeEditor, useToast } from '../../ui';
|
||||
@@ -8,6 +9,7 @@ import styles from '../Page.module.css';
|
||||
|
||||
export function CreateCredentialPage() {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t('credentials.create_title'));
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { Upload, Pencil, Trash2, KeyRound } from 'lucide-react';
|
||||
import { CodeBlock } from '../../ui';
|
||||
import { stringify as yamlStringify } from 'yaml';
|
||||
@@ -16,6 +17,7 @@ export function CredentialDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const [credential, setCredential] = useState<Credential | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
usePageTitle(credential?.name ?? null);
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { Settings, Pencil, Trash2, Plus, Download, KeyRound } from 'lucide-react';
|
||||
import { parse as yamlParse } from 'yaml';
|
||||
import { credentials } from '../../api';
|
||||
@@ -100,6 +101,7 @@ function AddCredentialCard() {
|
||||
|
||||
export function CredentialsPage() {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t('credentials.title'));
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<Credential[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, SubmitEvent } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { KeyRound, X, Save } from 'lucide-react';
|
||||
import { credentials } from '../../api';
|
||||
import type { Credential } from '../../api';
|
||||
@@ -15,6 +16,11 @@ export function EditCredentialPage() {
|
||||
|
||||
const [credential, setCredential] = useState<Credential | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
usePageTitle(
|
||||
credential
|
||||
? `${t('credentials.edit_title')} — ${credential.name}`
|
||||
: t('credentials.edit_title'),
|
||||
);
|
||||
const [data, setData] = useState('');
|
||||
const [nameError, setNameError] = useState('');
|
||||
const [dataError, setDataError] = useState('');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, SubmitEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { X, Globe, Save } from 'lucide-react';
|
||||
import { environments } from '../../api';
|
||||
import { Breadcrumbs, Button, Card, Input, CodeEditor, useToast } from '../../ui';
|
||||
@@ -8,6 +9,7 @@ import styles from '../Page.module.css';
|
||||
|
||||
export function CreateEnvironmentPage() {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t('environments.create_title'));
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, SubmitEvent } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { X, Globe, Save } from 'lucide-react';
|
||||
import { environments } from '../../api';
|
||||
import type { Environment } from '../../api';
|
||||
@@ -15,6 +16,9 @@ export function EditEnvironmentPage() {
|
||||
|
||||
const [env, setEnv] = useState<Environment | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
usePageTitle(
|
||||
env ? `${t('environments.edit_title')} — ${env.name}` : t('environments.edit_title'),
|
||||
);
|
||||
const [description, setDescription] = useState('');
|
||||
const [dataJson, setDataJson] = useState('{}');
|
||||
const [nameError, setNameError] = useState('');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Globe, Pencil, Trash2, Upload } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { stringify as yamlStringify } from 'yaml';
|
||||
import type { Environment } from '../../api';
|
||||
@@ -25,6 +26,7 @@ export function EnvironmentDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const [env, setEnv] = useState<Environment | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
usePageTitle(env?.name ?? null);
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const dataEntries = Object.entries(env?.data ?? {}).filter(([, v]) => v);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { Settings, ExternalLink, Pencil, Trash2, Plus, Globe, Download } from 'lucide-react';
|
||||
import { parse as yamlParse } from 'yaml';
|
||||
import { stringify as yamlStringify } from 'yaml';
|
||||
@@ -117,6 +118,7 @@ function AddEnvironmentCard() {
|
||||
|
||||
export function EnvironmentsPage() {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t('environments.title'));
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<Environment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { Activity } from 'lucide-react';
|
||||
import { runs } from '../../api';
|
||||
import type { ScenarioRun, ScenarioRunStatus } from '../../api';
|
||||
@@ -38,6 +39,7 @@ type AllRunRow = ScenarioRun & {
|
||||
|
||||
export function AllRunsPage() {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t('runs.title'));
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [sortBy, setSortBy] = useState('createdAt');
|
||||
@@ -45,12 +47,7 @@ export function AllRunsPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['runs', sortBy, sortDir, page, pageSize],
|
||||
queryFn: () =>
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
import { runs } from '../../api';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { runs, runFiles } from '../../api';
|
||||
import type {
|
||||
Scenario,
|
||||
ScenarioRunDetail,
|
||||
@@ -10,11 +11,13 @@ import type {
|
||||
ScenarioRunStatus,
|
||||
RunStepStatus,
|
||||
LogLevel,
|
||||
FileMetadata,
|
||||
} from '../../api';
|
||||
import { scenarios } from '../../api';
|
||||
import {
|
||||
AutoRefreshIndicator,
|
||||
Badge,
|
||||
Button,
|
||||
Breadcrumbs,
|
||||
Card,
|
||||
CodeBlock,
|
||||
@@ -27,7 +30,7 @@ import {
|
||||
type TableColumn,
|
||||
} 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';
|
||||
|
||||
const RUN_STATUS_VARIANT: Record<ScenarioRunStatus, BadgeVariant> = {
|
||||
@@ -69,6 +72,7 @@ export function RunDetailPage() {
|
||||
const [scenario, setScenario] = useState<Scenario | null>(null);
|
||||
const [run, setRun] = useState<ScenarioRunDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
usePageTitle(scenario ? `${t('runs.title')} — ${scenario.name}` : t('runs.title'));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [polling, setPolling] = useState(false);
|
||||
const [pulseKey, setPulseKey] = useState(0);
|
||||
@@ -77,6 +81,9 @@ export function RunDetailPage() {
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [expandAll, setExpandAll] = useState(false);
|
||||
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 LOG_PAGE_SIZE = 25;
|
||||
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 = () => {
|
||||
if (!id || !runId) return;
|
||||
runs
|
||||
@@ -101,6 +118,22 @@ export function RunDetailPage() {
|
||||
.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(() => {
|
||||
const t = setTimeout(() => {
|
||||
setDebouncedSearch(logSearch);
|
||||
@@ -127,6 +160,7 @@ export function RunDetailPage() {
|
||||
if (cancelled) return;
|
||||
setScenario(sc);
|
||||
setRun(r);
|
||||
loadArtifacts();
|
||||
if (!FINAL.includes(r.status)) {
|
||||
setPolling(true);
|
||||
pollRef.current = setInterval(async () => {
|
||||
@@ -135,7 +169,10 @@ export function RunDetailPage() {
|
||||
if (cancelled) return;
|
||||
setRun(updated);
|
||||
setPulseKey((k) => k + 1);
|
||||
if (FINAL.includes(updated.status)) stopPolling();
|
||||
if (FINAL.includes(updated.status)) {
|
||||
stopPolling();
|
||||
await loadArtifacts();
|
||||
}
|
||||
} catch {
|
||||
stopPolling();
|
||||
}
|
||||
@@ -153,7 +190,7 @@ export function RunDetailPage() {
|
||||
cancelled = true;
|
||||
stopPolling();
|
||||
};
|
||||
}, [id, runId]);
|
||||
}, [id, runId, loadArtifacts]);
|
||||
|
||||
const stepColumns: TableColumn<ScenarioRunStep>[] = [
|
||||
{ key: 'order', header: t('runs.step_order'), render: (s) => s.order, width: 50 },
|
||||
@@ -214,6 +251,7 @@ export function RunDetailPage() {
|
||||
),
|
||||
render: (s) => {
|
||||
const isExpanded = expandAll || expandedStepIds.has(s.id);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
@@ -421,6 +459,68 @@ export function RunDetailPage() {
|
||||
)}
|
||||
</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>
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { Play, ClipboardList, Activity } from 'lucide-react';
|
||||
import { environments, scenarios, runs } from '../../api';
|
||||
import type {
|
||||
Environment,
|
||||
Scenario,
|
||||
ScenarioRun,
|
||||
ScenarioRunStatus,
|
||||
} from '../../api';
|
||||
import type { Environment, Scenario, ScenarioRun, ScenarioRunStatus } from '../../api';
|
||||
import {
|
||||
AutoRefreshIndicator,
|
||||
Badge,
|
||||
@@ -50,6 +46,7 @@ export function RunsPage() {
|
||||
|
||||
const [scenario, setScenario] = useState<Scenario | null>(null);
|
||||
const [items, setItems] = useState<RunRow[]>([]);
|
||||
usePageTitle(scenario ? `${t('runs.title')} — ${scenario.name}` : t('runs.title'));
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pulseKey, setPulseKey] = useState(0);
|
||||
@@ -84,16 +81,16 @@ export function RunsPage() {
|
||||
scenarios.get(id!),
|
||||
runs.list(id!, p, limit, orderBy, orderDir === 'asc' ? 'ASC' : 'DESC'),
|
||||
])
|
||||
.then(([sc, res]) => {
|
||||
setScenario(sc);
|
||||
setItems(res.data);
|
||||
setTotal(res.total);
|
||||
setError(null);
|
||||
setPulseKey((k) => k + 1);
|
||||
hasDataRef.current = true;
|
||||
})
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
.then(([sc, res]) => {
|
||||
setScenario(sc);
|
||||
setItems(res.data);
|
||||
setTotal(res.total);
|
||||
setError(null);
|
||||
setPulseKey((k) => k + 1);
|
||||
hasDataRef.current = true;
|
||||
})
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
},
|
||||
[id],
|
||||
);
|
||||
@@ -107,7 +104,6 @@ export function RunsPage() {
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [load]);
|
||||
|
||||
const handleSort = (key: string, dir: SortDirection) => {
|
||||
@@ -192,7 +188,12 @@ export function RunsPage() {
|
||||
]}
|
||||
/>
|
||||
<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)}>
|
||||
<Play size={14} />
|
||||
{t('runs.action_run')}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, SubmitEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { X, Save, ClipboardList } from 'lucide-react';
|
||||
import { scenarios, environments } from '../../api';
|
||||
import type { Environment } from '../../api';
|
||||
@@ -9,6 +10,7 @@ import styles from '../Page.module.css';
|
||||
|
||||
export function CreateScenarioPage() {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t('scenarios.create_title'));
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
|
||||
@@ -20,7 +22,10 @@ export function CreateScenarioPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
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>) => {
|
||||
@@ -31,7 +36,11 @@ export function CreateScenarioPage() {
|
||||
}
|
||||
setSaving(true);
|
||||
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'));
|
||||
navigate(`/scenarios/${scenario.id}`);
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, SubmitEvent } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { X, Save, ClipboardList } from 'lucide-react';
|
||||
import { scenarios, steps } from '../../api';
|
||||
import type { Scenario } from '../../api';
|
||||
@@ -14,6 +15,7 @@ export function CreateStepPage() {
|
||||
const toast = useToast();
|
||||
|
||||
const [scenario, setScenario] = useState<Scenario | null>(null);
|
||||
usePageTitle(t('steps.create_title'));
|
||||
const [title, setTitle] = useState('');
|
||||
const [execCode, setExecCode] = useState('');
|
||||
const [timeoutSeconds, setTimeoutSeconds] = useState<string>('');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, SubmitEvent } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { X, Save, ClipboardList } from 'lucide-react';
|
||||
import { scenarios, environments } from '../../api';
|
||||
import type { Scenario, Environment } from '../../api';
|
||||
@@ -15,6 +16,9 @@ export function EditScenarioPage() {
|
||||
|
||||
const [scenario, setScenario] = useState<Scenario | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
usePageTitle(
|
||||
scenario ? `${t('scenarios.edit_title')} — ${scenario.name}` : t('scenarios.edit_title'),
|
||||
);
|
||||
const [description, setDescription] = useState('');
|
||||
const [environmentId, setEnvironmentId] = useState<string>('');
|
||||
const [timeoutSeconds, setTimeoutSeconds] = useState<string>('');
|
||||
@@ -35,7 +39,10 @@ export function EditScenarioPage() {
|
||||
setTimeoutSeconds(data.timeoutSeconds != null ? String(data.timeoutSeconds) : '');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
environments.list(1, 200).then((r) => setEnvs(r?.data ?? [])).catch(() => {});
|
||||
environments
|
||||
.list(1, 200)
|
||||
.then((r) => setEnvs(r?.data ?? []))
|
||||
.catch(() => {});
|
||||
}, [id]);
|
||||
|
||||
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, SubmitEvent } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { X, Save, ClipboardList } from 'lucide-react';
|
||||
import { scenarios, steps } from '../../api';
|
||||
import type { Scenario, ScenarioStep } from '../../api';
|
||||
@@ -15,6 +16,9 @@ export function EditStepPage() {
|
||||
|
||||
const [scenario, setScenario] = useState<Scenario | 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 [execCode, setExecCode] = useState('');
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import {
|
||||
Play,
|
||||
Pencil,
|
||||
Plus,
|
||||
History,
|
||||
Download,
|
||||
Trash2,
|
||||
Upload,
|
||||
GripVertical,
|
||||
ClipboardList,
|
||||
} from 'lucide-react';
|
||||
import { stringify as yamlStringify } from 'yaml';
|
||||
import {
|
||||
environments,
|
||||
scenarios,
|
||||
steps,
|
||||
scenarioCredentials,
|
||||
scenarioFiles,
|
||||
credentials as credentialsApi,
|
||||
} from '../../api';
|
||||
import type {
|
||||
@@ -25,6 +27,7 @@ import type {
|
||||
ScenarioCredential,
|
||||
Credential,
|
||||
Environment,
|
||||
FileMetadata,
|
||||
} from '../../api';
|
||||
import {
|
||||
Breadcrumbs,
|
||||
@@ -51,6 +54,7 @@ export function ScenarioDetailPage() {
|
||||
const toast = useToast();
|
||||
const [scenario, setScenario] = useState<(Scenario & { steps: ScenarioStep[] }) | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
usePageTitle(scenario?.name ?? null);
|
||||
|
||||
// Credentials state
|
||||
const [scenarioCreds, setScenarioCreds] = useState<ScenarioCredential[]>([]);
|
||||
@@ -81,6 +85,27 @@ export function ScenarioDetailPage() {
|
||||
const [includedCredentialIds, setIncludedCredentialIds] = useState<Set<string>>(new Set());
|
||||
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(() => {
|
||||
if (!id) return;
|
||||
void scenarios
|
||||
@@ -90,6 +115,7 @@ export function ScenarioDetailPage() {
|
||||
setScenarioCreds(s.scenarioCredentials ?? []);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
void loadFiles();
|
||||
credentialsApi
|
||||
.list(1, 200)
|
||||
.then((r) => setAllCredentials(r.data))
|
||||
@@ -100,7 +126,7 @@ export function ScenarioDetailPage() {
|
||||
setEnvs(r.data);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [id]);
|
||||
}, [id, loadFiles]);
|
||||
|
||||
// Default selected env to scenario's linked environment (or first env) once both are loaded
|
||||
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) => {
|
||||
await steps.remove(id!, stepId);
|
||||
await reloadScenario();
|
||||
@@ -425,17 +483,22 @@ export function ScenarioDetailPage() {
|
||||
{ term: t('scenarios.field_id'), detail: <UuidBadge id={scenario.id} /> },
|
||||
{ term: t('scenarios.field_name'), detail: scenario.name },
|
||||
...(scenario.environment
|
||||
? [{
|
||||
term: t('scenarios.field_environment'),
|
||||
detail: (
|
||||
<a
|
||||
href={`/environments/${scenario.environment.id}`}
|
||||
onClick={(e) => { e.preventDefault(); navigate(`/environments/${scenario.environment!.id}`); }}
|
||||
>
|
||||
{scenario.environment.name}
|
||||
</a>
|
||||
),
|
||||
}]
|
||||
? [
|
||||
{
|
||||
term: t('scenarios.field_environment'),
|
||||
detail: (
|
||||
<a
|
||||
href={`/environments/${scenario.environment.id}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate(`/environments/${scenario.environment!.id}`);
|
||||
}}
|
||||
>
|
||||
{scenario.environment.name}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
term: t('scenarios.field_created'),
|
||||
@@ -587,6 +650,101 @@ export function ScenarioDetailPage() {
|
||||
<p className={styles.muted}>{t('scenarios.cred_empty')}</p>
|
||||
)}
|
||||
</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 { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { Play, Plus, Trash2, Download, ClipboardList } from 'lucide-react';
|
||||
import { parse as yamlParse } from 'yaml';
|
||||
import { environments, scenarios } from '../../api';
|
||||
import type { Environment, Scenario } from '../../api';
|
||||
import {
|
||||
Badge,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Modal,
|
||||
@@ -17,10 +19,27 @@ import {
|
||||
UuidBadge,
|
||||
useToast,
|
||||
} from '../../ui';
|
||||
import type { BadgeVariant } from '../../ui';
|
||||
import type { ScenarioRunStatus } from '../../api';
|
||||
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() {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t('scenarios.title'));
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const [items, setItems] = useState<Scenario[]>([]);
|
||||
@@ -48,20 +67,21 @@ export function ScenariosPage() {
|
||||
scenarios.list(p, limit, orderBy, orderDir === 'asc' ? 'ASC' : 'DESC'),
|
||||
environments.list(1, 200),
|
||||
])
|
||||
.then(([scenarioRes, envRes]) => {
|
||||
setItems(scenarioRes?.data ?? []);
|
||||
setTotal(scenarioRes?.total ?? 0);
|
||||
setEnvs(envRes?.data ?? []);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error((err as Error).message);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [toast]);
|
||||
.then(([scenarioRes, envRes]) => {
|
||||
setItems(scenarioRes?.data ?? []);
|
||||
setTotal(scenarioRes?.total ?? 0);
|
||||
setEnvs(envRes?.data ?? []);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error((err as Error).message);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
load('updatedAt', 'desc', 1, 10);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [load]);
|
||||
|
||||
const handleSort = (key: string, dir: SortDirection) => {
|
||||
@@ -135,6 +155,24 @@ export function ScenariosPage() {
|
||||
const columns: TableColumn<Scenario>[] = [
|
||||
{ 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: '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',
|
||||
header: t('scenarios.col_updated'),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { Trash2, Monitor } from 'lucide-react';
|
||||
import { sessions } from '../../api';
|
||||
import type { Session } from '../../api';
|
||||
@@ -14,6 +15,7 @@ export function SessionDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
usePageTitle(session?.sessionName ?? null);
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { Trash2, Monitor } from 'lucide-react';
|
||||
import { sessions } from '../../api';
|
||||
import type { Session } from '../../api';
|
||||
@@ -19,6 +20,7 @@ import styles from '../Page.module.css';
|
||||
|
||||
export function SessionsPage() {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t('sessions.title'));
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<Session[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -34,7 +36,10 @@ export function SessionsPage() {
|
||||
setLoading(true);
|
||||
sessions
|
||||
.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));
|
||||
}, []);
|
||||
|
||||
@@ -62,7 +67,12 @@ export function SessionsPage() {
|
||||
|
||||
const columns: TableColumn<Session>[] = [
|
||||
{ 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',
|
||||
header: t('sessions.col_status'),
|
||||
@@ -115,7 +125,10 @@ export function SessionsPage() {
|
||||
total={total}
|
||||
page={page}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(s) => { setPageSize(s); setPage(1); }}
|
||||
onPageSizeChange={(s) => {
|
||||
setPageSize(s);
|
||||
setPage(1);
|
||||
}}
|
||||
pageSizeOptions={[10, 25, 50]}
|
||||
onRowClick={(s) => navigate(`/sessions/${s.id}`)}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, SubmitEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { X, Save, Braces } from 'lucide-react';
|
||||
import { snippets } from '../../api';
|
||||
import { Breadcrumbs, Button, Card, Input, CodeEditor, useToast } from '../../ui';
|
||||
@@ -8,6 +9,7 @@ import styles from '../Page.module.css';
|
||||
|
||||
export function CreateSnippetPage() {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t('snippets.create_title'));
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, SubmitEvent } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { X, Save, Braces } from 'lucide-react';
|
||||
import { snippets } from '../../api';
|
||||
import type { Snippet } from '../../api';
|
||||
@@ -15,6 +16,9 @@ export function EditSnippetPage() {
|
||||
|
||||
const [snippet, setSnippet] = useState<Snippet | null>(null);
|
||||
const [alias, setAlias] = useState('');
|
||||
usePageTitle(
|
||||
snippet ? `${t('snippets.edit_title')} — ${snippet.title}` : t('snippets.edit_title'),
|
||||
);
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { Upload, Pencil, Trash2, Braces } from 'lucide-react';
|
||||
import { CodeBlock } from '../../ui';
|
||||
import { stringify as yamlStringify } from 'yaml';
|
||||
@@ -24,6 +25,7 @@ export function SnippetDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const [snippet, setSnippet] = useState<Snippet | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
usePageTitle(snippet?.title ?? null);
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { Settings, Pencil, Trash2, Plus, Download, Braces } from 'lucide-react';
|
||||
import { parse as yamlParse } from 'yaml';
|
||||
import { snippets } from '../../api';
|
||||
@@ -114,6 +115,7 @@ function AddSnippetCard() {
|
||||
|
||||
export function SnippetsPage() {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t('snippets.title'));
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<Snippet[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -76,8 +76,7 @@ export function Table<T>({
|
||||
|
||||
const handleSortClick = (key: string) => {
|
||||
if (!onSort) return;
|
||||
const nextDir: SortDirection =
|
||||
sortBy === key && sortDir === 'asc' ? 'desc' : 'asc';
|
||||
const nextDir: SortDirection = sortBy === key && sortDir === 'asc' ? 'desc' : 'asc';
|
||||
onSort(key, nextDir);
|
||||
};
|
||||
|
||||
@@ -101,19 +100,12 @@ export function Table<T>({
|
||||
<tr>
|
||||
{columns.map((col) => {
|
||||
const isSorted = col.sortable && sortBy === col.key;
|
||||
const SortIcon = isSorted
|
||||
? sortDir === 'asc'
|
||||
? ArrowUp
|
||||
: ArrowDown
|
||||
: ArrowUpDown;
|
||||
const SortIcon = isSorted ? (sortDir === 'asc' ? ArrowUp : ArrowDown) : ArrowUpDown;
|
||||
|
||||
return (
|
||||
<th
|
||||
key={col.key}
|
||||
className={[
|
||||
styles.th,
|
||||
col.sortable ? styles.thSortable : '',
|
||||
]
|
||||
className={[styles.th, col.sortable ? styles.thSortable : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={{
|
||||
@@ -127,10 +119,7 @@ export function Table<T>({
|
||||
{col.sortable && (
|
||||
<SortIcon
|
||||
size={12}
|
||||
className={[
|
||||
styles.sortIcon,
|
||||
isSorted ? styles.sortIconActive : '',
|
||||
]
|
||||
className={[styles.sortIcon, isSorted ? styles.sortIconActive : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-hidden="true"
|
||||
|
||||
+22
@@ -50,7 +50,29 @@ The server currently registers the following tools.
|
||||
| `export_scenario` | Export 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
|
||||
|
||||
- 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.
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "liqa",
|
||||
"version": "1.6.1",
|
||||
"version": "1.7.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "liqa",
|
||||
"version": "1.6.1",
|
||||
"version": "1.7.1",
|
||||
"workspaces": [
|
||||
"server",
|
||||
"client"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "liqa",
|
||||
"version": "1.6.1",
|
||||
"version": "1.7.1",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"server",
|
||||
|
||||
@@ -8,6 +8,10 @@ import { CredentialEntity } from "./credential/credential.entity";
|
||||
import { CredentialModule } from "./credential/credential.module";
|
||||
import { EnvironmentEntity } from "./environment/environment.entity";
|
||||
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 { McpModule } from "./mcp/mcp.module";
|
||||
import { ScenarioCredentialEntity } from "./scenario/scenario-credential.entity";
|
||||
@@ -45,6 +49,9 @@ import { SnippetModule } from "./snippet/snippet.module";
|
||||
ScenarioRunLogEntity,
|
||||
ScenarioCredentialEntity,
|
||||
SnippetEntity,
|
||||
FileEntity,
|
||||
ScenarioFileEntity,
|
||||
ScenarioRunFileEntity,
|
||||
],
|
||||
synchronize: true,
|
||||
}),
|
||||
@@ -54,6 +61,7 @@ import { SnippetModule } from "./snippet/snippet.module";
|
||||
CredentialModule,
|
||||
ScenarioModule,
|
||||
SnippetModule,
|
||||
FileModule,
|
||||
McpModule,
|
||||
HealthModule,
|
||||
],
|
||||
|
||||
@@ -6,8 +6,11 @@ import {
|
||||
import { expect as playwrightExpect } from "@playwright/test";
|
||||
import { parse } from "acorn";
|
||||
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 type { EnvironmentData } from "../environment/environment.entity";
|
||||
import type { FileStorageService } from "../file/file-storage.service";
|
||||
import type { DomNode } from "./dom-helpers";
|
||||
import { dumpDom } from "./dom-helpers";
|
||||
|
||||
@@ -41,6 +44,16 @@ export interface ScriptContext {
|
||||
error: (...args: unknown[]) => void;
|
||||
/** Runs a named snippet with the same context, plus any extra positional args. */
|
||||
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 {
|
||||
@@ -53,6 +66,9 @@ export interface ExecContext {
|
||||
environment?: EnvironmentData | null;
|
||||
snippets?: Record<string, string> | null;
|
||||
result?: unknown;
|
||||
scenarioId?: string;
|
||||
runId?: string;
|
||||
fileService?: FileStorageService;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -91,6 +107,9 @@ export class CodeExecutorService {
|
||||
environment,
|
||||
snippets,
|
||||
result,
|
||||
scenarioId,
|
||||
runId,
|
||||
fileService,
|
||||
} = ctx;
|
||||
const scriptLog: ScriptLogger =
|
||||
log ?? ((level, msg) => this.logger[level](msg));
|
||||
@@ -158,6 +177,58 @@ export class CodeExecutorService {
|
||||
);
|
||||
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
|
||||
@@ -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 { EnvironmentData } from "../environment/environment.entity";
|
||||
import type { FileStorageService } from "../file/file-storage.service";
|
||||
import type { ExecContext, ScriptLogger } from "./code-executor.service";
|
||||
|
||||
export class ExecContextBuilder {
|
||||
@@ -50,6 +51,21 @@ export class ExecContextBuilder {
|
||||
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 {
|
||||
if (!this.ctx.page) throw new Error("ExecContextBuilder: page is required");
|
||||
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()
|
||||
@Min(1)
|
||||
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 {
|
||||
|
||||
@@ -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 { 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() {
|
||||
const API_PREFIX = "api/v1";
|
||||
const logger = new TraceLogger("Bootstrap");
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CredentialModule } from "../credential/credential.module";
|
||||
import { EnvironmentModule } from "../environment/environment.module";
|
||||
import { ScenarioModule } from "../scenario/scenario.module";
|
||||
import { SessionModule } from "../session/session.module";
|
||||
import { SnippetModule } from "../snippet/snippet.module";
|
||||
import { McpController } from "./mcp.controller";
|
||||
import { McpService } from "./mcp.service";
|
||||
|
||||
@@ -16,6 +17,7 @@ import { McpService } from "./mcp.service";
|
||||
BrowserModule,
|
||||
CodeExecutorModule,
|
||||
ScenarioModule,
|
||||
SnippetModule,
|
||||
],
|
||||
controllers: [McpController],
|
||||
providers: [McpService],
|
||||
|
||||
@@ -11,6 +11,7 @@ import { CredentialService } from "../credential/credential.service";
|
||||
import { BrowserService } from "../browser/browser.service";
|
||||
import { CodeExecutorService } from "../code-executor/code-executor.service";
|
||||
import { ScenarioService } from "../scenario/scenario.service";
|
||||
import { SnippetService } from "../snippet/snippet.service";
|
||||
|
||||
import pkg from "../../package.json";
|
||||
|
||||
@@ -24,6 +25,7 @@ export class McpService {
|
||||
private readonly browserService: BrowserService,
|
||||
private readonly codeExecutor: CodeExecutorService,
|
||||
private readonly scenarioService: ScenarioService,
|
||||
private readonly snippetService: SnippetService,
|
||||
) {}
|
||||
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Effect } from "effect";
|
||||
import type { Browser, BrowserContext, Page } from "playwright";
|
||||
import { chromium } from "playwright";
|
||||
import { Repository } from "typeorm";
|
||||
import { FileStorageService } from "../file/file-storage.service";
|
||||
import type { ScriptLogger } from "../code-executor/code-executor.service";
|
||||
import { CodeExecutorService } from "../code-executor/code-executor.service";
|
||||
import { ExecContextBuilder } from "../code-executor/exec-context.builder";
|
||||
@@ -43,6 +44,8 @@ export class ScenarioSchedulerService implements OnModuleInit {
|
||||
private readonly runEnvironments = new Map<string, EnvironmentData>();
|
||||
// Cache scenario-level timeout (seconds) per run
|
||||
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_STEP_TIMEOUT_SEC = 60;
|
||||
@@ -62,6 +65,7 @@ export class ScenarioSchedulerService implements OnModuleInit {
|
||||
private readonly snippetService: SnippetService,
|
||||
private readonly sessionService: SessionService,
|
||||
private readonly sessionContextService: SessionContextService,
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
@@ -198,6 +202,7 @@ export class ScenarioSchedulerService implements OnModuleInit {
|
||||
.findOne(run.scenarioId)
|
||||
.catch(() => null);
|
||||
this.runScenarioTimeouts.set(run.id, scenario?.timeoutSeconds ?? null);
|
||||
this.runScenarioIds.set(run.id, run.scenarioId);
|
||||
const traceId = crypto.randomUUID();
|
||||
void traceStorage.run({ traceId }, () =>
|
||||
this.processRunToCompletion(run.id),
|
||||
@@ -210,6 +215,7 @@ export class ScenarioSchedulerService implements OnModuleInit {
|
||||
this.runSnippets.delete(run.id);
|
||||
this.runEnvironments.delete(run.id);
|
||||
this.runScenarioTimeouts.delete(run.id);
|
||||
this.runScenarioIds.delete(run.id);
|
||||
this.logger.error(
|
||||
`Run #${run.id}: setup failed — ${String(err)}`,
|
||||
);
|
||||
@@ -272,6 +278,7 @@ export class ScenarioSchedulerService implements OnModuleInit {
|
||||
this.runSnippets.delete(runId);
|
||||
this.runEnvironments.delete(runId);
|
||||
this.runScenarioTimeouts.delete(runId);
|
||||
this.runScenarioIds.delete(runId);
|
||||
await this.maybePreserveSession(runId);
|
||||
}
|
||||
}
|
||||
@@ -349,6 +356,7 @@ export class ScenarioSchedulerService implements OnModuleInit {
|
||||
const creds = this.runCredentials.get(stepRun.runId);
|
||||
const snips = this.runSnippets.get(stepRun.runId);
|
||||
const env = this.runEnvironments.get(stepRun.runId);
|
||||
const scenarioId = this.runScenarioIds.get(stepRun.runId);
|
||||
const execCtx = new ExecContextBuilder()
|
||||
.page(page)
|
||||
.browser(context)
|
||||
@@ -358,6 +366,9 @@ export class ScenarioSchedulerService implements OnModuleInit {
|
||||
.credentials(creds)
|
||||
.environment(env)
|
||||
.snippets(snips)
|
||||
.scenarioId(scenarioId)
|
||||
.runId(stepRun.runId)
|
||||
.fileService(this.fileStorageService)
|
||||
.build();
|
||||
|
||||
const scenarioTimeoutSec = this.runScenarioTimeouts.get(stepRun.runId) ?? null;
|
||||
@@ -367,18 +378,26 @@ export class ScenarioSchedulerService implements OnModuleInit {
|
||||
ScenarioSchedulerService.DEFAULT_STEP_TIMEOUT_SEC;
|
||||
const stepTimeoutMs = stepTimeoutSec * 1000;
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(
|
||||
() => reject(new Error(`Step timed out after ${stepTimeoutSec}s`)),
|
||||
stepTimeoutMs,
|
||||
),
|
||||
);
|
||||
const { result: execOutput } = await Promise.race([
|
||||
this.codeExecutor.execute(execCtx),
|
||||
timeoutPromise,
|
||||
]);
|
||||
|
||||
await this.passStepRun(stepRun, null, execOutput);
|
||||
);
|
||||
});
|
||||
try {
|
||||
const { result: execOutput } = await Promise.race([
|
||||
this.codeExecutor.execute(execCtx),
|
||||
timeoutPromise,
|
||||
]);
|
||||
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) {
|
||||
const msg = (err as Error).message ?? String(err);
|
||||
this.logger.error(`StepRun #${stepRun.id} threw: ${msg}`);
|
||||
|
||||
@@ -10,9 +10,13 @@ import {
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
} from "@nestjs/common";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
||||
import { Response } from "express";
|
||||
import type { File as MulterFile } from "multer";
|
||||
import { stringify as yamlStringify } from "yaml";
|
||||
import { AddScenarioCredentialDto } from "./dto/add-scenario-credential.dto";
|
||||
import { CreateScenarioRunDto } from "./dto/create-scenario-run.dto";
|
||||
@@ -270,4 +274,78 @@ export class ScenarioController {
|
||||
) {
|
||||
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 { EnvironmentEntity } from "../environment/environment.entity";
|
||||
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 { SnippetModule } from "../snippet/snippet.module";
|
||||
import { ScenarioCredentialEntity } from "./scenario-credential.entity";
|
||||
@@ -27,11 +30,14 @@ import { ScenarioService } from "./scenario.service";
|
||||
ScenarioCredentialEntity,
|
||||
CredentialEntity,
|
||||
EnvironmentEntity,
|
||||
ScenarioFileEntity,
|
||||
ScenarioRunFileEntity,
|
||||
]),
|
||||
CodeExecutorModule,
|
||||
SessionModule,
|
||||
EnvironmentModule,
|
||||
SnippetModule,
|
||||
FileModule,
|
||||
],
|
||||
controllers: [ScenarioController],
|
||||
providers: [ScenarioService, ScenarioSchedulerService],
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import * as fs from "fs/promises";
|
||||
import * as path from "path";
|
||||
import { Like, Repository } from "typeorm";
|
||||
import {
|
||||
PaginatedResult,
|
||||
@@ -12,6 +14,9 @@ import {
|
||||
} from "../common/dto/pagination.dto";
|
||||
import { CredentialEntity } from "../credential/credential.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 { CreateScenarioStepDto } from "./dto/create-scenario-step.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 { ScenarioStepEntity } from "./scenario-step.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 type ScenarioOrderBy = "id" | "name" | "createdAt" | "updatedAt";
|
||||
@@ -53,6 +60,11 @@ export class ScenarioService {
|
||||
private readonly credentialRepo: Repository<CredentialEntity>,
|
||||
@InjectRepository(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 ─────────────────────────────────────────────────────────────
|
||||
@@ -63,7 +75,7 @@ export class ScenarioService {
|
||||
|
||||
async findAll(
|
||||
query: PaginationQueryDto<ScenarioOrderBy>,
|
||||
): Promise<PaginatedResult<ScenarioEntity>> {
|
||||
): Promise<PaginatedResult<ScenarioEntity & { lastRunStatus: string | null; lastRunAt: string | null }>> {
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const orderBy = query.orderBy ?? "id";
|
||||
@@ -73,7 +85,39 @@ export class ScenarioService {
|
||||
skip: (page - 1) * 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> {
|
||||
@@ -619,4 +663,262 @@ export class ScenarioService {
|
||||
}
|
||||
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 { LoggingInterceptor } from "../src/interceptors/logging.interceptor";
|
||||
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> {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
@@ -71,6 +74,9 @@ export async function buildTestApp(): Promise<INestApplication> {
|
||||
ScenarioCredentialEntity,
|
||||
CredentialEntity,
|
||||
SnippetEntity,
|
||||
FileEntity,
|
||||
ScenarioFileEntity,
|
||||
ScenarioRunFileEntity,
|
||||
],
|
||||
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 { getRepositoryToken } from "@nestjs/typeorm";
|
||||
import request from "supertest";
|
||||
import * as http from "http";
|
||||
import { Repository } from "typeorm";
|
||||
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.
|
||||
@@ -16,15 +24,65 @@ import { buildTestApp } from "./app.harness";
|
||||
*/
|
||||
describe("McpController", () => {
|
||||
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 () => {
|
||||
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 () => {
|
||||
if (testServer) {
|
||||
testServer.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. */
|
||||
function parseSse(text: string): Record<string, unknown> {
|
||||
const match = text.match(/^data:\s*(.+)$/m);
|
||||
@@ -47,6 +105,49 @@ describe("McpController", () => {
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /mcp — connectivity", () => {
|
||||
@@ -284,4 +385,462 @@ describe("McpController", () => {
|
||||
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")
|
||||
.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 ─────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user