feat: publish HoloLake model-native living system source

This commit is contained in:
冰朔 2026-08-03 10:04:41 +08:00
commit c395dd3a99
2467 changed files with 615073 additions and 0 deletions

View file

@ -0,0 +1,57 @@
import { type Page, test } from '@playwright/test'
const SCREENSHOT_PATH = '/Users/luca/OpenClaw/ai-chat-final.jpg'
async function clickNoteListItem(page: Page): Promise<string> {
return page.evaluate(() => {
const items = document.querySelectorAll('[class*="cursor-pointer"]')
const noteListItem = Array.from(items).find((el) => {
const rect = el.getBoundingClientRect()
return rect.x > 249 && rect.x < 700 && rect.height > 40 && rect.width > 200
})
if (!noteListItem) {
return 'Nothing found'
}
const noteListElement = noteListItem as HTMLElement
noteListElement.click()
const rect = noteListItem.getBoundingClientRect()
return `Clicked: ${noteListItem.textContent?.trim().slice(0, 50)} at x=${rect.x}`
})
}
async function clickAiToolbarButton(page: Page): Promise<string> {
return page.evaluate(() => {
const aiButton = Array.from(document.querySelectorAll('button')).find((btn) =>
btn.title?.includes('AI'),
)
if (!aiButton) {
return 'AI button not found'
}
aiButton.click()
return `Clicked: ${aiButton.title}`
})
}
test('screenshot AI chat panel', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto('/', { waitUntil: 'networkidle' })
await page.waitForTimeout(3000)
// Click a note item in the NoteList (x > 243, past the sidebar)
// Note items have: cursor-pointer border-b border-[var(--border)]
const clicked = await clickNoteListItem(page)
console.log('Note click result:', clicked)
await page.waitForTimeout(1200)
// Now find and click the AI button in the editor toolbar
const aiBtn = await clickAiToolbarButton(page)
console.log('AI btn result:', aiBtn)
await page.waitForTimeout(800)
await page.screenshot({ path: SCREENSHOT_PATH, type: 'jpeg', quality: 90 })
console.log('Done')
})

View file

@ -0,0 +1,69 @@
import { test, expect } from '@playwright/test'
test('AI chat panel: open, send message, close', async ({ page }) => {
await page.goto('http://localhost:5173')
await page.waitForTimeout(2000)
// Click the first note in the list — note items are cursor-pointer divs inside .app__note-list
const noteItem = page.locator('.app__note-list .cursor-pointer').first()
await noteItem.click()
await page.waitForTimeout(800)
// Screenshot before opening AI chat
await page.screenshot({ path: 'test-results/ai-chat-before.png', fullPage: true })
// Find the Sparkle button in the breadcrumb/info bar and click it
const sparkleButton = page.locator('button[title="Open AI Chat"]')
await expect(sparkleButton).toBeVisible({ timeout: 5000 })
await sparkleButton.click()
await page.waitForTimeout(300)
// AI Chat panel should be visible
const aiChatHeader = page.locator('text=AI Chat')
await expect(aiChatHeader).toBeVisible()
// Screenshot with AI chat panel open
await page.screenshot({ path: 'test-results/ai-chat-open.png', fullPage: true })
// Context pills should be visible
await expect(page.locator('text=Frontmatter')).toBeVisible()
await expect(page.locator('text=Links')).toBeVisible()
// Type a message and send
const textarea = page.locator('textarea[placeholder="Ask about this document..."]')
await textarea.fill('Summarize this note')
await page.locator('button[title="Send message"]').click()
// Should see user message
await expect(page.locator('text=Summarize this note')).toBeVisible()
// Wait for typing indicator to appear
await page.waitForTimeout(300)
await page.screenshot({ path: 'test-results/ai-chat-typing.png', fullPage: true })
// Wait for mock response
await page.waitForTimeout(1200)
await expect(page.locator('text=words and links to')).toBeVisible({ timeout: 5000 })
// Screenshot with response
await page.screenshot({ path: 'test-results/ai-chat-response.png', fullPage: true })
// Test quick action pill
const expandButton = page.locator('button', { hasText: 'Expand' })
await expandButton.click()
await page.waitForTimeout(1800)
await expect(page.locator('text=Add more detail to the introduction')).toBeVisible({ timeout: 5000 })
// Screenshot with quick action response
await page.screenshot({ path: 'test-results/ai-chat-quick-action.png', fullPage: true })
// Close the panel using the X button in the AI Chat header
await page.locator('button[title="Close AI Chat"]').last().click()
await page.waitForTimeout(300)
// Inspector should be back
await expect(page.locator('text=Properties')).toBeVisible()
// Screenshot after closing
await page.screenshot({ path: 'test-results/ai-chat-closed.png', fullPage: true })
})

View file

@ -0,0 +1,27 @@
import { test, expect } from '@playwright/test'
test('app loads with four-panel layout', async ({ page }) => {
await page.goto('/')
// Verify the four panels are present
await expect(page.locator('.sidebar')).toBeVisible()
await expect(page.locator('.note-list')).toBeVisible()
await expect(page.locator('.editor')).toBeVisible()
await expect(page.locator('.inspector')).toBeVisible()
})
test('sidebar shows filters and section groups', async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(500) // Wait for mock data
await expect(page.getByRole('heading', { name: 'Laputa' })).toBeVisible()
// Filters
await expect(page.locator('.sidebar__filter-item').filter({ hasText: 'All Notes' })).toBeVisible()
await expect(page.locator('.sidebar__filter-item').filter({ hasText: 'People' })).toBeVisible()
await expect(page.locator('.sidebar__filter-item').filter({ hasText: 'Events' })).toBeVisible()
// Section groups (use exact match to avoid collision with note list pills)
await expect(page.locator('.sidebar__section-label', { hasText: 'PROJECTS' })).toBeVisible()
await expect(page.locator('.sidebar__section-label', { hasText: 'EXPERIMENTS' })).toBeVisible()
await expect(page.locator('.sidebar__section-label', { hasText: 'RESPONSIBILITIES' })).toBeVisible()
await expect(page.locator('.sidebar__section-label', { hasText: 'PROCEDURES' })).toBeVisible()
})

View file

@ -0,0 +1,63 @@
import { test, expect } from '@playwright/test'
test.use({ baseURL: 'http://localhost:5239' })
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(2000) // Wait for vault data to load
})
test('editor loads and renders note content for editing', async ({ page }) => {
await page.screenshot({ path: 'test-results/save-01-initial.png', fullPage: true })
// 1. Click a note in the note list panel
const noteList = page.locator('.app__note-list')
await expect(noteList).toBeVisible({ timeout: 5000 })
const firstNote = noteList.locator('div.cursor-pointer').first()
await expect(firstNote).toBeVisible({ timeout: 5000 })
await firstNote.click()
await page.waitForTimeout(1000)
// 2. Verify the BlockNote editor is visible with content
const editor = page.locator('.bn-editor')
await expect(editor).toBeVisible({ timeout: 5000 })
// Verify the editor is contenteditable (ready for editing)
const isEditable = await editor.getAttribute('contenteditable')
expect(isEditable).toBe('true')
await page.screenshot({ path: 'test-results/save-02-note-open.png', fullPage: true })
// 3. Verify the editor has content (not empty)
const editorText = await page.evaluate(() => {
const el = document.querySelector('.bn-editor')
return el?.textContent ?? ''
})
expect(editorText.length).toBeGreaterThan(10)
// 4. Verify tab bar shows the active note
const tabBar = page.locator('.editor')
await expect(tabBar).toBeVisible()
await page.screenshot({ path: 'test-results/save-03-editor-ready.png', fullPage: true })
})
test('Cmd+S keyboard shortcut triggers save toast', async ({ page }) => {
// Open a note
const noteList = page.locator('.app__note-list')
await expect(noteList).toBeVisible({ timeout: 5000 })
const firstNote = noteList.locator('div.cursor-pointer').first()
await firstNote.click()
await page.waitForTimeout(1000)
// Press Cmd+S — shows either "Saved" or "Nothing to save" depending on
// whether BlockNote's onChange fired from prior interactions
await page.keyboard.press('Meta+s')
await page.waitForTimeout(500)
// Verify a save-related toast appears (the shortcut was handled)
const toast = page.locator('text=/Saved|Nothing to save/')
await expect(toast).toBeVisible({ timeout: 3000 })
await page.screenshot({ path: 'test-results/save-04-cmd-s.png', fullPage: true })
})

View file

