Files
liqa/server/src/scenario/scenario-scheduler.service.ts
T
ars9 1f3a604940 feat(runs): add runs pages, polling, autorefresh indicator, and scheduler fix
- add global runs page and per-scenario runs/run-detail pages
- run detail page polls every 1s until pass/fail, cleans up on unmount
- runs list pages poll every 10s with AutoRefreshIndicator (pulse on data load)
- fix scheduler: set run to pass after empty step loop to prevent stuck in_progress
- fix table header colors and link cell color for readability
- fix play button to navigate to the new run after creation
- fix package.json import paths in server for Docker build context
2026-04-09 21:58:27 +03:00

420 lines
14 KiB
TypeScript

import { Injectable } from "@nestjs/common";
import { TraceLogger } from "../common/trace-logger";
import { traceStorage } from "../common/trace-context";
import { Interval } from "@nestjs/schedule";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import * as crypto from "crypto";
import { chromium } from "playwright";
import type { Browser, BrowserContext, Page } from "playwright";
import { ScenarioRunEntity } from "./scenario-run.entity";
import { ScenarioRunStepEntity } from "./scenario-run-step.entity";
import { ScenarioRunLogEntity } from "./scenario-run-log.entity";
import { ScenarioStepEntity } from "./scenario-step.entity";
import type { ScriptLogger } from "../code-executor/code-executor.service";
import { AuthService } from "../auth/auth.service";
import { CodeExecutorService } from "../code-executor/code-executor.service";
import { SessionService } from "../session/session.service";
interface ValidateResult {
success: boolean;
description?: string;
}
interface BrowserHandle {
browser: Browser;
context: BrowserContext;
page: Page;
}
@Injectable()
export class ScenarioSchedulerService {
private readonly logger = new TraceLogger(ScenarioSchedulerService.name);
private readonly activeRuns = new Set<number>();
private readonly runBrowsers = new Map<number, BrowserHandle>();
constructor(
@InjectRepository(ScenarioRunEntity)
private readonly runRepo: Repository<ScenarioRunEntity>,
@InjectRepository(ScenarioRunStepEntity)
private readonly runStepRepo: Repository<ScenarioRunStepEntity>,
@InjectRepository(ScenarioRunLogEntity)
private readonly runLogRepo: Repository<ScenarioRunLogEntity>,
private readonly authService: AuthService,
private readonly codeExecutor: CodeExecutorService,
private readonly sessionService: SessionService,
) {}
private persistLog(
runId: number,
stepRunId: number | null,
level: "log" | "warn" | "error",
message: string,
): void {
void this.runLogRepo.save(
this.runLogRepo.create({ runId, stepRunId, level, message }),
);
}
private stepLogger(stepRunId: number, runId: number): ScriptLogger {
return (level, msg) => {
this.logger[level](`StepRun #${stepRunId} script: ${msg}`);
this.persistLog(runId, stepRunId, level, msg);
};
}
private makeGetStepOutput(
stepRun: ScenarioRunStepEntity,
): (order: number) => Promise<unknown> {
return async (order: number) => {
const targetOrder = order < 0 ? stepRun.order + order : order;
if (targetOrder < 0) return null;
const sr = await this.runStepRepo.findOne({
where: { runId: stepRun.runId, order: targetOrder },
});
if (!sr?.output) return null;
try {
return JSON.parse(sr.output) as unknown;
} catch {
return sr.output;
}
};
}
// ── Job: pick up pending runs and process each to completion ─────────────
@Interval(1000)
async pickUpPendingRuns(): Promise<void> {
const pending = await this.runRepo.find({ where: { status: "pending" } });
for (const run of pending) {
if (this.activeRuns.has(run.id)) continue;
this.activeRuns.add(run.id);
run.status = "in_progress";
await this.runRepo.save(run);
this.logger.log(`Run #${run.id} → in_progress`);
const traceId = crypto.randomUUID();
void traceStorage.run({ traceId }, () =>
this.processRunToCompletion(run.id),
);
}
}
private async processRunToCompletion(runId: number): Promise<void> {
try {
let stepRun = await this.runStepRepo.findOne({
where: { runId, status: "pending" },
relations: ["scenarioStep"],
order: { order: "ASC" },
});
while (stepRun) {
await this.executeStepRun(stepRun);
stepRun = await this.runStepRepo.findOne({
where: { runId, status: "pending" },
relations: ["scenarioStep"],
order: { order: "ASC" },
});
}
// Guard: if there were no steps (or all steps already resolved via passStepRun),
// ensure the run is not left in in_progress.
await this.runRepo
.createQueryBuilder()
.update()
.set({ status: "pass" })
.where("id = :id AND status = 'in_progress'", { id: runId })
.execute();
} catch (err) {
this.logger.error(
`Run #${runId}: unexpected error: ${(err as Error).message}`,
);
await this.runRepo.update(runId, { status: "fail" });
} finally {
this.activeRuns.delete(runId);
}
}
private async executeStepRun(stepRun: ScenarioRunStepEntity): Promise<void> {
const step = stepRun.scenarioStep as ScenarioStepEntity;
stepRun.status = "in_progress";
await this.runStepRepo.save(stepRun);
this.logger.log(
`StepRun #${stepRun.id} (run #${stepRun.runId}, step #${step.id} order=${step.order}) → in_progress`,
);
try {
if (step.type === "login") {
await this.executeLoginStep(stepRun, step);
} else if (step.type === "sign") {
await this.executeSignStep(stepRun, step);
} else {
await this.executeExecStep(stepRun, step);
}
} catch (err) {
const msg = (err as Error).message ?? String(err);
this.logger.error(`StepRun #${stepRun.id} threw: ${msg}`);
await this.failStepRun(stepRun, msg);
}
}
// ── Shared browser per run ─────────────────────────────────────────────────
private async getOrCreateBrowserHandle(
runId: number,
sessionName: string,
): Promise<BrowserHandle> {
const existing = this.runBrowsers.get(runId);
if (existing) return existing;
const session = await this.sessionService.findBySessionName(sessionName);
if (!session) throw new Error(`Session not found: ${sessionName}`);
const cookies = JSON.parse(session.cookies) as Parameters<
BrowserContext["addCookies"]
>[0];
const localStorageData: Record<string, string> = JSON.parse(
session.localStorage,
);
const browser = await chromium.launch({
headless: true,
// TODO: env var move to config
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
});
const context = await browser.newContext();
await context.addCookies(cookies);
await context.addInitScript((entries: Record<string, string>) => {
for (const [k, v] of Object.entries(entries))
window.localStorage.setItem(k, v);
}, localStorageData);
const page = await context.newPage();
const handle: BrowserHandle = { browser, context, page };
this.runBrowsers.set(runId, handle);
this.logger.log(`Run #${runId}: browser created (session: ${sessionName})`);
return handle;
}
private async closeBrowserHandle(runId: number): Promise<void> {
const handle = this.runBrowsers.get(runId);
if (!handle) return;
this.runBrowsers.delete(runId);
try {
await handle.browser.close();
this.logger.log(`Run #${runId}: browser closed`);
} catch (err) {
this.logger.warn(
`Run #${runId}: error closing browser: ${(err as Error).message}`,
);
}
}
// ── Login step ─────────────────────────────────────────────────────────────
private async executeLoginStep(
stepRun: ScenarioRunStepEntity,
step: ScenarioStepEntity,
): Promise<void> {
// execCode must be a JSON object: { "keyId": "...", "environmentName": "..." }
let params: { keyId: string; environmentName: string };
try {
params = JSON.parse(step.execCode ?? "{}");
} catch {
throw new Error(
"login step execCode must be valid JSON with keyId and environmentName",
);
}
if (!params.keyId || !params.environmentName) {
throw new Error(
"login step execCode must include keyId and environmentName",
);
}
const loginResult = await this.authService.login(
params.keyId,
params.environmentName,
step.sessionName,
);
this.logger.log(
`StepRun #${stepRun.id}: login OK, session=${loginResult.sessionName}`,
);
if (step.validateCode) {
const { page, context } = await this.getOrCreateBrowserHandle(
stepRun.runId,
step.sessionName,
);
this.codeExecutor.validate(step.validateCode);
const { result } = await this.codeExecutor.execute(
page,
context,
step.validateCode,
this.stepLogger(stepRun.id, stepRun.runId),
this.makeGetStepOutput(stepRun),
);
const vr = this.parseValidateResult(result);
if (!vr.success) throw new Error(vr.description ?? "Validation failed");
}
await this.passStepRun(stepRun, null);
}
// ── Exec step ──────────────────────────────────────────────────────────────
private async executeExecStep(
stepRun: ScenarioRunStepEntity,
step: ScenarioStepEntity,
): Promise<void> {
if (!step.execCode) throw new Error("exec step has no execCode");
this.codeExecutor.validate(step.execCode);
const { page, context } = await this.getOrCreateBrowserHandle(
stepRun.runId,
step.sessionName,
);
const getStepOutput = this.makeGetStepOutput(stepRun);
const { result: execOutput } = await this.codeExecutor.execute(
page,
context,
step.execCode,
this.stepLogger(stepRun.id, stepRun.runId),
getStepOutput,
);
this.logger.log(`StepRun #${stepRun.id}: exec OK`);
if (step.validateCode) {
this.codeExecutor.validate(step.validateCode);
const { result } = await this.codeExecutor.execute(
page,
context,
step.validateCode,
this.stepLogger(stepRun.id, stepRun.runId),
getStepOutput,
);
const vr = this.parseValidateResult(result);
if (!vr.success) throw new Error(vr.description ?? "Validation failed");
}
await this.passStepRun(stepRun, null, execOutput);
}
// ── Sign step ──────────────────────────────────────────────────────────────
private async executeSignStep(
stepRun: ScenarioRunStepEntity,
step: ScenarioStepEntity,
): Promise<void> {
// execCode must be a JSON object: { "keyId": "..." }
let params: { keyId: string };
try {
params = JSON.parse(step.execCode ?? "{}");
} catch {
throw new Error("sign step execCode must be valid JSON with keyId");
}
if (!params.keyId) throw new Error("sign step execCode must include keyId");
const { page, context } = await this.getOrCreateBrowserHandle(
stepRun.runId,
step.sessionName,
);
await this.authService.signWithKey(params.keyId, page);
this.logger.log(`StepRun #${stepRun.id}: sign OK`);
if (step.validateCode) {
this.codeExecutor.validate(step.validateCode);
const { result } = await this.codeExecutor.execute(
page,
context,
step.validateCode,
this.stepLogger(stepRun.id, stepRun.runId),
this.makeGetStepOutput(stepRun),
);
const vr = this.parseValidateResult(result);
if (!vr.success) throw new Error(vr.description ?? "Validation failed");
}
await this.passStepRun(stepRun, null);
}
// ── Validation helper ──────────────────────────────────────────────────────
private parseValidateResult(raw: unknown): ValidateResult {
if (typeof raw === "boolean") return { success: raw };
if (raw && typeof raw === "object") {
const r = raw as Record<string, unknown>;
return {
success: Boolean(r["success"]),
description:
r["description"] != null ? String(r["description"]) : undefined,
};
}
return { success: Boolean(raw) };
}
// ── Pass / fail helpers ────────────────────────────────────────────────────
private async passStepRun(
stepRun: ScenarioRunStepEntity,
description: string | null,
output: unknown = null,
): Promise<void> {
stepRun.status = "pass";
stepRun.description = description;
stepRun.output =
output !== null && output !== undefined ? JSON.stringify(output) : null;
await this.runStepRepo.save(stepRun);
this.logger.log(`StepRun #${stepRun.id} → pass`);
// Find the next waiting step in this run (next by order)
const nextStep = await this.runStepRepo.findOne({
where: { runId: stepRun.runId, status: "waiting" },
order: { order: "ASC" },
});
if (nextStep) {
nextStep.status = "pending";
await this.runStepRepo.save(nextStep);
} else {
// No more waiting steps — check if any are still in_progress/pending (shouldn't be, but guard anyway)
const remaining = await this.runStepRepo.count({
where: [
{ runId: stepRun.runId, status: "pending" },
{ runId: stepRun.runId, status: "in_progress" },
{ runId: stepRun.runId, status: "waiting" },
],
});
if (remaining === 0) {
await this.closeBrowserHandle(stepRun.runId);
await this.runRepo.update(stepRun.runId, { status: "pass" });
this.logger.log(`Run #${stepRun.runId} → pass (all steps passed)`);
}
}
}
private async failStepRun(
stepRun: ScenarioRunStepEntity,
description: string,
): Promise<void> {
stepRun.status = "fail";
stepRun.description = description;
await this.runStepRepo.save(stepRun);
this.logger.log(`StepRun #${stepRun.id} → fail: ${description}`);
// Cancel all remaining waiting/pending step runs in this run
await this.runStepRepo
.createQueryBuilder()
.update()
.set({ status: "cancelled" })
.where("runId = :runId AND status IN (:...statuses)", {
runId: stepRun.runId,
statuses: ["waiting", "pending"],
})
.execute();
this.logger.log(`Run #${stepRun.runId}: remaining steps cancelled`);
await this.closeBrowserHandle(stepRun.runId);
await this.runRepo.update(stepRun.runId, { status: "fail" });
this.logger.log(`Run #${stepRun.runId} → fail`);
}
}