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. 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 ## 1.10.0 - 2026-09-15
Changes since 1.9.1: Changes since 1.9.1:
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "liqa", "name": "liqa",
"version": "1.10.0", "version": "1.10.1",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"server", "server",
@@ -199,6 +199,7 @@ export class CodeExecutorService {
if (snippetCode == null) { if (snippetCode == null) {
throw new Error(`Snippet alias "${alias}" not found`); throw new Error(`Snippet alias "${alias}" not found`);
} }
scriptLog("log", `Snippet "${alias}" started`);
const snippetFn = new Function( const snippetFn = new Function(
"context", "context",
"console", "console",
@@ -206,7 +207,22 @@ export class CodeExecutorService {
"expect", "expect",
`return (async (context, ...args) => { ${snippetCode} })(context, ...snippetArgs)`, `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 }) => { getScenarioFiles: async (opts?: { limit?: number; offset?: number }) => {
if (!fileService || !scenarioId) { if (!fileService || !scenarioId) {
@@ -39,6 +39,11 @@ export class ScenarioRunLogEntity {
@Column({ type: "text" }) @Column({ type: "text" })
message: string; 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() @CreateDateColumn()
createdAt: Date; createdAt: Date;
} }
@@ -46,6 +46,9 @@ export class ScenarioSchedulerService implements OnModuleInit {
private readonly runScenarioTimeouts = new Map<string, number | null>(); private readonly runScenarioTimeouts = new Map<string, number | null>();
// Cache scenarioId per run // Cache scenarioId per run
private readonly runScenarioIds = new Map<string, string>(); 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_SCENARIO_TIMEOUT_SEC = 600;
private static readonly DEFAULT_STEP_TIMEOUT_SEC = 60; private static readonly DEFAULT_STEP_TIMEOUT_SEC = 60;
@@ -138,8 +141,10 @@ export class ScenarioSchedulerService implements OnModuleInit {
level: "log" | "warn" | "error", level: "log" | "warn" | "error",
message: string, message: string,
): void { ): void {
const seq = (this.runLogSeq.get(runId) ?? 0) + 1;
this.runLogSeq.set(runId, seq);
void this.runLogRepo.save( 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.runEnvironments.delete(run.id);
this.runScenarioTimeouts.delete(run.id); this.runScenarioTimeouts.delete(run.id);
this.runScenarioIds.delete(run.id); this.runScenarioIds.delete(run.id);
this.runLogSeq.delete(run.id);
this.logger.error( this.logger.error(
`Run #${run.id}: setup failed — ${String(err)}`, `Run #${run.id}: setup failed — ${String(err)}`,
); );
@@ -279,6 +285,7 @@ export class ScenarioSchedulerService implements OnModuleInit {
this.runEnvironments.delete(runId); this.runEnvironments.delete(runId);
this.runScenarioTimeouts.delete(runId); this.runScenarioTimeouts.delete(runId);
this.runScenarioIds.delete(runId); this.runScenarioIds.delete(runId);
this.runLogSeq.delete(runId);
await this.maybePreserveSession(runId); await this.maybePreserveSession(runId);
} }
} }
+6 -13
View File
@@ -402,7 +402,7 @@ export class ScenarioService {
); );
const logs = await this.runLogRepo.find({ const logs = await this.runLogRepo.find({
where: q?.trim() ? { runId, message: Like(`%${q.trim()}%`) } : { runId }, where: q?.trim() ? { runId, message: Like(`%${q.trim()}%`) } : { runId },
order: { createdAt: "ASC" }, order: { seq: "ASC", createdAt: "ASC" },
}); });
return Object.assign(run, { logs }); return Object.assign(run, { logs });
} }
@@ -422,7 +422,7 @@ export class ScenarioService {
); );
const logs = await this.runLogRepo.find({ const logs = await this.runLogRepo.find({
where: { runId }, where: { runId },
order: { createdAt: "ASC" }, order: { seq: "ASC", createdAt: "ASC" },
}); });
if (format === "csv") { if (format === "csv") {
@@ -439,17 +439,10 @@ export class ScenarioService {
return rows.join("\n"); return rows.join("\n");
} }
const lines = [ const sections = logs.map(
`# Run ${runId} logs`, (l) => `## [${l.level}] ${l.createdAt.toISOString()}\n\n${l.message}`,
"", );
"| Timestamp | Level | Message |", return [`# Run ${runId} logs`, ...sections].join("\n\n");
"| --- | --- | --- |",
...logs.map(
(l) =>
`| ${l.createdAt.toISOString()} | ${l.level} | ${l.message.replace(/\|/g, "\\|").replace(/\n/g, "<br>")} |`,
),
];
return lines.join("\n");
} }
async waitForRun( async waitForRun(