@ -0,0 +1,260 @@
import { test, expect } from '@playwright/test'
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(500) // Wait for mock data
})
// --- Flow 1: Open app, click note, verify editor shows content ---
test('clicking a note opens it in the editor with content', async ({ page }) => {
// Click "Build Laputa App" in the note list
await page.locator('.note-list__item', { hasText: 'Build Laputa App' }).click()
await page.waitForTimeout(300)
// Tab should appear and be active
await expect(page.locator('.editor__tab--active')).toHaveText(/Build Laputa App/)
// Editor should show note content (heading in live preview)
await expect(page.locator('.cm-editor')).toBeVisible()
// Inspector should show properties
await expect(page.locator('.inspector__prop-value', { hasText: 'Project' })).toBeVisible()
await page.screenshot({ path: 'test-results/core-open-note.png', fullPage: true })
})
test('editor shows markdown content with live preview', async ({ page }) => {
await page.locator('.note-list__item', { hasText: 'Build Laputa App' }).click()
await page.waitForTimeout(300)
// CodeMirror should render the content
const editor = page.locator('.cm-editor')
await expect(editor).toBeVisible()
// Should contain the heading text (rendered by live preview)
await expect(page.locator('.cm-content')).toContainText('Build Laputa App')
})
// --- Flow 2: Sidebar filter changes note list ---
test('sidebar filter: People shows only Person entries', async ({ page }) => {
await page.locator('.sidebar__filter-item', { hasText: 'People' }).click()
await page.waitForTimeout(200)
await expect(page.locator('.note-list__count')).toHaveText('1')
await expect(page.locator('.note-list__title', { hasText: 'Matteo Cellini' })).toBeVisible()
await expect(page.locator('.note-list__title', { hasText: 'Build Laputa App' })).not.toBeVisible()
})
test('sidebar filter: Events shows only Event entries', async ({ page }) => {
await page.locator('.sidebar__filter-item', { hasText: 'Events' }).click()
await page.waitForTimeout(200)
await expect(page.locator('.note-list__count')).toHaveText('1')
await expect(page.locator('.note-list__title', { hasText: 'Laputa App Design Session' })).toBeVisible()
})
test('sidebar filter: clicking back to All Notes restores full list', async ({ page }) => {
await page.locator('.sidebar__filter-item', { hasText: 'People' }).click()
await page.waitForTimeout(200)
await expect(page.locator('.note-list__count')).toHaveText('1')
await page.locator('.sidebar__filter-item', { hasText: 'All Notes' }).click()
await page.waitForTimeout(200)
await expect(page.locator('.note-list__count')).toHaveText('12')
})
// --- Flow 3: Search for a note ---
test('search filters notes by title', async ({ page }) => {
await page.fill('.note-list__search-input', 'stock')
await page.waitForTimeout(200)
await expect(page.locator('.note-list__count')).toHaveText('1')
await expect(page.locator('.note-list__title', { hasText: 'Stock Screener' })).toBeVisible()
await expect(page.locator('.note-list__title', { hasText: 'Build Laputa App' })).not.toBeVisible()
})
test('clearing search restores all results', async ({ page }) => {
await page.fill('.note-list__search-input', 'stock')
await page.waitForTimeout(200)
await expect(page.locator('.note-list__count')).toHaveText('1')
await page.fill('.note-list__search-input', '')
await page.waitForTimeout(200)
await expect(page.locator('.note-list__count')).toHaveText('12')
})
// --- Flow 4: Open multiple tabs and switch between them ---
test('opening multiple notes creates multiple tabs', async ({ page }) => {
// Open first note
await page.locator('.note-list__item', { hasText: 'Build Laputa App' }).click()
await page.waitForTimeout(300)
// Open second note
await page.locator('.note-list__item', { hasText: 'Grow Newsletter' }).click()
await page.waitForTimeout(300)
// Both tabs should exist
const tabs = page.locator('.editor__tab')
await expect(tabs).toHaveCount(2)
// Second tab should be active
await expect(page.locator('.editor__tab--active')).toHaveText(/Grow Newsletter/)
await page.screenshot({ path: 'test-results/core-multi-tabs.png', fullPage: true })
})
test('clicking a tab switches to it', async ({ page }) => {
// Open two notes
await page.locator('.note-list__item', { hasText: 'Build Laputa App' }).click()
await page.waitForTimeout(300)
await page.locator('.note-list__item', { hasText: 'Grow Newsletter' }).click()
await page.waitForTimeout(300)
// Switch back to first tab
await page.locator('.editor__tab', { hasText: 'Build Laputa App' }).click()
await page.waitForTimeout(200)
// First tab should now be active
await expect(page.locator('.editor__tab--active')).toHaveText(/Build Laputa App/)
// Editor should show first note's content
await expect(page.locator('.cm-content')).toContainText('Build Laputa App')
})
test('closing a tab removes it and switches to adjacent', async ({ page }) => {
// Open two notes
await page.locator('.note-list__item', { hasText: 'Build Laputa App' }).click()
await page.waitForTimeout(300)
await page.locator('.note-list__item', { hasText: 'Grow Newsletter' }).click()
await page.waitForTimeout(300)
// Close active tab (Grow Newsletter)
await page.locator('.editor__tab--active .editor__tab-close').click()
await page.waitForTimeout(200)
// Should have 1 tab left
await expect(page.locator('.editor__tab')).toHaveCount(1)
await expect(page.locator('.editor__tab--active')).toHaveText(/Build Laputa App/)
})
// --- Flow 5: Inspector shows correct properties ---
test('inspector shows properties for selected note', async ({ page }) => {
await page.locator('.note-list__item', { hasText: 'Build Laputa App' }).click()
await page.waitForTimeout(300)
// Type
await expect(page.locator('.inspector__prop-value', { hasText: 'Project' })).toBeVisible()
// Status
await expect(page.locator('.inspector__status-pill', { hasText: 'Active' })).toBeVisible()
// Owner
await expect(page.locator('.inspector__prop-value', { hasText: 'Luca Rossi' })).toBeVisible()
await page.screenshot({ path: 'test-results/core-inspector.png', fullPage: true })
})
test('inspector shows relationships', async ({ page }) => {
// Open a note with relationships
await page.locator('.note-list__item', { hasText: 'Build Laputa App' }).click()
await page.waitForTimeout(300)
// Should show "Related to" relationships
await expect(page.locator('.inspector__section', { hasText: 'Relationships' })).toBeVisible()
})
test('inspector shows backlinks', async ({ page }) => {
// Open a note that has backlinks (Build Laputa App is referenced by Facebook Ads and Budget Allocation)
await page.locator('.note-list__item', { hasText: 'Build Laputa App' }).click()
await page.waitForTimeout(300)
// Backlinks section should show
const backlinksSection = page.locator('.inspector__section', { hasText: 'Backlinks' })
await expect(backlinksSection).toBeVisible()
})
test('inspector shows git history', async ({ page }) => {
await page.locator('.note-list__item', { hasText: 'Build Laputa App' }).click()
await page.waitForTimeout(300)
// Should show commits
await expect(page.locator('.inspector__commit-hash').first()).toBeVisible()
await expect(page.locator('.inspector__commit-msg').first()).toContainText('26q1-laputa-app')
})
test('inspector updates when switching tabs', async ({ page }) => {
// Open first note
await page.locator('.note-list__item', { hasText: 'Build Laputa App' }).click()
await page.waitForTimeout(300)
await expect(page.locator('.inspector__prop-value', { hasText: 'Project' })).toBeVisible()
// Open second note with different type — use title locator for precision
await page.locator('.note-list__item').filter({ has: page.locator('.note-list__title', { hasText: 'Matteo Cellini' }) }).click()
await page.waitForTimeout(300)
await expect(page.locator('.inspector__prop-value', { hasText: 'Person' })).toBeVisible()
})
// --- Flow 6: Note list preview snippets ---
test('note list items show preview snippets', async ({ page }) => {
// Check that snippets are visible
const snippet = page.locator('.note-list__snippet').first()
await expect(snippet).toBeVisible()
// Snippet should have some text content
const text = await snippet.textContent()
expect(text!.length).toBeGreaterThan(10)
})
// --- Flow 7: Create note and verify it appears ---
test('full create note flow', async ({ page }) => {
// Count before
const countBefore = await page.locator('.note-list__count').textContent()
// Create new note
await page.click('.note-list__add-btn')
await page.waitForTimeout(200)
await page.fill('.create-dialog__input', 'E2E Test Note')
await page.click('.create-dialog__type-btn:text("Experiment")')
await page.click('.create-dialog__btn--create')
await page.waitForTimeout(500)
// Count should increase
const countAfter = await page.locator('.note-list__count').textContent()
expect(parseInt(countAfter!, 10)).toBe(parseInt(countBefore!, 10) + 1)
// Note should be opened in editor
await expect(page.locator('.editor__tab--active')).toHaveText(/E2E Test Note/)
await page.screenshot({ path: 'test-results/core-create-note.png', fullPage: true })
})
// --- Flow 8: Wiki-link navigation ---
test('clicking a wikilink opens the target note in a new tab', async ({ page }) => {
// Open "Manage Sponsorships" which contains [[Matteo Cellini]] wikilink
await page.locator('.note-list__item', { hasText: 'Manage Sponsorships' }).click()
await page.waitForTimeout(300)
// Verify we opened the right note
await expect(page.locator('.editor__tab--active')).toHaveText(/Manage Sponsorships/)
// Click the wikilink — use mouse.click to fire real mousedown
const wikilink = page.locator('.cm-wikilink', { hasText: 'Matteo Cellini' })
await expect(wikilink).toBeVisible()
const box = await wikilink.boundingBox()
expect(box).not.toBeNull()
await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height / 2)
await page.waitForTimeout(300)
// New tab should open with the target note active
await expect(page.locator('.editor__tab--active')).toHaveText(/Matteo Cellini/)
// Editor should show the target note's content
await expect(page.locator('.cm-content')).toContainText('Matteo Cellini')
await page.screenshot({ path: 'test-results/core-wikilink-nav.png', fullPage: true })
})

