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:
2026-04-08 21:28:01 +03:00
parent afc4627353
commit 5cc16725fb
88 changed files with 12637 additions and 405 deletions
+245
View File
@@ -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);
}
}