- replace (page, context, helpers) signature with async (context) => {}
- add ScriptContext interface exposing page, browser, env, getEnv,
getCredential, getStepOutput, runSnippet, dumpDom, log/warn/error
- rename getEnvUrl to getEnv throughout service and docs
- fix step ordering: normalizeStepOrder and run step rows are now 1-based
- migrate existing DB rows (scenario_steps, scenario_run_steps) +1
- update all tests and stored snippet/step code in DB to new API
444 lines
14 KiB
TypeScript
444 lines
14 KiB
TypeScript
import {
|
|
ConflictException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from "@nestjs/common";
|
|
import { InjectRepository } from "@nestjs/typeorm";
|
|
import { Like, 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 { ScenarioCredentialEntity } from "./scenario-credential.entity";
|
|
import { CredentialEntity } from "../credential/credential.entity";
|
|
import { EnvironmentEntity } from "../environment/environment.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 { AddScenarioCredentialDto } from "./dto/add-scenario-credential.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>,
|
|
@InjectRepository(ScenarioCredentialEntity)
|
|
private readonly scenarioCredRepo: Repository<ScenarioCredentialEntity>,
|
|
@InjectRepository(CredentialEntity)
|
|
private readonly credentialRepo: Repository<CredentialEntity>,
|
|
@InjectRepository(EnvironmentEntity)
|
|
private readonly environmentRepo: Repository<EnvironmentEntity>,
|
|
) {}
|
|
|
|
// ── 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: string): Promise<ScenarioEntity> {
|
|
const scenario = await this.scenarioRepo.findOne({
|
|
where: { id },
|
|
relations: [
|
|
"steps",
|
|
"scenarioCredentials",
|
|
"scenarioCredentials.credential",
|
|
],
|
|
order: { steps: { order: "ASC" } },
|
|
});
|
|
if (!scenario) throw new NotFoundException(`Scenario ${id} not found`);
|
|
return scenario;
|
|
}
|
|
|
|
async update(id: string, dto: UpdateScenarioDto): Promise<ScenarioEntity> {
|
|
const scenario = await this.findOne(id);
|
|
Object.assign(scenario, dto);
|
|
return this.scenarioRepo.save(scenario);
|
|
}
|
|
|
|
async remove(id: string): Promise<void> {
|
|
await this.findOne(id);
|
|
await this.scenarioRepo.delete(id);
|
|
}
|
|
|
|
// ── 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 + 1) {
|
|
step.order = i + 1;
|
|
await this.stepRepo.save(step);
|
|
}
|
|
}
|
|
}
|
|
|
|
async createStep(
|
|
scenarioId: string,
|
|
dto: CreateScenarioStepDto,
|
|
): Promise<ScenarioStepEntity> {
|
|
await this.findOne(scenarioId);
|
|
const currentCount = await this.stepRepo.count({ where: { scenarioId } });
|
|
const created = await this.stepRepo.save(
|
|
this.stepRepo.create({
|
|
...dto,
|
|
order: currentCount + 1,
|
|
scenarioId,
|
|
title: dto.title ?? null,
|
|
execCode: dto.execCode ?? null,
|
|
}),
|
|
);
|
|
await this.normalizeStepOrder(scenarioId);
|
|
return this.findStep(scenarioId, created.id);
|
|
}
|
|
|
|
async findStep(
|
|
scenarioId: string,
|
|
stepId: string,
|
|
): 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: string,
|
|
stepId: string,
|
|
dto: UpdateScenarioStepDto,
|
|
): Promise<ScenarioStepEntity> {
|
|
const step = await this.findStep(scenarioId, stepId);
|
|
|
|
const nextOrder = dto.order;
|
|
Object.assign(step, {
|
|
...dto,
|
|
order: step.order,
|
|
title: dto.title ?? step.title,
|
|
execCode: dto.execCode ?? step.execCode,
|
|
});
|
|
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 ──────────────────────────────────────────────────
|
|
|
|
async findScenarioCredentials(
|
|
scenarioId: string,
|
|
): Promise<ScenarioCredentialEntity[]> {
|
|
await this.findOne(scenarioId); // 404 guard
|
|
return this.scenarioCredRepo.find({
|
|
where: { scenarioId },
|
|
relations: ["credential"],
|
|
order: { alias: "ASC" },
|
|
});
|
|
}
|
|
|
|
async addScenarioCredential(
|
|
scenarioId: string,
|
|
dto: AddScenarioCredentialDto,
|
|
): Promise<ScenarioCredentialEntity> {
|
|
await this.findOne(scenarioId); // 404 guard
|
|
const credential = await this.credentialRepo.findOneBy({
|
|
id: dto.credentialId,
|
|
});
|
|
if (!credential)
|
|
throw new NotFoundException(`Credential ${dto.credentialId} not found`);
|
|
const existing = await this.scenarioCredRepo.findOneBy({
|
|
scenarioId,
|
|
alias: dto.alias,
|
|
});
|
|
if (existing)
|
|
throw new ConflictException(
|
|
`Alias "${dto.alias}" already used in this scenario`,
|
|
);
|
|
const sc = this.scenarioCredRepo.create({
|
|
scenarioId,
|
|
credentialId: dto.credentialId,
|
|
alias: dto.alias,
|
|
});
|
|
return this.scenarioCredRepo.save(sc);
|
|
}
|
|
|
|
async removeScenarioCredential(
|
|
scenarioId: string,
|
|
scCredId: string,
|
|
): Promise<void> {
|
|
await this.findOne(scenarioId); // 404 guard
|
|
const sc = await this.scenarioCredRepo.findOneBy({
|
|
id: scCredId,
|
|
scenarioId,
|
|
});
|
|
if (!sc)
|
|
throw new NotFoundException(
|
|
`Scenario credential ${scCredId} not found in scenario ${scenarioId}`,
|
|
);
|
|
await this.scenarioCredRepo.delete(scCredId);
|
|
}
|
|
|
|
/**
|
|
* Builds a map of alias → parsed credential data for use in code execution.
|
|
* Returns null values for credentials with no data.
|
|
*/
|
|
async buildCredentialMap(
|
|
scenarioId: string,
|
|
): Promise<Record<string, unknown>> {
|
|
const scs = await this.findScenarioCredentials(scenarioId);
|
|
const map: Record<string, unknown> = {};
|
|
for (const sc of scs) {
|
|
let parsed: unknown = null;
|
|
if (sc.credential.data) {
|
|
try {
|
|
parsed = JSON.parse(sc.credential.data);
|
|
} catch {
|
|
parsed = sc.credential.data;
|
|
}
|
|
}
|
|
map[sc.alias] = parsed;
|
|
}
|
|
return map;
|
|
}
|
|
|
|
// ── Runs ──────────────────────────────────────────────────────────────────
|
|
|
|
async findRuns(
|
|
scenarioId: string,
|
|
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: { createdAt: "DESC" },
|
|
skip: (page - 1) * limit,
|
|
take: limit,
|
|
});
|
|
return { data, total, page, limit };
|
|
}
|
|
|
|
async findAllRuns(
|
|
query: RunsQueryDto,
|
|
): Promise<
|
|
PaginatedResult<ScenarioRunEntity & { scenario: ScenarioEntity }>
|
|
> {
|
|
const page = query.page ?? 1;
|
|
const limit = query.limit ?? 20;
|
|
const where: Record<string, unknown> = {};
|
|
if (query.status) where["status"] = query.status;
|
|
const [data, total] = await this.runRepo.findAndCount({
|
|
where,
|
|
relations: ["stepRuns", "scenario"],
|
|
order: { createdAt: "DESC" },
|
|
skip: (page - 1) * limit,
|
|
take: limit,
|
|
});
|
|
return { data, total, page, limit } as PaginatedResult<
|
|
ScenarioRunEntity & { scenario: ScenarioEntity }
|
|
>;
|
|
}
|
|
|
|
async findRun(
|
|
scenarioId: string,
|
|
runId: string,
|
|
q?: string,
|
|
): 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: q?.trim() ? { runId, message: Like(`%${q.trim()}%`) } : { runId },
|
|
order: { createdAt: "ASC" },
|
|
});
|
|
return Object.assign(run, { logs });
|
|
}
|
|
|
|
async waitForRun(
|
|
scenarioId: string,
|
|
runId: string,
|
|
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: string,
|
|
environmentId: string,
|
|
): Promise<ScenarioRunEntity> {
|
|
const scenario = await this.findOne(scenarioId);
|
|
const environment = await this.environmentRepo.findOneBy({
|
|
id: environmentId,
|
|
});
|
|
if (!environment) {
|
|
throw new NotFoundException(`Environment ${environmentId} not found`);
|
|
}
|
|
|
|
const run = await this.runRepo.save(
|
|
this.runRepo.create({ scenarioId, environmentId, 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: string): Promise<ScenarioExportDto> {
|
|
const scenario = await this.findOne(id);
|
|
return {
|
|
kind: "scenario",
|
|
id: scenario.id,
|
|
name: scenario.name,
|
|
steps: scenario.steps.map((s) => ({
|
|
title: s.title,
|
|
execCode: s.execCode,
|
|
})),
|
|
};
|
|
}
|
|
|
|
async importScenario(dto: ScenarioExportDto): Promise<ScenarioEntity> {
|
|
// Upsert: if id provided and entity exists, replace steps; else create new
|
|
let scenario: ScenarioEntity;
|
|
if (dto.id) {
|
|
const existing = await this.scenarioRepo.findOneBy({ id: dto.id });
|
|
if (existing) {
|
|
existing.name = dto.name;
|
|
scenario = await this.scenarioRepo.save(existing);
|
|
// Delete old steps and recreate
|
|
await this.stepRepo.delete({ scenarioId: scenario.id });
|
|
} else {
|
|
scenario = await this.scenarioRepo.save(
|
|
this.scenarioRepo.create({ id: dto.id, name: dto.name }),
|
|
);
|
|
}
|
|
} else {
|
|
scenario = await this.scenarioRepo.save(
|
|
this.scenarioRepo.create({ name: dto.name }),
|
|
);
|
|
}
|
|
if (dto.steps.length > 0) {
|
|
const steps = dto.steps.map((s, index) =>
|
|
this.stepRepo.create({
|
|
scenarioId: scenario.id,
|
|
order: index,
|
|
title: s.title ?? null,
|
|
execCode: s.execCode ?? null,
|
|
}),
|
|
);
|
|
await this.stepRepo.save(steps);
|
|
await this.normalizeStepOrder(scenario.id);
|
|
}
|
|
return this.findOne(scenario.id);
|
|
}
|
|
}
|