Compare commits

...

4 Commits

Author SHA1 Message Date
lucaronin
bc11c869aa fix: stabilize ai mode paste editing 2026-04-18 16:52:36 +02:00
lucaronin
502ecf0572 test: stabilize wikilink smoke coverage 2026-04-18 16:18:43 +02:00
lucaronin
8bbbba98b7 fix: correct vault path type guard 2026-04-18 16:02:19 +02:00
lucaronin
86adf860b5 fix: restore bottom bar vault switching 2026-04-18 15:59:34 +02:00
11 changed files with 487 additions and 131 deletions

View File

@@ -358,6 +358,30 @@ describe('App', () => {
expect(appShell).toBeInTheDocument()
})
it('switches vaults from the bottom bar after onboarding is ready', async () => {
mockCommandResults.load_vault_list = {
vaults: [
{ label: 'Test Vault', path: '/work' },
{ label: 'Work Vault', path: '/vault-2' },
],
active_vault: '/work',
hidden_defaults: [],
}
render(<App />)
await waitFor(() => {
expect(screen.getByTestId('status-vault-trigger')).toHaveTextContent('Test Vault')
})
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
fireEvent.click(screen.getByTestId('vault-menu-item-Work Vault'))
await waitFor(() => {
expect(screen.getByTestId('status-vault-trigger')).toHaveTextContent('Work Vault')
})
})
it('Cmd+1 hides sidebar and note list (editor-only mode)', async () => {
render(<App />)
await waitFor(() => {

View File

@@ -105,6 +105,16 @@ declare global {
const DEFAULT_SELECTION: SidebarSelection = INBOX_SELECTION
function shouldPreferOnboardingVaultPath(
onboardingState: { status: string; vaultPath?: string },
vaults: Array<{ path: string }>,
): onboardingState is { status: 'ready'; vaultPath: string } {
return onboardingState.status === 'ready'
&& typeof onboardingState.vaultPath === 'string'
&& onboardingState.vaultPath.length > 0
&& !vaults.some((vault) => vault.path === onboardingState.vaultPath)
}
async function resolveNoteWindowEntry(
noteWindowParams: NoteWindowParams,
entries: VaultEntry[],
@@ -234,16 +244,26 @@ function App() {
})
const aiAgentsStatus = useAiAgentsStatus()
const aiAgentsOnboarding = useAiAgentsOnboarding(onboarding.state.status === 'ready' && !noteWindowParams)
const lastHandledOnboardingUserVaultPathRef = useRef<string | null>(null)
useEffect(() => {
if (onboarding.state.status !== 'ready') return
if (!onboarding.state.vaultPath || onboarding.state.vaultPath === vaultSwitcher.vaultPath) return
rememberOnboardingVaultChoice(onboarding.state.vaultPath)
}, [onboarding.state, rememberOnboardingVaultChoice, vaultSwitcher.vaultPath])
const onboardingVaultPath = onboarding.userReadyVaultPath
if (!onboardingVaultPath || lastHandledOnboardingUserVaultPathRef.current === onboardingVaultPath) return
// The active vault path can temporarily come from onboarding before the
// persisted vault switcher catches up to the newly cloned starter vault.
const resolvedPath = noteWindowParams?.vaultPath ?? (onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath)
lastHandledOnboardingUserVaultPathRef.current = onboardingVaultPath
if (onboardingVaultPath !== vaultSwitcher.vaultPath) {
rememberOnboardingVaultChoice(onboardingVaultPath)
}
}, [onboarding.userReadyVaultPath, rememberOnboardingVaultChoice, vaultSwitcher.vaultPath])
// Onboarding can briefly own the vault path for a newly created/opened vault
// before the persisted switcher catches up, but once the path is already in
// the switcher list we should trust the explicit switcher state.
const resolvedPath = noteWindowParams?.vaultPath ?? (
shouldPreferOnboardingVaultPath(onboarding.state, vaultSwitcher.allVaults)
? onboarding.state.vaultPath
: vaultSwitcher.vaultPath
)
// Git repo check: 'checking' | 'required' | 'ready'
const [gitRepoState, setGitRepoState] = useState<'checking' | 'required' | 'ready'>('checking')
useEffect(() => {

View File

@@ -75,6 +75,7 @@ function renderInlineEditorField({
editorClassName,
onInput,
onKeyDown,
onPaste,
onSelectionChange,
segments,
typeEntryMap,
@@ -87,6 +88,7 @@ function renderInlineEditorField({
editorClassName?: string
onInput: () => void
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
onPaste: (event: React.ClipboardEvent<HTMLDivElement>) => void
onSelectionChange: () => void
segments: ReturnType<typeof buildInlineWikilinkSegments>
typeEntryMap: Record<string, VaultEntry>
@@ -101,6 +103,7 @@ function renderInlineEditorField({
editorClassName={editorClassName}
onInput={onInput}
onKeyDown={onKeyDown}
onPaste={onPaste}
onSelectionChange={onSelectionChange}
segments={segments}
typeEntryMap={typeEntryMap}
@@ -209,6 +212,17 @@ export function InlineWikilinkInput({
onChange(nextState.value)
setSelectionRange(nextState.selection)
}
const handlePaste = (event: React.ClipboardEvent<HTMLDivElement>) => {
if (disabled) return
const pastedText = normalizeInlineWikilinkValue(event.clipboardData.getData('text/plain'))
if (!pastedText) return
event.preventDefault()
const nextState = replaceInlineSelection(value, selectionRange, pastedText)
onChange(nextState.value)
setSelectionRange(nextState.selection)
}
const submitValue = () =>
submitInlineValue({ onSubmit, submitOnEmpty, value, references })
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) =>
@@ -232,6 +246,7 @@ export function InlineWikilinkInput({
editorClassName,
onInput: commitValueFromEditor,
onKeyDown: handleKeyDown,
onPaste: handlePaste,
onSelectionChange: syncSelectionRange,
segments,
typeEntryMap,

View File

@@ -160,6 +160,7 @@ export function InlineWikilinkEditorField({
editorClassName,
onInput,
onKeyDown,
onPaste,
onSelectionChange,
segments,
typeEntryMap,
@@ -172,6 +173,7 @@ export function InlineWikilinkEditorField({
editorClassName?: string
onInput: () => void
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
onPaste: (event: React.ClipboardEvent<HTMLDivElement>) => void
onSelectionChange: () => void
segments: InlineWikilinkSegment[]
typeEntryMap: Record<string, VaultEntry>
@@ -204,6 +206,7 @@ export function InlineWikilinkEditorField({
)}
onInput={onInput}
onKeyDown={onKeyDown}
onPaste={onPaste}
onClick={onSelectionChange}
onKeyUp={onSelectionChange}
onMouseUp={onSelectionChange}

View File

@@ -1,5 +1,5 @@
import { useMemo, useRef, useState } from 'react'
import type { CSSProperties, ReactNode } from 'react'
import type { ReactNode } from 'react'
import { AlertTriangle, Check, FolderOpen, GitBranch, Rocket, X } from 'lucide-react'
import { ActionTooltip } from '@/components/ui/action-tooltip'
import { Button } from '@/components/ui/button'
@@ -88,67 +88,49 @@ function VaultMenuIcon({ isActive, unavailable }: { isActive: boolean; unavailab
return <span style={{ width: 12 }} />
}
function vaultItemStyle(isActive: boolean, unavailable: boolean): CSSProperties {
return {
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '4px 8px',
borderRadius: 4,
cursor: unavailable ? 'not-allowed' : 'pointer',
background: isActive ? 'var(--hover)' : 'transparent',
opacity: unavailable ? 0.45 : 1,
color: isActive ? 'var(--foreground)' : 'var(--muted-foreground)',
fontSize: 12,
}
}
function VaultMenuItem({ vault, isActive, canRemove, onSelect, onRemove }: VaultMenuItemProps) {
const unavailable = vault.available === false
const canHover = !isActive && !unavailable
return (
<div
role="button"
onClick={unavailable ? undefined : onSelect}
style={{ ...vaultItemStyle(isActive, unavailable), justifyContent: 'space-between' }}
title={unavailable ? `Vault not found: ${vault.path}` : vault.path}
onMouseEnter={canHover ? (event) => { event.currentTarget.style.background = 'var(--hover)' } : undefined}
onMouseLeave={canHover ? (event) => { event.currentTarget.style.background = 'transparent' } : undefined}
data-testid={`vault-menu-item-${vault.label}`}
>
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<VaultMenuIcon isActive={isActive} unavailable={unavailable} />
{vault.label}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Button
type="button"
variant="ghost"
size="xs"
disabled={unavailable}
onClick={onSelect}
aria-current={isActive ? 'true' : undefined}
title={unavailable ? `Vault not found: ${vault.path}` : vault.path}
data-testid={`vault-menu-item-${vault.label}`}
className="w-full justify-between rounded-sm px-2 py-1 text-xs font-normal"
style={{
height: 'auto',
color: isActive ? 'var(--foreground)' : 'var(--muted-foreground)',
background: isActive ? 'var(--hover)' : 'transparent',
opacity: unavailable ? 0.45 : 1,
}}
>
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<VaultMenuIcon isActive={isActive} unavailable={unavailable} />
{vault.label}
</span>
</Button>
{canRemove && onRemove && (
<span
role="button"
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={(event) => {
event.stopPropagation()
onRemove()
}}
style={{
display: 'flex',
alignItems: 'center',
padding: 2,
borderRadius: 3,
cursor: 'pointer',
opacity: 0.5,
}}
title="Remove from list"
data-testid={`vault-menu-remove-${vault.label}`}
onMouseEnter={(event) => {
event.currentTarget.style.opacity = '1'
event.currentTarget.style.background = 'var(--hover)'
}}
onMouseLeave={(event) => {
event.currentTarget.style.opacity = '0.5'
event.currentTarget.style.background = 'transparent'
}}
className="rounded-sm"
style={{ opacity: 0.5 }}
>
<X size={10} />
</span>
</Button>
)}
</div>
)
@@ -156,27 +138,18 @@ function VaultMenuItem({ vault, isActive, canRemove, onSelect, onRemove }: Vault
function VaultMenuAction({ icon, label, testId, accent = false, onClick }: VaultMenuActionProps) {
return (
<div
role="button"
<Button
type="button"
variant="ghost"
size="xs"
onClick={onClick}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '4px 8px',
borderRadius: 4,
cursor: 'pointer',
background: 'transparent',
color: accent ? 'var(--accent-blue)' : 'var(--muted-foreground)',
fontSize: 12,
}}
onMouseEnter={(event) => { event.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={(event) => { event.currentTarget.style.background = 'transparent' }}
className="h-auto w-full justify-start rounded-sm px-2 py-1 text-xs font-normal"
style={{ color: accent ? 'var(--accent-blue)' : 'var(--muted-foreground)' }}
data-testid={testId}
>
{icon}
{label}
</div>
</Button>
)
}

View File

@@ -64,6 +64,7 @@ export function useOnboarding(
const [creatingAction, setCreatingAction] = useState<CreatingAction>(null)
const [error, setError] = useState<string | null>(null)
const [lastTemplatePath, setLastTemplatePath] = useState<string | null>(null)
const [userReadyVaultPath, setUserReadyVaultPath] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
@@ -110,6 +111,7 @@ export function useOnboarding(
const vaultPath = await tauriCall<string>('create_getting_started_vault', { targetPath })
markDismissed()
setState({ status: 'ready', vaultPath })
setUserReadyVaultPath(vaultPath)
onTemplateVaultReady?.(vaultPath)
} catch (err) {
setError(formatGettingStartedCloneError(err))
@@ -138,6 +140,7 @@ export function useOnboarding(
const vaultPath = await tauriCall<string>('create_empty_vault', { targetPath: path })
markDismissed()
setState({ status: 'ready', vaultPath })
setUserReadyVaultPath(vaultPath)
} catch (err) {
setError(typeof err === 'string' ? err : `Failed to create vault: ${err}`)
} finally {
@@ -152,6 +155,7 @@ export function useOnboarding(
if (!path) return
markDismissed()
setState({ status: 'ready', vaultPath: path })
setUserReadyVaultPath(path)
} catch (err) {
setError(`Failed to open folder: ${err}`)
}
@@ -173,5 +177,6 @@ export function useOnboarding(
handleCreateNewVault,
handleOpenFolder,
handleDismiss,
userReadyVaultPath,
}
}

View File

@@ -1,10 +1,12 @@
import { test, expect, type Page } from '@playwright/test'
import { openCommandPalette } from './helpers'
import {
expectEditorSelectionRange,
expectNoPageErrors,
expectNormalizedEditorText,
selectEditorTextRange,
trackPageErrors,
writeClipboardText,
} from './inlineWikilinkEditorHelpers'
async function readCaretGapAfterChip(page: Page) {
@@ -72,4 +74,57 @@ test.describe('Command palette AI mode regression', () => {
await expect(aiInput).toBeVisible()
await expectNoPageErrors(pageErrors)
})
test('keeps pasted text, caret movement, and selection replacement stable in AI mode', async ({ page }) => {
const pageErrors = trackPageErrors(page)
const aiInputTarget = { dataTestId: 'command-palette-ai-input' }
await openCommandPalette(page)
await page.locator('input[placeholder="Type a command..."]').pressSequentially(' ')
const aiInput = page.getByTestId('command-palette-ai-input')
await expect(aiInput).toBeVisible()
await expect(aiInput).toBeFocused()
await writeClipboardText(page, { text: 'hello world' })
await page.keyboard.press('Meta+V')
await expectNormalizedEditorText(aiInput, 'hello world')
await expectEditorSelectionRange(page, {
expectedRange: { start: 12, end: 12 },
target: aiInputTarget,
})
for (let i = 0; i < 5; i += 1) {
await page.keyboard.press('ArrowLeft')
}
await expectEditorSelectionRange(page, {
expectedRange: { start: 7, end: 7 },
target: aiInputTarget,
})
await page.keyboard.press('Shift+ArrowRight')
await page.keyboard.press('Shift+ArrowRight')
await expectEditorSelectionRange(page, {
expectedRange: { start: 7, end: 9 },
target: aiInputTarget,
})
await page.keyboard.type('XY')
await expectNormalizedEditorText(aiInput, 'hello XYrld')
await expectEditorSelectionRange(page, {
expectedRange: { start: 9, end: 9 },
target: aiInputTarget,
})
for (let i = 0; i < 3; i += 1) {
await page.keyboard.press('ArrowRight')
}
await expectEditorSelectionRange(page, {
expectedRange: { start: 12, end: 12 },
target: aiInputTarget,
})
await page.keyboard.press('Backspace')
await expectNormalizedEditorText(aiInput, 'hello XYrl')
await expectNoPageErrors(pageErrors)
})
})

View File

@@ -1,9 +1,11 @@
import { test, expect } from '@playwright/test'
import {
expectEditorSelectionRange,
expectNoPageErrors,
expectNormalizedEditorText,
selectEditorTextRange,
trackPageErrors,
writeClipboardText,
} from './inlineWikilinkEditorHelpers'
test.describe('Inline wikilink editor regression', () => {
@@ -38,4 +40,53 @@ test.describe('Inline wikilink editor regression', () => {
await expect(editor).toBeVisible()
await expectNoPageErrors(pageErrors)
})
test('keeps pasted text, caret movement, and selection replacement stable in the AI panel chat input', async ({ page }) => {
const pageErrors = trackPageErrors(page)
const agentInputTarget = { dataTestId: 'agent-input' }
const editor = page.getByTestId('agent-input')
await expect(editor).toBeFocused()
await writeClipboardText(page, { text: 'hello world' })
await page.keyboard.press('Meta+V')
await expectNormalizedEditorText(editor, 'hello world')
await expectEditorSelectionRange(page, {
expectedRange: { start: 11, end: 11 },
target: agentInputTarget,
})
for (let i = 0; i < 5; i += 1) {
await page.keyboard.press('ArrowLeft')
}
await expectEditorSelectionRange(page, {
expectedRange: { start: 6, end: 6 },
target: agentInputTarget,
})
await page.keyboard.press('Shift+ArrowRight')
await page.keyboard.press('Shift+ArrowRight')
await expectEditorSelectionRange(page, {
expectedRange: { start: 6, end: 8 },
target: agentInputTarget,
})
await page.keyboard.type('XY')
await expectNormalizedEditorText(editor, 'hello XYrld')
await expectEditorSelectionRange(page, {
expectedRange: { start: 8, end: 8 },
target: agentInputTarget,
})
for (let i = 0; i < 3; i += 1) {
await page.keyboard.press('ArrowRight')
}
await expectEditorSelectionRange(page, {
expectedRange: { start: 11, end: 11 },
target: agentInputTarget,
})
await page.keyboard.press('Backspace')
await expectNormalizedEditorText(editor, 'hello XYrl')
await expectNoPageErrors(pageErrors)
})
})

View File

@@ -5,6 +5,24 @@ interface SelectEditorTextRangeArgs {
startOffset: number
}
interface ClipboardWriteArgs {
text: string
}
interface EditorSelectionRange {
start: number
end: number
}
interface EditorTarget {
dataTestId: string
}
interface ExpectEditorSelectionRangeArgs {
expectedRange: EditorSelectionRange
target: EditorTarget
}
export function trackPageErrors(page: Page): string[] {
const pageErrors: string[] = []
page.on('pageerror', (error) => pageErrors.push(error.message))
@@ -34,6 +52,28 @@ export async function expectNoPageErrors(pageErrors: string[]): Promise<void> {
.toEqual([])
}
export async function writeClipboardText(
page: Page,
{ text }: ClipboardWriteArgs,
): Promise<void> {
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
await page.evaluate(async ({ text: nextText }) => {
await navigator.clipboard.writeText(nextText)
}, { text })
}
export async function expectEditorSelectionRange(
page: Page,
{ expectedRange, target }: ExpectEditorSelectionRangeArgs,
): Promise<void> {
await expect
.poll(
async () => page.evaluate(readEditorSelectionRangeInBrowser, target),
{ timeout: 2_000 },
)
.toEqual(expectedRange)
}
function normalizeEditorText(value: string | null): string {
return value?.replace(/\s+/g, ' ').trim() ?? ''
}
@@ -55,3 +95,28 @@ function selectEditorTextRangeInBrowser({
selection.removeAllRanges()
selection.addRange(range)
}
function readEditorSelectionRangeInBrowser({
dataTestId,
}: EditorTarget): EditorSelectionRange | null {
const editor = document.querySelector(`[data-testid="${dataTestId}"]`) as HTMLDivElement | null
const selection = window.getSelection()
if (!editor || !selection || selection.rangeCount === 0) return null
const range = selection.getRangeAt(0)
if (!editor.contains(range.startContainer) || !editor.contains(range.endContainer)) return null
const startRange = document.createRange()
startRange.selectNodeContents(editor)
startRange.setEnd(range.startContainer, range.startOffset)
const endRange = document.createRange()
endRange.selectNodeContents(editor)
endRange.setEnd(range.endContainer, range.endOffset)
const normalizeSelectionText = (value: string) => value.replace(/\u200B/g, '')
return {
start: normalizeSelectionText(startRange.toString()).length,
end: normalizeSelectionText(endRange.toString()).length,
}
}

View File

@@ -0,0 +1,167 @@
import { test, expect, type Page } from '@playwright/test'
interface MockEntry {
path: string
filename: string
title: string
isA: string
aliases: string[]
belongsTo: string[]
relatedTo: string[]
status: string | null
archived: boolean
modifiedAt: number | null
createdAt: number | null
fileSize: number
snippet: string
wordCount: number
relationships: Record<string, string[]>
outgoingLinks: string[]
properties: Record<string, unknown>
template: null
sort: null
}
async function installVaultSwitcherMocks(page: Page) {
await page.addInitScript(() => {
const workVaultPath = '/Users/mock/Work'
const personalVaultPath = '/Users/mock/Personal'
const gettingStartedPath = '/Users/mock/Documents/Getting Started'
const entriesByVault = {
[gettingStartedPath]: [({
path: `${gettingStartedPath}/getting-started.md`,
filename: 'getting-started.md',
title: 'Getting Started Note',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 256,
snippet: 'Getting Started snippet',
wordCount: 12,
relationships: {},
outgoingLinks: [],
properties: {},
template: null,
sort: null,
})],
[workVaultPath]: [({
path: `${workVaultPath}/work-home.md`,
filename: 'work-home.md',
title: 'Work Home',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 256,
snippet: 'Work Home snippet',
wordCount: 12,
relationships: {},
outgoingLinks: [],
properties: {},
template: null,
sort: null,
})],
[personalVaultPath]: [({
path: `${personalVaultPath}/personal-home.md`,
filename: 'personal-home.md',
title: 'Personal Home',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 256,
snippet: 'Personal Home snippet',
wordCount: 12,
relationships: {},
outgoingLinks: [],
properties: {},
template: null,
sort: null,
})],
} satisfies Record<string, MockEntry[]>
const allContent = Object.fromEntries(
Object.values(entriesByVault)
.flat()
.map((entry) => [entry.path, `# ${entry.title}\n\n${entry.snippet}`]),
)
localStorage.clear()
localStorage.setItem('tolaria:claude-code-onboarding-dismissed', '1')
let ref: Record<string, unknown> | null = null
Object.defineProperty(window, '__mockHandlers', {
configurable: true,
set(value) {
ref = value as Record<string, unknown>
ref.load_vault_list = () => ({
vaults: [
{ label: 'Work Vault', path: workVaultPath },
{ label: 'Personal Vault', path: personalVaultPath },
],
active_vault: workVaultPath,
hidden_defaults: [],
})
ref.get_default_vault_path = () => gettingStartedPath
ref.check_vault_exists = (args: { path?: string }) => typeof args?.path === 'string' && args.path.length > 0
ref.list_vault = (args: { path?: string }) => entriesByVault[args?.path ?? ''] ?? []
ref.list_vault_folders = () => []
ref.list_views = () => []
ref.get_all_content = () => allContent
ref.get_note_content = (args: { path?: string }) => allContent[args?.path ?? ''] ?? ''
ref.get_modified_files = () => []
ref.get_file_history = () => []
},
get() {
return ref
},
})
})
}
test('bottom bar vault switching works with keyboard and mouse @smoke', async ({ page }) => {
await installVaultSwitcherMocks(page)
await page.goto('/', { waitUntil: 'domcontentloaded' })
const trigger = page.getByTestId('status-vault-trigger')
const noteList = page.getByTestId('note-list-container')
await expect(trigger).toContainText('Work Vault')
await expect(noteList.getByText('Work Home', { exact: true })).toBeVisible()
await trigger.focus()
await expect(trigger).toBeFocused()
await page.keyboard.press('Enter')
await page.keyboard.press('Tab')
await expect(page.getByTestId('vault-menu-item-Getting Started')).toBeFocused()
await page.getByTestId('vault-menu-item-Personal Vault').focus()
await expect(page.getByTestId('vault-menu-item-Personal Vault')).toBeFocused()
await page.keyboard.press('Enter')
await expect(trigger).toContainText('Personal Vault')
await expect(noteList.getByText('Personal Home', { exact: true })).toBeVisible()
await expect(noteList.getByText('Work Home', { exact: true })).toHaveCount(0)
await trigger.click()
await page.getByTestId('vault-menu-item-Work Vault').click()
await expect(trigger).toContainText('Work Vault')
await expect(noteList.getByText('Work Home', { exact: true })).toBeVisible()
await expect(noteList.getByText('Personal Home', { exact: true })).toHaveCount(0)
})

View File

@@ -1,84 +1,62 @@
import { test, expect } from '@playwright/test'
import { test, expect, type Page } from '@playwright/test'
const SOURCE_NOTE_TITLE = 'Grow Newsletter'
const INSERTED_WIKILINK_QUERY = '[[Mana'
const INSERTED_WIKILINK_TITLE = 'Manage Sponsorships'
async function insertWikilink(page: Page) {
const editor = page.locator('.bn-editor')
await expect(editor).toBeVisible({ timeout: 5000 })
const firstParagraph = editor.locator('p').first()
await expect(
firstParagraph,
).toContainText('Build a sustainable audience through high-quality weekly essays', { timeout: 5000 })
await firstParagraph.click()
await page.keyboard.press('End')
await page.keyboard.press('Enter')
await page.waitForTimeout(200)
await page.keyboard.type(INSERTED_WIKILINK_QUERY)
const suggestionMenu = page.locator('.wikilink-menu')
await expect(suggestionMenu).toBeVisible({ timeout: 5000 })
const matchingWikilinks = editor.locator('.wikilink').filter({ hasText: INSERTED_WIKILINK_TITLE })
const existingCount = await matchingWikilinks.count()
await suggestionMenu.getByText(INSERTED_WIKILINK_TITLE, { exact: true }).click()
await page.waitForTimeout(500)
await expect(matchingWikilinks).toHaveCount(existingCount + 1)
return matchingWikilinks.nth(existingCount)
}
test.describe('Wikilink insertion and navigation', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(500)
// Select first note to open in editor
const noteItem = page.locator('.app__note-list .cursor-pointer').first()
const noteItem = page.locator('.app__note-list .cursor-pointer').filter({ hasText: SOURCE_NOTE_TITLE }).first()
await noteItem.click()
await page.waitForTimeout(1000)
})
test('[[ autocomplete inserts wikilink that is not broken', async ({ page }) => {
// Focus editor and move to a new line
const editor = page.locator('.bn-editor')
await expect(editor).toBeVisible({ timeout: 5000 })
await editor.click()
await page.keyboard.press('End')
await page.keyboard.press('Enter')
await page.waitForTimeout(200)
const wikilink = await insertWikilink(page)
// Type [[ then a query (>= 2 chars for MIN_QUERY_LENGTH)
await page.keyboard.type('[[Ma')
await page.waitForTimeout(800)
// The wikilink suggestion menu should appear
const suggestionMenu = page.locator('.wikilink-menu')
await expect(suggestionMenu).toBeVisible({ timeout: 5000 })
// Select the first suggestion
await page.keyboard.press('Enter')
await page.waitForTimeout(500)
// A wikilink should have been inserted
const wikilinks = page.locator('.wikilink')
const count = await wikilinks.count()
expect(count).toBeGreaterThanOrEqual(1)
// The wikilink should NOT be broken
const lastWikilink = wikilinks.last()
const isBroken = await lastWikilink.evaluate(
const isBroken = await wikilink.evaluate(
el => el.classList.contains('wikilink--broken'),
)
expect(isBroken).toBe(false)
// The wikilink should have a data-target attribute
const target = await lastWikilink.getAttribute('data-target')
const target = await wikilink.getAttribute('data-target')
expect(target).toBeTruthy()
})
test('@smoke Cmd+clicking an inserted wikilink navigates to the note', async ({ page }) => {
// Insert a wikilink via autocomplete
const editor = page.locator('.bn-editor')
await expect(editor).toBeVisible({ timeout: 5000 })
await editor.click()
await page.keyboard.press('End')
await page.keyboard.press('Enter')
await page.waitForTimeout(200)
await page.keyboard.type('[[Ma')
await page.waitForTimeout(800)
const suggestionMenu = page.locator('.wikilink-menu')
await expect(suggestionMenu).toBeVisible({ timeout: 5000 })
await page.keyboard.press('Enter')
await page.waitForTimeout(500)
// Get the wikilink that was just inserted
const wikilink = page.locator('.wikilink').last()
const wikilink = await insertWikilink(page)
await expect(wikilink).toBeVisible()
const targetTitle = await wikilink.textContent()
// Cmd+click the wikilink to navigate
await wikilink.click({ modifiers: ['Meta'] })
await page.waitForTimeout(1000)
const expected = targetTitle?.substring(0, 4) ?? ''
await expect.poll(async () => {
const heading = page.locator('.bn-editor h1').first()
if (await heading.count()) return (await heading.textContent()) ?? ''
return ''
}, { timeout: 5000 }).toContain(expected)
await expect(page.locator('.bn-editor h1').first()).toHaveText(INSERTED_WIKILINK_TITLE, { timeout: 5000 })
})
})