Compare commits

..
10 Commits
Author SHA1 Message Date
ars9 502a3e4f2c 1.7.1 2026-04-21 18:09:49 +03:00
ars9 b57b55f3e1 feat(mcp): add file tools for scenarios and runs
- expose scenario file upload, listing, and content retrieval over MCP\n- expose run artifact listing and content retrieval with end-to-end coverage
2026-04-21 18:00:41 +03:00
ars9 06c662042b doc(changelog): add changelog 2026-04-21 17:09:05 +03:00
ars9 461669b3fb 1.7.0 2026-04-21 16:49:46 +03:00
ars9 9580a7b2df fix(files): expose expiry and disable expired downloads
- return expiresAt and createdAt for scenario and run file metadata\n- keep download actions visible while preventing expired file access
2026-04-21 16:46:33 +03:00
ars9 86f9ac5603 feat(files): add file management UI for scenarios and runs
- add scenario upload and download UI with typed file API helpers
- show run artifacts on run detail pages after polling completes
- handle multipart uploads and absolute file paths for downloads
2026-04-21 15:58:04 +03:00
ars9 6a91ce30e3 feat(files): add file storage subsystem with scenario and run artifact support
- add FileEntity, ScenarioFileEntity, ScenarioRunFileEntity with UUID-sharded disk tree
- FileStorageService: write/sha256/expiry, paginated listing, dynamic cleanup cron via SchedulerRegistry
- expose getScenarioFiles() and downloadFile() on ScriptContext for use in exec code
- wire scenarioId, runId, fileService into ExecContextBuilder and scenario scheduler
- add file upload and listing endpoints to ScenarioController
- add FILES_DIR, FILE_RUN_EXPIRATION_DAYS, FILE_CLEANUP_CRON to AppConfig
2026-04-21 13:01:09 +03:00
ars9 aa3bae9020 feat(client): set dynamic page title based on content
- add usePageTitle hook that writes '<title> | Liqa' to document.title
- list pages use section name; detail pages use entity name once loaded
- create/edit pages use static action title, edit includes entity name
2026-04-20 16:32:16 +03:00
ars9 ba46789226 feat(mcp): add snippet CRUD tools to MCP server
- register list_snippets, get_snippet, create_snippet, update_snippet, delete_snippet
- inject SnippetService into McpService and import SnippetModule into McpModule
- cover all five tools with integration tests including error cases
2026-04-20 16:21:52 +03:00
ars9 4e494ce4fd feat(scenarios): show last run status and timestamp on scenarios list
- extend findAll to join latest run per scenario via correlated subquery
- return lastRunStatus and lastRunAt fields in GET /scenarios response
- add Last Run column with badge and timestamp to ScenariosPage
- add integration tests for null and populated lastRunStatus
2026-04-17 15:13:28 +03:00
55 changed files with 3490 additions and 105 deletions
+36
View File
@@ -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
View File
@@ -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
View File
@@ -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`;
},
};
+19
View File
@@ -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;
}
+17
View File
@@ -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]);
}
+18
View File
@@ -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);
+3 -6
View File
@@ -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'),
+105 -5
View File
@@ -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>
+19 -18
View File
@@ -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>('');
+172 -14
View File
@@ -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>
</>
)}
+49 -11
View File
@@ -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);
+16 -3
View File
@@ -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);
+4 -15
View File
@@ -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
View File
@@ -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.
+2 -2
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "liqa",
"version": "1.6.1",
"version": "1.7.1",
"private": true,
"workspaces": [
"server",
+8
View File
@@ -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)
+77
View File
@@ -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();
});
}
+10
View File
@@ -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 {
+281
View File
@@ -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),
};
}
}
+28
View File
@@ -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;
}
+13
View File
@@ -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 {}
+33
View File
@@ -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;
}
+15
View File
@@ -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");
+2
View File
@@ -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],
+425
View File
@@ -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);
}
}
+6
View File
@@ -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],
+304 -2
View File
@@ -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,
};
}
}
+6
View File
@@ -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,
}),
+419
View File
@@ -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);
});
});
});
+421
View File
@@ -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);
});
});
});
+559
View File
@@ -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);
});
});
});
+29
View File
@@ -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 ─────────────────────────────────────────────────────