View file

@ -0,0 +1,57 @@
import { test, expect } from '@playwright/test'
test('clicking + button opens create note dialog', async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(500)
// Click the + button
await page.click('.note-list__add-btn')
await page.waitForTimeout(200)
// Dialog should be visible
await expect(page.locator('.create-dialog')).toBeVisible()
await expect(page.locator('.create-dialog__title')).toHaveText('Create New Note')
await page.screenshot({ path: 'test-results/create-dialog.png', fullPage: true })
})
test('create a new note via dialog', async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(500)
// Open dialog
await page.click('.note-list__add-btn')
await page.waitForTimeout(200)
// Type a title
await page.fill('.create-dialog__input', 'My Test Note')
// Select "Project" type
await page.click('.create-dialog__type-btn:text("Project")')
// Click Create
await page.click('.create-dialog__btn--create')
await page.waitForTimeout(300)
// Dialog should close
await expect(page.locator('.create-dialog')).not.toBeVisible()
// New note should appear in the list and be opened in editor
await expect(page.locator('.note-list__item:has-text("My Test Note")').first()).toBeVisible()
await expect(page.locator('.editor__tab--active:has-text("My Test Note")')).toBeVisible()
await page.screenshot({ path: 'test-results/create-note-result.png', fullPage: true })
})
test('escape closes create dialog', async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(500)
await page.click('.note-list__add-btn')
await page.waitForTimeout(200)
await expect(page.locator('.create-dialog')).toBeVisible()
await page.keyboard.press('Escape')
await page.waitForTimeout(100)
await expect(page.locator('.create-dialog')).not.toBeVisible()
})

View file

@ -0,0 +1,120 @@
import { test, expect } from '@playwright/test'
test.describe('Create New Type Feature', () => {
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:5203')
await page.waitForSelector('text=All Notes')
})
test('clicking + on Types section opens Create Type dialog', async ({ page }) => {
// Hover over the Types section to reveal the + button
const typesSection = page.locator('text=Types').first()
await typesSection.hover()
// Click the + button next to Types
const createTypeBtn = page.locator('[title="New Type"]')
await createTypeBtn.click()
// Dialog should open with correct title and elements
await expect(page.locator('text=Create New Type')).toBeVisible()
await expect(page.locator('input[placeholder="e.g. Recipe, Book, Habit..."]')).toBeVisible()
await expect(page.locator('text=Creates a type document')).toBeVisible()
await page.screenshot({ path: 'test-results/create-type-dialog.png', fullPage: true })
})
test('Create button is disabled when name is empty', async ({ page }) => {
const typesSection = page.locator('text=Types').first()
await typesSection.hover()
await page.locator('[title="New Type"]').click()
const createBtn = page.locator('button:has-text("Create")')
await expect(createBtn).toBeDisabled()
})
test('can create a new type and it appears in sidebar', async ({ page }) => {
// Open Create Type dialog
const typesSection = page.locator('text=Types').first()
await typesSection.hover()
await page.locator('[title="New Type"]').click()
// Type a name and submit
await page.locator('input[placeholder="e.g. Recipe, Book, Habit..."]').fill('Workout')
await page.locator('button:has-text("Create")').click()
// Dialog should close
await expect(page.locator('text=Create New Type')).not.toBeVisible()
// New type should appear as a sidebar section (pluralized)
await expect(page.locator('text=Workouts')).toBeVisible({ timeout: 3000 })
// The type document should open in the editor
await expect(page.locator('text=Workout').first()).toBeVisible()
await page.screenshot({ path: 'test-results/after-create-type.png', fullPage: true })
})
test('newly created type appears in Create Note dialog type selector', async ({ page }) => {
// First, create a custom type
const typesSection = page.locator('text=Types').first()
await typesSection.hover()
await page.locator('[title="New Type"]').click()
await page.locator('input[placeholder="e.g. Recipe, Book, Habit..."]').fill('Workout')
await page.locator('button:has-text("Create")').click()
// Now open Create Note dialog
await page.keyboard.press('Meta+n')
await page.waitForSelector('text=Create New Note')
// Built-in types should be visible
await expect(page.locator('button:has-text("Note")')).toBeVisible()
await expect(page.locator('button:has-text("Project")')).toBeVisible()
// Our custom type should also be visible
await expect(page.locator('button:has-text("Workout")')).toBeVisible()
await page.screenshot({ path: 'test-results/create-note-with-custom-type.png', fullPage: true })
})
test('can create an instance of a custom type', async ({ page }) => {
// First, create a custom type
const typesSection = page.locator('text=Types').first()
await typesSection.hover()
await page.locator('[title="New Type"]').click()
await page.locator('input[placeholder="e.g. Recipe, Book, Habit..."]').fill('Workout')
await page.locator('button:has-text("Create")').click()
await expect(page.locator('text=Workouts')).toBeVisible({ timeout: 3000 })
// Hover over the new Workouts section and click +
const workoutsSection = page.locator('text=Workouts').first()
await workoutsSection.hover()
await page.locator('[title="New Workout"]').click()
// Create Note dialog should open
await expect(page.locator('text=Create New Note')).toBeVisible()
// Type a title and create
await page.locator('input[placeholder="Enter note title..."]').fill('Morning Run')
await page.locator('button:has-text("Create")').last().click()
// The note should open in editor
await expect(page.locator('text=Morning Run').first()).toBeVisible({ timeout: 3000 })
await page.screenshot({ path: 'test-results/custom-type-instance.png', fullPage: true })
})
test('Cancel closes the dialog without creating', async ({ page }) => {
const typesSection = page.locator('text=Types').first()
await typesSection.hover()
await page.locator('[title="New Type"]').click()
await page.locator('input[placeholder="e.g. Recipe, Book, Habit..."]').fill('ShouldNotExist')
await page.locator('button:has-text("Cancel")').click()
// Dialog should close
await expect(page.locator('text=Create New Type')).not.toBeVisible()
// Type should NOT appear in sidebar
await expect(page.locator('text=ShouldNotExists')).not.toBeVisible()
})
})

View file

@ -0,0 +1,38 @@
import { test, expect } from '@playwright/test'
test.use({ baseURL: 'http://localhost:5201' })
/**
* Regression test: editor content must appear after clicking a note.
*
* Root cause: BlockNote's replaceBlocks/insertBlocks internally calls flushSync,
* which fails silently when invoked from inside React's useEffect lifecycle.
* Fix: defer the content swap via queueMicrotask.
*/
test('editor content appears on first note click', async ({ page }) => {
const errors: string[] = []
page.on('console', msg => {
if (msg.type() === 'error') errors.push(msg.text())
})
await page.goto('/')
// Wait for note list to load
await page.waitForSelector('.app__note-list .cursor-pointer', { timeout: 15000 })
await page.waitForTimeout(200)
// Click the first note in the note list
const noteList = page.locator('.app__note-list')
const firstNote = noteList.locator('.cursor-pointer').first()
await firstNote.click()
// Wait for ProseMirror editor to have content
const pm = page.locator('.ProseMirror')
await expect(async () => {
const text = await pm.textContent()
expect(text?.trim().length, 'Editor content should not be empty').toBeGreaterThan(5)
}).toPass({ timeout: 5000 })
// Verify no flushSync errors appeared
const flushSyncErrors = errors.filter(e => e.includes('flushSync'))
expect(flushSyncErrors, 'No flushSync-inside-lifecycle errors should occur').toHaveLength(0)
})

View file

@ -0,0 +1,33 @@
import { test, expect } from '@playwright/test'
test('editor panel has proper width regardless of title length', async ({ page }) => {
await page.goto('http://localhost:5204')
await page.waitForTimeout(1000)
// Click on a note with a short title from the note list
const noteItems = page.locator('.note-list__item')
const count = await noteItems.count()
if (count > 0) {
// Click the first note
await noteItems.first().click()
await page.waitForTimeout(500)
// Measure editor container width
const editorContainer = page.locator('.editor__blocknote-container')
const box = await editorContainer.boundingBox()
// The editor should have a reasonable width (at least 300px)
expect(box).toBeTruthy()
expect(box!.width).toBeGreaterThan(300)
// Screenshot for visual verification
await page.screenshot({ path: 'test-results/editor-min-width.png', fullPage: true })
// Also check that .bn-container fills the editor width
const bnContainer = page.locator('.editor__blocknote-container .bn-container')
const bnBox = await bnContainer.boundingBox()
expect(bnBox).toBeTruthy()
expect(bnBox!.width).toBeGreaterThan(300)
}
})

