refactor(workspace): restore yaml exports and align docker builds
- return scenario export payloads as yaml to match existing workflows - harden step reordering flow for drag-and-drop and one-based ui labels - switch server container to workspace-lockfile installs and root ignore rules
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/.DS_Store
|
||||||
|
**/*.log
|
||||||
|
data
|
||||||
|
keys
|
||||||
@@ -27,9 +27,13 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
}
|
}
|
||||||
const contentLength = res.headers.get('content-length');
|
const contentLength = res.headers.get('content-length');
|
||||||
if (res.status === 204 || contentLength === '0') return undefined as T;
|
if (res.status === 204 || contentLength === '0') return undefined as T;
|
||||||
|
const contentType = res.headers.get('content-type')?.toLowerCase() ?? '';
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
if (!text) return undefined as T;
|
if (!text) return undefined as T;
|
||||||
|
if (contentType.includes('application/json')) {
|
||||||
return JSON.parse(text) as T;
|
return JSON.parse(text) as T;
|
||||||
|
}
|
||||||
|
return text as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Credentials ───────────────────────────────────────────────────────────────
|
// ── Credentials ───────────────────────────────────────────────────────────────
|
||||||
@@ -185,7 +189,7 @@ export const scenarios = {
|
|||||||
): Promise<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[] }>> {
|
): Promise<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[] }>> {
|
||||||
return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}`);
|
return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}`);
|
||||||
},
|
},
|
||||||
exportScenario(id: string): Promise<unknown> {
|
exportScenario(id: string): Promise<string> {
|
||||||
return request(`/scenarios/${id}/export`);
|
return request(`/scenarios/${id}/export`);
|
||||||
},
|
},
|
||||||
importScenario(payload: unknown): Promise<Scenario> {
|
importScenario(payload: unknown): Promise<Scenario> {
|
||||||
@@ -247,7 +251,6 @@ export const scenarioCredentials = {
|
|||||||
// ── Scenario Steps ────────────────────────────────────────────────────────────
|
// ── Scenario Steps ────────────────────────────────────────────────────────────
|
||||||
export interface CreateStepPayload {
|
export interface CreateStepPayload {
|
||||||
title?: string;
|
title?: string;
|
||||||
order: number;
|
|
||||||
execCode?: string;
|
execCode?: string;
|
||||||
validateCode?: string;
|
validateCode?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,6 +145,35 @@
|
|||||||
color: var(--color-link-hover);
|
color: var(--color-link-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dragHandle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: grab;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragHandle:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-secondary) 18%, transparent);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragHandle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragHandleDisabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragOverRow {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 16%, transparent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.envCardHeader button:hover {
|
.envCardHeader button:hover {
|
||||||
background: color-mix(in srgb, currentColor 15%, transparent) !important;
|
background: color-mix(in srgb, currentColor 15%, transparent) !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useNavigate, useParams } from 'react-router-dom';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { scenarios, steps } from '../../api';
|
import { scenarios, steps } from '../../api';
|
||||||
import type { Scenario } from '../../api';
|
import type { Scenario } from '../../api';
|
||||||
import type { CreateStepPayload } from '../../api/client';
|
|
||||||
import { Breadcrumbs, Button, Card, Input, CodeEditor } from '../../ui';
|
import { Breadcrumbs, Button, Card, Input, CodeEditor } from '../../ui';
|
||||||
import styles from '../Page.module.css';
|
import styles from '../Page.module.css';
|
||||||
|
|
||||||
@@ -13,11 +12,9 @@ export function CreateStepPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [scenario, setScenario] = useState<Scenario | null>(null);
|
const [scenario, setScenario] = useState<Scenario | null>(null);
|
||||||
const [order, setOrder] = useState('0');
|
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [execCode, setExecCode] = useState('');
|
const [execCode, setExecCode] = useState('');
|
||||||
const [validateCode, setValidateCode] = useState('');
|
const [validateCode, setValidateCode] = useState('');
|
||||||
const [errors, setErrors] = useState<Partial<Record<keyof CreateStepPayload, string>>>({});
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -29,21 +26,12 @@ export function CreateStepPage() {
|
|||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
const validate = (): boolean => {
|
|
||||||
const next: typeof errors = {};
|
|
||||||
if (isNaN(Number(order)) || Number(order) < 0) next.order = t('steps.form_order_invalid');
|
|
||||||
setErrors(next);
|
|
||||||
return Object.keys(next).length === 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!validate()) return;
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await steps.create(id!, {
|
await steps.create(id!, {
|
||||||
order: Number(order),
|
|
||||||
title: title.trim() || undefined,
|
title: title.trim() || undefined,
|
||||||
execCode: execCode.trim() || undefined,
|
execCode: execCode.trim() || undefined,
|
||||||
validateCode: validateCode.trim() || undefined,
|
validateCode: validateCode.trim() || undefined,
|
||||||
@@ -76,14 +64,6 @@ export function CreateStepPage() {
|
|||||||
<form onSubmit={handleSubmit} noValidate>
|
<form onSubmit={handleSubmit} noValidate>
|
||||||
<Card className={styles.formCardFull}>
|
<Card className={styles.formCardFull}>
|
||||||
<div className={styles.formFields}>
|
<div className={styles.formFields}>
|
||||||
<Input
|
|
||||||
label={t('steps.form_order')}
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={order}
|
|
||||||
onChange={(e) => setOrder(e.target.value)}
|
|
||||||
error={errors.order}
|
|
||||||
/>
|
|
||||||
<Input
|
<Input
|
||||||
label={t('steps.form_title')}
|
label={t('steps.form_title')}
|
||||||
placeholder={t('steps.form_title_placeholder')}
|
placeholder={t('steps.form_title_placeholder')}
|
||||||
@@ -92,21 +72,11 @@ export function CreateStepPage() {
|
|||||||
/>
|
/>
|
||||||
<div className={styles.formField}>
|
<div className={styles.formField}>
|
||||||
<label className={styles.fieldLabel}>{t('steps.form_exec_code')}</label>
|
<label className={styles.fieldLabel}>{t('steps.form_exec_code')}</label>
|
||||||
<CodeEditor
|
<CodeEditor value={execCode} onChange={setExecCode} rows={6} />
|
||||||
value={execCode}
|
|
||||||
onChange={setExecCode}
|
|
||||||
rows={6}
|
|
||||||
placeholder={t('steps.form_exec_code_placeholder')}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.formField}>
|
<div className={styles.formField}>
|
||||||
<label className={styles.fieldLabel}>{t('steps.form_validate_code')}</label>
|
<label className={styles.fieldLabel}>{t('steps.form_validate_code')}</label>
|
||||||
<CodeEditor
|
<CodeEditor value={validateCode} onChange={setValidateCode} rows={6} />
|
||||||
value={validateCode}
|
|
||||||
onChange={setValidateCode}
|
|
||||||
rows={6}
|
|
||||||
placeholder={t('steps.form_validate_code_placeholder')}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,9 @@ export function EditStepPage() {
|
|||||||
|
|
||||||
const [scenario, setScenario] = useState<Scenario | null>(null);
|
const [scenario, setScenario] = useState<Scenario | null>(null);
|
||||||
const [step, setStep] = useState<ScenarioStep | null>(null);
|
const [step, setStep] = useState<ScenarioStep | null>(null);
|
||||||
const [order, setOrder] = useState('');
|
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [execCode, setExecCode] = useState('');
|
const [execCode, setExecCode] = useState('');
|
||||||
const [validateCode, setValidateCode] = useState('');
|
const [validateCode, setValidateCode] = useState('');
|
||||||
const [orderError, setOrderError] = useState('');
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -28,7 +26,6 @@ export function EditStepPage() {
|
|||||||
.then(([sc, st]) => {
|
.then(([sc, st]) => {
|
||||||
setScenario(sc);
|
setScenario(sc);
|
||||||
setStep(st);
|
setStep(st);
|
||||||
setOrder(String(st.order));
|
|
||||||
setTitle(st.title ?? '');
|
setTitle(st.title ?? '');
|
||||||
setExecCode(st.execCode ?? '');
|
setExecCode(st.execCode ?? '');
|
||||||
setValidateCode(st.validateCode ?? '');
|
setValidateCode(st.validateCode ?? '');
|
||||||
@@ -37,23 +34,12 @@ export function EditStepPage() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [id, stepId]);
|
}, [id, stepId]);
|
||||||
|
|
||||||
const validate = (): boolean => {
|
|
||||||
let valid = true;
|
|
||||||
if (isNaN(Number(order)) || Number(order) < 0) {
|
|
||||||
setOrderError(t('steps.form_order_invalid'));
|
|
||||||
valid = false;
|
|
||||||
}
|
|
||||||
return valid;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!validate()) return;
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await steps.update(id!, stepId!, {
|
await steps.update(id!, stepId!, {
|
||||||
order: Number(order),
|
|
||||||
title: title.trim() || undefined,
|
title: title.trim() || undefined,
|
||||||
execCode: execCode.trim() || undefined,
|
execCode: execCode.trim() || undefined,
|
||||||
validateCode: validateCode.trim() || undefined,
|
validateCode: validateCode.trim() || undefined,
|
||||||
@@ -76,7 +62,7 @@ export function EditStepPage() {
|
|||||||
label: scenario?.name ?? `#${id}`,
|
label: scenario?.name ?? `#${id}`,
|
||||||
onClick: () => navigate(`/scenarios/${id}`),
|
onClick: () => navigate(`/scenarios/${id}`),
|
||||||
},
|
},
|
||||||
{ label: t('steps.edit_title', { order: step?.order ?? stepId }) },
|
{ label: t('steps.edit_title', { order: step ? step.order + 1 : stepId }) },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,17 +74,6 @@ export function EditStepPage() {
|
|||||||
<form onSubmit={handleSubmit} noValidate>
|
<form onSubmit={handleSubmit} noValidate>
|
||||||
<Card className={styles.formCardFull}>
|
<Card className={styles.formCardFull}>
|
||||||
<div className={styles.formFields}>
|
<div className={styles.formFields}>
|
||||||
<Input
|
|
||||||
label={t('steps.form_order')}
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={order}
|
|
||||||
onChange={(e) => {
|
|
||||||
setOrder(e.target.value);
|
|
||||||
setOrderError('');
|
|
||||||
}}
|
|
||||||
error={orderError || undefined}
|
|
||||||
/>
|
|
||||||
<Input
|
<Input
|
||||||
label={t('steps.form_title')}
|
label={t('steps.form_title')}
|
||||||
placeholder={t('steps.form_title_placeholder')}
|
placeholder={t('steps.form_title_placeholder')}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Play, Pencil, Plus, History, Trash2, Upload } from 'lucide-react';
|
import { Play, Pencil, Plus, History, Trash2, Upload, GripVertical } from 'lucide-react';
|
||||||
import { stringify as yamlStringify } from 'yaml';
|
import { stringify as yamlStringify } from 'yaml';
|
||||||
import { scenarios, steps, scenarioCredentials, credentials as credentialsApi } from '../../api';
|
import { scenarios, steps, scenarioCredentials, credentials as credentialsApi } from '../../api';
|
||||||
import type { Scenario, ScenarioStep, ScenarioCredential, Credential } from '../../api';
|
import type { Scenario, ScenarioStep, ScenarioCredential, Credential } from '../../api';
|
||||||
@@ -37,10 +37,13 @@ export function ScenarioDetailPage() {
|
|||||||
const [addCredError, setAddCredError] = useState('');
|
const [addCredError, setAddCredError] = useState('');
|
||||||
const [addAliasError, setAddAliasError] = useState('');
|
const [addAliasError, setAddAliasError] = useState('');
|
||||||
const [addCredSaving, setAddCredSaving] = useState(false);
|
const [addCredSaving, setAddCredSaving] = useState(false);
|
||||||
|
const [draggedStepId, setDraggedStepId] = useState<string | null>(null);
|
||||||
|
const [dragOverStepId, setDragOverStepId] = useState<string | null>(null);
|
||||||
|
const [reorderingSteps, setReorderingSteps] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
scenarios
|
void scenarios
|
||||||
.get(id)
|
.get(id)
|
||||||
.then((s) => {
|
.then((s) => {
|
||||||
setScenario(s);
|
setScenario(s);
|
||||||
@@ -54,6 +57,13 @@ export function ScenarioDetailPage() {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
|
const reloadScenario = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
const s = await scenarios.get(id);
|
||||||
|
setScenario(s);
|
||||||
|
setScenarioCreds(s.scenarioCredentials ?? []);
|
||||||
|
};
|
||||||
|
|
||||||
const handleRun = async () => {
|
const handleRun = async () => {
|
||||||
if (!scenario) return;
|
if (!scenario) return;
|
||||||
const run = await scenarios.run(scenario.id);
|
const run = await scenarios.run(scenario.id);
|
||||||
@@ -69,7 +79,8 @@ export function ScenarioDetailPage() {
|
|||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
if (!scenario) return;
|
if (!scenario) return;
|
||||||
const data = await scenarios.exportScenario(scenario.id);
|
const data = await scenarios.exportScenario(scenario.id);
|
||||||
const blob = new Blob([yamlStringify(data)], { type: 'application/yaml' });
|
const yamlContent = typeof data === 'string' ? data : yamlStringify(data);
|
||||||
|
const blob = new Blob([yamlContent], { type: 'application/yaml' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
@@ -80,9 +91,21 @@ export function ScenarioDetailPage() {
|
|||||||
|
|
||||||
const handleDeleteStep = async (stepId: string) => {
|
const handleDeleteStep = async (stepId: string) => {
|
||||||
await steps.remove(id!, stepId);
|
await steps.remove(id!, stepId);
|
||||||
setScenario((prev) =>
|
await reloadScenario();
|
||||||
prev ? { ...prev, steps: prev.steps.filter((s) => s.id !== stepId) } : prev,
|
};
|
||||||
);
|
|
||||||
|
const handleMoveStep = async (fromStepId: string, toStepId: string) => {
|
||||||
|
if (!scenario || reorderingSteps) return;
|
||||||
|
setReorderingSteps(true);
|
||||||
|
try {
|
||||||
|
const orderedSteps = [...scenario.steps].sort((a, b) => a.order - b.order);
|
||||||
|
const toIndex = orderedSteps.findIndex((step) => step.id === toStepId);
|
||||||
|
if (toIndex < 0) return;
|
||||||
|
await steps.update(id!, fromStepId, { order: toIndex });
|
||||||
|
await reloadScenario();
|
||||||
|
} finally {
|
||||||
|
setReorderingSteps(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddCredential = async (e: React.FormEvent) => {
|
const handleAddCredential = async (e: React.FormEvent) => {
|
||||||
@@ -120,7 +143,45 @@ export function ScenarioDetailPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const stepColumns: TableColumn<ScenarioStep>[] = [
|
const stepColumns: TableColumn<ScenarioStep>[] = [
|
||||||
{ key: 'order', header: t('scenarios.step_order'), render: (s) => s.order, width: 60 },
|
{
|
||||||
|
key: 'drag',
|
||||||
|
header: '',
|
||||||
|
width: 44,
|
||||||
|
align: 'center',
|
||||||
|
render: (s) => (
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label="Drag to reorder step"
|
||||||
|
className={[styles.dragHandle, reorderingSteps ? styles.dragHandleDisabled : '']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
draggable={!reorderingSteps}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onDragStart={(e) => {
|
||||||
|
if (reorderingSteps) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDraggedStepId(s.id);
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('text/plain', s.id);
|
||||||
|
}}
|
||||||
|
onDragEnd={() => {
|
||||||
|
setDraggedStepId(null);
|
||||||
|
setDragOverStepId(null);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GripVertical size={14} />
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'order', header: t('scenarios.step_order'), render: (s) => s.order + 1, width: 60 },
|
||||||
{
|
{
|
||||||
key: 'title',
|
key: 'title',
|
||||||
header: t('scenarios.step_title'),
|
header: t('scenarios.step_title'),
|
||||||
@@ -256,7 +317,6 @@ export function ScenarioDetailPage() {
|
|||||||
items={[
|
items={[
|
||||||
{ term: t('scenarios.field_id'), detail: <UuidBadge id={scenario.id} /> },
|
{ term: t('scenarios.field_id'), detail: <UuidBadge id={scenario.id} /> },
|
||||||
{ term: t('scenarios.field_name'), detail: scenario.name },
|
{ term: t('scenarios.field_name'), detail: scenario.name },
|
||||||
{ term: t('scenarios.field_steps'), detail: scenario.steps.length },
|
|
||||||
{
|
{
|
||||||
term: t('scenarios.field_created'),
|
term: t('scenarios.field_created'),
|
||||||
detail: <Timestamp value={scenario.createdAt} />,
|
detail: <Timestamp value={scenario.createdAt} />,
|
||||||
@@ -284,6 +344,30 @@ export function ScenarioDetailPage() {
|
|||||||
rowKey={(s) => s.id}
|
rowKey={(s) => s.id}
|
||||||
loading={false}
|
loading={false}
|
||||||
emptyMessage=""
|
emptyMessage=""
|
||||||
|
getRowProps={(s) => ({
|
||||||
|
className: dragOverStepId === s.id ? styles.dragOverRow : undefined,
|
||||||
|
onDragOver: (e) => {
|
||||||
|
if (reorderingSteps) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (draggedStepId && draggedStepId !== s.id) {
|
||||||
|
setDragOverStepId(s.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDragLeave: () => {
|
||||||
|
if (dragOverStepId === s.id) {
|
||||||
|
setDragOverStepId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDrop: async (e) => {
|
||||||
|
if (reorderingSteps) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const fromId = draggedStepId ?? e.dataTransfer.getData('text/plain');
|
||||||
|
setDragOverStepId(null);
|
||||||
|
setDraggedStepId(null);
|
||||||
|
if (!fromId || fromId === s.id) return;
|
||||||
|
await handleMoveStep(fromId, s.id);
|
||||||
|
},
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, type ReactNode } from 'react';
|
import { useState, type HTMLAttributes, type MouseEvent, type ReactNode } from 'react';
|
||||||
import { Pagination } from '../Pagination/Pagination';
|
import { Pagination } from '../Pagination/Pagination';
|
||||||
import styles from './Table.module.css';
|
import styles from './Table.module.css';
|
||||||
|
|
||||||
@@ -23,6 +23,8 @@ export interface TableProps<T> {
|
|||||||
pageSizeOptions?: number[];
|
pageSizeOptions?: number[];
|
||||||
/** Called when a row is clicked */
|
/** Called when a row is clicked */
|
||||||
onRowClick?: (row: T) => void;
|
onRowClick?: (row: T) => void;
|
||||||
|
/** Optional per-row props, useful for drag-and-drop and row-level attributes */
|
||||||
|
getRowProps?: (row: T) => HTMLAttributes<HTMLTableRowElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Table<T>({
|
export function Table<T>({
|
||||||
@@ -35,6 +37,7 @@ export function Table<T>({
|
|||||||
pageSize: defaultPageSize,
|
pageSize: defaultPageSize,
|
||||||
pageSizeOptions,
|
pageSizeOptions,
|
||||||
onRowClick,
|
onRowClick,
|
||||||
|
getRowProps,
|
||||||
}: TableProps<T>) {
|
}: TableProps<T>) {
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(defaultPageSize ?? 0);
|
const [pageSize, setPageSize] = useState(defaultPageSize ?? 0);
|
||||||
@@ -79,13 +82,29 @@ export function Table<T>({
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
visibleData.map((row) => (
|
visibleData.map((row) => {
|
||||||
|
const rowProps = getRowProps?.(row);
|
||||||
|
const mergedClassName = [
|
||||||
|
styles.tr,
|
||||||
|
onRowClick ? styles.clickable : '',
|
||||||
|
rowProps?.className ?? '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
const handleClick = (event: MouseEvent<HTMLTableRowElement>) => {
|
||||||
|
rowProps?.onClick?.(event);
|
||||||
|
if (!event.defaultPrevented) {
|
||||||
|
onRowClick?.(row);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={rowKey(row)}
|
key={rowKey(row)}
|
||||||
className={[styles.tr, onRowClick ? styles.clickable : '']
|
{...rowProps}
|
||||||
.filter(Boolean)
|
className={mergedClassName}
|
||||||
.join(' ')}
|
onClick={onRowClick || rowProps?.onClick ? handleClick : undefined}
|
||||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
|
||||||
>
|
>
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<td
|
<td
|
||||||
@@ -98,7 +117,8 @@ export function Table<T>({
|
|||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
services:
|
services:
|
||||||
server:
|
server:
|
||||||
build:
|
build:
|
||||||
context: ./server
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: server/Dockerfile
|
||||||
ports:
|
ports:
|
||||||
- "13000:3000"
|
- "13000:3000"
|
||||||
env_file:
|
env_file:
|
||||||
|
|||||||
Generated
+1
@@ -14520,6 +14520,7 @@
|
|||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
"typeorm": "^0.3.28",
|
"typeorm": "^0.3.28",
|
||||||
|
"yaml": "^2.8.3",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+9
-6
@@ -4,11 +4,13 @@ FROM node:22-slim AS builder
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
COPY server/package.json ./server/
|
||||||
|
RUN npm ci -w server
|
||||||
|
|
||||||
COPY nest-cli.json tsconfig*.json ./
|
COPY server/nest-cli.json ./server/
|
||||||
COPY src ./src
|
COPY server/tsconfig*.json ./server/
|
||||||
RUN npm run build
|
COPY server/src ./server/src
|
||||||
|
RUN npm run -w server build
|
||||||
|
|
||||||
# ── Runtime stage ─────────────────────────────────────────────────────────────
|
# ── Runtime stage ─────────────────────────────────────────────────────────────
|
||||||
FROM node:22-slim AS runtime
|
FROM node:22-slim AS runtime
|
||||||
@@ -27,9 +29,10 @@ ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci --omit=dev
|
COPY server/package.json ./server/
|
||||||
|
RUN npm ci --omit=dev -w server
|
||||||
|
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/server/dist ./dist
|
||||||
|
|
||||||
# Runtime directories (keys and SQLite DB mounted via volumes)
|
# Runtime directories (keys and SQLite DB mounted via volumes)
|
||||||
RUN mkdir -p data keys
|
RUN mkdir -p data keys
|
||||||
|
|||||||
Generated
-11239
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "liqa-server",
|
"name": "liqa-server",
|
||||||
"version": "1.0.0",
|
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
@@ -37,6 +36,7 @@
|
|||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
"typeorm": "^0.3.28",
|
"typeorm": "^0.3.28",
|
||||||
|
"yaml": "^2.8.3",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
import { IsInt, IsNotEmpty, IsOptional, IsString, Min } from "class-validator";
|
import { IsNotEmpty, IsOptional, IsString } from "class-validator";
|
||||||
|
|
||||||
export class CreateScenarioStepDto {
|
export class CreateScenarioStepDto {
|
||||||
@ApiPropertyOptional({ example: "Check login page title" })
|
@ApiPropertyOptional({ example: "Check login page title" })
|
||||||
@@ -7,11 +7,6 @@ export class CreateScenarioStepDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 0, description: "Execution order (ascending)" })
|
|
||||||
@IsInt()
|
|
||||||
@Min(0)
|
|
||||||
order: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description:
|
description:
|
||||||
"Session name (deprecated — browser is created automatically per run).",
|
"Session name (deprecated — browser is created automatically per run).",
|
||||||
|
|||||||
@@ -3,21 +3,14 @@ import { Type } from "class-transformer";
|
|||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
IsIn,
|
IsIn,
|
||||||
IsInt,
|
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
Min,
|
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
|
|
||||||
export class ScenarioStepExportDto {
|
export class ScenarioStepExportDto {
|
||||||
@ApiProperty()
|
|
||||||
@IsInt()
|
|
||||||
@Min(0)
|
|
||||||
order: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ import {
|
|||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
|
Res,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
||||||
|
import { Response } from "express";
|
||||||
|
import { stringify as yamlStringify } from "yaml";
|
||||||
import { ScenarioService } from "./scenario.service";
|
import { ScenarioService } from "./scenario.service";
|
||||||
import { CreateScenarioDto } from "./dto/create-scenario.dto";
|
import { CreateScenarioDto } from "./dto/create-scenario.dto";
|
||||||
import { UpdateScenarioDto } from "./dto/update-scenario.dto";
|
import { UpdateScenarioDto } from "./dto/update-scenario.dto";
|
||||||
@@ -88,11 +91,16 @@ export class ScenarioController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(":id/export")
|
@Get(":id/export")
|
||||||
@ApiOperation({ summary: "Export a scenario as a portable JSON payload" })
|
@ApiOperation({ summary: "Export a scenario as portable YAML" })
|
||||||
@ApiResponse({ status: 200 })
|
@ApiResponse({ status: 200 })
|
||||||
@ApiResponse({ status: 404, description: "Scenario not found" })
|
@ApiResponse({ status: 404, description: "Scenario not found" })
|
||||||
exportScenario(@Param("id", ParseUUIDPipe) id: string) {
|
async exportScenario(
|
||||||
return this.scenarioService.exportScenario(id);
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
) {
|
||||||
|
const payload = await this.scenarioService.exportScenario(id);
|
||||||
|
res.setHeader("Content-Type", "application/yaml; charset=utf-8");
|
||||||
|
return yamlStringify(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Steps ─────────────────────────────────────────────────────────────────
|
// ── Steps ─────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -94,19 +94,39 @@ export class ScenarioService {
|
|||||||
|
|
||||||
// ── Steps ─────────────────────────────────────────────────────────────────
|
// ── Steps ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async normalizeStepOrder(scenarioId: string): Promise<void> {
|
||||||
|
const ordered = await this.stepRepo.find({
|
||||||
|
where: { scenarioId },
|
||||||
|
order: { order: "ASC", createdAt: "ASC", id: "ASC" },
|
||||||
|
});
|
||||||
|
for (let i = 0; i < ordered.length; i += 1) {
|
||||||
|
const step = ordered[i];
|
||||||
|
if (step.order !== i) {
|
||||||
|
step.order = i;
|
||||||
|
await this.stepRepo.save(step);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async createStep(
|
async createStep(
|
||||||
scenarioId: string,
|
scenarioId: string,
|
||||||
dto: CreateScenarioStepDto,
|
dto: CreateScenarioStepDto,
|
||||||
): Promise<ScenarioStepEntity> {
|
): Promise<ScenarioStepEntity> {
|
||||||
await this.findOne(scenarioId);
|
await this.findOne(scenarioId);
|
||||||
return this.stepRepo.save(
|
const currentCount = await this.stepRepo.count({ where: { scenarioId } });
|
||||||
|
const created = await this.stepRepo.save(
|
||||||
this.stepRepo.create({
|
this.stepRepo.create({
|
||||||
...dto,
|
...dto,
|
||||||
|
order: currentCount,
|
||||||
scenarioId,
|
scenarioId,
|
||||||
|
title: dto.title ?? null,
|
||||||
|
sessionName: dto.sessionName ?? null,
|
||||||
execCode: dto.execCode ?? null,
|
execCode: dto.execCode ?? null,
|
||||||
validateCode: dto.validateCode ?? null,
|
validateCode: dto.validateCode ?? null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
await this.normalizeStepOrder(scenarioId);
|
||||||
|
return this.findStep(scenarioId, created.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findStep(
|
async findStep(
|
||||||
@@ -127,13 +147,52 @@ export class ScenarioService {
|
|||||||
dto: UpdateScenarioStepDto,
|
dto: UpdateScenarioStepDto,
|
||||||
): Promise<ScenarioStepEntity> {
|
): Promise<ScenarioStepEntity> {
|
||||||
const step = await this.findStep(scenarioId, stepId);
|
const step = await this.findStep(scenarioId, stepId);
|
||||||
Object.assign(step, dto);
|
|
||||||
return this.stepRepo.save(step);
|
const nextOrder = dto.order;
|
||||||
|
Object.assign(step, {
|
||||||
|
...dto,
|
||||||
|
order: step.order,
|
||||||
|
title: dto.title ?? step.title,
|
||||||
|
sessionName: dto.sessionName ?? step.sessionName,
|
||||||
|
execCode: dto.execCode ?? step.execCode,
|
||||||
|
validateCode: dto.validateCode ?? step.validateCode,
|
||||||
|
});
|
||||||
|
await this.stepRepo.save(step);
|
||||||
|
|
||||||
|
if (nextOrder !== undefined) {
|
||||||
|
const ordered = await this.stepRepo.find({
|
||||||
|
where: { scenarioId },
|
||||||
|
order: { order: "ASC", createdAt: "ASC", id: "ASC" },
|
||||||
|
});
|
||||||
|
const currentIndex = ordered.findIndex((s) => s.id === stepId);
|
||||||
|
if (currentIndex >= 0) {
|
||||||
|
const boundedTarget = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(nextOrder, ordered.length - 1),
|
||||||
|
);
|
||||||
|
if (currentIndex !== boundedTarget) {
|
||||||
|
const [moved] = ordered.splice(currentIndex, 1);
|
||||||
|
ordered.splice(boundedTarget, 0, moved);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < ordered.length; i += 1) {
|
||||||
|
const item = ordered[i];
|
||||||
|
if (item.order !== i) {
|
||||||
|
item.order = i;
|
||||||
|
await this.stepRepo.save(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await this.normalizeStepOrder(scenarioId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.findStep(scenarioId, stepId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeStep(scenarioId: string, stepId: string): Promise<void> {
|
async removeStep(scenarioId: string, stepId: string): Promise<void> {
|
||||||
await this.findStep(scenarioId, stepId);
|
await this.findStep(scenarioId, stepId);
|
||||||
await this.stepRepo.delete(stepId);
|
await this.stepRepo.delete(stepId);
|
||||||
|
await this.normalizeStepOrder(scenarioId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Scenario Credentials ──────────────────────────────────────────────────
|
// ── Scenario Credentials ──────────────────────────────────────────────────
|
||||||
@@ -333,7 +392,6 @@ export class ScenarioService {
|
|||||||
id: scenario.id,
|
id: scenario.id,
|
||||||
name: scenario.name,
|
name: scenario.name,
|
||||||
steps: scenario.steps.map((s) => ({
|
steps: scenario.steps.map((s) => ({
|
||||||
order: s.order,
|
|
||||||
title: s.title,
|
title: s.title,
|
||||||
execCode: s.execCode,
|
execCode: s.execCode,
|
||||||
validateCode: s.validateCode,
|
validateCode: s.validateCode,
|
||||||
@@ -362,16 +420,17 @@ export class ScenarioService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (dto.steps.length > 0) {
|
if (dto.steps.length > 0) {
|
||||||
const steps = dto.steps.map((s) =>
|
const steps = dto.steps.map((s, index) =>
|
||||||
this.stepRepo.create({
|
this.stepRepo.create({
|
||||||
scenarioId: scenario.id,
|
scenarioId: scenario.id,
|
||||||
order: s.order,
|
order: index,
|
||||||
title: s.title ?? null,
|
title: s.title ?? null,
|
||||||
execCode: s.execCode ?? null,
|
execCode: s.execCode ?? null,
|
||||||
validateCode: s.validateCode ?? null,
|
validateCode: s.validateCode ?? null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await this.stepRepo.save(steps);
|
await this.stepRepo.save(steps);
|
||||||
|
await this.normalizeStepOrder(scenario.id);
|
||||||
}
|
}
|
||||||
return this.findOne(scenario.id);
|
return this.findOne(scenario.id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,12 +50,11 @@ describe("ScenarioRunStepEntity.output + helpers.getStepOutput", () => {
|
|||||||
/** Create a step and return its id. */
|
/** Create a step and return its id. */
|
||||||
async function createStep(
|
async function createStep(
|
||||||
scenarioId: string,
|
scenarioId: string,
|
||||||
order: number,
|
_order: number,
|
||||||
execCode: string,
|
execCode: string,
|
||||||
sessionName = "output-test-session",
|
sessionName = "output-test-session",
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const step = await scenarioService.createStep(scenarioId, {
|
const step = await scenarioService.createStep(scenarioId, {
|
||||||
order,
|
|
||||||
sessionName,
|
sessionName,
|
||||||
execCode,
|
execCode,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { INestApplication } from "@nestjs/common";
|
import { INestApplication } from "@nestjs/common";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import { DataSource } from "typeorm";
|
import { DataSource } from "typeorm";
|
||||||
|
import { parse as yamlParse } from "yaml";
|
||||||
import { buildTestApp } from "./app.harness";
|
import { buildTestApp } from "./app.harness";
|
||||||
|
|
||||||
describe("ScenarioController", () => {
|
describe("ScenarioController", () => {
|
||||||
@@ -211,12 +212,12 @@ describe("ScenarioController", () => {
|
|||||||
expect(res.body.execCode).toBeNull();
|
expect(res.body.execCode).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 400 when order is missing", async () => {
|
it("allows creating a step without explicit order", async () => {
|
||||||
const sc = await createScenario();
|
const sc = await createScenario();
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.post(`/scenarios/${sc.id}/steps`)
|
.post(`/scenarios/${sc.id}/steps`)
|
||||||
.send({ sessionName: "x" })
|
.send({ sessionName: "x" })
|
||||||
.expect(400);
|
.expect(201);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores unknown fields in payload", async () => {
|
it("ignores unknown fields in payload", async () => {
|
||||||
@@ -293,7 +294,7 @@ describe("ScenarioController", () => {
|
|||||||
.send({ order: 5, execCode: "return 99;" })
|
.send({ order: 5, execCode: "return 99;" })
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(res.body.order).toBe(5);
|
expect(res.body.order).toBe(0);
|
||||||
expect(res.body.execCode).toBe("return 99;");
|
expect(res.body.execCode).toBe("return 99;");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -304,6 +305,44 @@ describe("ScenarioController", () => {
|
|||||||
.send({ order: 1 })
|
.send({ order: 1 })
|
||||||
.expect(404);
|
.expect(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reorders by exact target index", async () => {
|
||||||
|
const sc = await createScenario();
|
||||||
|
const stepA = await createStep(sc.id, { title: "A" });
|
||||||
|
const stepB = await createStep(sc.id, { title: "B" });
|
||||||
|
const stepC = await createStep(sc.id, { title: "C" });
|
||||||
|
const stepD = await createStep(sc.id, { title: "D" });
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.patch(`/scenarios/${sc.id}/steps/${stepA.id}`)
|
||||||
|
.send({ order: 2 })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
let res = await request(app.getHttpServer())
|
||||||
|
.get(`/scenarios/${sc.id}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(
|
||||||
|
res.body.steps.map((s: { title: string }) => s.title),
|
||||||
|
).toEqual(["B", "C", "A", "D"]);
|
||||||
|
expect(
|
||||||
|
res.body.steps.map((s: { order: number }) => s.order),
|
||||||
|
).toEqual([0, 1, 2, 3]);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.patch(`/scenarios/${sc.id}/steps/${stepD.id}`)
|
||||||
|
.send({ order: 0 })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
res = await request(app.getHttpServer())
|
||||||
|
.get(`/scenarios/${sc.id}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(
|
||||||
|
res.body.steps.map((s: { title: string }) => s.title),
|
||||||
|
).toEqual(["D", "B", "C", "A"]);
|
||||||
|
expect(
|
||||||
|
res.body.steps.map((s: { order: number }) => s.order),
|
||||||
|
).toEqual([0, 1, 2, 3]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── DELETE /scenarios/:id/steps/:stepId ───────────────────────────────────
|
// ── DELETE /scenarios/:id/steps/:stepId ───────────────────────────────────
|
||||||
@@ -431,23 +470,37 @@ describe("ScenarioController", () => {
|
|||||||
.get(`/scenarios/${sc.id}/export`)
|
.get(`/scenarios/${sc.id}/export`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(res.body.name).toBe("export-me");
|
const exported = yamlParse(res.text) as {
|
||||||
expect(Array.isArray(res.body.steps)).toBe(true);
|
name: string;
|
||||||
expect(res.body.steps).toHaveLength(2);
|
steps: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(exported.name).toBe("export-me");
|
||||||
|
expect(Array.isArray(exported.steps)).toBe(true);
|
||||||
|
expect(exported.steps).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("exports steps ordered by order field", async () => {
|
it("exports steps ordered by sequential position", async () => {
|
||||||
const sc = await createScenario("export-order");
|
const sc = await createScenario("export-order");
|
||||||
await createStep(sc.id, { order: 2, sessionName: "s" });
|
await createStep(sc.id, { sessionName: "s", execCode: "return 'first';" });
|
||||||
await createStep(sc.id, { order: 0, sessionName: "s" });
|
await createStep(sc.id, { sessionName: "s", execCode: "return 'second';" });
|
||||||
await createStep(sc.id, { order: 1, sessionName: "s" });
|
await createStep(sc.id, { sessionName: "s", execCode: "return 'third';" });
|
||||||
|
|
||||||
const res = await request(app.getHttpServer())
|
const res = await request(app.getHttpServer())
|
||||||
.get(`/scenarios/${sc.id}/export`)
|
.get(`/scenarios/${sc.id}/export`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
const orders = res.body.steps.map((s: { order: number }) => s.order);
|
const exported = yamlParse(res.text) as {
|
||||||
expect(orders).toEqual([0, 1, 2]);
|
steps: Array<{ execCode: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const codes = exported.steps.map((s: { execCode: string }) => s.execCode);
|
||||||
|
expect(codes).toEqual([
|
||||||
|
"return 'first';",
|
||||||
|
"return 'second';",
|
||||||
|
"return 'third';",
|
||||||
|
]);
|
||||||
|
expect(exported.steps[0]).not.toHaveProperty("order");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits internal fields (id, scenarioId, timestamps)", async () => {
|
it("omits internal fields (id, scenarioId, timestamps)", async () => {
|
||||||
@@ -458,7 +511,11 @@ describe("ScenarioController", () => {
|
|||||||
.get(`/scenarios/${sc.id}/export`)
|
.get(`/scenarios/${sc.id}/export`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
const step = res.body.steps[0];
|
const exported = yamlParse(res.text) as {
|
||||||
|
steps: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const step = exported.steps[0];
|
||||||
expect(step).not.toHaveProperty("id");
|
expect(step).not.toHaveProperty("id");
|
||||||
expect(step).not.toHaveProperty("scenarioId");
|
expect(step).not.toHaveProperty("scenarioId");
|
||||||
expect(step).not.toHaveProperty("createdAt");
|
expect(step).not.toHaveProperty("createdAt");
|
||||||
@@ -477,7 +534,11 @@ describe("ScenarioController", () => {
|
|||||||
.get(`/scenarios/${sc.id}/export`)
|
.get(`/scenarios/${sc.id}/export`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(res.body.steps[0].validateCode).toBeNull();
|
const exported = yamlParse(res.text) as {
|
||||||
|
steps: Array<{ validateCode: string | null }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(exported.steps[0].validateCode).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 404 for unknown scenario", async () => {
|
it("returns 404 for unknown scenario", async () => {
|
||||||
@@ -495,19 +556,16 @@ describe("ScenarioController", () => {
|
|||||||
name: "imported scenario",
|
name: "imported scenario",
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
order: 0,
|
|
||||||
sessionName: "s",
|
sessionName: "s",
|
||||||
execCode: '{"keyId":"k","environmentName":"e"}',
|
execCode: '{"keyId":"k","environmentName":"e"}',
|
||||||
validateCode: null,
|
validateCode: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
order: 1,
|
|
||||||
sessionName: "s",
|
sessionName: "s",
|
||||||
execCode: "return 1;",
|
execCode: "return 1;",
|
||||||
validateCode: "return true;",
|
validateCode: "return true;",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
order: 2,
|
|
||||||
sessionName: "s",
|
sessionName: "s",
|
||||||
execCode: '{"keyId":"k"}',
|
execCode: '{"keyId":"k"}',
|
||||||
validateCode: null,
|
validateCode: null,
|
||||||
@@ -523,6 +581,9 @@ describe("ScenarioController", () => {
|
|||||||
expect(res.body.id).toBeDefined();
|
expect(res.body.id).toBeDefined();
|
||||||
expect(res.body.name).toBe("imported scenario");
|
expect(res.body.name).toBe("imported scenario");
|
||||||
expect(res.body.steps).toHaveLength(3);
|
expect(res.body.steps).toHaveLength(3);
|
||||||
|
expect(res.body.steps.map((s: { order: number }) => s.order)).toEqual([
|
||||||
|
0, 1, 2,
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves id when importing an exported scenario with id", async () => {
|
it("preserves id when importing an exported scenario with id", async () => {
|
||||||
@@ -531,9 +592,11 @@ describe("ScenarioController", () => {
|
|||||||
.get(`/scenarios/${sc.id}/export`)
|
.get(`/scenarios/${sc.id}/export`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
|
const exported = yamlParse(exportRes.text) as Record<string, unknown>;
|
||||||
|
|
||||||
const importRes = await request(app.getHttpServer())
|
const importRes = await request(app.getHttpServer())
|
||||||
.post("/scenarios/import")
|
.post("/scenarios/import")
|
||||||
.send(exportRes.body)
|
.send(exported)
|
||||||
.expect(201);
|
.expect(201);
|
||||||
|
|
||||||
expect(importRes.body.id).toBe(sc.id);
|
expect(importRes.body.id).toBe(sc.id);
|
||||||
@@ -552,18 +615,22 @@ describe("ScenarioController", () => {
|
|||||||
.get(`/scenarios/${sc.id}/export`)
|
.get(`/scenarios/${sc.id}/export`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
|
const exported = yamlParse(exportRes.text) as {
|
||||||
|
steps: Array<{ execCode: string; validateCode: string | null }>;
|
||||||
|
};
|
||||||
|
|
||||||
const importRes = await request(app.getHttpServer())
|
const importRes = await request(app.getHttpServer())
|
||||||
.post("/scenarios/import")
|
.post("/scenarios/import")
|
||||||
.send(exportRes.body)
|
.send(exported)
|
||||||
.expect(201);
|
.expect(201);
|
||||||
|
|
||||||
expect(importRes.body.name).toBe("roundtrip");
|
expect(importRes.body.name).toBe("roundtrip");
|
||||||
expect(importRes.body.steps).toHaveLength(1);
|
expect(importRes.body.steps).toHaveLength(1);
|
||||||
expect(importRes.body.steps[0].execCode).toBe(
|
expect(importRes.body.steps[0].execCode).toBe(
|
||||||
exportRes.body.steps[0].execCode,
|
exported.steps[0].execCode,
|
||||||
);
|
);
|
||||||
expect(importRes.body.steps[0].validateCode).toBe(
|
expect(importRes.body.steps[0].validateCode).toBe(
|
||||||
exportRes.body.steps[0].validateCode,
|
exported.steps[0].validateCode,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -596,7 +663,7 @@ describe("ScenarioController", () => {
|
|||||||
.post("/scenarios/import")
|
.post("/scenarios/import")
|
||||||
.send({
|
.send({
|
||||||
name: "bad-type",
|
name: "bad-type",
|
||||||
steps: [{ order: 0, type: "unknown", sessionName: "s" }],
|
steps: [{ type: "unknown", sessionName: "s" }],
|
||||||
})
|
})
|
||||||
.expect(201);
|
.expect(201);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user