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/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; } } 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`, ); } } 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); } }