From e01403ea104c7bbc0d79f01ce25f3112197fbbf6 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 17 Mar 2026 16:59:04 +0100 Subject: [PATCH] test: Playwright smoke test for title/filename sync + update docs - 3 smoke tests: new note title, open note title display, rename atomicity - Updated ABSTRACTIONS.md: title/filename sync section, title in semantic fields - Updated ARCHITECTURE.md: title_sync.rs module, sync_note_title command Co-Authored-By: Claude Opus 4.6 (1M context) --- demo-vault-v2/renamed-title-xyz.md | 6 ++ ...career-tracks-depend-on-company-shape-2.md | 6 ++ docs/ABSTRACTIONS.md | 10 +- docs/ARCHITECTURE.md | 10 +- tests/smoke/title-filename-sync.spec.ts | 98 +++++++++++++++++++ 5 files changed, 124 insertions(+), 6 deletions(-) create mode 100644 demo-vault-v2/renamed-title-xyz.md create mode 100644 demo-vault-v2/untitled-career-tracks-depend-on-company-shape-2.md create mode 100644 tests/smoke/title-filename-sync.spec.ts diff --git a/demo-vault-v2/renamed-title-xyz.md b/demo-vault-v2/renamed-title-xyz.md new file mode 100644 index 00000000..12229ee5 --- /dev/null +++ b/demo-vault-v2/renamed-title-xyz.md @@ -0,0 +1,6 @@ +--- +title: Renamed Title XYZ +type: Note +status: Active +--- +# Renamed Title XYZ diff --git a/demo-vault-v2/untitled-career-tracks-depend-on-company-shape-2.md b/demo-vault-v2/untitled-career-tracks-depend-on-company-shape-2.md new file mode 100644 index 00000000..9cf4de94 --- /dev/null +++ b/demo-vault-v2/untitled-career-tracks-depend-on-company-shape-2.md @@ -0,0 +1,6 @@ +--- +title: Untitled Career Tracks Depend on Company Shape 2 +type: Note +status: Active +--- +# Untitled Career Tracks Depend on Company Shape 2 diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 9afde745..d9c1be6b 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -14,6 +14,7 @@ These frontmatter field names have special meaning in Laputa's UI: | Field | Meaning | UI behavior | |---|---|---| +| `title:` | Human-readable title (synced with filename) | Tab label, breadcrumb, sidebar. Filename = `slugify(title).md` | | `type:` | Entity type (Project, Person, Quarter…) | Type chip in note list + sidebar grouping | | `status:` | Lifecycle stage (active, done, blocked…) | Colored chip in note list + editor header | | `url:` | External link | Clickable link chip in editor header | @@ -210,9 +211,14 @@ This enables arbitrary, extensible relationship types without code changes. All `[[wikilinks]]` in the note body (not frontmatter) are extracted by regex and stored in `outgoingLinks`. Used for backlink detection and relationship graphs. -### Title Extraction +### Title / Filename Sync -Title comes from the first `# Heading` in the markdown body. If none is found, the filename (without `.md`) is used as fallback. Logic in `vault/parsing.rs:extract_title()`. +Every note has a `title` field in frontmatter that stores the human-readable title. The filename is always the slug of the title (`slugify(title).md`). The two are kept in sync: + +- **Source of truth**: filename (on open), user input (on rename inside Laputa) +- **`extract_title`** reads `title` from frontmatter; falls back to deriving a title from the filename via `slug_to_title()` (hyphens → spaces, title-case). Never reads from H1. Logic in `vault/parsing.rs`. +- **On note open** (`sync_title_on_open`): if `title` frontmatter is absent or desynced from the filename, it is auto-corrected (filename wins). Logic in `vault/title_sync.rs`. +- **On rename** (`rename_note`): updates both `title` frontmatter and filename atomically, plus wikilinks across the vault. Always writes `title` to frontmatter. ### Title Field (UI) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bb607060..36589df0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -565,10 +565,11 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules: | File | Purpose | |------|---------| | `mod.rs` | Core types (`VaultEntry`, `Frontmatter`), `parse_md_file`, `scan_vault`, relationship/link extraction | -| `parsing.rs` | Text processing: snippet extraction, markdown stripping, ISO date parsing, `extract_title` | +| `parsing.rs` | Text processing: snippet extraction, markdown stripping, ISO date parsing, `extract_title`, `slug_to_title` | +| `title_sync.rs` | `sync_title_on_open` — ensures `title` frontmatter matches filename on note open | | `cache.rs` | Git-based incremental vault caching (`scan_vault_cached`), git helpers | | `trash.rs` | `purge_trash` — deletes trashed notes older than 30 days | -| `rename.rs` | `rename_note` — renames files and updates wikilinks across the vault | +| `rename.rs` | `rename_note` — renames files, updates `title` frontmatter, and updates wikilinks across the vault | | `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames | | `migration.rs` | `flatten_vault`, `vault_health_check`, `migrate_is_a_to_type` | | `config_seed.rs` | Seeds `config/` folder, migrates `AGENTS.md`, repairs missing config files | @@ -594,7 +595,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules: | `vault_list.rs` | Vault list persistence | | `menu.rs` | Native macOS menu bar | -## Tauri IPC Commands (64 total) +## Tauri IPC Commands (65 total) ### Vault Operations @@ -604,7 +605,8 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules: | `get_note_content` | Read note file content | | `save_note_content` | Write note content to disk | | `delete_note` | Move note to trash | -| `rename_note` | Rename note + update cross-vault wikilinks | +| `rename_note` | Rename note + update `title` frontmatter + cross-vault wikilinks | +| `sync_note_title` | Sync `title` frontmatter with filename on note open → `bool` (modified) | | `batch_archive_notes` | Archive multiple notes | | `batch_trash_notes` | Trash multiple notes | | `batch_delete_notes` | Permanently delete notes from disk | diff --git a/tests/smoke/title-filename-sync.spec.ts b/tests/smoke/title-filename-sync.spec.ts new file mode 100644 index 00000000..c67c3da9 --- /dev/null +++ b/tests/smoke/title-filename-sync.spec.ts @@ -0,0 +1,98 @@ +import { test, expect } from '@playwright/test' +import { sendShortcut } from './helpers' + +/** Known BlockNote/ProseMirror error that fires during editor re-mount after rename. */ +const KNOWN_EDITOR_ERRORS = ['isConnected'] + +function isKnownEditorError(msg: string): boolean { + return KNOWN_EDITOR_ERRORS.some(k => msg.includes(k)) +} + +test.describe('Title/filename sync', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + await page.waitForTimeout(2500) + }) + + test('new note has title in frontmatter and filename = slug of title', async ({ page }) => { + const errors: string[] = [] + page.on('pageerror', (err) => { if (!isKnownEditorError(err.message)) errors.push(err.message) }) + + // Create note via Cmd+N + await page.locator('body').click() + await sendShortcut(page, 'n', ['Control']) + await expect(page.getByText(/Untitled note/).first()).toBeVisible({ timeout: 3000 }) + + // Type a title in the heading + const heading = page.locator('[data-content-type="heading"] h1') + await heading.waitFor({ timeout: 3000 }) + await heading.click({ clickCount: 3 }) + await page.keyboard.type('Career Tracks Depend on Company Shape', { delay: 15 }) + + // Wait for debounce + rename + await page.waitForTimeout(800) + const toast = page.locator('.fixed.bottom-8') + await expect(toast).toContainText('Renamed', { timeout: 5000 }) + + // Breadcrumb should show the new title + const breadcrumb = page.locator('span.truncate.font-medium') + await expect(breadcrumb.first()).toContainText('Career Tracks Depend on Company Shape', { timeout: 2000 }) + + expect(errors).toEqual([]) + }) + + test('opening a note shows title from frontmatter in tab/breadcrumb', async ({ page }) => { + // Click "All Notes" to see all notes + const allNotes = page.locator('text=All Notes').first() + await allNotes.click() + await page.waitForTimeout(500) + + // Click on the first note in the note list + const noteListContainer = page.locator('[data-testid="note-list-container"]') + await noteListContainer.waitFor({ timeout: 5000 }) + const noteItem = noteListContainer.locator('.cursor-pointer').first() + const noteTitle = await noteItem.locator('.font-medium, .text-sm').first().textContent() + await noteItem.click() + await page.waitForTimeout(500) + + // Breadcrumb should show the note title + if (noteTitle) { + const breadcrumb = page.locator('span.truncate.font-medium') + await expect(breadcrumb.first()).toContainText(noteTitle.trim(), { timeout: 2000 }) + } + }) + + test('rename via title editing updates both title and filename atomically', async ({ page }) => { + const errors: string[] = [] + page.on('pageerror', (err) => { if (!isKnownEditorError(err.message)) errors.push(err.message) }) + + // Create a note + await page.locator('body').click() + await sendShortcut(page, 'n', ['Control']) + await expect(page.getByText(/Untitled note/).first()).toBeVisible({ timeout: 3000 }) + + const heading = page.locator('[data-content-type="heading"] h1') + await heading.waitFor({ timeout: 3000 }) + await heading.click({ clickCount: 3 }) + await page.keyboard.type('Original Title XYZ', { delay: 15 }) + + // Wait for rename + await page.waitForTimeout(800) + await expect(page.locator('.fixed.bottom-8')).toContainText('Renamed', { timeout: 5000 }) + + // Now rename by editing the heading again + await heading.click({ clickCount: 3 }) + await page.keyboard.type('Renamed Title XYZ', { delay: 15 }) + + // Wait for debounce + rename + await page.waitForTimeout(800) + await expect(page.locator('.fixed.bottom-8')).toContainText('Renamed', { timeout: 5000 }) + + // Breadcrumb should show the new title + const breadcrumb = page.locator('span.truncate.font-medium') + await expect(breadcrumb.first()).toContainText('Renamed Title XYZ', { timeout: 2000 }) + + expect(errors).toEqual([]) + }) +})