Compare commits
7 Commits
v0.2026033
...
v0.2026033
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c29206da3b | ||
|
|
6764fd04a1 | ||
|
|
59ed6897c1 | ||
|
|
9b59c269d8 | ||
|
|
ff1f166ca6 | ||
|
|
289ab82ed1 | ||
|
|
94da70ba30 |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.83
|
||||
AVERAGE_THRESHOLD=9.38
|
||||
HOTSPOT_THRESHOLD=9.85
|
||||
AVERAGE_THRESHOLD=9.39
|
||||
|
||||
92
CLAUDE.md
92
CLAUDE.md
@@ -6,47 +6,27 @@
|
||||
|
||||
## 1. Task Workflow
|
||||
|
||||
This is how you pick up and complete a task. Follow this order every time.
|
||||
|
||||
### 1a. Pick up a task
|
||||
|
||||
Run `/laputa-next-task` — it fetches the next task from Todoist (To Rework first, then Open), moves it to In Progress, and returns the full description.
|
||||
Run `/laputa-next-task` — fetches next task (To Rework first, then Open), moves to In Progress, returns full description.
|
||||
|
||||
When starting a task:
|
||||
- Read the task description and comments fully
|
||||
- Read task description and all comments fully
|
||||
- For To Rework: the ❌ QA failed comment tells you exactly what to fix
|
||||
- Check `docs/adr/` for relevant architecture decisions before making structural choices
|
||||
- **Add a comment** when you move the task to In Progress:
|
||||
```
|
||||
🚀 Starting work on this task. [Brief description of approach]
|
||||
```
|
||||
- Check `docs/adr/` for relevant architecture decisions before structural choices
|
||||
- Add a comment: `🚀 Starting work on this task. [Brief description of approach]`
|
||||
|
||||
### 1b. Implement
|
||||
|
||||
- Work on `main` branch — **no branches, no PRs, ever**
|
||||
- Commit every 20–30 min: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`
|
||||
- **⛔ NEVER use --no-verify**
|
||||
- For UI tasks: open `ui-design.pen` first, study the visual language, design in light mode
|
||||
- For UI tasks: open `ui-design.pen` first, study visual language, design in light mode
|
||||
|
||||
### 1c. When done — three mandatory steps
|
||||
### 1c. When done
|
||||
|
||||
**Step 1: Move task to In### 1c. When done
|
||||
**Phase 1 — Playwright (only for core user flows):**
|
||||
|
||||
After Phase 1 (Playwright) and Phase 2 (native QA) both pass, run:
|
||||
|
||||
```
|
||||
/laputa-done <task_id>
|
||||
```
|
||||
|
||||
This moves the task to In Review, notifies Brian, and self-dispatches the next task automatically.
|
||||
|
||||
nal (steps 1–3 above).
|
||||
|
||||
**Phase 1 — Playwright (you, only when needed):**
|
||||
|
||||
Write a smoke test in `tests/smoke/<slug>.spec.ts` only if the feature touches a **core user flow**: vault open, note create/save/delete, search, wikilink navigation, git commit/push, conflict resolution. Do NOT write Playwright tests for cosmetic/UI-only changes (padding, chip size, label text, color, border) — use Vitest instead.
|
||||
|
||||
The full Playwright suite must stay under **10 minutes**. If your new test would push it over, remove an existing non-core test first.
|
||||
Write smoke test in `tests/smoke/<slug>.spec.ts` only if feature touches: vault open, note create/save/delete, search, wikilink navigation, git commit/push, conflict resolution. Do NOT write Playwright tests for cosmetic changes — use Vitest instead. Suite must stay under **10 minutes**.
|
||||
|
||||
```bash
|
||||
pnpm dev --port 5201 &
|
||||
@@ -54,28 +34,18 @@ sleep 3
|
||||
BASE_URL="http://localhost:5201" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
```
|
||||
|
||||
**Phase 2 — Native app QA (also you):**
|
||||
|
||||
Run the app in dev mode and test natively — no need to build a release DMG:
|
||||
**Phase 2 — Native app QA:**
|
||||
|
||||
```bash
|
||||
pnpm tauri dev &
|
||||
sleep 10 # wait for app to start
|
||||
```
|
||||
|
||||
Then use the QA scripts:
|
||||
```bash
|
||||
sleep 10
|
||||
bash ~/.openclaw/skills/laputa-qa/scripts/focus-app.sh laputa
|
||||
bash ~/.openclaw/skills/laputa-qa/scripts/screenshot.sh /tmp/qa-native.png
|
||||
# Analyze screenshot with image tool — verify the feature looks correct
|
||||
bash ~/.openclaw/skills/laputa-qa/scripts/shortcut.sh "command" "s"
|
||||
```
|
||||
|
||||
Use `osascript` for keyboard interactions. Write the result as a Todoist comment on the task:
|
||||
- ✅ if native QA passes (describe what you tested and saw)
|
||||
- ❌ if it fails (describe what's wrong) — fix and repeat from Phase 1
|
||||
Use `osascript` for keyboard interactions. Write result as Todoist comment (✅ or ❌). **⚠️ WKWebView:** `osascript keystroke` blocked inside editor — rely on Playwright for text input features.
|
||||
|
||||
**⚠️ WKWebView limitation:** `osascript keystroke` is blocked inside the editor for text input. For features requiring text input: verify app launches + stability, then rely on Playwright for correctness.
|
||||
After both phases pass, run `/laputa-done <task_id>` → moves to In Review, notifies Brian, self-dispatches next task.
|
||||
|
||||
---
|
||||
|
||||
@@ -84,31 +54,24 @@ Use `osascript` for keyboard interactions. Write the result as a Todoist comment
|
||||
### Commits & pushes
|
||||
|
||||
- Push directly to `main` — no PRs, no branches
|
||||
- The pre-push hook runs the full check suite (build + tests + Playwright + CodeScene)
|
||||
- Pre-push hook runs full check suite (build + tests + Playwright + CodeScene)
|
||||
- **⛔ NEVER use --no-verify**
|
||||
- Commit message format: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`
|
||||
|
||||
### TDD (mandatory)
|
||||
|
||||
Red → Green → Refactor → Commit. One cycle per commit.
|
||||
For bugs: write a failing regression test first, then fix.
|
||||
Exception: pure CSS/layout changes with no logic.
|
||||
Red → Green → Refactor → Commit. One cycle per commit. For bugs: write failing regression test first, then fix. Exception: pure CSS/layout changes.
|
||||
|
||||
**Test quality (Kent Beck's Desiderata):** Isolated · Deterministic · Fast · Behavioral · Structure-insensitive · Specific · Predictive. Fix flaky tests before adding new ones. Prefer E2E over unit tests for user flows.
|
||||
|
||||
### Code health (mandatory)
|
||||
|
||||
Pre-commit and pre-push hooks enforce:
|
||||
- **Hotspot Code Health** ≥ threshold in `.codescene-thresholds`
|
||||
- **Average Code Health** ≥ threshold in `.codescene-thresholds`
|
||||
Pre-commit and pre-push hooks enforce **Hotspot Code Health** and **Average Code Health** ≥ thresholds in `.codescene-thresholds`. Both gates block commit/push. Thresholds are a **ratchet** — only go up, auto-updated after each successful push. Never add `// eslint-disable`, `#[allow(...)]`, or `as any`.
|
||||
|
||||
Both gates block commit/push. Thresholds are a **ratchet** — they only go up, auto-updated after each successful push. Never add `// eslint-disable`, `#[allow(...)]`, or `as any` to pass them.
|
||||
**Before every commit:**
|
||||
- `mcp__codescene__code_health_review` — check file before touching
|
||||
- `mcp__codescene__code_health_score` — verify score is higher after changes
|
||||
|
||||
**Before every commit:** run checks via MCP CodeScene:
|
||||
- `mcp__codescene__code_health_review` — check file before touching it
|
||||
- `mcp__codescene__code_health_score` — verify score is higher after your changes
|
||||
|
||||
**Boy Scout Rule:** every file you touch must leave with a higher score than it had. If Average drops below 9.0, fix regressions before pushing.
|
||||
**Boy Scout Rule:** every file you touch must leave with a higher score. If Average drops below 9.0, fix regressions before pushing.
|
||||
|
||||
### Check suite (runs on every push)
|
||||
```bash
|
||||
@@ -121,19 +84,13 @@ cargo llvm-cov --manifest-path src-tauri/Cargo.toml --no-clean --fail-under-line
|
||||
|
||||
### Architecture Decision Records (ADRs)
|
||||
|
||||
ADRs live in `docs/adr/`. Check them before making structural choices.
|
||||
ADRs live in `docs/adr/`. Check before structural choices. Create the ADR **in the same commit as the code**. Never edit existing ADRs — create a new one that supersedes. Use `/create-adr` for template.
|
||||
|
||||
**When to create one:** storage strategy, new dependency, platform support, core abstraction change, cross-cutting concern. Use `/create-adr` for the template.
|
||||
|
||||
**Timing:** create the ADR **in the same commit as the code** — never before, never after.
|
||||
|
||||
**Superseding:** never edit an existing ADR — create a new one that supersedes it.
|
||||
|
||||
**Don't create ADRs for:** bug fixes, UI styling, refactors, test additions.
|
||||
**When to create one:** new dependency, storage strategy, platform target, core abstraction change, cross-cutting pattern. **Not for:** bug fixes, UI styling, refactors, test additions.
|
||||
|
||||
### Keep docs/ in sync
|
||||
|
||||
After adding a Tauri command, new component/hook, data model change, or new integration: update `docs/ARCHITECTURE.md`, `docs/ABSTRACTIONS.md`, and/or `docs/GETTING-STARTED.md` in the same commit.
|
||||
After Tauri command, new component/hook, data model change, or new integration: update `docs/ARCHITECTURE.md`, `docs/ABSTRACTIONS.md`, and/or `docs/GETTING-STARTED.md` in the same commit.
|
||||
|
||||
---
|
||||
|
||||
@@ -141,10 +98,7 @@ After adding a Tauri command, new component/hook, data model change, or new inte
|
||||
|
||||
### User vault (`~/Laputa/`)
|
||||
|
||||
You may use `~/Laputa/` for testing when the demo vault isn't sufficient (e.g. verifying against real git history). But:
|
||||
- **Never commit changes to `~/Laputa/`** — discard them before finishing
|
||||
- After any testing that touched the vault, run: `cd ~/Laputa && git checkout -- . && git clean -fd`
|
||||
- Default to `demo-vault-v2/` whenever possible
|
||||
Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing: **never commit changes** — always run `cd ~/Laputa && git checkout -- . && git clean -fd` when done.
|
||||
|
||||
### UI design
|
||||
|
||||
|
||||
30
docs/adr/0031-full-app-for-note-windows.md
Normal file
30
docs/adr/0031-full-app-for-note-windows.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0031"
|
||||
title: "Full App instance for secondary note windows"
|
||||
status: active
|
||||
date: 2026-03-31
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Laputa supports opening a note in a secondary window ("Open in New Window"). The original implementation used a dedicated `NoteWindow` component — a thin shell that rendered only the editor, duplicating some App-level logic (vault loading, settings, keyboard shortcuts) in a simplified but diverging form. As the main App gained features (properties editing, zoom, command palette, keyboard shortcuts), the `NoteWindow` shell fell behind, requiring ongoing maintenance to keep parity.
|
||||
|
||||
## Decision
|
||||
|
||||
**Remove `NoteWindow` and render the full `App` component in secondary note windows.** The window type is detected at startup via URL query parameters (`?window=note&path=...&vault=...`). When in note-window mode, the App initialises with panels hidden (sidebar collapsed, inspector collapsed) and auto-opens the target note once vault entries load. The window title is kept in sync with the active note title via the Tauri window API.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Keep `NoteWindow` shell** (status quo): lower initial bundle weight per window, but divergence grows with every main-App feature. Rejected — maintenance cost dominates.
|
||||
- **Full `App` instance with URL-param mode** (chosen): complete feature parity for free; single code path for all window types. Trade-off: slightly heavier startup for secondary windows (full vault load), acceptable given local filesystem speed.
|
||||
- **IPC-driven secondary window (no vault reload)**: secondary window subscribes to primary window's vault state via Tauri events. Maximum efficiency, avoids double vault reads. Deferred — requires significant IPC plumbing; can be layered on top later without changing the rendering model.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Removes ~163 lines (`NoteWindow.tsx` deleted entirely)
|
||||
- Secondary note windows get full feature parity: all keyboard shortcuts, properties panel, zoom, command palette, diff mode, raw editor
|
||||
- `useLayoutPanels` gains an `initialInspectorCollapsed` option to support the hidden-panel initial state
|
||||
- A new `src/utils/windowMode.ts` utility encapsulates URL-param detection — single source of truth for window-type logic
|
||||
- Vault is loaded independently in each note window (no shared state with the main window); writes go to the same filesystem so eventual consistency is maintained via file-watching
|
||||
- Triggers re-evaluation if: multiple simultaneous note windows cause measurable vault-read contention, or if IPC-driven shared-state windows become a product requirement
|
||||
29
docs/adr/0032-status-bar-for-git-actions.md
Normal file
29
docs/adr/0032-status-bar-for-git-actions.md
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0032"
|
||||
title: "Git actions (Changes, Pulse, Commit) in status bar, not sidebar"
|
||||
status: active
|
||||
date: 2026-03-31
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The Laputa sidebar originally surfaced git-related affordances — a "Changes" nav item (visible when modified files > 0), a "Pulse" nav item, and a "Commit & Push" button — alongside the note-type navigation filters and sections. This mixed two concerns in the sidebar: **navigation** (where to go) and **git status / actions** (what changed, what to do). As the sidebar grew, the git items created visual noise and made the nav hierarchy harder to scan.
|
||||
|
||||
## Decision
|
||||
|
||||
**Move Changes, Pulse, and Commit & Push out of the sidebar and into the bottom status bar.** The status bar shows a GitDiff icon with an orange count badge for modified files; a Pulse icon sits next to it. Commit & Push is accessible via an icon button beside the Changes indicator. The sidebar now contains only navigation items (filters and type sections).
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Keep git items in sidebar** (status quo): familiar placement, visible at all times. Rejected — mixes navigation and action concerns; sidebar becomes harder to scan.
|
||||
- **Status bar** (chosen): consistent with app conventions (build number, sync status, vault switcher already live there); persistent but unobtrusive; follows macOS app patterns where status/action items live at window bottom.
|
||||
- **Toolbar / breadcrumb bar**: would require a new chrome layer or polluting the per-note breadcrumb with global git state. Rejected.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Sidebar props `modifiedCount`, `onCommitPush`, `isGitVault` removed; sidebar renders navigation-only
|
||||
- `StatusBar` gains `onClickPending`, `onClickPulse`, `onCommitPush`, `isGitVault` props
|
||||
- Sidebar tests for Changes/Pulse/Commit button removed; StatusBar tests extended
|
||||
- Users find Commit & Push in the status bar (same location as sync indicators) rather than bottom of sidebar — small discoverability change, offset by status bar being always visible regardless of sidebar collapsed state
|
||||
- Triggers re-evaluation if: user research shows git actions are hard to discover in the status bar
|
||||
@@ -86,3 +86,5 @@ proposed → active → superseded
|
||||
| [0028](0028-cli-agent-only-no-api-key.md) | CLI agent only — no direct Anthropic API key | active |
|
||||
| [0029](0029-domain-command-builder-pattern.md) | Domain command builder pattern for useCommandRegistry | active |
|
||||
| [0030](0030-rust-commands-module-split.md) | Rust commands/ module split by domain | active |
|
||||
| [0031](0031-full-app-for-note-windows.md) | Full App instance for secondary note windows | active |
|
||||
| [0032](0032-status-bar-for-git-actions.md) | Git actions (Changes, Pulse, Commit) in status bar, not sidebar | active |
|
||||
|
||||
@@ -64,9 +64,9 @@ export function UrlValue({
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="group/url inline-flex min-w-0 items-center gap-1">
|
||||
<span className="group/url flex min-w-0 max-w-full items-center gap-1">
|
||||
<span
|
||||
className="min-w-0 cursor-pointer truncate rounded px-1 py-0.5 text-right text-[12px] text-[var(--accent-blue)] underline decoration-[var(--accent-blue)]/40 transition-colors hover:decoration-[var(--accent-blue)]"
|
||||
className="min-w-0 cursor-pointer truncate rounded-md px-2 py-1 text-right text-[12px] text-[var(--accent-blue)] underline decoration-[var(--accent-blue)]/40 transition-colors hover:decoration-[var(--accent-blue)]"
|
||||
onClick={handleOpen}
|
||||
title={value}
|
||||
data-testid="url-link"
|
||||
@@ -125,7 +125,7 @@ export function EditableValue({
|
||||
|
||||
return (
|
||||
<span
|
||||
className="min-w-0 cursor-pointer truncate rounded px-1 py-0.5 text-right text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
|
||||
className="min-w-0 max-w-full cursor-pointer truncate rounded-md px-2 py-1 text-right text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
|
||||
onClick={onStartEdit}
|
||||
title={value || 'Click to edit'}
|
||||
>
|
||||
@@ -216,7 +216,7 @@ export function TagPillList({
|
||||
style={{
|
||||
...getTagStyle(item),
|
||||
backgroundColor: getTagStyle(item).bg,
|
||||
padding: '2px 8px',
|
||||
padding: '4px 8px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
@@ -256,7 +256,7 @@ export function TagPillList({
|
||||
) : (
|
||||
<button
|
||||
className="inline-flex items-center justify-center rounded-md border-none bg-muted px-2 text-[12px] font-medium leading-none text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
style={{ padding: '2px 8px' }}
|
||||
style={{ padding: '4px 8px' }}
|
||||
onClick={() => setIsAddingNew(true)}
|
||||
title={`Add ${label.toLowerCase()}`}
|
||||
>
|
||||
|
||||
@@ -84,7 +84,7 @@ function TagsValue({ propKey, value, isEditing, vaultTags, onSave, onStartEdit }
|
||||
<span
|
||||
key={tag}
|
||||
className="group/tag relative inline-flex items-center overflow-hidden rounded-md"
|
||||
style={{ backgroundColor: style.bg, padding: '2px 8px', maxWidth: 120 }}
|
||||
style={{ backgroundColor: style.bg, padding: '4px 8px', maxWidth: 120 }}
|
||||
>
|
||||
<span
|
||||
className="transition-[max-width] duration-150 group-hover/tag:[mask-image:linear-gradient(to_right,black_60%,transparent_100%)]"
|
||||
@@ -111,7 +111,7 @@ function TagsValue({ propKey, value, isEditing, vaultTags, onSave, onStartEdit }
|
||||
})}
|
||||
<button
|
||||
className="inline-flex shrink-0 items-center justify-center rounded-md border-none bg-muted text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
style={{ padding: '2px 8px' }}
|
||||
style={{ padding: '4px 8px' }}
|
||||
onClick={() => onStartEdit(propKey)}
|
||||
title="Add tag"
|
||||
data-testid="tags-add-button"
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
const EDITOR_CONTAINER = '.editor__blocknote-container'
|
||||
|
||||
test.describe('Clickable editor empty space — click below content focuses editor', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
// Open the first note to mount the editor
|
||||
const noteList = page.locator('[data-testid="note-list-container"]')
|
||||
await noteList.waitFor({ timeout: 5_000 })
|
||||
await noteList.locator('.cursor-pointer').first().click()
|
||||
await page.waitForTimeout(500)
|
||||
await page.waitForSelector(EDITOR_CONTAINER, { timeout: 5_000 })
|
||||
})
|
||||
|
||||
test('container onClick handler focuses the editor', async ({ page }) => {
|
||||
// Dispatch a click directly on the container element (simulating empty space click)
|
||||
// This is equivalent to a user clicking on the container background, which happens
|
||||
// when the editor content is shorter than the container.
|
||||
const focused = await page.evaluate((sel) => {
|
||||
const container = document.querySelector(sel)
|
||||
if (!container) return 'no-container'
|
||||
container.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
|
||||
const active = document.activeElement
|
||||
if (!active) return 'no-active'
|
||||
return container.contains(active) ? 'focused' : `active-is-${active.tagName}`
|
||||
}, EDITOR_CONTAINER)
|
||||
expect(focused).toBe('focused')
|
||||
})
|
||||
|
||||
test('editor container has cursor:text CSS for visual affordance', async ({ page }) => {
|
||||
// Verify the cursor:text rule is in the stylesheet
|
||||
const hasCursorText = await page.evaluate(() => {
|
||||
for (const sheet of document.styleSheets) {
|
||||
try {
|
||||
for (const rule of sheet.cssRules) {
|
||||
if (rule instanceof CSSStyleRule &&
|
||||
rule.selectorText?.includes('editor__blocknote-container') &&
|
||||
rule.style.cursor === 'text') {
|
||||
return true
|
||||
}
|
||||
}
|
||||
} catch { /* cross-origin sheets throw */ }
|
||||
}
|
||||
return false
|
||||
})
|
||||
expect(hasCursorText).toBe(true)
|
||||
})
|
||||
|
||||
test('clicking on actual editor content does not disrupt normal editing', async ({ page }) => {
|
||||
const editor = page.locator('.bn-editor').first()
|
||||
await editor.waitFor({ timeout: 5_000 })
|
||||
|
||||
await editor.click()
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
const editorHasFocus = await page.evaluate(() => {
|
||||
const active = document.activeElement
|
||||
if (!active) return false
|
||||
const container = document.querySelector('.editor__blocknote-container')
|
||||
return container?.contains(active) ?? false
|
||||
})
|
||||
expect(editorHasFocus).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,66 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Emoji icon shown everywhere title appears', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.waitForTimeout(2500)
|
||||
})
|
||||
|
||||
test('emoji icon appears in editor and note list after setting it', async ({ page }) => {
|
||||
// Open a note
|
||||
const noteItem = page.locator('[data-testid="note-list-container"] .cursor-pointer').first()
|
||||
await noteItem.waitFor({ timeout: 5000 })
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Remove any existing icon first for a clean state
|
||||
const iconDisplay = page.locator('[data-testid="note-icon-display"]')
|
||||
if (await iconDisplay.isVisible()) {
|
||||
await iconDisplay.click()
|
||||
await page.locator('[data-testid="note-icon-remove"]').click()
|
||||
await page.waitForTimeout(300)
|
||||
}
|
||||
|
||||
// Set an emoji icon
|
||||
const iconArea = page.locator('[data-testid="note-icon-area"]')
|
||||
await iconArea.hover()
|
||||
await page.locator('[data-testid="note-icon-add"]').click()
|
||||
const firstEmoji = page.locator('[data-testid="emoji-option"]').first()
|
||||
const emojiText = await firstEmoji.textContent()
|
||||
expect(emojiText).toBeTruthy()
|
||||
await firstEmoji.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Verify emoji in note list item
|
||||
const noteListText = await noteItem.textContent()
|
||||
expect(noteListText).toContain(emojiText!)
|
||||
|
||||
// Verify emoji appears in the editor NoteIcon area
|
||||
// Wait for frontmatter update to propagate through the single-note reload cycle
|
||||
const iconAfterSet = page.locator('[data-testid="note-icon-display"]')
|
||||
await expect(iconAfterSet).toBeVisible({ timeout: 8000 })
|
||||
await expect(iconAfterSet).toHaveText(emojiText!, { timeout: 3000 })
|
||||
})
|
||||
|
||||
test('note without emoji shows no emoji span in tab or note list', async ({ page }) => {
|
||||
// Open a note
|
||||
const noteItem = page.locator('[data-testid="note-list-container"] .cursor-pointer').first()
|
||||
await noteItem.waitFor({ timeout: 5000 })
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Remove icon if present
|
||||
const iconDisplay = page.locator('[data-testid="note-icon-display"]')
|
||||
if (await iconDisplay.isVisible()) {
|
||||
await iconDisplay.click()
|
||||
await page.locator('[data-testid="note-icon-remove"]').click()
|
||||
await page.waitForTimeout(300)
|
||||
}
|
||||
|
||||
// The note list item title row should not contain an emoji span (mr-1)
|
||||
const titleRow = noteItem.locator('.truncate.text-foreground').first()
|
||||
const emojiSpans = titleRow.locator('.mr-1')
|
||||
await expect(emojiSpans).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
@@ -1,40 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Filter pills height matches breadcrumb bar', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('filter pills row height equals breadcrumb bar height (45px)', async ({ page }) => {
|
||||
// Click on the "Projects" type section in the sidebar
|
||||
await page.getByText('Projects', { exact: true }).click()
|
||||
|
||||
// Filter pills should now be visible
|
||||
const pills = page.getByTestId('filter-pills')
|
||||
await expect(pills).toBeVisible()
|
||||
|
||||
// Verify height matches breadcrumb bar (45px)
|
||||
const pillsBox = await pills.boundingBox()
|
||||
expect(pillsBox).not.toBeNull()
|
||||
expect(pillsBox!.height).toBe(45)
|
||||
|
||||
// Verify all three pills are present
|
||||
await expect(page.getByTestId('filter-pill-open')).toBeVisible()
|
||||
await expect(page.getByTestId('filter-pill-archived')).toBeVisible()
|
||||
await expect(page.getByTestId('filter-pill-trashed')).toBeVisible()
|
||||
|
||||
// Open pill should be active by default
|
||||
const openPill = page.getByTestId('filter-pill-open')
|
||||
await expect(openPill).toHaveAttribute('aria-selected', 'true')
|
||||
|
||||
// Verify pills are keyboard accessible (Tab + Enter)
|
||||
await openPill.focus()
|
||||
await expect(openPill).toBeFocused()
|
||||
await page.keyboard.press('Tab')
|
||||
const archivedPill = page.getByTestId('filter-pill-archived')
|
||||
await expect(archivedPill).toBeFocused()
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(archivedPill).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
})
|
||||
@@ -1,114 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
const DROP_OVERLAY = '.editor__drop-overlay'
|
||||
const EDITOR_CONTAINER = '.editor__blocknote-container'
|
||||
|
||||
test.describe('Image drop overlay — internal drag does not trigger overlay', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
// Open the first note to mount the editor
|
||||
const noteList = page.locator('[data-testid="note-list-container"]')
|
||||
await noteList.waitFor({ timeout: 5_000 })
|
||||
await noteList.locator('.cursor-pointer').first().click()
|
||||
await page.waitForTimeout(300)
|
||||
await page.waitForSelector(EDITOR_CONTAINER, { timeout: 5_000 })
|
||||
})
|
||||
|
||||
test('internal drag (no image files) does not show the overlay', async ({ page }) => {
|
||||
// Simulate an internal drag (e.g. block or tab) — dataTransfer has no files
|
||||
await page.locator(EDITOR_CONTAINER).first().dispatchEvent('dragover', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
|
||||
// The overlay should NOT appear for non-file drags
|
||||
await expect(page.locator(DROP_OVERLAY)).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('dragover with image file shows the overlay', async ({ page }) => {
|
||||
// Simulate an OS file drag with an image file in dataTransfer
|
||||
// Playwright dispatchEvent can't set dataTransfer items directly,
|
||||
// so we use page.evaluate to dispatch a proper DragEvent with file items
|
||||
await page.evaluate((selector) => {
|
||||
const el = document.querySelector(selector)
|
||||
if (!el) throw new Error('Editor container not found')
|
||||
|
||||
const dt = new DataTransfer()
|
||||
dt.items.add(new File(['fake'], 'photo.png', { type: 'image/png' }))
|
||||
|
||||
const event = new DragEvent('dragover', {
|
||||
dataTransfer: dt,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
el.dispatchEvent(event)
|
||||
}, EDITOR_CONTAINER)
|
||||
|
||||
await expect(page.locator(DROP_OVERLAY)).toBeVisible()
|
||||
await expect(page.locator(DROP_OVERLAY)).toContainText('Drop image here')
|
||||
})
|
||||
|
||||
test('dragleave after image dragover hides the overlay', async ({ page }) => {
|
||||
// First show the overlay via image dragover
|
||||
await page.evaluate((selector) => {
|
||||
const el = document.querySelector(selector)
|
||||
if (!el) throw new Error('Editor container not found')
|
||||
|
||||
const dt = new DataTransfer()
|
||||
dt.items.add(new File(['fake'], 'photo.png', { type: 'image/png' }))
|
||||
el.dispatchEvent(new DragEvent('dragover', {
|
||||
dataTransfer: dt,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}))
|
||||
}, EDITOR_CONTAINER)
|
||||
|
||||
await expect(page.locator(DROP_OVERLAY)).toBeVisible()
|
||||
|
||||
// Now simulate dragleave (cursor left the container)
|
||||
await page.evaluate((selector) => {
|
||||
const el = document.querySelector(selector)
|
||||
if (!el) throw new Error('Editor container not found')
|
||||
|
||||
el.dispatchEvent(new DragEvent('dragleave', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
relatedTarget: document.body,
|
||||
}))
|
||||
}, EDITOR_CONTAINER)
|
||||
|
||||
await expect(page.locator(DROP_OVERLAY)).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('drop resets the overlay', async ({ page }) => {
|
||||
// Show overlay via image dragover
|
||||
await page.evaluate((selector) => {
|
||||
const el = document.querySelector(selector)
|
||||
if (!el) throw new Error('Editor container not found')
|
||||
|
||||
const dt = new DataTransfer()
|
||||
dt.items.add(new File(['fake'], 'photo.png', { type: 'image/png' }))
|
||||
el.dispatchEvent(new DragEvent('dragover', {
|
||||
dataTransfer: dt,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}))
|
||||
}, EDITOR_CONTAINER)
|
||||
|
||||
await expect(page.locator(DROP_OVERLAY)).toBeVisible()
|
||||
|
||||
// Simulate drop
|
||||
await page.evaluate((selector) => {
|
||||
const el = document.querySelector(selector)
|
||||
if (!el) throw new Error('Editor container not found')
|
||||
|
||||
el.dispatchEvent(new DragEvent('drop', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}))
|
||||
}, EDITOR_CONTAINER)
|
||||
|
||||
await expect(page.locator(DROP_OVERLAY)).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,62 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Flat vault migration', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('title field appears above editor when note is open', async ({ page }) => {
|
||||
// Click a note in the sidebar to open it
|
||||
const noteItem = page.locator('[data-testid="note-list-item"]').first()
|
||||
if (await noteItem.isVisible()) {
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(300)
|
||||
// The TitleField should be visible
|
||||
const titleField = page.locator('[data-testid="title-field"]')
|
||||
await expect(titleField).toBeVisible()
|
||||
// The input should have the note's title
|
||||
const input = page.locator('[data-testid="title-field-input"]')
|
||||
const value = await input.inputValue()
|
||||
expect(value.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
test('title field is editable and shows filename on change', async ({ page }) => {
|
||||
// Open a note
|
||||
const noteItem = page.locator('[data-testid="note-list-item"]').first()
|
||||
if (await noteItem.isVisible()) {
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(300)
|
||||
const input = page.locator('[data-testid="title-field-input"]')
|
||||
await input.focus()
|
||||
// Should show filename indicator when focused
|
||||
const filenameIndicator = page.locator('[data-testid="title-field-filename"]')
|
||||
await expect(filenameIndicator).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('no migration banner when vault is already flat (mock)', async ({ page }) => {
|
||||
// In browser mode (mock), vault should be flat and no migration banner
|
||||
const banner = page.locator('[data-testid="migration-banner"]')
|
||||
await page.waitForTimeout(1000)
|
||||
await expect(banner).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('H1 heading is hidden in editor (CSS rule active)', async ({ page }) => {
|
||||
// Check that the CSS rule for hiding H1 is present in the document
|
||||
const styles = await page.evaluate(() => {
|
||||
for (const sheet of document.styleSheets) {
|
||||
try {
|
||||
for (const rule of sheet.cssRules) {
|
||||
if (rule.cssText?.includes('data-content-type="heading"') && rule.cssText?.includes('display: none')) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
} catch { /* cross-origin */ }
|
||||
}
|
||||
return false
|
||||
})
|
||||
expect(styles).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,136 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { openCommandPalette, executeCommand } from './helpers'
|
||||
|
||||
test.describe('Note icon emoji picker — full emoji set, search, continuous scroll', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
// Open the first note to mount the editor
|
||||
const noteList = page.locator('[data-testid="note-list-container"]')
|
||||
await noteList.waitFor({ timeout: 5_000 })
|
||||
await noteList.locator('.cursor-pointer').first().click()
|
||||
await page.waitForTimeout(500)
|
||||
await page.waitForSelector('[data-testid="note-icon-area"]', { timeout: 5_000 })
|
||||
})
|
||||
|
||||
test('add-icon button opens emoji picker', async ({ page }) => {
|
||||
// Hover the note-icon-area to reveal the Add icon button
|
||||
await page.locator('[data-testid="note-icon-area"]').hover()
|
||||
const addBtn = page.locator('[data-testid="note-icon-add"]')
|
||||
await addBtn.waitFor({ timeout: 3_000 })
|
||||
await addBtn.click()
|
||||
await expect(page.locator('[data-testid="emoji-picker"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test('emoji picker contains full emoji set (1800+)', async ({ page }) => {
|
||||
await page.locator('[data-testid="note-icon-area"]').hover()
|
||||
await page.locator('[data-testid="note-icon-add"]').click()
|
||||
const emojiCount = await page.locator('[data-testid="emoji-option"]').count()
|
||||
expect(emojiCount).toBeGreaterThan(1800)
|
||||
})
|
||||
|
||||
test('emoji search by English name works (rocket → 🚀)', async ({ page }) => {
|
||||
await page.locator('[data-testid="note-icon-area"]').hover()
|
||||
await page.locator('[data-testid="note-icon-add"]').click()
|
||||
const searchInput = page.locator('[data-testid="emoji-picker-search"]')
|
||||
await searchInput.fill('rocket')
|
||||
await page.waitForTimeout(100)
|
||||
const results = page.locator('[data-testid="emoji-option"]')
|
||||
const count = await results.count()
|
||||
expect(count).toBeGreaterThan(0)
|
||||
// Verify 🚀 is in the results
|
||||
const texts = await results.allTextContents()
|
||||
expect(texts).toContain('🚀')
|
||||
})
|
||||
|
||||
test('emoji search for "heart" returns results', async ({ page }) => {
|
||||
await page.locator('[data-testid="note-icon-area"]').hover()
|
||||
await page.locator('[data-testid="note-icon-add"]').click()
|
||||
await page.locator('[data-testid="emoji-picker-search"]').fill('heart')
|
||||
await page.waitForTimeout(100)
|
||||
const results = page.locator('[data-testid="emoji-option"]')
|
||||
const count = await results.count()
|
||||
expect(count).toBeGreaterThan(5)
|
||||
})
|
||||
|
||||
test('emoji search for "fire" finds 🔥', async ({ page }) => {
|
||||
await page.locator('[data-testid="note-icon-area"]').hover()
|
||||
await page.locator('[data-testid="note-icon-add"]').click()
|
||||
await page.locator('[data-testid="emoji-picker-search"]').fill('fire')
|
||||
await page.waitForTimeout(100)
|
||||
const texts = await page.locator('[data-testid="emoji-option"]').allTextContents()
|
||||
expect(texts).toContain('🔥')
|
||||
})
|
||||
|
||||
test('all emojis visible in continuous scroll (no category lock)', async ({ page }) => {
|
||||
await page.locator('[data-testid="note-icon-area"]').hover()
|
||||
await page.locator('[data-testid="note-icon-add"]').click()
|
||||
const grid = page.locator('[data-testid="emoji-picker-grid"]')
|
||||
// Verify the grid is scrollable (content height > container height)
|
||||
const isScrollable = await grid.evaluate(el => el.scrollHeight > el.clientHeight)
|
||||
expect(isScrollable).toBe(true)
|
||||
// All emojis should be in the DOM (continuous scroll, not category-locked)
|
||||
const emojiCount = await page.locator('[data-testid="emoji-option"]').count()
|
||||
expect(emojiCount).toBeGreaterThan(1800)
|
||||
})
|
||||
|
||||
test('selecting an emoji saves it and shows it in editor', async ({ page }) => {
|
||||
await page.locator('[data-testid="note-icon-area"]').hover()
|
||||
await page.locator('[data-testid="note-icon-add"]').click()
|
||||
// Pick the first emoji
|
||||
const firstEmoji = page.locator('[data-testid="emoji-option"]').first()
|
||||
const emojiText = await firstEmoji.textContent()
|
||||
await firstEmoji.click()
|
||||
// Picker should close and emoji should be displayed
|
||||
await expect(page.locator('[data-testid="emoji-picker"]')).not.toBeVisible()
|
||||
const display = page.locator('[data-testid="note-icon-display"]')
|
||||
await expect(display).toBeVisible()
|
||||
await expect(display).toHaveText(emojiText!)
|
||||
})
|
||||
|
||||
test('clicking set emoji shows change/remove menu', async ({ page }) => {
|
||||
// First set an emoji
|
||||
await page.locator('[data-testid="note-icon-area"]').hover()
|
||||
await page.locator('[data-testid="note-icon-add"]').click()
|
||||
await page.locator('[data-testid="emoji-option"]').first().click()
|
||||
// Now click the displayed emoji to open menu
|
||||
await page.locator('[data-testid="note-icon-display"]').click()
|
||||
await expect(page.locator('[data-testid="note-icon-menu"]')).toBeVisible()
|
||||
await expect(page.locator('[data-testid="note-icon-change"]')).toBeVisible()
|
||||
await expect(page.locator('[data-testid="note-icon-remove"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test('remove emoji removes it from display', async ({ page }) => {
|
||||
// Set an emoji first
|
||||
await page.locator('[data-testid="note-icon-area"]').hover()
|
||||
await page.locator('[data-testid="note-icon-add"]').click()
|
||||
await page.locator('[data-testid="emoji-option"]').first().click()
|
||||
await expect(page.locator('[data-testid="note-icon-display"]')).toBeVisible()
|
||||
// Remove it
|
||||
await page.locator('[data-testid="note-icon-display"]').click()
|
||||
await page.locator('[data-testid="note-icon-remove"]').click()
|
||||
await expect(page.locator('[data-testid="note-icon-display"]')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('command palette "Set Note Icon" opens picker', async ({ page }) => {
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Set Note Icon')
|
||||
await expect(page.locator('[data-testid="emoji-picker"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Escape closes the emoji picker', async ({ page }) => {
|
||||
await page.locator('[data-testid="note-icon-area"]').hover()
|
||||
await page.locator('[data-testid="note-icon-add"]').click()
|
||||
await expect(page.locator('[data-testid="emoji-picker"]')).toBeVisible()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.locator('[data-testid="emoji-picker"]')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('no results shows empty state message', async ({ page }) => {
|
||||
await page.locator('[data-testid="note-icon-area"]').hover()
|
||||
await page.locator('[data-testid="note-icon-add"]').click()
|
||||
await page.locator('[data-testid="emoji-picker-search"]').fill('xyzzyplugh')
|
||||
await page.waitForTimeout(100)
|
||||
await expect(page.getByText('No emojis found')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,165 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { openCommandPalette, findCommand, executeCommand, sendShortcut } from './helpers'
|
||||
|
||||
test.describe('Note icon emoji picker', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.waitForTimeout(2500)
|
||||
})
|
||||
|
||||
test('emoji picker opens from Add Icon button and selects emoji', async ({ page }) => {
|
||||
// Open a note by clicking the first item in the note list
|
||||
const noteItem = page.locator('[data-testid="note-list-container"] .cursor-pointer').first()
|
||||
await noteItem.waitFor({ timeout: 5000 })
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// The note icon area should be visible
|
||||
const iconArea = page.locator('[data-testid="note-icon-area"]')
|
||||
await expect(iconArea).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Hover to reveal the "Add icon" button and click it
|
||||
await iconArea.hover()
|
||||
const addButton = page.locator('[data-testid="note-icon-add"]')
|
||||
await expect(addButton).toBeVisible()
|
||||
await addButton.click()
|
||||
|
||||
// Emoji picker should appear
|
||||
const picker = page.locator('[data-testid="emoji-picker"]')
|
||||
await expect(picker).toBeVisible({ timeout: 2000 })
|
||||
|
||||
// Click the first emoji
|
||||
const emojiOption = picker.locator('[data-testid="emoji-option"]').first()
|
||||
await emojiOption.click()
|
||||
|
||||
// Picker should close
|
||||
await expect(picker).not.toBeVisible()
|
||||
|
||||
// The icon should now be displayed
|
||||
const iconDisplay = page.locator('[data-testid="note-icon-display"]')
|
||||
await expect(iconDisplay).toBeVisible({ timeout: 2000 })
|
||||
})
|
||||
|
||||
test('emoji icon can be removed via menu', async ({ page }) => {
|
||||
// Open a note
|
||||
const noteItem = page.locator('[data-testid="note-list-container"] .cursor-pointer').first()
|
||||
await noteItem.waitFor({ timeout: 5000 })
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Add an icon first
|
||||
const iconArea = page.locator('[data-testid="note-icon-area"]')
|
||||
await iconArea.hover()
|
||||
await page.locator('[data-testid="note-icon-add"]').click()
|
||||
await page.locator('[data-testid="emoji-option"]').first().click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Click the displayed icon to show the menu
|
||||
const iconDisplay = page.locator('[data-testid="note-icon-display"]')
|
||||
await iconDisplay.click()
|
||||
|
||||
// Menu should show Remove option
|
||||
const removeButton = page.locator('[data-testid="note-icon-remove"]')
|
||||
await expect(removeButton).toBeVisible()
|
||||
await removeButton.click()
|
||||
|
||||
// Icon should be removed, add button returns on hover
|
||||
await expect(iconDisplay).not.toBeVisible()
|
||||
await iconArea.hover()
|
||||
await expect(page.locator('[data-testid="note-icon-add"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Cmd+K shows Set Note Icon command', async ({ page }) => {
|
||||
// Open a note first
|
||||
const noteItem = page.locator('[data-testid="note-list-container"] .cursor-pointer').first()
|
||||
await noteItem.waitFor({ timeout: 5000 })
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Open command palette and search for the icon command
|
||||
await openCommandPalette(page)
|
||||
const found = await findCommand(page, 'Set Note Icon')
|
||||
expect(found).toBe(true)
|
||||
})
|
||||
|
||||
test('Set Note Icon command opens emoji picker', async ({ page }) => {
|
||||
// Open a note first
|
||||
const noteItem = page.locator('[data-testid="note-list-container"] .cursor-pointer').first()
|
||||
await noteItem.waitFor({ timeout: 5000 })
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Use command palette to open emoji picker
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Set Note Icon')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Emoji picker should be visible
|
||||
const picker = page.locator('[data-testid="emoji-picker"]')
|
||||
await expect(picker).toBeVisible({ timeout: 2000 })
|
||||
|
||||
// Select an emoji
|
||||
await picker.locator('[data-testid="emoji-option"]').first().click()
|
||||
|
||||
// Icon should be displayed
|
||||
const iconDisplay = page.locator('[data-testid="note-icon-display"]')
|
||||
await expect(iconDisplay).toBeVisible({ timeout: 2000 })
|
||||
})
|
||||
|
||||
test('Escape closes the emoji picker', async ({ page }) => {
|
||||
// Open a note
|
||||
const noteItem = page.locator('[data-testid="note-list-container"] .cursor-pointer').first()
|
||||
await noteItem.waitFor({ timeout: 5000 })
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Open picker
|
||||
const iconArea = page.locator('[data-testid="note-icon-area"]')
|
||||
await iconArea.hover()
|
||||
await page.locator('[data-testid="note-icon-add"]').click()
|
||||
|
||||
const picker = page.locator('[data-testid="emoji-picker"]')
|
||||
await expect(picker).toBeVisible()
|
||||
|
||||
// Press Escape to close
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(picker).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('emoji icon is shown in Quick Open (Cmd+P) results', async ({ page }) => {
|
||||
// Open a note and set an icon
|
||||
const noteItem = page.locator('[data-testid="note-list-container"] .cursor-pointer').first()
|
||||
await noteItem.waitFor({ timeout: 5000 })
|
||||
// Get the note title for later search
|
||||
const noteTitle = await noteItem.locator('.font-medium, .text-sm').first().textContent()
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Set an icon
|
||||
const iconArea = page.locator('[data-testid="note-icon-area"]')
|
||||
await iconArea.hover()
|
||||
await page.locator('[data-testid="note-icon-add"]').click()
|
||||
const firstEmoji = page.locator('[data-testid="emoji-option"]').first()
|
||||
const emojiText = await firstEmoji.textContent()
|
||||
await firstEmoji.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Open Quick Open and search for the note
|
||||
await page.locator('body').click()
|
||||
await sendShortcut(page, 'p', ['Control'])
|
||||
const searchInput = page.locator('input[placeholder="Search notes..."]')
|
||||
await expect(searchInput).toBeVisible()
|
||||
|
||||
if (noteTitle && emojiText) {
|
||||
await searchInput.fill(noteTitle.trim().substring(0, 10))
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Verify the emoji appears in the search results
|
||||
const results = page.locator('.py-1 .cursor-pointer .truncate')
|
||||
const resultTexts = await results.allTextContents()
|
||||
const hasEmoji = resultTexts.some(t => t.includes(emojiText))
|
||||
expect(hasEmoji).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,103 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { sendShortcut } from './helpers'
|
||||
|
||||
test.describe('Note list shows complete relationships when opening from sidebar', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 900 })
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('entity view shows relationship groups with correct counts after sidebar click', async ({ page }) => {
|
||||
// Navigate to Responsibilities type to find entity notes in the sidebar
|
||||
// First, click on a sidebar type that contains entity notes
|
||||
// We'll use the sidebar search to filter to "Sponsorships"
|
||||
const searchToggle = page.locator('[data-testid="search-toggle"]')
|
||||
if (await searchToggle.isVisible()) {
|
||||
await searchToggle.click()
|
||||
}
|
||||
|
||||
// Type in sidebar search to find Sponsorships
|
||||
const sidebarSearch = page.locator('[data-testid="note-list-search"]')
|
||||
if (await sidebarSearch.isVisible()) {
|
||||
await sidebarSearch.fill('Sponsorships')
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
// Click the Sponsorships note in the note list to trigger entity view
|
||||
const sponsorshipsItem = page.locator('[data-entry-path*="sponsorships"]').first()
|
||||
if (await sponsorshipsItem.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await sponsorshipsItem.click()
|
||||
await page.waitForTimeout(1000)
|
||||
} else {
|
||||
// Fallback: open via quick open — then click in sidebar
|
||||
await page.locator('body').click()
|
||||
await sendShortcut(page, 'p', ['Control'])
|
||||
const searchInput = page.locator('input[placeholder="Search notes..."]')
|
||||
await expect(searchInput).toBeVisible()
|
||||
await searchInput.fill('Sponsorships')
|
||||
await page.waitForTimeout(500)
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForTimeout(1000)
|
||||
}
|
||||
|
||||
// The inspector panel (right side) should show relationship labels with font-mono-overline
|
||||
// This verifies the note loaded and relationships were parsed correctly
|
||||
const measuresLabel = page.locator('span.font-mono-overline').filter({ hasText: 'Has Measures' })
|
||||
await expect(measuresLabel).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const proceduresLabel = page.locator('span.font-mono-overline').filter({ hasText: 'Has Procedures' })
|
||||
await expect(proceduresLabel).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Verify the relationships panel shows the correct number of items
|
||||
// Has Measures should have 2 entries (measure-sponsorship-mrr, measure-close-rate)
|
||||
const measuresSection = measuresLabel.locator('xpath=ancestor::div[1]')
|
||||
const measureItems = measuresSection.locator('a, button').filter({ hasText: /measure|Measure/ })
|
||||
// At least 1 resolved relationship entry
|
||||
const hasItems = await measureItems.count()
|
||||
expect(hasItems).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('relationship labels persist after navigating away and back', async ({ page }) => {
|
||||
// Open Sponsorships via quick open
|
||||
await page.locator('body').click()
|
||||
await sendShortcut(page, 'p', ['Control'])
|
||||
const searchInput = page.locator('input[placeholder="Search notes..."]')
|
||||
await expect(searchInput).toBeVisible()
|
||||
await searchInput.fill('Sponsorships')
|
||||
await page.waitForTimeout(500)
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Verify Has Measures relationship appears in inspector
|
||||
const measuresLabel = page.locator('span.font-mono-overline').filter({ hasText: 'Has Measures' })
|
||||
await expect(measuresLabel).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Navigate to different note
|
||||
await page.locator('body').click()
|
||||
await sendShortcut(page, 'p', ['Control'])
|
||||
const searchInput2 = page.locator('input[placeholder="Search notes..."]')
|
||||
await expect(searchInput2).toBeVisible()
|
||||
await searchInput2.fill('Start Laputa App')
|
||||
await page.waitForTimeout(500)
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Navigate back to Sponsorships
|
||||
await page.locator('body').click()
|
||||
await sendShortcut(page, 'p', ['Control'])
|
||||
const searchInput3 = page.locator('input[placeholder="Search notes..."]')
|
||||
await expect(searchInput3).toBeVisible()
|
||||
await searchInput3.fill('Sponsorships')
|
||||
await page.waitForTimeout(500)
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Relationships should still be visible — not stale or incomplete
|
||||
const measuresLabelAgain = page.locator('span.font-mono-overline').filter({ hasText: 'Has Measures' })
|
||||
await expect(measuresLabelAgain).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const proceduresLabelAgain = page.locator('span.font-mono-overline').filter({ hasText: 'Has Procedures' })
|
||||
await expect(proceduresLabelAgain).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
@@ -1,40 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Note list preview snippet', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('snippet does not contain raw markdown formatting', async ({ page }) => {
|
||||
const noteListContainer = page.locator('[data-testid="note-list-container"]')
|
||||
await expect(noteListContainer).toBeVisible()
|
||||
|
||||
const snippets = noteListContainer.locator('[data-testid="note-snippet"]')
|
||||
const count = await snippets.count()
|
||||
|
||||
for (let i = 0; i < Math.min(count, 8); i++) {
|
||||
const text = await snippets.nth(i).textContent()
|
||||
if (text && text.length > 10) {
|
||||
expect(text).not.toMatch(/\*\*[^*]+\*\*/)
|
||||
expect(text).not.toContain('```')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('snippet does not start with list markers', async ({ page }) => {
|
||||
const noteListContainer = page.locator('[data-testid="note-list-container"]')
|
||||
await expect(noteListContainer).toBeVisible()
|
||||
|
||||
const snippets = noteListContainer.locator('[data-testid="note-snippet"]')
|
||||
const count = await snippets.count()
|
||||
|
||||
for (let i = 0; i < Math.min(count, 10); i++) {
|
||||
const text = await snippets.nth(i).textContent()
|
||||
if (text && text.length > 15) {
|
||||
expect(text.trimStart()).not.toMatch(/^[*\-+] /)
|
||||
expect(text.trimStart()).not.toMatch(/^\d+\. /)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,41 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { openCommandPalette, findCommand } from './helpers'
|
||||
|
||||
test.describe('Open in New Window', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('"Open in New Window" command appears in palette when note is active', async ({ page }) => {
|
||||
// Open a note first (click first item in note list)
|
||||
const firstNote = page.locator('.cursor-pointer.border-b').first()
|
||||
await expect(firstNote).toBeVisible({ timeout: 3_000 })
|
||||
await firstNote.click()
|
||||
|
||||
// Wait for editor to load
|
||||
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 3_000 })
|
||||
|
||||
// Open command palette and search
|
||||
await openCommandPalette(page)
|
||||
const found = await findCommand(page, 'Open in New Window')
|
||||
expect(found).toBe(true)
|
||||
})
|
||||
|
||||
test('"Open in New Window" shows shortcut hint', async ({ page }) => {
|
||||
// Open a note
|
||||
const firstNote = page.locator('.cursor-pointer.border-b').first()
|
||||
await expect(firstNote).toBeVisible({ timeout: 3_000 })
|
||||
await firstNote.click()
|
||||
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 3_000 })
|
||||
|
||||
// Open command palette and search for the command
|
||||
await openCommandPalette(page)
|
||||
const input = page.locator('input[placeholder="Type a command..."]')
|
||||
await input.fill('Open in New Window')
|
||||
|
||||
// The shortcut hint should be visible
|
||||
const commandRow = page.locator('text=Open in New Window').first()
|
||||
await expect(commandRow).toBeVisible({ timeout: 2_000 })
|
||||
})
|
||||
})
|
||||
@@ -1,131 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { sendShortcut } from './helpers'
|
||||
|
||||
async function openNoteViaQuickOpen(page: import('@playwright/test').Page, query: string) {
|
||||
await page.locator('body').click()
|
||||
await sendShortcut(page, 'p', ['Control'])
|
||||
const searchInput = page.locator('input[placeholder="Search notes..."]')
|
||||
await expect(searchInput).toBeVisible()
|
||||
await searchInput.fill(query)
|
||||
await page.waitForTimeout(500)
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
test.describe('Properties panel visual style', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 900 })
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('labels are sentence case, not ALL CAPS', async ({ page }) => {
|
||||
await openNoteViaQuickOpen(page, 'Untitled note 58')
|
||||
|
||||
const propertyRows = page.locator('[data-testid="editable-property"]')
|
||||
await expect(propertyRows.first()).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Labels should be sentence case (e.g. "Status"), not ALL CAPS
|
||||
const statusLabel = propertyRows.locator('span.truncate').filter({ hasText: 'Status' })
|
||||
await expect(statusLabel).toBeVisible()
|
||||
|
||||
// Verify no ALL CAPS label exists
|
||||
const allLabels = propertyRows.locator('span.truncate')
|
||||
const count = await allLabels.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
const text = await allLabels.nth(i).textContent()
|
||||
if (text && text.length > 1) {
|
||||
expect(text).not.toEqual(text.toUpperCase())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('status renders as colored badge with dot indicator', async ({ page }) => {
|
||||
await openNoteViaQuickOpen(page, 'Untitled note 58')
|
||||
|
||||
const statusBadge = page.locator('[data-testid="status-badge"]')
|
||||
await expect(statusBadge).toBeVisible({ timeout: 5000 })
|
||||
await expect(statusBadge).toHaveText('Active')
|
||||
|
||||
// Should have a dot indicator (small circle span inside)
|
||||
const dot = statusBadge.locator('span.rounded-full')
|
||||
await expect(dot).toBeVisible()
|
||||
})
|
||||
|
||||
test('date renders as chip with background', async ({ page }) => {
|
||||
await openNoteViaQuickOpen(page, 'Untitled note 58')
|
||||
|
||||
const dateDisplay = page.locator('[data-testid="date-display"]')
|
||||
// Date may or may not exist in the note — if it exists, verify chip styling
|
||||
const count = await dateDisplay.count()
|
||||
if (count > 0) {
|
||||
await expect(dateDisplay.first()).toHaveClass(/bg-muted/)
|
||||
}
|
||||
})
|
||||
|
||||
test('boolean renders as checkbox', async ({ page }) => {
|
||||
await openNoteViaQuickOpen(page, 'Untitled note 58')
|
||||
|
||||
const booleanToggle = page.locator('[data-testid="boolean-toggle"]')
|
||||
const count = await booleanToggle.count()
|
||||
if (count > 0) {
|
||||
// Should contain an actual checkbox input
|
||||
const checkbox = booleanToggle.first().locator('input[type="checkbox"]')
|
||||
await expect(checkbox).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('no horizontal overflow from property values', async ({ page }) => {
|
||||
await openNoteViaQuickOpen(page, 'Untitled note 58')
|
||||
|
||||
const propertyRows = page.locator('[data-testid="editable-property"]')
|
||||
await expect(propertyRows.first()).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Each row should not overflow its container
|
||||
const count = await propertyRows.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
const row = propertyRows.nth(i)
|
||||
const box = await row.boundingBox()
|
||||
if (!box) continue
|
||||
// Inspector panel is ~300px wide; rows shouldn't exceed parent
|
||||
expect(box.width).toBeLessThan(500)
|
||||
}
|
||||
})
|
||||
|
||||
test('Type label uses same font-size as other property labels', async ({ page }) => {
|
||||
await openNoteViaQuickOpen(page, 'Untitled note 58')
|
||||
|
||||
const typeSelector = page.locator('[data-testid="type-selector"]')
|
||||
const typeCount = await typeSelector.count()
|
||||
if (typeCount > 0) {
|
||||
const typeLabel = typeSelector.locator('span').first()
|
||||
const typeFontSize = await typeLabel.evaluate(el => getComputedStyle(el).fontSize)
|
||||
// Should be 12px, matching other property labels
|
||||
expect(typeFontSize).toBe('12px')
|
||||
}
|
||||
})
|
||||
|
||||
test('tags add button is solid pill, not dashed circle', async ({ page }) => {
|
||||
await openNoteViaQuickOpen(page, 'Untitled note 58')
|
||||
|
||||
const tagsAddBtn = page.locator('[data-testid="tags-add-button"]')
|
||||
const count = await tagsAddBtn.count()
|
||||
if (count > 0) {
|
||||
// Should NOT have dashed border
|
||||
const borderStyle = await tagsAddBtn.first().evaluate(el => getComputedStyle(el).borderStyle)
|
||||
expect(borderStyle).not.toBe('dashed')
|
||||
// Should have rounded-md (not rounded-full circle)
|
||||
await expect(tagsAddBtn.first()).toHaveClass(/rounded-md/)
|
||||
}
|
||||
})
|
||||
|
||||
test('add property button is subtle', async ({ page }) => {
|
||||
await openNoteViaQuickOpen(page, 'Untitled note 58')
|
||||
|
||||
const addBtn = page.getByText('Add property')
|
||||
await expect(addBtn).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Should have reduced opacity (subtle appearance)
|
||||
await expect(addBtn).toHaveCSS('opacity', '0.5')
|
||||
})
|
||||
})
|
||||
@@ -1,47 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { sendShortcut } from './helpers'
|
||||
|
||||
async function openNoteViaQuickOpen(page: import('@playwright/test').Page, query: string) {
|
||||
await page.locator('body').click()
|
||||
await sendShortcut(page, 'p', ['Control'])
|
||||
const searchInput = page.locator('input[placeholder="Search notes..."]')
|
||||
await expect(searchInput).toBeVisible()
|
||||
await searchInput.fill(query)
|
||||
await page.waitForTimeout(500)
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
test.describe('Type instances in inspector', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 900 })
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('shows Instances section when viewing a Type note', async ({ page }) => {
|
||||
await openNoteViaQuickOpen(page, 'Project')
|
||||
|
||||
// The inspector starts open by default. Look for the "Properties" header.
|
||||
// If it's collapsed, click the SlidersHorizontal icon to expand.
|
||||
const propertiesHeader = page.getByText('Properties', { exact: true })
|
||||
if (!(await propertiesHeader.isVisible())) {
|
||||
// Inspector might be collapsed — click the toggle button
|
||||
const toggleBtn = page.locator('button').filter({ has: page.locator('svg') }).last()
|
||||
await toggleBtn.click()
|
||||
await page.waitForTimeout(300)
|
||||
}
|
||||
|
||||
// The Instances section should be visible with a count
|
||||
const instancesLabel = page.getByText(/Instances \(\d+\)/)
|
||||
await expect(instancesLabel).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('does not show Instances section for non-Type note', async ({ page }) => {
|
||||
await openNoteViaQuickOpen(page, 'Build Laputa App')
|
||||
|
||||
// No Instances section should appear for a non-Type note
|
||||
const instancesLabel = page.getByText(/Instances \(\d+\)/)
|
||||
await expect(instancesLabel).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,66 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('NoteList split refactor – no behavior changes', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('note list container renders and shows notes', async ({ page }) => {
|
||||
const container = page.locator('[data-testid="note-list-container"]')
|
||||
await expect(container).toBeVisible()
|
||||
|
||||
// Notes should render inside the container
|
||||
const noteItems = container.locator('.cursor-pointer')
|
||||
await expect(noteItems.first()).toBeVisible({ timeout: 5000 })
|
||||
const count = await noteItems.count()
|
||||
expect(count).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('header with title and action buttons renders', async ({ page }) => {
|
||||
// The header area sits above the note-list-container: h3 title + action buttons
|
||||
const header = page.locator('h3.truncate')
|
||||
await expect(header).toBeVisible()
|
||||
const title = await header.textContent()
|
||||
expect(title && title.length > 0).toBe(true)
|
||||
|
||||
// Search button (magnifying glass) should be present
|
||||
const searchBtn = page.locator('button[title="Search notes"]')
|
||||
await expect(searchBtn).toBeVisible()
|
||||
|
||||
// Create note button should be present (when not in trash view)
|
||||
const createBtn = page.locator('button[title="Create new note"]')
|
||||
await expect(createBtn).toBeVisible()
|
||||
})
|
||||
|
||||
test('search toggle shows and hides search input', async ({ page }) => {
|
||||
const searchBtn = page.locator('button[title="Search notes"]')
|
||||
await searchBtn.click()
|
||||
|
||||
const searchInput = page.locator('input[placeholder="Search notes..."]')
|
||||
await expect(searchInput).toBeVisible()
|
||||
|
||||
// Toggle off
|
||||
await searchBtn.click()
|
||||
await expect(searchInput).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('clicking a note opens it in the editor', async ({ page }) => {
|
||||
const container = page.locator('[data-testid="note-list-container"]')
|
||||
await expect(container).toBeVisible()
|
||||
|
||||
const noteItems = container.locator('.cursor-pointer')
|
||||
await expect(noteItems.first()).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Get the title of the first note
|
||||
const noteTitle = await noteItems.first().locator('.font-medium, .font-semibold, .font-bold').first().textContent()
|
||||
|
||||
// Click the first note
|
||||
await noteItems.first().click()
|
||||
|
||||
// After click, the editor area or tab bar should reflect the opened note
|
||||
// Verify the note list container is still functional (no crash from refactor)
|
||||
await expect(container).toBeVisible()
|
||||
expect(noteTitle && noteTitle.length > 0).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,56 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('split-usenoteactions: note creation and rename still work', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/api/vault/ping', route => route.fulfill({ status: 503 }))
|
||||
await page.goto('/')
|
||||
await page.waitForTimeout(500)
|
||||
})
|
||||
|
||||
test('Cmd+N creates a new untitled note visible in the sidebar', async ({ page }) => {
|
||||
await page.keyboard.press('Meta+n')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// The new note should appear in the note list sidebar
|
||||
const noteList = page.locator('.app__note-list')
|
||||
const untitledItem = noteList.locator('text=/untitled/i').first()
|
||||
await expect(untitledItem).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('creating multiple notes generates unique names in the sidebar', async ({ page }) => {
|
||||
await page.keyboard.press('Meta+n')
|
||||
await page.waitForTimeout(200)
|
||||
await page.keyboard.press('Meta+n')
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
// Both notes should be visible in the sidebar with different names
|
||||
const noteList = page.locator('.app__note-list')
|
||||
const untitledItems = noteList.locator('text=/untitled/i')
|
||||
await expect(untitledItems.first()).toBeVisible({ timeout: 3000 })
|
||||
const count = await untitledItems.count()
|
||||
expect(count).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test('selecting a note opens it without errors', async ({ page }) => {
|
||||
const noteItem = page.locator('.app__note-list .cursor-pointer').first()
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Editor area should be visible (no crash from the refactored hooks)
|
||||
const editor = page.locator('.app__editor')
|
||||
await expect(editor).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('frontmatter property update shows toast', async ({ page }) => {
|
||||
// Select an existing note
|
||||
const noteItem = page.locator('.app__note-list .cursor-pointer').first()
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Inspector panel renders without errors from frontmatterOps extraction
|
||||
const inspector = page.getByTestId('inspector')
|
||||
if (await inspector.isVisible()) {
|
||||
await expect(inspector).toBeVisible()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,34 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { openCommandPalette, findCommand, executeCommand } from './helpers'
|
||||
|
||||
test.describe('Three source of truth', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('Reload Vault command appears in Cmd+K palette', async ({ page }) => {
|
||||
await openCommandPalette(page)
|
||||
const found = await findCommand(page, 'reload')
|
||||
expect(found).toBe(true)
|
||||
})
|
||||
|
||||
test('Reload Vault command is executable', async ({ page }) => {
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'reload vault')
|
||||
// After execution, palette should close and app should still render
|
||||
await expect(page.locator('input[placeholder="Type a command..."]')).not.toBeVisible()
|
||||
// Sidebar should still be visible after reload
|
||||
await expect(page.locator('[data-testid="sidebar"], nav, aside').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Reload Vault findable by keyword "rescan"', async ({ page }) => {
|
||||
await openCommandPalette(page)
|
||||
// Type a keyword synonym — the command should appear as selected result
|
||||
await page.locator('input[placeholder="Type a command..."]').fill('rescan')
|
||||
const match = page.locator('[data-selected="true"]').first()
|
||||
await match.waitFor({ timeout: 2_000 })
|
||||
const text = await match.textContent()
|
||||
expect(text?.toLowerCase()).toContain('reload')
|
||||
})
|
||||
})
|
||||
@@ -1,110 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Title H1 with inline emoji', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Open a note so TitleField is visible
|
||||
const noteItem = page.locator('.app__note-list .cursor-pointer').first()
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(500)
|
||||
})
|
||||
|
||||
test('title-section__row is a horizontal flex container', async ({ page }) => {
|
||||
const row = page.locator('.title-section__row')
|
||||
await expect(row).toBeVisible({ timeout: 3000 })
|
||||
|
||||
const display = await row.evaluate(el => getComputedStyle(el).display)
|
||||
const flexDir = await row.evaluate(el => getComputedStyle(el).flexDirection)
|
||||
expect(display).toBe('flex')
|
||||
expect(flexDir).toBe('row')
|
||||
})
|
||||
|
||||
test('title field renders with large H1 font size', async ({ page }) => {
|
||||
const input = page.locator('.title-field__input')
|
||||
await expect(input).toBeVisible({ timeout: 3000 })
|
||||
|
||||
const fontSize = await input.evaluate(el => {
|
||||
const computed = getComputedStyle(el)
|
||||
return parseFloat(computed.fontSize)
|
||||
})
|
||||
// Title must be at least 24px (H1 style, significantly larger than body ~16px)
|
||||
expect(fontSize).toBeGreaterThanOrEqual(24)
|
||||
})
|
||||
|
||||
test('title field renders with bold font weight', async ({ page }) => {
|
||||
const input = page.locator('.title-field__input')
|
||||
await expect(input).toBeVisible({ timeout: 3000 })
|
||||
|
||||
const fontWeight = await input.evaluate(el => {
|
||||
const computed = getComputedStyle(el)
|
||||
return parseInt(computed.fontWeight, 10)
|
||||
})
|
||||
// Font weight 700+ (bold or heavier)
|
||||
expect(fontWeight).toBeGreaterThanOrEqual(700)
|
||||
})
|
||||
|
||||
test('no reserved space for emoji when note has no icon', async ({ page }) => {
|
||||
const titleRow = page.locator('.title-section__row')
|
||||
await expect(titleRow).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// When no emoji is set, icon-display should not be in the row
|
||||
const iconInRow = titleRow.locator('[data-testid="note-icon-display"]')
|
||||
await expect(iconInRow).toHaveCount(0)
|
||||
|
||||
// Title should be the first (and only significant) child in the row
|
||||
const titleInput = titleRow.locator('[data-testid="title-field-input"]')
|
||||
await expect(titleInput).toBeVisible()
|
||||
})
|
||||
|
||||
test('emoji icon and title are on the same horizontal line when icon present', async ({ page }) => {
|
||||
// Hover over the title section to reveal the "Add icon" button
|
||||
const titleSection = page.locator('.title-section')
|
||||
await titleSection.hover()
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
const addBtn = page.locator('[data-testid="note-icon-add"]')
|
||||
if (await addBtn.isVisible()) {
|
||||
await addBtn.click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Pick the first emoji in the picker
|
||||
const emojiBtn = page.locator('.emoji-picker-grid button').first()
|
||||
if (await emojiBtn.isVisible()) {
|
||||
await emojiBtn.click()
|
||||
await page.waitForTimeout(300)
|
||||
}
|
||||
}
|
||||
|
||||
const iconDisplay = page.locator('[data-testid="note-icon-display"]')
|
||||
const titleInput = page.locator('[data-testid="title-field-input"]')
|
||||
|
||||
if (await iconDisplay.isVisible()) {
|
||||
const iconBox = await iconDisplay.boundingBox()
|
||||
const titleBox = await titleInput.boundingBox()
|
||||
|
||||
expect(iconBox).not.toBeNull()
|
||||
expect(titleBox).not.toBeNull()
|
||||
|
||||
// Emoji and title must be on the same row: their vertical centers overlap
|
||||
const iconCenter = iconBox!.y + iconBox!.height / 2
|
||||
const titleTop = titleBox!.y
|
||||
const titleBottom = titleBox!.y + titleBox!.height
|
||||
|
||||
expect(iconCenter).toBeGreaterThanOrEqual(titleTop - 8)
|
||||
expect(iconCenter).toBeLessThanOrEqual(titleBottom + 8)
|
||||
|
||||
// Emoji must be to the left of the title
|
||||
expect(iconBox!.x).toBeLessThan(titleBox!.x)
|
||||
}
|
||||
})
|
||||
|
||||
test('clicking title still enters edit mode', async ({ page }) => {
|
||||
const titleInput = page.locator('[data-testid="title-field-input"]')
|
||||
await expect(titleInput).toBeVisible({ timeout: 3000 })
|
||||
|
||||
await titleInput.click()
|
||||
await expect(titleInput).toBeFocused()
|
||||
})
|
||||
})
|
||||
@@ -1,82 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('TitleField alignment and separator', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Open a note so TitleField is visible
|
||||
const noteItem = page.locator('.app__note-list .cursor-pointer').first()
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(500)
|
||||
})
|
||||
|
||||
test('title-section and editor body share the same scroll container', async ({ page }) => {
|
||||
// The .editor-scroll-area should contain both .title-section and .editor__blocknote-container
|
||||
const scrollArea = page.locator('.editor-scroll-area')
|
||||
await expect(scrollArea).toBeVisible({ timeout: 3000 })
|
||||
|
||||
const titleSection = scrollArea.locator('.title-section')
|
||||
await expect(titleSection).toBeVisible()
|
||||
|
||||
const editorContainer = scrollArea.locator('.editor__blocknote-container')
|
||||
await expect(editorContainer).toBeVisible()
|
||||
})
|
||||
|
||||
test('separator line is visible between title and editor', async ({ page }) => {
|
||||
const separator = page.locator('.title-section__separator')
|
||||
await expect(separator).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Separator must have a visible border-bottom
|
||||
const borderBottom = await separator.evaluate(
|
||||
el => getComputedStyle(el).borderBottomStyle
|
||||
)
|
||||
expect(borderBottom).toBe('solid')
|
||||
})
|
||||
|
||||
test('title-section and editor content are horizontally aligned', async ({ page }) => {
|
||||
// Resize viewport to a wide width to test alignment
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const titleSection = page.locator('.title-section')
|
||||
const editorBlock = page.locator('.editor__blocknote-container .bn-editor')
|
||||
|
||||
await expect(titleSection).toBeVisible({ timeout: 3000 })
|
||||
await expect(editorBlock).toBeVisible({ timeout: 3000 })
|
||||
|
||||
const titleBox = await titleSection.boundingBox()
|
||||
const editorBox = await editorBlock.boundingBox()
|
||||
|
||||
expect(titleBox).not.toBeNull()
|
||||
expect(editorBox).not.toBeNull()
|
||||
|
||||
// Both should have the same left edge (within 2px tolerance)
|
||||
expect(Math.abs(titleBox!.x - editorBox!.x)).toBeLessThanOrEqual(2)
|
||||
})
|
||||
|
||||
test('title field uses H1 CSS variables for font styling', async ({ page }) => {
|
||||
// Verify the title-field__input element references the H1 heading CSS vars
|
||||
const usesH1Vars = await page.evaluate(() => {
|
||||
const sheets = Array.from(document.styleSheets)
|
||||
for (const sheet of sheets) {
|
||||
try {
|
||||
for (const rule of Array.from(sheet.cssRules)) {
|
||||
if (rule instanceof CSSStyleRule && rule.selectorText === '.title-field__input') {
|
||||
const fontSize = rule.style.getPropertyValue('font-size')
|
||||
const fontWeight = rule.style.getPropertyValue('font-weight')
|
||||
return {
|
||||
fontSize: fontSize.includes('--headings-h1-font-size'),
|
||||
fontWeight: fontWeight.includes('--headings-h1-font-weight'),
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* cross-origin sheet */ }
|
||||
}
|
||||
return { fontSize: false, fontWeight: false }
|
||||
})
|
||||
|
||||
expect(usesH1Vars.fontSize).toBe(true)
|
||||
expect(usesH1Vars.fontWeight).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,63 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Type icon, color, and sidebar label', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Block vault API so the app falls back to mock data (which contains our test fixtures)
|
||||
await page.route('**/api/vault/**', (route) => route.abort())
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('Config section shows correct sidebar label from type entry', async ({ page }) => {
|
||||
const sidebar = page.locator('aside')
|
||||
await sidebar.locator('button[aria-label*="Collapse"], button[aria-label*="Expand"]').first().waitFor({ timeout: 5000 })
|
||||
|
||||
// The Config type entry has sidebarLabel: "Config" — the section button should use that label
|
||||
const configBtn = sidebar.locator('button[aria-label="Collapse Config"], button[aria-label="Expand Config"]')
|
||||
await expect(configBtn).toBeVisible()
|
||||
})
|
||||
|
||||
test('Config section icon is not the default FileText', async ({ page }) => {
|
||||
const sidebar = page.locator('aside')
|
||||
await sidebar.locator('button[aria-label*="Collapse"], button[aria-label*="Expand"]').first().waitFor({ timeout: 5000 })
|
||||
|
||||
const configSection = sidebar.locator('div.group\\/section').filter({
|
||||
has: page.locator('button[aria-label="Collapse Config"], button[aria-label="Expand Config"]'),
|
||||
})
|
||||
await expect(configSection).toBeVisible()
|
||||
|
||||
// The icon SVG should be present (GearSix, resolved from type entry icon: 'gear-six')
|
||||
const icon = configSection.locator('svg').first()
|
||||
await expect(icon).toBeVisible()
|
||||
})
|
||||
|
||||
test('Config section icon has gray color applied via style', async ({ page }) => {
|
||||
const sidebar = page.locator('aside')
|
||||
await sidebar.locator('button[aria-label*="Collapse"], button[aria-label*="Expand"]').first().waitFor({ timeout: 5000 })
|
||||
|
||||
const configSection = sidebar.locator('div.group\\/section').filter({
|
||||
has: page.locator('button[aria-label="Collapse Config"], button[aria-label="Expand Config"]'),
|
||||
})
|
||||
await expect(configSection).toBeVisible()
|
||||
|
||||
// The icon should have gray color from the type entry's color: 'gray'
|
||||
const icon = configSection.locator('svg').first()
|
||||
const color = await icon.evaluate((el) => el.style.color)
|
||||
expect(color).toContain('var(--accent-gray)')
|
||||
})
|
||||
|
||||
test('custom type with icon/color reflects in sidebar (Recipe)', async ({ page }) => {
|
||||
const sidebar = page.locator('aside')
|
||||
await sidebar.locator('button[aria-label*="Collapse"], button[aria-label*="Expand"]').first().waitFor({ timeout: 5000 })
|
||||
|
||||
// Recipe type has icon: cooking-pot, color: orange — check section has correct color
|
||||
const recipeSection = sidebar.locator('div.group\\/section').filter({
|
||||
has: page.locator('button[aria-label="Collapse Recipes"], button[aria-label="Expand Recipes"]'),
|
||||
})
|
||||
await expect(recipeSection).toBeVisible()
|
||||
|
||||
const icon = recipeSection.locator('svg').first()
|
||||
const color = await icon.evaluate((el) => el.style.color)
|
||||
expect(color).toContain('var(--accent-orange)')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user