fix: bump cache version + handle Yes/No in TS frontmatter parser

Root cause: commit d6d35b3 added a custom Rust deserializer for
Archived/Trashed Yes/No strings but did not bump CACHE_VERSION.
Existing vaults had stale cached entries with archived: false from the
old parser, and since the cache version (5) matched, stale values were
served without re-parsing from disk.

- Bump CACHE_VERSION 5 → 6 to force full rescan on next vault load
- Add Yes/No handling to TypeScript parseScalar (Inspector display)
- Add integration tests: cached vault path with Archived/Trashed: Yes
- Add stale cache version invalidation test
- Add frontmatter.test.ts for TS Yes/No boolean parsing
- Add Playwright smoke test for archived note filtering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-11 17:07:22 +01:00
parent e891ff6191
commit 4c7e64dc0c
4 changed files with 243 additions and 3 deletions

View File

@@ -8,7 +8,7 @@ use super::{parse_md_file, scan_vault, VaultEntry};
// --- Vault Cache ---
/// Bump this when VaultEntry fields change to force a full rescan.
const CACHE_VERSION: u32 = 5;
const CACHE_VERSION: u32 = 6;
#[derive(Debug, Serialize, Deserialize)]
struct VaultCache {
@@ -870,4 +870,88 @@ mod tests {
"note must be trashed after invalidate + rescan"
);
}
/// Integration test: a note with `Archived: Yes` (string, not boolean)
/// must be recognized as archived through the full cached vault load path.
/// This catches the scenario where a stale cache stores `archived: false`
/// and the cache version bump forces a correct re-parse.
#[test]
fn test_cached_vault_archived_yes_string() {
let (_lock, _cache_tmp, dir) = setup_git_vault();
let vault = dir.path();
create_test_file(
vault,
"archived-note.md",
"---\nArchived: Yes\n---\n# Old Note\n",
);
git_add_commit(vault, "init");
let entries = scan_vault_cached(vault).unwrap();
assert_eq!(entries.len(), 1);
assert!(
entries[0].archived,
"'Archived: Yes' must be parsed as true through the cached vault path"
);
}
/// Integration test: `Trashed: Yes` (string) through full cached path.
#[test]
fn test_cached_vault_trashed_yes_string() {
let (_lock, _cache_tmp, dir) = setup_git_vault();
let vault = dir.path();
create_test_file(
vault,
"trashed-note.md",
"---\nTrashed: Yes\n---\n# Gone\n",
);
git_add_commit(vault, "init");
let entries = scan_vault_cached(vault).unwrap();
assert_eq!(entries.len(), 1);
assert!(
entries[0].trashed,
"'Trashed: Yes' must be parsed as true through the cached vault path"
);
}
/// Integration test: stale cache with old version is invalidated and
/// re-parses `Archived: Yes` correctly after cache version bump.
#[test]
fn test_stale_cache_version_forces_rescan_of_archived_yes() {
let (_lock, _cache_tmp, dir) = setup_git_vault();
let vault = dir.path();
create_test_file(
vault,
"note.md",
"---\nArchived: Yes\n---\n# Note\n",
);
git_add_commit(vault, "init");
let hash = git_head_hash(vault).unwrap();
// Simulate a stale cache written by old code that parsed Archived: Yes as false
let stale_entry = {
let mut e = parse_md_file(&vault.join("note.md")).unwrap();
e.archived = false; // simulate old parser behavior
e
};
let stale_cache = VaultCache {
version: CACHE_VERSION - 1, // old version
vault_path: vault.to_string_lossy().to_string(),
commit_hash: hash,
entries: vec![stale_entry],
};
write_cache(vault, &stale_cache);
// Load via cached path — stale version must trigger full rescan
let entries = scan_vault_cached(vault).unwrap();
assert_eq!(entries.len(), 1);
assert!(
entries[0].archived,
"stale cache with old version must be invalidated, re-parsing 'Archived: Yes' as true"
);
}
}

View File

@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest'
import { parseFrontmatter } from './frontmatter'
describe('parseFrontmatter', () => {
describe('boolean-like Yes/No values', () => {
it('parses Archived: Yes as true', () => {
const fm = parseFrontmatter('---\nArchived: Yes\n---\nBody')
expect(fm['Archived']).toBe(true)
})
it('parses Archived: No as false', () => {
const fm = parseFrontmatter('---\nArchived: No\n---\nBody')
expect(fm['Archived']).toBe(false)
})
it('parses Trashed: Yes as true', () => {
const fm = parseFrontmatter('---\nTrashed: Yes\n---\nBody')
expect(fm['Trashed']).toBe(true)
})
it('parses Trashed: No as false', () => {
const fm = parseFrontmatter('---\nTrashed: No\n---\nBody')
expect(fm['Trashed']).toBe(false)
})
it('parses yes (lowercase) as true', () => {
const fm = parseFrontmatter('---\nArchived: yes\n---\nBody')
expect(fm['Archived']).toBe(true)
})
it('parses no (lowercase) as false', () => {
const fm = parseFrontmatter('---\nArchived: no\n---\nBody')
expect(fm['Archived']).toBe(false)
})
it('still parses true as true', () => {
const fm = parseFrontmatter('---\nArchived: true\n---\nBody')
expect(fm['Archived']).toBe(true)
})
it('still parses false as false', () => {
const fm = parseFrontmatter('---\nArchived: false\n---\nBody')
expect(fm['Archived']).toBe(false)
})
})
})

