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:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
46
src/utils/frontmatter.test.ts
Normal file
46
src/utils/frontmatter.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
109
tests/smoke/fix-archived-trashed-detection-v2.spec.ts
Normal file
109
tests/smoke/fix-archived-trashed-detection-v2.spec.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user