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) <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-17 16:59:04 +01:00
parent 77b6961cb9
commit 0ac8ad8dff
5 changed files with 124 additions and 6 deletions

View File

@@ -0,0 +1,6 @@
---
title: Renamed Title XYZ
type: Note
status: Active
---
# Renamed Title XYZ

View File

@@ -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

View File

@@ -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)

View File

@@ -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 |

View File

@@ -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([])
})
})