View file

@ -0,0 +1,82 @@
import { test, expect } from '@playwright/test'
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(500) // Wait for mock data
})
test('All Notes shows all entries', async ({ page }) => {
const count = page.locator('.note-list__count')
await expect(count).toHaveText('12')
})
test('clicking People filter shows only people', async ({ page }) => {
await page.click('text=People')
await page.waitForTimeout(100)
const count = page.locator('.note-list__count')
await expect(count).toHaveText('1')
await expect(page.locator('.note-list__title', { hasText: 'Matteo Cellini' })).toBeVisible()
await page.screenshot({ path: 'test-results/filter-people.png' })
})
test('clicking Events filter shows only events', async ({ page }) => {
await page.click('text=Events')
await page.waitForTimeout(100)
const count = page.locator('.note-list__count')
await expect(count).toHaveText('1')
await expect(page.locator('.note-list__title', { hasText: 'Laputa App Design Session' })).toBeVisible()
await page.screenshot({ path: 'test-results/filter-events.png' })
})
test('clicking PROJECTS header shows all projects', async ({ page }) => {
await page.click('text=PROJECTS')
await page.waitForTimeout(100)
const count = page.locator('.note-list__count')
await expect(count).toHaveText('1')
await expect(page.locator('.note-list__title', { hasText: 'Build Laputa App' })).toBeVisible()
await page.screenshot({ path: 'test-results/filter-projects.png' })
})
test('clicking specific entity shows it pinned with children', async ({ page }) => {
await page.locator('.sidebar__item', { hasText: 'Build Laputa App' }).click()
await page.waitForTimeout(100)
// Pinned entity + children that belongTo this project
await expect(page.locator('.note-list__title', { hasText: 'Build Laputa App' })).toBeVisible()
await expect(page.locator('.note-list__title', { hasText: 'Facebook Ads Strategy' })).toBeVisible()
await expect(page.locator('.note-list__title', { hasText: 'Budget Allocation' })).toBeVisible()
await expect(page.locator('.note-list__item--pinned')).toBeVisible()
await page.screenshot({ path: 'test-results/filter-entity.png' })
})
test('clicking topic shows entries related to that topic', async ({ page }) => {
await page.locator('.sidebar__topic-item', { hasText: 'Software Development' }).click()
await page.waitForTimeout(100)
await expect(page.locator('.note-list__title', { hasText: 'Build Laputa App' })).toBeVisible()
await page.screenshot({ path: 'test-results/filter-topic.png' })
})
test('search bar filters by title substring', async ({ page }) => {
await page.fill('.note-list__search-input', 'budget')
await page.waitForTimeout(100)
const count = page.locator('.note-list__count')
await expect(count).toHaveText('1')
await expect(page.locator('.note-list__title', { hasText: 'Budget Allocation' })).toBeVisible()
await expect(page.locator('.note-list__title', { hasText: 'Build Laputa App' })).not.toBeVisible()
await page.screenshot({ path: 'test-results/search-budget.png' })
})
test('type filter pills narrow results', async ({ page }) => {
// Click "Projects" pill
await page.locator('.note-list__pill', { hasText: 'Projects' }).click()
await page.waitForTimeout(100)
const count = page.locator('.note-list__count')
await expect(count).toHaveText('1')
await expect(page.locator('.note-list__title', { hasText: 'Build Laputa App' })).toBeVisible()
await expect(page.locator('.note-list__title', { hasText: 'Matteo Cellini' })).not.toBeVisible()
await page.screenshot({ path: 'test-results/pill-projects.png' })
// Click "All" to reset
await page.locator('.note-list__pill', { hasText: 'All' }).click()
await page.waitForTimeout(100)
await expect(count).toHaveText('12')
})

View file

@ -0,0 +1,19 @@
import { test } from '@playwright/test'
test('find note selectors', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto('/', { waitUntil: 'networkidle' })
await page.waitForTimeout(3000)
// Get all clickable elements with their text
const info = await page.evaluate(() => {
const items = document.querySelectorAll('[class*="cursor-pointer"]')
return Array.from(items).slice(0, 20).map(el => ({
tag: el.tagName,
text: el.textContent?.trim().slice(0, 50),
cls: el.className.toString().slice(0, 80),
rect: el.getBoundingClientRect().toJSON()
}))
})
console.log(JSON.stringify(info, null, 2))
})

View file

@ -0,0 +1,182 @@
import { test, expect } from '@playwright/test'
import * as path from 'path'
import * as fs from 'fs'
// Minimal valid PNG: 1x1 red pixel
const TEST_PNG_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='
function createTestImage(filepath: string) {
fs.mkdirSync(path.dirname(filepath), { recursive: true })
fs.writeFileSync(filepath, Buffer.from(TEST_PNG_BASE64, 'base64'))
}
test('drag & drop image into editor inserts image block', async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(1000)
// Open a note
await page.locator('[data-testid="type-icon"]').first().click({ timeout: 10000 })
await page.waitForTimeout(500)
const editor = page.locator('.bn-editor')
await expect(editor).toBeVisible({ timeout: 10000 })
await editor.click()
await page.waitForTimeout(200)
// Create a test image file
const testImagePath = path.join(process.cwd(), 'test-results', 'drag-test-image.png')
createTestImage(testImagePath)
// Screenshot before
await page.screenshot({ path: 'test-results/drag-drop-before.png', fullPage: true })
// Simulate drag-and-drop of a file into the editor
// Playwright supports dispatching drag events with DataTransfer
const editorContainer = page.locator('.editor__blocknote-container')
const box = await editorContainer.boundingBox()
expect(box).toBeTruthy()
// Use Playwright's page.dispatchEvent with a custom script to simulate file drop
await page.evaluate(async ({ base64, x, y }) => {
const container = document.querySelector('.editor__blocknote-container')
if (!container) throw new Error('Editor container not found')
// Convert base64 to Uint8Array
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
// Create a File object
const file = new File([bytes], 'drag-test-image.png', { type: 'image/png' })
// Create DataTransfer with the file
const dt = new DataTransfer()
dt.items.add(file)
// Dispatch dragover first (to set the drop effect)
const dragOverEvent = new DragEvent('dragover', {
dataTransfer: dt,
bubbles: true,
cancelable: true,
clientX: x,
clientY: y,
})
container.dispatchEvent(dragOverEvent)
// Then dispatch drop
const dropEvent = new DragEvent('drop', {
dataTransfer: dt,
bubbles: true,
cancelable: true,
clientX: x,
clientY: y,
})
container.dispatchEvent(dropEvent)
}, {
base64: TEST_PNG_BASE64,
x: box!.x + box!.width / 2,
y: box!.y + box!.height / 2,
})
// Wait for the image to be uploaded and inserted
await page.waitForTimeout(2000)
// Verify: image element exists in the editor
const images = page.locator('.bn-editor img')
await expect(images.first()).toBeVisible({ timeout: 5000 })
// Verify: image uses data URL (browser mode)
const src = await images.first().getAttribute('src')
expect(src).toMatch(/^data:/)
await page.screenshot({ path: 'test-results/drag-drop-after.png', fullPage: true })
// Clean up
if (fs.existsSync(testImagePath)) fs.unlinkSync(testImagePath)
})
test('drop zone overlay appears during image drag', async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(1000)
// Open a note
await page.locator('[data-testid="type-icon"]').first().click({ timeout: 10000 })
await page.waitForTimeout(500)
const editor = page.locator('.bn-editor')
await expect(editor).toBeVisible({ timeout: 10000 })
const editorContainer = page.locator('.editor__blocknote-container')
const box = await editorContainer.boundingBox()
expect(box).toBeTruthy()
// Simulate dragover with an image file to trigger overlay
await page.evaluate(({ x, y }) => {
const container = document.querySelector('.editor__blocknote-container')
if (!container) throw new Error('Editor container not found')
const file = new File([new Uint8Array(1)], 'test.png', { type: 'image/png' })
const dt = new DataTransfer()
dt.items.add(file)
const event = new DragEvent('dragover', {
dataTransfer: dt,
bubbles: true,
cancelable: true,
clientX: x,
clientY: y,
})
container.dispatchEvent(event)
}, { x: box!.x + box!.width / 2, y: box!.y + box!.height / 2 })
// The drop overlay should be visible
const overlay = page.locator('.editor__drop-overlay')
await expect(overlay).toBeVisible({ timeout: 2000 })
await expect(overlay).toContainText('Drop image here')
await page.screenshot({ path: 'test-results/drag-drop-overlay.png', fullPage: true })
})
test('non-image file drop is ignored', async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(1000)
// Open a note
await page.locator('[data-testid="type-icon"]').first().click({ timeout: 10000 })
await page.waitForTimeout(500)
const editor = page.locator('.bn-editor')
await expect(editor).toBeVisible({ timeout: 10000 })
const editorContainer = page.locator('.editor__blocknote-container')
const box = await editorContainer.boundingBox()
expect(box).toBeTruthy()
// Count images before
const imagesBefore = await page.locator('.bn-editor img').count()
// Simulate dropping a text file
await page.evaluate(({ x, y }) => {
const container = document.querySelector('.editor__blocknote-container')
if (!container) throw new Error('Editor container not found')
const file = new File(['not an image'], 'readme.txt', { type: 'text/plain' })
const dt = new DataTransfer()
dt.items.add(file)
container.dispatchEvent(new DragEvent('drop', {
dataTransfer: dt,
bubbles: true,
cancelable: true,
clientX: x,
clientY: y,
}))
}, { x: box!.x + box!.width / 2, y: box!.y + box!.height / 2 })
await page.waitForTimeout(500)
// No new images should have been added
const imagesAfter = await page.locator('.bn-editor img').count()
expect(imagesAfter).toBe(imagesBefore)
})

