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:
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user