fix(scenario-runner): harden run failure handling across all paths
- add onModuleInit to fail in_progress runs on server restart
(playwright sessions cannot be recovered after restart)
- extract failRun() shared method used by restart cleanup, timeout,
and unexpected error paths for consistent step run status updates
- fix catch path to also fail/cancel step runs (previously left orphaned)
- accept plain {name, steps} payload in importScenario alongside
full export format; throw BadRequestException for invalid input
- include stepRuns relation in findRuns paginated response
- add scenario-scheduler.spec.ts integration tests for onModuleInit
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { getRepositoryToken } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { buildTestApp } from "./app.harness";
|
||||
import { ScenarioSchedulerService } from "../src/scenario/scenario-scheduler.service";
|
||||
import { ScenarioRunEntity } from "../src/scenario/scenario-run.entity";
|
||||
import { ScenarioRunStepEntity } from "../src/scenario/scenario-run-step.entity";
|
||||
import { ScenarioRunLogEntity } from "../src/scenario/scenario-run-log.entity";
|
||||
import { ScenarioEntity } from "../src/scenario/scenario.entity";
|
||||
import { ScenarioStepEntity } from "../src/scenario/scenario-step.entity";
|
||||
|
||||
describe("ScenarioSchedulerService.onModuleInit", () => {
|
||||
let app: INestApplication;
|
||||
let scheduler: ScenarioSchedulerService;
|
||||
let scenarioRepo: Repository<ScenarioEntity>;
|
||||
let stepRepo: Repository<ScenarioStepEntity>;
|
||||
let runRepo: Repository<ScenarioRunEntity>;
|
||||
let runStepRepo: Repository<ScenarioRunStepEntity>;
|
||||
let runLogRepo: Repository<ScenarioRunLogEntity>;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildTestApp();
|
||||
scheduler = app.get(ScenarioSchedulerService);
|
||||
scenarioRepo = app.get<Repository<ScenarioEntity>>(
|
||||
getRepositoryToken(ScenarioEntity),
|
||||
);
|
||||
stepRepo = app.get<Repository<ScenarioStepEntity>>(
|
||||
getRepositoryToken(ScenarioStepEntity),
|
||||
);
|
||||
runRepo = app.get<Repository<ScenarioRunEntity>>(
|
||||
getRepositoryToken(ScenarioRunEntity),
|
||||
);
|
||||
runStepRepo = app.get<Repository<ScenarioRunStepEntity>>(
|
||||
getRepositoryToken(ScenarioRunStepEntity),
|
||||
);
|
||||
runLogRepo = app.get<Repository<ScenarioRunLogEntity>>(
|
||||
getRepositoryToken(ScenarioRunLogEntity),
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function seedScenarioWithStep() {
|
||||
const scenario = await scenarioRepo.save(
|
||||
scenarioRepo.create({ name: "test-scenario" }),
|
||||
);
|
||||
const step = await stepRepo.save(
|
||||
stepRepo.create({ scenarioId: scenario.id, order: 1 }),
|
||||
);
|
||||
return { scenario, step };
|
||||
}
|
||||
|
||||
async function seedRun(
|
||||
scenarioId: string,
|
||||
runStatus: ScenarioRunEntity["status"],
|
||||
) {
|
||||
return runRepo.save(
|
||||
runRepo.create({ scenarioId, status: runStatus }),
|
||||
);
|
||||
}
|
||||
|
||||
async function seedStepRun(
|
||||
runId: string,
|
||||
scenarioStepId: string,
|
||||
status: ScenarioRunStepEntity["status"],
|
||||
) {
|
||||
return runStepRepo.save(
|
||||
runStepRepo.create({ runId, scenarioStepId, order: 1, status }),
|
||||
);
|
||||
}
|
||||
|
||||
// ── tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
it("does nothing when no in_progress runs exist", async () => {
|
||||
// All existing runs (if any) should already be clean after app init.
|
||||
// This call should be a no-op and not throw.
|
||||
await expect(scheduler.onModuleInit()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails an in_progress run left from a previous server instance", async () => {
|
||||
const { scenario } = await seedScenarioWithStep();
|
||||
const run = await seedRun(scenario.id, "in_progress");
|
||||
|
||||
await scheduler.onModuleInit();
|
||||
|
||||
const updated = await runRepo.findOneByOrFail({ id: run.id });
|
||||
expect(updated.status).toBe("fail");
|
||||
});
|
||||
|
||||
it("leaves pending and completed runs untouched", async () => {
|
||||
const { scenario } = await seedScenarioWithStep();
|
||||
const pending = await seedRun(scenario.id, "pending");
|
||||
const passed = await seedRun(scenario.id, "pass");
|
||||
const failed = await seedRun(scenario.id, "fail");
|
||||
|
||||
await scheduler.onModuleInit();
|
||||
|
||||
expect((await runRepo.findOneByOrFail({ id: pending.id })).status).toBe(
|
||||
"pending",
|
||||
);
|
||||
expect((await runRepo.findOneByOrFail({ id: passed.id })).status).toBe(
|
||||
"pass",
|
||||
);
|
||||
expect((await runRepo.findOneByOrFail({ id: failed.id })).status).toBe(
|
||||
"fail",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails in_progress step runs belonging to the stale run", async () => {
|
||||
const { scenario, step } = await seedScenarioWithStep();
|
||||
const run = await seedRun(scenario.id, "in_progress");
|
||||
const stepRun = await seedStepRun(run.id, step.id, "in_progress");
|
||||
|
||||
await scheduler.onModuleInit();
|
||||
|
||||
const updated = await runStepRepo.findOneByOrFail({ id: stepRun.id });
|
||||
expect(updated.status).toBe("fail");
|
||||
});
|
||||
|
||||
it("cancels pending and waiting step runs of a stale run", async () => {
|
||||
const { scenario, step } = await seedScenarioWithStep();
|
||||
const run = await seedRun(scenario.id, "in_progress");
|
||||
const pendingStep = await seedStepRun(run.id, step.id, "pending");
|
||||
const waitingStep = await seedStepRun(run.id, step.id, "waiting");
|
||||
|
||||
await scheduler.onModuleInit();
|
||||
|
||||
expect(
|
||||
(await runStepRepo.findOneByOrFail({ id: pendingStep.id })).status,
|
||||
).toBe("cancelled");
|
||||
expect(
|
||||
(await runStepRepo.findOneByOrFail({ id: waitingStep.id })).status,
|
||||
).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("does not affect step runs of non-stale runs", async () => {
|
||||
const { scenario, step } = await seedScenarioWithStep();
|
||||
const staleRun = await seedRun(scenario.id, "in_progress");
|
||||
const passedRun = await seedRun(scenario.id, "pass");
|
||||
const staleStep = await seedStepRun(staleRun.id, step.id, "in_progress");
|
||||
const passedStep = await seedStepRun(passedRun.id, step.id, "pass");
|
||||
|
||||
await scheduler.onModuleInit();
|
||||
|
||||
// in_progress step of the stale run → fail
|
||||
expect(
|
||||
(await runStepRepo.findOneByOrFail({ id: staleStep.id })).status,
|
||||
).toBe("fail");
|
||||
// step of a completed run → untouched
|
||||
expect(
|
||||
(await runStepRepo.findOneByOrFail({ id: passedStep.id })).status,
|
||||
).toBe("pass");
|
||||
});
|
||||
|
||||
it("persists an error log entry for each failed run", async () => {
|
||||
const { scenario } = await seedScenarioWithStep();
|
||||
const run = await seedRun(scenario.id, "in_progress");
|
||||
|
||||
await scheduler.onModuleInit();
|
||||
|
||||
// Allow the void log persist to flush (persistLog uses void save)
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
const logs = await runLogRepo.findBy({ runId: run.id });
|
||||
expect(logs.length).toBeGreaterThanOrEqual(1);
|
||||
const errorLog = logs.find((l) => l.level === "error");
|
||||
expect(errorLog).toBeDefined();
|
||||
expect(errorLog!.message).toMatch(/server restarted/i);
|
||||
});
|
||||
|
||||
it("handles multiple stale runs in one pass", async () => {
|
||||
const { scenario } = await seedScenarioWithStep();
|
||||
const run1 = await seedRun(scenario.id, "in_progress");
|
||||
const run2 = await seedRun(scenario.id, "in_progress");
|
||||
|
||||
await scheduler.onModuleInit();
|
||||
|
||||
expect((await runRepo.findOneByOrFail({ id: run1.id })).status).toBe(
|
||||
"fail",
|
||||
);
|
||||
expect((await runRepo.findOneByOrFail({ id: run2.id })).status).toBe(
|
||||
"fail",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user