View file

@ -0,0 +1,134 @@
import { test, expect, type Page } from '@playwright/test'
import * as path from 'path'
import * as fs from 'fs'
import {
createFixtureVaultCopy,
openFixtureVault,
removeFixtureVaultCopy,
} from '../tests/helpers/fixtureVault'
// Minimal valid PNG: 1x1 red pixel
const TEST_PNG_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='
function createTestPng(filepath: string) {
fs.mkdirSync(path.dirname(filepath), { recursive: true })
fs.writeFileSync(filepath, Buffer.from(TEST_PNG_BASE64, 'base64'))
}
let tempVaultDir: string
async function openImageTestNote(page: Page) {
await page.locator('[data-testid="note-list-container"]').getByText('Alpha Project', { exact: true }).click()
const editor = page.locator('.bn-editor')
await expect(editor).toBeVisible({ timeout: 10000 })
return editor
}
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(60_000)
tempVaultDir = createFixtureVaultCopy()
await openFixtureVault(page, tempVaultDir)
})
test.afterEach(async () => {
removeFixtureVaultCopy(tempVaultDir)
})
test('image upload via file picker displays image with data URL', async ({ page }) => {
const editor = await openImageTestNote(page)
await editor.click()
await page.waitForTimeout(200)
// Insert image block via slash command
await page.keyboard.press('Enter')
await page.waitForTimeout(100)
await page.keyboard.type('/image', { delay: 80 })
await page.waitForTimeout(500)
// Select Image from slash menu (press Enter to pick first match)
await page.keyboard.press('Enter')
await page.waitForTimeout(500)
// Verify Upload tab is available (uploadFile is configured)
const fileInput = page.locator('input[type="file"]')
expect(await fileInput.count()).toBeGreaterThan(0)
// Upload a test image
const testImagePath = path.join(process.cwd(), 'test-results', 'test-image.png')
createTestPng(testImagePath)
await fileInput.first().setInputFiles(testImagePath)
await page.waitForTimeout(2000)
// Verify: image element exists in the editor
const images = page.locator('.bn-editor img')
const imageCount = await images.count()
expect(imageCount).toBeGreaterThan(0)
// Verify: image uses data URL (stable, survives reload in dev mode)
const src = await images.first().getAttribute('src')
expect(src).toMatch(/^data:/)
// Verify: no "Loading..." elements remain
const loadingEls = page.locator('.bn-file-loading-preview')
expect(await loadingEls.count()).toBe(0)
await page.screenshot({ path: 'test-results/image-upload-after.png', fullPage: true })
if (fs.existsSync(testImagePath)) fs.unlinkSync(testImagePath)
})
test('image paste into editor inserts image block', async ({ page }) => {
const editor = await openImageTestNote(page)
await editor.click()
await page.evaluate((base64) => {
const editorElement = document.querySelector('.bn-editor')
if (!editorElement) throw new Error('Editor not found')
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
const file = new File([bytes], 'pasted-image.png', { type: 'image/png' })
const clipboardData = new DataTransfer()
clipboardData.items.add(file)
editorElement.dispatchEvent(new ClipboardEvent('paste', {
clipboardData,
bubbles: true,
cancelable: true,
}))
}, TEST_PNG_BASE64)
const images = page.locator('.bn-editor img')
await expect(images.first()).toBeVisible({ timeout: 5000 })
const src = await images.first().getAttribute('src')
expect(src).toMatch(/^data:/)
})
test('editor has uploadFile configured (no error on image block insert)', async ({ page }) => {
const editor = await openImageTestNote(page)
// Capture console errors
const errors: string[] = []
page.on('pageerror', (err) => errors.push(err.message))
// Insert an image block via slash command
await editor.click()
await page.keyboard.press('Enter')
await page.keyboard.type('/image', { delay: 30 })
await page.waitForTimeout(500)
// Press Enter to select Image
await page.keyboard.press('Enter')
await page.waitForTimeout(500)
await page.screenshot({ path: 'test-results/image-block-inserted.png', fullPage: true })
// No errors related to upload should have occurred
const uploadErrors = errors.filter(e => e.includes('upload'))
expect(uploadErrors).toHaveLength(0)
})

View file

@ -0,0 +1,43 @@
import { test, expect } from '@playwright/test'
test('Cmd+N opens create note dialog', async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(500)
await page.keyboard.press('Meta+n')
await page.waitForTimeout(200)
await expect(page.locator('.create-dialog')).toBeVisible()
await expect(page.locator('.create-dialog__title')).toHaveText('Create New Note')
})
test('Cmd+S shows save toast', async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(500)
await page.keyboard.press('Meta+s')
await page.waitForTimeout(200)
await expect(page.locator('.toast')).toBeVisible()
await expect(page.locator('.toast')).toHaveText('Saved')
await page.screenshot({ path: 'test-results/save-toast.png', fullPage: true })
})
test('Cmd+W closes the active tab', async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(500)
// Open a note
await page.click('.note-list__item')
await page.waitForTimeout(300)
await expect(page.locator('.editor__tab--active')).toBeVisible()
// Close it with Cmd+W
await page.keyboard.press('Meta+w')
await page.waitForTimeout(300)
// Tab should be gone, placeholder should show
await expect(page.locator('.editor__tab')).not.toBeVisible()
await expect(page.locator('.editor__placeholder')).toBeVisible()
})

View file

@ -0,0 +1,88 @@
import { test, expect } from '@playwright/test'
test('Cmd+P opens quick open palette', async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(500)
// Open palette with keyboard shortcut
await page.keyboard.press('Meta+p')
await page.waitForTimeout(200)
await expect(page.locator('.palette')).toBeVisible()
await expect(page.locator('.palette__input')).toBeFocused()
await page.screenshot({ path: 'test-results/quick-open.png', fullPage: true })
})
test('quick open: search and select a note', async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(500)
await page.keyboard.press('Meta+p')
await page.waitForTimeout(200)
// Type to search
await page.fill('.palette__input', 'laputa')
await page.waitForTimeout(100)
// Should show matching result
await expect(page.locator('.palette__item-title:has-text("Build Laputa App")')).toBeVisible()
// Press Enter to select
await page.keyboard.press('Enter')
await page.waitForTimeout(500)
// Palette should close and note should be opened
await expect(page.locator('.palette')).not.toBeVisible()
// The top result should have been opened (wait for async content load)
await expect(page.locator('.editor__tab--active')).toBeVisible({ timeout: 3000 })
await page.screenshot({ path: 'test-results/quick-open-selected.png', fullPage: true })
})
test('quick open: arrow keys navigate results', async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(500)
await page.keyboard.press('Meta+p')
await page.waitForTimeout(200)
// First item should be selected by default
await expect(page.locator('.palette__item--selected').first()).toBeVisible()
// Arrow down to move selection
await page.keyboard.press('ArrowDown')
await page.waitForTimeout(50)
// Second item should be selected
const items = page.locator('.palette__item')
const count = await items.count()
expect(count).toBeGreaterThan(1)
})
test('quick open: Escape closes palette', async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(500)
await page.keyboard.press('Meta+p')
await page.waitForTimeout(200)
await expect(page.locator('.palette')).toBeVisible()
await page.keyboard.press('Escape')
await page.waitForTimeout(100)
await expect(page.locator('.palette')).not.toBeVisible()
})
test('quick open: clicking outside closes palette', async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(500)
await page.keyboard.press('Meta+p')
await page.waitForTimeout(200)
await expect(page.locator('.palette')).toBeVisible()
// Click the overlay area (outside the palette) using mouse click at top-left
await page.mouse.click(10, 10)
await page.waitForTimeout(200)
await expect(page.locator('.palette')).not.toBeVisible()
})

