-
Notifications
You must be signed in to change notification settings - Fork 1k
chore(editor): snapshot and unit testing #3487
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: canary
Are you sure you want to change the base?
Changes from 19 commits
41d86af
33b5b39
d7df09f
1cd63a6
ff56263
08b3527
20f9bcc
f09a759
dc00ce0
79b179a
0321127
d1a8054
3fb852c
db6e7a6
263f498
367a73e
0bfd284
13c2b96
37094f6
58495cf
09e1568
87f3e83
cd1984b
8665d28
b9fec52
2a95350
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| import type { JSONContent } from '@tiptap/core'; | ||
| import * as fc from 'fast-check'; | ||
|
|
||
| /** | ||
| * Generators for `fast-check` property tests. | ||
| * | ||
| * Constrained intentionally to keep shrinking fast on ProseMirror trees: | ||
| * - shallow nesting (heading/paragraph/list only) | ||
| * - small text content | ||
| * - schema-valid by construction (so `fc.pre()` filters are rare) | ||
| */ | ||
|
|
||
| const safeText = fc | ||
| .string({ minLength: 1, maxLength: 30 }) | ||
| .filter((s) => /^[a-zA-Z0-9 .,!?-]+$/.test(s)); | ||
|
|
||
| const textNode = safeText.map<JSONContent>((text) => ({ type: 'text', text })); | ||
|
|
||
| const paragraphNode = fc | ||
| .array(textNode, { minLength: 1, maxLength: 3 }) | ||
| .map<JSONContent>((content) => ({ type: 'paragraph', content })); | ||
|
|
||
| const headingNode = fc | ||
| .tuple(fc.integer({ min: 1, max: 3 }), safeText) | ||
| .map<JSONContent>(([level, text]) => ({ | ||
| type: 'heading', | ||
| attrs: { level }, | ||
| content: [{ type: 'text', text }], | ||
| })); | ||
|
|
||
| const listItemNode = paragraphNode.map<JSONContent>((p) => ({ | ||
| type: 'listItem', | ||
| content: [p], | ||
| })); | ||
|
|
||
| const bulletListNode = fc | ||
| .array(listItemNode, { minLength: 1, maxLength: 3 }) | ||
| .map<JSONContent>((content) => ({ type: 'bulletList', content })); | ||
|
|
||
| const blockNode: fc.Arbitrary<JSONContent> = fc.oneof( | ||
| paragraphNode, | ||
| headingNode, | ||
| bulletListNode, | ||
| ); | ||
|
|
||
| /** | ||
| * Arbitrary doc with up to `maxBlocks` top-level block nodes. | ||
| */ | ||
| export function proseMirrorDocArbitrary( | ||
| options: { maxBlocks?: number } = {}, | ||
| ): fc.Arbitrary<JSONContent> { | ||
| const maxBlocks = options.maxBlocks ?? 5; | ||
| return fc | ||
| .array(blockNode, { minLength: 1, maxLength: maxBlocks }) | ||
| .map((content) => ({ type: 'doc', content })); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| import { | ||
| type AnyExtension, | ||
| Editor, | ||
| type EditorOptions, | ||
| type JSONContent, | ||
| } from '@tiptap/core'; | ||
| import { StarterKit } from '../extensions'; | ||
|
|
||
| interface CreateTestEditorOptions { | ||
| content?: EditorOptions['content']; | ||
| extensions?: AnyExtension[]; | ||
| editorProps?: EditorOptions['editorProps']; | ||
| } | ||
|
|
||
| /** | ||
| * Creates a real tiptap `Editor` instance for unit tests. | ||
| * Mirrors the inline `createEditorWithContent` pattern from | ||
| * `core/serializer/compose-react-email.spec.tsx` so all specs share the | ||
| * same setup. Always remember to `editor.destroy()` in `afterEach`. | ||
| */ | ||
| export function createTestEditor( | ||
| options: CreateTestEditorOptions = {}, | ||
| ): Editor { | ||
| const editorOptions: EditorOptions = { | ||
| extensions: options.extensions ?? [StarterKit], | ||
| } as EditorOptions; | ||
| if (options.content !== undefined) { | ||
| (editorOptions as { content: EditorOptions['content'] }).content = | ||
| options.content; | ||
| } | ||
| if (options.editorProps !== undefined) { | ||
| (editorOptions as { editorProps: EditorOptions['editorProps'] }).editorProps = | ||
| options.editorProps; | ||
| } | ||
| return new Editor(editorOptions); | ||
| } | ||
|
|
||
| /** | ||
| * Convenience: returns the doc JSON for a single-paragraph string of `text`. | ||
| */ | ||
| export function paragraphDoc(text: string): JSONContent { | ||
| return { | ||
| type: 'doc', | ||
| content: [ | ||
| { | ||
| type: 'paragraph', | ||
| content: text ? [{ type: 'text', text }] : undefined, | ||
| }, | ||
| ], | ||
| }; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| <blockquote>To be, or not to be.</blockquote> | ||
| <p>Inline <code>code()</code> mid-sentence.</p> | ||
| <pre><code>const x = 1; | ||
| const y = 2; | ||
| </code></pre> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| <h1>Welcome</h1> | ||
| <h2>Subheader</h2> | ||
| <p>Intro paragraph.</p> | ||
| <ul> | ||
| <li>One</li> | ||
| <li>Two</li> | ||
| </ul> | ||
| <ol> | ||
| <li>First</li> | ||
| <li>Second</li> | ||
| </ol> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| <p style="color: rgb(255, 0, 0); font-weight: 700;">Red bold paragraph.</p> | ||
| <h2 style="color: #1f3864;">Heading with color.</h2> | ||
| <p>Plain after.</p> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| <p>Visit <a href="https://example.com">our site</a> for more.</p> | ||
| <p><img src="https://example.com/logo.png" alt="Logo" width="200" height="50"></p> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| <p>Some <strong>bold</strong>, <em>italic</em>, <u>underline</u>, and <s>strike</s> text.</p> | ||
| <p>And <strong><em>both bold and italic</em></strong> together.</p> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| <table> | ||
| <tr> | ||
| <td>Cell A</td> | ||
| <td>Cell B</td> | ||
| </tr> | ||
| <tr> | ||
| <td colspan="2">Spans both</td> | ||
| </tr> | ||
| </table> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| <p>Hello world.</p> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| import { readFileSync } from 'node:fs'; | ||
| import { resolve } from 'node:path'; | ||
|
|
||
| const FIXTURE_DIR = resolve(process.cwd(), 'src/__tests__/fixtures'); | ||
| const cache = new Map<string, string>(); | ||
|
|
||
| /** | ||
| * Reads a fixture file relative to `src/__tests__/fixtures/`. | ||
| * Cached so a single fixture file is read once per test process. | ||
| * | ||
| * Vitest runs from the package root, which is where `process.cwd()` | ||
| * points; that anchors the resolution. | ||
| * | ||
| * Example: `loadFixture('paste-sources/word.html')` | ||
| */ | ||
| export function loadFixture(relativePath: string): string { | ||
| const absolute = resolve(FIXTURE_DIR, relativePath); | ||
| let content = cache.get(absolute); | ||
| if (content === undefined) { | ||
| content = readFileSync(absolute, 'utf8'); | ||
| cache.set(absolute, content); | ||
| } | ||
| return content; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| <meta http-equiv="Content-Type" content="text/html; charset=utf-8"><div style="font-family:-apple-system,BlinkMacSystemFont,'Helvetica Neue',Helvetica,sans-serif;font-size:14px"><div>Hello,</div><div><br></div><div>Quoted text:</div><blockquote type="cite" style="margin:0 0 0 .8ex;border-left:1px #ccc solid;padding-left:1ex"><div>Original message text.</div></blockquote></div> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| <div dir="ltr" style="font-family:Arial,sans-serif"><div>Hi team,</div><div><br></div><div>Please review the <a href="https://example.com/doc" target="_blank" rel="noopener" style="color:#1155cc">attached document</a>.</div><div><br></div><div>Thanks,<br>Sender</div></div> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| <meta charset="utf-8"><h2 style="font-weight:600;font-size:1.5em;color:rgb(55,53,47)">Project Plan</h2><p style="padding:3px 2px;color:rgb(55,53,47)"><strong>Phase 1:</strong> kickoff</p><ul style="padding-left:1.5em"><li>Research</li><li>Design</li></ul><p>See <a href="https://www.notion.so/page-id" style="color:rgb(46,170,220)">notion link</a>.</p> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| <html><head><title>x</title><style>.evil{display:none}</style></head><body><table border="1" cellpadding="2" cellspacing="0"><tr><td colspan="2">Spans two</td></tr><tr><td>A</td><td>B</td></tr></table><img src="https://example.com/x.png" alt="x" width="100" height="50" data-track="abc"></body></html> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| <div style="color:#d4d4d4;background-color:#1e1e1e;font-family:'Cascadia Code', 'Fira Code', monospace;font-size:14px;line-height:19px;white-space:pre"><div><span style="color:#c586c0">function</span> <span style="color:#dcdcaa">greet</span>(<span style="color:#9cdcfe">name</span>: <span style="color:#4ec9b0">string</span>) {</div><div> <span style="color:#c586c0">return</span> <span style="color:#ce9178">`Hello, ${</span><span style="color:#9cdcfe">name</span><span style="color:#ce9178">}`</span>;</div><div>}</div></div> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| <meta charset="utf-8"><meta name="ProgId" content="Word.Document"><div class="WordSection1" style="font-family:Calibri;font-size:11pt"><p class="MsoNormal" style="margin:0in 0in 8pt"><span style="font-size:11.0pt;font-family:"Calibri",sans-serif;color:#1F3864">Hello from Word</span></p><p class="MsoNormal"><b><span style="color:#C00000">Important note</span></b> with <i>mixed</i> formatting.</p></div> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| import { afterEach, describe, expect, it, vi } from 'vitest'; | ||
| import { createTestEditor, paragraphDoc } from './editor-test-helpers'; | ||
|
|
||
| vi.mock('@/actions/ai', () => ({ | ||
| uploadImageViaAI: vi.fn(), | ||
| })); | ||
|
|
||
| describe('createTestEditor', () => { | ||
| let editor: ReturnType<typeof createTestEditor> | null = null; | ||
| afterEach(() => { | ||
| editor?.destroy(); | ||
| editor = null; | ||
| }); | ||
|
|
||
| it('creates an editor with default StarterKit and renders text', () => { | ||
| editor = createTestEditor({ content: paragraphDoc('hello') }); | ||
| expect(editor.getHTML()).toContain('hello'); | ||
| }); | ||
|
|
||
| it('accepts custom content as JSON', () => { | ||
| editor = createTestEditor({ | ||
| content: { | ||
| type: 'doc', | ||
| content: [ | ||
| { | ||
| type: 'heading', | ||
| attrs: { level: 2 }, | ||
| content: [{ type: 'text', text: 'World' }], | ||
| }, | ||
| ], | ||
| }, | ||
| }); | ||
| const html = editor.getHTML(); | ||
| expect(html).toContain('<h2'); | ||
| expect(html).toContain('World'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,26 +1,68 @@ | ||
| import type { Extensions } from '@tiptap/core'; | ||
| import { generateJSON } from '@tiptap/html'; | ||
| import type { EditorView } from '@tiptap/pm/view'; | ||
| import { sanitizePastedHtml } from '../utils/paste-sanitizer'; | ||
| import type { PasteHandler } from './create-paste-handler'; | ||
|
|
||
| export function createDropHandler({ onPaste }: { onPaste?: PasteHandler }) { | ||
| interface CreateDropHandlerOptions { | ||
| onPaste?: PasteHandler; | ||
| /** | ||
| * Editor extensions; required to convert dropped HTML/text into | ||
| * ProseMirror JSON via `generateJSON`. When omitted, only file drops | ||
| * (the legacy behavior) are handled. | ||
| */ | ||
| extensions?: Extensions; | ||
| } | ||
|
|
||
| export function createDropHandler({ | ||
| onPaste, | ||
| extensions, | ||
| }: CreateDropHandlerOptions) { | ||
| return ( | ||
| view: EditorView, | ||
| event: DragEvent, | ||
| _slice: unknown, | ||
| moved: boolean, | ||
| ): boolean => { | ||
| if ( | ||
| !moved && | ||
| event.dataTransfer && | ||
| event.dataTransfer.files && | ||
| event.dataTransfer.files[0] | ||
| ) { | ||
| // Internal block reorders: let ProseMirror handle them. | ||
| if (moved) return false; | ||
|
|
||
| const dataTransfer = event.dataTransfer; | ||
| if (!dataTransfer) return false; | ||
|
|
||
| if (dataTransfer.files && dataTransfer.files[0]) { | ||
| event.preventDefault(); | ||
| const file = event.dataTransfer.files[0]; | ||
| const file = dataTransfer.files[0]; | ||
|
|
||
| if (onPaste?.(file, view)) { | ||
| return true; | ||
| } | ||
| } | ||
| return false; | ||
|
|
||
| if (extensions === undefined) { | ||
| return false; | ||
| } | ||
|
|
||
| const html = dataTransfer.getData('text/html'); | ||
| const text = dataTransfer.getData('text/plain'); | ||
|
|
||
| if (!html && !text) return false; | ||
|
|
||
| event.preventDefault(); | ||
| const source = html || `<p>${escapeHtml(text)}</p>`; | ||
| const sanitized = sanitizePastedHtml(source); | ||
| const json = generateJSON(sanitized, extensions); | ||
| const node = view.state.schema.nodeFromJSON(json); | ||
| view.dispatch(view.state.tr.replaceSelectionWith(node, false)); | ||
| return true; | ||
| }; | ||
| } | ||
|
|
||
| function escapeHtml(text: string): string { | ||
| return text | ||
| .replace(/&/g, '&') | ||
| .replace(/</g, '<') | ||
| .replace(/>/g, '>') | ||
| .replace(/"/g, '"') | ||
| .replace(/'/g, '''); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,10 +34,10 @@ export function createPasteHandler({ | |
| } | ||
| } | ||
|
|
||
| if (slice.content.childCount === 1) { | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The previous behavior short-circuited single-node slices (skipping sanitization), which let pasted HTML from external sources bypass the paste-sanitizer and corrupt the document. Always sanitize when text/html is present. |
||
| return false; | ||
| } | ||
|
|
||
| // The previous behavior short-circuited single-node slices (skipping | ||
| // sanitization), which let pasted HTML from external sources bypass | ||
| // the paste-sanitizer and corrupt the document. Always sanitize when | ||
| // text/html is present. | ||
| if (event.clipboardData?.getData?.('text/html')) { | ||
| event.preventDefault(); | ||
| const html = event.clipboardData.getData('text/html'); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,6 +18,7 @@ const EVENT_PREFIX = '@react-email/editor:'; | |
| export interface EditorEventMap { | ||
| 'bubble-menu:add-link': undefined; | ||
| 'node-clicked': NodeClickedEvent; | ||
| 'image-upload-error': ImageUploadErrorEvent; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is also part of the changes in the image upload flow that should be part of a follow up |
||
| } | ||
|
|
||
| export type NodeClickedEvent = { | ||
|
|
@@ -26,6 +27,11 @@ export type NodeClickedEvent = { | |
| nodePos: { pos: number; inside: number }; | ||
| }; | ||
|
|
||
| export type ImageUploadErrorEvent = { | ||
| fileName: string; | ||
| error: Error; | ||
| }; | ||
|
|
||
| export type EditorEventName = keyof EditorEventMap; | ||
|
|
||
| export type EditorEventHandler<T extends EditorEventName> = ( | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.