fix(scenario-logs): fix out-of-order run log persistence

- Add seq column assigned in call order to fix logs racing each other
  as fire-and-forget writes (e.g. a snippet's "started" entry could be
  saved after its "finished" entry)
- Log snippet start/finish/failure via context.runSnippet, matching
  existing step start/pass/fail logging
- Rework Markdown log export to per-entry sections instead of a table
- Version bump: 1.10.0 -> 1.10.1
This commit is contained in:
Andrii Arsenin
2026-09-15 13:17:29 +03:00
parent ce85aa02f1
commit 85a87b5fb2
6 changed files with 53 additions and 16 deletions
+16
View File
@@ -2,6 +2,22 @@
All notable changes to this project will be documented in this file.
## 1.10.1 - 2026-09-15
Changes since 1.10.0:
### Added
- `context.runSnippet()` now logs "Snippet started"/"finished"/"failed" entries, matching the automatic step start/pass/fail logging.
### Changed
- Reworked the Markdown log export format: each log entry is now its own `## [level] timestamp` section with the message as its body, instead of a single large table.
### Fixed
- Run logs could persist out of call order (e.g. a snippet's "started" log appearing after its "finished" log) because writes were fire-and-forget and raced against each other. Log entries now carry a per-run sequence number assigned in call order, and queries/exports sort by it.
## 1.10.0 - 2026-09-15
Changes since 1.9.1:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "liqa",
"version": "1.10.0",
"version": "1.10.1",
"private": true,
"workspaces": [
"server",
@@ -199,6 +199,7 @@ export class CodeExecutorService {
if (snippetCode == null) {
throw new Error(`Snippet alias "${alias}" not found`);
}
scriptLog("log", `Snippet "${alias}" started`);
const snippetFn = new Function(
"context",
"console",
@@ -206,7 +207,22 @@ export class CodeExecutorService {
"expect",
`return (async (context, ...args) => { ${snippetCode} })(context, ...snippetArgs)`,
);
return snippetFn(scriptContext, fakeConsole, args, playwrightExpect);
try {
const snippetResult = await snippetFn(
scriptContext,
fakeConsole,
args,
playwrightExpect,
);
scriptLog("log", `Snippet "${alias}" finished`);
return snippetResult;
} catch (err) {
scriptLog(
"error",
`Snippet "${alias}" failed: ${(err as Error).message}`,
);
throw err;
}
},
getScenarioFiles: async (opts?: { limit?: number; offset?: number }) => {
if (!fileService || !scenarioId) {
@@ -39,6 +39,11 @@ export class ScenarioRunLogEntity {
@Column({ type: "text" })
message: string;
// Assigned in call order per run, since concurrent unawaited writes can
// otherwise persist out of order (createdAt alone isn't a reliable tiebreaker).
@Column({ type: "int", default: 0 })
seq: number;
@CreateDateColumn()
createdAt: Date;
}
@@ -46,6 +46,9 @@ export class ScenarioSchedulerService implements OnModuleInit {
private readonly runScenarioTimeouts = new Map<string, number | null>();
// Cache scenarioId per run
private readonly runScenarioIds = new Map<string, string>();
// Monotonic log sequence per run, since persistLog writes are unawaited and
// can otherwise resolve out of call order
private readonly runLogSeq = new Map<string, number>();
private static readonly DEFAULT_SCENARIO_TIMEOUT_SEC = 600;
private static readonly DEFAULT_STEP_TIMEOUT_SEC = 60;
@@ -138,8 +141,10 @@ export class ScenarioSchedulerService implements OnModuleInit {
level: "log" | "warn" | "error",
message: string,
): void {
const seq = (this.runLogSeq.get(runId) ?? 0) + 1;
this.runLogSeq.set(runId, seq);
void this.runLogRepo.save(
this.runLogRepo.create({ runId, stepRunId, level, message }),
this.runLogRepo.create({ runId, stepRunId, level, message, seq }),
);
}
@@ -216,6 +221,7 @@ export class ScenarioSchedulerService implements OnModuleInit {
this.runEnvironments.delete(run.id);
this.runScenarioTimeouts.delete(run.id);
this.runScenarioIds.delete(run.id);
this.runLogSeq.delete(run.id);
this.logger.error(
`Run #${run.id}: setup failed — ${String(err)}`,
);
@@ -279,6 +285,7 @@ export class ScenarioSchedulerService implements OnModuleInit {
this.runEnvironments.delete(runId);
this.runScenarioTimeouts.delete(runId);
this.runScenarioIds.delete(runId);
this.runLogSeq.delete(runId);
await this.maybePreserveSession(runId);
}
}
+6 -13
View File
@@ -402,7 +402,7 @@ export class ScenarioService {
);
const logs = await this.runLogRepo.find({
where: q?.trim() ? { runId, message: Like(`%${q.trim()}%`) } : { runId },
order: { createdAt: "ASC" },
order: { seq: "ASC", createdAt: "ASC" },
});
return Object.assign(run, { logs });
}
@@ -422,7 +422,7 @@ export class ScenarioService {
);
const logs = await this.runLogRepo.find({
where: { runId },
order: { createdAt: "ASC" },
order: { seq: "ASC", createdAt: "ASC" },
});
if (format === "csv") {
@@ -439,17 +439,10 @@ export class ScenarioService {
return rows.join("\n");
}
const lines = [
`# Run ${runId} logs`,
"",
"| Timestamp | Level | Message |",
"| --- | --- | --- |",
...logs.map(
(l) =>
`| ${l.createdAt.toISOString()} | ${l.level} | ${l.message.replace(/\|/g, "\\|").replace(/\n/g, "<br>")} |`,
),
];
return lines.join("\n");
const sections = logs.map(
(l) => `## [${l.level}] ${l.createdAt.toISOString()}\n\n${l.message}`,
);
return [`# Run ${runId} logs`, ...sections].join("\n\n");
}
async waitForRun(