View file

@ -0,0 +1,80 @@
import { test, expect } from '@playwright/test'
test('rename tab by double-clicking', async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(2000)
// Screenshot initial state
await page.screenshot({ path: 'test-results/rename-01-initial.png', fullPage: true })
// Click a note in the list using text content
await page.getByText('Deprecated Workflow').first().click()
await page.waitForTimeout(500)
// Screenshot: note opened as tab
await page.screenshot({ path: 'test-results/rename-02-note-opened.png', fullPage: true })
// Find the tab title in the tab bar and double-click it
const tabTitle = page.locator('.group span.truncate').first()
await expect(tabTitle).toBeVisible({ timeout: 5000 })
const originalTitle = await tabTitle.textContent()
console.log(`Original title: "${originalTitle}"`)
await tabTitle.dblclick()
await page.waitForTimeout(300)
// Screenshot: editing mode
await page.screenshot({ path: 'test-results/rename-03-editing.png', fullPage: true })
// Verify input appeared
const editInput = page.locator('.group input')
await expect(editInput).toBeVisible({ timeout: 3000 })
// Type new name
await editInput.fill('Renamed Test Note')
await page.screenshot({ path: 'test-results/rename-04-typing.png', fullPage: true })
// Press Enter to save
await editInput.press('Enter')
await page.waitForTimeout(1000)
// Screenshot: after rename
await page.screenshot({ path: 'test-results/rename-05-saved.png', fullPage: true })
// Verify tab title changed
const newTabTitle = page.locator('.group span.truncate').first()
await expect(newTabTitle).toHaveText('Renamed Test Note')
})
test('cancel rename with Escape', async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(2000)
// Open a note
await page.getByText('Deprecated Workflow').first().click()
await page.waitForTimeout(500)
const tabTitle = page.locator('.group span.truncate').first()
const originalTitle = await tabTitle.textContent()
// Double-click to edit
await tabTitle.dblclick()
await page.waitForTimeout(300)
const editInput = page.locator('.group input')
await expect(editInput).toBeVisible({ timeout: 3000 })
// Type something different
await editInput.fill('Will Be Cancelled')
// Press Escape to cancel
await editInput.press('Escape')
await page.waitForTimeout(300)
// Screenshot: after cancel
await page.screenshot({ path: 'test-results/rename-06-cancelled.png', fullPage: true })
// Verify title unchanged
const afterTitle = page.locator('.group span.truncate').first()
await expect(afterTitle).toHaveText(originalTitle!)
})

View file

@ -0,0 +1,94 @@
import { test } from '@playwright/test'
test('capture app screenshot for review', async ({ page }) => {
await page.goto('/')
// Wait for mock data to load
await page.waitForTimeout(500)
await page.screenshot({ path: 'test-results/app-screenshot.png', fullPage: true })
})
test('capture editor with note selected', async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(500)
// Click the first note in the list
await page.click('.note-list__item')
await page.waitForTimeout(300)
await page.screenshot({ path: 'test-results/editor-screenshot.png', fullPage: true })
})
test('live preview: headings styled, syntax hidden', async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(500)
// Click a note to load it
await page.click('.note-list__item')
await page.waitForTimeout(500)
// Screenshot showing live preview (headings styled, syntax hidden)
await page.screenshot({ path: 'test-results/live-preview.png', fullPage: true })
})
test('tab bar: multiple tabs open and close', async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(500)
// Click first note
await page.click('.note-list__item')
await page.waitForTimeout(500)
// Debug: screenshot after first click
await page.screenshot({ path: 'test-results/tabs-debug-1.png', fullPage: true })
// Check if other items are visible
const items = await page.locator('.note-list__item').count()
console.log(`Note list items visible after first click: ${items}`)
// Click second item if available
if (items >= 2) {
await page.locator('.note-list__item').nth(1).click({ timeout: 5000 })
await page.waitForTimeout(500)
}
if (items >= 3) {
await page.locator('.note-list__item').nth(2).click({ timeout: 5000 })
await page.waitForTimeout(500)
}
await page.screenshot({ path: 'test-results/tabs-screenshot.png', fullPage: true })
})
test('frontmatter hidden from editor view', async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(500)
// Click a note that has frontmatter
await page.click('.note-list__item')
await page.waitForTimeout(500)
// Frontmatter should be hidden — editor starts with content, not ---
await page.screenshot({ path: 'test-results/frontmatter-hidden.png', fullPage: true })
})
test('wikilinks: rendered as styled elements and clickable', async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(500)
// Open "Manage Sponsorships" which contains [[Matteo Cellini]] wikilink
await page.locator('.note-list__item', { hasText: 'Manage Sponsorships' }).click()
await page.waitForTimeout(500)
// Screenshot showing wikilink rendered as styled text
await page.screenshot({ path: 'test-results/wikilinks-styled.png', fullPage: true })
// Click the wikilink to navigate — use mouse.click to fire real mousedown
const wikilink = page.locator('.cm-wikilink', { hasText: 'Matteo Cellini' })
const box = await wikilink.boundingBox()
if (box) {
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2)
await page.waitForTimeout(500)
// Should now have a new tab for Matteo Cellini
await page.screenshot({ path: 'test-results/wikilinks-navigated.png', fullPage: true })
}
})

View file

@ -0,0 +1,51 @@
import { test, expect } from '@playwright/test'
test('settings shows connected GitHub state with username', async ({ page }) => {
await page.goto('http://localhost:5243/')
await page.waitForLoadState('networkidle')
await page.waitForTimeout(500)
// Open settings
await page.keyboard.press('Meta+Comma')
await page.waitForTimeout(500)
// Verify settings panel opened
await expect(page.getByTestId('settings-panel')).toBeVisible()
// Mock data starts with github connected — verify connected state
const connected = page.getByTestId('github-connected')
await expect(connected).toBeVisible({ timeout: 5000 })
await expect(connected).toContainText('lucaong')
await expect(connected).toContainText('Connected')
// Verify disconnect button
await expect(page.getByTestId('github-disconnect')).toBeVisible()
// Verify NO token input field
await expect(page.getByTestId('settings-key-github-token')).not.toBeVisible()
await page.screenshot({ path: 'test-results/settings-oauth-connected.png' })
})
test('disconnect shows Login with GitHub button', async ({ page }) => {
await page.goto('http://localhost:5243/')
await page.waitForLoadState('networkidle')
await page.waitForTimeout(500)
// Open settings
await page.keyboard.press('Meta+Comma')
await page.waitForTimeout(500)
// Click disconnect
const disconnectBtn = page.getByTestId('github-disconnect')
await expect(disconnectBtn).toBeVisible({ timeout: 5000 })
await disconnectBtn.click()
await page.waitForTimeout(300)
// Login button should now be visible
const loginBtn = page.getByTestId('github-login')
await expect(loginBtn).toBeVisible({ timeout: 3000 })
await expect(loginBtn).toContainText('Login with GitHub')
await page.screenshot({ path: 'test-results/settings-oauth-login.png' })
})

View file

@ -0,0 +1,90 @@
import { test, expect } from '@playwright/test'
// On macOS, Alt+key produces special characters, so we dispatch events directly
function dispatchAltKey(page: import('@playwright/test').Page, key: string) {
return page.evaluate((k) => {
window.dispatchEvent(new KeyboardEvent('keydown', {
key: k, altKey: true, metaKey: false, ctrlKey: false,
bubbles: true, cancelable: true,
}))
}, key)
}
async function loadApp(page: import('@playwright/test').Page) {
await page.goto('/')
// Clear stored view mode so we start fresh
await page.evaluate(() => localStorage.removeItem('laputa-view-mode'))
await page.reload()
await page.waitForTimeout(500)
}
test.describe('Sidebar collapse', () => {
test('default: all three panels visible', async ({ page }) => {
await loadApp(page)
await expect(page.locator('.app__sidebar')).toBeVisible()
await expect(page.locator('.app__note-list')).toBeVisible()
await expect(page.locator('.app__editor')).toBeVisible()
await page.screenshot({ path: 'test-results/collapse-all-panels.png', fullPage: true })
})
test('collapse button hides sidebar', async ({ page }) => {
await loadApp(page)
const collapseBtn = page.locator('button[aria-label="Collapse sidebar"]')
await expect(collapseBtn).toBeVisible()
await collapseBtn.click()
await page.waitForTimeout(500)
await expect(page.locator('.app__sidebar')).toHaveCount(0)
await expect(page.locator('.app__note-list')).toBeVisible()
await expect(page.locator('.app__editor')).toBeVisible()
await page.screenshot({ path: 'test-results/collapse-sidebar-hidden.png', fullPage: true })
})
test('Alt+1 shows editor only', async ({ page }) => {
await loadApp(page)
await dispatchAltKey(page, '1')
await page.waitForTimeout(500)
await expect(page.locator('.app__sidebar')).toHaveCount(0)
await expect(page.locator('.app__note-list')).toHaveCount(0)
await expect(page.locator('.app__editor')).toBeVisible()
await page.screenshot({ path: 'test-results/collapse-editor-only.png', fullPage: true })
})
test('Alt+2 shows editor + note list', async ({ page }) => {
await loadApp(page)
await dispatchAltKey(page, '2')
await page.waitForTimeout(500)
await expect(page.locator('.app__sidebar')).toHaveCount(0)
await expect(page.locator('.app__note-list')).toBeVisible()
await expect(page.locator('.app__editor')).toBeVisible()
await page.screenshot({ path: 'test-results/collapse-editor-list.png', fullPage: true })
})
test('Alt+3 restores all panels after collapse', async ({ page }) => {
await loadApp(page)
// Collapse first
await dispatchAltKey(page, '1')
await page.waitForTimeout(500)
await expect(page.locator('.app__sidebar')).toHaveCount(0)
// Restore
await dispatchAltKey(page, '3')
await page.waitForTimeout(500)
await expect(page.locator('.app__sidebar')).toBeVisible()
await expect(page.locator('.app__note-list')).toBeVisible()
await expect(page.locator('.app__editor')).toBeVisible()
await page.screenshot({ path: 'test-results/collapse-restored.png', fullPage: true })
})
})

