336 lines
9.6 KiB
TypeScript
336 lines
9.6 KiB
TypeScript
import { test, expect, type Locator, type Page } from '@playwright/test'
|
|
import { createFixtureVaultCopy, openFixtureVault, removeFixtureVaultCopy } from '../helpers/fixtureVault'
|
|
import { executeCommand, openCommandPalette } from './helpers'
|
|
|
|
const MIXED_RICH_TEXT_CONTENT = `---
|
|
title: Note B
|
|
type: Note
|
|
status: Active
|
|
---
|
|
|
|
# Note B
|
|
|
|
> 인용문
|
|
|
|
## 섹션
|
|
|
|
- 첫 번째 항목
|
|
- 두 번째 항목
|
|
|
|
Plain paragraph after the list.
|
|
`
|
|
|
|
const NUMBERED_REENTRY_CONTENT = `---
|
|
title: Note B
|
|
type: Note
|
|
status: Active
|
|
---
|
|
|
|
# Note B
|
|
|
|
Intro paragraph before the list.
|
|
|
|
1. First numbered item
|
|
2. Second numbered item
|
|
|
|
Plain paragraph after the numbered list.
|
|
`
|
|
|
|
const PROCEDURE_NOTE_CONTENT = `---
|
|
title: Procedure
|
|
type: Procedure
|
|
status: Active
|
|
---
|
|
|
|
# Procedure
|
|
|
|
Procedures are long-running processes tied to a [[responsibility|Responsibility]].
|
|
- Status: Active
|
|
- Owner: The person responsible
|
|
|
|
Plain follow-up paragraph after the procedure checklist.
|
|
`
|
|
|
|
const NESTED_PROCEDURE_NOTE_CONTENT = `---
|
|
title: Note B
|
|
type: Procedure
|
|
status: Active
|
|
---
|
|
|
|
# Note B
|
|
|
|
Procedures are long-running processes tied to a responsibility.
|
|
|
|
- Prepare release
|
|
1. Validate nested numbered content
|
|
2. Notify the owner
|
|
- Record outcome
|
|
|
|
Plain follow-up paragraph after the nested procedure checklist.
|
|
`
|
|
|
|
let tempVaultDir: string
|
|
|
|
test.beforeEach(async ({ page }, testInfo) => {
|
|
testInfo.setTimeout(90_000)
|
|
tempVaultDir = createFixtureVaultCopy()
|
|
await openFixtureVault(page, tempVaultDir)
|
|
})
|
|
|
|
test.afterEach(async () => {
|
|
removeFixtureVaultCopy(tempVaultDir)
|
|
})
|
|
|
|
async function openNote(page: Page, title: string) {
|
|
await page.locator('[data-testid="note-list-container"]').getByText(title, { exact: true }).click()
|
|
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
|
|
}
|
|
|
|
async function toggleRawMode(page: Page, visibleSelector: string) {
|
|
await openCommandPalette(page)
|
|
await executeCommand(page, 'Toggle Raw')
|
|
await expect(page.locator(visibleSelector)).toBeVisible({ timeout: 5_000 })
|
|
}
|
|
|
|
async function getRawEditorContent(page: Page): Promise<string> {
|
|
return page.evaluate(() => {
|
|
type CodeMirrorHost = Element & {
|
|
cmTile?: {
|
|
view?: {
|
|
state: {
|
|
doc: {
|
|
toString(): string
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const el = document.querySelector('.cm-content')
|
|
const view = (el as CodeMirrorHost | null)?.cmTile?.view
|
|
return view?.state.doc.toString() ?? el?.textContent ?? ''
|
|
})
|
|
}
|
|
|
|
async function setRawEditorContent(page: Page, content: string) {
|
|
await page.evaluate((nextContent) => {
|
|
type CodeMirrorHost = Element & {
|
|
cmTile?: {
|
|
view?: {
|
|
state: {
|
|
doc: {
|
|
length: number
|
|
}
|
|
}
|
|
dispatch(update: { changes: { from: number; to: number; insert: string } }): void
|
|
}
|
|
}
|
|
}
|
|
|
|
const el = document.querySelector('.cm-content')
|
|
const view = (el as CodeMirrorHost | null)?.cmTile?.view
|
|
if (!view) throw new Error('CodeMirror view not found')
|
|
view.dispatch({
|
|
changes: { from: 0, to: view.state.doc.length, insert: nextContent },
|
|
})
|
|
}, content)
|
|
}
|
|
|
|
function collectEditorCrashSignals(page: Page) {
|
|
const messages: string[] = []
|
|
|
|
page.on('pageerror', (error) => {
|
|
messages.push(error.message)
|
|
})
|
|
|
|
page.on('console', (message) => {
|
|
if (message.type() === 'error') {
|
|
messages.push(message.text())
|
|
}
|
|
})
|
|
|
|
return messages
|
|
}
|
|
|
|
function expectNoBlockContainerCrash(messages: string[]) {
|
|
expect(messages.filter((message) => (
|
|
message.includes('Invalid content for node blockContainer') ||
|
|
message.includes('RangeError')
|
|
))).toEqual([])
|
|
}
|
|
|
|
async function loadRawContentInRichEditor(page: Page, content: string) {
|
|
await openNote(page, 'Note B')
|
|
await toggleRawMode(page, '.cm-content')
|
|
await setRawEditorContent(page, content)
|
|
await page.waitForTimeout(700)
|
|
await toggleRawMode(page, '.bn-editor')
|
|
}
|
|
|
|
async function appendMarkerToBlock(page: Page, block: Locator, marker: string) {
|
|
await expect(block).toBeVisible({ timeout: 5_000 })
|
|
await block.click()
|
|
await page.keyboard.press('End')
|
|
await page.keyboard.insertText(` ${marker}`)
|
|
await page.waitForTimeout(900)
|
|
}
|
|
|
|
async function reopenNoteB(page: Page) {
|
|
await openNote(page, 'Note C')
|
|
await openNote(page, 'Note B')
|
|
}
|
|
|
|
async function expectListContentEditableAfterReopen(page: Page, options: {
|
|
content: string
|
|
markerPrefix: string
|
|
primary: {
|
|
selector: string
|
|
text: string
|
|
}
|
|
secondary: {
|
|
selector: string
|
|
text: string
|
|
}
|
|
}) {
|
|
const crashSignals = collectEditorCrashSignals(page)
|
|
const markerId = Date.now()
|
|
const primaryMarker = `${options.markerPrefix}-primary-${markerId}`
|
|
const secondaryMarker = `${options.markerPrefix}-secondary-${markerId}`
|
|
const reentryMarker = `${options.markerPrefix}-reentry-${markerId}`
|
|
|
|
await loadRawContentInRichEditor(page, options.content)
|
|
|
|
const primaryBlock = page.locator(options.primary.selector, {
|
|
hasText: options.primary.text,
|
|
}).first()
|
|
await appendMarkerToBlock(page, primaryBlock, primaryMarker)
|
|
|
|
const secondaryBlock = page.locator(options.secondary.selector, {
|
|
hasText: options.secondary.text,
|
|
}).first()
|
|
await appendMarkerToBlock(page, secondaryBlock, secondaryMarker)
|
|
|
|
await reopenNoteB(page)
|
|
await appendMarkerToBlock(page, primaryBlock, reentryMarker)
|
|
|
|
expectNoBlockContainerCrash(crashSignals)
|
|
|
|
await toggleRawMode(page, '.cm-content')
|
|
const raw = await getRawEditorContent(page)
|
|
expect(raw).toContain(primaryMarker)
|
|
expect(raw).toContain(secondaryMarker)
|
|
expect(raw).toContain(reentryMarker)
|
|
expectNoBlockContainerCrash(crashSignals)
|
|
}
|
|
|
|
const listReopenScenarios = [
|
|
{
|
|
name: 'numbered list content stays editable after autosave and re-entry',
|
|
content: NUMBERED_REENTRY_CONTENT,
|
|
markerPrefix: 'numbered',
|
|
primary: {
|
|
selector: '.bn-block-content[data-content-type="numberedListItem"]',
|
|
text: 'First numbered item',
|
|
},
|
|
secondary: {
|
|
selector: '.bn-block-content',
|
|
text: 'Plain paragraph after the numbered list.',
|
|
},
|
|
},
|
|
{
|
|
name: 'nested procedure list content stays editable after save and reopen',
|
|
content: NESTED_PROCEDURE_NOTE_CONTENT,
|
|
markerPrefix: 'nested',
|
|
primary: {
|
|
selector: '.bn-block-content[data-content-type="numberedListItem"]',
|
|
text: 'Validate nested numbered content',
|
|
},
|
|
secondary: {
|
|
selector: '.bn-block-content[data-content-type="bulletListItem"]',
|
|
text: 'Record outcome',
|
|
},
|
|
},
|
|
] as const
|
|
|
|
test('mixed rich-text blocks with Korean list content stay editable after action clicks', async ({ page }) => {
|
|
const crashSignals = collectEditorCrashSignals(page)
|
|
|
|
await openNote(page, 'Note B')
|
|
await toggleRawMode(page, '.cm-content')
|
|
await setRawEditorContent(page, MIXED_RICH_TEXT_CONTENT)
|
|
await page.waitForTimeout(700)
|
|
await toggleRawMode(page, '.bn-editor')
|
|
|
|
const bulletBlock = page.locator('.bn-block-content', { hasText: '첫 번째 항목' }).first()
|
|
await expect(bulletBlock).toBeVisible({ timeout: 5_000 })
|
|
|
|
await page.locator('.bn-block-content', { hasText: '인용문' }).first().click()
|
|
await page.keyboard.press('End')
|
|
await page.keyboard.insertText(' 추가')
|
|
await bulletBlock.hover()
|
|
await expect(page.locator('.bn-side-menu').first()).toBeVisible({ timeout: 5_000 })
|
|
await page.locator('.bn-side-menu').first().click({ force: true })
|
|
await page.keyboard.press('Escape')
|
|
await bulletBlock.click()
|
|
await page.keyboard.press('End')
|
|
await page.keyboard.insertText(' 계속')
|
|
await page.waitForTimeout(700)
|
|
|
|
expectNoBlockContainerCrash(crashSignals)
|
|
|
|
await toggleRawMode(page, '.cm-content')
|
|
const raw = await getRawEditorContent(page)
|
|
expect(raw).toContain('> 인용문 추가')
|
|
expect(raw).toContain('- 첫 번째 항목 계속')
|
|
expectNoBlockContainerCrash(crashSignals)
|
|
})
|
|
|
|
for (const scenario of listReopenScenarios) {
|
|
test(scenario.name, async ({ page }) => {
|
|
await expectListContentEditableAfterReopen(page, scenario)
|
|
})
|
|
}
|
|
|
|
test('Procedure prose and adjacent bullet-list content stay editable through keydown edits', async ({ page }) => {
|
|
const crashSignals = collectEditorCrashSignals(page)
|
|
const markerId = Date.now()
|
|
const paragraphMarker = `procedure-paragraph-${markerId}`
|
|
const ownerMarker = `procedure-owner-${markerId}`
|
|
const cadenceMarker = `procedure-cadence-${markerId}`
|
|
|
|
await openNote(page, 'Note B')
|
|
await toggleRawMode(page, '.cm-content')
|
|
await setRawEditorContent(page, PROCEDURE_NOTE_CONTENT)
|
|
await page.waitForTimeout(700)
|
|
await toggleRawMode(page, '.bn-editor')
|
|
|
|
const procedureParagraph = page.locator('.bn-block-content', {
|
|
hasText: 'Procedures are long-running processes',
|
|
}).first()
|
|
await expect(procedureParagraph).toBeVisible({ timeout: 5_000 })
|
|
await procedureParagraph.click()
|
|
await page.keyboard.press('End')
|
|
await page.keyboard.insertText(` ${paragraphMarker}`)
|
|
await page.waitForTimeout(700)
|
|
|
|
const ownerItem = page.locator('.bn-block-content[data-content-type="bulletListItem"]', {
|
|
hasText: 'Owner: The person responsible',
|
|
}).first()
|
|
await expect(ownerItem).toBeVisible({ timeout: 5_000 })
|
|
await ownerItem.click()
|
|
await page.keyboard.press('End')
|
|
await page.keyboard.insertText(` ${ownerMarker}`)
|
|
await page.keyboard.press('Enter')
|
|
await page.keyboard.insertText(`Cadence: Weekly ${cadenceMarker}`)
|
|
await page.waitForTimeout(900)
|
|
|
|
expectNoBlockContainerCrash(crashSignals)
|
|
|
|
await toggleRawMode(page, '.cm-content')
|
|
const raw = await getRawEditorContent(page)
|
|
expect(raw).toContain(paragraphMarker)
|
|
expect(raw).toContain(`- Owner: The person responsible ${ownerMarker}`)
|
|
expect(raw).toContain(`- Cadence: Weekly ${cadenceMarker}`)
|
|
expectNoBlockContainerCrash(crashSignals)
|
|
})
|