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
@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsInt, IsNotEmpty, IsOptional, IsString, Min } from "class-validator";
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsNotEmpty, IsOptional, IsString } from "class-validator";
export class CreateScenarioStepDto {
@ApiPropertyOptional({ example: "Check login page title" })
@@ -7,11 +7,6 @@ export class CreateScenarioStepDto {
@IsString()
title?: string;
@ApiProperty({ example: 0, description: "Execution order (ascending)" })
@IsInt()
@Min(0)
order: number;
@ApiPropertyOptional({
description:
"Session name (deprecated — browser is created automatically per run).",
@@ -3,21 +3,14 @@ import { Type } from "class-transformer";
import {
IsArray,
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
Min,
ValidateNested,
} from "class-validator";
export class ScenarioStepExportDto {
@ApiProperty()
@IsInt()
@Min(0)
order: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
+11 -3
View File
@@ -9,8 +9,11 @@ import {
Patch,
Post,
Query,
Res,
} from "@nestjs/common";
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { Response } from "express";
import { stringify as yamlStringify } from "yaml";
import { ScenarioService } from "./scenario.service";
import { CreateScenarioDto } from "./dto/create-scenario.dto";
import { UpdateScenarioDto } from "./dto/update-scenario.dto";
@@ -88,11 +91,16 @@ export class ScenarioController {
}
@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: 404, description: "Scenario not found" })
exportScenario(@Param("id", ParseUUIDPipe) id: string) {
return this.scenarioService.exportScenario(id);
async exportScenario(
@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 ─────────────────────────────────────────────────────────────────
+65 -6
View File
@@ -94,19 +94,39 @@ export class ScenarioService {
// ── 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(
scenarioId: string,
dto: CreateScenarioStepDto,
): Promise<ScenarioStepEntity> {
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({
...dto,
order: currentCount,
scenarioId,
title: dto.title ?? null,
sessionName: dto.sessionName ?? null,
execCode: dto.execCode ?? null,
validateCode: dto.validateCode ?? null,
}),
);
await this.normalizeStepOrder(scenarioId);
return this.findStep(scenarioId, created.id);
}
async findStep(
@@ -127,13 +147,52 @@ export class ScenarioService {
dto: UpdateScenarioStepDto,
): Promise<ScenarioStepEntity> {
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> {
await this.findStep(scenarioId, stepId);
await this.stepRepo.delete(stepId);
await this.normalizeStepOrder(scenarioId);
}
// ── Scenario Credentials ──────────────────────────────────────────────────
@@ -333,7 +392,6 @@ export class ScenarioService {
id: scenario.id,
name: scenario.name,
steps: scenario.steps.map((s) => ({
order: s.order,
title: s.title,
execCode: s.execCode,
validateCode: s.validateCode,
@@ -362,16 +420,17 @@ export class ScenarioService {
);
}
if (dto.steps.length > 0) {
const steps = dto.steps.map((s) =>
const steps = dto.steps.map((s, index) =>
this.stepRepo.create({
scenarioId: scenario.id,
order: s.order,
order: index,
title: s.title ?? null,
execCode: s.execCode ?? null,
validateCode: s.validateCode ?? null,
}),
);
await this.stepRepo.save(steps);
await this.normalizeStepOrder(scenario.id);
}
return this.findOne(scenario.id);
}