Compare commits
No commits in common. "86eff9b7a36303f08dd3a45d35af0f434fdc89c8" and "fbecfffa6eb18992375b8db7837141f549a16d02" have entirely different histories.
86eff9b7a3
...
fbecfffa6e
11 changed files with 44 additions and 352 deletions
|
|
@ -10,6 +10,7 @@
|
|||
### Corrigé
|
||||
|
||||
- La page d'erreur du démarrage ne reste plus affichée quand le profil finit de s'ouvrir après le délai de 10 secondes : l'application s'affiche dès que le profil est ouvert (#395).
|
||||
|
||||
- Les installations faites depuis le paquet `.rpm` reçoivent désormais les mises à jour automatiques. Le manifeste de mise à jour ne listait que le `.deb`, qu'une installation `.rpm` téléchargeait puis refusait ; il liste maintenant chaque paquet Linux sous sa propre clé. Les installations `.rpm` existantes n'ont rien à réinstaller : elles recevront la prochaine version (vérifié sur le paquet v0.17.1 publié). Les instructions de téléchargement de la page de release ne mentionnent plus d'AppImage, qui n'est pas construite, et nomment désormais le `.rpm`. Le manifeste n'a plus d'entrée Linux générique : les AppImage des versions 0.4.3 à 0.4.5, qui ne savaient pas installer le `.deb` qu'elle désignait et en étaient cassées, affichent désormais une erreur à la vérification des mises à jour ; installez plutôt le paquet `.deb` ou `.rpm` (#320).
|
||||
|
||||
### Sécurité
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
### Fixed
|
||||
|
||||
- The startup error page no longer stays on screen when the profile finishes opening after the 10-second delay: the app now shows as soon as the profile is open (#395).
|
||||
|
||||
- Installs from the `.rpm` package now receive automatic updates. The update manifest only listed the `.deb`, which an `.rpm` install downloaded and then refused; it now lists each Linux package under its own key. Existing `.rpm` installs need no reinstall: they will receive the next version (checked on the published v0.17.1 package). The download instructions of the release page no longer mention an AppImage, which is not built, and now name the `.rpm`. The manifest no longer has a generic Linux entry: the AppImage builds of 0.4.3 to 0.4.5, which could not install the `.deb` it pointed to and were broken by it, now report an error when checking for updates instead; install the `.deb` or `.rpm` package (#320).
|
||||
|
||||
### Security
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ Comme sqlx pour le profil par défaut. Une version qui échoue sur une clé étr
|
|||
|
||||
### 7. L'échec reste ouvert
|
||||
|
||||
Un rattrapage interrompu est écrit au Journal et le profil s'ouvre comme avant #390. Les messages nomment des fichiers, jamais le dossier : il contient le nom d'utilisateur du système, et une ligne d'erreur du Journal peut être jointe à une rétroaction (Loi 25). Un rattrapage qui s'arrête après sa copie la nomme quand même : `catch_up_backup` (nom de fichier seul) est rempli dès que la copie existe, et la ligne d'erreur du Journal finit par « copy of the previous state kept as … » (#395). Côté TypeScript, `withoutFolders` réduit chaque chemin d'une ligne d'erreur, message de repli compris, à son dernier segment (le nom de fichier). Un chemin part d'un début reconnu (lecteur `C:\`, `/`, `~/`, `./`, partage `\\`, ou le `<profile folder>` de `scrub`) et court jusqu'à son dernier séparateur, espaces compris, pour qu'un dossier comme `C:\Users\Jean Dupont\` ou `/home/marie claire/` ne laisse aucun mot derrière lui ; il s'arrête à un saut de ligne, un guillemet double, une apostrophe inverse, `;`, `|`, une virgule ou un deux-points suivis d'un blanc, une parenthèse fermante qui n'est pas suivie d'un séparateur (`Program Files (x86)\` continue), ou un blanc suivi d'un autre chemin. Une apostrophe placée juste avant le chemin reste autour du nom de fichier (`'C:\Users\Bob\x.db'` donne `'x.db'`). Quand rien ne l'arrête, la règle est prudente : elle retire le texte qui suit jusqu'au dernier séparateur de la ligne plutôt que de laisser passer un dossier. Limite : un chemin relatif sans début reconnu (`dossier/sous dossier/x.db`) n'a que ses séparateurs remplacés par `-` ; il ne part pas du dossier personnel, donc n'en contient pas le nom. Tout autre séparateur devient `-` : aucune ligne d'erreur ne contient `/` ni `\`.
|
||||
Un rattrapage interrompu est écrit au Journal et le profil s'ouvre comme avant #390. Les messages nomment des fichiers, jamais le dossier : il contient le nom d'utilisateur du système, et une ligne d'erreur du Journal peut être jointe à une rétroaction (Loi 25). Un rattrapage qui s'arrête après sa copie la nomme quand même : `catch_up_backup` (nom de fichier seul) est rempli dès que la copie existe, et la ligne d'erreur du Journal finit par « copy of the previous state kept as … » (#395). Côté TypeScript, `withoutFolders` ne garde que le dernier segment de tout chemin d'une ligne d'erreur, message de repli compris : aucune ligne d'erreur ne contient `/` ni `\`.
|
||||
|
||||
### 8. Un seul dossier des bases
|
||||
|
||||
|
|
|
|||
|
|
@ -148,9 +148,9 @@ Index Bilan (9) — 7 ajoutés en v9 :
|
|||
|
||||
- **Verrou strict, non réentrant (#408).** Le rappel de `withTransaction` reçoit un **handle de transaction** (typé `Database`) qui exécute directement sur la connexion. Toute autre requête attend derrière la transaction, y compris un `getDb()` appelé depuis le rappel ou depuis une fonction qu'il appelle : celui-ci attend la transaction qui l'attend, un interblocage. Règle : dans un rappel, n'utiliser **que** le handle reçu et le passer aux fonctions appelées (par exemple `exec`, obligatoire sur `findOrCreateSecurity`). Avant #408, un drapeau global laissait passer hors verrou toute requête lancée pendant un rappel, d'où qu'elle vienne.
|
||||
- **Handle révoqué.** À la fin du rappel (succès ou erreur), le handle est révoqué : un appel tardif depuis une promesse orpheline lève `TransactionHandleRevokedError` au lieu de passer hors verrou. `close()` sur le handle lève toujours `TransactionHandleCloseError`.
|
||||
- **Changement de profil dans une seule section.** `connectToProfile`, `initializeNewProfileDb` et `closeDb` sont chacun **un seul** `runExclusive` : fermeture de l'ancien pool, puis `repair_migrations` et `prepare_profile_db` (pour `connectToProfile`, appelé seulement après la fin du `close()`), puis `Database.load` et la bascule. Ils attendent donc les opérations déjà en file et une transaction en cours, et rien ne s'exécute entre la fermeture et l'ouverture. Les helpers internes (`closeUnlocked`, `loadUnlocked`) ne prennent jamais le verrou. Pendant un rattrapage long, toute la file attend : rien d'utile ne peut tourner pendant la bascule. Un `close()` de l'ancien pool qui échoue ne bloque pas la bascule : l'état est vidé et la génération incrémentée avant le `close()`, l'échec part au Journal (`logWarn`) et le profil suivant s'ouvre quand même. `closeDb()` seul se résout aussi dans ce cas, sans connexion, et tout ancien handle refuse de tourner.
|
||||
- **Génération de connexion.** Chaque ouverture et chaque fermeture incrémentent un numéro de génération. Le proxy de `getDb()` et le handle de transaction le vérifient au moment où l'opération sort de la file : une opération mise en file pour une connexion fermée entre-temps lève `DbConnectionClosedError` (« connection closed by profile switch »), au lieu de tourner sur la base du profil suivant ou sur un pool fermé. Un handle obtenu par `getDb()` avant un changement de profil ne sert donc plus après : le reprendre par `getDb()`. Son `close()` est vérifié de la même façon et lève `DbConnectionClosedError`, au lieu de fermer la connexion du profil courant. Dans le handle de transaction, la vérification est une défense, inatteignable aujourd'hui : rien ne ferme la connexion pendant qu'une transaction tient le verrou.
|
||||
- **Alerte de blocage.** Une opération qui attend le verrou depuis plus de `BLOCKED_ALERT_MS` (15 s) écrit **un** `logWarn` au Journal, qui nomme ce qui tient le verrou (`transaction`, `query`, `profile opening`, `profile initialization`, `connection close`) et depuis quand. L'alerte cite un `getDb()` dans un rappel comme cause possible, pas comme diagnostic : une transaction longue (grosse restauration, migration de catégories sur un gros profil) dépasse aussi 15 s. Un rattrapage de migrations de plus de 15 s produit une alerte « profile opening », attendue. `logService` n'écrit qu'en mémoire et en `sessionStorage`, jamais en base : l'appel est sûr pendant que le verrou est tenu.
|
||||
- **Changement de profil dans une seule section.** `connectToProfile`, `initializeNewProfileDb` et `closeDb` sont chacun **un seul** `runExclusive` : fermeture de l'ancien pool, puis `repair_migrations` et `prepare_profile_db` (pour `connectToProfile`, appelé seulement après la fin du `close()`), puis `Database.load` et la bascule. Ils attendent donc les opérations déjà en file et une transaction en cours, et rien ne s'exécute entre la fermeture et l'ouverture. Les helpers internes (`closeUnlocked`, `loadUnlocked`) ne prennent jamais le verrou. Pendant un rattrapage long, toute la file attend : rien d'utile ne peut tourner pendant la bascule.
|
||||
- **Génération de connexion.** Chaque ouverture et chaque fermeture incrémentent un numéro de génération. Le proxy de `getDb()` et le handle de transaction le vérifient au moment où l'opération sort de la file : une opération mise en file pour une connexion fermée entre-temps lève `DbConnectionClosedError` (« connection closed by profile switch »), au lieu de tourner sur la base du profil suivant ou sur un pool fermé. Un handle obtenu par `getDb()` avant un changement de profil ne sert donc plus après : le reprendre par `getDb()`.
|
||||
- **Alerte de blocage.** Une opération qui attend le verrou depuis plus de `BLOCKED_ALERT_MS` (15 s) écrit **un** `logWarn` au Journal, qui nomme ce qui tient le verrou (`transaction`, `query`, `profile opening`, `profile initialization`, `connection close`) et depuis quand. Cause typique : un `getDb()` dans un rappel. Un rattrapage de migrations de plus de 15 s produit une alerte « profile opening », attendue. `logService` n'écrit qu'en mémoire et en `sessionStorage`, jamais en base : l'appel est sûr pendant que le verrou est tenu.
|
||||
- **Tests.** Seul `src/services/db.test.ts` voit le verrou (un `Database` factice qui journalise le début et la fin de chaque opération). Les tests de services mockent `db.ts` (`withTransaction: fn => fn(await getDb())`) et ne le voient pas.
|
||||
|
||||
## Système de migrations
|
||||
|
|
@ -613,13 +613,13 @@ Sémantique simulée, chaque point prouvé par `src/test/hookHarness.test.ts` :
|
|||
- `useEffect` : capturé pendant le rendu, joué seulement par `commit()` s'il a une dépendance changée, nettoyages d'abord ; `replayEffects()` joue le double passage de StrictMode au montage, `unmount()` les nettoyages ;
|
||||
- tout autre hook (`useContext`, `useLayoutEffect`, `useSyncExternalStore`, `useId`, `use`…) **lève** : un test rougit sur son assertion, jamais par « Invalid hook call ». Mocker le module qui l'appelle (`useOnboarding`, `useLocation`, `useTranslation`…).
|
||||
|
||||
Non simulé : le nouveau rendu après un setter ou un dispatch, les enfants (un composant dans l'arbre rendu reste un élément), le DOM, le concurrent, la boucle de mises à jour pendant le rendu (le `render()` suivant la lit), et un reducer qui change d'un rendu à l'autre : le `dispatch` d'un `useReducer` garde le reducer du premier rendu (il est créé une fois, avec l'emplacement), là où React applique celui du dernier rendu. Un reducer défini au niveau du module, seul cas utilisé, n'est pas touché.
|
||||
Non simulé : le nouveau rendu après un setter ou un dispatch, les enfants (un composant dans l'arbre rendu reste un élément), le DOM, le concurrent, la boucle de mises à jour pendant le rendu (le `render()` suivant la lit).
|
||||
|
||||
Utilisateurs : `useOnboardingChecklist.wiring.test.ts`, `UsersSettingsPage.test.tsx`, `BudgetTable.wiring.test.ts`, `BudgetPage.wiring.test.ts`, `AppShell.slot.test.tsx`, `OnboardingStepCard.test.tsx`, `PriceFetchConsentToggle.test.tsx` (#389). Hors périmètre de #409, `useImportWizard.mount.test.ts` et `useImportWizard.executeImport.test.ts` gardent leurs faux non persistants.
|
||||
|
||||
### Faux `t` marqué — `src/test/i18nMock.ts` (#389)
|
||||
|
||||
**Jamais de `t` brut dans un test.** Un faux `t` qui rend la clé telle quelle cache un appel à `t` oublié : un composant qui affiche `license.removeKey.confirmValid` au lieu du texte traduit passe toute la suite. Dans les fichiers de test, le seul faux `t` est `markedT` (`src/test/i18nMock.ts`), servi par la fabrique `i18nMock` ou passé tel quel à une fonction qui prend `t` en paramètre (par exemple `renderCategoryLabel`) : il rend `t(clé)`, ou `t(clé[a=1,b=2])` avec des options, dans l'ordre des options. La garde ci-dessous ne refuse que les formes qu'elle sait reconnaître : un faux `t` écrit autrement lui échapperait. Motif unique, en tête du fichier de test :
|
||||
**Jamais de `t` brut dans un test.** Un faux `t` qui rend la clé telle quelle cache un appel à `t` oublié : un composant qui affiche `license.removeKey.confirmValid` au lieu du texte traduit passe toute la suite. Le seul faux `t` du dépôt est `markedT` : il rend `t(clé)`, ou `t(clé[a=1,b=2])` avec des options, dans l'ordre des options. Motif unique, en tête du fichier de test :
|
||||
|
||||
```ts
|
||||
vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());
|
||||
|
|
@ -629,7 +629,7 @@ Options de la fabrique : `language` (« fr » par défaut ; une fonction est rel
|
|||
|
||||
Les assertions qui nomment un texte affiché s'écrivent sous la forme marquée (`toContain(">t(dashboard.title)<")`, `toEqual(["t(onboarding.state.done)"])`) : elles rougissent sur une chaîne qui a sauté `t`. Une assertion par sous-chaîne sur une clé nue reste valide, la clé figurant dans la marque, mais ne prouve pas l'appel à `t`. Dans une expression régulière, échapper les parenthèses et crochets de la marque.
|
||||
|
||||
Garde : `src/__integration__/i18n-mock-guard.test.ts` lit tous les `*.test.ts(x)` de `src/` et échoue, hors de `src/test/i18nMock.ts`, sur une propriété `useTranslation:`, sur un faux `t` en propriété (`t: (`, `t: vi.fn(`) ou en liaison (`t = (`, `t = vi.fn(`), tous ancrés sur une frontière de mot, ou sur un `vi.mock("react-i18next", …)` sans appel à `i18nMock(` (le nom seul, dans un commentaire par exemple, ne compte pas : les commentaires sont retirés avant la recherche).
|
||||
Garde : `src/__integration__/i18n-mock-guard.test.ts` lit tous les `*.test.ts(x)` de `src/` et échoue sur une propriété `useTranslation:` ou `t: (` (ancrées sur une frontière de mot) hors de `src/test/i18nMock.ts`, ou sur un `vi.mock("react-i18next", …)` qui ne passe pas par `i18nMock`.
|
||||
|
||||
## CI/CD
|
||||
|
||||
|
|
|
|||
|
|
@ -7,17 +7,13 @@
|
|||
* does, and fails on any test file (`*.test.ts`, `*.test.tsx`) other than the
|
||||
* module itself that:
|
||||
* 1. declares a `useTranslation:` property (a hand-written `react-i18next` module);
|
||||
* 2. declares a hand-written fake `t`: a `t: (` or `t: vi.fn(` property, or a
|
||||
* `t = (` / `t = vi.fn(` binding (`const t = (key) => key`, passed to a
|
||||
* function that takes `t`);
|
||||
* 3. mocks `react-i18next` without an actual call to `i18nMock(` (the name
|
||||
* alone, in a comment for instance, is not enough: comments are dropped
|
||||
* before the search).
|
||||
* 2. declares a `t: (` property (a hand-written fake `t`);
|
||||
* 3. mocks `react-i18next` without going through `i18nMock`.
|
||||
*
|
||||
* Keywords rather than an analysis of the factory: less fragile. The matches
|
||||
* are anchored on a word boundary, so `useLicenseContext: () =>`,
|
||||
* `get: (target, key)`, `const at = (` or `t === (` are not a fake `t`. This
|
||||
* file names the patterns only inside regular expressions and excludes itself.
|
||||
* are anchored on a word boundary, so `useLicenseContext: () =>` or
|
||||
* `get: (target, key)` are not a `t: (`. This file names the patterns only
|
||||
* inside regular expressions and excludes itself.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
|
@ -29,14 +25,8 @@ const MODULE = resolve(SRC, "test", "i18nMock.ts");
|
|||
const SELF = resolve(import.meta.dirname, "i18n-mock-guard.test.ts");
|
||||
|
||||
const LOCAL_MODULE = /\buseTranslation\s*:/;
|
||||
const LOCAL_T = /\bt\s*[:=]\s*(?:\(|vi\.fn\()/;
|
||||
const LOCAL_T = /\bt\s*:\s*\(/;
|
||||
const MOCKS_I18NEXT = /vi\.mock\(\s*["']react-i18next["']/;
|
||||
const CALLS_I18NMOCK = /\bi18nMock\s*\(/;
|
||||
|
||||
/** The source without its comments, so a mention of `i18nMock(` in one does not count as a call. */
|
||||
function withoutComments(source: string): string {
|
||||
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
|
||||
}
|
||||
|
||||
/** The offending lines of one source, as `path:line: text`. */
|
||||
function offences(path: string, source: string): string[] {
|
||||
|
|
@ -46,7 +36,7 @@ function offences(path: string, source: string): string[] {
|
|||
.flatMap((line, i) =>
|
||||
LOCAL_MODULE.test(line) || LOCAL_T.test(line) ? [`${name}:${i + 1}: ${line.trim()}`] : [],
|
||||
);
|
||||
if (MOCKS_I18NEXT.test(source) && !CALLS_I18NMOCK.test(withoutComments(source))) {
|
||||
if (MOCKS_I18NEXT.test(source) && !source.includes("i18nMock")) {
|
||||
found.push(`${name}: mocks react-i18next without i18nMock`);
|
||||
}
|
||||
return found;
|
||||
|
|
@ -76,35 +66,10 @@ describe("i18n mock guard (#389)", () => {
|
|||
'vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());',
|
||||
"const context = { useLicenseContext: () => ctx, get: (target: object) => target };",
|
||||
"await view.removeKey({ confirm, t: markedT });",
|
||||
"const at = (n: number) => n; const format = (v: string) => v;",
|
||||
"if (t === (other as unknown)) return; const run = (t) => t;",
|
||||
"const t = markedT; const set = { t: markedT };",
|
||||
].join("\n");
|
||||
expect(offences(resolve(SRC, "x.test.tsx"), clean)).toEqual([]);
|
||||
});
|
||||
|
||||
it("recognises a fake t bound to a name or built with vi.fn", () => {
|
||||
const x = resolve(SRC, "x.test.ts");
|
||||
expect(offences(x, "const t = (key: string) => key;")).toHaveLength(1);
|
||||
expect(offences(x, "let t = (key: string, o?: object) => key;")).toHaveLength(1);
|
||||
expect(offences(x, " t = (key: string) => key;")).toHaveLength(1);
|
||||
expect(offences(x, "const t = vi.fn((key: string) => key);")).toHaveLength(1);
|
||||
expect(offences(x, " useT: () => ({ t: vi.fn((k: string) => k) }),")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("requires an actual call to i18nMock(, not its name in a comment", () => {
|
||||
const x = resolve(SRC, "x.test.tsx");
|
||||
const inComment = [
|
||||
"// Not i18nMock(): a hand-written module.",
|
||||
"/* i18nMock( would be the pattern */",
|
||||
'vi.mock("react-i18next", () => fake);',
|
||||
].join("\n");
|
||||
expect(offences(x, inComment)).toEqual(["x.test.tsx: mocks react-i18next without i18nMock"]);
|
||||
expect(offences(x, '// i18nMock is the rule\nvi.mock("react-i18next", () => fake);')).toHaveLength(1);
|
||||
const called = 'vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock({ language: "en" }));';
|
||||
expect(offences(x, called)).toEqual([]);
|
||||
});
|
||||
|
||||
it("finds no other fake t than the one of src/test/i18nMock.ts", () => {
|
||||
const found = TEST_FILES.flatMap((path) => offences(path, readFileSync(path, "utf-8")));
|
||||
expect(found, "use i18nMock from src/test/i18nMock.ts").toEqual([]);
|
||||
|
|
|
|||
|
|
@ -125,8 +125,7 @@ describe("useOnboardingChecklist — wiring (#396)", () => {
|
|||
vi.useFakeTimers();
|
||||
harness.dispatch.mockReset();
|
||||
|
||||
// A route change re-runs the read (that it leaves the follower and its
|
||||
// pending re-read alone is pinned by the next test).
|
||||
// A route change re-runs the read, not the follower: one subscription only.
|
||||
ctx.pathname = "/reports";
|
||||
render();
|
||||
harness.commit();
|
||||
|
|
@ -147,31 +146,6 @@ describe("useOnboardingChecklist — wiring (#396)", () => {
|
|||
expect(actionsOfType("LOAD_START")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("a route change during the 150 ms wait does not drop the deferred re-read", async () => {
|
||||
render();
|
||||
harness.commit();
|
||||
await flush();
|
||||
vi.useFakeTimers();
|
||||
harness.dispatch.mockReset();
|
||||
|
||||
notifyDataChanged();
|
||||
vi.advanceTimersByTime(100);
|
||||
// The route changes while the re-read waits: the read effect runs again,
|
||||
// the follower (deps: [refresh]) must not be cleaned up and resubscribed,
|
||||
// which would drop its pending timer.
|
||||
ctx.pathname = "/reports";
|
||||
render();
|
||||
harness.commit();
|
||||
await flush();
|
||||
expect(actionsOfType("LOAD_START")).toHaveLength(1);
|
||||
|
||||
vi.advanceTimersByTime(50);
|
||||
await flush();
|
||||
// One read for the route, one for the data event.
|
||||
expect(actionsOfType("LOAD_START")).toHaveLength(2);
|
||||
expect(getOnboardingSnapshot).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("stores a card latch that changed during render, and only then", () => {
|
||||
mountWith({ ...ready, cardPage: "/import" });
|
||||
ctx.pathname = "/reports";
|
||||
|
|
|
|||
|
|
@ -322,65 +322,6 @@ describe("profile switch goes through the lock (#408)", () => {
|
|||
await expect(db.execute("AFTER")).rejects.toBeInstanceOf(DbConnectionClosedError);
|
||||
});
|
||||
|
||||
it("close() on a getDb() handle kept from the previous profile rejects and keeps the current one open", async () => {
|
||||
const before = await getDb();
|
||||
const next = makeFakeDb("next");
|
||||
h.fakeDb = next;
|
||||
await connectToProfile("next.db");
|
||||
events.length = 0;
|
||||
await expect(before.close()).rejects.toBeInstanceOf(DbConnectionClosedError);
|
||||
expect(next.close).not.toHaveBeenCalled();
|
||||
await (await getDb()).execute("STILL OPEN");
|
||||
expect(next.execute).toHaveBeenCalledWith("STILL OPEN", undefined);
|
||||
});
|
||||
|
||||
it("close() on the current getDb() handle closes the connection", async () => {
|
||||
const current = h.fakeDb;
|
||||
await (await getDb()).close();
|
||||
expect(current.close).toHaveBeenCalledTimes(1);
|
||||
await expect(getDb()).rejects.toThrow(/No database connection/);
|
||||
});
|
||||
|
||||
it("a failing close of the old pool does not prevent the switch, and goes to the Journal", async () => {
|
||||
vi.mocked(logWarn).mockClear();
|
||||
const old = h.fakeDb;
|
||||
const before = await getDb();
|
||||
vi.mocked(old.close).mockImplementation(async () => {
|
||||
throw new Error("close refused for /home/someone/.config/x.db");
|
||||
});
|
||||
const next = makeFakeDb("next");
|
||||
h.fakeDb = next;
|
||||
await connectToProfile("next.db");
|
||||
expect(old.close).toHaveBeenCalledTimes(1);
|
||||
expect(logWarn).toHaveBeenCalledWith(
|
||||
"DB: closing the previous connection failed, continuing without it: close refused for x.db"
|
||||
);
|
||||
await (await getDb()).execute("ON NEXT");
|
||||
expect(next.execute).toHaveBeenCalledWith("ON NEXT", undefined);
|
||||
await expect(before.execute("ON OLD")).rejects.toBeInstanceOf(DbConnectionClosedError);
|
||||
expect(old.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closeDb with a failing close resolves, warns, and leaves a coherent state without connection", async () => {
|
||||
vi.mocked(logWarn).mockClear();
|
||||
const old = h.fakeDb;
|
||||
const before = await getDb();
|
||||
vi.mocked(old.close).mockImplementation(async () => {
|
||||
throw new Error("close refused");
|
||||
});
|
||||
await expect(closeDb()).resolves.toBeUndefined();
|
||||
expect(logWarn).toHaveBeenCalledWith("DB: closing the previous connection failed, continuing without it: close refused");
|
||||
// No connection, and the generation moved: the old handle refuses to run.
|
||||
await expect(getDb()).rejects.toThrow(/No database connection/);
|
||||
await expect(withTransaction(async () => undefined)).rejects.toThrow(/No database connection/);
|
||||
await expect(before.execute("ON OLD")).rejects.toBeInstanceOf(DbConnectionClosedError);
|
||||
expect(old.execute).not.toHaveBeenCalled();
|
||||
// The old pool is no longer referenced: the next open does not close it again.
|
||||
h.fakeDb = makeFakeDb("next");
|
||||
await connectToProfile("next.db");
|
||||
expect(old.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("initializeNewProfileDb runs its SQL inside the section, before a queued query", async () => {
|
||||
const next = makeFakeDb("new");
|
||||
h.fakeDb = next;
|
||||
|
|
@ -430,8 +371,6 @@ describe("blocking alert (#408)", () => {
|
|||
const message = String(vi.mocked(logWarn).mock.calls[0][0]);
|
||||
expect(message).toContain("a query has waited 15 s");
|
||||
expect(message).toContain(`held by a transaction since ${startedAt}`);
|
||||
// A cause the reader may check, not a diagnosis: a long restore also waits 15 s.
|
||||
expect(message).toContain("(possible cause: a getDb() call inside a withTransaction callback");
|
||||
// Once per operation: still one warning much later.
|
||||
await vi.advanceTimersByTimeAsync(BLOCKED_ALERT_MS * 3);
|
||||
expect(logWarn).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -443,32 +382,6 @@ describe("blocking alert (#408)", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("says the lock is free, not who held it last, when the check lands between two operations", async () => {
|
||||
// In the app only microtasks separate the end of one operation from the
|
||||
// start of the next, so a timer cannot fire there; the test puts the check
|
||||
// there to pin that the holder is cleared when an operation ends.
|
||||
const db = await getDb();
|
||||
let release: () => void = () => {};
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
vi.mocked(h.fakeDb.execute).mockImplementationOnce(async () => {
|
||||
await gate;
|
||||
return { rowsAffected: 0 };
|
||||
});
|
||||
const first = db.execute("LONG");
|
||||
const waiting = db.select("WAITING");
|
||||
// Registered after the queue's own reaction: runs once the first operation
|
||||
// ended (holder cleared), before the waiting one starts.
|
||||
const checked = first.then(() => vi.advanceTimersByTime(BLOCKED_ALERT_MS));
|
||||
release();
|
||||
await checked;
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
await waiting;
|
||||
expect(logWarn).toHaveBeenCalledTimes(1);
|
||||
expect(String(vi.mocked(logWarn).mock.calls[0][0])).toBe("DB lock: a query has waited 15 s behind the queue");
|
||||
});
|
||||
|
||||
it("says nothing for an operation that got the lock in time", async () => {
|
||||
const db = await getDb();
|
||||
const run = db.execute("QUICK");
|
||||
|
|
@ -708,45 +621,4 @@ describe("describePreparation (#390)", () => {
|
|||
// A separator that is no path: kept readable, still no slash.
|
||||
expect(withoutFolders("pragma_failed: disk I/O error")).toBe("pragma_failed: disk I-O error");
|
||||
});
|
||||
|
||||
// A folder holding a blank must not leave its first word behind (#436 review, S1).
|
||||
it.each([
|
||||
["C:\\Users\\Jean Dupont\\AppData\\Roaming\\com.simpl.resultat\\profile_x.db", "profile_x.db"],
|
||||
["C:/Users/Jean Dupont/x.db", "x.db"],
|
||||
["/home/marie claire/.config/x.db", "x.db"],
|
||||
["~/Jean Dupont/x.db", "x.db"],
|
||||
["\\\\server\\share\\Jean Dupont\\x.db", "x.db"],
|
||||
["C:\\Program Files (x86)\\Simpl\\x.db", "x.db"],
|
||||
['"C:\\Users\\Jean Dupont\\x.db" failed', '"x.db" failed'],
|
||||
["(see /home/marie claire/x.db) and I/O", "(see x.db) and I-O"],
|
||||
["path=/home/marie claire/x.db;next", "path=x.db;next"],
|
||||
])("withoutFolders reduces %s to its last segment", (input, expected) => {
|
||||
const cleaned = withoutFolders(input);
|
||||
expect(cleaned).toBe(expected);
|
||||
expect(cleaned).not.toMatch(/Jean|Dupont|marie|claire|Users|home|Program|server/);
|
||||
});
|
||||
|
||||
it("withoutFolders ends a path at sentence punctuation and at the next path", () => {
|
||||
expect(withoutFolders("unable to open /home/marie claire/x.db: disk I/O error")).toBe(
|
||||
"unable to open x.db: disk I-O error"
|
||||
);
|
||||
expect(withoutFolders("read /home/marie claire/a.db, then C:\\Users\\Jean Dupont\\b.db")).toBe(
|
||||
"read a.db, then b.db"
|
||||
);
|
||||
expect(withoutFolders("'C:\\a\\b.db' and 'C:\\Users\\Jean Dupont\\d.db'")).toBe("'b.db' and 'd.db'");
|
||||
});
|
||||
|
||||
it.each([["\""], [";"], ["|"], ["`"], ["\n"]])("withoutFolders ends the folders at %j", (stop) => {
|
||||
expect(withoutFolders(`/home/marie claire/x.db${stop} I/O`)).toBe(`x.db${stop} I-O`);
|
||||
});
|
||||
|
||||
it("withoutFolders keeps a single quote around the file name, both sides", () => {
|
||||
expect(withoutFolders("'C:\\Users\\Bob\\x.db'")).toBe("'x.db'");
|
||||
expect(withoutFolders("cannot open '/home/marie claire/x.db' now")).toBe("cannot open 'x.db' now");
|
||||
});
|
||||
|
||||
it("withoutFolders drops more text rather than a folder when nothing ends the path", () => {
|
||||
// Conservative: everything up to the last separator of the line goes.
|
||||
expect(withoutFolders("/home/marie claire/x.db then I/O")).toBe("O");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -41,11 +41,6 @@ let serializedInstance: Database | null = null;
|
|||
// generation when its operation leaves the queue: an operation queued for a
|
||||
// connection that was closed meanwhile throws `DbConnectionClosedError`
|
||||
// rather than running on the next profile's database or on a closed pool.
|
||||
// `close()` on a `getDb()` handle is checked the same way, so a handle kept
|
||||
// from a previous profile cannot close the current one.
|
||||
// - A failing `close()` of the old pool never blocks a switch: the state is
|
||||
// already cleared and the generation bumped, the failure goes to the Journal
|
||||
// and the next profile still opens (see `closeUnlocked`).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** An operation queued for a connection closed before it could run. */
|
||||
|
|
@ -82,13 +77,10 @@ type LockKind =
|
|||
|
||||
/**
|
||||
* An operation still waiting for the lock after this delay writes ONE warning
|
||||
* to the Journal, naming what holds the lock and since when. One possible
|
||||
* cause is a `getDb()` call from inside a `withTransaction` callback
|
||||
* (deadlock), but a long transaction (a large restore, a category migration on
|
||||
* a big profile) also exceeds the delay, so the warning names it as a possible
|
||||
* cause only. A profile catch-up (`prepare_profile_db`) that takes longer also
|
||||
* triggers it, naming a "profile opening": expected, nothing can run during a
|
||||
* switch.
|
||||
* to the Journal, naming what holds the lock and since when. The typical cause
|
||||
* is a `getDb()` call from inside a `withTransaction` callback (deadlock). A
|
||||
* profile catch-up (`prepare_profile_db`) that takes longer also triggers it,
|
||||
* naming a "profile opening": expected, nothing can run during a switch.
|
||||
* `logWarn` only writes to memory and sessionStorage, never to the DB, so it
|
||||
* is safe to call while the lock is held.
|
||||
*/
|
||||
|
|
@ -110,7 +102,7 @@ function runExclusive<T>(kind: LockKind, fn: () => Promise<T>): Promise<T> {
|
|||
holder
|
||||
? `DB lock: a ${kind} has waited ${waited} s, the lock is held by a ${holder.kind} since ${new Date(holder.since).toISOString()}` +
|
||||
(holder.kind === "transaction"
|
||||
? " (possible cause: a getDb() call inside a withTransaction callback, which deadlocks; the callback must use its own handle)"
|
||||
? " (a getDb() call inside a withTransaction callback deadlocks: use the callback's handle)"
|
||||
: "")
|
||||
: `DB lock: a ${kind} has waited ${waited} s behind the queue`
|
||||
);
|
||||
|
|
@ -165,13 +157,7 @@ function serialize(db: Database, handleGeneration: number): Database {
|
|||
});
|
||||
}
|
||||
if (prop === "close") {
|
||||
// Same check as execute/select: a handle kept from a previous profile
|
||||
// must not close the current profile's connection (#408 review).
|
||||
return () =>
|
||||
runExclusive("connection close", async () => {
|
||||
assertGeneration(handleGeneration);
|
||||
await closeUnlocked();
|
||||
});
|
||||
return () => closeDb();
|
||||
}
|
||||
const value = Reflect.get(target, prop, receiver);
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
|
|
@ -183,11 +169,6 @@ function serialize(db: Database, handleGeneration: number): Database {
|
|||
* The handle a `withTransaction` callback receives: runs directly on the
|
||||
* connection (the transaction already holds the lock), refuses `close()`, and
|
||||
* throws once `revoke()` ran at the end of the callback.
|
||||
*
|
||||
* Its generation check is a defence, not reachable today: only the exclusive
|
||||
* profile sections change the generation, and none of them can run while a
|
||||
* transaction holds the lock, so nothing closes the connection under a
|
||||
* running callback. It stays in case a future path closes without the lock.
|
||||
*/
|
||||
function transactionHandle(
|
||||
db: Database,
|
||||
|
|
@ -266,29 +247,13 @@ export async function withTransaction<T>(
|
|||
// Internal helpers for the profile sections below. They NEVER take the lock:
|
||||
// only call them from inside a `runExclusive` section.
|
||||
|
||||
/**
|
||||
* Drop the current connection, then close its pool. Never rejects: the state
|
||||
* is cleared and the generation bumped BEFORE `close()`, so a failing close
|
||||
* leaves a coherent "no connection" state (every older handle refuses to run)
|
||||
* and only writes a warning to the Journal. A switch therefore still opens the
|
||||
* next profile, and `closeDb()` resolves: the app no longer references the old
|
||||
* pool, and keeping it would leave the app without any usable connection.
|
||||
*/
|
||||
async function closeUnlocked(): Promise<void> {
|
||||
if (rawInstance) {
|
||||
const closing = rawInstance;
|
||||
rawInstance = null;
|
||||
serializedInstance = null;
|
||||
generation += 1;
|
||||
try {
|
||||
await closing.close();
|
||||
} catch (e) {
|
||||
logWarn(
|
||||
withoutFolders(
|
||||
`DB: closing the previous connection failed, continuing without it: ${e instanceof Error ? e.message : String(e)}`
|
||||
)
|
||||
);
|
||||
}
|
||||
await closing.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -321,90 +286,22 @@ export interface ProfilePreparation {
|
|||
}
|
||||
|
||||
/**
|
||||
* Where a path starts: the placeholder Rust's `scrub` puts for the profile
|
||||
* folder, a drive (`C:\`), the home (`~/`), `./` or `../`, or a separator
|
||||
* (rooted path, UNC share). Only after the start of the text or one of
|
||||
* BEFORE_START, so the `/` of `I/O` starts nothing.
|
||||
*/
|
||||
const PATH_START = /<profile folder>[\\/]|[A-Za-z]:[\\/]|~[\\/]|\.{1,2}[\\/]|[\\/]/y;
|
||||
const BEFORE_START = /[\s'"`(=:,[]/;
|
||||
/** What ends the folders of a path for sure: a line break, a double quote or a backtick, `;`, `|`. */
|
||||
const PATH_STOP = /[\n\r"`;|]/;
|
||||
/** The last segment of a path: the file name, kept. */
|
||||
const LAST_SEGMENT = /[^\s"`()<>\\/]*/y;
|
||||
/**
|
||||
* Fallback for what the first pass leaves: a run of non-blank characters
|
||||
* holding a `/` or `\` that no PATH_START opened (`dir/sub/x.db`, `I/O`).
|
||||
* A run of non-blank characters holding a `/` or `\`, with the placeholder
|
||||
* Rust's `scrub` puts for the profile folder in front of it. An apostrophe
|
||||
* stays inside the run, so `C:\Users\L'Heureux\x.db` is one path.
|
||||
*/
|
||||
const PATH_RUN = /(?:<profile folder>)?[^\s"`()<>]*[\\/][^\s"`()<>]*/g;
|
||||
/** A run that names a place on disk: rooted, home, relative to `.`, a drive, or two separators. */
|
||||
const LOOKS_LIKE_PATH = /^(?:<profile folder>|[\\/~.]|[A-Za-z]:)|[\\/].*[\\/]/;
|
||||
|
||||
/** Length of the path start at `i`, 0 when no path starts there. */
|
||||
function pathStartAt(text: string, i: number): number {
|
||||
if (i > 0 && !BEFORE_START.test(text[i - 1])) return 0;
|
||||
PATH_START.lastIndex = i;
|
||||
return PATH_START.exec(text)?.[0].length ?? 0;
|
||||
}
|
||||
|
||||
/** Whether a new path starts after the blank at `k` (quotes and an opening parenthesis skipped). */
|
||||
function newPathAfter(text: string, k: number): boolean {
|
||||
let j = k + 1;
|
||||
while (j < text.length && /['"`(]/.test(text[j])) j++;
|
||||
return pathStartAt(text, j) > 0;
|
||||
}
|
||||
|
||||
/** Index of the last separator of the path whose start ends at `from` - 1. */
|
||||
function lastSeparator(text: string, from: number): number {
|
||||
let last = from - 1;
|
||||
for (let k = from; k < text.length; k++) {
|
||||
const c = text[k];
|
||||
const next = text[k + 1] ?? "";
|
||||
if (c === "\\" || c === "/") {
|
||||
last = k;
|
||||
continue;
|
||||
}
|
||||
if (PATH_STOP.test(c)) break;
|
||||
// Sentence punctuation: Windows forbids `:` in a name, and no user name holds `,`.
|
||||
if ((c === "," || c === ":") && (next === "" || /\s/.test(next))) break;
|
||||
// `Program Files (x86)\…` goes on; `(see /x/y.db) and…` stops.
|
||||
if (c === ")" && next !== "\\" && next !== "/") break;
|
||||
if (/\s/.test(c) && newPathAfter(text, k)) break;
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only the last segment of every path in `text`: error lines can be
|
||||
* attached to a feedback report, and a folder holds the OS user name
|
||||
* (Loi 25). A folder may contain blanks (`C:\Users\Jean Dupont\…`,
|
||||
* `/home/marie claire/…`): from its start, a path runs to its LAST separator
|
||||
* before a line break, a double quote, a backtick, `;`, `|`, a `,` or `:`
|
||||
* followed by a blank, a closing parenthesis, or a blank followed by another
|
||||
* path. When nothing ends it, the rule is conservative: text after a path is
|
||||
* dropped up to the last separator of the line (`/a/x.db then I/O` gives
|
||||
* `O`), rather than letting a folder through. A quote right before the path is
|
||||
* not part of it, so both quotes stay around the file name
|
||||
* (`'C:\Users\Bob\x.db'` gives `'x.db'`). Any other separator becomes `-` (SQLite's "disk I/O error" reads
|
||||
* (Loi 25). Any other separator becomes `-` (SQLite's "disk I/O error" reads
|
||||
* "disk I-O error"), so an error line never contains a `/` nor a `\` (#395).
|
||||
*/
|
||||
export function withoutFolders(text: string): string {
|
||||
let out = "";
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
const start = pathStartAt(text, i);
|
||||
if (start === 0) {
|
||||
out += text[i];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const from = lastSeparator(text, i + start) + 1;
|
||||
LAST_SEGMENT.lastIndex = from;
|
||||
const segment = LAST_SEGMENT.exec(text)?.[0] ?? "";
|
||||
out += segment;
|
||||
i = from + segment.length;
|
||||
}
|
||||
return out.replace(PATH_RUN, (run) =>
|
||||
return text.replace(PATH_RUN, (run) =>
|
||||
LOOKS_LIKE_PATH.test(run)
|
||||
? run.split(/[\\/]/).filter(Boolean).pop() ?? ""
|
||||
: run.replace(/[\\/]/g, "-")
|
||||
|
|
|
|||
|
|
@ -140,22 +140,6 @@ describe("hookHarness — useMemo and useCallback", () => {
|
|||
expect(compute).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("recomputes when the dependency array changes length, even over an equal prefix", () => {
|
||||
// React warns on such an array; the harness still treats it as a change.
|
||||
// Shrinking keeps every remaining item equal, and growing by `undefined`
|
||||
// matches the missing item read as `undefined`: only the length differs.
|
||||
let deps: unknown[] = [1, 2];
|
||||
const compute = vi.fn(() => ({}));
|
||||
const Memo = () => R.useMemo(compute, deps);
|
||||
harness.render(Memo);
|
||||
deps = [1];
|
||||
harness.render(Memo);
|
||||
expect(compute).toHaveBeenCalledTimes(2);
|
||||
deps = [1, undefined];
|
||||
harness.render(Memo);
|
||||
expect(compute).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("useMemo without dependencies recomputes at every render", () => {
|
||||
const compute = vi.fn(() => ({}));
|
||||
const Memo = () => R.useMemo(compute, undefined as unknown as []);
|
||||
|
|
|
|||
|
|
@ -55,10 +55,7 @@
|
|||
// children (a component in the returned tree is an element, never called),
|
||||
// the DOM, concurrent features, the bail-out of a setter called with the same
|
||||
// value, the render-phase update loop React runs for a dispatch during render
|
||||
// (the next `render()` reads it), and a reducer that changes between renders:
|
||||
// the `dispatch` of a `useReducer` keeps the reducer of the first render (it is
|
||||
// created once, with the slot), where React applies the reducer of the latest
|
||||
// render. A module-level reducer, the only kind in use, is not affected.
|
||||
// (the next `render()` reads it).
|
||||
//
|
||||
// React is imported here only as a type: a value import, from inside the
|
||||
// factory that builds the mock of "react", would load that very mock.
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { markedT as t } from "../test/i18nMock";
|
||||
import {
|
||||
renderCategoryLabel,
|
||||
renderCategoryLabelFromCategory,
|
||||
renderCategoryLabelFromAccount,
|
||||
} from "./renderCategoryLabel";
|
||||
|
||||
// `t` is the marked one of src/test/i18nMock.ts (#389): a translation reads
|
||||
// `t(key[defaultValue=…])`, so a label that skipped `t` fails. Falling back to
|
||||
// `defaultValue` for an unknown key is i18next's job; the helper's part is to
|
||||
// pass the raw key as that default, which the mark shows.
|
||||
const CASH = "t(balance.category.cash[defaultValue=cash])";
|
||||
// Fake `t`: echoes the key prefixed, but honours defaultValue for unknown keys
|
||||
// the way react-i18next does. We model "known" keys explicitly.
|
||||
const known: Record<string, string> = {
|
||||
"balance.category.cash": "Liquidités",
|
||||
};
|
||||
const t = (key: string, options?: { defaultValue?: string }) =>
|
||||
known[key] ?? options?.defaultValue ?? key;
|
||||
|
||||
describe("renderCategoryLabel", () => {
|
||||
it("prefers a non-empty custom_label over the i18n translation", () => {
|
||||
|
|
@ -22,25 +23,25 @@ describe("renderCategoryLabel", () => {
|
|||
it("trims custom_label and treats whitespace-only as absent", () => {
|
||||
expect(
|
||||
renderCategoryLabel(" ", "balance.category.cash", "cash", t)
|
||||
).toBe(CASH);
|
||||
).toBe("Liquidités");
|
||||
});
|
||||
|
||||
it("falls back to the i18n translation when custom_label is null", () => {
|
||||
expect(
|
||||
renderCategoryLabel(null, "balance.category.cash", "cash", t)
|
||||
).toBe(CASH);
|
||||
).toBe("Liquidités");
|
||||
});
|
||||
|
||||
it("falls back to the i18n translation when custom_label is undefined", () => {
|
||||
expect(
|
||||
renderCategoryLabel(undefined, "balance.category.cash", "cash", t)
|
||||
).toBe(CASH);
|
||||
).toBe("Liquidités");
|
||||
});
|
||||
|
||||
it("passes the raw key as the default i18next falls back to", () => {
|
||||
it("falls back to the raw key when no translation exists", () => {
|
||||
expect(
|
||||
renderCategoryLabel(null, "balance.category.unknown", "unknown", t)
|
||||
).toBe("t(balance.category.unknown[defaultValue=unknown])");
|
||||
).toBe("unknown");
|
||||
});
|
||||
|
||||
it("returns a trimmed custom_label (leading/trailing spaces removed)", () => {
|
||||
|
|
@ -57,7 +58,7 @@ describe("renderCategoryLabelFromCategory", () => {
|
|||
{ custom_label: null, i18n_key: "balance.category.cash", key: "cash" },
|
||||
t
|
||||
)
|
||||
).toBe(CASH);
|
||||
).toBe("Liquidités");
|
||||
expect(
|
||||
renderCategoryLabelFromCategory(
|
||||
{ custom_label: "X", i18n_key: "balance.category.cash", key: "cash" },
|
||||
|
|
@ -78,7 +79,7 @@ describe("renderCategoryLabelFromAccount", () => {
|
|||
},
|
||||
t
|
||||
)
|
||||
).toBe(CASH);
|
||||
).toBe("Liquidités");
|
||||
expect(
|
||||
renderCategoryLabelFromAccount(
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue