feat(scenarios): add step title, scenario credentials, and environment context in executor

- add nullable title column to scenario steps; exposed in create/edit forms and step table
- add scenario-credential join (many-to-many with CredentialEntity) with CRUD endpoints
- expose environment URLs in code executor helpers via helpers.env and helpers.getEnvUrl()
- resolve and cache environment per run from the login step's environmentName in scheduler
- add section spacing and step table title column to ScenarioDetailPage
This commit is contained in:
2026-04-09 23:37:21 +03:00
parent 1f3a604940
commit 1efbbb38a3
34 changed files with 1362 additions and 14 deletions
@@ -15,6 +15,9 @@ 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";
import { ScenarioService } from "./scenario.service";
import { EnvironmentService } from "../environment/environment.service";
import type { EnvironmentUrls } from "../environment/environment.entity";
interface ValidateResult {
success: boolean;
@@ -32,6 +35,10 @@ export class ScenarioSchedulerService {
private readonly logger = new TraceLogger(ScenarioSchedulerService.name);
private readonly activeRuns = new Set<number>();
private readonly runBrowsers = new Map<number, BrowserHandle>();
// Cache credential maps per run (built once when a run starts)
private readonly runCredentials = new Map<number, Record<string, unknown>>();
// Cache environment URLs per run (resolved from the first login step)
private readonly runEnvironments = new Map<number, EnvironmentUrls>();
constructor(
@InjectRepository(ScenarioRunEntity)
@@ -43,6 +50,8 @@ export class ScenarioSchedulerService {
private readonly authService: AuthService,
private readonly codeExecutor: CodeExecutorService,
private readonly sessionService: SessionService,
private readonly scenarioService: ScenarioService,
private readonly environmentService: EnvironmentService,
) {}
private persistLog(
@@ -81,6 +90,30 @@ export class ScenarioSchedulerService {
};
}
/**
* Finds the first login step for a scenario and returns the environment URLs
* for the environment named in that step's execCode. Returns null if there is
* no login step or the environment cannot be found.
*/
private async resolveRunEnvironment(
scenarioId: number,
): Promise<EnvironmentUrls | null> {
try {
const scenario = await this.scenarioService.findOne(scenarioId);
const loginStep = scenario.steps.find((s) => s.type === "login");
if (!loginStep?.execCode) return null;
const params = JSON.parse(loginStep.execCode) as {
environmentName?: string;
};
if (!params.environmentName) return null;
const { data } = await this.environmentService.findAll({});
const env = data.find((e) => e.name === params.environmentName);
return env?.urls ?? null;
} catch {
return null;
}
}
// ── Job: pick up pending runs and process each to completion ─────────────
@Interval(1000)
@@ -92,6 +125,14 @@ export class ScenarioSchedulerService {
run.status = "in_progress";
await this.runRepo.save(run);
this.logger.log(`Run #${run.id} → in_progress`);
// Pre-load credential map for the scenario
const credMap = await this.scenarioService
.buildCredentialMap(run.scenarioId)
.catch(() => ({} as Record<string, unknown>));
this.runCredentials.set(run.id, credMap);
// Resolve environment from the first login step (best-effort)
const envUrls = await this.resolveRunEnvironment(run.scenarioId);
if (envUrls) this.runEnvironments.set(run.id, envUrls);
const traceId = crypto.randomUUID();
void traceStorage.run({ traceId }, () =>
this.processRunToCompletion(run.id),
@@ -114,8 +155,6 @@ export class ScenarioSchedulerService {
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()
@@ -129,6 +168,8 @@ export class ScenarioSchedulerService {
await this.runRepo.update(runId, { status: "fail" });
} finally {
this.activeRuns.delete(runId);
this.runCredentials.delete(runId);
this.runEnvironments.delete(runId);
}
}
@@ -250,6 +291,8 @@ export class ScenarioSchedulerService {
step.validateCode,
this.stepLogger(stepRun.id, stepRun.runId),
this.makeGetStepOutput(stepRun),
this.runCredentials.get(stepRun.runId),
this.runEnvironments.get(stepRun.runId),
);
const vr = this.parseValidateResult(result);
if (!vr.success) throw new Error(vr.description ?? "Validation failed");
@@ -272,12 +315,16 @@ export class ScenarioSchedulerService {
step.sessionName,
);
const getStepOutput = this.makeGetStepOutput(stepRun);
const creds = this.runCredentials.get(stepRun.runId);
const env = this.runEnvironments.get(stepRun.runId);
const { result: execOutput } = await this.codeExecutor.execute(
page,
context,
step.execCode,
this.stepLogger(stepRun.id, stepRun.runId),
getStepOutput,
creds,
env,
);
this.logger.log(`StepRun #${stepRun.id}: exec OK`);
@@ -289,6 +336,8 @@ export class ScenarioSchedulerService {
step.validateCode,
this.stepLogger(stepRun.id, stepRun.runId),
getStepOutput,
creds,
env,
);
const vr = this.parseValidateResult(result);
if (!vr.success) throw new Error(vr.description ?? "Validation failed");
@@ -327,6 +376,8 @@ export class ScenarioSchedulerService {
step.validateCode,
this.stepLogger(stepRun.id, stepRun.runId),
this.makeGetStepOutput(stepRun),
this.runCredentials.get(stepRun.runId),
this.runEnvironments.get(stepRun.runId),
);
const vr = this.parseValidateResult(result);
if (!vr.success) throw new Error(vr.description ?? "Validation failed");