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:
+9
-6
@@ -4,11 +4,13 @@ FROM node:22-slim AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY server/package.json ./server/
|
||||
RUN npm ci -w server
|
||||
|
||||
COPY nest-cli.json tsconfig*.json ./
|
||||
COPY src ./src
|
||||
RUN npm run build
|
||||
COPY server/nest-cli.json ./server/
|
||||
COPY server/tsconfig*.json ./server/
|
||||
COPY server/src ./server/src
|
||||
RUN npm run -w server build
|
||||
|
||||
# ── Runtime stage ─────────────────────────────────────────────────────────────
|
||||
FROM node:22-slim AS runtime
|
||||
@@ -27,9 +29,10 @@ ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
WORKDIR /app
|
||||
|
||||
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)
|
||||
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",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
@@ -37,6 +36,7 @@
|
||||
"rxjs": "^7.8.2",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"typeorm": "^0.3.28",
|
||||
"yaml": "^2.8.3",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -50,12 +50,11 @@ describe("ScenarioRunStepEntity.output + helpers.getStepOutput", () => {
|
||||
/** Create a step and return its id. */
|
||||
async function createStep(
|
||||
scenarioId: string,
|
||||
order: number,
|
||||
_order: number,
|
||||
execCode: string,
|
||||
sessionName = "output-test-session",
|
||||
): Promise<string> {
|
||||
const step = await scenarioService.createStep(scenarioId, {
|
||||
order,
|
||||
sessionName,
|
||||
execCode,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import request from "supertest";
|
||||
import { DataSource } from "typeorm";
|
||||
import { parse as yamlParse } from "yaml";
|
||||
import { buildTestApp } from "./app.harness";
|
||||
|
||||
describe("ScenarioController", () => {
|
||||
@@ -211,12 +212,12 @@ describe("ScenarioController", () => {
|
||||
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();
|
||||
await request(app.getHttpServer())
|
||||
.post(`/scenarios/${sc.id}/steps`)
|
||||
.send({ sessionName: "x" })
|
||||
.expect(400);
|
||||
.expect(201);
|
||||
});
|
||||
|
||||
it("ignores unknown fields in payload", async () => {
|
||||
@@ -293,7 +294,7 @@ describe("ScenarioController", () => {
|
||||
.send({ order: 5, execCode: "return 99;" })
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.order).toBe(5);
|
||||
expect(res.body.order).toBe(0);
|
||||
expect(res.body.execCode).toBe("return 99;");
|
||||
});
|
||||
|
||||
@@ -304,6 +305,44 @@ describe("ScenarioController", () => {
|
||||
.send({ order: 1 })
|
||||
.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 ───────────────────────────────────
|
||||
@@ -431,23 +470,37 @@ describe("ScenarioController", () => {
|
||||
.get(`/scenarios/${sc.id}/export`)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.name).toBe("export-me");
|
||||
expect(Array.isArray(res.body.steps)).toBe(true);
|
||||
expect(res.body.steps).toHaveLength(2);
|
||||
const exported = yamlParse(res.text) as {
|
||||
name: string;
|
||||
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");
|
||||
await createStep(sc.id, { order: 2, sessionName: "s" });
|
||||
await createStep(sc.id, { order: 0, sessionName: "s" });
|
||||
await createStep(sc.id, { order: 1, sessionName: "s" });
|
||||
await createStep(sc.id, { sessionName: "s", execCode: "return 'first';" });
|
||||
await createStep(sc.id, { sessionName: "s", execCode: "return 'second';" });
|
||||
await createStep(sc.id, { sessionName: "s", execCode: "return 'third';" });
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}/export`)
|
||||
.expect(200);
|
||||
|
||||
const orders = res.body.steps.map((s: { order: number }) => s.order);
|
||||
expect(orders).toEqual([0, 1, 2]);
|
||||
const exported = yamlParse(res.text) as {
|
||||
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 () => {
|
||||
@@ -458,7 +511,11 @@ describe("ScenarioController", () => {
|
||||
.get(`/scenarios/${sc.id}/export`)
|
||||
.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("scenarioId");
|
||||
expect(step).not.toHaveProperty("createdAt");
|
||||
@@ -477,7 +534,11 @@ describe("ScenarioController", () => {
|
||||
.get(`/scenarios/${sc.id}/export`)
|
||||
.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 () => {
|
||||
@@ -495,19 +556,16 @@ describe("ScenarioController", () => {
|
||||
name: "imported scenario",
|
||||
steps: [
|
||||
{
|
||||
order: 0,
|
||||
sessionName: "s",
|
||||
execCode: '{"keyId":"k","environmentName":"e"}',
|
||||
validateCode: null,
|
||||
},
|
||||
{
|
||||
order: 1,
|
||||
sessionName: "s",
|
||||
execCode: "return 1;",
|
||||
validateCode: "return true;",
|
||||
},
|
||||
{
|
||||
order: 2,
|
||||
sessionName: "s",
|
||||
execCode: '{"keyId":"k"}',
|
||||
validateCode: null,
|
||||
@@ -523,6 +581,9 @@ describe("ScenarioController", () => {
|
||||
expect(res.body.id).toBeDefined();
|
||||
expect(res.body.name).toBe("imported scenario");
|
||||
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 () => {
|
||||
@@ -531,9 +592,11 @@ describe("ScenarioController", () => {
|
||||
.get(`/scenarios/${sc.id}/export`)
|
||||
.expect(200);
|
||||
|
||||
const exported = yamlParse(exportRes.text) as Record<string, unknown>;
|
||||
|
||||
const importRes = await request(app.getHttpServer())
|
||||
.post("/scenarios/import")
|
||||
.send(exportRes.body)
|
||||
.send(exported)
|
||||
.expect(201);
|
||||
|
||||
expect(importRes.body.id).toBe(sc.id);
|
||||
@@ -552,18 +615,22 @@ describe("ScenarioController", () => {
|
||||
.get(`/scenarios/${sc.id}/export`)
|
||||
.expect(200);
|
||||
|
||||
const exported = yamlParse(exportRes.text) as {
|
||||
steps: Array<{ execCode: string; validateCode: string | null }>;
|
||||
};
|
||||
|
||||
const importRes = await request(app.getHttpServer())
|
||||
.post("/scenarios/import")
|
||||
.send(exportRes.body)
|
||||
.send(exported)
|
||||
.expect(201);
|
||||
|
||||
expect(importRes.body.name).toBe("roundtrip");
|
||||
expect(importRes.body.steps).toHaveLength(1);
|
||||
expect(importRes.body.steps[0].execCode).toBe(
|
||||
exportRes.body.steps[0].execCode,
|
||||
exported.steps[0].execCode,
|
||||
);
|
||||
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")
|
||||
.send({
|
||||
name: "bad-type",
|
||||
steps: [{ order: 0, type: "unknown", sessionName: "s" }],
|
||||
steps: [{ type: "unknown", sessionName: "s" }],
|
||||
})
|
||||
.expect(201);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user