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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Run all commands from the repo root (`package.json` there wraps both Angular and

- `cargo test` from `src-tauri/` — unit tests are inline `#[cfg(test)] mod tests` blocks at the bottom of the file they cover; `src-tauri/tests/` holds the integration binaries (`notes`, `spaces`, `folders`, `transfer`, `ipc_contract`), which see only the crate's public API. No extra setup: they run against an in-memory SQLite database.

- `npm run test:scripts` — `node --test` on the release-notes generator and on the palette's contrast ratios. Node's own runner: no dependency, no config, and it cannot be swept up by the Angular builder, which only sees `src/**/*.spec.ts` — which is also the point, since both of these read a **shipped file off disk** and the builder compiles for a browser. ⚠️ The test **files are named one by one**, not discovered from `scripts/`: Node 24 (what `.nvmrc` pins, and what CI installs) does not expand a bare directory positional the way newer versions do — it tries to load it as the entry module and dies on `MODULE_NOT_FOUND`. A new test file has to be added to the script.
- `npm run test:scripts` — `node --test` on the release-notes generator, on the palette's contrast ratios and on the e2e suite's waits. Node's own runner: no dependency, no config, and it cannot be swept up by the Angular builder, which only sees `src/**/*.spec.ts` — which is also the point, since both of these read a **shipped file off disk** and the builder compiles for a browser. ⚠️ The test **files are named one by one**, not discovered from `scripts/`: Node 24 (what `.nvmrc` pins, and what CI installs) does not expand a bare directory positional the way newer versions do — it tries to load it as the entry module and dies on `MODULE_NOT_FOUND`. A new test file has to be added to the script.

- **Releasing is a `workflow_dispatch`.** Bump the version in `src-tauri/Cargo.toml`, `package.json`, both lockfiles, merge to `main`, then Actions → Release, `dry_run` first. `release.yml` writes the changelog section, commits, tags, builds and publishes in one run. See `docs/architecture.md` → "Releasing".

Expand All @@ -76,7 +76,7 @@ Run all commands from the repo root (`package.json` there wraps both Angular and
These are the non-obvious constraints; the rest of the architecture is in `docs/architecture.md`.

- **The end-to-end harness is a build flavour, not a flag.** `npm run e2e:build` merges `src-tauri/tauri.e2e.conf.json` and links the `e2e` Cargo feature: `withGlobalTauri`, the `wdio` capability declared **inline** (a file under `capabilities/` would ship), the `e2e` Angular configuration whose only difference is `"polyfills": ["@wdio/tauri-plugin"]`, and an identifier of its own — `app_data_dir()` being `data_dir()/identifier`, that is what keeps the suite out of the library you dogfood. ⚠️ The Rust plugins and the npm polyfill go **together**: with the crates alone the runner waits on `window.wdioTauri` and hangs before opening a session; with the polyfill alone the front end invokes `plugin:wdio|…` commands nothing answers. Details in `docs/architecture.md`.
- **⚠️ The e2e runs are not isolated from each other, and that shapes every scenario.** `driverProvider` is `embedded`, so the WebDriver server lives inside the application and the service spawns it **once** for the whole run: the fifteen spec files share one process, one SQLite file and one `preferences.json`. Raising `maxInstances` changes nothing — the service skips its per-worker spawn for this provider. So the profile is wiped once, by `tsx e2e/reset-profile.ts` **before** wdio starts (a wdio hook has no ordering against the service's own `onPrepare`, and would meet a locked database); `before()` only reloads the page, which resets the front end and no data; and a spec file seeds its own preconditions instead of assuming a clean corpus. The numeric prefix on each file is the run order, and `01-first-launch` is the only one that meets a virgin profile — hence the only one that can resolve the seeded space, which it writes to a marker file for the rest. ⚠️ `reopenSession()` is **not** a restart and nothing can be: the process hosts the server, so it must stay up. It proves the interface was rebuilt from what the commands answer, never that anything reached the disk — `15-preferences-on-disk` reads the file from Node for that. ⚠️ **A scenario waits on a condition, never on a duration**: `eventually(read, matches, what)` in `support/app.ts`, which hands the value back so the assertion reads what it waited for. A `browser.pause` before an `expect` is a guess at a round trip on a runner sharing a CPU with a WebView, and it is how one scenario went red on Windows and green on a re-run of the very same commit. The **one** exception is an assertion that nothing happened — that is not a condition anything can wait on, and the two that remain say so in a comment. Address a control by its id rather than by position wherever a page can hold two of them.
- **⚠️ The e2e runs are not isolated from each other, and that shapes every scenario.** `driverProvider` is `embedded`, so the WebDriver server lives inside the application and the service spawns it **once** for the whole run: the fifteen spec files share one process, one SQLite file and one `preferences.json`. Raising `maxInstances` changes nothing — the service skips its per-worker spawn for this provider. So the profile is wiped once, by `tsx e2e/reset-profile.ts` **before** wdio starts (a wdio hook has no ordering against the service's own `onPrepare`, and would meet a locked database); `before()` only reloads the page, which resets the front end and no data; and a spec file seeds its own preconditions instead of assuming a clean corpus. The numeric prefix on each file is the run order, and `01-first-launch` is the only one that meets a virgin profile — hence the only one that can resolve the seeded space, which it writes to a marker file for the rest. ⚠️ `reopenSession()` is **not** a restart and nothing can be: the process hosts the server, so it must stay up. It proves the interface was rebuilt from what the commands answer, never that anything reached the disk — `15-preferences-on-disk` reads the file from Node for that. ⚠️ **A scenario waits on a condition, never on a duration**: `eventually(read, matches, what)` in `support/app.ts`, which hands the value back so the assertion reads what it waited for. A `browser.pause` before an `expect` is a guess at a round trip on a runner sharing a CPU with a WebView, and it is how one scenario went red on Windows and green on a re-run of the very same commit. The **one** exception is an assertion that nothing happened — that is not a condition anything can wait on, and the three that remain say so in a comment. ⚠️ `scripts/e2e-waits.test.mjs` holds that: it fails on a `browser.pause` followed within four lines by a read or an assertion, unless a comment above says the wait is `deliberately` one. The sweep is only as good as its lookahead — #190 demanded a literal `expect(` on the very next line, missed a value read now and asserted two lines down, and covered a sixth of the job (#197). Address a control by its id rather than by position wherever a page can hold two of them.
- **The IPC surface is generated.** `src/app/core/ipc/bindings.ts` comes from tauri-specta: one typed function per command plus a TS type per struct crossing the bridge. It is committed and regenerated by `npm run tauri dev` or `npm run bindings` (the `export-bindings` binary). Adding a command means annotating it `#[tauri::command]` **and** `#[specta::specta]`, adding it to `collect_commands![...]` in `src-tauri/src/lib.rs` — the single list, it both registers with Tauri and drives the generation — then regenerating. Every type crossing the bridge derives `specta::Type`. Specta refuses `usize`/`i64`/… (JSON precision), hence `NotesView.matched: u32`. The generator is _not_ wired as a `#[test]`: on Windows the test exe lives in `target/debug/deps/`, without the `WebView2Loader.dll` that linking `Builder::export` then needs, and the whole test binary fails to start.
- **Calls return a Result, not a rejection.** `commands.queryNotes(q)` gives `{ status: 'ok' | 'error' }`. Repositories run it through `unwrap()` (`core/ipc/ipc.error.ts`), which returns the data or throws an `IpcError` — stores and components keep their `try`/`catch`. Only `core/data/` and `core/ipc/` call a generated command; everything else speaks the model, which `note.mapper.ts` converts to and from. `core/services/app-info/` also imports `bindings.ts`, for the `APP_METADATA` **constant** — no bridge involved — and re-exports it once as `APP_INFO`.
- **Serialisation contract.** The camelCase and `tag = "kind"` serde attributes are still load-bearing, but specta reads them, so the TS side follows automatically. What generation does _not_ cover, and what `core/data/note.mapper.ts` still exists for: JSON has no date type (the Rust model holds `DateTime<Utc>`, which crosses as an ISO string and the front turns back into a `Date`), and a patch omits the keys it does not touch (hence `#[specta(optional)]` on every `NotePatch` field — without it the generated type would demand explicit `null`s, which overwrite). `language` no longer needs anything: it is a Rust enum, so the bindings hand the front a real union and `LanguageTag` is a plain alias of it.
Expand Down
12 changes: 8 additions & 4 deletions e2e/specs/03-editing-a-note.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { browser, expect } from '@wdio/globals';
import { expect } from '@wdio/globals';

import { canvas } from '../pageobjects/canvas.page.js';
import { editor } from '../pageobjects/editor.page.js';
import { spaces } from '../pageobjects/overlays.page.js';
import { reloadCanvas, viewportSize } from '../support/app.js';
import { eventually, reloadCanvas, viewportSize } from '../support/app.js';
import { bridge, draft, homeSpaceId, query } from '../support/bridge.js';

/**
Expand Down Expand Up @@ -133,9 +133,13 @@ describe('Editing a note', () => {

it('moves the note to another space, through the renamed argument', async () => {
await canvas.moveNote(title, refugeId);
await browser.pause(500);

expect((await reread())?.spaceId).toBe(refugeId);
const filed = await eventually(
async () => (await reread())?.spaceId,
(spaceId) => spaceId === refugeId,
'the note to be filed in the refuge',
);
expect(filed).toBe(refugeId);
});

it('leaves the note reachable from the space it moved to', async () => {
Expand Down
7 changes: 5 additions & 2 deletions e2e/specs/04-trash-and-undo.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,13 @@ describe('Deleting a note, and taking it back', () => {
expect((await trash.titles()).length).toBeGreaterThan(1);

await trash.empty();
await browser.pause(800);

// The empty state replaces the list rather than leaving a header over nothing.
expect(await trash.emptyState().isExisting()).toBe(true);
await eventually(
() => trash.emptyState().isExisting(),
(showing) => showing,
'the trash to say it is empty',
);
expect(await trash.rows().length).toBe(0);
expect(await bridge.listTrash()).toHaveLength(0);
await trash.close();
Expand Down
55 changes: 41 additions & 14 deletions e2e/specs/05-spaces.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,12 @@ describe('Spaces', () => {
it('creates a space from the switcher', async () => {
await spaces.open();
await spaces.create('Veille');
await browser.pause(500);

const all = await bridge.listSpaces();
const all = await eventually(
() => bridge.listSpaces(),
(listed) => listed.some((space) => space.name === 'Veille'),
'the new space to be listed',
);
expect(all.map((space) => space.name)).toContain('Veille');
});

Expand All @@ -61,9 +64,12 @@ describe('Spaces', () => {
const before = (await bridge.listSpaces()).find((space) => space.name === 'Veille');
await spaces.open();
await spaces.rename(before!.id, 'Lectures');
await browser.pause(500);

const after = (await bridge.listSpaces()).find((space) => space.id === before!.id);
const after = await eventually(
async () => (await bridge.listSpaces()).find((space) => space.id === before!.id),
(space) => space?.name === 'Lectures',
'the renamed space to come back under its new name',
);
expect(after?.name).toBe('Lectures');
});

Expand Down Expand Up @@ -92,17 +98,23 @@ describe('Spaces', () => {

await spaces.open();
await spaces.togglePin(id);
await browser.pause(500);

const pinned = await bridge.listSpaces();
const pinned = await eventually(
() => bridge.listSpaces(),
(listed) => listed[0]?.pinned === true,
'the pinned space to reach the head of the list',
);
expect(pinned[0]?.id).toBe(id);
expect(pinned[0]?.pinned).toBe(true);

await spaces.open();
await spaces.togglePin(id);
await browser.pause(500);

const loose = await bridge.listSpaces();
const loose = await eventually(
() => bridge.listSpaces(),
(listed) => listed.at(-1)?.pinned === false,
'the unpinned space to fall back to its name order',
);
expect(loose.at(-1)?.id).toBe(id);
expect(loose.at(-1)?.pinned).toBe(false);
});
Expand All @@ -111,13 +123,20 @@ describe('Spaces', () => {
it('survives a rename', async () => {
await spaces.open();
await spaces.togglePin(id);
await browser.pause(500);
await eventually(
() => bridge.listSpaces(),
(listed) => listed.find((space) => space.id === id)?.pinned === true,
'the space to be pinned before it is renamed',
);

await spaces.open();
await spaces.rename(id, 'Zzz renamed');
await browser.pause(500);

const after = (await bridge.listSpaces()).find((space) => space.id === id);
const after = await eventually(
async () => (await bridge.listSpaces()).find((space) => space.id === id),
(space) => space?.name === 'Zzz renamed',
'the pinned space to come back renamed',
);
expect(after?.name).toBe('Zzz renamed');
expect(after?.pinned).toBe(true);
});
Expand All @@ -141,9 +160,12 @@ describe('Spaces', () => {
const target = (await bridge.listSpaces()).find((space) => space.name === 'Lectures')!;
await spaces.open();
await spaces.remove(target.id, homeId);
await browser.pause(800);

expect((await bridge.listSpaces()).map((space) => space.name)).not.toContain('Lectures');
await eventually(
() => bridge.listSpaces(),
(listed) => !listed.some((space) => space.name === 'Lectures'),
'the absorbed space to be gone',
);

// The note survived, in the refuge — a cascade would have taken it.
const view = await bridge.queryNotes(query({ search: 'Only in Lectures' }));
Expand Down Expand Up @@ -189,8 +211,13 @@ describe('Spaces', () => {
it('gives the switchers back when it is put away, and is remembered', async () => {
await rail.hide();
expect(await browser.$(testid('space-switcher')).isExisting()).toBe(true);
await browser.pause(500);

// ⚠️ The preference has to reach the file before the reload, or the rail comes back.
await eventually(
() => rail.isShowing(),
(showing) => !showing,
'the rail to be put away',
);
await reloadCanvas();

expect(await rail.isShowing()).toBe(false);
Expand Down
17 changes: 12 additions & 5 deletions e2e/specs/07-checklists.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,21 +105,28 @@ describe('Todo lists', () => {
await canvas.openNote(title);
await editor.toggleItem(1);
await editor.close();
await browser.pause(400);

// `- [x] ` is `notes::checklist::to_markdown`'s syntax, reaching the card as
// `DisplayNote.copyText`; the front end holds no second copy of it.
const copyText = (await reread())?.copyText;
const copyText = await eventually(
async () => (await reread())?.copyText,
(text) => text?.includes('- [ ] Tag the release') === true,
'the untick to come back in the rendered Markdown',
);
expect(copyText).toContain('- [x] Write the changelog');
expect(copyText).toContain('- [ ] Tag the release');
});

it('puts that same Markdown on the clipboard', async function () {
const card = await canvas.cardWithTitle(title);
await card.$('[data-testid="copy-button"]').click();
await browser.pause(600);
await card.$(testid('copy-button')).click();

const copied = await clipboardText();
// ⚠️ An unreadable clipboard answers null at once, so only the readable case waits.
const copied = await eventually(
() => clipboardText(),
(text) => text === null || text.includes('- [x] Write the changelog'),
'the copy to reach the clipboard',
);
if (copied === null) {
// No readable clipboard on this runner. Skipped rather than returned: a bare
// `return` is a green test that asserted nothing. See `clipboardText`.
Expand Down
32 changes: 24 additions & 8 deletions e2e/specs/08-placeholder-fields.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { browser, expect } from '@wdio/globals';
import { expect } from '@wdio/globals';

import { canvas } from '../pageobjects/canvas.page.js';
import { editor } from '../pageobjects/editor.page.js';
import { fieldsForm } from '../pageobjects/overlays.page.js';
import { settings, variables } from '../pageobjects/titlebar.page.js';
import { clipboardText, reloadCanvas } from '../support/app.js';
import { clipboardText, eventually, reloadCanvas } from '../support/app.js';
import { bridge, draft, homeSpaceId, query } from '../support/bridge.js';

/**
Expand Down Expand Up @@ -77,8 +77,12 @@ describe('{{fields}} in a snippet', () => {
expect(await editor.hasCopyFilled()).toBe(true);

await editor.copyFilled();
await browser.pause(800);
const filled = await clipboardText();
// ⚠️ An unreadable clipboard answers null at once, so only the readable case waits.
const filled = await eventually(
() => clipboardText(),
(text) => text === null || text.includes('-p 5432'),
'the filled copy to reach the clipboard',
);
await editor.close();

if (filled === null) {
Expand Down Expand Up @@ -118,9 +122,13 @@ describe('{{fields}} in a snippet', () => {
await fieldsForm.field('host').setValue('db.internal');
await fieldsForm.field('user').setValue('reader');
await fieldsForm.submit();
await browser.pause(800);
await fieldsForm.form().waitForExist({ reverse: true, timeout: 10_000 });

const fields = (await reread())?.placeholders ?? [];
const fields = await eventually(
async () => (await reread())?.placeholders ?? [],
(stored) => stored.find((field) => field.name === 'host')?.value === 'db.internal',
'the typed values to be stored',
);
expect(fields.find((field) => field.name === 'host')?.value).toBe('db.internal');
expect(fields.find((field) => field.name === 'user')?.value).toBe('reader');
});
Expand Down Expand Up @@ -155,9 +163,17 @@ describe('{{fields}} in a snippet', () => {
await fieldsForm.form().waitForExist({ timeout: 10_000 });
await fieldsForm.field('host').setValue('db.other');
await fieldsForm.submit();
await browser.pause(800);
// ⚠️ The form closing is its own condition, and the next scenario opens a panel over
// this one: waiting on the stored value alone let the two dialogs overlap.
await fieldsForm.form().waitForExist({ reverse: true, timeout: 10_000 });

expect((await reread())?.updatedAt).toBe(before);
// The write is waited on through the value, then the column it must *not* have moved.
const after = await eventually(
() => reread(),
(note) => note?.placeholders.find((field) => field.name === 'host')?.value === 'db.other',
'the new value to be stored',
);
expect(after?.updatedAt).toBe(before);
});

it('lets a global variable propose a value without freezing it', async () => {
Expand Down
Loading