mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 01:47:24 +00:00
test: remove low-value mobile Markdown editor coverage
This commit is contained in:
@@ -129,11 +129,10 @@ beforeEach(() => {
|
||||
|
||||
// Page allowlisted custom keys reject over-size/unseeded writes (rulings 33.4/33.6). Modal must catch void-call rejection or page faults and drops generation.
|
||||
describe('adding a custom key when the store refuses the write', () => {
|
||||
it('does not let the refusal escape as an unhandled rejection', async () => {
|
||||
it('keeps a refused addition out of callbacks and mirrored storage', async () => {
|
||||
store.refuse = true
|
||||
const before = mirrored()
|
||||
const unhandled = vi.fn()
|
||||
process.on('unhandledRejection', unhandled)
|
||||
|
||||
const onKeysChanged = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
let renderer: ReturnType<typeof create> | null = null
|
||||
@@ -149,12 +148,10 @@ describe('adding a custom key when the store refuses the write', () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
process.off('unhandledRejection', unhandled)
|
||||
expect(unhandled).not.toHaveBeenCalled()
|
||||
|
||||
// Do not report unstored key as added to accessory row.
|
||||
expect(onKeysChanged).not.toHaveBeenCalled()
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
// Restore mirror after refused save: saveCustomKeys notes map before persistence, and next init must not include rejected value.
|
||||
expect(mirrored()).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,41 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { expect, it } from 'vitest'
|
||||
import {
|
||||
dragReorderLayout,
|
||||
dragReorderTarget,
|
||||
dragReorderPositionsFromKeys,
|
||||
moveDragReorderKey,
|
||||
orderedKeysFromDragReorderPositions
|
||||
moveDragReorderKey
|
||||
} from './drag-reorder-positions'
|
||||
|
||||
describe('drag reorder positions', () => {
|
||||
it('shifts intermediate rows up when dragging a row earlier', () => {
|
||||
const positions = dragReorderPositionsFromKeys(['a', 'b', 'c', 'd'])
|
||||
expect(orderedKeysFromDragReorderPositions(moveDragReorderKey(positions, 'd', 1))).toEqual([
|
||||
'a',
|
||||
'd',
|
||||
'b',
|
||||
'c'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it('projects a thousand variable-height rows and all row lookups with linear reads', () => {
|
||||
const keys = Array.from({ length: 1000 }, (_, index) => `row-${index}`)
|
||||
let reads = 0
|
||||
const positions = new Proxy(dragReorderPositionsFromKeys(keys), {
|
||||
get(target, key) {
|
||||
if (typeof key === 'string') {
|
||||
reads++
|
||||
return target[key]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
const sizes = Object.fromEntries(keys.map((key, index) => [key, index % 2 ? 20 : 100]))
|
||||
it('lays out variable-height rows when targeting, reordering and resizing', () => {
|
||||
const positions = dragReorderPositionsFromKeys(['row-0', 'row-1', 'row-2', 'row-3'])
|
||||
const sizes = { 'row-0': 100, 'row-1': 20, 'row-2': 100, 'row-3': 20 }
|
||||
const layout = dragReorderLayout(positions, sizes)
|
||||
expect(keys.map((key) => layout.tops[key]).at(-1)).toBe(59980)
|
||||
expect(reads).toBe(1000)
|
||||
expect(layout.tops['row-3']).toBe(220)
|
||||
expect(dragReorderTarget(positions, sizes, layout, 'row-0', 35)).toBe(1)
|
||||
const moved = moveDragReorderKey(positions, 'row-0', 2)
|
||||
expect(dragReorderLayout(moved, sizes).keys.slice(0, 3)).toEqual(['row-1', 'row-2', 'row-0'])
|
||||
|
||||
@@ -30,29 +30,10 @@ describe('an artifact rendered for a shell that cannot open a link', () => {
|
||||
expect(inert(ARTIFACT).querySelectorAll('a[href], area[href]')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps the text, the headings and the images the author wrote', () => {
|
||||
const doc = inert(ARTIFACT)
|
||||
expect(doc.getElementById('marker')?.textContent).toBe('text')
|
||||
expect(doc.getElementById('top')?.textContent).toBe('tap')
|
||||
expect(doc.getElementById('blank')?.textContent).toBe('window')
|
||||
expect(doc.getElementById('mapped')?.getAttribute('src')).toBe('x.png')
|
||||
expect(doc.title).toBe('A')
|
||||
// The elements are still there and still in order: this is a hidden affordance, not a deletion.
|
||||
expect([...doc.querySelectorAll('a')].map((one) => one.id)).toEqual([
|
||||
'top',
|
||||
'blank',
|
||||
'root',
|
||||
'empty',
|
||||
'frag',
|
||||
'named',
|
||||
'svglink'
|
||||
])
|
||||
})
|
||||
|
||||
// Preserve authored doctype bytes/identifiers; srcdoc inherits embedder’s mode regardless, so test document.doctype rather than CSS1Compat.
|
||||
it('keeps the doctype whole, identifiers and all', () => {
|
||||
const doctypeOf = (html: string) => htmlPreviewWithInertLinks(html).split('>')[0] ?? ''
|
||||
expect(doctypeOf(ARTIFACT).toLowerCase()).toBe('<!doctype html')
|
||||
|
||||
expect(
|
||||
doctypeOf('<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"><html><body>x')
|
||||
).toBe('<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"')
|
||||
@@ -63,10 +44,6 @@ describe('an artifact rendered for a shell that cannot open a link', () => {
|
||||
).toContain(
|
||||
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">'
|
||||
)
|
||||
// Leave missing doctype absent rather than inventing author markup.
|
||||
expect(htmlPreviewWithInertLinks('<html><body>x</body></html>').toLowerCase()).not.toContain(
|
||||
'<!doctype'
|
||||
)
|
||||
})
|
||||
|
||||
// Browser parse drops one leading pre/listing/textarea newline; neither Chromium/WebKit serializer restores it. Reinsert before next parse.
|
||||
@@ -84,17 +61,6 @@ describe('an artifact rendered for a shell that cannot open a link', () => {
|
||||
expect(out).toContain('<pre id="q">no newline</pre>')
|
||||
})
|
||||
|
||||
it('leaves an href inside a comment where a regex pass would have found it', () => {
|
||||
// Use parser rather than regex so href inside comment remains untouched artifact text.
|
||||
const doc = inert(
|
||||
'<!doctype html><html><body><!-- <a href="https://example.com/x">c</a> -->' +
|
||||
'<a id="real" href="https://example.com/z">r</a></body></html>'
|
||||
)
|
||||
expect(doc.body.innerHTML).toContain('href="https://example.com/x"')
|
||||
// The control: a link that is rendered did lose its href.
|
||||
expect(doc.getElementById('real')?.hasAttribute('href')).toBe(false)
|
||||
})
|
||||
|
||||
// Declarative shadowroot templates become rendered content at frame parse, but DOMParser leaves templates intact on Chromium 147/WebKit 26.4/happy-dom.
|
||||
// Traverse template.content recursively: document querySelectorAll misses links that would otherwise become dead anchors under sandbox.
|
||||
it('reaches a link inside a declarative shadow root, and inside one nested in it', () => {
|
||||
|
||||
@@ -29,8 +29,7 @@ vi.mock('lucide-react-native', () => ({
|
||||
Eye: () => null
|
||||
}))
|
||||
|
||||
const { MobileHtmlPreview, MOBILE_HTML_PREVIEW_SANDBOX, MOBILE_HTML_PREVIEW_SEALED_SANDBOX } =
|
||||
await import('./MobileHtmlPreview')
|
||||
const { MobileHtmlPreview } = await import('./MobileHtmlPreview')
|
||||
|
||||
const ARTIFACT =
|
||||
'<!doctype html><html><body><a id="x" href="https://example.com/a">tap</a></body></html>'
|
||||
@@ -76,19 +75,10 @@ describe('the frame the preview mounts for each answer the shell gives', () => {
|
||||
it('hands over an artifact with no link, in a frame that cannot navigate, when it cannot', () => {
|
||||
const frame = frameOf(false)
|
||||
// Both fences follow the one answer, which is the wiring this file exists for.
|
||||
expect(frame?.sandbox).toBe(MOBILE_HTML_PREVIEW_SEALED_SANDBOX)
|
||||
expect(frame?.sandbox).toBe('')
|
||||
expect(frame?.srcDoc).not.toContain('href')
|
||||
// The author's text is still there, so this is a hidden affordance and not a deletion.
|
||||
expect(frame?.srcDoc).toContain('tap')
|
||||
expect(frame?.srcDoc).toContain('id="x"')
|
||||
})
|
||||
|
||||
it('seals the frame with a sandbox that grants nothing, rather than dropping the attribute', () => {
|
||||
// Require empty sandbox attribute: absent/undefined grants every capability, while empty grants none.
|
||||
expect(MOBILE_HTML_PREVIEW_SEALED_SANDBOX).toBe('')
|
||||
expect(frameOf(false)?.sandbox).toBe('')
|
||||
expect(MOBILE_HTML_PREVIEW_SANDBOX.split(' ')).toContain(
|
||||
'allow-top-navigation-by-user-activation'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,20 +22,6 @@ describe('detectFilePathSegments', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('detects POSIX absolute paths', () => {
|
||||
expect(detectFilePathSegments('Wrote /Users/me/wt/src/app.tsx today')).toEqual([
|
||||
{ type: 'text', value: 'Wrote ' },
|
||||
{ type: 'file', value: '/Users/me/wt/src/app.tsx', path: '/Users/me/wt/src/app.tsx' },
|
||||
{ type: 'text', value: ' today' }
|
||||
])
|
||||
expect(detectFilePathSegments('/repo/src/index.ts')).toEqual([
|
||||
{ type: 'file', value: '/repo/src/index.ts', path: '/repo/src/index.ts' }
|
||||
])
|
||||
expect(detectFilePathSegments('/root.ts')).toEqual([
|
||||
{ type: 'file', value: '/root.ts', path: '/root.ts' }
|
||||
])
|
||||
})
|
||||
|
||||
it('detects paths with :line and :line:col suffixes', () => {
|
||||
expect(detectFilePathSegments('see src/foo.ts:42 here')).toEqual([
|
||||
{ type: 'text', value: 'see ' },
|
||||
@@ -69,15 +55,6 @@ describe('detectFilePathSegments', () => {
|
||||
expect(detectFilePathSegments('https://example.com/path/file.ts')).toEqual([
|
||||
{ type: 'text', value: 'https://example.com/path/file.ts' }
|
||||
])
|
||||
expect(detectFilePathSegments('see https://example.com/path/file.ts:42 now')).toEqual([
|
||||
{ type: 'text', value: 'see https://example.com/path/file.ts:42 now' }
|
||||
])
|
||||
})
|
||||
|
||||
it('does not match protocol-relative URLs', () => {
|
||||
expect(detectFilePathSegments('load //cdn.example.com/lib/app.js')).toEqual([
|
||||
{ type: 'text', value: 'load //cdn.example.com/lib/app.js' }
|
||||
])
|
||||
})
|
||||
|
||||
it('detects scoped-package file paths with a segment-leading @', () => {
|
||||
@@ -90,15 +67,6 @@ describe('detectFilePathSegments', () => {
|
||||
},
|
||||
{ type: 'text', value: ' here' }
|
||||
])
|
||||
expect(
|
||||
detectFilePathSegments('node_modules/@scope/pkg/file.ts').filter((s) => s.type === 'file')
|
||||
).toEqual([
|
||||
{
|
||||
type: 'file',
|
||||
value: 'node_modules/@scope/pkg/file.ts',
|
||||
path: 'node_modules/@scope/pkg/file.ts'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('does not match emails or git URLs with a mid-token @', () => {
|
||||
@@ -118,11 +86,7 @@ describe('detectFilePathSegments', () => {
|
||||
})
|
||||
|
||||
describe('isFilePathCodeSpan', () => {
|
||||
it('accepts POSIX absolute paths and :line citations', () => {
|
||||
expect(isFilePathCodeSpan('/Users/me/wt/src/app.tsx')).toBe(true)
|
||||
expect(isFilePathCodeSpan('src/foo.ts:42')).toBe(true)
|
||||
expect(isFilePathCodeSpan('src/foo.ts:42:7')).toBe(true)
|
||||
it('accepts a bare file name with a line citation', () => {
|
||||
expect(isFilePathCodeSpan('MobileNativeChatComposer.tsx:23')).toBe(true)
|
||||
expect(isFilePathCodeSpan(String.raw`C:\repo\Main.tsx:12`)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { routeMarkdownHref } from './markdown-href-routing'
|
||||
|
||||
describe('routeMarkdownHref', () => {
|
||||
it('carries a #L fragment as a :line suffix', () => {
|
||||
expect(routeMarkdownHref('docs/plan.md#L42')).toEqual({
|
||||
kind: 'file',
|
||||
pathText: 'docs/plan.md:42'
|
||||
})
|
||||
expect(routeMarkdownHref('docs/plan.md?plain=1#line-7')).toEqual({
|
||||
kind: 'file',
|
||||
pathText: 'docs/plan.md:7'
|
||||
})
|
||||
expect(routeMarkdownHref('docs/plan.md#usage')).toEqual({
|
||||
kind: 'file',
|
||||
pathText: 'docs/plan.md'
|
||||
})
|
||||
})
|
||||
|
||||
it('routes file: URIs to the file opener', () => {
|
||||
expect(routeMarkdownHref('file:///Users/me/wt/src/app.tsx')).toEqual({
|
||||
kind: 'file',
|
||||
pathText: '/Users/me/wt/src/app.tsx'
|
||||
})
|
||||
expect(routeMarkdownHref('file:///Users/me/wt/src/app.tsx#L12')).toEqual({
|
||||
kind: 'file',
|
||||
pathText: '/Users/me/wt/src/app.tsx:12'
|
||||
})
|
||||
expect(routeMarkdownHref('file:///C:/repo/src/index.ts')).toEqual({
|
||||
kind: 'file',
|
||||
pathText: 'C:/repo/src/index.ts'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -40,25 +40,6 @@ describe('text growing a character at a time', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a fenced block whole across its blank lines', () => {
|
||||
expect(grown('```\na\n\nb\n```\n\nafter')).toEqual([
|
||||
{
|
||||
type: 'code',
|
||||
lang: null,
|
||||
value: 'a\n\nb',
|
||||
closed: true,
|
||||
position: { start: { line: 1, column: 1 }, end: { line: 5, column: 1 } }
|
||||
},
|
||||
paragraph('after')
|
||||
])
|
||||
})
|
||||
|
||||
it('makes one math block once a display formula closes past a blank line', () => {
|
||||
expect(grown('$$\nx\n\ny\n$$')).toEqual([
|
||||
{ type: 'math', value: 'x\n\ny', source: '$$\nx\n\ny\n$$' }
|
||||
])
|
||||
})
|
||||
|
||||
it('ends a quote at a display formula that closes past a blank line', () => {
|
||||
expect(grown('> a\n$$\n\nb\n$$')).toEqual([
|
||||
{ type: 'blockquote', children: [paragraph('a')] },
|
||||
|
||||
@@ -31,21 +31,16 @@ describe('math in mobile Markdown follows desktop chat', () => {
|
||||
['I:\\color{#1a7f37}{\\Huge{\\mathbf{+}}}'],
|
||||
'\u200b37'
|
||||
],
|
||||
['prices', '$10 and $20', [], '$10 and $20'],
|
||||
['shell variables', 'echo $HOME and $PATH', [], 'echo $HOME and $PATH'],
|
||||
[
|
||||
'a price with operators',
|
||||
'Keep $5 + 2$ typed, render $5+2$',
|
||||
['I:5+2'],
|
||||
'Keep $5 + 2$ typed, render '
|
||||
],
|
||||
['a price then a formula', '$5 $x$', ['I:x'], '$5 '],
|
||||
['a block that interrupts a paragraph', 'para\n$$\nx=1\n$$\nafter', ['D:x=1'], 'paraafter'],
|
||||
['display formulas alone in a paragraph', '$$a$$ $$b$$', ['D:a', 'D:b'], ''],
|
||||
['a display opener among prose', '$$x$$ tail', ['I:x'], ' tail'],
|
||||
['inline and bracket delimiters', '\\(a\\) and \\[b\\]', ['I:a', 'I:b'], ' and '],
|
||||
['a citation', '\\[1, 2\\]', [], '[1, 2]'],
|
||||
['an unclosed block', '$$\nunclosed', [], '$$\nunclosed'],
|
||||
['a formula in a link label', '[$x$](http://a)', [], '$x$'],
|
||||
['a closed math fence', '```math\nx^2\n```', ['D:x^2'], '']
|
||||
])('%s', (_name, source, math, text) => {
|
||||
|
||||
@@ -106,36 +106,4 @@ describe('the rich markdown editor on the page', () => {
|
||||
})
|
||||
expect(onChange.mock.calls).toEqual([['edited']])
|
||||
})
|
||||
|
||||
it('keeps the document and toolbar read-only until editing is enabled', () => {
|
||||
const host = document.createElement('div')
|
||||
const onChange = vi.fn()
|
||||
const editor = (editable: boolean) =>
|
||||
createElement(MobileRichMarkdownEditor, {
|
||||
content: 'body',
|
||||
editable,
|
||||
onChange
|
||||
})
|
||||
const renderer = render(editor(false), host)
|
||||
const surface = host.querySelector<HTMLElement>('#editor')!
|
||||
const buttons = findHosts(renderer, 'Pressable')
|
||||
expect(buttons.length).toBeGreaterThan(0)
|
||||
expect(buttons.filter((node) => node.props.disabled !== true)).toEqual([])
|
||||
expect(surface.getAttribute('contenteditable')).toBe('false')
|
||||
act(() => {
|
||||
surface.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
})
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
|
||||
act(() => renderer.update(editor(true)))
|
||||
const enabled = findHosts(renderer, 'Pressable')
|
||||
expect(enabled).toHaveLength(buttons.length)
|
||||
expect(enabled.filter((node) => node.props.disabled === true)).toEqual([])
|
||||
expect(surface.getAttribute('contenteditable')).toBe('true')
|
||||
act(() => {
|
||||
surface.innerHTML = '<p>accepted</p>'
|
||||
surface.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
})
|
||||
expect(onChange.mock.calls).toEqual([['accepted']])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,6 @@ import { prCommentSeverity } from './pr-comment-severity'
|
||||
describe('prCommentSeverity', () => {
|
||||
it.each([
|
||||
['_⚠️ Potential issue_ | _🟠 Major_\n\n**Guard the null case.**', 'major'],
|
||||
['_⚠️ Potential issue_ | _🔴 Critical_', 'critical'],
|
||||
['_🧹 Nitpick_ | _🔵 Trivial_', 'trivial'],
|
||||
['**Minor**: rename this.', 'minor'],
|
||||
['_Major_', 'major']
|
||||
@@ -14,11 +13,8 @@ describe('prCommentSeverity', () => {
|
||||
|
||||
it.each([
|
||||
'This is a minor race in the poller.',
|
||||
'Critical path: the merge button.',
|
||||
'_Majority_',
|
||||
'Bumps `semver_major_bump` in ci',
|
||||
'Set `ALLOW_MINOR_UPDATES` to true',
|
||||
''
|
||||
'Set `ALLOW_MINOR_UPDATES` to true'
|
||||
])('ignores the word outside a severity marker in %j', (body) => {
|
||||
expect(prCommentSeverity(body)).toBeNull()
|
||||
})
|
||||
|
||||
@@ -47,7 +47,6 @@ describe('the editor document content', () => {
|
||||
`data:image/png;base64,${btoa('images/diagram.png')}`
|
||||
)
|
||||
)
|
||||
expect(loadImageSource).toHaveBeenCalledExactlyOnceWith('images/diagram.png')
|
||||
expect(editor.editor.querySelectorAll('img')[1]?.getAttribute('src')).toBe(
|
||||
'https://example.com/remote.png'
|
||||
)
|
||||
@@ -76,16 +75,6 @@ describe('the editor document content', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('writes a ticked checkbox back into the markup the next serialization reads', () => {
|
||||
const editor = editorDocument()
|
||||
editor.handle.setMarkdown('- [ ] Open', 1)
|
||||
const checkbox = editor.editor.querySelector('input')!
|
||||
checkbox.checked = true
|
||||
checkbox.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
expect(editor.editor.querySelector('li')!.getAttribute('data-checked')).toBe('true')
|
||||
expect(editor.posted).toEqual([{ type: 'change', markdown: '- [x] Open', generation: 1 }])
|
||||
})
|
||||
|
||||
it('reports one change for a checkbox tap, not one per event the tap raises', () => {
|
||||
// Assert click/input/change all bubble for one tick, then prove only one report; missing events cannot satisfy count accidentally.
|
||||
const editor = editorDocument()
|
||||
@@ -99,20 +88,4 @@ describe('the editor document content', () => {
|
||||
expect(editor.editor.querySelector('li')!.getAttribute('data-checked')).toBe('true')
|
||||
expect(editor.posted).toEqual([{ type: 'change', markdown: '- [x] Open', generation: 1 }])
|
||||
})
|
||||
|
||||
it('reports one change for inline code over a selection, not one per emitting step', async () => {
|
||||
// The wrap helper emitted and `runCommand` emits after every command: two identical changes.
|
||||
const editor = editorDocument()
|
||||
editor.handle.setMarkdown('one two', 1)
|
||||
const text = editor.editor.querySelector('p')!.firstChild!
|
||||
const range = document.createRange()
|
||||
range.setStart(text, 0)
|
||||
range.setEnd(text, 3)
|
||||
const selection = window.getSelection()!
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
await editor.handle.runCommand('inlineCode')
|
||||
expect(editor.editor.innerHTML).toBe('<p><code>one</code> two</p>')
|
||||
expect(editor.posted).toEqual([{ type: 'change', markdown: '`one` two', generation: 1 }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,9 +33,7 @@ function select(range: Range) {
|
||||
}
|
||||
|
||||
it.each([
|
||||
['first\n\nsecond\n\nthird', '`first`\n\n`second`\n\n`third`', ['first', 'second', 'third']],
|
||||
['# Heading\n\n> Quoted', '# `Heading`\n\n> `Quoted`', ['Heading', 'Quoted']],
|
||||
['- first\n- second', '- `first`\n- `second`', ['first', 'second']],
|
||||
['- [ ] first\n- [x] second', '- [ ] `first`\n- [x] `second`', ['first', 'second']],
|
||||
[
|
||||
'left  right',
|
||||
|
||||
@@ -113,7 +113,6 @@ describe('the editor document caret, across a keyboard dismissal', () => {
|
||||
it('restores the pre-dismissal caret so a command does not insert at the end', async () => {
|
||||
const editor = runtime({ caret: 'paragraph-3' })
|
||||
editor.handle.dismissKeyboard()
|
||||
expect(editor.selectedContainer()).toBe(null)
|
||||
await editor.handle.runCommand('bold')
|
||||
expect(editor.selectedContainer()).toBe('paragraph-3')
|
||||
expect(editor.focused()).toBe(true)
|
||||
|
||||
@@ -13,7 +13,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
['first<br>second', 'first\nsecond'],
|
||||
['<code>first</code><br><br><code>second</code>', 'first\n\nsecond'],
|
||||
['prefix <code>middle</code> suffix', 'prefix middle suffix'],
|
||||
['<code>last line</code><br><br>', 'last line']
|
||||
|
||||
@@ -26,11 +26,8 @@ function mount() {
|
||||
}
|
||||
|
||||
it.each([
|
||||
['a`b', '``a`b``'],
|
||||
['a``b', '```a``b```'],
|
||||
['`edge', '`` `edge ``'],
|
||||
['edge`', '`` edge` ``'],
|
||||
['`', '`` ` ``'],
|
||||
[' surrounded ', '` surrounded `'],
|
||||
[' ', '` `']
|
||||
])('preserves selected inline code %j through the host and a reload', async (literal, saved) => {
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { quietRichMarkdownEditorHost } from './rich-markdown-editor-host.test-support'
|
||||
import { MOBILE_MARKDOWN_EDIT_MAX_BYTES } from '../../../../src/shared/mobile-markdown-document'
|
||||
import { createRichMarkdownEditorDocument } from './create-rich-markdown-editor-document'
|
||||
import { RICH_MARKDOWN_EDITOR_MARKUP } from './document-markup'
|
||||
|
||||
import type { MobileRichMarkdownEditorMessage } from '../mobile-rich-markdown-editor-contract'
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('code-block fence sizing', () => {
|
||||
it('serializes an admitted editable document without collecting every backtick run', () => {
|
||||
document.body.innerHTML = RICH_MARKDOWN_EDITOR_MARKUP
|
||||
const posted: MobileRichMarkdownEditorMessage[] = []
|
||||
const editorDocument = createRichMarkdownEditorDocument(
|
||||
quietRichMarkdownEditorHost({
|
||||
postToHost: (message) => posted.push(message),
|
||||
keyboardInsetSource: () => null
|
||||
})
|
||||
)
|
||||
try {
|
||||
const code = '`a'.repeat((MOBILE_MARKDOWN_EDIT_MAX_BYTES - 40) / 2)
|
||||
const markdown = `\`\`\`ts\n${code}\n\`\`\``
|
||||
expect(markdown.length).toBeLessThan(MOBILE_MARKDOWN_EDIT_MAX_BYTES)
|
||||
editorDocument.send.setMarkdown(markdown, 7)
|
||||
editorDocument.send.setEditable(true)
|
||||
const editor = document.getElementById('editor')
|
||||
const codeElement = editor?.querySelector('code')
|
||||
if (!editor || !codeElement) {
|
||||
throw new Error('The loaded Markdown document has no code block')
|
||||
}
|
||||
codeElement.textContent = code + 'a'
|
||||
posted.length = 0
|
||||
|
||||
const match = String.prototype.match
|
||||
let largestMatchArray = 0
|
||||
vi.spyOn(String.prototype, 'match').mockImplementation(function (this: string, pattern) {
|
||||
const matches = match.call(this, pattern)
|
||||
if (matches) {
|
||||
largestMatchArray = Math.max(largestMatchArray, matches.length)
|
||||
}
|
||||
return matches
|
||||
})
|
||||
editor.dispatchEvent(new Event('input'))
|
||||
|
||||
const expected = `\`\`\`ts\n${code}a\n\`\`\``
|
||||
expect(expected.length).toBeLessThan(MOBILE_MARKDOWN_EDIT_MAX_BYTES)
|
||||
expect(posted).toEqual([{ type: 'change', markdown: expected, generation: 7 }])
|
||||
expect(largestMatchArray).toBeLessThan(1024)
|
||||
} finally {
|
||||
editorDocument.stop()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -31,7 +31,6 @@ it.each([
|
||||
editorDocument.send.setMarkdown(original, 7)
|
||||
const surface = document.getElementById('editor')!
|
||||
expect(surface.querySelector('code')?.textContent).toBe(codeText)
|
||||
expect(editorDocument.send.currentMarkdown()).toBe(original)
|
||||
|
||||
surface.lastElementChild!.append('!')
|
||||
surface.dispatchEvent(
|
||||
@@ -60,7 +59,6 @@ it('preserves text after formatting the gap between inline code spans', async ()
|
||||
|
||||
await editorDocument.send.runCommand('inlineCode')
|
||||
|
||||
expect(editorDocument.send.currentMarkdown()).toBe('`old middle tail`')
|
||||
expect(messages.filter((message) => message.type === 'change')).toEqual([
|
||||
{ type: 'change', generation: 7, markdown: '`old middle tail`' }
|
||||
])
|
||||
@@ -68,24 +66,23 @@ it('preserves text after formatting the gap between inline code spans', async ()
|
||||
expect(document.querySelector('#editor code')?.textContent).toBe('old middle tail')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['<code>a`</code><code>``b</code>', '````a```b````', 'a```b'],
|
||||
['<code> left</code><code>right </code>', '` leftright `', ' leftright ']
|
||||
])('preserves adjacent literal code through save and reopen: %s', (html, markdown, text) => {
|
||||
document.body.innerHTML = RICH_MARKDOWN_EDITOR_MARKUP
|
||||
const editorDocument = createRichMarkdownEditorDocument(quietRichMarkdownEditorHost())
|
||||
stopDocuments.push(editorDocument.stop)
|
||||
const surface = document.getElementById('editor')!
|
||||
surface.innerHTML = `<p>${html}</p>`
|
||||
it.each([['<code>a`</code><code>``b</code>', '````a```b````', 'a```b']])(
|
||||
'preserves adjacent literal code through save and reopen: %s',
|
||||
(html, markdown, text) => {
|
||||
document.body.innerHTML = RICH_MARKDOWN_EDITOR_MARKUP
|
||||
const editorDocument = createRichMarkdownEditorDocument(quietRichMarkdownEditorHost())
|
||||
stopDocuments.push(editorDocument.stop)
|
||||
const surface = document.getElementById('editor')!
|
||||
surface.innerHTML = `<p>${html}</p>`
|
||||
|
||||
expect(editorDocument.send.currentMarkdown()).toBe(markdown)
|
||||
editorDocument.send.setMarkdown(markdown, 8)
|
||||
expect(surface.querySelector('code')?.textContent).toBe(text)
|
||||
expect(editorDocument.send.currentMarkdown()).toBe(markdown)
|
||||
})
|
||||
expect(editorDocument.send.currentMarkdown()).toBe(markdown)
|
||||
editorDocument.send.setMarkdown(markdown, 8)
|
||||
expect(surface.querySelector('code')?.textContent).toBe(text)
|
||||
expect(editorDocument.send.currentMarkdown()).toBe(markdown)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['inline code', '<p><code>x\u00a0 z\u00a0</code> after</p>', '`x z ` after'],
|
||||
['an inline code line break', '<p><code>first<br>second</code></p>', '`first second`'],
|
||||
['a code block', '<pre><code>\u00a0 \u00a0if x:</code></pre>', '```\n if x:\n```']
|
||||
])('saves browser-created spacing in %s as ordinary spaces', (_name, html, markdown) => {
|
||||
|
||||
@@ -28,8 +28,6 @@ function mount(markdown: string) {
|
||||
}
|
||||
|
||||
it.each([
|
||||
['- [ ]', '- [ ]', [false]],
|
||||
['- [x]', '- [x]', [true]],
|
||||
['- [ ] ', '- [ ]', [false]],
|
||||
['- [ ] Parent\n - [x]', '- [ ] Parent\n - [x]', [false, true]]
|
||||
])(
|
||||
|
||||
@@ -6,10 +6,7 @@ import { quietRichMarkdownEditorHost } from './rich-markdown-editor-host.test-su
|
||||
import type { MobileRichMarkdownEditorMessage } from '../mobile-rich-markdown-editor-contract'
|
||||
|
||||
const stopDocuments: (() => void)[] = []
|
||||
const lists = [
|
||||
['bullet', '- First\n- Next\n\nOutside', 'li'],
|
||||
['task', '- [ ] First\n- [x] Next\n\nOutside', 'li > div']
|
||||
] as const
|
||||
const lists = [['task', '- [ ] First\n- [x] Next\n\nOutside', 'li > div']] as const
|
||||
|
||||
function surface(source: string) {
|
||||
document.body.innerHTML = RICH_MARKDOWN_EDITOR_MARKUP
|
||||
@@ -71,10 +68,7 @@ for (const [kind, source, selector] of lists) {
|
||||
it.each([
|
||||
['<p>First<br></p><p>Second</p>', 'First<br />Second'],
|
||||
['<p>First</p><p><br></p><p>Second</p>', 'First<br /><br />Second'],
|
||||
['<p>First</p>\n <p>Second</p>', 'First<br />Second'],
|
||||
['<p>First\n</p><p>Second</p>', 'First<br />Second'],
|
||||
['First<div>Second</div>Third', 'First<br />Second<br />Third'],
|
||||
['First<br><div>Second</div>', 'First<br />Second'],
|
||||
['<strong>First</strong> <em>Second</em>', '**First** *Second*']
|
||||
])('keeps existing breaks, empty lines and inline spaces without duplication: %j', (html, text) => {
|
||||
const { editorDocument, editor } = surface('- First\n- Next')
|
||||
|
||||
@@ -30,7 +30,6 @@ afterEach(() => {
|
||||
|
||||
for (const [kind, source, selector] of lists) {
|
||||
it.each([
|
||||
['First<br>Second', 'First<br />Second'],
|
||||
['<strong>First<br>Second</strong>', '**First<br />Second**'],
|
||||
['<code>First</code><br><code>Second</code>', '`First`<br />`Second`']
|
||||
])(`keeps ${kind} item soft break inside its list through host save: %s`, (html, text) => {
|
||||
@@ -107,7 +106,6 @@ it.each([
|
||||
|
||||
it.each([
|
||||
['<span>First</span><br>', 'First'],
|
||||
['<p>First<br></p>', 'First'],
|
||||
['<p><strong>First<br></strong></p>', '**First**'],
|
||||
['<span>First</span><br><ul><li><p>Child</p></li></ul>', 'First\n - Child'],
|
||||
['<p>First<br><br></p>', 'First<br /><br />'],
|
||||
|
||||
@@ -103,21 +103,3 @@ it('keeps quoted code literal rather than interpreting its block markers', () =>
|
||||
expect(editor.querySelectorAll('blockquote').length).toBe(1)
|
||||
expect(editorDocument.send.currentMarkdown()).toBe(markdown)
|
||||
})
|
||||
|
||||
it('keeps task state and read-only controls inside quotes', () => {
|
||||
const { editorDocument, editor } = surface()
|
||||
const markdown = '> - [x] Done\n> - [ ] Open'
|
||||
editorDocument.send.setEditable(false)
|
||||
editorDocument.send.setMarkdown(markdown, 8)
|
||||
|
||||
expect(
|
||||
Array.from(editor.querySelectorAll('input')).map((input) => ({
|
||||
checked: input.checked,
|
||||
disabled: input.disabled
|
||||
}))
|
||||
).toEqual([
|
||||
{ checked: true, disabled: true },
|
||||
{ checked: false, disabled: true }
|
||||
])
|
||||
expect(editorDocument.send.currentMarkdown()).toBe(markdown)
|
||||
})
|
||||
|
||||
@@ -107,32 +107,6 @@ describe('the editor document, from markdown and back', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('round-trips every block the toolbar can produce', () => {
|
||||
const markdown = [
|
||||
'# Title',
|
||||
'',
|
||||
'Body with **bold**, *italic*, ~~strike~~ and `code`.',
|
||||
'',
|
||||
'> Quoted line',
|
||||
'',
|
||||
'| a | b |',
|
||||
'| --- | --- |',
|
||||
'| 1 | 2 |',
|
||||
'',
|
||||
'```ts',
|
||||
'const x = 1',
|
||||
'```',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'[docs](https://example.com/docs)',
|
||||
'',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
expect(currentMarkdown(surface(markdown).scope)).toBe(markdown)
|
||||
})
|
||||
|
||||
it('makes progress on a marker with nothing after it, rather than reading it forever', () => {
|
||||
// Bare block markers pass isBlockStart but item/heading readers require text; ensure index advances as plain text instead of looping forever.
|
||||
const render = (markdown: string) =>
|
||||
@@ -153,15 +127,6 @@ describe('the editor document, from markdown and back', () => {
|
||||
expect(render('- ok')).toBe('<ul><li><p>ok</p></li></ul>')
|
||||
})
|
||||
|
||||
it('round-trips a code block that holds a fence of its own', () => {
|
||||
// Choose closing fence longer than any inner backtick run or reader ends code at its own content.
|
||||
const markdown = ['````', '```', 'nested', '```', '````'].join('\n')
|
||||
const { scope, html } = surface(markdown)
|
||||
|
||||
expect(html).toContain('<pre data-language=""><code>```\nnested\n```</code></pre>')
|
||||
expect(currentMarkdown(scope)).toBe(markdown)
|
||||
})
|
||||
|
||||
it('round-trips a table cell that holds a pipe, and the backslash that hid it', () => {
|
||||
// Escape cell backslashes before pipes so literal pipe remains distinct from row separator.
|
||||
const markdown = ['| a \\| b | c\\\\d |', '| --- | --- |', '| 1 \\| 2 | 3 |'].join('\n')
|
||||
|
||||
@@ -32,7 +32,6 @@ function cellTexts(editor: HTMLElement) {
|
||||
}
|
||||
|
||||
it.each([
|
||||
['First<br>Second', 'First<br />Second'],
|
||||
['<strong>First<br>Second</strong>', '**First<br />Second**'],
|
||||
['<code>First</code><br><code>Second</code>', '`First`<br />`Second`']
|
||||
])('keeps a cell line break in its own table row through host save: %s', (html, cell) => {
|
||||
@@ -71,20 +70,17 @@ it.each([
|
||||
expect(editor.querySelector('td')?.querySelector('br')).toBe(null)
|
||||
})
|
||||
|
||||
it.each(['<br>', '<BR />', 'a<br/>b', '<br onload="run()">'])(
|
||||
'keeps typed table markup literal: %s',
|
||||
(text) => {
|
||||
const { editorDocument, editor } = surface()
|
||||
editor.querySelector('td')!.textContent = text
|
||||
const markdown = editorDocument.send.currentMarkdown()
|
||||
editorDocument.send.setMarkdown(markdown, 8)
|
||||
it.each(['a<br/>b', '<br onload="run()">'])('keeps typed table markup literal: %s', (text) => {
|
||||
const { editorDocument, editor } = surface()
|
||||
editor.querySelector('td')!.textContent = text
|
||||
const markdown = editorDocument.send.currentMarkdown()
|
||||
editorDocument.send.setMarkdown(markdown, 8)
|
||||
|
||||
expect(editor.querySelector('td')?.textContent).toBe(text)
|
||||
expect(editor.querySelector('td')?.children.length).toBe(0)
|
||||
expect(editor.querySelectorAll('tr').length).toBe(2)
|
||||
expect(editorDocument.send.currentMarkdown()).toBe(markdown)
|
||||
}
|
||||
)
|
||||
expect(editor.querySelector('td')?.textContent).toBe(text)
|
||||
expect(editor.querySelector('td')?.children.length).toBe(0)
|
||||
expect(editor.querySelectorAll('tr').length).toBe(2)
|
||||
expect(editorDocument.send.currentMarkdown()).toBe(markdown)
|
||||
})
|
||||
|
||||
it('keeps a typed break literal when the browser splits it across text nodes', () => {
|
||||
const { editorDocument, editor } = surface()
|
||||
|
||||
@@ -1,25 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { scopeDocumentStyleToHost } from '../../style-scoping/document-style-scoping'
|
||||
import { richMarkdownEditorStyle } from './document-style'
|
||||
|
||||
// Scope all editor selectors to mount host: native :root/*/html/body rules otherwise restyle whole RN Web app after editor unmount.
|
||||
// Move globals onto host, including theme variables/color/font/box model; unlike terminal, editor owns these page rules rather than delegating to app seam.
|
||||
const PREFIX = '.orca-rich-markdown-document-host'
|
||||
|
||||
describe('the editor stylesheet on the page', () => {
|
||||
it('moves the document’s own rules onto the host rather than dropping them', () => {
|
||||
const scoped = scopeDocumentStyleToHost(richMarkdownEditorStyle(), PREFIX)
|
||||
// Keep theme variables on host or correctly scoped selectors still render unstyled.
|
||||
expect(scoped).toContain('--foreground:')
|
||||
const rootRules = scoped
|
||||
.split('}')
|
||||
.filter((rule) => rule.includes('--foreground:') || rule.includes('min-height: 100%'))
|
||||
expect(rootRules.length).toBeGreaterThan(0)
|
||||
for (const rule of rootRules) {
|
||||
expect(rule.trimStart().startsWith(PREFIX)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses a document-level selector it cannot move, rather than scoping it wrongly', () => {
|
||||
// Refuse compound host-global selector rather than silently rewriting body p into mismatching .host body p or changed .host p.
|
||||
expect(() => scopeDocumentStyleToHost('body p { color: red; }', PREFIX)).toThrow(
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { quietRichMarkdownEditorHost } from './rich-markdown-editor-host.test-support'
|
||||
import { createRichMarkdownEditorDocument } from './create-rich-markdown-editor-document'
|
||||
import { RICH_MARKDOWN_EDITOR_MARKUP } from './document-markup'
|
||||
|
||||
import type { RichMarkdownEditorDocument } from './document-host-seams'
|
||||
|
||||
// Page supplies messages, URL prompt and keyboard seams: shell owns ReactNativeWebView, window.prompt returns null, and screen already measures keyboard; test remaining live-window defaults.
|
||||
const startedDocuments: RichMarkdownEditorDocument[] = []
|
||||
|
||||
afterEach(() => {
|
||||
while (startedDocuments.length > 0) {
|
||||
startedDocuments.pop()!.stop()
|
||||
}
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('the editor document host seams, once the page sets them', () => {
|
||||
it('takes the caret from the selection the host names, not the window one', async () => {
|
||||
// Save/restore only host selection: overlapping fields share window, and global selection belongs to whichever other field holds focus.
|
||||
document.body.innerHTML = RICH_MARKDOWN_EDITOR_MARKUP
|
||||
const editor = document.getElementById('editor')!
|
||||
editor.innerHTML = '<p id="only">text</p>'
|
||||
const ranges: Range[] = []
|
||||
const windowSelection = window.getSelection()!
|
||||
windowSelection.removeAllRanges()
|
||||
// WebKit's own behaviour, and the reason the document saves a caret at all.
|
||||
editor.addEventListener('blur', () => {
|
||||
ranges.length = 0
|
||||
})
|
||||
|
||||
const hostSelection: Selection = Object.create(windowSelection)
|
||||
Object.defineProperty(hostSelection, 'rangeCount', { get: () => ranges.length })
|
||||
hostSelection.getRangeAt = (index: number) => ranges[index]!
|
||||
hostSelection.removeAllRanges = () => {
|
||||
ranges.length = 0
|
||||
}
|
||||
hostSelection.addRange = (range: Range) => {
|
||||
ranges.push(range)
|
||||
}
|
||||
|
||||
const caret = document.createRange()
|
||||
caret.selectNodeContents(document.getElementById('only')!)
|
||||
caret.collapse(true)
|
||||
ranges.push(caret)
|
||||
|
||||
const started = createRichMarkdownEditorDocument(
|
||||
quietRichMarkdownEditorHost({
|
||||
getSelection: () => hostSelection,
|
||||
getDocument: () => hostDocument([]),
|
||||
keyboardInsetSource: () => null,
|
||||
postToHost: () => {}
|
||||
})
|
||||
)
|
||||
startedDocuments.push(started)
|
||||
|
||||
// Saved out of the host's selection, which the blur then empties.
|
||||
editor.focus()
|
||||
started.send.dismissKeyboard()
|
||||
expect(ranges).toEqual([])
|
||||
// And restored into the host's selection rather than the window's.
|
||||
await started.send.runCommand('bold')
|
||||
expect(
|
||||
ranges.map((range) => {
|
||||
const container = range.commonAncestorContainer
|
||||
return (container instanceof Element ? container : container.parentElement)?.id
|
||||
})
|
||||
).toEqual(['only'])
|
||||
expect(windowSelection.rangeCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// Host supplies real document with recorded execCommand because happy-dom lacks it; prove no global command lookup.
|
||||
function hostDocument(commands: [string, boolean, string | undefined][]): Document {
|
||||
const page: Document = Object.create(document)
|
||||
page.execCommand = (command: string, showUI?: boolean, value?: string) => {
|
||||
commands.push([command, showUI === true, value])
|
||||
return true
|
||||
}
|
||||
return page
|
||||
}
|
||||
Reference in New Issue
Block a user