From ad0383e25c3ec07e31c681916a3020dc048dc474 Mon Sep 17 00:00:00 2001 From: Valentin Millet Date: Mon, 14 Sep 2026 18:57:12 +0200 Subject: [PATCH 1/3] Keep the body of a note closed right after it was written MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing fires the title, the source and the content commits and closes in the same turn, so they are still suspended on the draft's resolution when the overlay goes. The draft was gone by then and a closed editor adopts nothing, leaving find() with only the canvas view — a round trip behind — so the commits resolved to nothing and were dropped. The note kept the title its creation payload carried and lost the body typed after it. The store now remembers the row the draft became until the editor is pointed elsewhere. Co-Authored-By: Claude Opus 5 --- src/app/core/state/notes.store.spec.ts | 27 ++++++++++++++++++++++++++ src/app/core/state/notes.store.ts | 27 ++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/src/app/core/state/notes.store.spec.ts b/src/app/core/state/notes.store.spec.ts index 41dde69..6e2d8d5 100644 --- a/src/app/core/state/notes.store.spec.ts +++ b/src/app/core/state/notes.store.spec.ts @@ -80,6 +80,33 @@ describe('NotesStore', () => { await vi.waitFor(() => expect(visibleIds(canvas)).toHaveLength(1)); }); + + /** + * ⚠️ The commits that were still in flight have to land on the row too. + * + * Closing fires the title, the source and the content back to back and the close + * lands between them. Once the write comes back the draft is gone, and a closed + * editor adopts nothing — so the later commits could only resolve the note through + * the canvas view, which is a round trip behind. They were dropped, and the note + * kept the title its creation payload carried and lost the body typed after it: + * `02-note-lifecycle` read `""` back on a Linux runner. + */ + it('lands the commits the close itself fired', async () => { + const { store, repository } = await createNotesHarness([]); + store.createNote('snippet'); + // The title is what materialises the row, and it is done being written. + await store.applyPatch(DRAFT_ID, { title: 'Rotate the certificate' }); + + const update = vi.spyOn(repository, 'update'); + + // `requestClose()` fires the commits and closes **in the same turn**, so the close + // lands while they are still suspended on the draft's resolution. + const writing = store.applyPatch(DRAFT_ID, { content: 'openssl req -new' }); + store.closeOverlay(); + await writing; + + expect(update).toHaveBeenCalledWith(expect.any(String), { content: 'openssl req -new' }); + }); }); describe('selection', () => { diff --git a/src/app/core/state/notes.store.ts b/src/app/core/state/notes.store.ts index 950e474..96afefb 100644 --- a/src/app/core/state/notes.store.ts +++ b/src/app/core/state/notes.store.ts @@ -202,7 +202,22 @@ export class NotesStore { */ private draftMaterialisation: Promise | null = null; + /** + * ⚠️ The row a draft became, held until the editor moves on to another note. + * + * `requestClose()` fires the title, the source and the content back to back and then + * closes, so commits are still in flight when the overlay goes. Once the row exists + * the draft is gone, and a closed editor adopts nothing — which left `find()` with + * only the canvas view to answer with, and that view is a round trip behind the write + * that created the note. The commits resolved against nothing and were **dropped**: + * the note kept the title its creation payload carried and lost the body typed after + * it. Windows won that race and Linux lost it, which is what made the end-to-end + * suite look flaky rather than wrong. + */ + private materialisedNote: Note | null = null; + openNote(id: string): void { + this.materialisedNote = null; this.discardDraft(); this._editorSession.update((session) => session + 1); this._selectedNote.set(this.find(id)); @@ -252,6 +267,7 @@ export class NotesStore { const spaceId = this.spaceForNewNote(); if (!spaceId) return; + this.materialisedNote = null; this._selectedNote.set(null); this.draftMaterialisation = null; this._editorSession.update((session) => session + 1); @@ -406,6 +422,9 @@ export class NotesStore { return null; } + // Before the draft is dropped: between the two, `find()` would know the note by + // neither name. + this.materialisedNote = created; this._draftNote.set(null); return created.id; @@ -526,6 +545,11 @@ export class NotesStore { if (this.persistedNoteId() === id) { this._selectedNote.set(saved); } + // Kept current, so the commits that follow compare against what was written and not + // against the payload the row was created with. + if (this.materialisedNote?.id === id) { + this.materialisedNote = saved; + } this.notes.reload(); } @@ -540,6 +564,9 @@ export class NotesStore { const selected = this._selectedNote(); if (selected?.id === id) return selected; + const materialised = this.materialisedNote; + if (materialised?.id === id) return materialised; + return this.notes.findVisible(id); } } From cbf08e86604f9966d39858ccef3590b25202db35 Mon Sep 17 00:00:00 2001 From: Valentin Millet Date: Mon, 14 Sep 2026 19:11:50 +0200 Subject: [PATCH 2/3] Wipe the config directory of the e2e profile too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tauri-plugin-window-state writes .window-state.json under app_config_dir(), which on Linux is not app_data_dir() — so the window geometry survived the wipe and a run opened on the window the previous one closed with. Windows cannot tell the two directories apart, which is why it passed there. Co-Authored-By: Claude Opus 5 --- docs/architecture.md | 5 +++++ e2e/support/profile.ts | 31 +++++++++++++++++++++++++++---- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index e71c8ff..2824766 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2145,6 +2145,11 @@ consequences, and they are rules rather than observations: `tsx e2e/reset-profile.ts && wdio run …`. Not from a hook: nothing orders a wdio hook against the service's own `onPrepare`, and a wipe from inside meets a living process, a locked database and an open WAL. A separate process beforehand has no ordering to get wrong. + ⚠️ The profile is **two** directories, not one: `tauri-plugin-window-state` writes + `.window-state.json` under `app_config_dir()` while everything else the application + writes lives under `app_data_dir()`. Windows cannot tell them apart, so wiping only the + data directory passed there and left the window geometry behind on Linux — where a run + then opened on the window the previous one closed with. - **`before()` buys each file a fresh front end and nothing more.** `browser.refresh()` reboots Angular and every store over the same database; it resets no data. - **A spec file establishes its own preconditions.** It seeds what it needs, and it does not diff --git a/e2e/support/profile.ts b/e2e/support/profile.ts index 0626be9..5fd0392 100644 --- a/e2e/support/profile.ts +++ b/e2e/support/profile.ts @@ -18,6 +18,25 @@ function e2eDataDir(): string { return xdg ? join(xdg, IDENTIFIER) : join(process.env['HOME'] ?? '', '.local/share', IDENTIFIER); } +/** + * ⚠️ A second directory, and on Linux it is **not** the first one. + * + * `tauri-plugin-window-state` writes `.window-state.json` under `app_config_dir()`, + * while everything else the application writes lives under `app_data_dir()`. Windows + * cannot tell the two apart — both are `%APPDATA%\` — which is why wiping + * only the data directory looked complete: on Linux the geometry survived the wipe, so + * a run inherited the window of the run before it and the first one to end on an + * unusual size handed it to every run after. CI never saw it, its runners being new + * each time; a developer running the suite twice did. + */ +function e2eConfigDir(): string { + if (process.platform === 'win32') { + return join(process.env['APPDATA'] ?? '', IDENTIFIER); + } + const xdg = process.env['XDG_CONFIG_HOME']; + return xdg ? join(xdg, IDENTIFIER) : join(process.env['HOME'] ?? '', '.config', IDENTIFIER); +} + /** * ⚠️ Under the **data** directory, not the config one. `tauri-plugin-store` resolves a * relative path against `BaseDirectory::AppData`, and `PreferencesService` passes it no @@ -49,9 +68,12 @@ export function homeSpaceMarker(): string { * no ordering to get wrong. */ export function resetProfile(): void { - const directory = e2eDataDir(); + // Both, and `new Set` because on Windows they are the same path. + const directories = [...new Set([e2eDataDir(), e2eConfigDir()])]; - rmSync(directory, { recursive: true, force: true }); + for (const directory of directories) { + rmSync(directory, { recursive: true, force: true }); + } rmSync(homeSpaceMarker(), { force: true }); // ⚠️ `force` covers "it was not there", which is the ordinary case — but it also @@ -62,9 +84,10 @@ export function resetProfile(): void { // the first-launch scenario, the only one that can resolve it, had already failed. // // Say so here rather than let fifteen files disagree about why. - if (existsSync(directory)) { + const survivor = directories.find((directory) => existsSync(directory)); + if (survivor) { throw new Error( - `the e2e profile at ${directory} could not be wiped — an application from a previous run is probably still holding it open`, + `the e2e profile at ${survivor} could not be wiped — an application from a previous run is probably still holding it open`, ); } } From 33f7cb01c6a610e029c6f2a5480970c29e3eccae Mon Sep 17 00:00:00 2001 From: Valentin Millet Date: Mon, 14 Sep 2026 21:23:12 +0200 Subject: [PATCH 3/3] Wait for a global variable to reach the back end, not for 200 ms The variables panel commits on blur and the write crosses the bridge, so the sleep was a bet. A Windows runner lost it: the note was re-read before the variable existed and reported the snippet's own default, and the scenario after it then found no row to remove. Co-Authored-By: Claude Opus 5 --- e2e/pageobjects/titlebar.page.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/e2e/pageobjects/titlebar.page.ts b/e2e/pageobjects/titlebar.page.ts index c5b1245..466b601 100644 --- a/e2e/pageobjects/titlebar.page.ts +++ b/e2e/pageobjects/titlebar.page.ts @@ -1,6 +1,7 @@ import { $, $$, browser } from '@wdio/globals'; import { blur, clickToAddRow, readEach, setNativeValue, testid } from '../support/app.js'; +import { bridge } from '../support/bridge.js'; /** The controls' `id`s, in one place: `select` needs the selector, the getters the element. */ const CONTROL = { @@ -78,12 +79,23 @@ export const variables = { names: (): Promise => readEach(testid('variable-row'), 'value', testid('variable-name')), + /** + * ⚠️ Waits for the value to **reach the back end**, not for a plausible number of + * milliseconds. The panel commits on blur and the write crosses the bridge, so the + * 200 ms sleep that used to stand here was a bet — and a Windows runner lost it: the + * note was re-read before the variable existed and reported the snippet's own default + * (`5432` where `6543` was expected), then the next scenario found no row to remove. + */ async add(name: string, value: string): Promise { const last = await clickToAddRow(testid('variable-add'), testid('variable-row')); await last.$(testid('variable-name')).setValue(name); await last.$(testid('variable-value')).setValue(value); await blur(); - await browser.pause(200); + + await browser.waitUntil(async () => (await bridge.listGlobalPlaceholders())[name] === value, { + timeout: 10_000, + timeoutMsg: `the variable "${name}" never reached the back end as ${JSON.stringify(value)}`, + }); }, async remove(name: string): Promise { @@ -106,6 +118,11 @@ export const variables = { timeout: 10_000, timeoutMsg: `the variable "${name}" is still listed`, }); + // Gone from the panel is not gone from the database — same bridge, same wait. + await browser.waitUntil(async () => !(name in (await bridge.listGlobalPlaceholders())), { + timeout: 10_000, + timeoutMsg: `the variable "${name}" is still stored`, + }); return; } }