diff --git a/CHANGELOG.md b/CHANGELOG.md index 23f126c..8fb0b7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: diff --git a/package.json b/package.json index d383ba1..dac7ff0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "liqa", - "version": "1.10.0", + "version": "1.10.1", "private": true, "workspaces": [ "server", diff --git a/server/src/code-executor/code-executor.service.ts b/server/src/code-executor/code-executor.service.ts index 05de3b7..7146805 100644 --- a/server/src/code-executor/code-executor.service.ts +++ b/server/src/code-executor/code-executor.service.ts @@ -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) { diff --git a/server/src/scenario/scenario-run-log.entity.ts b/server/src/scenario/scenario-run-log.entity.ts index ea4ecab..75d6e13 100644 --- a/server/src/scenario/scenario-run-log.entity.ts +++ b/server/src/scenario/scenario-run-log.entity.ts @@ -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; } diff --git a/server/src/scenario/scenario-scheduler.service.ts b/server/src/scenario/scenario-scheduler.service.ts index 6dff53e..cc0bea9 100644 --- a/server/src/scenario/scenario-scheduler.service.ts +++ b/server/src/scenario/scenario-scheduler.service.ts @@ -46,6 +46,9 @@ export class ScenarioSchedulerService implements OnModuleInit { private readonly runScenarioTimeouts = new Map(); // Cache scenarioId per run private readonly runScenarioIds = new Map(); + // Monotonic log sequence per run, since persistLog writes are unawaited and + // can otherwise resolve out of call order + private readonly runLogSeq = new Map(); 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); } } diff --git a/server/src/scenario/scenario.service.ts b/server/src/scenario/scenario.service.ts index 75322fd..a45d371 100644 --- a/server/src/scenario/scenario.service.ts +++ b/server/src/scenario/scenario.service.ts @@ -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, "
")} |`, - ), - ]; - 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(