chore(repo): restructure as monorepo with server and client workspaces
- move NestJS app into server/ subdirectory - add client/ React+TypeScript (Vite) app with Hello World - update docker-compose to build and run both services - add root package.json declaring npm workspaces - update .gitignore to cover node_modules and dist at all depths
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { ScenarioEntity } from "./scenario.entity";
|
||||
import { ScenarioStepEntity } from "./scenario-step.entity";
|
||||
import { ScenarioRunEntity } from "./scenario-run.entity";
|
||||
import { ScenarioRunStepEntity } from "./scenario-run-step.entity";
|
||||
import { ScenarioRunLogEntity } from "./scenario-run-log.entity";
|
||||
import { CreateScenarioDto } from "./dto/create-scenario.dto";
|
||||
import { UpdateScenarioDto } from "./dto/update-scenario.dto";
|
||||
import { CreateScenarioStepDto } from "./dto/create-scenario-step.dto";
|
||||
import { UpdateScenarioStepDto } from "./dto/update-scenario-step.dto";
|
||||
import {
|
||||
PaginationQueryDto,
|
||||
PaginatedResult,
|
||||
} from "../common/dto/pagination.dto";
|
||||
import { RunsQueryDto } from "./dto/runs-query.dto";
|
||||
import { ScenarioExportDto } from "./dto/scenario-export.dto";
|
||||
|
||||
export { PaginatedResult } from "../common/dto/pagination.dto";
|
||||
export type ScenarioOrderBy = "id" | "name" | "createdAt" | "updatedAt";
|
||||
|
||||
@Injectable()
|
||||
export class ScenarioService {
|
||||
constructor(
|
||||
@InjectRepository(ScenarioEntity)
|
||||
private readonly scenarioRepo: Repository<ScenarioEntity>,
|
||||
@InjectRepository(ScenarioStepEntity)
|
||||
private readonly stepRepo: Repository<ScenarioStepEntity>,
|
||||
@InjectRepository(ScenarioRunEntity)
|
||||
private readonly runRepo: Repository<ScenarioRunEntity>,
|
||||
@InjectRepository(ScenarioRunStepEntity)
|
||||
private readonly runStepRepo: Repository<ScenarioRunStepEntity>,
|
||||
@InjectRepository(ScenarioRunLogEntity)
|
||||
private readonly runLogRepo: Repository<ScenarioRunLogEntity>,
|
||||
) {}
|
||||
|
||||
// ── Scenarios ─────────────────────────────────────────────────────────────
|
||||
|
||||
create(dto: CreateScenarioDto): Promise<ScenarioEntity> {
|
||||
return this.scenarioRepo.save(this.scenarioRepo.create(dto));
|
||||
}
|
||||
|
||||
async findAll(
|
||||
query: PaginationQueryDto<ScenarioOrderBy>,
|
||||
): Promise<PaginatedResult<ScenarioEntity>> {
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const orderBy = query.orderBy ?? "id";
|
||||
const orderDir = query.orderDir ?? "ASC";
|
||||
const [data, total] = await this.scenarioRepo.findAndCount({
|
||||
order: { [orderBy]: orderDir },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
});
|
||||
return { data, total, page, limit };
|
||||
}
|
||||
|
||||
async findOne(id: number): Promise<ScenarioEntity> {
|
||||
const scenario = await this.scenarioRepo.findOne({
|
||||
where: { id },
|
||||
relations: ["steps"],
|
||||
order: { steps: { order: "ASC" } },
|
||||
});
|
||||
if (!scenario) throw new NotFoundException(`Scenario ${id} not found`);
|
||||
return scenario;
|
||||
}
|
||||
|
||||
async update(id: number, dto: UpdateScenarioDto): Promise<ScenarioEntity> {
|
||||
const scenario = await this.findOne(id);
|
||||
Object.assign(scenario, dto);
|
||||
return this.scenarioRepo.save(scenario);
|
||||
}
|
||||
|
||||
async remove(id: number): Promise<void> {
|
||||
await this.findOne(id);
|
||||
await this.scenarioRepo.delete(id);
|
||||
}
|
||||
|
||||
// ── Steps ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async createStep(
|
||||
scenarioId: number,
|
||||
dto: CreateScenarioStepDto,
|
||||
): Promise<ScenarioStepEntity> {
|
||||
await this.findOne(scenarioId);
|
||||
return this.stepRepo.save(
|
||||
this.stepRepo.create({
|
||||
...dto,
|
||||
scenarioId,
|
||||
execCode: dto.execCode ?? null,
|
||||
validateCode: dto.validateCode ?? null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async findStep(
|
||||
scenarioId: number,
|
||||
stepId: number,
|
||||
): Promise<ScenarioStepEntity> {
|
||||
const step = await this.stepRepo.findOneBy({ id: stepId, scenarioId });
|
||||
if (!step)
|
||||
throw new NotFoundException(
|
||||
`Step ${stepId} not found in scenario ${scenarioId}`,
|
||||
);
|
||||
return step;
|
||||
}
|
||||
|
||||
async updateStep(
|
||||
scenarioId: number,
|
||||
stepId: number,
|
||||
dto: UpdateScenarioStepDto,
|
||||
): Promise<ScenarioStepEntity> {
|
||||
const step = await this.findStep(scenarioId, stepId);
|
||||
Object.assign(step, dto);
|
||||
return this.stepRepo.save(step);
|
||||
}
|
||||
|
||||
async removeStep(scenarioId: number, stepId: number): Promise<void> {
|
||||
await this.findStep(scenarioId, stepId);
|
||||
await this.stepRepo.delete(stepId);
|
||||
}
|
||||
|
||||
async findRuns(
|
||||
scenarioId: number,
|
||||
query: RunsQueryDto,
|
||||
): Promise<PaginatedResult<ScenarioRunEntity>> {
|
||||
await this.findOne(scenarioId); // 404 guard
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const where: Record<string, unknown> = { scenarioId };
|
||||
if (query.status) where["status"] = query.status;
|
||||
const [data, total] = await this.runRepo.findAndCount({
|
||||
where,
|
||||
relations: ["stepRuns"],
|
||||
order: { id: "DESC", stepRuns: { order: "ASC" } },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
});
|
||||
return { data, total, page, limit };
|
||||
}
|
||||
|
||||
async findRun(
|
||||
scenarioId: number,
|
||||
runId: number,
|
||||
): Promise<ScenarioRunEntity & { logs: ScenarioRunLogEntity[] }> {
|
||||
await this.findOne(scenarioId); // 404 guard
|
||||
const run = await this.runRepo.findOne({
|
||||
where: { id: runId, scenarioId },
|
||||
relations: ["stepRuns", "stepRuns.scenarioStep"],
|
||||
order: { stepRuns: { order: "ASC" } },
|
||||
});
|
||||
if (!run)
|
||||
throw new NotFoundException(
|
||||
`Run ${runId} not found in scenario ${scenarioId}`,
|
||||
);
|
||||
const logs = await this.runLogRepo.find({
|
||||
where: { runId },
|
||||
order: { createdAt: "ASC" },
|
||||
});
|
||||
return Object.assign(run, { logs });
|
||||
}
|
||||
|
||||
async waitForRun(
|
||||
scenarioId: number,
|
||||
runId: number,
|
||||
timeoutMs = 300_000,
|
||||
): Promise<ScenarioRunEntity & { logs: ScenarioRunLogEntity[] }> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const run = await this.runRepo.findOneBy({ id: runId, scenarioId });
|
||||
if (!run)
|
||||
throw new NotFoundException(
|
||||
`Run ${runId} not found in scenario ${scenarioId}`,
|
||||
);
|
||||
if (run.status === "pass" || run.status === "fail") {
|
||||
return this.findRun(scenarioId, runId);
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
return this.findRun(scenarioId, runId);
|
||||
}
|
||||
|
||||
async createRun(scenarioId: number): Promise<ScenarioRunEntity> {
|
||||
const scenario = await this.findOne(scenarioId);
|
||||
|
||||
const run = await this.runRepo.save(
|
||||
this.runRepo.create({ scenarioId, status: "pending" }),
|
||||
);
|
||||
|
||||
const stepRuns = scenario.steps.map((step, index) =>
|
||||
this.runStepRepo.create({
|
||||
runId: run.id,
|
||||
scenarioStepId: step.id,
|
||||
order: step.order,
|
||||
status: index === 0 ? "pending" : "waiting",
|
||||
description: null,
|
||||
}),
|
||||
);
|
||||
|
||||
await this.runStepRepo.save(stepRuns);
|
||||
|
||||
return this.runRepo.findOne({
|
||||
where: { id: run.id },
|
||||
relations: ["stepRuns"],
|
||||
order: { stepRuns: { order: "ASC" } },
|
||||
}) as Promise<ScenarioRunEntity>;
|
||||
}
|
||||
|
||||
// ── Export / Import ───────────────────────────────────────────────────────
|
||||
|
||||
async exportScenario(id: number): Promise<ScenarioExportDto> {
|
||||
const scenario = await this.findOne(id);
|
||||
return {
|
||||
name: scenario.name,
|
||||
steps: scenario.steps.map((s) => ({
|
||||
order: s.order,
|
||||
type: s.type,
|
||||
sessionName: s.sessionName,
|
||||
execCode: s.execCode,
|
||||
validateCode: s.validateCode,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async importScenario(dto: ScenarioExportDto): Promise<ScenarioEntity> {
|
||||
const scenario = await this.scenarioRepo.save(
|
||||
this.scenarioRepo.create({ name: dto.name }),
|
||||
);
|
||||
if (dto.steps.length > 0) {
|
||||
const steps = dto.steps.map((s) =>
|
||||
this.stepRepo.create({
|
||||
scenarioId: scenario.id,
|
||||
order: s.order,
|
||||
type: s.type,
|
||||
sessionName: s.sessionName,
|
||||
execCode: s.execCode ?? null,
|
||||
validateCode: s.validateCode ?? null,
|
||||
}),
|
||||
);
|
||||
await this.stepRepo.save(steps);
|
||||
}
|
||||
return this.findOne(scenario.id);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user