Database lock (the reported 0.10.0 regression): - tauri-plugin-sql loads SQLite through a default multi-connection sqlx pool (Pool::connect => max_connections = 10) and exposes no JS transaction primitive. BEGIN and COMMIT issued as separate db.execute calls could land on different pooled connections, stranding an open write transaction on an idle connection — a permanent write lock until app restart. Triggered when the Bilan page's concurrent reads interleaved with an in-flight snapshot save. - Fix: funnel every db operation through one FIFO lock in db.ts; withTransaction holds it across the whole BEGIN..COMMIT so a transaction's statements never span connections (and the saves become genuinely atomic). A reentrancy guard lets nested getDb() calls run directly instead of deadlocking. Applied to all 5 transaction sites (saveSnapshotAtomic, upsertSnapshotLines, proposeStarterAccounts, applyKeywordWithReassignment, applyMigration) via in-place helper extraction. New db.test.ts covers serialization, cross-transaction non-interleaving, reentrancy, and lock release on error. Log console (Settings -> Systems -> Journaux): - getLogs returned the same mutated array reference, so useSyncExternalStore (identity comparison) never re-rendered on a new log -> the console never updated live. getLogs now returns an immutable snapshot rebuilt on each mutation (stable between mutations, new identity after each). - Add logInfo/logWarn/logError app-logging API and instrument the snapshot save (info on success, error on failure) so DB issues surface in the console. tsc + 635 vitest + vite build green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
97 lines
3 KiB
TypeScript
97 lines
3 KiB
TypeScript
import { describe, it, expect, beforeEach, beforeAll, vi } from "vitest";
|
|
import {
|
|
getRecentErrorLogs,
|
|
getLogs,
|
|
clearLogs,
|
|
initLogCapture,
|
|
} from "./logService";
|
|
|
|
beforeAll(() => {
|
|
// Patch console.* so addEntry runs. Idempotent.
|
|
initLogCapture();
|
|
});
|
|
|
|
describe("getRecentErrorLogs", () => {
|
|
beforeEach(() => {
|
|
// Reset the in-memory buffer. clearLogs also clears sessionStorage
|
|
// which jsdom provides in vitest.
|
|
clearLogs();
|
|
});
|
|
|
|
it("returns an empty string when the log buffer is empty", () => {
|
|
expect(getRecentErrorLogs(5)).toBe("");
|
|
});
|
|
|
|
it("returns an empty string when n <= 0", () => {
|
|
console.error("boom");
|
|
expect(getRecentErrorLogs(0)).toBe("");
|
|
expect(getRecentErrorLogs(-3)).toBe("");
|
|
});
|
|
|
|
it("filters out info-level entries", () => {
|
|
// Freeze time so the ISO prefix is predictable
|
|
vi.setSystemTime(new Date("2026-04-17T15:00:00.000Z"));
|
|
console.log("just chatter");
|
|
console.warn("low fuel");
|
|
console.error("engine out");
|
|
|
|
const out = getRecentErrorLogs(10);
|
|
expect(out).not.toContain("chatter");
|
|
expect(out).toContain("WARN: low fuel");
|
|
expect(out).toContain("ERROR: engine out");
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("keeps only the last N non-info entries in order", () => {
|
|
for (let i = 0; i < 5; i++) console.warn(`w${i}`);
|
|
const out = getRecentErrorLogs(2);
|
|
const lines = out.split("\n");
|
|
expect(lines).toHaveLength(2);
|
|
expect(lines[0]).toContain("w3");
|
|
expect(lines[1]).toContain("w4");
|
|
});
|
|
|
|
it("formats each line as `[ISO] LEVEL: message`", () => {
|
|
vi.setSystemTime(new Date("2026-04-17T15:23:45.000Z"));
|
|
console.error("export failed");
|
|
const out = getRecentErrorLogs(1);
|
|
expect(out).toMatch(/^\[2026-04-17T15:23:45\.000Z\] ERROR: export failed$/);
|
|
vi.useRealTimers();
|
|
});
|
|
});
|
|
|
|
// Regression: the log console (Settings → Systems → Journaux) stayed empty
|
|
// because getLogs returned the same mutated array reference on every call, so
|
|
// useSyncExternalStore (which compares snapshots by identity) never re-rendered
|
|
// when a new log arrived. getLogs must return a STABLE reference between
|
|
// mutations (no per-call re-slice, or React loops) and a NEW one after each.
|
|
describe("getLogs — live-update snapshot", () => {
|
|
beforeEach(() => {
|
|
clearLogs();
|
|
});
|
|
|
|
it("returns a stable reference between mutations", () => {
|
|
expect(getLogs()).toBe(getLogs());
|
|
console.warn("x");
|
|
const snap = getLogs();
|
|
expect(snap).toBe(getLogs());
|
|
});
|
|
|
|
it("returns a NEW reference after a log so the store re-renders", () => {
|
|
const before = getLogs();
|
|
console.warn("new entry");
|
|
const after = getLogs();
|
|
expect(after).not.toBe(before);
|
|
expect(after).toHaveLength(before.length + 1);
|
|
expect(after[after.length - 1].message).toBe("new entry");
|
|
});
|
|
|
|
it("returns a NEW reference after clearLogs", () => {
|
|
console.warn("y");
|
|
const before = getLogs();
|
|
clearLogs();
|
|
const after = getLogs();
|
|
expect(after).not.toBe(before);
|
|
expect(after).toHaveLength(0);
|
|
});
|
|
});
|