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:
2026-04-10 18:19:38 +03:00
parent 259dac806e
commit de0cbeef7c
18 changed files with 361 additions and 11385 deletions
+9
View File
@@ -0,0 +1,9 @@
.git
.gitignore
node_modules
**/node_modules
**/dist
**/.DS_Store
**/*.log
data
keys
+5 -2
View File
@@ -27,10 +27,14 @@ 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;
} }
+29
View File
@@ -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;
} }
+2 -32
View File
@@ -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>
+1 -26
View File
@@ -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>
)} )}
+27 -7
View File
@@ -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
View File
@@ -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:
+1
View File
@@ -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
View File
@@ -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
-11239
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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()
+11 -3
View File
@@ -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 ─────────────────────────────────────────────────────────────────
+65 -6
View File
@@ -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);
} }
+1 -2
View File
@@ -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,
}); });
+89 -22
View File
@@ -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);
}); });