View file

@ -0,0 +1,39 @@
import { test, expect } from '@playwright/test'
test.describe('Vault Picker Local Options', () => {
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:5240')
await page.waitForSelector('text=notes', { timeout: 10000 })
})
test('vault menu shows local folder options', async ({ page }) => {
// Screenshot before opening menu
await page.screenshot({ path: 'test-results/vault-picker-before.png', fullPage: true })
// Click the vault button in the status bar to open the menu
const vaultButton = page.locator('[title="Switch vault"]')
await expect(vaultButton).toBeVisible()
await vaultButton.click()
// Wait for menu to appear
await page.waitForTimeout(300)
// Verify all three options are visible
await expect(page.locator('text=Open local folder')).toBeVisible()
await expect(page.locator('text=Create new vault')).toBeVisible()
await expect(page.locator('text=Connect GitHub repo')).toBeVisible()
// Screenshot with menu open showing all options
await page.screenshot({ path: 'test-results/vault-picker-menu-open.png', fullPage: true })
})
test('vault menu options have correct test IDs', async ({ page }) => {
const vaultButton = page.locator('[title="Switch vault"]')
await vaultButton.click()
await page.waitForTimeout(200)
await expect(page.locator('[data-testid="vault-menu-open-local"]')).toBeVisible()
await expect(page.locator('[data-testid="vault-menu-create-new"]')).toBeVisible()
await expect(page.locator('[data-testid="vault-menu-connect-github"]')).toBeVisible()
})
})

View file

@ -0,0 +1,103 @@
import { test, expect } from '@playwright/test'
test('visual verify: editor theme + list indentation', async ({ page }) => {
const consoleErrors: string[] = []
page.on('console', msg => {
if (msg.type() === 'error') consoleErrors.push(msg.text())
})
await page.goto('http://localhost:5173')
await page.waitForTimeout(1000)
// Open "Write Weekly Essays" which has bullets, nested items, checkboxes
const noteItem = page.locator('.note-list__item', { hasText: 'Write Weekly Essays' })
await noteItem.click()
await page.waitForTimeout(1000)
const cmEditor = page.locator('.cm-editor')
await expect(cmEditor).toBeVisible()
// No console errors
expect(consoleErrors).toHaveLength(0)
// --- BUG 2: Verify list indentation ---
// Level-0 bullets should have 40px padding-left
const level0Lines = page.locator('.cm-line.cm-live-list-level-0')
const level0Count = await level0Lines.count()
console.log(`Level-0 bullet lines: ${level0Count}`)
expect(level0Count).toBeGreaterThan(0)
const paddingL0 = await level0Lines.first().evaluate(el =>
window.getComputedStyle(el).paddingLeft
)
console.log(`Level-0 padding-left: ${paddingL0}`)
expect(parseInt(paddingL0, 10)).toBe(40)
// Bullet widgets and checkboxes are rendered
const bulletCount = await page.locator('.cm-live-bullet').count()
console.log(`Bullet widgets: ${bulletCount}`)
expect(bulletCount).toBeGreaterThan(0)
const checkboxCount = await page.locator('.cm-live-checkbox').count()
console.log(`Checkbox widgets: ${checkboxCount}`)
expect(checkboxCount).toBeGreaterThan(0)
// Screenshot dark mode (top)
await page.screenshot({ path: 'test-results/01-dark-mode-editor.png', fullPage: true })
// Scroll down to see nested items section
const scroller = page.locator('.cm-scroller')
await scroller.evaluate(el => el.scrollTop = el.scrollHeight)
await page.waitForTimeout(300)
await page.screenshot({ path: 'test-results/01b-dark-mode-nested.png', fullPage: true })
// --- BUG 1: Verify theme toggle ---
// Dark mode: editor bg should be dark
const darkBg = await cmEditor.evaluate(el =>
window.getComputedStyle(el).backgroundColor
)
console.log(`Dark mode bg: ${darkBg}`)
expect(darkBg).toBe('rgb(15, 15, 26)')
// Toggle to light mode
const themeToggle = page.locator('.sidebar__theme-toggle')
await themeToggle.click()
await page.waitForTimeout(500)
// Scroll back to top for light mode screenshot
await scroller.evaluate(el => el.scrollTop = 0)
await page.waitForTimeout(200)
await page.screenshot({ path: 'test-results/02-light-mode-editor.png', fullPage: true })
// Light mode: editor bg should be white
const lightBg = await cmEditor.evaluate(el =>
window.getComputedStyle(el).backgroundColor
)
console.log(`Light mode bg: ${lightBg}`)
expect(lightBg).toBe('rgb(255, 255, 255)')
// Heading color should be dark in light mode
const headingColor = await page.locator('.cm-live-heading').first().evaluate(el =>
window.getComputedStyle(el).color
)
console.log(`Light mode heading color: ${headingColor}`)
expect(headingColor).toBe('rgb(55, 53, 47)')
// Scroll to nested items in light mode
await scroller.evaluate(el => el.scrollTop = el.scrollHeight)
await page.waitForTimeout(300)
await page.screenshot({ path: 'test-results/02b-light-mode-nested.png', fullPage: true })
// Toggle back to dark mode
await themeToggle.click()
await page.waitForTimeout(500)
await page.screenshot({ path: 'test-results/03-dark-mode-restored.png', fullPage: true })
const restoredBg = await cmEditor.evaluate(el =>
window.getComputedStyle(el).backgroundColor
)
console.log(`Restored dark mode bg: ${restoredBg}`)
expect(restoredBg).toBe('rgb(15, 15, 26)')
})

View file

@ -0,0 +1,77 @@
import { test, expect } from '@playwright/test'
test('closeup: heading marker in gutter', async ({ page }) => {
await page.goto('http://localhost:5173')
await page.waitForTimeout(800)
const noteItem = page.locator('.note-list__item', { hasText: 'Build Laputa App' })
await noteItem.click()
await page.waitForTimeout(800)
// Find "Overview" heading and take a tight crop around it
const heading = page.locator('.cm-header-2', { hasText: 'Overview' }).first()
await expect(heading).toBeVisible()
const hBox = await heading.boundingBox()
if (!hBox) throw new Error('heading not found')
// Crop: include gutter area to the left (extra 80px) and a small vertical band
const clip = {
x: Math.max(0, hBox.x - 80),
y: hBox.y - 10,
width: hBox.width + 120,
height: hBox.height + 20,
}
// Before click — preview mode, no marker visible
await page.screenshot({ path: 'test-results/closeup-heading-inactive.png', clip })
// Click on heading to activate
await page.mouse.click(hBox.x + hBox.width / 2, hBox.y + hBox.height / 2)
await page.waitForTimeout(500)
// After click — ## marker should appear in gutter
await page.screenshot({ path: 'test-results/closeup-heading-active.png', clip })
// Verify the marker is visible (opacity > 0)
const marker = page.locator('.cm-heading-line .cm-formatting-block.cm-formatting-block-visible').first()
if (await marker.count() > 0) {
const opacity = await marker.evaluate(el => window.getComputedStyle(el).opacity)
console.log(`Heading marker opacity when active: ${opacity}`)
expect(parseFloat(opacity)).toBeGreaterThan(0)
const mBox = await marker.boundingBox()
console.log(`Heading marker bounding box: ${JSON.stringify(mBox)}`)
// The marker should be to the LEFT of the heading text
if (mBox && hBox) {
console.log(`Marker right edge: ${mBox.x + mBox.width}, Heading left edge: ${hBox.x}`)
expect(mBox.x + mBox.width).toBeLessThanOrEqual(hBox.x + 5) // marker is left of heading
}
}
// Now check bullet closeup
const bulletLine = page.locator('.cm-line', { hasText: 'Four-panel layout working' }).first()
const bBox = await bulletLine.boundingBox()
if (!bBox) throw new Error('bullet not found')
// First click elsewhere to deactivate
await page.mouse.click(hBox.x + 10, hBox.y - 40)
await page.waitForTimeout(300)
const bulletClip = {
x: Math.max(0, bBox.x - 20),
y: bBox.y - 5,
width: Math.min(400, bBox.width + 40),
height: bBox.height + 60, // include next line too
}
// Inactive bullets
await page.screenshot({ path: 'test-results/closeup-bullet-inactive.png', clip: bulletClip })
// Click on bullet line
await page.mouse.click(bBox.x + bBox.width / 2, bBox.y + bBox.height / 2)
await page.waitForTimeout(500)
// Active bullet
await page.screenshot({ path: 'test-results/closeup-bullet-active.png', clip: bulletClip })
})

