Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 18 additions & 1 deletion e2e/pageobjects/titlebar.page.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -78,12 +79,23 @@ export const variables = {

names: (): Promise<string[]> => 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<void> {
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<void> {
Expand All @@ -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;
}
}
Expand Down
31 changes: 27 additions & 4 deletions e2e/support/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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%\<identifier>` — 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
Expand Down Expand Up @@ -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
Expand All @@ -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`,
);
}
}
27 changes: 27 additions & 0 deletions src/app/core/state/notes.store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
27 changes: 27 additions & 0 deletions src/app/core/state/notes.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,22 @@ export class NotesStore {
*/
private draftMaterialisation: Promise<string | null> | 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));
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}

Expand All @@ -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);
}
}