Compare commits
18
Commits
8c6158e8e3
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2aad413852 | ||
|
|
47529b2c1e | ||
|
|
69d16c9a94 | ||
|
|
85a87b5fb2 | ||
|
|
ce85aa02f1 | ||
|
|
f1aac987db | ||
|
|
715afd2151 | ||
|
|
622dfdbfb0 | ||
|
|
502a3e4f2c | ||
|
|
b57b55f3e1 | ||
|
|
06c662042b | ||
|
|
461669b3fb | ||
|
|
9580a7b2df | ||
|
|
86f9ac5603 | ||
|
|
6a91ce30e3 | ||
|
|
aa3bae9020 | ||
|
|
ba46789226 | ||
|
|
4e494ce4fd |
+103
@@ -0,0 +1,103 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## 1.11.0 - 2026-09-15
|
||||
|
||||
Changes since 1.10.1:
|
||||
|
||||
### Added
|
||||
|
||||
- Added export of a scenario as a standalone, plug-and-play Playwright e2e test project (zip archive with `package.json`, `playwright.config.ts`, `test.spec.ts`, `README.md`, `.env`, `credentials.json`, `snippets/*.js`, and any attached scenario files) that runs entirely independently of the liqa backend. Available as a "Export e2e test" button on the scenario detail page, `GET /scenarios/:id/export-e2e`, and the `export_e2e_test` MCP tool.
|
||||
- The exported test only bundles the credentials, snippets, and attached files actually referenced (directly or transitively) by the scenario's steps.
|
||||
- Added a "Used Snippets" section on the scenario detail page listing the snippets referenced by the scenario's steps, linking to each snippet's detail page. Backed by `GET /scenarios/:id/used-snippets`.
|
||||
|
||||
## 1.10.1 - 2026-09-15
|
||||
|
||||
Changes since 1.10.0:
|
||||
|
||||
### Added
|
||||
|
||||
- `context.runSnippet()` now logs "Snippet started"/"finished"/"failed" entries, matching the automatic step start/pass/fail logging.
|
||||
|
||||
### Changed
|
||||
|
||||
- Reworked the Markdown log export format: each log entry is now its own `## [level] timestamp` section with the message as its body, instead of a single large table.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Run logs could persist out of call order (e.g. a snippet's "started" log appearing after its "finished" log) because writes were fire-and-forget and raced against each other. Log entries now carry a per-run sequence number assigned in call order, and queries/exports sort by it.
|
||||
|
||||
## 1.10.0 - 2026-09-15
|
||||
|
||||
Changes since 1.9.1:
|
||||
|
||||
### Added
|
||||
|
||||
- Added a "Download logs" button on the scenario run detail page: opens a modal to choose Markdown (default) or CSV, then downloads the run's logs in that format.
|
||||
- Added `GET /scenarios/:id/run/:runId/logs/export?format=csv|md` backend endpoint to export a run's logs.
|
||||
|
||||
## 1.9.1 - 2026-09-15
|
||||
|
||||
Changes since 1.9.0:
|
||||
|
||||
### Added
|
||||
|
||||
- Scenario run logs now include an automatic entry when a step starts and when it finishes (pass or fail), so step boundaries are visible in run logs even for steps that don't call `context.log`/`context.assert` themselves.
|
||||
|
||||
## 1.9.0 - 2026-09-15
|
||||
|
||||
Changes since 1.8.0:
|
||||
|
||||
### Added
|
||||
|
||||
- Added `context.assert(fn, description)` for scenario/snippet script steps: runs the given function, requires it to return `true`, logs the outcome (`log` on success, `error` on failure or exception), and throws to stop the step on failure.
|
||||
|
||||
### Changed
|
||||
|
||||
- Documented `context.assert` in `docs/scenario.md`.
|
||||
|
||||
## 1.8.0 - 2026-09-15
|
||||
|
||||
Changes since 1.7.1:
|
||||
|
||||
### Added
|
||||
|
||||
- `context.downloadFile()` now accepts `data:` URIs, so scenario steps can save in-script generated content (e.g. a credential JWT) as a run artifact without a network fetch.
|
||||
|
||||
### Changed
|
||||
|
||||
- Documented the full scenario step `context` file API (`getScenarioFiles`, `downloadFile`) in `docs/scenario.md`.
|
||||
|
||||
## 1.7.1 - 2026-04-21
|
||||
|
||||
Changes since 1.7.0:
|
||||
|
||||
### Added
|
||||
|
||||
- Added MCP tools to upload, list, and fetch scenario files using base64 content payloads.
|
||||
- Added MCP tools to list run artifacts and fetch run artifact content with metadata.
|
||||
|
||||
### Changed
|
||||
|
||||
- Documented MCP binary content handling for scenario files and run artifacts.
|
||||
|
||||
## 1.7.0 - 2026-04-21
|
||||
|
||||
Changes since 1.6.1:
|
||||
|
||||
### Added
|
||||
|
||||
- Added scenario file storage and run artifact support on the backend.
|
||||
- Added file management UI for scenarios and run details, including artifact lists.
|
||||
- Added snippet CRUD tools to the MCP server.
|
||||
|
||||
### Changed
|
||||
|
||||
- Added dynamic page titles based on the active client content.
|
||||
- Extended the scenarios list to show the latest run status and timestamp.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Exposed file expiry metadata in scenario and run file responses.
|
||||
- Disabled expired download actions in the UI while keeping the action visible.
|
||||
+1
-4
@@ -90,10 +90,7 @@ const SnippetDetailPage = lazy(() =>
|
||||
import('./pages/snippet/SnippetDetailPage').then((m) => ({ default: m.SnippetDetailPage })),
|
||||
);
|
||||
|
||||
const NAV: (
|
||||
| { path: string; labelKey: string; Icon: LucideIcon }
|
||||
| { separator: true }
|
||||
)[] = [
|
||||
const NAV: ({ path: string; labelKey: string; Icon: LucideIcon } | { separator: true })[] = [
|
||||
{ path: '/runs', labelKey: 'nav.runs', Icon: Activity },
|
||||
{ path: '/sessions', labelKey: 'nav.sessions', Icon: Monitor },
|
||||
{ separator: true },
|
||||
|
||||
+71
-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),
|
||||
@@ -241,6 +261,12 @@ export const scenarios = {
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
exportE2eTestUrl(id: string): string {
|
||||
return `${BASE_URL}/scenarios/${id}/export-e2e`;
|
||||
},
|
||||
usedSnippets(id: string): Promise<Snippet[]> {
|
||||
return request(`/scenarios/${id}/used-snippets`);
|
||||
},
|
||||
};
|
||||
|
||||
// ── Runs ─────────────────────────────────────────────────────────────────────
|
||||
@@ -252,11 +278,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,12 +290,17 @@ 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())}` : '';
|
||||
return request(`/scenarios/${scenarioId}/run/${runId}${qs}`);
|
||||
},
|
||||
exportLogs(scenarioId: string, runId: string, format: 'csv' | 'md'): Promise<string> {
|
||||
return request(`/scenarios/${scenarioId}/run/${runId}/logs/export?format=${format}`);
|
||||
},
|
||||
};
|
||||
|
||||
// ── Scenario Credentials ──────────────────────────────────────────────────────
|
||||
@@ -329,3 +356,36 @@ export const steps = {
|
||||
return request(`/scenarios/${scenarioId}/steps/${stepId}`, { method: 'DELETE' });
|
||||
},
|
||||
};
|
||||
|
||||
// ── Scenario Files ────────────────────────────────────────────────────────
|
||||
|
||||
export const scenarioFiles = {
|
||||
upload(scenarioId: string, file: File, expiresAt?: Date): Promise<FileMetadata> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
if (expiresAt) form.append('expiresAt', expiresAt.toISOString());
|
||||
return request(`/scenarios/${scenarioId}/files`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
},
|
||||
list(scenarioId: string, limit = 20, offset = 0): Promise<FileListResponse> {
|
||||
return request(`/scenarios/${scenarioId}/files?limit=${limit}&offset=${offset}`);
|
||||
},
|
||||
/** Returns a URL that can be used as an <a href> for direct download. */
|
||||
contentUrl(scenarioId: string, fileId: string): string {
|
||||
return `${BASE_URL}/scenarios/${scenarioId}/files/${fileId}/content`;
|
||||
},
|
||||
};
|
||||
|
||||
// ── Run Files ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const runFiles = {
|
||||
list(scenarioId: string, runId: string, limit = 20, offset = 0): Promise<FileListResponse> {
|
||||
return request(`/scenarios/${scenarioId}/runs/${runId}/files?limit=${limit}&offset=${offset}`);
|
||||
},
|
||||
/** Returns a URL that can be used as an <a href> for direct download. */
|
||||
contentUrl(scenarioId: string, runId: string, fileId: string): string {
|
||||
return `${BASE_URL}/scenarios/${scenarioId}/runs/${runId}/files/${fileId}/content`;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -90,6 +90,8 @@ export interface Scenario {
|
||||
environment?: Pick<Environment, 'id' | 'name'>;
|
||||
steps?: ScenarioStep[];
|
||||
scenarioCredentials?: ScenarioCredential[];
|
||||
lastRunStatus?: ScenarioRunStatus | null;
|
||||
lastRunAt?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -141,3 +143,20 @@ export interface ScenarioRunDetail extends ScenarioRun {
|
||||
stepRuns: ScenarioRunStep[];
|
||||
logs: ScenarioRunLog[];
|
||||
}
|
||||
|
||||
// ── Files ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface FileMetadata {
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
sha256: string;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface FileListResponse {
|
||||
items: FileMetadata[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted, #888);
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.radioRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.radioRow input[type='radio'] {
|
||||
cursor: pointer;
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Download } from 'lucide-react';
|
||||
import { Modal, Button } from '../../ui';
|
||||
import styles from './ExportLogsModal.module.css';
|
||||
|
||||
export type LogExportFormat = 'md' | 'csv';
|
||||
|
||||
export interface ExportLogsModalProps {
|
||||
open: boolean;
|
||||
format: LogExportFormat;
|
||||
onFormatChange: (format: LogExportFormat) => void;
|
||||
onClose: () => void;
|
||||
onExport: () => void;
|
||||
isExporting: boolean;
|
||||
}
|
||||
|
||||
export function ExportLogsModal({
|
||||
open,
|
||||
format,
|
||||
onFormatChange,
|
||||
onClose,
|
||||
onExport,
|
||||
isExporting,
|
||||
}: ExportLogsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={t('runs.export_logs_modal_title')}
|
||||
onClose={() => !isExporting && onClose()}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose} disabled={isExporting}>
|
||||
<X size={14} />
|
||||
{t('common.button_cancel')}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={onExport} disabled={isExporting}>
|
||||
<Download size={14} />
|
||||
{t('runs.action_export')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={styles.section}>
|
||||
<h4 className={styles.sectionTitle}>{t('runs.export_logs_format')}</h4>
|
||||
<label className={styles.radioRow}>
|
||||
<input
|
||||
type="radio"
|
||||
name="log-export-format"
|
||||
checked={format === 'md'}
|
||||
onChange={() => onFormatChange('md')}
|
||||
/>
|
||||
<span>{t('runs.export_logs_format_md')}</span>
|
||||
</label>
|
||||
<label className={styles.radioRow}>
|
||||
<input
|
||||
type="radio"
|
||||
name="log-export-format"
|
||||
checked={format === 'csv'}
|
||||
onChange={() => onFormatChange('csv')}
|
||||
/>
|
||||
<span>{t('runs.export_logs_format_csv')}</span>
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -4,3 +4,5 @@ export { ExportScenarioModal } from './ExportScenarioModal';
|
||||
export type { ExportScenarioModalProps } from './ExportScenarioModal';
|
||||
export { RunScenarioModal } from './RunScenarioModal';
|
||||
export type { RunScenarioModalProps } from './RunScenarioModal';
|
||||
export { ExportLogsModal } from './ExportLogsModal';
|
||||
export type { ExportLogsModalProps, LogExportFormat } from './ExportLogsModal';
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const APP_NAME = 'Liqa';
|
||||
|
||||
/**
|
||||
* Sets document.title to "<title> | Liqa" for the current page.
|
||||
* Pass undefined or empty string to fall back to just "Liqa".
|
||||
*/
|
||||
export function usePageTitle(title: string | null | undefined): void {
|
||||
useEffect(() => {
|
||||
const prev = document.title;
|
||||
document.title = title ? `${title} | ${APP_NAME}` : APP_NAME;
|
||||
return () => {
|
||||
document.title = prev;
|
||||
};
|
||||
}, [title]);
|
||||
}
|
||||
@@ -127,6 +127,7 @@
|
||||
"col_id": "ID",
|
||||
"col_name": "Name",
|
||||
"col_description": "Description",
|
||||
"col_last_run": "Last Run",
|
||||
"col_updated": "Updated",
|
||||
"empty": "No scenarios yet.",
|
||||
"loading": "Loading…",
|
||||
@@ -148,6 +149,7 @@
|
||||
"action_edit": "Edit",
|
||||
"action_runs": "Runs",
|
||||
"action_export": "Export",
|
||||
"action_export_e2e": "Export e2e test",
|
||||
"action_import": "Import",
|
||||
"import_error": "Failed to import scenario",
|
||||
"action_save": "Create",
|
||||
@@ -164,6 +166,7 @@
|
||||
"form_name_placeholder": "e.g. Login flow",
|
||||
"form_name_required": "Name is required",
|
||||
"credentials_heading": "Credentials",
|
||||
"used_snippets_heading": "Used Snippets",
|
||||
"cred_empty": "No credentials assigned.",
|
||||
"cred_col_alias": "Alias",
|
||||
"cred_col_name": "Credential",
|
||||
@@ -256,7 +259,13 @@
|
||||
"step_description": "Result",
|
||||
"log_level": "Level",
|
||||
"log_message": "Message",
|
||||
"log_time": "Time"
|
||||
"log_time": "Time",
|
||||
"action_download_logs": "Download logs",
|
||||
"export_logs_modal_title": "Export logs",
|
||||
"export_logs_format": "Format",
|
||||
"export_logs_format_md": "Markdown (.md)",
|
||||
"export_logs_format_csv": "CSV (.csv)",
|
||||
"action_export": "Export"
|
||||
},
|
||||
"snippets": {
|
||||
"title": "Snippets",
|
||||
@@ -294,5 +303,22 @@
|
||||
"action_export": "Export",
|
||||
"action_import": "Import",
|
||||
"import_error": "Failed to import snippet"
|
||||
},
|
||||
"files": {
|
||||
"title": "Files",
|
||||
"upload": "Upload",
|
||||
"uploadTitle": "Upload File",
|
||||
"uploadSuccess": "File uploaded successfully",
|
||||
"name": "Name",
|
||||
"mimeType": "MIME Type",
|
||||
"size": "Size",
|
||||
"expiresAt": "Expires At",
|
||||
"expiresAtLabel": "Expires At (optional)",
|
||||
"createdAt": "Created",
|
||||
"download": "Download",
|
||||
"expiredTooltip": "File is expired",
|
||||
"empty": "No files uploaded.",
|
||||
"totalCount": "Total: {{count}} file(s)",
|
||||
"artifactsTitle": "Artifacts"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, SubmitEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { KeyRound, X, Save } from 'lucide-react';
|
||||
import { credentials } from '../../api';
|
||||
import { Breadcrumbs, Button, Card, Input, CodeEditor, useToast } from '../../ui';
|
||||
@@ -8,6 +9,7 @@ import styles from '../Page.module.css';
|
||||
|
||||
export function CreateCredentialPage() {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t('credentials.create_title'));
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { Upload, Pencil, Trash2, KeyRound } from 'lucide-react';
|
||||
import { CodeBlock } from '../../ui';
|
||||
import { stringify as yamlStringify } from 'yaml';
|
||||
@@ -16,6 +17,7 @@ export function CredentialDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const [credential, setCredential] = useState<Credential | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
usePageTitle(credential?.name ?? null);
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { Settings, Pencil, Trash2, Plus, Download, KeyRound } from 'lucide-react';
|
||||
import { parse as yamlParse } from 'yaml';
|
||||
import { credentials } from '../../api';
|
||||
@@ -100,6 +101,7 @@ function AddCredentialCard() {
|
||||
|
||||
export function CredentialsPage() {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t('credentials.title'));
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<Credential[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, SubmitEvent } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { KeyRound, X, Save } from 'lucide-react';
|
||||
import { credentials } from '../../api';
|
||||
import type { Credential } from '../../api';
|
||||
@@ -15,6 +16,11 @@ export function EditCredentialPage() {
|
||||
|
||||
const [credential, setCredential] = useState<Credential | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
usePageTitle(
|
||||
credential
|
||||
? `${t('credentials.edit_title')} — ${credential.name}`
|
||||
: t('credentials.edit_title'),
|
||||
);
|
||||
const [data, setData] = useState('');
|
||||
const [nameError, setNameError] = useState('');
|
||||
const [dataError, setDataError] = useState('');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, SubmitEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { X, Globe, Save } from 'lucide-react';
|
||||
import { environments } from '../../api';
|
||||
import { Breadcrumbs, Button, Card, Input, CodeEditor, useToast } from '../../ui';
|
||||
@@ -8,6 +9,7 @@ import styles from '../Page.module.css';
|
||||
|
||||
export function CreateEnvironmentPage() {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t('environments.create_title'));
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, SubmitEvent } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { X, Globe, Save } from 'lucide-react';
|
||||
import { environments } from '../../api';
|
||||
import type { Environment } from '../../api';
|
||||
@@ -15,6 +16,9 @@ export function EditEnvironmentPage() {
|
||||
|
||||
const [env, setEnv] = useState<Environment | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
usePageTitle(
|
||||
env ? `${t('environments.edit_title')} — ${env.name}` : t('environments.edit_title'),
|
||||
);
|
||||
const [description, setDescription] = useState('');
|
||||
const [dataJson, setDataJson] = useState('{}');
|
||||
const [nameError, setNameError] = useState('');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Globe, Pencil, Trash2, Upload } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { stringify as yamlStringify } from 'yaml';
|
||||
import type { Environment } from '../../api';
|
||||
@@ -25,6 +26,7 @@ export function EnvironmentDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const [env, setEnv] = useState<Environment | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
usePageTitle(env?.name ?? null);
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const dataEntries = Object.entries(env?.data ?? {}).filter(([, v]) => v);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { Settings, ExternalLink, Pencil, Trash2, Plus, Globe, Download } from 'lucide-react';
|
||||
import { parse as yamlParse } from 'yaml';
|
||||
import { stringify as yamlStringify } from 'yaml';
|
||||
@@ -117,6 +118,7 @@ function AddEnvironmentCard() {
|
||||
|
||||
export function EnvironmentsPage() {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t('environments.title'));
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<Environment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { Activity } from 'lucide-react';
|
||||
import { runs } from '../../api';
|
||||
import type { ScenarioRun, ScenarioRunStatus } from '../../api';
|
||||
@@ -38,6 +39,7 @@ type AllRunRow = ScenarioRun & {
|
||||
|
||||
export function AllRunsPage() {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t('runs.title'));
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [sortBy, setSortBy] = useState('createdAt');
|
||||
@@ -45,12 +47,7 @@ export function AllRunsPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['runs', sortBy, sortDir, page, pageSize],
|
||||
queryFn: () =>
|
||||
runs.listAll(page, pageSize, undefined, sortBy, sortDir === 'asc' ? 'ASC' : 'DESC'),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useNavigate, useParams, Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { runs } from '../../api';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { runs, runFiles } from '../../api';
|
||||
import type {
|
||||
Scenario,
|
||||
ScenarioRunDetail,
|
||||
@@ -10,11 +11,14 @@ import type {
|
||||
ScenarioRunStatus,
|
||||
RunStepStatus,
|
||||
LogLevel,
|
||||
FileMetadata,
|
||||
} from '../../api';
|
||||
import { scenarios } from '../../api';
|
||||
import { ExportLogsModal, type LogExportFormat } from '../../components/modals';
|
||||
import {
|
||||
AutoRefreshIndicator,
|
||||
Badge,
|
||||
Button,
|
||||
Breadcrumbs,
|
||||
Card,
|
||||
CodeBlock,
|
||||
@@ -27,7 +31,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 +73,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 +82,12 @@ 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 [exportLogsOpen, setExportLogsOpen] = useState(false);
|
||||
const [exportLogsFormat, setExportLogsFormat] = useState<LogExportFormat>('md');
|
||||
const [exportingLogs, setExportingLogs] = useState(false);
|
||||
const logSearchRef = useRef('');
|
||||
const LOG_PAGE_SIZE = 25;
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
@@ -89,6 +100,37 @@ 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 handleExportLogs = async () => {
|
||||
if (!id || !runId) return;
|
||||
setExportingLogs(true);
|
||||
try {
|
||||
const content = await runs.exportLogs(id, runId, exportLogsFormat);
|
||||
const mimeType = exportLogsFormat === 'csv' ? 'text/csv' : 'text/markdown';
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `run-${runId}-logs.${exportLogsFormat}`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setExportLogsOpen(false);
|
||||
} catch {
|
||||
// error toast is already emitted by the API client
|
||||
} finally {
|
||||
setExportingLogs(false);
|
||||
}
|
||||
};
|
||||
|
||||
const manualRefresh = () => {
|
||||
if (!id || !runId) return;
|
||||
runs
|
||||
@@ -101,6 +143,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 +185,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 +194,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 +215,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 +276,7 @@ export function RunDetailPage() {
|
||||
),
|
||||
render: (s) => {
|
||||
const isExpanded = expandAll || expandedStepIds.has(s.id);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
@@ -398,11 +461,17 @@ export function RunDetailPage() {
|
||||
<div className={styles.stepsSection}>
|
||||
<div className={styles.sectionHeadingRow}>
|
||||
<h2 className={styles.sectionHeading}>{t('runs.logs_heading')}</h2>
|
||||
<Search
|
||||
value={logSearch}
|
||||
onChange={setLogSearch}
|
||||
placeholder={t('runs.logs_search_placeholder')}
|
||||
/>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Search
|
||||
value={logSearch}
|
||||
onChange={setLogSearch}
|
||||
placeholder={t('runs.logs_search_placeholder')}
|
||||
/>
|
||||
<Button variant="secondary" size="sm" onClick={() => setExportLogsOpen(true)}>
|
||||
<Download size={14} />
|
||||
{t('runs.action_download_logs')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Table
|
||||
columns={logColumns}
|
||||
@@ -421,8 +490,79 @@ 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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<ExportLogsModal
|
||||
open={exportLogsOpen}
|
||||
format={exportLogsFormat}
|
||||
onFormatChange={setExportLogsFormat}
|
||||
onClose={() => setExportLogsOpen(false)}
|
||||
onExport={handleExportLogs}
|
||||
isExporting={exportingLogs}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { Play, ClipboardList, Activity } from 'lucide-react';
|
||||
import { environments, scenarios, runs } from '../../api';
|
||||
import type {
|
||||
Environment,
|
||||
Scenario,
|
||||
ScenarioRun,
|
||||
ScenarioRunStatus,
|
||||
} from '../../api';
|
||||
import type { Environment, Scenario, ScenarioRun, ScenarioRunStatus } from '../../api';
|
||||
import {
|
||||
AutoRefreshIndicator,
|
||||
Badge,
|
||||
@@ -50,6 +46,7 @@ export function RunsPage() {
|
||||
|
||||
const [scenario, setScenario] = useState<Scenario | null>(null);
|
||||
const [items, setItems] = useState<RunRow[]>([]);
|
||||
usePageTitle(scenario ? `${t('runs.title')} — ${scenario.name}` : t('runs.title'));
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pulseKey, setPulseKey] = useState(0);
|
||||
@@ -84,16 +81,16 @@ export function RunsPage() {
|
||||
scenarios.get(id!),
|
||||
runs.list(id!, p, limit, orderBy, orderDir === 'asc' ? 'ASC' : 'DESC'),
|
||||
])
|
||||
.then(([sc, res]) => {
|
||||
setScenario(sc);
|
||||
setItems(res.data);
|
||||
setTotal(res.total);
|
||||
setError(null);
|
||||
setPulseKey((k) => k + 1);
|
||||
hasDataRef.current = true;
|
||||
})
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
.then(([sc, res]) => {
|
||||
setScenario(sc);
|
||||
setItems(res.data);
|
||||
setTotal(res.total);
|
||||
setError(null);
|
||||
setPulseKey((k) => k + 1);
|
||||
hasDataRef.current = true;
|
||||
})
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
},
|
||||
[id],
|
||||
);
|
||||
@@ -107,7 +104,6 @@ export function RunsPage() {
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [load]);
|
||||
|
||||
const handleSort = (key: string, dir: SortDirection) => {
|
||||
@@ -192,7 +188,12 @@ export function RunsPage() {
|
||||
]}
|
||||
/>
|
||||
<div className={styles.toolbarActions}>
|
||||
<AutoRefreshIndicator active pulseKey={pulseKey} error={!!error} onClick={() => load(sortBy, sortDir, page, pageSize)} />
|
||||
<AutoRefreshIndicator
|
||||
active
|
||||
pulseKey={pulseKey}
|
||||
error={!!error}
|
||||
onClick={() => load(sortBy, sortDir, page, pageSize)}
|
||||
/>
|
||||
<Button size="sm" onClick={() => setRunModalOpen(true)}>
|
||||
<Play size={14} />
|
||||
{t('runs.action_run')}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, SubmitEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { X, Save, ClipboardList } from 'lucide-react';
|
||||
import { scenarios, environments } from '../../api';
|
||||
import type { Environment } from '../../api';
|
||||
@@ -9,6 +10,7 @@ import styles from '../Page.module.css';
|
||||
|
||||
export function CreateScenarioPage() {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t('scenarios.create_title'));
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
|
||||
@@ -20,7 +22,10 @@ export function CreateScenarioPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
environments.list(1, 200).then((r) => setEnvs(r?.data ?? [])).catch(() => {});
|
||||
environments
|
||||
.list(1, 200)
|
||||
.then((r) => setEnvs(r?.data ?? []))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||
@@ -31,7 +36,11 @@ export function CreateScenarioPage() {
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const scenario = await scenarios.create(name.trim(), description.trim() || undefined, environmentId || undefined);
|
||||
const scenario = await scenarios.create(
|
||||
name.trim(),
|
||||
description.trim() || undefined,
|
||||
environmentId || undefined,
|
||||
);
|
||||
toast.success(t('scenarios.created'));
|
||||
navigate(`/scenarios/${scenario.id}`);
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, SubmitEvent } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { X, Save, ClipboardList } from 'lucide-react';
|
||||
import { scenarios, steps } from '../../api';
|
||||
import type { Scenario } from '../../api';
|
||||
@@ -14,6 +15,7 @@ export function CreateStepPage() {
|
||||
const toast = useToast();
|
||||
|
||||
const [scenario, setScenario] = useState<Scenario | null>(null);
|
||||
usePageTitle(t('steps.create_title'));
|
||||
const [title, setTitle] = useState('');
|
||||
const [execCode, setExecCode] = useState('');
|
||||
const [timeoutSeconds, setTimeoutSeconds] = useState<string>('');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, SubmitEvent } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { X, Save, ClipboardList } from 'lucide-react';
|
||||
import { scenarios, environments } from '../../api';
|
||||
import type { Scenario, Environment } from '../../api';
|
||||
@@ -15,6 +16,9 @@ export function EditScenarioPage() {
|
||||
|
||||
const [scenario, setScenario] = useState<Scenario | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
usePageTitle(
|
||||
scenario ? `${t('scenarios.edit_title')} — ${scenario.name}` : t('scenarios.edit_title'),
|
||||
);
|
||||
const [description, setDescription] = useState('');
|
||||
const [environmentId, setEnvironmentId] = useState<string>('');
|
||||
const [timeoutSeconds, setTimeoutSeconds] = useState<string>('');
|
||||
@@ -35,7 +39,10 @@ export function EditScenarioPage() {
|
||||
setTimeoutSeconds(data.timeoutSeconds != null ? String(data.timeoutSeconds) : '');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
environments.list(1, 200).then((r) => setEnvs(r?.data ?? [])).catch(() => {});
|
||||
environments
|
||||
.list(1, 200)
|
||||
.then((r) => setEnvs(r?.data ?? []))
|
||||
.catch(() => {});
|
||||
}, [id]);
|
||||
|
||||
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, SubmitEvent } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { X, Save, ClipboardList } from 'lucide-react';
|
||||
import { scenarios, steps } from '../../api';
|
||||
import type { Scenario, ScenarioStep } from '../../api';
|
||||
@@ -15,6 +16,9 @@ export function EditStepPage() {
|
||||
|
||||
const [scenario, setScenario] = useState<Scenario | null>(null);
|
||||
const [step, setStep] = useState<ScenarioStep | null>(null);
|
||||
usePageTitle(
|
||||
step ? t('steps.edit_title', { order: step.order }) : t('steps.edit_title', { order: '' }),
|
||||
);
|
||||
const [title, setTitle] = useState('');
|
||||
const [execCode, setExecCode] = useState('');
|
||||
const [timeoutSeconds, setTimeoutSeconds] = useState<string>('');
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
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,
|
||||
FileArchive,
|
||||
} from 'lucide-react';
|
||||
import { stringify as yamlStringify } from 'yaml';
|
||||
import {
|
||||
environments,
|
||||
scenarios,
|
||||
steps,
|
||||
scenarioCredentials,
|
||||
scenarioFiles,
|
||||
credentials as credentialsApi,
|
||||
} from '../../api';
|
||||
import type {
|
||||
@@ -25,6 +28,8 @@ import type {
|
||||
ScenarioCredential,
|
||||
Credential,
|
||||
Environment,
|
||||
FileMetadata,
|
||||
Snippet,
|
||||
} from '../../api';
|
||||
import {
|
||||
Breadcrumbs,
|
||||
@@ -51,6 +56,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 +87,29 @@ 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 [usedSnippets, setUsedSnippets] = useState<Snippet[]>([]);
|
||||
|
||||
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 +119,11 @@ export function ScenarioDetailPage() {
|
||||
setScenarioCreds(s.scenarioCredentials ?? []);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
void loadFiles();
|
||||
scenarios
|
||||
.usedSnippets(id)
|
||||
.then(setUsedSnippets)
|
||||
.catch(() => {});
|
||||
credentialsApi
|
||||
.list(1, 200)
|
||||
.then((r) => setAllCredentials(r.data))
|
||||
@@ -100,7 +134,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(() => {
|
||||
@@ -116,6 +150,10 @@ export function ScenarioDetailPage() {
|
||||
const s = await scenarios.get(id);
|
||||
setScenario(s);
|
||||
setScenarioCreds(s.scenarioCredentials ?? []);
|
||||
scenarios
|
||||
.usedSnippets(id)
|
||||
.then(setUsedSnippets)
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const handleRun = async () => {
|
||||
@@ -176,6 +214,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();
|
||||
@@ -398,6 +468,16 @@ export function ScenarioDetailPage() {
|
||||
<Upload size={14} />
|
||||
{t('scenarios.action_export')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleFileDownload(scenarios.exportE2eTestUrl(id!), `${scenario.name}-e2e-test.zip`)
|
||||
}
|
||||
>
|
||||
<FileArchive size={14} />
|
||||
{t('scenarios.action_export_e2e')}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => navigate(`/scenarios/${id}/edit`)}>
|
||||
<Pencil size={14} />
|
||||
{t('scenarios.action_edit')}
|
||||
@@ -425,17 +505,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 +672,131 @@ export function ScenarioDetailPage() {
|
||||
<p className={styles.muted}>{t('scenarios.cred_empty')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Used snippets section ───────────────────────────────────────── */}
|
||||
{usedSnippets.length > 0 && (
|
||||
<div className={styles.stepsSection}>
|
||||
<div className={styles.sectionToolbar}>
|
||||
<h2 className={styles.sectionHeading}>{t('scenarios.used_snippets_heading')}</h2>
|
||||
</div>
|
||||
<Table<Snippet>
|
||||
data={usedSnippets}
|
||||
rowKey={(s) => s.id}
|
||||
loading={false}
|
||||
emptyMessage=""
|
||||
onRowClick={(s) => navigate(`/snippets/${s.id}`)}
|
||||
columns={
|
||||
[
|
||||
{
|
||||
key: 'alias',
|
||||
header: t('snippets.field_alias'),
|
||||
render: (s) => s.alias,
|
||||
},
|
||||
{
|
||||
key: 'title',
|
||||
header: t('snippets.field_title'),
|
||||
render: (s) => s.title,
|
||||
},
|
||||
] satisfies TableColumn<Snippet>[]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Files section ───────────────────────────────────────────────── */}
|
||||
<div className={styles.stepsSection}>
|
||||
<div className={styles.sectionToolbar}>
|
||||
<h2 className={styles.sectionHeading}>{t('files.title')}</h2>
|
||||
<Button size="sm" onClick={() => setUploadOpen(true)}>
|
||||
<Upload size={14} /> {t('files.upload')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table<FileMetadata>
|
||||
loading={filesLoading}
|
||||
data={files}
|
||||
rowKey={(f) => f.id}
|
||||
columns={
|
||||
[
|
||||
{
|
||||
key: 'name',
|
||||
header: t('files.name'),
|
||||
render: (f) => f.name,
|
||||
},
|
||||
{
|
||||
key: 'mimeType',
|
||||
header: t('files.mimeType'),
|
||||
render: (f) => f.mimeType,
|
||||
},
|
||||
{
|
||||
key: 'size',
|
||||
header: t('files.size'),
|
||||
render: (f) => `${(f.size / 1024).toFixed(1)} KB`,
|
||||
},
|
||||
{
|
||||
key: 'expiresAt',
|
||||
header: t('files.expiresAt'),
|
||||
render: (f) => (f.expiresAt ? <Timestamp value={f.expiresAt} /> : '—'),
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
header: t('files.createdAt'),
|
||||
render: (f) => <Timestamp value={f.createdAt} />,
|
||||
},
|
||||
{
|
||||
key: 'download',
|
||||
header: '',
|
||||
render: (f) => {
|
||||
const expired = isFileExpired(f);
|
||||
const tooltip = expired ? t('files.expiredTooltip') : t('files.download');
|
||||
|
||||
return (
|
||||
<span title={tooltip}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
title={tooltip}
|
||||
aria-label={tooltip}
|
||||
disabled={expired}
|
||||
onClick={() =>
|
||||
handleFileDownload(scenarioFiles.contentUrl(id!, f.id), f.name)
|
||||
}
|
||||
>
|
||||
<Download size={14} />
|
||||
</Button>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
] satisfies TableColumn<FileMetadata>[]
|
||||
}
|
||||
emptyMessage={t('files.empty')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Upload modal ─────────────────────────────────────────────── */}
|
||||
<Modal
|
||||
open={uploadOpen}
|
||||
title={t('files.uploadTitle')}
|
||||
onClose={() => setUploadOpen(false)}
|
||||
>
|
||||
<form onSubmit={handleUpload}>
|
||||
<input
|
||||
type="file"
|
||||
required
|
||||
onChange={(e) => setUploadFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<Input
|
||||
label={t('files.expiresAtLabel')}
|
||||
type="datetime-local"
|
||||
value={uploadExpiresAt}
|
||||
onChange={(e) => setUploadExpiresAt(e.target.value)}
|
||||
/>
|
||||
<Button type="submit" loading={uploading} disabled={!uploadFile}>
|
||||
{t('files.upload')}
|
||||
</Button>
|
||||
</form>
|
||||
</Modal>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { Play, Plus, Trash2, Download, ClipboardList } from 'lucide-react';
|
||||
import { parse as yamlParse } from 'yaml';
|
||||
import { environments, scenarios } from '../../api';
|
||||
import type { Environment, Scenario } from '../../api';
|
||||
import {
|
||||
Badge,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Modal,
|
||||
@@ -17,10 +19,27 @@ import {
|
||||
UuidBadge,
|
||||
useToast,
|
||||
} from '../../ui';
|
||||
import type { BadgeVariant } from '../../ui';
|
||||
import type { ScenarioRunStatus } from '../../api';
|
||||
import styles from '../Page.module.css';
|
||||
|
||||
const LAST_RUN_VARIANT: Record<ScenarioRunStatus, BadgeVariant> = {
|
||||
pending: 'neutral',
|
||||
in_progress: 'info',
|
||||
pass: 'success',
|
||||
fail: 'error',
|
||||
};
|
||||
|
||||
const LAST_RUN_LABEL: Record<ScenarioRunStatus, string> = {
|
||||
pending: 'Pending',
|
||||
in_progress: 'Running',
|
||||
pass: 'Pass',
|
||||
fail: 'Fail',
|
||||
};
|
||||
|
||||
export function ScenariosPage() {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t('scenarios.title'));
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const [items, setItems] = useState<Scenario[]>([]);
|
||||
@@ -48,20 +67,21 @@ export function ScenariosPage() {
|
||||
scenarios.list(p, limit, orderBy, orderDir === 'asc' ? 'ASC' : 'DESC'),
|
||||
environments.list(1, 200),
|
||||
])
|
||||
.then(([scenarioRes, envRes]) => {
|
||||
setItems(scenarioRes?.data ?? []);
|
||||
setTotal(scenarioRes?.total ?? 0);
|
||||
setEnvs(envRes?.data ?? []);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error((err as Error).message);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [toast]);
|
||||
.then(([scenarioRes, envRes]) => {
|
||||
setItems(scenarioRes?.data ?? []);
|
||||
setTotal(scenarioRes?.total ?? 0);
|
||||
setEnvs(envRes?.data ?? []);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error((err as Error).message);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
load('updatedAt', 'desc', 1, 10);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [load]);
|
||||
|
||||
const handleSort = (key: string, dir: SortDirection) => {
|
||||
@@ -135,6 +155,24 @@ export function ScenariosPage() {
|
||||
const columns: TableColumn<Scenario>[] = [
|
||||
{ key: 'id', header: t('scenarios.col_id'), render: (s) => <UuidBadge id={s.id} />, width: 60 },
|
||||
{ key: 'name', header: t('scenarios.col_name'), render: (s) => s.name, sortable: true },
|
||||
{
|
||||
key: 'lastRunStatus',
|
||||
header: t('scenarios.col_last_run'),
|
||||
width: 160,
|
||||
render: (s) =>
|
||||
s.lastRunStatus ? (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ display: 'inline-flex', minWidth: 62 }}>
|
||||
<Badge variant={LAST_RUN_VARIANT[s.lastRunStatus]}>
|
||||
{LAST_RUN_LABEL[s.lastRunStatus]}
|
||||
</Badge>
|
||||
</span>
|
||||
{s.lastRunAt && <Timestamp value={s.lastRunAt} />}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--color-text-muted)', fontSize: '0.8em' }}>—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'updatedAt',
|
||||
header: t('scenarios.col_updated'),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { Trash2, Monitor } from 'lucide-react';
|
||||
import { sessions } from '../../api';
|
||||
import type { Session } from '../../api';
|
||||
@@ -14,6 +15,7 @@ export function SessionDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
usePageTitle(session?.sessionName ?? null);
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { Trash2, Monitor } from 'lucide-react';
|
||||
import { sessions } from '../../api';
|
||||
import type { Session } from '../../api';
|
||||
@@ -19,6 +20,7 @@ import styles from '../Page.module.css';
|
||||
|
||||
export function SessionsPage() {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t('sessions.title'));
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<Session[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -34,7 +36,10 @@ export function SessionsPage() {
|
||||
setLoading(true);
|
||||
sessions
|
||||
.list(p, limit, orderBy, orderDir === 'asc' ? 'ASC' : 'DESC')
|
||||
.then((res) => { setItems(res.data); setTotal(res.total); })
|
||||
.then((res) => {
|
||||
setItems(res.data);
|
||||
setTotal(res.total);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
@@ -62,7 +67,12 @@ export function SessionsPage() {
|
||||
|
||||
const columns: TableColumn<Session>[] = [
|
||||
{ key: 'id', header: t('sessions.col_id'), render: (s) => <UuidBadge id={s.id} />, width: 60 },
|
||||
{ key: 'sessionName', header: t('sessions.col_name'), sortable: true, render: (s) => s.sessionName },
|
||||
{
|
||||
key: 'sessionName',
|
||||
header: t('sessions.col_name'),
|
||||
sortable: true,
|
||||
render: (s) => s.sessionName,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: t('sessions.col_status'),
|
||||
@@ -115,7 +125,10 @@ export function SessionsPage() {
|
||||
total={total}
|
||||
page={page}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(s) => { setPageSize(s); setPage(1); }}
|
||||
onPageSizeChange={(s) => {
|
||||
setPageSize(s);
|
||||
setPage(1);
|
||||
}}
|
||||
pageSizeOptions={[10, 25, 50]}
|
||||
onRowClick={(s) => navigate(`/sessions/${s.id}`)}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, SubmitEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { X, Save, Braces } from 'lucide-react';
|
||||
import { snippets } from '../../api';
|
||||
import { Breadcrumbs, Button, Card, Input, CodeEditor, useToast } from '../../ui';
|
||||
@@ -8,6 +9,7 @@ import styles from '../Page.module.css';
|
||||
|
||||
export function CreateSnippetPage() {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t('snippets.create_title'));
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, SubmitEvent } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { X, Save, Braces } from 'lucide-react';
|
||||
import { snippets } from '../../api';
|
||||
import type { Snippet } from '../../api';
|
||||
@@ -15,6 +16,9 @@ export function EditSnippetPage() {
|
||||
|
||||
const [snippet, setSnippet] = useState<Snippet | null>(null);
|
||||
const [alias, setAlias] = useState('');
|
||||
usePageTitle(
|
||||
snippet ? `${t('snippets.edit_title')} — ${snippet.title}` : t('snippets.edit_title'),
|
||||
);
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { Upload, Pencil, Trash2, Braces } from 'lucide-react';
|
||||
import { CodeBlock } from '../../ui';
|
||||
import { stringify as yamlStringify } from 'yaml';
|
||||
@@ -24,6 +25,7 @@ export function SnippetDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const [snippet, setSnippet] = useState<Snippet | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
usePageTitle(snippet?.title ?? null);
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTitle } from '../../hooks/usePageTitle';
|
||||
import { Settings, Pencil, Trash2, Plus, Download, Braces } from 'lucide-react';
|
||||
import { parse as yamlParse } from 'yaml';
|
||||
import { snippets } from '../../api';
|
||||
@@ -114,6 +115,7 @@ function AddSnippetCard() {
|
||||
|
||||
export function SnippetsPage() {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t('snippets.title'));
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<Snippet[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -76,8 +76,7 @@ export function Table<T>({
|
||||
|
||||
const handleSortClick = (key: string) => {
|
||||
if (!onSort) return;
|
||||
const nextDir: SortDirection =
|
||||
sortBy === key && sortDir === 'asc' ? 'desc' : 'asc';
|
||||
const nextDir: SortDirection = sortBy === key && sortDir === 'asc' ? 'desc' : 'asc';
|
||||
onSort(key, nextDir);
|
||||
};
|
||||
|
||||
@@ -101,19 +100,12 @@ export function Table<T>({
|
||||
<tr>
|
||||
{columns.map((col) => {
|
||||
const isSorted = col.sortable && sortBy === col.key;
|
||||
const SortIcon = isSorted
|
||||
? sortDir === 'asc'
|
||||
? ArrowUp
|
||||
: ArrowDown
|
||||
: ArrowUpDown;
|
||||
const SortIcon = isSorted ? (sortDir === 'asc' ? ArrowUp : ArrowDown) : ArrowUpDown;
|
||||
|
||||
return (
|
||||
<th
|
||||
key={col.key}
|
||||
className={[
|
||||
styles.th,
|
||||
col.sortable ? styles.thSortable : '',
|
||||
]
|
||||
className={[styles.th, col.sortable ? styles.thSortable : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={{
|
||||
@@ -127,10 +119,7 @@ export function Table<T>({
|
||||
{col.sortable && (
|
||||
<SortIcon
|
||||
size={12}
|
||||
className={[
|
||||
styles.sortIcon,
|
||||
isSorted ? styles.sortIconActive : '',
|
||||
]
|
||||
className={[styles.sortIcon, isSorted ? styles.sortIconActive : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-hidden="true"
|
||||
|
||||
+22
@@ -50,7 +50,29 @@ The server currently registers the following tools.
|
||||
| `export_scenario` | Export scenario payload |
|
||||
| `import_scenario` | Import scenario payload |
|
||||
|
||||
## Scenario Files
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `upload_scenario_file` | Upload a file to a scenario; accepts optional `expiresAt` (ISO 8601 date string) |
|
||||
| `list_scenario_files` | List scenario files (paginated) |
|
||||
| `get_scenario_file_content` | Retrieve file content as base64 with metadata |
|
||||
|
||||
## Run Artifact Files
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `list_run_files` | List files (artifacts) created during a scenario run (paginated) |
|
||||
| `get_run_file_content` | Retrieve run artifact content as base64 with metadata; run artifacts are read-only and inherit expiry from the run artifact subsystem |
|
||||
|
||||
## Binary Content Handling
|
||||
|
||||
File tools transport binary payloads as base64-encoded strings in a `contentBase64` field with an accompanying `encoding: "base64"` marker in the response. When retrieving file content via `get_scenario_file_content` or `get_run_file_content`, decode the base64 to recover the original bytes.
|
||||
|
||||
Scenario files uploaded via `upload_scenario_file` must have their content pre-encoded as base64. Run artifacts are created implicitly through scenario execution (via `context.downloadFile()` during step execution) and cannot be uploaded via MCP.
|
||||
|
||||
## Notes
|
||||
|
||||
- Tool IDs and entity IDs are UUIDs.
|
||||
- `create_scenario_step` schema still accepts legacy `type`/`sessionName` fields for compatibility. Current scheduler executes `execCode` plus optional `validateCode` and does not branch by step type.
|
||||
|
||||
|
||||
@@ -35,7 +35,10 @@ Available in scope:
|
||||
- `context.getStepOutput(order)`: prior step output by absolute order (`1`, `2`, ...) or relative (`-1` = previous step)
|
||||
- `context.dumpDom(selector?)`: simplified DOM snapshot
|
||||
- `context.log(...args)`, `context.warn(...args)`, `context.error(...args)`: structured step logs
|
||||
- `context.assert(() => boolean, description)`: runs the arrow function and requires it to return `true`. Logs `description` (as a `log` entry on success, as an `error` entry on failure or exception), then throws to stop the step if the function returned anything other than `true` or threw
|
||||
- `context.runSnippet(name, ...args)`: execute stored snippet code with the same context
|
||||
- `context.getScenarioFiles(opts?)`: list files uploaded to the scenario (`limit`, `offset`)
|
||||
- `context.downloadFile(url, opts?)`: fetch `url` and save the result as a run artifact (requires a real scenario run — throws when invoked ad hoc, e.g. via the `exec_code` MCP tool). `opts` may include `method`, `headers`, `body`, `filename`. `url` also accepts a `data:` URI (`data:<mediaType>;base64,<data>` or `data:<mediaType>,<percent-encoded data>`) to save content generated in-script (e.g. a credential JWT) directly, without an actual network fetch.
|
||||
- `console`: proxied to run logs (`log`, `warn`, `error`, etc.)
|
||||
|
||||
`validateCode` additionally receives `result` in scope, which is the value returned by `execCode`.
|
||||
|
||||
Generated
+667
-6
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "liqa",
|
||||
"version": "1.6.1",
|
||||
"version": "1.12.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "liqa",
|
||||
"version": "1.6.1",
|
||||
"version": "1.12.0",
|
||||
"workspaces": [
|
||||
"server",
|
||||
"client"
|
||||
@@ -4665,6 +4665,16 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/readdir-glob": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz",
|
||||
"integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/resolve": {
|
||||
"version": "1.20.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.6.tgz",
|
||||
@@ -5565,6 +5575,18 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"event-target-shim": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.5"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
@@ -5760,6 +5782,201 @@
|
||||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/archiver": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz",
|
||||
"integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"archiver-utils": "^5.0.2",
|
||||
"async": "^3.2.4",
|
||||
"buffer-crc32": "^1.0.0",
|
||||
"readable-stream": "^4.0.0",
|
||||
"readdir-glob": "^1.1.2",
|
||||
"tar-stream": "^3.0.0",
|
||||
"zip-stream": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/archiver-utils": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz",
|
||||
"integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"glob": "^10.0.0",
|
||||
"graceful-fs": "^4.2.0",
|
||||
"is-stream": "^2.0.1",
|
||||
"lazystream": "^1.0.0",
|
||||
"lodash": "^4.17.15",
|
||||
"normalize-path": "^3.0.0",
|
||||
"readable-stream": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/archiver-utils/node_modules/brace-expansion": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
|
||||
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/archiver-utils/node_modules/buffer": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/archiver-utils/node_modules/glob": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
||||
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"foreground-child": "^3.1.0",
|
||||
"jackspeak": "^3.1.2",
|
||||
"minimatch": "^9.0.4",
|
||||
"minipass": "^7.1.2",
|
||||
"package-json-from-dist": "^1.0.0",
|
||||
"path-scurry": "^1.11.1"
|
||||
},
|
||||
"bin": {
|
||||
"glob": "dist/esm/bin.mjs"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/archiver-utils/node_modules/lru-cache": {
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/archiver-utils/node_modules/minimatch": {
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/archiver-utils/node_modules/path-scurry": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
|
||||
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"lru-cache": "^10.2.0",
|
||||
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/archiver-utils/node_modules/readable-stream": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
|
||||
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"abort-controller": "^3.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"events": "^3.3.0",
|
||||
"process": "^0.11.10",
|
||||
"string_decoder": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/archiver/node_modules/buffer": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/archiver/node_modules/readable-stream": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
|
||||
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"abort-controller": "^3.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"events": "^3.3.0",
|
||||
"process": "^0.11.10",
|
||||
"string_decoder": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/archiver/node_modules/tar-stream": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.1.tgz",
|
||||
"integrity": "sha512-nqsEO8zLZJvrOMdEwkA0QdCLFbetHMn95Zqu4fKwX+hkaTWJPZZOrxx/PwtxoK0MMGQmBQNRW3CPs8IFYQz4cQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"b4a": "^1.6.4",
|
||||
"bare-fs": "^4.5.5",
|
||||
"fast-fifo": "^1.2.0",
|
||||
"streamx": "^2.15.0"
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
@@ -5842,6 +6059,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/async": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
@@ -5874,6 +6097,20 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/b4a": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.9.0.tgz",
|
||||
"integrity": "sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"react-native-b4a": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-native-b4a": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/babel-jest": {
|
||||
"version": "30.3.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz",
|
||||
@@ -5989,6 +6226,86 @@
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bare-events": {
|
||||
"version": "2.9.2",
|
||||
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.2.tgz",
|
||||
"integrity": "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"bare-abort-controller": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bare-abort-controller": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/bare-fs": {
|
||||
"version": "4.8.1",
|
||||
"resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.1.tgz",
|
||||
"integrity": "sha512-N1nnXdHZAOSstz0XiHikGS4HGMH4CnSwhqWdGQQMqqdvp4Jybm9sE3R1WVnpWVd4SFkc8ryPDBLViNLwiEqECg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"bare-events": "^2.5.4",
|
||||
"bare-path": "^3.0.0",
|
||||
"bare-stream": "^2.6.4",
|
||||
"bare-url": "^2.2.2",
|
||||
"fast-fifo": "^1.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"bare": ">=1.28.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bare-buffer": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bare-buffer": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/bare-path": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.2.tgz",
|
||||
"integrity": "sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/bare-stream": {
|
||||
"version": "2.13.4",
|
||||
"resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.4.tgz",
|
||||
"integrity": "sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"b4a": "^1.8.1",
|
||||
"streamx": "^2.25.0",
|
||||
"teex": "^1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bare-abort-controller": "*",
|
||||
"bare-buffer": "*",
|
||||
"bare-events": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bare-abort-controller": {
|
||||
"optional": true
|
||||
},
|
||||
"bare-buffer": {
|
||||
"optional": true
|
||||
},
|
||||
"bare-events": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/bare-url": {
|
||||
"version": "2.5.4",
|
||||
"resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.4.tgz",
|
||||
"integrity": "sha512-Gxa7UVWBr0/edU1b+TJhn/AZvMQUj9OGspvYsaTYQrAbZA4BOTZGL3LiZxvD+CeMlDH4juwD84+eTAp/bLYW5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"bare-path": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -6181,6 +6498,15 @@
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-crc32": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz",
|
||||
"integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
@@ -6680,6 +7006,62 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/compress-commons": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz",
|
||||
"integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"crc-32": "^1.2.0",
|
||||
"crc32-stream": "^6.0.0",
|
||||
"is-stream": "^2.0.1",
|
||||
"normalize-path": "^3.0.0",
|
||||
"readable-stream": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/compress-commons/node_modules/buffer": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/compress-commons/node_modules/readable-stream": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
|
||||
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"abort-controller": "^3.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"events": "^3.3.0",
|
||||
"process": "^0.11.10",
|
||||
"string_decoder": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@@ -6765,6 +7147,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cors": {
|
||||
"version": "2.8.6",
|
||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||
@@ -6809,6 +7197,71 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/crc32-stream": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz",
|
||||
"integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"crc-32": "^1.2.0",
|
||||
"readable-stream": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/crc32-stream/node_modules/buffer": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/crc32-stream/node_modules/readable-stream": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
|
||||
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"abort-controller": "^3.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"events": "^3.3.0",
|
||||
"process": "^0.11.10",
|
||||
"string_decoder": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cron": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz",
|
||||
@@ -7951,16 +8404,33 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/event-target-shim": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/events": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/events-universal": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
|
||||
"integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"bare-events": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventsource": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
||||
@@ -8178,6 +8648,12 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/fast-fifo": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
|
||||
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-json-stable-stringify": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||
@@ -8721,7 +9197,6 @@
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/handlebars": {
|
||||
@@ -9336,7 +9811,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -10523,6 +10997,54 @@
|
||||
"json-buffer": "3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lazystream": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
|
||||
"integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readable-stream": "^2.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6.3"
|
||||
}
|
||||
},
|
||||
"node_modules/lazystream/node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lazystream/node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lazystream/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lazystream/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/leven": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
||||
@@ -12011,7 +12533,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -12637,6 +13158,21 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/process": {
|
||||
"version": "0.11.10",
|
||||
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
||||
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/property-information": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
|
||||
@@ -12939,6 +13475,36 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/readdir-glob": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz",
|
||||
"integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"minimatch": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readdir-glob/node_modules/brace-expansion": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
|
||||
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readdir-glob/node_modules/minimatch": {
|
||||
"version": "5.1.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
|
||||
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
@@ -13813,6 +14379,17 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/streamx": {
|
||||
"version": "2.28.1",
|
||||
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz",
|
||||
"integrity": "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"events-universal": "^1.0.0",
|
||||
"fast-fifo": "^1.3.2",
|
||||
"text-decoder": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
@@ -14140,6 +14717,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/teex": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
|
||||
"integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"streamx": "^2.12.5"
|
||||
}
|
||||
},
|
||||
"node_modules/terser": {
|
||||
"version": "5.46.1",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz",
|
||||
@@ -14275,6 +14861,15 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/text-decoder": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
|
||||
"integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"b4a": "^1.6.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
@@ -15974,6 +16569,60 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zip-stream": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz",
|
||||
"integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"archiver-utils": "^5.0.0",
|
||||
"compress-commons": "^6.0.2",
|
||||
"readable-stream": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/zip-stream/node_modules/buffer": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/zip-stream/node_modules/readable-stream": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
|
||||
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"abort-controller": "^3.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"events": "^3.3.0",
|
||||
"process": "^0.11.10",
|
||||
"string_decoder": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
@@ -16031,6 +16680,7 @@
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"acorn": "^8.16.0",
|
||||
"archiver": "^7.0.1",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.4",
|
||||
@@ -16048,6 +16698,7 @@
|
||||
"@nestjs/cli": "^11.0.17",
|
||||
"@nestjs/testing": "^11.1.18",
|
||||
"@types/acorn": "^4.0.6",
|
||||
"@types/archiver": "^6.0.4",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/debug": "^4.1.13",
|
||||
"@types/express": "^5.0.6",
|
||||
@@ -16067,6 +16718,16 @@
|
||||
"typescript-eslint": "^8.58.1"
|
||||
}
|
||||
},
|
||||
"server/node_modules/@types/archiver": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-6.0.4.tgz",
|
||||
"integrity": "sha512-ULdQpARQ3sz9WH4nb98mJDYA0ft2A8C4f4fovvUcFwINa1cgGjY36JCAYuP5YypRq4mco1lJp1/7jEMS2oR0Hg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/readdir-glob": "*"
|
||||
}
|
||||
},
|
||||
"server/node_modules/ajv": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "liqa",
|
||||
"version": "1.6.1",
|
||||
"version": "1.12.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"server",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"acorn": "^8.16.0",
|
||||
"archiver": "^7.0.1",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.4",
|
||||
@@ -46,6 +47,7 @@
|
||||
"@nestjs/cli": "^11.0.17",
|
||||
"@nestjs/testing": "^11.1.18",
|
||||
"@types/acorn": "^4.0.6",
|
||||
"@types/archiver": "^6.0.4",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/debug": "^4.1.13",
|
||||
"@types/express": "^5.0.6",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -39,8 +42,27 @@ export interface ScriptContext {
|
||||
log: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
/**
|
||||
* Runs `fn` and requires it to return `true`. Logs the outcome under
|
||||
* `description`, then throws if `fn` returned anything other than `true`
|
||||
* or threw an exception.
|
||||
*/
|
||||
assert: (
|
||||
fn: () => boolean | Promise<boolean>,
|
||||
description: string,
|
||||
) => Promise<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 +75,9 @@ export interface ExecContext {
|
||||
environment?: EnvironmentData | null;
|
||||
snippets?: Record<string, string> | null;
|
||||
result?: unknown;
|
||||
scenarioId?: string;
|
||||
runId?: string;
|
||||
fileService?: FileStorageService;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -91,6 +116,9 @@ export class CodeExecutorService {
|
||||
environment,
|
||||
snippets,
|
||||
result,
|
||||
scenarioId,
|
||||
runId,
|
||||
fileService,
|
||||
} = ctx;
|
||||
const scriptLog: ScriptLogger =
|
||||
log ?? ((level, msg) => this.logger[level](msg));
|
||||
@@ -122,6 +150,28 @@ export class CodeExecutorService {
|
||||
log: (...args: unknown[]) => scriptLog("log", toStr(args)),
|
||||
warn: (...args: unknown[]) => scriptLog("warn", toStr(args)),
|
||||
error: (...args: unknown[]) => scriptLog("error", toStr(args)),
|
||||
assert: async (
|
||||
fn: () => boolean | Promise<boolean>,
|
||||
description: string,
|
||||
): Promise<void> => {
|
||||
let passed: boolean;
|
||||
let failureReason: string | undefined;
|
||||
try {
|
||||
passed = (await fn()) === true;
|
||||
} catch (err) {
|
||||
passed = false;
|
||||
failureReason = (err as Error).message;
|
||||
}
|
||||
if (passed) {
|
||||
scriptLog("log", `Assertion passed: ${description}`);
|
||||
return;
|
||||
}
|
||||
const message = failureReason
|
||||
? `Assertion failed: ${description} (${failureReason})`
|
||||
: `Assertion failed: ${description}`;
|
||||
scriptLog("error", message);
|
||||
throw new Error(message);
|
||||
},
|
||||
getStepOutput: getStepOutput ?? (() => Promise.resolve(null)),
|
||||
getCredential: (alias: string): unknown => {
|
||||
if (!(alias in credMap)) {
|
||||
@@ -149,6 +199,7 @@ export class CodeExecutorService {
|
||||
if (snippetCode == null) {
|
||||
throw new Error(`Snippet alias "${alias}" not found`);
|
||||
}
|
||||
scriptLog("log", `Snippet "${alias}" started`);
|
||||
const snippetFn = new Function(
|
||||
"context",
|
||||
"console",
|
||||
@@ -156,7 +207,74 @@ export class CodeExecutorService {
|
||||
"expect",
|
||||
`return (async (context, ...args) => { ${snippetCode} })(context, ...snippetArgs)`,
|
||||
);
|
||||
return snippetFn(scriptContext, fakeConsole, args, playwrightExpect);
|
||||
try {
|
||||
const snippetResult = await snippetFn(
|
||||
scriptContext,
|
||||
fakeConsole,
|
||||
args,
|
||||
playwrightExpect,
|
||||
);
|
||||
scriptLog("log", `Snippet "${alias}" finished`);
|
||||
return snippetResult;
|
||||
} catch (err) {
|
||||
scriptLog(
|
||||
"error",
|
||||
`Snippet "${alias}" failed: ${(err as Error).message}`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
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),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -184,4 +302,26 @@ export class CodeExecutorService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Infers MIME type from filename.
|
||||
*/
|
||||
private getMimeType(filename: string): string {
|
||||
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
||||
const mimeTypes: Record<string, string> = {
|
||||
pdf: "application/pdf",
|
||||
doc: "application/msword",
|
||||
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
xls: "application/vnd.ms-excel",
|
||||
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
png: "image/png",
|
||||
gif: "image/gif",
|
||||
csv: "text/csv",
|
||||
txt: "text/plain",
|
||||
zip: "application/zip",
|
||||
};
|
||||
return mimeTypes[ext] ?? "application/octet-stream";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { BrowserContext, Page } from "playwright";
|
||||
import type { EnvironmentData } from "../environment/environment.entity";
|
||||
import type { FileStorageService } from "../file/file-storage.service";
|
||||
import type { ExecContext, ScriptLogger } from "./code-executor.service";
|
||||
|
||||
export class ExecContextBuilder {
|
||||
@@ -50,6 +51,21 @@ export class ExecContextBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
scenarioId(scenarioId: string): this {
|
||||
this.ctx.scenarioId = scenarioId;
|
||||
return this;
|
||||
}
|
||||
|
||||
runId(runId: string): this {
|
||||
this.ctx.runId = runId;
|
||||
return this;
|
||||
}
|
||||
|
||||
fileService(fileService: FileStorageService): this {
|
||||
this.ctx.fileService = fileService;
|
||||
return this;
|
||||
}
|
||||
|
||||
build(): ExecContext {
|
||||
if (!this.ctx.page) throw new Error("ExecContextBuilder: page is required");
|
||||
if (!this.ctx.browser)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
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
|
||||
|
||||
function decodeDataUrl(urlStr: string): Buffer {
|
||||
const match = /^data:([^,]*),(.*)$/s.exec(urlStr);
|
||||
if (!match) {
|
||||
throw new Error("Malformed data: URL");
|
||||
}
|
||||
const [, meta, data] = match;
|
||||
if (meta.endsWith(";base64")) {
|
||||
return Buffer.from(data, "base64");
|
||||
}
|
||||
return Buffer.from(decodeURIComponent(data), "utf-8");
|
||||
}
|
||||
|
||||
export async function downloadFile(
|
||||
urlStr: string,
|
||||
options?: {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
},
|
||||
): Promise<Buffer> {
|
||||
if (urlStr.startsWith("data:")) {
|
||||
return decodeDataUrl(urlStr);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/** Extracts the alias literals passed to `runSnippet("alias", ...)` calls in `code`. */
|
||||
export function extractRunSnippetAliases(code: string): string[] {
|
||||
const aliases: string[] = [];
|
||||
const regex = /runSnippet\s*\(\s*(['"`])((?:(?!\1).)*)\1/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(code)) !== null) {
|
||||
aliases.push(match[2]);
|
||||
}
|
||||
return aliases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the transitive closure of snippet aliases referenced (directly, or via
|
||||
* other snippets) by `stepCodes`, against the full alias -> code map. Aliases with
|
||||
* no matching entry in `snippetCodeByAlias` are dropped. Returned in discovery order.
|
||||
*/
|
||||
export function resolveUsedSnippetAliases(
|
||||
stepCodes: string[],
|
||||
snippetCodeByAlias: Record<string, string>,
|
||||
): string[] {
|
||||
const used: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const queue = stepCodes.flatMap((code) => extractRunSnippetAliases(code));
|
||||
while (queue.length > 0) {
|
||||
const alias = queue.shift()!;
|
||||
if (seen.has(alias)) continue;
|
||||
seen.add(alias);
|
||||
const code = snippetCodeByAlias[alias];
|
||||
if (code == null) continue;
|
||||
used.push(alias);
|
||||
queue.push(...extractRunSnippetAliases(code));
|
||||
}
|
||||
return used;
|
||||
}
|
||||
@@ -31,6 +31,16 @@ export class AppConfig {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
SESSION_DELETE_CLOSED_DAYS: number = 7;
|
||||
|
||||
@IsString()
|
||||
FILES_DIR: string = "data/files";
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
FILE_RUN_EXPIRATION_DAYS: number = 7;
|
||||
|
||||
@IsString()
|
||||
FILE_CLEANUP_CRON: string = "0 3 * * *";
|
||||
}
|
||||
|
||||
export function validateAppConfig(config: Record<string, unknown>): AppConfig {
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import { Injectable, OnModuleInit } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { SchedulerRegistry } from "@nestjs/schedule";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import * as crypto from "crypto";
|
||||
import * as fs from "fs/promises";
|
||||
import * as path from "path";
|
||||
import { CronJob } from "cron";
|
||||
import { Repository } from "typeorm";
|
||||
import { TraceLogger } from "../common/trace-logger";
|
||||
import { AppConfig } from "../config/app.config";
|
||||
import { FileEntity } from "./file.entity";
|
||||
import { ScenarioFileEntity } from "./scenario-file.entity";
|
||||
import { ScenarioRunFileEntity } from "./scenario-run-file.entity";
|
||||
|
||||
export interface FileMetadata {
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
sha256: string;
|
||||
expiresAt: Date | null;
|
||||
createdAt: Date;
|
||||
path: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FileStorageService implements OnModuleInit {
|
||||
private static readonly DEFAULT_PAGE_SIZE = 20;
|
||||
private readonly logger = new TraceLogger(FileStorageService.name);
|
||||
private readonly filesDir: string;
|
||||
private readonly fileRunExpirationDays: number;
|
||||
private readonly fileCleanupCron: string;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(FileEntity)
|
||||
readonly fileRepo: Repository<FileEntity>,
|
||||
@InjectRepository(ScenarioFileEntity)
|
||||
private readonly scenarioFileRepo: Repository<ScenarioFileEntity>,
|
||||
@InjectRepository(ScenarioRunFileEntity)
|
||||
private readonly scenarioRunFileRepo: Repository<ScenarioRunFileEntity>,
|
||||
configService: ConfigService<AppConfig, true>,
|
||||
private readonly schedulerRegistry: SchedulerRegistry,
|
||||
) {
|
||||
this.filesDir = configService.get("FILES_DIR");
|
||||
this.fileRunExpirationDays = configService.get("FILE_RUN_EXPIRATION_DAYS");
|
||||
this.fileCleanupCron = configService.get("FILE_CLEANUP_CRON");
|
||||
}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
// Ensure the files directory exists
|
||||
try {
|
||||
await fs.mkdir(this.filesDir, { recursive: true });
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to create FILES_DIR ${this.filesDir}: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
// Register cleanup cron job dynamically so the schedule is runtime-configurable
|
||||
const job = new CronJob(this.fileCleanupCron, () => {
|
||||
void this.cleanupExpiredFiles();
|
||||
});
|
||||
this.schedulerRegistry.addCronJob("file-cleanup", job);
|
||||
job.start();
|
||||
this.logger.debug(`File cleanup cron registered: ${this.fileCleanupCron}`);
|
||||
|
||||
// Run cleanup at startup to catch any backlog
|
||||
await this.cleanupExpiredFiles();
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a file to disk with sharded directory structure.
|
||||
* Returns the FileEntity (unsaved) with computed filePath.
|
||||
*/
|
||||
async saveFile(
|
||||
buffer: Buffer,
|
||||
originalName: string,
|
||||
mimeType: string,
|
||||
expiresAt?: Date,
|
||||
): Promise<FileEntity> {
|
||||
const fileId = crypto.randomUUID();
|
||||
const sha256 = crypto.createHash("sha256").update(buffer).digest("hex");
|
||||
const shardPath = this.getShardedPath(fileId);
|
||||
const fullPath = path.join(this.filesDir, shardPath);
|
||||
|
||||
// Create sharded directory
|
||||
const dirPath = path.dirname(fullPath);
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
|
||||
// Write file to disk and persist entity
|
||||
await fs.writeFile(fullPath, buffer);
|
||||
|
||||
const file = this.fileRepo.create({
|
||||
id: fileId,
|
||||
originalName,
|
||||
mimeType,
|
||||
size: buffer.length,
|
||||
sha256,
|
||||
filePath: shardPath,
|
||||
expiresAt: expiresAt ?? null,
|
||||
});
|
||||
|
||||
return this.fileRepo.save(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the sharded path for a file ID: xx/xxxx/uuid
|
||||
*/
|
||||
private getShardedPath(uuid: string): string {
|
||||
return `${uuid.substring(0, 2)}/${uuid.substring(0, 4)}/${uuid}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns absolute disk path for a file.
|
||||
*/
|
||||
getAbsolutePath(filePath: string): string {
|
||||
return path.join(this.filesDir, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists scenario files with pagination.
|
||||
*/
|
||||
async listScenarioFiles(
|
||||
scenarioId: string,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
): Promise<{ items: FileMetadata[]; total: number }> {
|
||||
const query = this.scenarioFileRepo
|
||||
.createQueryBuilder("sf")
|
||||
.innerJoinAndSelect("sf.file", "f")
|
||||
.where("sf.scenarioId = :scenarioId", { scenarioId });
|
||||
|
||||
const total = await query.getCount();
|
||||
|
||||
const items = await query
|
||||
.orderBy("sf.createdAt", "DESC")
|
||||
.skip(offset ?? 0)
|
||||
.take(limit ?? FileStorageService.DEFAULT_PAGE_SIZE)
|
||||
.getMany();
|
||||
return {
|
||||
items: items.map((sf) => this.mapFileEntityToMetadata(sf.file)),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists run artifact files with pagination.
|
||||
*/
|
||||
async listRunFiles(
|
||||
runId: string,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
): Promise<{ items: FileMetadata[]; total: number }> {
|
||||
const query = this.scenarioRunFileRepo
|
||||
.createQueryBuilder("srf")
|
||||
.innerJoinAndSelect("srf.file", "f")
|
||||
.where("srf.runId = :runId", { runId });
|
||||
|
||||
const total = await query.getCount();
|
||||
|
||||
const items = await query
|
||||
.orderBy("srf.createdAt", "DESC")
|
||||
.skip(offset ?? 0)
|
||||
.take(limit ?? FileStorageService.DEFAULT_PAGE_SIZE)
|
||||
.getMany();
|
||||
return {
|
||||
items: items.map((srf) => this.mapFileEntityToMetadata(srf.file)),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a file entity by ID.
|
||||
*/
|
||||
async getFile(fileId: string): Promise<FileEntity | null> {
|
||||
return this.fileRepo.findOneBy({ id: fileId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a file and checks for orphaned FileEntity rows.
|
||||
* If a FileEntity has no remaining mappings, it is deleted along with its physical file.
|
||||
*/
|
||||
async deleteFile(fileId: string): Promise<void> {
|
||||
const file = await this.fileRepo.findOneBy({ id: fileId });
|
||||
if (!file) return;
|
||||
|
||||
// Check if this file still has any mappings
|
||||
const scenarioCount = await this.scenarioFileRepo.countBy({ fileId });
|
||||
const runCount = await this.scenarioRunFileRepo.countBy({ fileId });
|
||||
|
||||
// If no mappings remain, delete the file and its physical copy
|
||||
if (scenarioCount === 0 && runCount === 0) {
|
||||
const fullPath = this.getAbsolutePath(file.filePath);
|
||||
try {
|
||||
await fs.unlink(fullPath);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to delete physical file ${fullPath}: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
await this.fileRepo.delete(fileId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a run artifact file entry with auto-expiration.
|
||||
*/
|
||||
async createRunArtifact(buffer: Buffer, originalName: string, mimeType: string): Promise<FileEntity> {
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + this.fileRunExpirationDays * 24 * 60 * 60 * 1000);
|
||||
return this.saveFile(buffer, originalName, mimeType, expiresAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a run artifact with file and mapping in the database.
|
||||
*/
|
||||
async createAndSaveRunArtifact(
|
||||
runId: string,
|
||||
buffer: Buffer,
|
||||
originalName: string,
|
||||
mimeType: string,
|
||||
): Promise<FileEntity> {
|
||||
const savedFile = await this.createRunArtifact(buffer, originalName, mimeType);
|
||||
|
||||
// Create run file mapping
|
||||
const runFile = this.scenarioRunFileRepo.create({
|
||||
runId,
|
||||
fileId: savedFile.id,
|
||||
});
|
||||
await this.scenarioRunFileRepo.save(runFile);
|
||||
|
||||
return savedFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup job: deletes expired FileEntity rows and orphaned physical files.
|
||||
* Runs via @Cron scheduler and at module init.
|
||||
*/
|
||||
async cleanupExpiredFiles(): Promise<void> {
|
||||
const now = new Date();
|
||||
const expiredFiles = await this.fileRepo
|
||||
.createQueryBuilder("file")
|
||||
.where("file.expiresAt IS NOT NULL")
|
||||
.andWhere("file.expiresAt <= :now", { now })
|
||||
.setParameters({ now })
|
||||
.getMany();
|
||||
|
||||
if (expiredFiles.length === 0) return;
|
||||
|
||||
this.logger.debug(`Cleaning up ${expiredFiles.length} expired files`);
|
||||
|
||||
for (const file of expiredFiles) {
|
||||
try {
|
||||
// Delete associated mapping rows first (cascade will be handled, but we delete manually to ensure orphan check works)
|
||||
await this.scenarioFileRepo.delete({ fileId: file.id });
|
||||
await this.scenarioRunFileRepo.delete({ fileId: file.id });
|
||||
|
||||
// Delete the file entity and physical file
|
||||
await this.deleteFile(file.id);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to clean up expired file ${file.id}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps FileEntity to FileMetadata with absolute path.
|
||||
*/
|
||||
private mapFileEntityToMetadata(file: FileEntity): FileMetadata {
|
||||
return {
|
||||
id: file.id,
|
||||
name: file.originalName,
|
||||
mimeType: file.mimeType,
|
||||
size: file.size,
|
||||
sha256: file.sha256,
|
||||
expiresAt: file.expiresAt,
|
||||
createdAt: file.createdAt,
|
||||
path: this.getAbsolutePath(file.filePath),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from "typeorm";
|
||||
|
||||
@Entity("files")
|
||||
export class FileEntity {
|
||||
@PrimaryGeneratedColumn("uuid")
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
originalName: string;
|
||||
|
||||
@Column()
|
||||
mimeType: string;
|
||||
|
||||
@Column({ type: "integer" })
|
||||
size: number;
|
||||
|
||||
@Column()
|
||||
sha256: string;
|
||||
|
||||
@Column()
|
||||
filePath: string;
|
||||
|
||||
@Column({ type: "datetime", nullable: true })
|
||||
expiresAt: Date | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { FileEntity } from "./file.entity";
|
||||
import { FileStorageService } from "./file-storage.service";
|
||||
import { ScenarioFileEntity } from "./scenario-file.entity";
|
||||
import { ScenarioRunFileEntity } from "./scenario-run-file.entity";
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([FileEntity, ScenarioFileEntity, ScenarioRunFileEntity])],
|
||||
providers: [FileStorageService],
|
||||
exports: [FileStorageService],
|
||||
})
|
||||
export class FileModule {}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from "typeorm";
|
||||
import { ScenarioEntity } from "../scenario/scenario.entity";
|
||||
import { FileEntity } from "./file.entity";
|
||||
|
||||
@Entity("scenario_files")
|
||||
export class ScenarioFileEntity {
|
||||
@PrimaryGeneratedColumn("uuid")
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
scenarioId: string;
|
||||
|
||||
@Column()
|
||||
fileId: string;
|
||||
|
||||
@ManyToOne(() => ScenarioEntity, { onDelete: "CASCADE" })
|
||||
@JoinColumn({ name: "scenarioId" })
|
||||
scenario: ScenarioEntity;
|
||||
|
||||
@ManyToOne(() => FileEntity, { onDelete: "CASCADE" })
|
||||
@JoinColumn({ name: "fileId" })
|
||||
file: FileEntity;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from "typeorm";
|
||||
import { ScenarioRunEntity } from "../scenario/scenario-run.entity";
|
||||
import { FileEntity } from "./file.entity";
|
||||
|
||||
@Entity("scenario_run_files")
|
||||
export class ScenarioRunFileEntity {
|
||||
@PrimaryGeneratedColumn("uuid")
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
runId: string;
|
||||
|
||||
@Column()
|
||||
fileId: string;
|
||||
|
||||
@ManyToOne(() => ScenarioRunEntity, { onDelete: "CASCADE" })
|
||||
@JoinColumn({ name: "runId" })
|
||||
run: ScenarioRunEntity;
|
||||
|
||||
@ManyToOne(() => FileEntity, { onDelete: "CASCADE" })
|
||||
@JoinColumn({ name: "fileId" })
|
||||
file: FileEntity;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -10,6 +10,21 @@ import { HttpExceptionFilter } from "./filters/http-exception.filter";
|
||||
import { LoggingInterceptor } from "./interceptors/logging.interceptor";
|
||||
import { TraceInterceptor } from "./interceptors/trace.interceptor";
|
||||
|
||||
// Guard against uncaught exceptions thrown inside Playwright event listener
|
||||
// callbacks (e.g. a bad waitForURL predicate). Without this, Node.js v15+
|
||||
// crashes the process on any unhandled rejection or uncaught exception.
|
||||
const processLogger = new TraceLogger("Process");
|
||||
process.on("uncaughtException", (err) => {
|
||||
processLogger.error(`Uncaught exception (process kept alive): ${err.message}`, err.stack);
|
||||
});
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
processLogger.error(
|
||||
`Unhandled promise rejection (process kept alive): ${
|
||||
reason instanceof Error ? reason.message : String(reason)
|
||||
}`,
|
||||
);
|
||||
});
|
||||
|
||||
async function bootstrap() {
|
||||
const API_PREFIX = "api/v1";
|
||||
const logger = new TraceLogger("Bootstrap");
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CredentialModule } from "../credential/credential.module";
|
||||
import { EnvironmentModule } from "../environment/environment.module";
|
||||
import { ScenarioModule } from "../scenario/scenario.module";
|
||||
import { SessionModule } from "../session/session.module";
|
||||
import { SnippetModule } from "../snippet/snippet.module";
|
||||
import { McpController } from "./mcp.controller";
|
||||
import { McpService } from "./mcp.service";
|
||||
|
||||
@@ -16,6 +17,7 @@ import { McpService } from "./mcp.service";
|
||||
BrowserModule,
|
||||
CodeExecutorModule,
|
||||
ScenarioModule,
|
||||
SnippetModule,
|
||||
],
|
||||
controllers: [McpController],
|
||||
providers: [McpService],
|
||||
|
||||
@@ -11,6 +11,8 @@ 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 { E2eExportService } from "../scenario/e2e-export.service";
|
||||
import { SnippetService } from "../snippet/snippet.service";
|
||||
|
||||
import pkg from "../../package.json";
|
||||
|
||||
@@ -24,6 +26,8 @@ export class McpService {
|
||||
private readonly browserService: BrowserService,
|
||||
private readonly codeExecutor: CodeExecutorService,
|
||||
private readonly scenarioService: ScenarioService,
|
||||
private readonly e2eExportService: E2eExportService,
|
||||
private readonly snippetService: SnippetService,
|
||||
) {}
|
||||
|
||||
private createServer(): McpServer {
|
||||
@@ -1060,6 +1064,463 @@ export class McpService {
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"export_e2e_test",
|
||||
{
|
||||
description:
|
||||
"Export a scenario as a standalone, plug-and-play Playwright e2e test project (package.json, playwright.config.ts, test.spec.ts, .env), zipped and returned as base64",
|
||||
inputSchema: {
|
||||
id: z.uuid().describe("Scenario ID to export"),
|
||||
},
|
||||
},
|
||||
async ({ id }) => {
|
||||
try {
|
||||
const { filename, buffer } =
|
||||
await this.e2eExportService.buildE2eTestPackage(id);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({
|
||||
filename,
|
||||
contentBase64: buffer.toString("base64"),
|
||||
encoding: "base64",
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── 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 ─────────────────────────────────────────────────────────────
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import archiver = require("archiver");
|
||||
import { resolveUsedSnippetAliases } from "../common/snippet-usage";
|
||||
import { SnippetService } from "../snippet/snippet.service";
|
||||
import { ScenarioService } from "./scenario.service";
|
||||
|
||||
const DEFAULT_SCENARIO_TIMEOUT_SEC = 600;
|
||||
const DEFAULT_STEP_TIMEOUT_SEC = 60;
|
||||
|
||||
function slugify(name: string): string {
|
||||
const slug = name
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return slug || "scenario";
|
||||
}
|
||||
|
||||
function jsStringLiteral(code: string): string {
|
||||
return JSON.stringify(code);
|
||||
}
|
||||
|
||||
function sanitizeAlias(alias: string): string {
|
||||
const sanitized = alias.replace(/[^A-Za-z0-9_-]+/g, "_").replace(/^_+|_+$/g, "");
|
||||
return sanitized || "snippet";
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a standalone, plug-and-play Playwright test project (package.json,
|
||||
* playwright.config.ts, test.spec.ts, .env, README) from a scenario's steps,
|
||||
* zipped for download. Each step's execCode is embedded verbatim and run
|
||||
* against a `context` shim that mirrors the shape scripts already use in
|
||||
* liqa (env, credentials, snippets, assert, ...).
|
||||
*/
|
||||
@Injectable()
|
||||
export class E2eExportService {
|
||||
constructor(
|
||||
private readonly scenarioService: ScenarioService,
|
||||
private readonly snippetService: SnippetService,
|
||||
) {}
|
||||
|
||||
async buildE2eTestPackage(scenarioId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const scenario = await this.scenarioService.findOne(scenarioId);
|
||||
const [credentialMap, snippetMap, attachedFiles] = await Promise.all([
|
||||
this.scenarioService.buildCredentialMap(scenarioId),
|
||||
this.snippetService.buildSnippetMap(),
|
||||
this.scenarioService.listScenarioFiles(scenarioId, 1000, 0),
|
||||
]);
|
||||
const envData = scenario.environment?.data ?? {};
|
||||
|
||||
const slug = slugify(scenario.name);
|
||||
const files: Record<string, string | Buffer> = {
|
||||
"package.json": this.buildPackageJson(slug),
|
||||
"playwright.config.ts": this.buildPlaywrightConfig(scenario.timeoutSeconds),
|
||||
".env": this.buildEnvFile(envData),
|
||||
"credentials.json": JSON.stringify(credentialMap, null, 2) + "\n",
|
||||
".gitignore": [
|
||||
"node_modules/",
|
||||
"test-results/",
|
||||
"playwright-report/",
|
||||
"downloads/",
|
||||
".env",
|
||||
"credentials.json",
|
||||
].join("\n") + "\n",
|
||||
"README.md": this.buildReadme(scenario.name, scenario.description),
|
||||
};
|
||||
|
||||
// Bundle any files attached to the scenario so getScenarioFiles works offline.
|
||||
const fileManifest: { id: string; name: string; mimeType: string; size: number; sha256: string; zipPath: string }[] =
|
||||
[];
|
||||
for (const item of attachedFiles.items as {
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
sha256: string;
|
||||
}[]) {
|
||||
const { contentBuffer } = await this.scenarioService.getScenarioFileContentAsBuffer(
|
||||
scenarioId,
|
||||
item.id,
|
||||
);
|
||||
const zipPath = `files/${item.id}__${item.name}`;
|
||||
files[zipPath] = contentBuffer;
|
||||
fileManifest.push({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
mimeType: item.mimeType,
|
||||
size: item.size,
|
||||
sha256: item.sha256,
|
||||
zipPath,
|
||||
});
|
||||
}
|
||||
|
||||
// Bundle only the snippets actually referenced (directly or transitively via
|
||||
// other snippets) by this scenario's steps, as individual .js files under snippets/.
|
||||
const usedAliases = resolveUsedSnippetAliases(
|
||||
scenario.steps.map((s) => s.execCode ?? ""),
|
||||
snippetMap,
|
||||
);
|
||||
const snippetManifest: { alias: string; filename: string }[] = [];
|
||||
const usedSnippetFilenames = new Set<string>();
|
||||
for (const alias of usedAliases) {
|
||||
const code = snippetMap[alias];
|
||||
const base = sanitizeAlias(alias);
|
||||
let filename = `${base}.js`;
|
||||
let suffix = 2;
|
||||
while (usedSnippetFilenames.has(filename)) {
|
||||
filename = `${base}-${suffix}.js`;
|
||||
suffix += 1;
|
||||
}
|
||||
usedSnippetFilenames.add(filename);
|
||||
files[`snippets/${filename}`] = code;
|
||||
snippetManifest.push({ alias, filename });
|
||||
}
|
||||
|
||||
files["test.spec.ts"] = this.buildTestSpec(scenario, snippetManifest, fileManifest);
|
||||
|
||||
const buffer = await this.zip(files);
|
||||
return { filename: `${slug}-e2e-test.zip`, buffer };
|
||||
}
|
||||
|
||||
private buildPackageJson(slug: string): string {
|
||||
return (
|
||||
JSON.stringify(
|
||||
{
|
||||
name: `${slug}-e2e-test`,
|
||||
version: "1.0.0",
|
||||
private: true,
|
||||
scripts: {
|
||||
test: "playwright test",
|
||||
"test:headed": "playwright test --headed",
|
||||
},
|
||||
devDependencies: {
|
||||
"@playwright/test": "^1.59.1",
|
||||
dotenv: "^17.4.2",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n"
|
||||
);
|
||||
}
|
||||
|
||||
private buildPlaywrightConfig(scenarioTimeoutSeconds: number | null): string {
|
||||
const timeoutMs = (scenarioTimeoutSeconds ?? DEFAULT_SCENARIO_TIMEOUT_SEC) * 1000;
|
||||
return `import { defineConfig } from "@playwright/test";
|
||||
import * as dotenv from "dotenv";
|
||||
import * as path from "path";
|
||||
|
||||
dotenv.config({ path: path.resolve(__dirname, ".env") });
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
timeout: ${timeoutMs},
|
||||
use: {
|
||||
headless: true,
|
||||
},
|
||||
});
|
||||
`;
|
||||
}
|
||||
|
||||
private buildEnvFile(envData: Record<string, string>): string {
|
||||
const lines = Object.entries(envData).map(([key, value]) => `${key}=${value}`);
|
||||
return (
|
||||
[
|
||||
"# Environment values copied from the scenario's liqa environment.",
|
||||
"# This file may contain secrets - do not commit it.",
|
||||
...lines,
|
||||
].join("\n") + "\n"
|
||||
);
|
||||
}
|
||||
|
||||
private buildReadme(scenarioName: string, description: string | null): string {
|
||||
const descriptionSection = description?.trim() ? `${description.trim()}\n\n` : "";
|
||||
return `# ${scenarioName} - standalone e2e test
|
||||
|
||||
${descriptionSection}## Setup
|
||||
|
||||
\`\`\`sh
|
||||
npm install
|
||||
npx playwright install --with-deps chromium
|
||||
\`\`\`
|
||||
|
||||
## Run
|
||||
|
||||
\`\`\`sh
|
||||
npm test
|
||||
\`\`\`
|
||||
|
||||
Environment values are loaded from \`.env\` (as plain process env vars). Credential
|
||||
data resolved from this scenario's aliases is stored in \`credentials.json\` and
|
||||
loaded by \`context.getCredential\`. Snippet bodies are stored as individual \`.js\`
|
||||
files under \`snippets/\` and loaded by \`context.runSnippet\`. Files attached to
|
||||
the scenario are bundled under \`files/\` and served locally by
|
||||
\`context.getScenarioFiles\`. \`context.downloadFile\` performs a real HTTP (or
|
||||
\`data:\` URL) download and saves the result under \`downloads/\`.
|
||||
`;
|
||||
}
|
||||
|
||||
private buildTestSpec(
|
||||
scenario: { name: string; timeoutSeconds: number | null; steps: { order: number; title: string | null; execCode: string | null; timeoutSeconds: number | null }[] },
|
||||
snippetManifest: { alias: string; filename: string }[],
|
||||
fileManifest: { id: string; name: string; mimeType: string; size: number; sha256: string; zipPath: string }[],
|
||||
): string {
|
||||
const steps = [...scenario.steps].sort((a, b) => a.order - b.order);
|
||||
const scenarioTimeoutSec = scenario.timeoutSeconds ?? DEFAULT_SCENARIO_TIMEOUT_SEC;
|
||||
|
||||
const stepBlocks = steps
|
||||
.map((step, index) => {
|
||||
const title = (step.title ?? `Step ${index + 1}`).replace(/`/g, "\\`");
|
||||
const timeoutSec = step.timeoutSeconds ?? scenarioTimeoutSec ?? DEFAULT_STEP_TIMEOUT_SEC;
|
||||
const code = step.execCode ?? "";
|
||||
return ` await test.step(\`${title}\`, async () => {
|
||||
const __run = new Function(
|
||||
"context",
|
||||
"console",
|
||||
"result",
|
||||
"expect",
|
||||
\`return (async (context) => { ${code.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${")} })(context)\`,
|
||||
);
|
||||
const __timeoutMs = ${timeoutSec * 1000};
|
||||
const __output = await Promise.race([
|
||||
__run(context, console, undefined, expect),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error(\`Step timed out after ${timeoutSec}s\`)), __timeoutMs),
|
||||
),
|
||||
]);
|
||||
stepOutputs.push({ order: ${step.order}, output: __output });
|
||||
});`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
return `import { test, expect } from "@playwright/test";
|
||||
import type { BrowserContext, Page } from "@playwright/test";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as crypto from "crypto";
|
||||
import * as http from "http";
|
||||
import * as https from "https";
|
||||
import { URL } from "url";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
// Credential data resolved from this scenario's aliases at export time.
|
||||
// Stored in credentials.json (gitignored) - contains sensitive values.
|
||||
const CREDENTIALS: Record<string, unknown> = JSON.parse(
|
||||
fs.readFileSync(path.resolve(__dirname, "credentials.json"), "utf-8"),
|
||||
);
|
||||
|
||||
// Snippet bodies are stored as individual .js files under snippets/, available
|
||||
// via context.runSnippet(alias, ...args).
|
||||
const SNIPPET_FILES: { alias: string; filename: string }[] = ${JSON.stringify(snippetManifest, null, 2)};
|
||||
|
||||
// Files attached to the scenario, bundled alongside this test under files/.
|
||||
const FILES: { id: string; name: string; mimeType: string; size: number; sha256: string; zipPath: string }[] =
|
||||
${JSON.stringify(fileManifest, null, 2)};
|
||||
|
||||
interface StepOutput {
|
||||
order: number;
|
||||
output: unknown;
|
||||
}
|
||||
|
||||
// ── Standalone downloadFile: real HTTP/data: URL fetch, no liqa backend involved ──
|
||||
|
||||
const MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024; // 100 MB
|
||||
const DOWNLOAD_TIMEOUT_MS = 30000;
|
||||
|
||||
function decodeDataUrl(urlStr: string): Buffer {
|
||||
const match = /^data:([^,]*),(.*)$/s.exec(urlStr);
|
||||
if (!match) {
|
||||
throw new Error("Malformed data: URL");
|
||||
}
|
||||
const [, meta, data] = match;
|
||||
if (meta.endsWith(";base64")) {
|
||||
return Buffer.from(data, "base64");
|
||||
}
|
||||
return Buffer.from(decodeURIComponent(data), "utf-8");
|
||||
}
|
||||
|
||||
function fetchBytes(
|
||||
urlStr: string,
|
||||
options?: { method?: string; headers?: Record<string, string>; body?: string },
|
||||
): Promise<Buffer> {
|
||||
if (urlStr.startsWith("data:")) {
|
||||
return Promise.resolve(decodeDataUrl(urlStr));
|
||||
}
|
||||
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: DOWNLOAD_TIMEOUT_MS },
|
||||
(res) => {
|
||||
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
fetchBytes(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_DOWNLOAD_SIZE) {
|
||||
req.destroy();
|
||||
reject(new Error(\`File exceeds maximum size of \${MAX_DOWNLOAD_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();
|
||||
});
|
||||
}
|
||||
|
||||
const MIME_TYPES: 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",
|
||||
};
|
||||
|
||||
function inferMimeType(filename: string): string {
|
||||
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
||||
return MIME_TYPES[ext] ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
function buildContext(page: Page, browser: BrowserContext, stepOutputs: StepOutput[]) {
|
||||
const context: Record<string, unknown> = {
|
||||
page,
|
||||
browser,
|
||||
env: { ...process.env },
|
||||
getEnv(key: string): string {
|
||||
const value = process.env[key];
|
||||
if (value == null) {
|
||||
throw new Error(\`Environment value "\${key}" is not defined\`);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
getCredential(alias: string): unknown {
|
||||
if (!(alias in CREDENTIALS)) {
|
||||
throw new Error(\`Credential alias "\${alias}" not found in this export\`);
|
||||
}
|
||||
return CREDENTIALS[alias];
|
||||
},
|
||||
log: (...args: unknown[]) => console.log(...args),
|
||||
warn: (...args: unknown[]) => console.warn(...args),
|
||||
error: (...args: unknown[]) => console.error(...args),
|
||||
assert: async (fn: () => boolean | Promise<boolean>, description: string): Promise<void> => {
|
||||
let passed: boolean;
|
||||
let failureReason: string | undefined;
|
||||
try {
|
||||
passed = (await fn()) === true;
|
||||
} catch (err) {
|
||||
passed = false;
|
||||
failureReason = (err as Error).message;
|
||||
}
|
||||
if (passed) {
|
||||
console.log(\`Assertion passed: \${description}\`);
|
||||
return;
|
||||
}
|
||||
const message = failureReason
|
||||
? \`Assertion failed: \${description} (\${failureReason})\`
|
||||
: \`Assertion failed: \${description}\`;
|
||||
console.error(message);
|
||||
throw new Error(message);
|
||||
},
|
||||
getStepOutput: async (order: number): Promise<unknown> => {
|
||||
const targetOrder = order < 0 ? stepOutputs.length + order + 1 : order;
|
||||
return stepOutputs.find((s) => s.order === targetOrder)?.output ?? null;
|
||||
},
|
||||
runSnippet: async (alias: string, ...args: unknown[]): Promise<unknown> => {
|
||||
const entry = SNIPPET_FILES.find((s) => s.alias === alias);
|
||||
if (!entry) {
|
||||
throw new Error(\`Snippet alias "\${alias}" not found\`);
|
||||
}
|
||||
const snippetCode = fs.readFileSync(path.resolve(__dirname, "snippets", entry.filename), "utf-8");
|
||||
const snippetFn = new Function(
|
||||
"context",
|
||||
"console",
|
||||
"snippetArgs",
|
||||
"expect",
|
||||
\`return (async (context, ...args) => { \${snippetCode} })(context, ...snippetArgs)\`,
|
||||
);
|
||||
return snippetFn(context, console, args, expect);
|
||||
},
|
||||
dumpDom: async (): Promise<never> => {
|
||||
throw new Error("dumpDom is not available in this standalone export");
|
||||
},
|
||||
getScenarioFiles: async (opts?: { limit?: number; offset?: number }) => {
|
||||
const offset = opts?.offset ?? 0;
|
||||
const limit = opts?.limit ?? FILES.length;
|
||||
const items = FILES.slice(offset, offset + limit).map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
mimeType: f.mimeType,
|
||||
size: f.size,
|
||||
sha256: f.sha256,
|
||||
path: path.resolve(__dirname, f.zipPath),
|
||||
}));
|
||||
return { items, total: FILES.length };
|
||||
},
|
||||
downloadFile: async (
|
||||
url: string,
|
||||
opts?: { method?: string; headers?: Record<string, string>; body?: string; filename?: string },
|
||||
) => {
|
||||
const buffer = await fetchBytes(url, opts);
|
||||
const filename =
|
||||
opts?.filename ??
|
||||
(url.startsWith("data:") ? "download" : new URL(url).pathname.split("/").pop() || "download");
|
||||
const downloadsDir = path.resolve(__dirname, "downloads");
|
||||
fs.mkdirSync(downloadsDir, { recursive: true });
|
||||
const filePath = path.join(downloadsDir, filename);
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
return {
|
||||
id: randomUUID(),
|
||||
name: filename,
|
||||
mimeType: inferMimeType(filename),
|
||||
size: buffer.length,
|
||||
sha256: crypto.createHash("sha256").update(buffer).digest("hex"),
|
||||
path: filePath,
|
||||
};
|
||||
},
|
||||
};
|
||||
return context;
|
||||
}
|
||||
|
||||
test.describe(${jsStringLiteral(scenario.name)}, () => {
|
||||
test(${jsStringLiteral(scenario.name)}, async ({ page, context: browserContext }) => {
|
||||
const stepOutputs: StepOutput[] = [];
|
||||
const context = buildContext(page, browserContext, stepOutputs);
|
||||
|
||||
${stepBlocks}
|
||||
});
|
||||
});
|
||||
`;
|
||||
}
|
||||
|
||||
private zip(files: Record<string, string | Buffer>): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const archive = archiver("zip", { zlib: { level: 9 } });
|
||||
const chunks: Buffer[] = [];
|
||||
archive.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
archive.on("error", (err) => reject(err));
|
||||
archive.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
archive.append(content, { name });
|
||||
}
|
||||
void archive.finalize();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,11 @@ export class ScenarioRunLogEntity {
|
||||
@Column({ type: "text" })
|
||||
message: string;
|
||||
|
||||
// Assigned in call order per run, since concurrent unawaited writes can
|
||||
// otherwise persist out of order (createdAt alone isn't a reliable tiebreaker).
|
||||
@Column({ type: "int", default: 0 })
|
||||
seq: number;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@@ -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,11 @@ 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>();
|
||||
// Monotonic log sequence per run, since persistLog writes are unawaited and
|
||||
// can otherwise resolve out of call order
|
||||
private readonly runLogSeq = new Map<string, number>();
|
||||
|
||||
private static readonly DEFAULT_SCENARIO_TIMEOUT_SEC = 600;
|
||||
private static readonly DEFAULT_STEP_TIMEOUT_SEC = 60;
|
||||
@@ -62,6 +68,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> {
|
||||
@@ -134,8 +141,10 @@ export class ScenarioSchedulerService implements OnModuleInit {
|
||||
level: "log" | "warn" | "error",
|
||||
message: string,
|
||||
): void {
|
||||
const seq = (this.runLogSeq.get(runId) ?? 0) + 1;
|
||||
this.runLogSeq.set(runId, seq);
|
||||
void this.runLogRepo.save(
|
||||
this.runLogRepo.create({ runId, stepRunId, level, message }),
|
||||
this.runLogRepo.create({ runId, stepRunId, level, message, seq }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -198,6 +207,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 +220,8 @@ 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.runLogSeq.delete(run.id);
|
||||
this.logger.error(
|
||||
`Run #${run.id}: setup failed — ${String(err)}`,
|
||||
);
|
||||
@@ -272,6 +284,8 @@ export class ScenarioSchedulerService implements OnModuleInit {
|
||||
this.runSnippets.delete(runId);
|
||||
this.runEnvironments.delete(runId);
|
||||
this.runScenarioTimeouts.delete(runId);
|
||||
this.runScenarioIds.delete(runId);
|
||||
this.runLogSeq.delete(runId);
|
||||
await this.maybePreserveSession(runId);
|
||||
}
|
||||
}
|
||||
@@ -338,6 +352,12 @@ export class ScenarioSchedulerService implements OnModuleInit {
|
||||
this.logger.log(
|
||||
`StepRun #${stepRun.id} (run #${stepRun.runId}, step #${step.id} order=${step.order}) → in_progress`,
|
||||
);
|
||||
this.persistLog(
|
||||
stepRun.runId,
|
||||
stepRun.id,
|
||||
"log",
|
||||
`Step ${step.order} started${step.title ? `: ${step.title}` : ""}`,
|
||||
);
|
||||
|
||||
try {
|
||||
if (!step.execCode) throw new Error("step has no execCode");
|
||||
@@ -349,6 +369,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 +379,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 +391,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}`);
|
||||
@@ -434,6 +466,12 @@ export class ScenarioSchedulerService implements OnModuleInit {
|
||||
output !== null && output !== undefined ? JSON.stringify(output) : null;
|
||||
await this.runStepRepo.save(stepRun);
|
||||
this.logger.log(`StepRun #${stepRun.id} → pass`);
|
||||
this.persistLog(
|
||||
stepRun.runId,
|
||||
stepRun.id,
|
||||
"log",
|
||||
`Step ${(stepRun.scenarioStep as ScenarioStepEntity | undefined)?.order ?? "?"} passed`,
|
||||
);
|
||||
|
||||
// Find the next waiting step in this run (next by order)
|
||||
const nextStep = await this.runStepRepo.findOne({
|
||||
@@ -470,6 +508,14 @@ export class ScenarioSchedulerService implements OnModuleInit {
|
||||
stepRun.description = description;
|
||||
await this.runStepRepo.save(stepRun);
|
||||
this.logger.log(`StepRun #${stepRun.id} → fail: ${description}`);
|
||||
this.persistLog(
|
||||
stepRun.runId,
|
||||
stepRun.id,
|
||||
"error",
|
||||
`Step ${(stepRun.scenarioStep as ScenarioStepEntity | undefined)?.order ?? "?"} failed: ${description}`,
|
||||
);
|
||||
|
||||
await this.dumpPageOnFailure(stepRun);
|
||||
|
||||
// Cancel all remaining waiting/pending step runs in this run
|
||||
await this.runStepRepo
|
||||
@@ -490,4 +536,51 @@ export class ScenarioSchedulerService implements OnModuleInit {
|
||||
await this.runRepo.update(stepRun.runId, { status: "fail" });
|
||||
this.logger.log(`Run #${stepRun.runId} → fail`);
|
||||
}
|
||||
|
||||
// ── Page dump on failure ────────────────────────────────────────────────────
|
||||
|
||||
private async dumpPageOnFailure(
|
||||
stepRun: ScenarioRunStepEntity,
|
||||
): Promise<void> {
|
||||
const handle = this.runBrowsers.get(stepRun.runId);
|
||||
if (!handle) return; // no browser was ever opened for this run (e.g. setup error)
|
||||
|
||||
const step = stepRun.scenarioStep as ScenarioStepEntity | undefined;
|
||||
const namePrefix = `step-${step?.order ?? stepRun.id}-failure`;
|
||||
|
||||
try {
|
||||
const screenshot = await handle.page.screenshot({ fullPage: true });
|
||||
await this.fileStorageService.createAndSaveRunArtifact(
|
||||
stepRun.runId,
|
||||
screenshot,
|
||||
`${namePrefix}.png`,
|
||||
"image/png",
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`StepRun #${stepRun.id}: failed to capture failure screenshot — ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const html = await handle.page.content();
|
||||
await this.fileStorageService.createAndSaveRunArtifact(
|
||||
stepRun.runId,
|
||||
Buffer.from(html, "utf-8"),
|
||||
`${namePrefix}.html`,
|
||||
"text/html",
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`StepRun #${stepRun.id}: failed to capture failure page HTML — ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.persistLog(
|
||||
stepRun.runId,
|
||||
stepRun.id,
|
||||
"log",
|
||||
`Step ${step?.order ?? "?"} failed: page dump uploaded to run files`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -24,11 +28,15 @@ import { ExportEntity } from "./dto/scenario-export.dto";
|
||||
import { UpdateScenarioStepDto } from "./dto/update-scenario-step.dto";
|
||||
import { UpdateScenarioDto } from "./dto/update-scenario.dto";
|
||||
import { ScenarioService } from "./scenario.service";
|
||||
import { E2eExportService } from "./e2e-export.service";
|
||||
|
||||
@ApiTags("scenarios")
|
||||
@Controller("scenarios")
|
||||
export class ScenarioController {
|
||||
constructor(private readonly scenarioService: ScenarioService) {}
|
||||
constructor(
|
||||
private readonly scenarioService: ScenarioService,
|
||||
private readonly e2eExportService: E2eExportService,
|
||||
) {}
|
||||
|
||||
// ── Scenarios ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -126,6 +134,23 @@ export class ScenarioController {
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
@Get(":id/export-e2e")
|
||||
@ApiOperation({
|
||||
summary: "Export a scenario as a standalone, plug-and-play Playwright e2e test (zip)",
|
||||
})
|
||||
@ApiResponse({ status: 200, description: "Zip archive" })
|
||||
@ApiResponse({ status: 404, description: "Scenario not found" })
|
||||
async exportE2eTest(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { filename, buffer } = await this.e2eExportService.buildE2eTestPackage(id);
|
||||
res.setHeader("Content-Type", "application/zip");
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
|
||||
res.setHeader("Content-Length", buffer.length);
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
// ── Steps ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@Post(":id/steps")
|
||||
@@ -174,6 +199,16 @@ export class ScenarioController {
|
||||
return this.scenarioService.removeStep(id, stepId);
|
||||
}
|
||||
|
||||
@Get(":id/used-snippets")
|
||||
@ApiOperation({
|
||||
summary: "List snippets referenced (directly or transitively) by this scenario's steps",
|
||||
})
|
||||
@ApiResponse({ status: 200 })
|
||||
@ApiResponse({ status: 404, description: "Scenario not found" })
|
||||
getUsedSnippets(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.scenarioService.getUsedSnippets(id);
|
||||
}
|
||||
|
||||
// ── Credentials ───────────────────────────────────────────────────────────
|
||||
|
||||
@Get(":id/credentials")
|
||||
@@ -256,6 +291,29 @@ export class ScenarioController {
|
||||
return this.scenarioService.findRun(id, runId, q);
|
||||
}
|
||||
|
||||
@Get(":id/run/:runId/logs/export")
|
||||
@ApiOperation({ summary: "Export run logs as CSV or Markdown" })
|
||||
@ApiResponse({ status: 200 })
|
||||
@ApiResponse({ status: 404, description: "Scenario or run not found" })
|
||||
async exportRunLogs(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("runId", ParseUUIDPipe) runId: string,
|
||||
@Query("format") format: string | undefined,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const fmt = format === "csv" ? "csv" : "md";
|
||||
const content = await this.scenarioService.exportRunLogs(id, runId, fmt);
|
||||
const contentType =
|
||||
fmt === "csv" ? "text/csv; charset=utf-8" : "text/markdown; charset=utf-8";
|
||||
const ext = fmt === "csv" ? "csv" : "md";
|
||||
res.setHeader("Content-Type", contentType);
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="run-${runId}-logs.${ext}"`,
|
||||
);
|
||||
return content;
|
||||
}
|
||||
|
||||
@Post(":id/run/:runId/wait")
|
||||
@HttpCode(200)
|
||||
@ApiOperation({
|
||||
@@ -270,4 +328,78 @@ export class ScenarioController {
|
||||
) {
|
||||
return this.scenarioService.waitForRun(id, runId);
|
||||
}
|
||||
|
||||
// ── Files ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@Post(":id/files")
|
||||
@UseInterceptors(FileInterceptor("file"))
|
||||
@ApiOperation({ summary: "Upload a file to a scenario" })
|
||||
@ApiResponse({ status: 201, description: "File uploaded" })
|
||||
@ApiResponse({ status: 404, description: "Scenario not found" })
|
||||
uploadFile(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: MulterFile,
|
||||
@Body() body: { expiresAt?: string },
|
||||
) {
|
||||
return this.scenarioService.uploadFile(id, file, body.expiresAt);
|
||||
}
|
||||
|
||||
@Get(":id/files")
|
||||
@ApiOperation({ summary: "List files attached to a scenario" })
|
||||
@ApiResponse({ status: 200 })
|
||||
@ApiResponse({ status: 404, description: "Scenario not found" })
|
||||
listScenarioFiles(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Query("limit") limit?: string,
|
||||
@Query("offset") offset?: string,
|
||||
) {
|
||||
return this.scenarioService.listScenarioFiles(
|
||||
id,
|
||||
limit ? parseInt(limit, 10) : undefined,
|
||||
offset ? parseInt(offset, 10) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(":id/files/:fileId/content")
|
||||
@ApiOperation({ summary: "Download file content" })
|
||||
@ApiResponse({ status: 200, description: "File content" })
|
||||
@ApiResponse({ status: 404, description: "Scenario or file not found" })
|
||||
async getScenarioFileContent(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("fileId", ParseUUIDPipe) fileId: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
return this.scenarioService.getScenarioFileContent(id, fileId, res);
|
||||
}
|
||||
|
||||
@Get(":id/runs/:runId/files")
|
||||
@ApiOperation({ summary: "List files created during a run" })
|
||||
@ApiResponse({ status: 200 })
|
||||
@ApiResponse({ status: 404, description: "Scenario or run not found" })
|
||||
listRunFiles(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("runId", ParseUUIDPipe) runId: string,
|
||||
@Query("limit") limit?: string,
|
||||
@Query("offset") offset?: string,
|
||||
) {
|
||||
return this.scenarioService.listRunFiles(
|
||||
id,
|
||||
runId,
|
||||
limit ? parseInt(limit, 10) : undefined,
|
||||
offset ? parseInt(offset, 10) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(":id/runs/:runId/files/:fileId/content")
|
||||
@ApiOperation({ summary: "Download run artifact file content" })
|
||||
@ApiResponse({ status: 200, description: "File content" })
|
||||
@ApiResponse({ status: 404, description: "Scenario, run, or file not found" })
|
||||
async getRunFileContent(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("runId", ParseUUIDPipe) runId: string,
|
||||
@Param("fileId", ParseUUIDPipe) fileId: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
return this.scenarioService.getRunFileContent(id, runId, fileId, res);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import { CodeExecutorModule } from "../code-executor/code-executor.module";
|
||||
import { CredentialEntity } from "../credential/credential.entity";
|
||||
import { EnvironmentEntity } from "../environment/environment.entity";
|
||||
import { EnvironmentModule } from "../environment/environment.module";
|
||||
import { FileModule } from "../file/file.module";
|
||||
import { ScenarioFileEntity } from "../file/scenario-file.entity";
|
||||
import { ScenarioRunFileEntity } from "../file/scenario-run-file.entity";
|
||||
import { SessionModule } from "../session/session.module";
|
||||
import { SnippetModule } from "../snippet/snippet.module";
|
||||
import { ScenarioCredentialEntity } from "./scenario-credential.entity";
|
||||
@@ -15,6 +18,7 @@ import { ScenarioStepEntity } from "./scenario-step.entity";
|
||||
import { ScenarioController } from "./scenario.controller";
|
||||
import { ScenarioEntity } from "./scenario.entity";
|
||||
import { ScenarioService } from "./scenario.service";
|
||||
import { E2eExportService } from "./e2e-export.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -27,14 +31,17 @@ import { ScenarioService } from "./scenario.service";
|
||||
ScenarioCredentialEntity,
|
||||
CredentialEntity,
|
||||
EnvironmentEntity,
|
||||
ScenarioFileEntity,
|
||||
ScenarioRunFileEntity,
|
||||
]),
|
||||
CodeExecutorModule,
|
||||
SessionModule,
|
||||
EnvironmentModule,
|
||||
SnippetModule,
|
||||
FileModule,
|
||||
],
|
||||
controllers: [ScenarioController],
|
||||
providers: [ScenarioService, ScenarioSchedulerService],
|
||||
exports: [ScenarioService],
|
||||
providers: [ScenarioService, ScenarioSchedulerService, E2eExportService],
|
||||
exports: [ScenarioService, E2eExportService],
|
||||
})
|
||||
export class ScenarioModule {}
|
||||
|
||||
@@ -5,13 +5,21 @@ 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,
|
||||
PaginationQueryDto,
|
||||
} from "../common/dto/pagination.dto";
|
||||
import { resolveUsedSnippetAliases } from "../common/snippet-usage";
|
||||
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 { SnippetEntity } from "../snippet/snippet.entity";
|
||||
import { SnippetService } from "../snippet/snippet.service";
|
||||
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 +38,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 +63,12 @@ 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,
|
||||
private readonly snippetService: SnippetService,
|
||||
) {}
|
||||
|
||||
// ── Scenarios ─────────────────────────────────────────────────────────────
|
||||
@@ -63,7 +79,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 +89,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> {
|
||||
@@ -288,6 +336,25 @@ export class ScenarioService {
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the snippets referenced (directly, or transitively via other snippets)
|
||||
* by this scenario's step code, in discovery order - for linking to them from the UI.
|
||||
*/
|
||||
async getUsedSnippets(scenarioId: string): Promise<SnippetEntity[]> {
|
||||
const scenario = await this.findOne(scenarioId);
|
||||
const snippetMap = await this.snippetService.buildSnippetMap();
|
||||
const usedAliases = resolveUsedSnippetAliases(
|
||||
scenario.steps.map((s) => s.execCode ?? ""),
|
||||
snippetMap,
|
||||
);
|
||||
if (usedAliases.length === 0) return [];
|
||||
const snippets = await this.snippetService.findByAliases(usedAliases);
|
||||
const byAlias = new Map(snippets.map((s) => [s.alias, s]));
|
||||
return usedAliases
|
||||
.map((alias) => byAlias.get(alias))
|
||||
.filter((s): s is SnippetEntity => s != null);
|
||||
}
|
||||
|
||||
// ── Runs ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async findRuns(
|
||||
@@ -358,11 +425,49 @@ export class ScenarioService {
|
||||
);
|
||||
const logs = await this.runLogRepo.find({
|
||||
where: q?.trim() ? { runId, message: Like(`%${q.trim()}%`) } : { runId },
|
||||
order: { createdAt: "ASC" },
|
||||
order: { seq: "ASC", createdAt: "ASC" },
|
||||
});
|
||||
return Object.assign(run, { logs });
|
||||
}
|
||||
|
||||
async exportRunLogs(
|
||||
scenarioId: string,
|
||||
runId: string,
|
||||
format: "csv" | "md",
|
||||
): Promise<string> {
|
||||
await this.findOne(scenarioId); // 404 guard
|
||||
const run = await this.runRepo.findOne({
|
||||
where: { id: runId, scenarioId },
|
||||
});
|
||||
if (!run)
|
||||
throw new NotFoundException(
|
||||
`Run ${runId} not found in scenario ${scenarioId}`,
|
||||
);
|
||||
const logs = await this.runLogRepo.find({
|
||||
where: { runId },
|
||||
order: { seq: "ASC", createdAt: "ASC" },
|
||||
});
|
||||
|
||||
if (format === "csv") {
|
||||
const escapeCsv = (value: string): string =>
|
||||
/[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
|
||||
const rows = [
|
||||
["Timestamp", "Level", "Message"].map(escapeCsv).join(","),
|
||||
...logs.map((l) =>
|
||||
[l.createdAt.toISOString(), l.level, l.message]
|
||||
.map((v) => escapeCsv(String(v)))
|
||||
.join(","),
|
||||
),
|
||||
];
|
||||
return rows.join("\n");
|
||||
}
|
||||
|
||||
const sections = logs.map(
|
||||
(l) => `## [${l.level}] ${l.createdAt.toISOString()}\n\n${l.message}`,
|
||||
);
|
||||
return [`# Run ${runId} logs`, ...sections].join("\n\n");
|
||||
}
|
||||
|
||||
async waitForRun(
|
||||
scenarioId: string,
|
||||
runId: string,
|
||||
@@ -619,4 +724,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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
OnModuleInit,
|
||||
} from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { In, Repository } from "typeorm";
|
||||
import {
|
||||
PaginatedResult,
|
||||
PaginationQueryDto,
|
||||
@@ -111,6 +111,12 @@ export class SnippetService implements OnModuleInit {
|
||||
return Object.fromEntries(data.map((s) => [s.alias, s.code]));
|
||||
}
|
||||
|
||||
/** Returns the snippet entities matching the given aliases (used by executor/executor consumers). */
|
||||
async findByAliases(aliases: string[]): Promise<SnippetEntity[]> {
|
||||
if (aliases.length === 0) return [];
|
||||
return this.repo.find({ where: { alias: In(aliases) } });
|
||||
}
|
||||
|
||||
exportSnippet(snippet: SnippetEntity): SnippetExportDto {
|
||||
return {
|
||||
kind: "snippet",
|
||||
|
||||
@@ -48,6 +48,9 @@ import { HealthController } from "../src/health/health.controller";
|
||||
import { HttpExceptionFilter } from "../src/filters/http-exception.filter";
|
||||
import { LoggingInterceptor } from "../src/interceptors/logging.interceptor";
|
||||
import { validateAppConfig } from "../src/config/app.config";
|
||||
import { FileEntity } from "../src/file/file.entity";
|
||||
import { ScenarioFileEntity } from "../src/file/scenario-file.entity";
|
||||
import { ScenarioRunFileEntity } from "../src/file/scenario-run-file.entity";
|
||||
|
||||
export async function buildTestApp(): Promise<INestApplication> {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
@@ -71,6 +74,9 @@ export async function buildTestApp(): Promise<INestApplication> {
|
||||
ScenarioCredentialEntity,
|
||||
CredentialEntity,
|
||||
SnippetEntity,
|
||||
FileEntity,
|
||||
ScenarioFileEntity,
|
||||
ScenarioRunFileEntity,
|
||||
],
|
||||
synchronize: true,
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { getRepositoryToken } from "@nestjs/typeorm";
|
||||
import * as http from "http";
|
||||
import * as fs from "fs/promises";
|
||||
import { Repository } from "typeorm";
|
||||
import { buildTestApp } from "./app.harness";
|
||||
import { FileStorageService } from "../src/file/file-storage.service";
|
||||
import { FileEntity } from "../src/file/file.entity";
|
||||
import { ScenarioFileEntity } from "../src/file/scenario-file.entity";
|
||||
import { ScenarioRunFileEntity } from "../src/file/scenario-run-file.entity";
|
||||
import { ScenarioEntity } from "../src/scenario/scenario.entity";
|
||||
import { ScenarioStepEntity } from "../src/scenario/scenario-step.entity";
|
||||
import { ScenarioRunEntity } from "../src/scenario/scenario-run.entity";
|
||||
import { ScenarioRunStepEntity } from "../src/scenario/scenario-run-step.entity";
|
||||
import { ScenarioSchedulerService } from "../src/scenario/scenario-scheduler.service";
|
||||
|
||||
describe("Exec Context File Methods Integration Tests", () => {
|
||||
let app: INestApplication;
|
||||
let fileStorageService: FileStorageService;
|
||||
let scheduler: ScenarioSchedulerService;
|
||||
let fileRepo: Repository<FileEntity>;
|
||||
let scenarioFileRepo: Repository<ScenarioFileEntity>;
|
||||
let scenarioRunFileRepo: Repository<ScenarioRunFileEntity>;
|
||||
let scenarioRepo: Repository<ScenarioEntity>;
|
||||
let stepRepo: Repository<ScenarioStepEntity>;
|
||||
let runRepo: Repository<ScenarioRunEntity>;
|
||||
let runStepRepo: Repository<ScenarioRunStepEntity>;
|
||||
let filesDir: string;
|
||||
let testServer: http.Server;
|
||||
let testServerUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildTestApp();
|
||||
fileStorageService = app.get(FileStorageService);
|
||||
scheduler = app.get(ScenarioSchedulerService);
|
||||
fileRepo = app.get<Repository<FileEntity>>(getRepositoryToken(FileEntity));
|
||||
scenarioFileRepo = app.get<Repository<ScenarioFileEntity>>(
|
||||
getRepositoryToken(ScenarioFileEntity),
|
||||
);
|
||||
scenarioRunFileRepo = app.get<Repository<ScenarioRunFileEntity>>(
|
||||
getRepositoryToken(ScenarioRunFileEntity),
|
||||
);
|
||||
scenarioRepo = app.get<Repository<ScenarioEntity>>(
|
||||
getRepositoryToken(ScenarioEntity),
|
||||
);
|
||||
stepRepo = app.get<Repository<ScenarioStepEntity>>(
|
||||
getRepositoryToken(ScenarioStepEntity),
|
||||
);
|
||||
runRepo = app.get<Repository<ScenarioRunEntity>>(
|
||||
getRepositoryToken(ScenarioRunEntity),
|
||||
);
|
||||
runStepRepo = app.get<Repository<ScenarioRunStepEntity>>(
|
||||
getRepositoryToken(ScenarioRunStepEntity),
|
||||
);
|
||||
|
||||
filesDir = fileStorageService.getAbsolutePath("");
|
||||
|
||||
// Start a test HTTP server for downloadFile tests
|
||||
testServer = await createTestServer();
|
||||
testServerUrl = `http://localhost:${(testServer.address() as any).port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Close the test server
|
||||
if (testServer) {
|
||||
testServer.close();
|
||||
}
|
||||
|
||||
// Clean up files directory
|
||||
try {
|
||||
await fs.rm(filesDir, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
// Ignore errors if directory doesn't exist
|
||||
}
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function createTestServer(): Promise<http.Server> {
|
||||
return new Promise((resolve) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === "/test-file.bin") {
|
||||
res.writeHead(200, { "Content-Type": "application/octet-stream" });
|
||||
res.end(Buffer.from("test downloaded file content"));
|
||||
} else if (req.url === "/test.pdf") {
|
||||
res.writeHead(200, { "Content-Type": "application/pdf" });
|
||||
res.end(Buffer.from("fake pdf content"));
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(0, "localhost", () => {
|
||||
resolve(server);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function createScenario(name = "test-scenario") {
|
||||
const scenario = await scenarioRepo.save(
|
||||
scenarioRepo.create({ name }),
|
||||
);
|
||||
return scenario;
|
||||
}
|
||||
|
||||
async function createStep(
|
||||
scenarioId: string,
|
||||
execCode: string,
|
||||
order = 0,
|
||||
): Promise<ScenarioStepEntity> {
|
||||
const step = await stepRepo.save(
|
||||
stepRepo.create({
|
||||
scenarioId,
|
||||
order,
|
||||
execCode,
|
||||
}),
|
||||
);
|
||||
return step;
|
||||
}
|
||||
|
||||
async function createRun(scenarioId: string) {
|
||||
const run = await runRepo.save(
|
||||
runRepo.create({ scenarioId, status: "pending" }),
|
||||
);
|
||||
return run;
|
||||
}
|
||||
|
||||
async function createRunStep(
|
||||
runId: string,
|
||||
scenarioStepId: string,
|
||||
order = 0,
|
||||
) {
|
||||
const runStep = await runStepRepo.save(
|
||||
runStepRepo.create({
|
||||
runId,
|
||||
scenarioStepId,
|
||||
order,
|
||||
status: "pending",
|
||||
}),
|
||||
);
|
||||
return runStep;
|
||||
}
|
||||
|
||||
async function waitForRunCompletion(
|
||||
runId: string,
|
||||
maxAttempts = 50,
|
||||
): Promise<ScenarioRunEntity> {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
// Run scheduler pickup once
|
||||
await scheduler.pickUpPendingRuns();
|
||||
|
||||
// Wait a bit for execution
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const run = await runRepo.findOneBy({ id: runId });
|
||||
if (run && (run.status === "pass" || run.status === "fail")) {
|
||||
return run;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Run ${runId} did not complete within timeout`);
|
||||
}
|
||||
|
||||
// ── context.getScenarioFiles() ─────────────────────────────────────────────
|
||||
|
||||
describe("context.getScenarioFiles()", () => {
|
||||
it("returns scenario files during a run", async () => {
|
||||
const scenario = await createScenario("get-scenario-files-test");
|
||||
|
||||
// Upload a file to the scenario
|
||||
const fileBuffer = Buffer.from("scenario file content");
|
||||
const uploadedFile = await fileStorageService.saveFile(
|
||||
fileBuffer,
|
||||
"test-file.txt",
|
||||
"text/plain",
|
||||
);
|
||||
|
||||
// Create scenario file mapping
|
||||
await scenarioFileRepo.save(
|
||||
scenarioFileRepo.create({
|
||||
scenarioId: scenario.id,
|
||||
fileId: uploadedFile.id,
|
||||
}),
|
||||
);
|
||||
|
||||
// Create a step that calls getScenarioFiles
|
||||
const step = await createStep(
|
||||
scenario.id,
|
||||
"const files = await context.getScenarioFiles(); return files;",
|
||||
);
|
||||
|
||||
// Create a run and run step
|
||||
const run = await createRun(scenario.id);
|
||||
await createRunStep(run.id, step.id);
|
||||
|
||||
// Wait for execution
|
||||
const completedRun = await waitForRunCompletion(run.id);
|
||||
|
||||
// Verify run completed successfully
|
||||
expect(completedRun.status).toBe("pass");
|
||||
|
||||
// Get the step run to check output
|
||||
const runStep = await runStepRepo.findOne({
|
||||
where: { runId: run.id },
|
||||
relations: ["scenarioStep"],
|
||||
});
|
||||
|
||||
expect(runStep).toBeDefined();
|
||||
expect(runStep!.output).toBeDefined();
|
||||
|
||||
const output = runStep!.output ? JSON.parse(runStep!.output) : null;
|
||||
expect(output).toBeDefined();
|
||||
expect(output.items).toBeDefined();
|
||||
expect(Array.isArray(output.items)).toBe(true);
|
||||
expect(output.total).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Find the uploaded file in the output
|
||||
const foundFile = output.items.find((f: any) => f.id === uploadedFile.id);
|
||||
expect(foundFile).toBeDefined();
|
||||
expect(foundFile.name).toBe("test-file.txt");
|
||||
expect(foundFile.mimeType).toBe("text/plain");
|
||||
expect(foundFile.size).toBe(fileBuffer.length);
|
||||
expect(foundFile.sha256).toBeDefined();
|
||||
expect(foundFile.path).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns empty list when no files are attached to scenario", async () => {
|
||||
const scenario = await createScenario("no-files-test");
|
||||
|
||||
// Create a step that calls getScenarioFiles
|
||||
const step = await createStep(
|
||||
scenario.id,
|
||||
"const files = await context.getScenarioFiles(); return files;",
|
||||
);
|
||||
|
||||
// Create a run and run step
|
||||
const run = await createRun(scenario.id);
|
||||
await createRunStep(run.id, step.id);
|
||||
|
||||
// Wait for execution
|
||||
const completedRun = await waitForRunCompletion(run.id);
|
||||
|
||||
expect(completedRun.status).toBe("pass");
|
||||
|
||||
const runStep = await runStepRepo.findOne({
|
||||
where: { runId: run.id },
|
||||
});
|
||||
|
||||
const output = runStep!.output ? JSON.parse(runStep!.output) : null;
|
||||
expect(output).toBeDefined();
|
||||
expect(output.items).toEqual([]);
|
||||
expect(output.total).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── context.downloadFile() ────────────────────────────────────────────────
|
||||
|
||||
describe("context.downloadFile()", () => {
|
||||
it("downloads a file and creates a run artifact", async () => {
|
||||
const scenario = await createScenario("download-file-test");
|
||||
const downloadUrl = `${testServerUrl}/test.pdf`;
|
||||
|
||||
// Create a step that downloads a file
|
||||
const step = await createStep(
|
||||
scenario.id,
|
||||
`const result = await context.downloadFile('${downloadUrl}'); return result;`,
|
||||
);
|
||||
|
||||
// Create a run and run step
|
||||
const run = await createRun(scenario.id);
|
||||
await createRunStep(run.id, step.id);
|
||||
|
||||
// Wait for execution
|
||||
const completedRun = await waitForRunCompletion(run.id);
|
||||
|
||||
expect(completedRun.status).toBe("pass");
|
||||
|
||||
// Get the step run to check output
|
||||
const runStep = await runStepRepo.findOne({
|
||||
where: { runId: run.id },
|
||||
relations: ["scenarioStep"],
|
||||
});
|
||||
|
||||
const output = runStep!.output ? JSON.parse(runStep!.output) : null;
|
||||
expect(output).toBeDefined();
|
||||
expect(output.id).toBeDefined();
|
||||
expect(output.name).toBeDefined();
|
||||
expect(output.mimeType).toBe("application/pdf");
|
||||
expect(output.size).toBeGreaterThan(0);
|
||||
expect(output.sha256).toBeDefined();
|
||||
expect(output.path).toBeDefined();
|
||||
|
||||
// Verify the file was created as a run artifact
|
||||
const runFileMapping = await scenarioRunFileRepo.findOne({
|
||||
where: { runId: run.id, fileId: output.id },
|
||||
});
|
||||
expect(runFileMapping).toBeDefined();
|
||||
|
||||
// Verify the file exists on disk
|
||||
const file = await fileRepo.findOneBy({ id: output.id });
|
||||
expect(file).toBeDefined();
|
||||
const fullPath = fileStorageService.getAbsolutePath(file!.filePath);
|
||||
await fs.access(fullPath);
|
||||
|
||||
// Verify file content matches what was downloaded
|
||||
const diskContent = await fs.readFile(fullPath);
|
||||
expect(diskContent).toEqual(Buffer.from("fake pdf content"));
|
||||
|
||||
// Verify the file has an expiresAt date set
|
||||
expect(file!.expiresAt).toBeDefined();
|
||||
});
|
||||
|
||||
it("downloads file with custom filename", async () => {
|
||||
const scenario = await createScenario("download-custom-filename-test");
|
||||
const downloadUrl = `${testServerUrl}/test-file.bin`;
|
||||
|
||||
// Create a step that downloads a file with a custom filename
|
||||
const step = await createStep(
|
||||
scenario.id,
|
||||
`const result = await context.downloadFile('${downloadUrl}', { filename: 'custom.dat' }); return result;`,
|
||||
);
|
||||
|
||||
// Create a run and run step
|
||||
const run = await createRun(scenario.id);
|
||||
await createRunStep(run.id, step.id);
|
||||
|
||||
// Wait for execution
|
||||
const completedRun = await waitForRunCompletion(run.id);
|
||||
|
||||
expect(completedRun.status).toBe("pass");
|
||||
|
||||
const runStep = await runStepRepo.findOne({
|
||||
where: { runId: run.id },
|
||||
});
|
||||
|
||||
const output = runStep!.output ? JSON.parse(runStep!.output) : null;
|
||||
expect(output.name).toBe("custom.dat");
|
||||
});
|
||||
|
||||
it("creates ScenarioRunFileEntity mapping after download", async () => {
|
||||
const scenario = await createScenario("download-mapping-test");
|
||||
const downloadUrl = `${testServerUrl}/test.pdf`;
|
||||
|
||||
// Create a step
|
||||
const step = await createStep(
|
||||
scenario.id,
|
||||
`const result = await context.downloadFile('${downloadUrl}'); return result;`,
|
||||
);
|
||||
|
||||
// Create a run and run step
|
||||
const run = await createRun(scenario.id);
|
||||
await createRunStep(run.id, step.id);
|
||||
|
||||
// Wait for execution
|
||||
await waitForRunCompletion(run.id);
|
||||
|
||||
// Verify that a ScenarioRunFileEntity was created
|
||||
const runFileCount = await scenarioRunFileRepo.count({
|
||||
where: { runId: run.id },
|
||||
});
|
||||
expect(runFileCount).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Verify the mapping links to the correct run
|
||||
const mappings = await scenarioRunFileRepo.find({
|
||||
where: { runId: run.id },
|
||||
});
|
||||
|
||||
expect(mappings.length).toBeGreaterThanOrEqual(1);
|
||||
for (const mapping of mappings) {
|
||||
expect(mapping.runId).toBe(run.id);
|
||||
expect(mapping.fileId).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("file returned by downloadFile has correct metadata", async () => {
|
||||
const scenario = await createScenario("download-metadata-test");
|
||||
const downloadUrl = `${testServerUrl}/test.pdf`;
|
||||
|
||||
// Create a step
|
||||
const step = await createStep(
|
||||
scenario.id,
|
||||
`const result = await context.downloadFile('${downloadUrl}');
|
||||
return {
|
||||
hasId: result.id !== undefined,
|
||||
hasName: result.name !== undefined,
|
||||
hasMimeType: result.mimeType !== undefined,
|
||||
hasSize: result.size > 0,
|
||||
hasSha256: result.sha256 !== undefined,
|
||||
hasPath: result.path !== undefined
|
||||
};`,
|
||||
);
|
||||
|
||||
// Create a run and run step
|
||||
const run = await createRun(scenario.id);
|
||||
await createRunStep(run.id, step.id);
|
||||
|
||||
// Wait for execution
|
||||
const completedRun = await waitForRunCompletion(run.id);
|
||||
|
||||
expect(completedRun.status).toBe("pass");
|
||||
|
||||
const runStep = await runStepRepo.findOne({
|
||||
where: { runId: run.id },
|
||||
});
|
||||
|
||||
const output = runStep!.output ? JSON.parse(runStep!.output) : null;
|
||||
expect(output).toBeDefined();
|
||||
expect(output.hasId).toBe(true);
|
||||
expect(output.hasName).toBe(true);
|
||||
expect(output.hasMimeType).toBe(true);
|
||||
expect(output.hasSize).toBe(true);
|
||||
expect(output.hasSha256).toBe(true);
|
||||
expect(output.hasPath).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,421 @@
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { getRepositoryToken } from "@nestjs/typeorm";
|
||||
import request from "supertest";
|
||||
import * as fs from "fs/promises";
|
||||
import { Repository } from "typeorm";
|
||||
import { buildTestApp } from "./app.harness";
|
||||
import { FileStorageService } from "../src/file/file-storage.service";
|
||||
import { FileEntity } from "../src/file/file.entity";
|
||||
import { ScenarioEntity } from "../src/scenario/scenario.entity";
|
||||
import { ScenarioRunEntity } from "../src/scenario/scenario-run.entity";
|
||||
|
||||
describe("File Controller Integration Tests", () => {
|
||||
let app: INestApplication;
|
||||
let fileStorageService: FileStorageService;
|
||||
let fileRepo: Repository<FileEntity>;
|
||||
let scenarioRepo: Repository<ScenarioEntity>;
|
||||
let runRepo: Repository<ScenarioRunEntity>;
|
||||
let filesDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildTestApp();
|
||||
fileStorageService = app.get(FileStorageService);
|
||||
fileRepo = app.get<Repository<FileEntity>>(getRepositoryToken(FileEntity));
|
||||
scenarioRepo = app.get<Repository<ScenarioEntity>>(
|
||||
getRepositoryToken(ScenarioEntity),
|
||||
);
|
||||
runRepo = app.get<Repository<ScenarioRunEntity>>(
|
||||
getRepositoryToken(ScenarioRunEntity),
|
||||
);
|
||||
|
||||
// Get the files directory from the service
|
||||
filesDir = fileStorageService.getAbsolutePath("");
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up files directory
|
||||
try {
|
||||
await fs.rm(filesDir, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
// Ignore errors if directory doesn't exist
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function createScenario(name = "test-scenario") {
|
||||
const scenario = await scenarioRepo.save(
|
||||
scenarioRepo.create({ name }),
|
||||
);
|
||||
return scenario;
|
||||
}
|
||||
|
||||
async function createRun(scenarioId: string) {
|
||||
const run = await runRepo.save(
|
||||
runRepo.create({ scenarioId, status: "pending" }),
|
||||
);
|
||||
return run;
|
||||
}
|
||||
|
||||
// ── POST /scenarios/:id/files ──────────────────────────────────────────────
|
||||
|
||||
describe("POST /scenarios/:id/files", () => {
|
||||
it("uploads a file and returns 201 with metadata", async () => {
|
||||
const scenario = await createScenario("upload-test");
|
||||
const fileBuffer = Buffer.from("test file content");
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(`/scenarios/${scenario.id}/files`)
|
||||
.attach("file", fileBuffer, "test.txt")
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.id).toBeDefined();
|
||||
expect(res.body.name).toBe("test.txt");
|
||||
expect(res.body.mimeType).toBe("text/plain");
|
||||
expect(res.body.size).toBe(fileBuffer.length);
|
||||
expect(res.body.createdAt).toBeDefined();
|
||||
|
||||
// Verify file exists on disk
|
||||
const savedFile = await fileRepo.findOneBy({ id: res.body.id });
|
||||
expect(savedFile).toBeDefined();
|
||||
const fullPath = fileStorageService.getAbsolutePath(savedFile!.filePath);
|
||||
await fs.access(fullPath);
|
||||
});
|
||||
|
||||
it("uploads a file with expiresAt and includes it in response", async () => {
|
||||
const scenario = await createScenario("upload-expires-test");
|
||||
const fileBuffer = Buffer.from("expires file");
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(`/scenarios/${scenario.id}/files`)
|
||||
.attach("file", fileBuffer, "expires.txt")
|
||||
.field("expiresAt", expiresAt)
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.expiresAt).toBeDefined();
|
||||
// Verify expiresAt is close to the one we sent
|
||||
const returnedExpires = new Date(res.body.expiresAt);
|
||||
const sentExpires = new Date(expiresAt);
|
||||
expect(Math.abs(returnedExpires.getTime() - sentExpires.getTime())).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it("returns 404 when scenario does not exist", async () => {
|
||||
const nonExistentId = "00000000-0000-0000-0000-000000000000";
|
||||
const fileBuffer = Buffer.from("test");
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/scenarios/${nonExistentId}/files`)
|
||||
.attach("file", fileBuffer, "test.txt")
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("returns 400 when no file is provided", async () => {
|
||||
const scenario = await createScenario("no-file-test");
|
||||
|
||||
// Send a request with no file
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(`/scenarios/${scenario.id}/files`)
|
||||
.send({});
|
||||
|
||||
// Should either be 400 or 422 depending on validation
|
||||
expect([400, 422]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /scenarios/:id/files ───────────────────────────────────────────────
|
||||
|
||||
describe("GET /scenarios/:id/files", () => {
|
||||
it("lists files attached to a scenario", async () => {
|
||||
const scenario = await createScenario("list-test");
|
||||
const fileBuffer = Buffer.from("list test file");
|
||||
|
||||
// Upload a file
|
||||
await request(app.getHttpServer())
|
||||
.post(`/scenarios/${scenario.id}/files`)
|
||||
.attach("file", fileBuffer, "listed.txt")
|
||||
.expect(201);
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${scenario.id}/files`)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.items).toBeDefined();
|
||||
expect(Array.isArray(res.body.items)).toBe(true);
|
||||
expect(res.body.items.length).toBeGreaterThanOrEqual(1);
|
||||
expect(res.body.total).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const file = res.body.items.find((f: any) => f.name === "listed.txt");
|
||||
expect(file).toBeDefined();
|
||||
expect(file.id).toBeDefined();
|
||||
expect(file.mimeType).toBe("text/plain");
|
||||
expect(file.size).toBe(fileBuffer.length);
|
||||
});
|
||||
|
||||
it("respects limit and offset pagination", async () => {
|
||||
const scenario = await createScenario("pagination-test");
|
||||
|
||||
// Upload 3 files
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await request(app.getHttpServer())
|
||||
.post(`/scenarios/${scenario.id}/files`)
|
||||
.attach("file", Buffer.from(`file ${i}`), `file${i}.txt`)
|
||||
.expect(201);
|
||||
}
|
||||
|
||||
// List with limit=2, offset=0
|
||||
const page1 = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${scenario.id}/files`)
|
||||
.query({ limit: 2, offset: 0 })
|
||||
.expect(200);
|
||||
|
||||
expect(page1.body.items).toHaveLength(2);
|
||||
expect(page1.body.total).toBe(3);
|
||||
|
||||
// List with limit=2, offset=2
|
||||
const page2 = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${scenario.id}/files`)
|
||||
.query({ limit: 2, offset: 2 })
|
||||
.expect(200);
|
||||
|
||||
expect(page2.body.items.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("returns 404 when scenario does not exist", async () => {
|
||||
const nonExistentId = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get(`/scenarios/${nonExistentId}/files`)
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /scenarios/:id/files/:fileId/content ───────────────────────────────
|
||||
|
||||
describe("GET /scenarios/:id/files/:fileId/content", () => {
|
||||
it("downloads file content with correct bytes and Content-Type header", async () => {
|
||||
const scenario = await createScenario("download-test");
|
||||
const fileBuffer = Buffer.from("download test content");
|
||||
|
||||
// Upload the file
|
||||
const uploadRes = await request(app.getHttpServer())
|
||||
.post(`/scenarios/${scenario.id}/files`)
|
||||
.attach("file", fileBuffer, "download.txt")
|
||||
.expect(201);
|
||||
|
||||
const fileId = uploadRes.body.id;
|
||||
|
||||
// Download content
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${scenario.id}/files/${fileId}/content`)
|
||||
.buffer(true)
|
||||
.parse((res, callback) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on("end", () => callback(null, Buffer.concat(chunks)));
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
// The response body should contain the file bytes
|
||||
expect((res.body as Buffer).toString()).toBe(fileBuffer.toString());
|
||||
expect(res.headers["content-type"]).toContain("text/plain");
|
||||
});
|
||||
|
||||
it("returns 404 when file does not exist", async () => {
|
||||
const scenario = await createScenario("not-found-test");
|
||||
const nonExistentFileId = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get(`/scenarios/${scenario.id}/files/${nonExistentFileId}/content`)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("returns 404 when file is not linked to scenario", async () => {
|
||||
const scenario1 = await createScenario("scenario-1");
|
||||
const scenario2 = await createScenario("scenario-2");
|
||||
|
||||
// Upload file to scenario1
|
||||
const uploadRes = await request(app.getHttpServer())
|
||||
.post(`/scenarios/${scenario1.id}/files`)
|
||||
.attach("file", Buffer.from("content"), "file.txt")
|
||||
.expect(201);
|
||||
|
||||
const fileId = uploadRes.body.id;
|
||||
|
||||
// Try to access from scenario2
|
||||
await request(app.getHttpServer())
|
||||
.get(`/scenarios/${scenario2.id}/files/${fileId}/content`)
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /scenarios/:id/runs/:runId/files ───────────────────────────────────
|
||||
|
||||
describe("GET /scenarios/:id/runs/:runId/files", () => {
|
||||
it("lists files created during a run", async () => {
|
||||
const scenario = await createScenario("run-files-list-test");
|
||||
const run = await createRun(scenario.id);
|
||||
|
||||
// Create a run artifact file
|
||||
const fileBuffer = Buffer.from("run artifact content");
|
||||
const savedFile = await fileStorageService.createAndSaveRunArtifact(
|
||||
run.id,
|
||||
fileBuffer,
|
||||
"artifact.txt",
|
||||
"text/plain",
|
||||
);
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${scenario.id}/runs/${run.id}/files`)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.items).toBeDefined();
|
||||
expect(Array.isArray(res.body.items)).toBe(true);
|
||||
expect(res.body.total).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const artifact = res.body.items.find((f: any) => f.id === savedFile.id);
|
||||
expect(artifact).toBeDefined();
|
||||
expect(artifact.name).toBe("artifact.txt");
|
||||
expect(artifact.size).toBe(fileBuffer.length);
|
||||
});
|
||||
|
||||
it("returns 404 when run does not exist", async () => {
|
||||
const scenario = await createScenario("run-not-found-test");
|
||||
const nonExistentRunId = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get(`/scenarios/${scenario.id}/runs/${nonExistentRunId}/files`)
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /scenarios/:id/runs/:runId/files/:fileId/content ──────────────────
|
||||
|
||||
describe("GET /scenarios/:id/runs/:runId/files/:fileId/content", () => {
|
||||
it("downloads run artifact content with correct bytes", async () => {
|
||||
const scenario = await createScenario("run-artifact-download-test");
|
||||
const run = await createRun(scenario.id);
|
||||
|
||||
// Create a run artifact
|
||||
const fileBuffer = Buffer.from("run artifact bytes");
|
||||
const savedFile = await fileStorageService.createAndSaveRunArtifact(
|
||||
run.id,
|
||||
fileBuffer,
|
||||
"run-artifact.bin",
|
||||
"application/octet-stream",
|
||||
);
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(
|
||||
`/scenarios/${scenario.id}/runs/${run.id}/files/${savedFile.id}/content`,
|
||||
)
|
||||
.buffer(true)
|
||||
.parse((res, callback) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on("end", () => callback(null, Buffer.concat(chunks)));
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect((res.body as Buffer).toString()).toBe(fileBuffer.toString());
|
||||
expect(res.headers["content-type"]).toContain("application/octet-stream");
|
||||
});
|
||||
|
||||
it("returns 404 when artifact file does not exist", async () => {
|
||||
const scenario = await createScenario("artifact-not-found");
|
||||
const run = await createRun(scenario.id);
|
||||
const nonExistentFileId = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get(
|
||||
`/scenarios/${scenario.id}/runs/${run.id}/files/${nonExistentFileId}/content`,
|
||||
)
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── File cleanup ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("File cleanup", () => {
|
||||
it("removes expired files from disk and database", async () => {
|
||||
const scenario = await createScenario("cleanup-test");
|
||||
|
||||
// Upload a file with a past expiration date
|
||||
const fileBuffer = Buffer.from("to be cleaned up");
|
||||
const savedFile = await fileStorageService.saveFile(
|
||||
fileBuffer,
|
||||
"cleanup.txt",
|
||||
"text/plain",
|
||||
new Date(Date.now() - 60 * 60 * 1000), // 1 hour ago
|
||||
);
|
||||
|
||||
// Verify file exists
|
||||
const fullPath = fileStorageService.getAbsolutePath(savedFile.filePath);
|
||||
await fs.access(fullPath);
|
||||
|
||||
// Run cleanup
|
||||
await fileStorageService.cleanupExpiredFiles();
|
||||
|
||||
// Verify file is deleted from database
|
||||
const deleted = await fileRepo.findOneBy({ id: savedFile.id });
|
||||
expect(deleted).toBeNull();
|
||||
|
||||
// Verify file is deleted from disk
|
||||
try {
|
||||
await fs.access(fullPath);
|
||||
fail("File should have been deleted");
|
||||
} catch (err) {
|
||||
// Expected: file not found
|
||||
expect((err as any).code).toBe("ENOENT");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not delete files with future expiration dates", async () => {
|
||||
const scenario = await createScenario("no-cleanup-test");
|
||||
|
||||
// Upload a file with a future expiration date
|
||||
const fileBuffer = Buffer.from("should not be cleaned");
|
||||
const futureDate = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
const savedFile = await fileStorageService.saveFile(
|
||||
fileBuffer,
|
||||
"keep.txt",
|
||||
"text/plain",
|
||||
futureDate,
|
||||
);
|
||||
|
||||
// Run cleanup
|
||||
await fileStorageService.cleanupExpiredFiles();
|
||||
|
||||
// Verify file still exists in database
|
||||
const kept = await fileRepo.findOneBy({ id: savedFile.id });
|
||||
expect(kept).toBeDefined();
|
||||
|
||||
// Verify file still exists on disk
|
||||
const fullPath = fileStorageService.getAbsolutePath(savedFile.filePath);
|
||||
await fs.access(fullPath);
|
||||
});
|
||||
|
||||
it("does not delete files without expiration dates", async () => {
|
||||
const scenario = await createScenario("no-expire-test");
|
||||
|
||||
// Upload a file without expiration
|
||||
const fileBuffer = Buffer.from("permanent file");
|
||||
const savedFile = await fileStorageService.saveFile(
|
||||
fileBuffer,
|
||||
"permanent.txt",
|
||||
"text/plain",
|
||||
);
|
||||
|
||||
// Run cleanup
|
||||
await fileStorageService.cleanupExpiredFiles();
|
||||
|
||||
// Verify file still exists
|
||||
const kept = await fileRepo.findOneBy({ id: savedFile.id });
|
||||
expect(kept).toBeDefined();
|
||||
|
||||
const fullPath = fileStorageService.getAbsolutePath(savedFile.filePath);
|
||||
await fs.access(fullPath);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,14 @@
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { getRepositoryToken } from "@nestjs/typeorm";
|
||||
import request from "supertest";
|
||||
import * as http from "http";
|
||||
import { Repository } from "typeorm";
|
||||
import { buildTestApp } from "./app.harness";
|
||||
import { ScenarioEntity } from "../src/scenario/scenario.entity";
|
||||
import { ScenarioStepEntity } from "../src/scenario/scenario-step.entity";
|
||||
import { ScenarioRunEntity } from "../src/scenario/scenario-run.entity";
|
||||
import { ScenarioRunStepEntity } from "../src/scenario/scenario-run-step.entity";
|
||||
import { ScenarioSchedulerService } from "../src/scenario/scenario-scheduler.service";
|
||||
|
||||
/**
|
||||
* MCP controller integration tests.
|
||||
@@ -16,15 +24,65 @@ import { buildTestApp } from "./app.harness";
|
||||
*/
|
||||
describe("McpController", () => {
|
||||
let app: INestApplication;
|
||||
let testServer: http.Server;
|
||||
let testServerUrl: string;
|
||||
let scheduler: ScenarioSchedulerService;
|
||||
let scenarioRepo: Repository<ScenarioEntity>;
|
||||
let stepRepo: Repository<ScenarioStepEntity>;
|
||||
let runRepo: Repository<ScenarioRunEntity>;
|
||||
let runStepRepo: Repository<ScenarioRunStepEntity>;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildTestApp();
|
||||
scheduler = app.get(ScenarioSchedulerService);
|
||||
scenarioRepo = app.get<Repository<ScenarioEntity>>(
|
||||
getRepositoryToken(ScenarioEntity),
|
||||
);
|
||||
stepRepo = app.get<Repository<ScenarioStepEntity>>(
|
||||
getRepositoryToken(ScenarioStepEntity),
|
||||
);
|
||||
runRepo = app.get<Repository<ScenarioRunEntity>>(
|
||||
getRepositoryToken(ScenarioRunEntity),
|
||||
);
|
||||
runStepRepo = app.get<Repository<ScenarioRunStepEntity>>(
|
||||
getRepositoryToken(ScenarioRunStepEntity),
|
||||
);
|
||||
|
||||
// Start a test HTTP server for downloadFile tests
|
||||
testServer = await createTestServer();
|
||||
testServerUrl = `http://localhost:${(testServer.address() as any).port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (testServer) {
|
||||
testServer.close();
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function createTestServer(): Promise<http.Server> {
|
||||
return new Promise((resolve) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === "/test-file.bin") {
|
||||
res.writeHead(200, { "Content-Type": "application/octet-stream" });
|
||||
res.end(Buffer.from("test downloaded file content"));
|
||||
} else if (req.url === "/test.pdf") {
|
||||
res.writeHead(200, { "Content-Type": "application/pdf" });
|
||||
res.end(Buffer.from("fake pdf content"));
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(0, "localhost", () => {
|
||||
resolve(server);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Parse the JSON-RPC payload from an SSE response body. */
|
||||
function parseSse(text: string): Record<string, unknown> {
|
||||
const match = text.match(/^data:\s*(.+)$/m);
|
||||
@@ -47,6 +105,49 @@ describe("McpController", () => {
|
||||
return { status: res.status, rpc: parseSse(res.text) };
|
||||
}
|
||||
|
||||
async function createScenario(name: string) {
|
||||
return scenarioRepo.save(scenarioRepo.create({ name }));
|
||||
}
|
||||
|
||||
async function createStep(scenarioId: string, execCode: string, order = 0) {
|
||||
return stepRepo.save(
|
||||
stepRepo.create({
|
||||
scenarioId,
|
||||
order,
|
||||
execCode,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function createRun(scenarioId: string) {
|
||||
return runRepo.save(runRepo.create({ scenarioId, status: "pending" }));
|
||||
}
|
||||
|
||||
async function createRunStep(runId: string, scenarioStepId: string, order = 0) {
|
||||
return runStepRepo.save(
|
||||
runStepRepo.create({
|
||||
runId,
|
||||
scenarioStepId,
|
||||
order,
|
||||
status: "pending",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForRunCompletion(runId: string, maxAttempts = 50) {
|
||||
for (let index = 0; index < maxAttempts; index += 1) {
|
||||
await scheduler.pickUpPendingRuns();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const run = await runRepo.findOneBy({ id: runId });
|
||||
if (run && (run.status === "pass" || run.status === "fail")) {
|
||||
return run;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Run ${runId} did not complete within timeout`);
|
||||
}
|
||||
|
||||
// ── Connectivity ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /mcp — connectivity", () => {
|
||||
@@ -284,4 +385,462 @@ describe("McpController", () => {
|
||||
expect(cleared.timeoutSeconds).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Scenario File Tools ────────────────────────────────────────────────────
|
||||
|
||||
describe("upload_scenario_file", () => {
|
||||
it("uploads a small text file via MCP and returns metadata", async () => {
|
||||
const scRpc = (
|
||||
await mcpCall("create_scenario", { name: "mcp-file-upload-test" })
|
||||
).rpc;
|
||||
const sc = JSON.parse(
|
||||
(scRpc.result as { content: { text: string }[] }).content[0].text,
|
||||
) as { id: string };
|
||||
|
||||
const content = "Hello, MCP!";
|
||||
const contentBase64 = Buffer.from(content).toString("base64");
|
||||
|
||||
const { status, rpc } = await mcpCall("upload_scenario_file", {
|
||||
scenarioId: sc.id,
|
||||
name: "test.txt",
|
||||
contentBase64,
|
||||
mimeType: "text/plain",
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
const result = rpc.result as { content: { text: string }[] };
|
||||
const uploaded = JSON.parse(result.content[0].text) as {
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
};
|
||||
expect(uploaded.name).toBe("test.txt");
|
||||
expect(uploaded.mimeType).toBe("text/plain");
|
||||
expect(uploaded.size).toBe(content.length);
|
||||
expect(typeof uploaded.id).toBe("string");
|
||||
});
|
||||
|
||||
it("returns MCP error for invalid base64", async () => {
|
||||
const scRpc = (
|
||||
await mcpCall("create_scenario", {
|
||||
name: "mcp-file-bad-base64-test",
|
||||
})
|
||||
).rpc;
|
||||
const sc = JSON.parse(
|
||||
(scRpc.result as { content: { text: string }[] }).content[0].text,
|
||||
) as { id: string };
|
||||
|
||||
const { status, rpc } = await mcpCall("upload_scenario_file", {
|
||||
scenarioId: sc.id,
|
||||
name: "test.txt",
|
||||
contentBase64: "!@#$%^&*()",
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
const result = rpc.result as { isError: boolean };
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("list_scenario_files", () => {
|
||||
it("returns uploaded files with metadata", async () => {
|
||||
const scRpc = (
|
||||
await mcpCall("create_scenario", { name: "mcp-file-list-test" })
|
||||
).rpc;
|
||||
const sc = JSON.parse(
|
||||
(scRpc.result as { content: { text: string }[] }).content[0].text,
|
||||
) as { id: string };
|
||||
|
||||
// Upload a file
|
||||
const content = "Test content";
|
||||
const contentBase64 = Buffer.from(content).toString("base64");
|
||||
await mcpCall("upload_scenario_file", {
|
||||
scenarioId: sc.id,
|
||||
name: "test.txt",
|
||||
contentBase64,
|
||||
});
|
||||
|
||||
// List files
|
||||
const { status, rpc } = await mcpCall("list_scenario_files", {
|
||||
scenarioId: sc.id,
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
const result = rpc.result as { content: { text: string }[] };
|
||||
const list = JSON.parse(result.content[0].text) as {
|
||||
items: Array<{ id: string; name: string }>;
|
||||
total: number;
|
||||
};
|
||||
expect(list.items.length).toBeGreaterThan(0);
|
||||
expect(list.items[0].name).toBe("test.txt");
|
||||
expect(typeof list.total).toBe("number");
|
||||
});
|
||||
|
||||
it("returns MCP error for non-existent scenario", async () => {
|
||||
const { status, rpc } = await mcpCall("list_scenario_files", {
|
||||
scenarioId: "00000000-0000-0000-0000-000000000000",
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
const result = rpc.result as { isError: boolean };
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get_scenario_file_content", () => {
|
||||
it("retrieves file content as base64 with metadata", async () => {
|
||||
const scRpc = (
|
||||
await mcpCall("create_scenario", { name: "mcp-file-content-test" })
|
||||
).rpc;
|
||||
const sc = JSON.parse(
|
||||
(scRpc.result as { content: { text: string }[] }).content[0].text,
|
||||
) as { id: string };
|
||||
|
||||
// Upload a file
|
||||
const originalContent = "Hello, this is test content!";
|
||||
const contentBase64 = Buffer.from(originalContent).toString("base64");
|
||||
const uploadRpc = (
|
||||
await mcpCall("upload_scenario_file", {
|
||||
scenarioId: sc.id,
|
||||
name: "content-test.txt",
|
||||
contentBase64,
|
||||
mimeType: "text/plain",
|
||||
})
|
||||
).rpc;
|
||||
const uploaded = JSON.parse(
|
||||
(uploadRpc.result as { content: { text: string }[] }).content[0].text,
|
||||
) as { id: string };
|
||||
|
||||
// Retrieve content
|
||||
const { status, rpc } = await mcpCall("get_scenario_file_content", {
|
||||
scenarioId: sc.id,
|
||||
fileId: uploaded.id,
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
const result = rpc.result as { content: { text: string }[] };
|
||||
const response = JSON.parse(result.content[0].text) as {
|
||||
file: { id: string; name: string; size: number };
|
||||
contentBase64: string;
|
||||
encoding: string;
|
||||
};
|
||||
|
||||
// Verify roundtrip
|
||||
const retrievedContent = Buffer.from(response.contentBase64, "base64").toString(
|
||||
"utf8",
|
||||
);
|
||||
expect(retrievedContent).toBe(originalContent);
|
||||
expect(response.file.name).toBe("content-test.txt");
|
||||
expect(response.file.size).toBe(originalContent.length);
|
||||
expect(response.encoding).toBe("base64");
|
||||
});
|
||||
|
||||
it("returns MCP error for unknown file ID", async () => {
|
||||
const scRpc = (
|
||||
await mcpCall("create_scenario", {
|
||||
name: "mcp-file-unknown-id-test",
|
||||
})
|
||||
).rpc;
|
||||
const sc = JSON.parse(
|
||||
(scRpc.result as { content: { text: string }[] }).content[0].text,
|
||||
) as { id: string };
|
||||
|
||||
const { status, rpc } = await mcpCall("get_scenario_file_content", {
|
||||
scenarioId: sc.id,
|
||||
fileId: "00000000-0000-0000-0000-000000000000",
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
const result = rpc.result as { isError: boolean };
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Run Artifact Tools ─────────────────────────────────────────────────────
|
||||
|
||||
async function createRunArtifactFixture(name: string) {
|
||||
const scenario = await createScenario(name);
|
||||
const step = await createStep(
|
||||
scenario.id,
|
||||
`return await context.downloadFile('${testServerUrl}/test-file.bin', { filename: 'artifact.bin' });`,
|
||||
);
|
||||
const run = await createRun(scenario.id);
|
||||
await createRunStep(run.id, step.id);
|
||||
|
||||
const completedRun = await waitForRunCompletion(run.id);
|
||||
expect(completedRun.status).toBe("pass");
|
||||
|
||||
return { sc: scenario, run: completedRun };
|
||||
}
|
||||
|
||||
describe("list_run_files", () => {
|
||||
it("returns artifacts created during a run with expiry metadata", async () => {
|
||||
const { sc, run } = await createRunArtifactFixture("mcp-run-files-test");
|
||||
|
||||
const { status, rpc } = await mcpCall("list_run_files", {
|
||||
scenarioId: sc.id,
|
||||
runId: run.id,
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
const result = rpc.result as { content: { text: string }[] };
|
||||
const list = JSON.parse(result.content[0].text) as {
|
||||
items: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
expiresAt: string | null;
|
||||
}>;
|
||||
total: number;
|
||||
};
|
||||
|
||||
expect(list.total).toBe(1);
|
||||
expect(list.items).toHaveLength(1);
|
||||
expect(list.items[0].name).toBe("artifact.bin");
|
||||
expect(list.items[0].mimeType).toBe("application/octet-stream");
|
||||
expect(list.items[0].expiresAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it("returns MCP error for non-existent scenario", async () => {
|
||||
const { status, rpc } = await mcpCall("list_run_files", {
|
||||
scenarioId: "00000000-0000-0000-0000-000000000000",
|
||||
runId: "00000000-0000-0000-0000-000000000000",
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
const result = rpc.result as { isError: boolean };
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get_run_file_content", () => {
|
||||
it("returns run artifact bytes as base64 for a valid file", async () => {
|
||||
const { sc, run } = await createRunArtifactFixture("mcp-run-file-content-test");
|
||||
|
||||
const listRpc = (
|
||||
await mcpCall("list_run_files", {
|
||||
scenarioId: sc.id,
|
||||
runId: run.id,
|
||||
})
|
||||
).rpc;
|
||||
const list = JSON.parse(
|
||||
(listRpc.result as { content: { text: string }[] }).content[0].text,
|
||||
) as {
|
||||
items: Array<{ id: string; name: string; mimeType: string }>;
|
||||
};
|
||||
const file = list.items[0];
|
||||
|
||||
const { status, rpc } = await mcpCall("get_run_file_content", {
|
||||
scenarioId: sc.id,
|
||||
runId: run.id,
|
||||
fileId: file.id,
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
const result = rpc.result as { content: { text: string }[] };
|
||||
const response = JSON.parse(result.content[0].text) as {
|
||||
file: { id: string; name: string; mimeType: string; expiresAt: string | null };
|
||||
contentBase64: string;
|
||||
encoding: string;
|
||||
};
|
||||
|
||||
expect(response.file.id).toBe(file.id);
|
||||
expect(response.file.name).toBe("artifact.bin");
|
||||
expect(response.file.mimeType).toBe("application/octet-stream");
|
||||
expect(response.file.expiresAt).not.toBeNull();
|
||||
expect(response.encoding).toBe("base64");
|
||||
expect(Buffer.from(response.contentBase64, "base64").toString("utf8")).toBe(
|
||||
"test downloaded file content",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns MCP error for mismatched run and file ids", async () => {
|
||||
const { sc: firstScenario, run: firstRun } = await createRunArtifactFixture(
|
||||
"mcp-run-file-mismatch-a",
|
||||
);
|
||||
const { sc: secondScenario, run: secondRun } = await createRunArtifactFixture(
|
||||
"mcp-run-file-mismatch-b",
|
||||
);
|
||||
|
||||
const firstListRpc = (
|
||||
await mcpCall("list_run_files", {
|
||||
scenarioId: firstScenario.id,
|
||||
runId: firstRun.id,
|
||||
})
|
||||
).rpc;
|
||||
const firstList = JSON.parse(
|
||||
(firstListRpc.result as { content: { text: string }[] }).content[0].text,
|
||||
) as { items: Array<{ id: string }> };
|
||||
|
||||
const { status, rpc } = await mcpCall("get_run_file_content", {
|
||||
scenarioId: secondScenario.id,
|
||||
runId: secondRun.id,
|
||||
fileId: firstList.items[0].id,
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
const result = rpc.result as { isError: boolean };
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Snippet CRUD tools ────────────────────────────────────────────────────
|
||||
|
||||
describe("list_snippets", () => {
|
||||
it("returns a paginated result with a data array", async () => {
|
||||
const { status, rpc } = await mcpCall("list_snippets");
|
||||
expect(status).toBe(200);
|
||||
const result = rpc.result as { content: { text: string }[] };
|
||||
const body = JSON.parse(result.content[0].text) as {
|
||||
data: unknown[];
|
||||
total: number;
|
||||
};
|
||||
expect(Array.isArray(body.data)).toBe(true);
|
||||
expect(typeof body.total).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
describe("create_snippet", () => {
|
||||
it("creates a snippet and returns it with alias and title", async () => {
|
||||
const { status, rpc } = await mcpCall("create_snippet", {
|
||||
alias: "mcp-test-snippet",
|
||||
title: "MCP Test Snippet",
|
||||
description: "Created by integration test",
|
||||
code: "await page.click('#btn');",
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
const result = rpc.result as { content: { text: string }[] };
|
||||
const created = JSON.parse(result.content[0].text) as {
|
||||
id: string;
|
||||
alias: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
code: string;
|
||||
};
|
||||
expect(created.alias).toBe("mcp-test-snippet");
|
||||
expect(created.title).toBe("MCP Test Snippet");
|
||||
expect(created.description).toBe("Created by integration test");
|
||||
expect(created.code).toBe("await page.click('#btn');");
|
||||
expect(typeof created.id).toBe("string");
|
||||
});
|
||||
|
||||
it("returns an MCP error when alias already exists", async () => {
|
||||
await mcpCall("create_snippet", {
|
||||
alias: "mcp-duplicate-snippet",
|
||||
title: "First",
|
||||
code: "return 1;",
|
||||
});
|
||||
const { status, rpc } = await mcpCall("create_snippet", {
|
||||
alias: "mcp-duplicate-snippet",
|
||||
title: "Second",
|
||||
code: "return 2;",
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
const result = rpc.result as { isError: boolean };
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get_snippet", () => {
|
||||
it("returns the created snippet by id", async () => {
|
||||
const createRpc = (
|
||||
await mcpCall("create_snippet", {
|
||||
alias: "mcp-get-snippet",
|
||||
title: "Get Me",
|
||||
code: "return 42;",
|
||||
})
|
||||
).rpc;
|
||||
const created = JSON.parse(
|
||||
(createRpc.result as { content: { text: string }[] }).content[0].text,
|
||||
) as { id: string };
|
||||
|
||||
const { status, rpc } = await mcpCall("get_snippet", { id: created.id });
|
||||
expect(status).toBe(200);
|
||||
const result = rpc.result as { content: { text: string }[] };
|
||||
const fetched = JSON.parse(result.content[0].text) as {
|
||||
id: string;
|
||||
alias: string;
|
||||
};
|
||||
expect(fetched.id).toBe(created.id);
|
||||
expect(fetched.alias).toBe("mcp-get-snippet");
|
||||
});
|
||||
|
||||
it("returns an MCP error for a non-existent snippet id", async () => {
|
||||
const { status, rpc } = await mcpCall("get_snippet", {
|
||||
id: "00000000-0000-0000-0000-000000000000",
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
const result = rpc.result as { isError: boolean };
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("update_snippet", () => {
|
||||
it("updates alias, title, and code of an existing snippet", async () => {
|
||||
const createRpc = (
|
||||
await mcpCall("create_snippet", {
|
||||
alias: "mcp-upd-snippet-orig",
|
||||
title: "Original Title",
|
||||
code: "return 1;",
|
||||
})
|
||||
).rpc;
|
||||
const created = JSON.parse(
|
||||
(createRpc.result as { content: { text: string }[] }).content[0].text,
|
||||
) as { id: string };
|
||||
|
||||
const { status, rpc } = await mcpCall("update_snippet", {
|
||||
id: created.id,
|
||||
alias: "mcp-upd-snippet-new",
|
||||
title: "Updated Title",
|
||||
code: "return 2;",
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
const result = rpc.result as { content: { text: string }[] };
|
||||
const updated = JSON.parse(result.content[0].text) as {
|
||||
alias: string;
|
||||
title: string;
|
||||
code: string;
|
||||
};
|
||||
expect(updated.alias).toBe("mcp-upd-snippet-new");
|
||||
expect(updated.title).toBe("Updated Title");
|
||||
expect(updated.code).toBe("return 2;");
|
||||
});
|
||||
|
||||
it("returns an MCP error for a non-existent snippet id", async () => {
|
||||
const { status, rpc } = await mcpCall("update_snippet", {
|
||||
id: "00000000-0000-0000-0000-000000000000",
|
||||
title: "Ghost",
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
const result = rpc.result as { isError: boolean };
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete_snippet", () => {
|
||||
it("deletes an existing snippet and confirms deletion", async () => {
|
||||
const createRpc = (
|
||||
await mcpCall("create_snippet", {
|
||||
alias: "mcp-del-snippet",
|
||||
title: "Delete Me",
|
||||
code: "return 0;",
|
||||
})
|
||||
).rpc;
|
||||
const created = JSON.parse(
|
||||
(createRpc.result as { content: { text: string }[] }).content[0].text,
|
||||
) as { id: string };
|
||||
|
||||
const { status, rpc } = await mcpCall("delete_snippet", {
|
||||
id: created.id,
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
const result = rpc.result as { content: { text: string }[] };
|
||||
expect(result.content[0].text).toContain(created.id);
|
||||
|
||||
// confirm gone
|
||||
const getResult = (await mcpCall("get_snippet", { id: created.id }))
|
||||
.rpc.result as { isError: boolean };
|
||||
expect(getResult.isError).toBe(true);
|
||||
});
|
||||
|
||||
it("returns an MCP error for a non-existent snippet id", async () => {
|
||||
const { status, rpc } = await mcpCall("delete_snippet", {
|
||||
id: "00000000-0000-0000-0000-000000000000",
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
const result = rpc.result as { isError: boolean };
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -171,6 +171,35 @@ describe("ScenarioController", () => {
|
||||
.get("/scenarios?orderDir=SIDEWAYS")
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("returns lastRunStatus and lastRunAt as null when scenario has no runs", async () => {
|
||||
await createScenario("no-runs-sc");
|
||||
const res = await request(app.getHttpServer())
|
||||
.get("/scenarios?orderBy=name&orderDir=ASC")
|
||||
.expect(200);
|
||||
const item = res.body.data.find(
|
||||
(s: { name: string }) => s.name === "no-runs-sc",
|
||||
);
|
||||
expect(item).toBeDefined();
|
||||
expect(item.lastRunStatus).toBeNull();
|
||||
expect(item.lastRunAt).toBeNull();
|
||||
});
|
||||
|
||||
it("returns lastRunStatus and lastRunAt reflecting the most recent run", async () => {
|
||||
const sc = await createScenario("last-run-status-sc");
|
||||
await createRun(sc.id);
|
||||
const res = await request(app.getHttpServer())
|
||||
.get("/scenarios?orderBy=name&orderDir=ASC")
|
||||
.expect(200);
|
||||
const item = res.body.data.find(
|
||||
(s: { name: string }) => s.name === "last-run-status-sc",
|
||||
);
|
||||
expect(item).toBeDefined();
|
||||
expect(["pending", "in_progress", "pass", "fail"]).toContain(
|
||||
item.lastRunStatus,
|
||||
);
|
||||
expect(typeof item.lastRunAt).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /scenarios/:id ─────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user