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
This commit is contained in:
+1
-4
@@ -90,10 +90,7 @@ const SnippetDetailPage = lazy(() =>
|
||||
import('./pages/snippet/SnippetDetailPage').then((m) => ({ default: m.SnippetDetailPage })),
|
||||
);
|
||||
|
||||
const NAV: (
|
||||
| { path: string; labelKey: string; Icon: LucideIcon }
|
||||
| { separator: true }
|
||||
)[] = [
|
||||
const NAV: ({ path: string; labelKey: string; Icon: LucideIcon } | { separator: true })[] = [
|
||||
{ path: '/runs', labelKey: 'nav.runs', Icon: Activity },
|
||||
{ path: '/sessions', labelKey: 'nav.sessions', Icon: Monitor },
|
||||
{ separator: true },
|
||||
|
||||
+62
-11
@@ -2,6 +2,8 @@ import type {
|
||||
PaginatedResponse,
|
||||
Credential,
|
||||
Environment,
|
||||
FileMetadata,
|
||||
FileListResponse,
|
||||
ScenarioCredential,
|
||||
Session,
|
||||
Scenario,
|
||||
@@ -23,9 +25,14 @@ const BASE_URL = ((import.meta.env.VITE_API_URL as string | undefined) ?? '/api/
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
let res: Response;
|
||||
try {
|
||||
const headers = new Headers(init?.headers);
|
||||
if (!(init?.body instanceof FormData) && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
res = await fetch(`${BASE_URL}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json', ...init?.headers },
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = (err as Error).message || 'Network request failed';
|
||||
@@ -139,7 +146,10 @@ export const environments = {
|
||||
body: JSON.stringify({ name, description, data }),
|
||||
});
|
||||
},
|
||||
update(id: string, patch: Partial<Pick<Environment, 'name' | 'description' | 'data'>>): Promise<Environment> {
|
||||
update(
|
||||
id: string,
|
||||
patch: Partial<Pick<Environment, 'name' | 'description' | 'data'>>,
|
||||
): Promise<Environment> {
|
||||
return request(`/environments/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(patch),
|
||||
@@ -162,7 +172,12 @@ export const environments = {
|
||||
// ── Sessions ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const sessions = {
|
||||
list(page = 1, limit = 50, orderBy = 'id', orderDir: 'ASC' | 'DESC' = 'DESC'): Promise<PaginatedResponse<Session>> {
|
||||
list(
|
||||
page = 1,
|
||||
limit = 50,
|
||||
orderBy = 'id',
|
||||
orderDir: 'ASC' | 'DESC' = 'DESC',
|
||||
): Promise<PaginatedResponse<Session>> {
|
||||
return request(`/sessions?page=${page}&limit=${limit}&orderBy=${orderBy}&orderDir=${orderDir}`);
|
||||
},
|
||||
get(id: string): Promise<Session> {
|
||||
@@ -182,7 +197,9 @@ export const scenarios = {
|
||||
orderBy = 'updatedAt',
|
||||
orderDir: 'ASC' | 'DESC' = 'DESC',
|
||||
): Promise<PaginatedResponse<Scenario>> {
|
||||
return request(`/scenarios?page=${page}&limit=${limit}&orderBy=${orderBy}&orderDir=${orderDir}`);
|
||||
return request(
|
||||
`/scenarios?page=${page}&limit=${limit}&orderBy=${orderBy}&orderDir=${orderDir}`,
|
||||
);
|
||||
},
|
||||
get(id: string): Promise<Scenario & { steps: ScenarioStep[] }> {
|
||||
return request(`/scenarios/${id}`);
|
||||
@@ -193,7 +210,10 @@ export const scenarios = {
|
||||
body: JSON.stringify({ name, description, environmentId }),
|
||||
});
|
||||
},
|
||||
update(id: string, patch: Partial<Pick<Scenario, 'name' | 'description' | 'environmentId' | 'timeoutSeconds'>>): Promise<Scenario> {
|
||||
update(
|
||||
id: string,
|
||||
patch: Partial<Pick<Scenario, 'name' | 'description' | 'environmentId' | 'timeoutSeconds'>>,
|
||||
): Promise<Scenario> {
|
||||
return request(`/scenarios/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(patch),
|
||||
@@ -252,11 +272,7 @@ export const runs = {
|
||||
status?: string,
|
||||
orderBy = 'createdAt',
|
||||
orderDir: 'ASC' | 'DESC' = 'DESC',
|
||||
): Promise<
|
||||
PaginatedResponse<
|
||||
ScenarioRun & { scenario: { id: string; name: string } }
|
||||
>
|
||||
> {
|
||||
): Promise<PaginatedResponse<ScenarioRun & { scenario: { id: string; name: string } }>> {
|
||||
const q = new URLSearchParams({ page: String(page), limit: String(limit), orderBy, orderDir });
|
||||
if (status) q.set('status', status);
|
||||
return request(`/scenarios/runs?${q}`);
|
||||
@@ -268,7 +284,9 @@ export const runs = {
|
||||
orderBy = 'createdAt',
|
||||
orderDir: 'ASC' | 'DESC' = 'DESC',
|
||||
): Promise<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[] }>> {
|
||||
return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}&orderBy=${orderBy}&orderDir=${orderDir}`);
|
||||
return request(
|
||||
`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}&orderBy=${orderBy}&orderDir=${orderDir}`,
|
||||
);
|
||||
},
|
||||
get(scenarioId: string, runId: string, q?: string): Promise<ScenarioRunDetail> {
|
||||
const qs = q?.trim() ? `?q=${encodeURIComponent(q.trim())}` : '';
|
||||
@@ -329,3 +347,36 @@ export const steps = {
|
||||
return request(`/scenarios/${scenarioId}/steps/${stepId}`, { method: 'DELETE' });
|
||||
},
|
||||
};
|
||||
|
||||
// ── Scenario Files ────────────────────────────────────────────────────────
|
||||
|
||||
export const scenarioFiles = {
|
||||
upload(scenarioId: string, file: File, expiresAt?: Date): Promise<FileMetadata> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
if (expiresAt) form.append('expiresAt', expiresAt.toISOString());
|
||||
return request(`/scenarios/${scenarioId}/files`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
},
|
||||
list(scenarioId: string, limit = 20, offset = 0): Promise<FileListResponse> {
|
||||
return request(`/scenarios/${scenarioId}/files?limit=${limit}&offset=${offset}`);
|
||||
},
|
||||
/** Returns a URL that can be used as an <a href> for direct download. */
|
||||
contentUrl(scenarioId: string, fileId: string): string {
|
||||
return `${BASE_URL}/scenarios/${scenarioId}/files/${fileId}/content`;
|
||||
},
|
||||
};
|
||||
|
||||
// ── Run Files ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const runFiles = {
|
||||
list(scenarioId: string, runId: string, limit = 20, offset = 0): Promise<FileListResponse> {
|
||||
return request(`/scenarios/${scenarioId}/runs/${runId}/files?limit=${limit}&offset=${offset}`);
|
||||
},
|
||||
/** Returns a URL that can be used as an <a href> for direct download. */
|
||||
contentUrl(scenarioId: string, runId: string, fileId: string): string {
|
||||
return `${BASE_URL}/scenarios/${scenarioId}/runs/${runId}/files/${fileId}/content`;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -143,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;
|
||||
}
|
||||
|
||||
@@ -295,5 +295,21 @@
|
||||
"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",
|
||||
"empty": "No files uploaded.",
|
||||
"totalCount": "Total: {{count}} file(s)",
|
||||
"artifactsTitle": "Artifacts"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +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'));
|
||||
usePageTitle(
|
||||
credential
|
||||
? `${t('credentials.edit_title')} — ${credential.name}`
|
||||
: t('credentials.edit_title'),
|
||||
);
|
||||
const [data, setData] = useState('');
|
||||
const [nameError, setNameError] = useState('');
|
||||
const [dataError, setDataError] = useState('');
|
||||
|
||||
@@ -16,7 +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'));
|
||||
usePageTitle(
|
||||
env ? `${t('environments.edit_title')} — ${env.name}` : t('environments.edit_title'),
|
||||
);
|
||||
const [description, setDescription] = useState('');
|
||||
const [dataJson, setDataJson] = useState('{}');
|
||||
const [nameError, setNameError] = useState('');
|
||||
|
||||
@@ -47,12 +47,7 @@ export function AllRunsPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['runs', sortBy, sortDir, page, pageSize],
|
||||
queryFn: () =>
|
||||
runs.listAll(page, pageSize, undefined, sortBy, sortDir === 'asc' ? 'ASC' : 'DESC'),
|
||||
|
||||
@@ -1,8 +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 { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { runs } from '../../api';
|
||||
import { runs, runFiles } from '../../api';
|
||||
import type {
|
||||
Scenario,
|
||||
ScenarioRunDetail,
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
ScenarioRunStatus,
|
||||
RunStepStatus,
|
||||
LogLevel,
|
||||
FileMetadata,
|
||||
} from '../../api';
|
||||
import { scenarios } from '../../api';
|
||||
import {
|
||||
@@ -79,6 +80,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);
|
||||
@@ -103,6 +107,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);
|
||||
@@ -129,6 +149,7 @@ export function RunDetailPage() {
|
||||
if (cancelled) return;
|
||||
setScenario(sc);
|
||||
setRun(r);
|
||||
loadArtifacts();
|
||||
if (!FINAL.includes(r.status)) {
|
||||
setPolling(true);
|
||||
pollRef.current = setInterval(async () => {
|
||||
@@ -137,7 +158,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();
|
||||
}
|
||||
@@ -155,7 +179,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 },
|
||||
@@ -423,6 +447,52 @@ 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) => (
|
||||
<a href={runFiles.contentUrl(id!, runId!, f.id)} download={f.name}>
|
||||
{t('files.download')}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
] satisfies TableColumn<FileMetadata>[]
|
||||
}
|
||||
emptyMessage={t('files.empty')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,12 +4,7 @@ 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,
|
||||
@@ -109,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) => {
|
||||
@@ -194,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')}
|
||||
|
||||
@@ -22,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>) => {
|
||||
@@ -33,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) {
|
||||
|
||||
@@ -16,7 +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'));
|
||||
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>('');
|
||||
@@ -37,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>) => {
|
||||
|
||||
@@ -16,7 +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: '' }));
|
||||
usePageTitle(
|
||||
step ? t('steps.edit_title', { order: step.order }) : t('steps.edit_title', { order: '' }),
|
||||
);
|
||||
const [title, setTitle] = useState('');
|
||||
const [execCode, setExecCode] = useState('');
|
||||
const [timeoutSeconds, setTimeoutSeconds] = useState<string>('');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
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';
|
||||
@@ -12,12 +12,12 @@ import {
|
||||
GripVertical,
|
||||
ClipboardList,
|
||||
} from 'lucide-react';
|
||||
import { stringify as yamlStringify } from 'yaml';
|
||||
import {
|
||||
environments,
|
||||
scenarios,
|
||||
steps,
|
||||
scenarioCredentials,
|
||||
scenarioFiles,
|
||||
credentials as credentialsApi,
|
||||
} from '../../api';
|
||||
import type {
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
ScenarioCredential,
|
||||
Credential,
|
||||
Environment,
|
||||
FileMetadata,
|
||||
} from '../../api';
|
||||
import {
|
||||
Breadcrumbs,
|
||||
@@ -83,6 +84,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
|
||||
@@ -92,6 +114,7 @@ export function ScenarioDetailPage() {
|
||||
setScenarioCreds(s.scenarioCredentials ?? []);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
void loadFiles();
|
||||
credentialsApi
|
||||
.list(1, 200)
|
||||
.then((r) => setAllCredentials(r.data))
|
||||
@@ -102,7 +125,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(() => {
|
||||
@@ -178,6 +201,28 @@ export function ScenarioDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
@@ -427,17 +472,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}`); }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate(`/environments/${scenario.environment!.id}`);
|
||||
}}
|
||||
>
|
||||
{scenario.environment.name}
|
||||
</a>
|
||||
),
|
||||
}]
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
term: t('scenarios.field_created'),
|
||||
@@ -589,6 +639,88 @@ 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>
|
||||
|
||||
<Card>
|
||||
<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) => (
|
||||
<a href={scenarioFiles.contentUrl(id!, f.id)} download={f.name}>
|
||||
{t('files.download')}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
] satisfies TableColumn<FileMetadata>[]
|
||||
}
|
||||
emptyMessage={t('files.empty')}
|
||||
/>
|
||||
<p>{t('files.totalCount', { count: filesTotal })}</p>
|
||||
</Card>
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -76,11 +76,12 @@ export function ScenariosPage() {
|
||||
toast.error((err as Error).message);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [toast]);
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
load('updatedAt', 'desc', 1, 10);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [load]);
|
||||
|
||||
const handleSort = (key: string, dir: SortDirection) => {
|
||||
|
||||
@@ -36,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));
|
||||
}, []);
|
||||
|
||||
@@ -64,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'),
|
||||
@@ -117,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}`)}
|
||||
/>
|
||||
|
||||
@@ -16,7 +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'));
|
||||
usePageTitle(
|
||||
snippet ? `${t('snippets.edit_title')} — ${snippet.title}` : t('snippets.edit_title'),
|
||||
);
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import * as path from "path";
|
||||
import { Like, Repository } from "typeorm";
|
||||
import {
|
||||
PaginatedResult,
|
||||
@@ -755,7 +756,7 @@ export class ScenarioService {
|
||||
}
|
||||
|
||||
const file = link.file;
|
||||
const fullPath = this.fileStorageService.getAbsolutePath(file.filePath);
|
||||
const fullPath = path.resolve(this.fileStorageService.getAbsolutePath(file.filePath));
|
||||
|
||||
res.setHeader("Content-Type", file.mimeType);
|
||||
res.setHeader("Content-Disposition", `inline; filename="${file.originalName}"`);
|
||||
@@ -818,7 +819,7 @@ export class ScenarioService {
|
||||
}
|
||||
|
||||
const file = link.file;
|
||||
const fullPath = this.fileStorageService.getAbsolutePath(file.filePath);
|
||||
const fullPath = path.resolve(this.fileStorageService.getAbsolutePath(file.filePath));
|
||||
|
||||
res.setHeader("Content-Type", file.mimeType);
|
||||
res.setHeader("Content-Disposition", `inline; filename="${file.originalName}"`);
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { getRepositoryToken } from "@nestjs/typeorm";
|
||||
import * as http from "http";
|
||||
import * as fs from "fs/promises";
|
||||
import { Repository } from "typeorm";
|
||||
import { buildTestApp } from "./app.harness";
|
||||
import { FileStorageService } from "../src/file/file-storage.service";
|
||||
import { FileEntity } from "../src/file/file.entity";
|
||||
import { ScenarioFileEntity } from "../src/file/scenario-file.entity";
|
||||
import { ScenarioRunFileEntity } from "../src/file/scenario-run-file.entity";
|
||||
import { ScenarioEntity } from "../src/scenario/scenario.entity";
|
||||
import { ScenarioStepEntity } from "../src/scenario/scenario-step.entity";
|
||||
import { ScenarioRunEntity } from "../src/scenario/scenario-run.entity";
|
||||
import { ScenarioRunStepEntity } from "../src/scenario/scenario-run-step.entity";
|
||||
import { ScenarioSchedulerService } from "../src/scenario/scenario-scheduler.service";
|
||||
|
||||
describe("Exec Context File Methods Integration Tests", () => {
|
||||
let app: INestApplication;
|
||||
let fileStorageService: FileStorageService;
|
||||
let scheduler: ScenarioSchedulerService;
|
||||
let fileRepo: Repository<FileEntity>;
|
||||
let scenarioFileRepo: Repository<ScenarioFileEntity>;
|
||||
let scenarioRunFileRepo: Repository<ScenarioRunFileEntity>;
|
||||
let scenarioRepo: Repository<ScenarioEntity>;
|
||||
let stepRepo: Repository<ScenarioStepEntity>;
|
||||
let runRepo: Repository<ScenarioRunEntity>;
|
||||
let runStepRepo: Repository<ScenarioRunStepEntity>;
|
||||
let filesDir: string;
|
||||
let testServer: http.Server;
|
||||
let testServerUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildTestApp();
|
||||
fileStorageService = app.get(FileStorageService);
|
||||
scheduler = app.get(ScenarioSchedulerService);
|
||||
fileRepo = app.get<Repository<FileEntity>>(getRepositoryToken(FileEntity));
|
||||
scenarioFileRepo = app.get<Repository<ScenarioFileEntity>>(
|
||||
getRepositoryToken(ScenarioFileEntity),
|
||||
);
|
||||
scenarioRunFileRepo = app.get<Repository<ScenarioRunFileEntity>>(
|
||||
getRepositoryToken(ScenarioRunFileEntity),
|
||||
);
|
||||
scenarioRepo = app.get<Repository<ScenarioEntity>>(
|
||||
getRepositoryToken(ScenarioEntity),
|
||||
);
|
||||
stepRepo = app.get<Repository<ScenarioStepEntity>>(
|
||||
getRepositoryToken(ScenarioStepEntity),
|
||||
);
|
||||
runRepo = app.get<Repository<ScenarioRunEntity>>(
|
||||
getRepositoryToken(ScenarioRunEntity),
|
||||
);
|
||||
runStepRepo = app.get<Repository<ScenarioRunStepEntity>>(
|
||||
getRepositoryToken(ScenarioRunStepEntity),
|
||||
);
|
||||
|
||||
filesDir = fileStorageService.getAbsolutePath("");
|
||||
|
||||
// Start a test HTTP server for downloadFile tests
|
||||
testServer = await createTestServer();
|
||||
testServerUrl = `http://localhost:${(testServer.address() as any).port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Close the test server
|
||||
if (testServer) {
|
||||
testServer.close();
|
||||
}
|
||||
|
||||
// Clean up files directory
|
||||
try {
|
||||
await fs.rm(filesDir, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
// Ignore errors if directory doesn't exist
|
||||
}
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function createTestServer(): Promise<http.Server> {
|
||||
return new Promise((resolve) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === "/test-file.bin") {
|
||||
res.writeHead(200, { "Content-Type": "application/octet-stream" });
|
||||
res.end(Buffer.from("test downloaded file content"));
|
||||
} else if (req.url === "/test.pdf") {
|
||||
res.writeHead(200, { "Content-Type": "application/pdf" });
|
||||
res.end(Buffer.from("fake pdf content"));
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(0, "localhost", () => {
|
||||
resolve(server);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function createScenario(name = "test-scenario") {
|
||||
const scenario = await scenarioRepo.save(
|
||||
scenarioRepo.create({ name }),
|
||||
);
|
||||
return scenario;
|
||||
}
|
||||
|
||||
async function createStep(
|
||||
scenarioId: string,
|
||||
execCode: string,
|
||||
order = 0,
|
||||
): Promise<ScenarioStepEntity> {
|
||||
const step = await stepRepo.save(
|
||||
stepRepo.create({
|
||||
scenarioId,
|
||||
order,
|
||||
execCode,
|
||||
}),
|
||||
);
|
||||
return step;
|
||||
}
|
||||
|
||||
async function createRun(scenarioId: string) {
|
||||
const run = await runRepo.save(
|
||||
runRepo.create({ scenarioId, status: "pending" }),
|
||||
);
|
||||
return run;
|
||||
}
|
||||
|
||||
async function createRunStep(
|
||||
runId: string,
|
||||
scenarioStepId: string,
|
||||
order = 0,
|
||||
) {
|
||||
const runStep = await runStepRepo.save(
|
||||
runStepRepo.create({
|
||||
runId,
|
||||
scenarioStepId,
|
||||
order,
|
||||
status: "pending",
|
||||
}),
|
||||
);
|
||||
return runStep;
|
||||
}
|
||||
|
||||
async function waitForRunCompletion(
|
||||
runId: string,
|
||||
maxAttempts = 50,
|
||||
): Promise<ScenarioRunEntity> {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
// Run scheduler pickup once
|
||||
await scheduler.pickUpPendingRuns();
|
||||
|
||||
// Wait a bit for execution
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const run = await runRepo.findOneBy({ id: runId });
|
||||
if (run && (run.status === "pass" || run.status === "fail")) {
|
||||
return run;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Run ${runId} did not complete within timeout`);
|
||||
}
|
||||
|
||||
// ── context.getScenarioFiles() ─────────────────────────────────────────────
|
||||
|
||||
describe("context.getScenarioFiles()", () => {
|
||||
it("returns scenario files during a run", async () => {
|
||||
const scenario = await createScenario("get-scenario-files-test");
|
||||
|
||||
// Upload a file to the scenario
|
||||
const fileBuffer = Buffer.from("scenario file content");
|
||||
const uploadedFile = await fileStorageService.saveFile(
|
||||
fileBuffer,
|
||||
"test-file.txt",
|
||||
"text/plain",
|
||||
);
|
||||
|
||||
// Create scenario file mapping
|
||||
await scenarioFileRepo.save(
|
||||
scenarioFileRepo.create({
|
||||
scenarioId: scenario.id,
|
||||
fileId: uploadedFile.id,
|
||||
}),
|
||||
);
|
||||
|
||||
// Create a step that calls getScenarioFiles
|
||||
const step = await createStep(
|
||||
scenario.id,
|
||||
"const files = await context.getScenarioFiles(); return files;",
|
||||
);
|
||||
|
||||
// Create a run and run step
|
||||
const run = await createRun(scenario.id);
|
||||
await createRunStep(run.id, step.id);
|
||||
|
||||
// Wait for execution
|
||||
const completedRun = await waitForRunCompletion(run.id);
|
||||
|
||||
// Verify run completed successfully
|
||||
expect(completedRun.status).toBe("pass");
|
||||
|
||||
// Get the step run to check output
|
||||
const runStep = await runStepRepo.findOne({
|
||||
where: { runId: run.id },
|
||||
relations: ["scenarioStep"],
|
||||
});
|
||||
|
||||
expect(runStep).toBeDefined();
|
||||
expect(runStep!.output).toBeDefined();
|
||||
|
||||
const output = runStep!.output ? JSON.parse(runStep!.output) : null;
|
||||
expect(output).toBeDefined();
|
||||
expect(output.items).toBeDefined();
|
||||
expect(Array.isArray(output.items)).toBe(true);
|
||||
expect(output.total).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Find the uploaded file in the output
|
||||
const foundFile = output.items.find((f: any) => f.id === uploadedFile.id);
|
||||
expect(foundFile).toBeDefined();
|
||||
expect(foundFile.name).toBe("test-file.txt");
|
||||
expect(foundFile.mimeType).toBe("text/plain");
|
||||
expect(foundFile.size).toBe(fileBuffer.length);
|
||||
expect(foundFile.sha256).toBeDefined();
|
||||
expect(foundFile.path).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns empty list when no files are attached to scenario", async () => {
|
||||
const scenario = await createScenario("no-files-test");
|
||||
|
||||
// Create a step that calls getScenarioFiles
|
||||
const step = await createStep(
|
||||
scenario.id,
|
||||
"const files = await context.getScenarioFiles(); return files;",
|
||||
);
|
||||
|
||||
// Create a run and run step
|
||||
const run = await createRun(scenario.id);
|
||||
await createRunStep(run.id, step.id);
|
||||
|
||||
// Wait for execution
|
||||
const completedRun = await waitForRunCompletion(run.id);
|
||||
|
||||
expect(completedRun.status).toBe("pass");
|
||||
|
||||
const runStep = await runStepRepo.findOne({
|
||||
where: { runId: run.id },
|
||||
});
|
||||
|
||||
const output = runStep!.output ? JSON.parse(runStep!.output) : null;
|
||||
expect(output).toBeDefined();
|
||||
expect(output.items).toEqual([]);
|
||||
expect(output.total).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── context.downloadFile() ────────────────────────────────────────────────
|
||||
|
||||
describe("context.downloadFile()", () => {
|
||||
it("downloads a file and creates a run artifact", async () => {
|
||||
const scenario = await createScenario("download-file-test");
|
||||
const downloadUrl = `${testServerUrl}/test.pdf`;
|
||||
|
||||
// Create a step that downloads a file
|
||||
const step = await createStep(
|
||||
scenario.id,
|
||||
`const result = await context.downloadFile('${downloadUrl}'); return result;`,
|
||||
);
|
||||
|
||||
// Create a run and run step
|
||||
const run = await createRun(scenario.id);
|
||||
await createRunStep(run.id, step.id);
|
||||
|
||||
// Wait for execution
|
||||
const completedRun = await waitForRunCompletion(run.id);
|
||||
|
||||
expect(completedRun.status).toBe("pass");
|
||||
|
||||
// Get the step run to check output
|
||||
const runStep = await runStepRepo.findOne({
|
||||
where: { runId: run.id },
|
||||
relations: ["scenarioStep"],
|
||||
});
|
||||
|
||||
const output = runStep!.output ? JSON.parse(runStep!.output) : null;
|
||||
expect(output).toBeDefined();
|
||||
expect(output.id).toBeDefined();
|
||||
expect(output.name).toBeDefined();
|
||||
expect(output.mimeType).toBe("application/pdf");
|
||||
expect(output.size).toBeGreaterThan(0);
|
||||
expect(output.sha256).toBeDefined();
|
||||
expect(output.path).toBeDefined();
|
||||
|
||||
// Verify the file was created as a run artifact
|
||||
const runFileMapping = await scenarioRunFileRepo.findOne({
|
||||
where: { runId: run.id, fileId: output.id },
|
||||
});
|
||||
expect(runFileMapping).toBeDefined();
|
||||
|
||||
// Verify the file exists on disk
|
||||
const file = await fileRepo.findOneBy({ id: output.id });
|
||||
expect(file).toBeDefined();
|
||||
const fullPath = fileStorageService.getAbsolutePath(file!.filePath);
|
||||
await fs.access(fullPath);
|
||||
|
||||
// Verify file content matches what was downloaded
|
||||
const diskContent = await fs.readFile(fullPath);
|
||||
expect(diskContent).toEqual(Buffer.from("fake pdf content"));
|
||||
|
||||
// Verify the file has an expiresAt date set
|
||||
expect(file!.expiresAt).toBeDefined();
|
||||
});
|
||||
|
||||
it("downloads file with custom filename", async () => {
|
||||
const scenario = await createScenario("download-custom-filename-test");
|
||||
const downloadUrl = `${testServerUrl}/test-file.bin`;
|
||||
|
||||
// Create a step that downloads a file with a custom filename
|
||||
const step = await createStep(
|
||||
scenario.id,
|
||||
`const result = await context.downloadFile('${downloadUrl}', { filename: 'custom.dat' }); return result;`,
|
||||
);
|
||||
|
||||
// Create a run and run step
|
||||
const run = await createRun(scenario.id);
|
||||
await createRunStep(run.id, step.id);
|
||||
|
||||
// Wait for execution
|
||||
const completedRun = await waitForRunCompletion(run.id);
|
||||
|
||||
expect(completedRun.status).toBe("pass");
|
||||
|
||||
const runStep = await runStepRepo.findOne({
|
||||
where: { runId: run.id },
|
||||
});
|
||||
|
||||
const output = runStep!.output ? JSON.parse(runStep!.output) : null;
|
||||
expect(output.name).toBe("custom.dat");
|
||||
});
|
||||
|
||||
it("creates ScenarioRunFileEntity mapping after download", async () => {
|
||||
const scenario = await createScenario("download-mapping-test");
|
||||
const downloadUrl = `${testServerUrl}/test.pdf`;
|
||||
|
||||
// Create a step
|
||||
const step = await createStep(
|
||||
scenario.id,
|
||||
`const result = await context.downloadFile('${downloadUrl}'); return result;`,
|
||||
);
|
||||
|
||||
// Create a run and run step
|
||||
const run = await createRun(scenario.id);
|
||||
await createRunStep(run.id, step.id);
|
||||
|
||||
// Wait for execution
|
||||
await waitForRunCompletion(run.id);
|
||||
|
||||
// Verify that a ScenarioRunFileEntity was created
|
||||
const runFileCount = await scenarioRunFileRepo.count({
|
||||
where: { runId: run.id },
|
||||
});
|
||||
expect(runFileCount).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Verify the mapping links to the correct run
|
||||
const mappings = await scenarioRunFileRepo.find({
|
||||
where: { runId: run.id },
|
||||
});
|
||||
|
||||
expect(mappings.length).toBeGreaterThanOrEqual(1);
|
||||
for (const mapping of mappings) {
|
||||
expect(mapping.runId).toBe(run.id);
|
||||
expect(mapping.fileId).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("file returned by downloadFile has correct metadata", async () => {
|
||||
const scenario = await createScenario("download-metadata-test");
|
||||
const downloadUrl = `${testServerUrl}/test.pdf`;
|
||||
|
||||
// Create a step
|
||||
const step = await createStep(
|
||||
scenario.id,
|
||||
`const result = await context.downloadFile('${downloadUrl}');
|
||||
return {
|
||||
hasId: result.id !== undefined,
|
||||
hasName: result.name !== undefined,
|
||||
hasMimeType: result.mimeType !== undefined,
|
||||
hasSize: result.size > 0,
|
||||
hasSha256: result.sha256 !== undefined,
|
||||
hasPath: result.path !== undefined
|
||||
};`,
|
||||
);
|
||||
|
||||
// Create a run and run step
|
||||
const run = await createRun(scenario.id);
|
||||
await createRunStep(run.id, step.id);
|
||||
|
||||
// Wait for execution
|
||||
const completedRun = await waitForRunCompletion(run.id);
|
||||
|
||||
expect(completedRun.status).toBe("pass");
|
||||
|
||||
const runStep = await runStepRepo.findOne({
|
||||
where: { runId: run.id },
|
||||
});
|
||||
|
||||
const output = runStep!.output ? JSON.parse(runStep!.output) : null;
|
||||
expect(output).toBeDefined();
|
||||
expect(output.hasId).toBe(true);
|
||||
expect(output.hasName).toBe(true);
|
||||
expect(output.hasMimeType).toBe(true);
|
||||
expect(output.hasSize).toBe(true);
|
||||
expect(output.hasSha256).toBe(true);
|
||||
expect(output.hasPath).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,421 @@
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { getRepositoryToken } from "@nestjs/typeorm";
|
||||
import request from "supertest";
|
||||
import * as fs from "fs/promises";
|
||||
import { Repository } from "typeorm";
|
||||
import { buildTestApp } from "./app.harness";
|
||||
import { FileStorageService } from "../src/file/file-storage.service";
|
||||
import { FileEntity } from "../src/file/file.entity";
|
||||
import { ScenarioEntity } from "../src/scenario/scenario.entity";
|
||||
import { ScenarioRunEntity } from "../src/scenario/scenario-run.entity";
|
||||
|
||||
describe("File Controller Integration Tests", () => {
|
||||
let app: INestApplication;
|
||||
let fileStorageService: FileStorageService;
|
||||
let fileRepo: Repository<FileEntity>;
|
||||
let scenarioRepo: Repository<ScenarioEntity>;
|
||||
let runRepo: Repository<ScenarioRunEntity>;
|
||||
let filesDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildTestApp();
|
||||
fileStorageService = app.get(FileStorageService);
|
||||
fileRepo = app.get<Repository<FileEntity>>(getRepositoryToken(FileEntity));
|
||||
scenarioRepo = app.get<Repository<ScenarioEntity>>(
|
||||
getRepositoryToken(ScenarioEntity),
|
||||
);
|
||||
runRepo = app.get<Repository<ScenarioRunEntity>>(
|
||||
getRepositoryToken(ScenarioRunEntity),
|
||||
);
|
||||
|
||||
// Get the files directory from the service
|
||||
filesDir = fileStorageService.getAbsolutePath("");
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up files directory
|
||||
try {
|
||||
await fs.rm(filesDir, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
// Ignore errors if directory doesn't exist
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function createScenario(name = "test-scenario") {
|
||||
const scenario = await scenarioRepo.save(
|
||||
scenarioRepo.create({ name }),
|
||||
);
|
||||
return scenario;
|
||||
}
|
||||
|
||||
async function createRun(scenarioId: string) {
|
||||
const run = await runRepo.save(
|
||||
runRepo.create({ scenarioId, status: "pending" }),
|
||||
);
|
||||
return run;
|
||||
}
|
||||
|
||||
// ── POST /scenarios/:id/files ──────────────────────────────────────────────
|
||||
|
||||
describe("POST /scenarios/:id/files", () => {
|
||||
it("uploads a file and returns 201 with metadata", async () => {
|
||||
const scenario = await createScenario("upload-test");
|
||||
const fileBuffer = Buffer.from("test file content");
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(`/scenarios/${scenario.id}/files`)
|
||||
.attach("file", fileBuffer, "test.txt")
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.id).toBeDefined();
|
||||
expect(res.body.name).toBe("test.txt");
|
||||
expect(res.body.mimeType).toBe("text/plain");
|
||||
expect(res.body.size).toBe(fileBuffer.length);
|
||||
expect(res.body.createdAt).toBeDefined();
|
||||
|
||||
// Verify file exists on disk
|
||||
const savedFile = await fileRepo.findOneBy({ id: res.body.id });
|
||||
expect(savedFile).toBeDefined();
|
||||
const fullPath = fileStorageService.getAbsolutePath(savedFile!.filePath);
|
||||
await fs.access(fullPath);
|
||||
});
|
||||
|
||||
it("uploads a file with expiresAt and includes it in response", async () => {
|
||||
const scenario = await createScenario("upload-expires-test");
|
||||
const fileBuffer = Buffer.from("expires file");
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(`/scenarios/${scenario.id}/files`)
|
||||
.attach("file", fileBuffer, "expires.txt")
|
||||
.field("expiresAt", expiresAt)
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.expiresAt).toBeDefined();
|
||||
// Verify expiresAt is close to the one we sent
|
||||
const returnedExpires = new Date(res.body.expiresAt);
|
||||
const sentExpires = new Date(expiresAt);
|
||||
expect(Math.abs(returnedExpires.getTime() - sentExpires.getTime())).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it("returns 404 when scenario does not exist", async () => {
|
||||
const nonExistentId = "00000000-0000-0000-0000-000000000000";
|
||||
const fileBuffer = Buffer.from("test");
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/scenarios/${nonExistentId}/files`)
|
||||
.attach("file", fileBuffer, "test.txt")
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("returns 400 when no file is provided", async () => {
|
||||
const scenario = await createScenario("no-file-test");
|
||||
|
||||
// Send a request with no file
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(`/scenarios/${scenario.id}/files`)
|
||||
.send({});
|
||||
|
||||
// Should either be 400 or 422 depending on validation
|
||||
expect([400, 422]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /scenarios/:id/files ───────────────────────────────────────────────
|
||||
|
||||
describe("GET /scenarios/:id/files", () => {
|
||||
it("lists files attached to a scenario", async () => {
|
||||
const scenario = await createScenario("list-test");
|
||||
const fileBuffer = Buffer.from("list test file");
|
||||
|
||||
// Upload a file
|
||||
await request(app.getHttpServer())
|
||||
.post(`/scenarios/${scenario.id}/files`)
|
||||
.attach("file", fileBuffer, "listed.txt")
|
||||
.expect(201);
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${scenario.id}/files`)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.items).toBeDefined();
|
||||
expect(Array.isArray(res.body.items)).toBe(true);
|
||||
expect(res.body.items.length).toBeGreaterThanOrEqual(1);
|
||||
expect(res.body.total).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const file = res.body.items.find((f: any) => f.name === "listed.txt");
|
||||
expect(file).toBeDefined();
|
||||
expect(file.id).toBeDefined();
|
||||
expect(file.mimeType).toBe("text/plain");
|
||||
expect(file.size).toBe(fileBuffer.length);
|
||||
});
|
||||
|
||||
it("respects limit and offset pagination", async () => {
|
||||
const scenario = await createScenario("pagination-test");
|
||||
|
||||
// Upload 3 files
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await request(app.getHttpServer())
|
||||
.post(`/scenarios/${scenario.id}/files`)
|
||||
.attach("file", Buffer.from(`file ${i}`), `file${i}.txt`)
|
||||
.expect(201);
|
||||
}
|
||||
|
||||
// List with limit=2, offset=0
|
||||
const page1 = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${scenario.id}/files`)
|
||||
.query({ limit: 2, offset: 0 })
|
||||
.expect(200);
|
||||
|
||||
expect(page1.body.items).toHaveLength(2);
|
||||
expect(page1.body.total).toBe(3);
|
||||
|
||||
// List with limit=2, offset=2
|
||||
const page2 = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${scenario.id}/files`)
|
||||
.query({ limit: 2, offset: 2 })
|
||||
.expect(200);
|
||||
|
||||
expect(page2.body.items.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("returns 404 when scenario does not exist", async () => {
|
||||
const nonExistentId = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get(`/scenarios/${nonExistentId}/files`)
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /scenarios/:id/files/:fileId/content ───────────────────────────────
|
||||
|
||||
describe("GET /scenarios/:id/files/:fileId/content", () => {
|
||||
it("downloads file content with correct bytes and Content-Type header", async () => {
|
||||
const scenario = await createScenario("download-test");
|
||||
const fileBuffer = Buffer.from("download test content");
|
||||
|
||||
// Upload the file
|
||||
const uploadRes = await request(app.getHttpServer())
|
||||
.post(`/scenarios/${scenario.id}/files`)
|
||||
.attach("file", fileBuffer, "download.txt")
|
||||
.expect(201);
|
||||
|
||||
const fileId = uploadRes.body.id;
|
||||
|
||||
// Download content
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${scenario.id}/files/${fileId}/content`)
|
||||
.buffer(true)
|
||||
.parse((res, callback) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on("end", () => callback(null, Buffer.concat(chunks)));
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
// The response body should contain the file bytes
|
||||
expect((res.body as Buffer).toString()).toBe(fileBuffer.toString());
|
||||
expect(res.headers["content-type"]).toContain("text/plain");
|
||||
});
|
||||
|
||||
it("returns 404 when file does not exist", async () => {
|
||||
const scenario = await createScenario("not-found-test");
|
||||
const nonExistentFileId = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get(`/scenarios/${scenario.id}/files/${nonExistentFileId}/content`)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("returns 404 when file is not linked to scenario", async () => {
|
||||
const scenario1 = await createScenario("scenario-1");
|
||||
const scenario2 = await createScenario("scenario-2");
|
||||
|
||||
// Upload file to scenario1
|
||||
const uploadRes = await request(app.getHttpServer())
|
||||
.post(`/scenarios/${scenario1.id}/files`)
|
||||
.attach("file", Buffer.from("content"), "file.txt")
|
||||
.expect(201);
|
||||
|
||||
const fileId = uploadRes.body.id;
|
||||
|
||||
// Try to access from scenario2
|
||||
await request(app.getHttpServer())
|
||||
.get(`/scenarios/${scenario2.id}/files/${fileId}/content`)
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /scenarios/:id/runs/:runId/files ───────────────────────────────────
|
||||
|
||||
describe("GET /scenarios/:id/runs/:runId/files", () => {
|
||||
it("lists files created during a run", async () => {
|
||||
const scenario = await createScenario("run-files-list-test");
|
||||
const run = await createRun(scenario.id);
|
||||
|
||||
// Create a run artifact file
|
||||
const fileBuffer = Buffer.from("run artifact content");
|
||||
const savedFile = await fileStorageService.createAndSaveRunArtifact(
|
||||
run.id,
|
||||
fileBuffer,
|
||||
"artifact.txt",
|
||||
"text/plain",
|
||||
);
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${scenario.id}/runs/${run.id}/files`)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.items).toBeDefined();
|
||||
expect(Array.isArray(res.body.items)).toBe(true);
|
||||
expect(res.body.total).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const artifact = res.body.items.find((f: any) => f.id === savedFile.id);
|
||||
expect(artifact).toBeDefined();
|
||||
expect(artifact.name).toBe("artifact.txt");
|
||||
expect(artifact.size).toBe(fileBuffer.length);
|
||||
});
|
||||
|
||||
it("returns 404 when run does not exist", async () => {
|
||||
const scenario = await createScenario("run-not-found-test");
|
||||
const nonExistentRunId = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get(`/scenarios/${scenario.id}/runs/${nonExistentRunId}/files`)
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /scenarios/:id/runs/:runId/files/:fileId/content ──────────────────
|
||||
|
||||
describe("GET /scenarios/:id/runs/:runId/files/:fileId/content", () => {
|
||||
it("downloads run artifact content with correct bytes", async () => {
|
||||
const scenario = await createScenario("run-artifact-download-test");
|
||||
const run = await createRun(scenario.id);
|
||||
|
||||
// Create a run artifact
|
||||
const fileBuffer = Buffer.from("run artifact bytes");
|
||||
const savedFile = await fileStorageService.createAndSaveRunArtifact(
|
||||
run.id,
|
||||
fileBuffer,
|
||||
"run-artifact.bin",
|
||||
"application/octet-stream",
|
||||
);
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(
|
||||
`/scenarios/${scenario.id}/runs/${run.id}/files/${savedFile.id}/content`,
|
||||
)
|
||||
.buffer(true)
|
||||
.parse((res, callback) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on("end", () => callback(null, Buffer.concat(chunks)));
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect((res.body as Buffer).toString()).toBe(fileBuffer.toString());
|
||||
expect(res.headers["content-type"]).toContain("application/octet-stream");
|
||||
});
|
||||
|
||||
it("returns 404 when artifact file does not exist", async () => {
|
||||
const scenario = await createScenario("artifact-not-found");
|
||||
const run = await createRun(scenario.id);
|
||||
const nonExistentFileId = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get(
|
||||
`/scenarios/${scenario.id}/runs/${run.id}/files/${nonExistentFileId}/content`,
|
||||
)
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── File cleanup ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("File cleanup", () => {
|
||||
it("removes expired files from disk and database", async () => {
|
||||
const scenario = await createScenario("cleanup-test");
|
||||
|
||||
// Upload a file with a past expiration date
|
||||
const fileBuffer = Buffer.from("to be cleaned up");
|
||||
const savedFile = await fileStorageService.saveFile(
|
||||
fileBuffer,
|
||||
"cleanup.txt",
|
||||
"text/plain",
|
||||
new Date(Date.now() - 60 * 60 * 1000), // 1 hour ago
|
||||
);
|
||||
|
||||
// Verify file exists
|
||||
const fullPath = fileStorageService.getAbsolutePath(savedFile.filePath);
|
||||
await fs.access(fullPath);
|
||||
|
||||
// Run cleanup
|
||||
await fileStorageService.cleanupExpiredFiles();
|
||||
|
||||
// Verify file is deleted from database
|
||||
const deleted = await fileRepo.findOneBy({ id: savedFile.id });
|
||||
expect(deleted).toBeNull();
|
||||
|
||||
// Verify file is deleted from disk
|
||||
try {
|
||||
await fs.access(fullPath);
|
||||
fail("File should have been deleted");
|
||||
} catch (err) {
|
||||
// Expected: file not found
|
||||
expect((err as any).code).toBe("ENOENT");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not delete files with future expiration dates", async () => {
|
||||
const scenario = await createScenario("no-cleanup-test");
|
||||
|
||||
// Upload a file with a future expiration date
|
||||
const fileBuffer = Buffer.from("should not be cleaned");
|
||||
const futureDate = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
const savedFile = await fileStorageService.saveFile(
|
||||
fileBuffer,
|
||||
"keep.txt",
|
||||
"text/plain",
|
||||
futureDate,
|
||||
);
|
||||
|
||||
// Run cleanup
|
||||
await fileStorageService.cleanupExpiredFiles();
|
||||
|
||||
// Verify file still exists in database
|
||||
const kept = await fileRepo.findOneBy({ id: savedFile.id });
|
||||
expect(kept).toBeDefined();
|
||||
|
||||
// Verify file still exists on disk
|
||||
const fullPath = fileStorageService.getAbsolutePath(savedFile.filePath);
|
||||
await fs.access(fullPath);
|
||||
});
|
||||
|
||||
it("does not delete files without expiration dates", async () => {
|
||||
const scenario = await createScenario("no-expire-test");
|
||||
|
||||
// Upload a file without expiration
|
||||
const fileBuffer = Buffer.from("permanent file");
|
||||
const savedFile = await fileStorageService.saveFile(
|
||||
fileBuffer,
|
||||
"permanent.txt",
|
||||
"text/plain",
|
||||
);
|
||||
|
||||
// Run cleanup
|
||||
await fileStorageService.cleanupExpiredFiles();
|
||||
|
||||
// Verify file still exists
|
||||
const kept = await fileRepo.findOneBy({ id: savedFile.id });
|
||||
expect(kept).toBeDefined();
|
||||
|
||||
const fullPath = fileStorageService.getAbsolutePath(savedFile.filePath);
|
||||
await fs.access(fullPath);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user