View File

@@ -23,8 +23,9 @@ function parseInlineArray(value: string): FrontmatterValue {
function parseScalar(value: string): FrontmatterValue {
const clean = unquote(value)
if (clean.toLowerCase() === 'true') return true
if (clean.toLowerCase() === 'false') return false
const lower = clean.toLowerCase()
if (lower === 'true' || lower === 'yes') return true
if (lower === 'false' || lower === 'no') return false
return clean
}

View File

@@ -0,0 +1,109 @@
import { test, expect } from '@playwright/test'
import { sendShortcut } from './helpers'
const QUICK_OPEN_INPUT = 'input[placeholder="Search notes..."]'
/** Known archived note titles from mock data */
const ARCHIVED_TITLES = ['Website Redesign', 'Twitter Thread Growth Experiment']
async function openQuickOpen(page: import('@playwright/test').Page) {
await page.locator('body').click()
await sendShortcut(page, 'p', ['Control'])
await expect(page.locator(QUICK_OPEN_INPUT)).toBeVisible()
}
function quickOpenPanel(page: import('@playwright/test').Page) {
return page.locator('.fixed.inset-0').filter({ has: page.locator(QUICK_OPEN_INPUT) })
}
function getResultTitles(container: import('@playwright/test').Locator) {
return container.locator('span.truncate').allTextContents()
}
test.describe('Archived/Trashed Yes/No detection', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
})
test('archived notes are filtered out of the default sidebar', async ({ page }) => {
// In the default sidebar view (non-archived section), archived notes must not appear
const noteItems = page.locator('[data-testid="note-item"]')
const count = await noteItems.count()
for (let i = 0; i < count; i++) {
const text = await noteItems.nth(i).textContent()
for (const title of ARCHIVED_TITLES) {
expect(text, `archived note "${title}" should not be in default sidebar`).not.toContain(title)
}
}
})
test('archived note shows ArchivedNoteBanner in the editor', async ({ page }) => {
// Open quick open and navigate to an archived note
await openQuickOpen(page)
await page.locator(QUICK_OPEN_INPUT).fill('Website Redesign')
await page.waitForTimeout(400)
// The archived note might not appear in quick open (filtered), so try direct URL
const panel = quickOpenPanel(page)
const titles = await getResultTitles(panel)
if (titles.some(t => t.includes('Website Redesign'))) {
// If it appears in quick open, click it
const result = panel.locator('span.truncate').filter({ hasText: 'Website Redesign' }).first()
await result.click()
} else {
// Close quick open and use sidebar Archived section if available
await page.keyboard.press('Escape')
// Click the Archived section header to expand it
const archivedSection = page.locator('text=Archived').first()
if (await archivedSection.isVisible({ timeout: 1000 }).catch(() => false)) {
await archivedSection.click()
await page.waitForTimeout(300)
const archivedNote = page.locator('[data-testid="note-item"]').filter({ hasText: 'Website Redesign' })
if (await archivedNote.isVisible({ timeout: 1000 }).catch(() => false)) {
await archivedNote.click()
} else {
test.skip()
return
}
} else {
test.skip()
return
}
}
await page.waitForTimeout(500)
// Verify the archived banner is visible
const banner = page.locator('[data-testid="archived-note-banner"]')
await expect(banner).toBeVisible({ timeout: 3000 })
await expect(banner).toContainText('Archived')
})
test('archived note shows archived badge in the note list', async ({ page }) => {
// Navigate to the Archived section in the sidebar
const archivedSection = page.locator('button, [role="button"], span').filter({ hasText: /^Archived/ }).first()
if (!await archivedSection.isVisible({ timeout: 2000 }).catch(() => false)) {
test.skip()
return
}
await archivedSection.click()
await page.waitForTimeout(300)
// Look for archived badge on note items
const badges = page.locator('[data-testid="state-badge"]')
const badgeCount = await badges.count()
expect(badgeCount).toBeGreaterThan(0)
})
test('archived notes do not appear in Quick Open search', async ({ page }) => {
await openQuickOpen(page)
const panel = quickOpenPanel(page)
for (const title of ARCHIVED_TITLES) {
const query = title.split(' ')[0]
await page.locator(QUICK_OPEN_INPUT).fill(query)
await page.waitForTimeout(400)
const titles = await getResultTitles(panel)
expect(titles, `"${title}" should not appear in Quick Open`).not.toContain(title)
}
})
})