View file

@ -0,0 +1,83 @@
import { test, expect } from '@playwright/test'
test('zero-shift detail: focused editor screenshots', async ({ page }) => {
await page.goto('http://localhost:5173')
await page.waitForTimeout(800)
// Open "Build Laputa App" which has headings and bullets
const noteItem = page.locator('.note-list__item', { hasText: 'Build Laputa App' })
await noteItem.click()
await page.waitForTimeout(800)
const cmEditor = page.locator('.cm-editor')
await expect(cmEditor).toBeVisible()
// Screenshot just the editor content area
const editorBox = await cmEditor.boundingBox()
if (!editorBox) throw new Error('Editor not visible')
// Crop to top portion of editor where headings and bullets are
const clip = {
x: editorBox.x,
y: editorBox.y,
width: editorBox.width,
height: Math.min(editorBox.height, 500),
}
// 1. Initial preview state (cursor not on headings/bullets)
await page.screenshot({ path: 'test-results/detail-01-preview.png', clip })
// 2. Click on "Overview" heading
const headingSpan = page.locator('.cm-header-2', { hasText: 'Overview' }).first()
const hBox = await headingSpan.boundingBox()
if (hBox) {
await page.mouse.click(hBox.x + hBox.width / 2, hBox.y + hBox.height / 2)
await page.waitForTimeout(500)
}
await page.screenshot({ path: 'test-results/detail-02-heading-active.png', clip })
// 3. Click on a bullet line
const bulletLine = page.locator('.cm-line', { hasText: 'Four-panel layout working' }).first()
const bBox = await bulletLine.boundingBox()
if (bBox) {
await page.mouse.click(bBox.x + bBox.width / 2, bBox.y + bBox.height / 2)
await page.waitForTimeout(500)
}
await page.screenshot({ path: 'test-results/detail-03-bullet-active.png', clip })
// 4. Click on plain paragraph to go back to preview
const para = page.locator('.cm-line', { hasText: 'Custom desktop app' }).first()
const pBox = await para.boundingBox()
if (pBox) {
await page.mouse.click(pBox.x + 10, pBox.y + pBox.height / 2)
await page.waitForTimeout(500)
}
await page.screenshot({ path: 'test-results/detail-04-back-preview.png', clip })
// 5. Check that heading marker (.cm-formatting-block inside .cm-heading-line) is position:absolute
const headingMarkers = page.locator('.cm-heading-line .cm-formatting-block')
const markerCount = await headingMarkers.count()
console.log(`Heading markers found: ${markerCount}`)
if (markerCount > 0) {
const position = await headingMarkers.first().evaluate(el =>
window.getComputedStyle(el).position
)
console.log(`Heading marker position: ${position}`)
expect(position).toBe('absolute')
}
// 6. Check that non-heading markers always have font-size != 0.01em
const bulletMarkers = page.locator('.cm-line:not(.cm-heading-line) .cm-formatting-block')
const bmCount = await bulletMarkers.count()
console.log(`Non-heading block markers found: ${bmCount}`)
if (bmCount > 0) {
const fontSize = await bulletMarkers.first().evaluate(el =>
window.getComputedStyle(el).fontSize
)
console.log(`Bullet marker font-size: ${fontSize}`)
// Should NOT be tiny (library default is 0.01em ≈ 0.15px)
expect(parseFloat(fontSize)).toBeGreaterThan(10)
}
})

View file

@ -0,0 +1,98 @@
import { test, expect } from '@playwright/test'
test('zero horizontal shift: headings and bullets', async ({ page }) => {
await page.goto('http://localhost:5173')
await page.waitForTimeout(800)
// Open "Build Laputa App" which has headings and bullets
const noteItem = page.locator('.note-list__item', { hasText: 'Build Laputa App' })
await noteItem.click()
await page.waitForTimeout(800)
const cmEditor = page.locator('.cm-editor')
await expect(cmEditor).toBeVisible()
// Screenshot 1: initial state — cursor after frontmatter, headings/bullets in preview mode
await page.screenshot({ path: 'test-results/zero-shift-01-initial.png', fullPage: true })
// Find a heading line (## Overview) and measure its text position
const headingText = page.locator('.cm-header-2', { hasText: 'Overview' }).first()
await expect(headingText).toBeVisible()
// Get bounding box BEFORE clicking on it (inactive state)
const beforeBox = await headingText.boundingBox()
console.log('Heading "Overview" BEFORE click:', JSON.stringify(beforeBox))
// Screenshot 2: before clicking heading
await page.screenshot({ path: 'test-results/zero-shift-02-before-heading-click.png', fullPage: true })
// Click on the heading to activate it
if (beforeBox) {
await page.mouse.click(beforeBox.x + beforeBox.width / 2, beforeBox.y + beforeBox.height / 2)
}
await page.waitForTimeout(500)
// Screenshot 3: after clicking heading — ## should appear in gutter, text stays put
await page.screenshot({ path: 'test-results/zero-shift-03-after-heading-click.png', fullPage: true })
// Get bounding box AFTER clicking (active state)
const afterBox = await headingText.boundingBox()
console.log('Heading "Overview" AFTER click:', JSON.stringify(afterBox))
// CRITICAL: The heading text X position must not change
if (beforeBox && afterBox) {
const xShift = Math.abs(afterBox.x - beforeBox.x)
console.log(`Horizontal shift: ${xShift}px`)
expect(xShift).toBeLessThan(2) // Allow 1px tolerance for subpixel rendering
}
// Now test bullet lines: click on a bullet item
const bulletText = page.locator('.cm-line', { hasText: 'Four-panel layout working' }).first()
await expect(bulletText).toBeVisible()
const bulletBeforeBox = await bulletText.boundingBox()
console.log('Bullet line BEFORE click:', JSON.stringify(bulletBeforeBox))
// Screenshot 4: before clicking bullet
await page.screenshot({ path: 'test-results/zero-shift-04-before-bullet-click.png', fullPage: true })
// Click on the bullet line
if (bulletBeforeBox) {
await page.mouse.click(bulletBeforeBox.x + bulletBeforeBox.width / 2, bulletBeforeBox.y + bulletBeforeBox.height / 2)
}
await page.waitForTimeout(500)
// Screenshot 5: after clicking bullet
await page.screenshot({ path: 'test-results/zero-shift-05-after-bullet-click.png', fullPage: true })
const bulletAfterBox = await bulletText.boundingBox()
console.log('Bullet line AFTER click:', JSON.stringify(bulletAfterBox))
// CRITICAL: Bullet line X position must not change
if (bulletBeforeBox && bulletAfterBox) {
const xShift = Math.abs(bulletAfterBox.x - bulletBeforeBox.x)
console.log(`Bullet horizontal shift: ${xShift}px`)
expect(xShift).toBeLessThan(2)
}
// Screenshot 6: click somewhere else (a plain paragraph) to verify heading/bullets go back to preview
const paragraph = page.locator('.cm-line', { hasText: 'Custom desktop app' }).first()
if (await paragraph.isVisible()) {
const paraBox = await paragraph.boundingBox()
if (paraBox) {
await page.mouse.click(paraBox.x + 10, paraBox.y + paraBox.height / 2)
}
await page.waitForTimeout(500)
}
await page.screenshot({ path: 'test-results/zero-shift-06-back-to-preview.png', fullPage: true })
// Verify heading underline is removed (FIX 1)
const headingLine = page.locator('.cm-heading-line').first()
if (await headingLine.count() > 0) {
const borderBottom = await headingLine.evaluate(el =>
window.getComputedStyle(el).borderBottom
)
console.log(`Heading line border-bottom: ${borderBottom}`)
expect(borderBottom).toContain('none')
}
})