diff --git a/biome.json b/biome.json index d918b995e4..520c8a0dc6 100644 --- a/biome.json +++ b/biome.json @@ -100,7 +100,8 @@ "!**/*.d.ts", "!**/out", "!**/.turbo", - "!**/prism.ts" + "!**/prism.ts", + "!packages/editor/src/__tests__/fixtures" ] } } diff --git a/packages/editor/package.json b/packages/editor/package.json index 3ef65b0703..d667ddfbd5 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -145,6 +145,7 @@ "@types/prismjs": "1.26.6", "@vitejs/plugin-react": "catalog:", "@vitest/browser-playwright": "4.1.4", + "fast-check": "3.23.2", "playwright": "1.59.1", "postcss": "8.5.10", "postcss-import": "16.1.1", diff --git a/packages/editor/src/__tests__/arbitraries.ts b/packages/editor/src/__tests__/arbitraries.ts new file mode 100644 index 0000000000..561d29d6d8 --- /dev/null +++ b/packages/editor/src/__tests__/arbitraries.ts @@ -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((text) => ({ type: 'text', text })); + +const paragraphNode = fc + .array(textNode, { minLength: 1, maxLength: 3 }) + .map((content) => ({ type: 'paragraph', content })); + +const headingNode = fc + .tuple(fc.integer({ min: 1, max: 3 }), safeText) + .map(([level, text]) => ({ + type: 'heading', + attrs: { level }, + content: [{ type: 'text', text }], + })); + +const listItemNode = paragraphNode.map((p) => ({ + type: 'listItem', + content: [p], +})); + +const bulletListNode = fc + .array(listItemNode, { minLength: 1, maxLength: 3 }) + .map((content) => ({ type: 'bulletList', content })); + +const blockNode: fc.Arbitrary = fc.oneof( + paragraphNode, + headingNode, + bulletListNode, +); + +/** + * Arbitrary doc with up to `maxBlocks` top-level block nodes. + */ +export function proseMirrorDocArbitrary( + options: { maxBlocks?: number } = {}, +): fc.Arbitrary { + const maxBlocks = options.maxBlocks ?? 5; + return fc + .array(blockNode, { minLength: 1, maxLength: maxBlocks }) + .map((content) => ({ type: 'doc', content })); +} diff --git a/packages/editor/src/__tests__/editor-test-helpers.ts b/packages/editor/src/__tests__/editor-test-helpers.ts new file mode 100644 index 0000000000..855ce5edbf --- /dev/null +++ b/packages/editor/src/__tests__/editor-test-helpers.ts @@ -0,0 +1,52 @@ +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, + }, + ], + }; +} diff --git a/packages/editor/src/__tests__/fixtures/emails/blockquote-and-code.html b/packages/editor/src/__tests__/fixtures/emails/blockquote-and-code.html new file mode 100644 index 0000000000..c443bd6bb4 --- /dev/null +++ b/packages/editor/src/__tests__/fixtures/emails/blockquote-and-code.html @@ -0,0 +1,5 @@ +
To be, or not to be.
+

Inline code() mid-sentence.

+
const x = 1;
+const y = 2;
+
diff --git a/packages/editor/src/__tests__/fixtures/emails/digest-newsletter.html b/packages/editor/src/__tests__/fixtures/emails/digest-newsletter.html new file mode 100644 index 0000000000..d1eceee898 --- /dev/null +++ b/packages/editor/src/__tests__/fixtures/emails/digest-newsletter.html @@ -0,0 +1,16 @@ +

The Weekly Digest

+

Issue #84 · May 11, 2026

+ +

What's new

+

We shipped granular API key permissions this week. You can now scope a key to a single domain or audience. Read the changelog →

+ +

From the blog

+ + +

Customer spotlight

+
"Switching to Acme cut our delivery latency by 60% on the first day. Setup took less time than reading our last vendor's docs."
+

— Priya N., CTO at Linear-not-actually-Linear

diff --git a/packages/editor/src/__tests__/fixtures/emails/headings-and-lists.html b/packages/editor/src/__tests__/fixtures/emails/headings-and-lists.html new file mode 100644 index 0000000000..c7738a3d57 --- /dev/null +++ b/packages/editor/src/__tests__/fixtures/emails/headings-and-lists.html @@ -0,0 +1,11 @@ +

Welcome

+

Subheader

+

Intro paragraph.

+
    +
  • One
  • +
  • Two
  • +
+
    +
  1. First
  2. +
  3. Second
  4. +
diff --git a/packages/editor/src/__tests__/fixtures/emails/inline-styled-typography.html b/packages/editor/src/__tests__/fixtures/emails/inline-styled-typography.html new file mode 100644 index 0000000000..5986790168 --- /dev/null +++ b/packages/editor/src/__tests__/fixtures/emails/inline-styled-typography.html @@ -0,0 +1,3 @@ +

Red bold paragraph.

+

Heading with color.

+

Plain after.

diff --git a/packages/editor/src/__tests__/fixtures/emails/links-and-images.html b/packages/editor/src/__tests__/fixtures/emails/links-and-images.html new file mode 100644 index 0000000000..de9315c40e --- /dev/null +++ b/packages/editor/src/__tests__/fixtures/emails/links-and-images.html @@ -0,0 +1,2 @@ +

Visit our site for more.

+

Logo

diff --git a/packages/editor/src/__tests__/fixtures/emails/magic-link.html b/packages/editor/src/__tests__/fixtures/emails/magic-link.html new file mode 100644 index 0000000000..f0ac94f384 --- /dev/null +++ b/packages/editor/src/__tests__/fixtures/emails/magic-link.html @@ -0,0 +1,5 @@ +

Hi Sarah,

+

Click below to sign in to Acme. The link is good for the next 10 minutes.

+

Sign in to Acme

+

If you didn't try to sign in, you can ignore this email. No action is needed.

+

For security reasons, this link expires after one use.

diff --git a/packages/editor/src/__tests__/fixtures/emails/marketing-announcement.html b/packages/editor/src/__tests__/fixtures/emails/marketing-announcement.html new file mode 100644 index 0000000000..ade3756053 --- /dev/null +++ b/packages/editor/src/__tests__/fixtures/emails/marketing-announcement.html @@ -0,0 +1,10 @@ +

Acme Mobile launch

+

Introducing Acme Mobile

+

Everything you love about Acme, now on iPhone and Android. Same workspace, same teammates, same data — wherever you are.

+

Download the app

+

What you get

+
    +
  • Real-time push notifications for replies and mentions
  • +
  • Offline drafting that syncs the moment you reconnect
  • +
  • Biometric sign-in (Face ID / fingerprint)
  • +
diff --git a/packages/editor/src/__tests__/fixtures/emails/mixed-marks.html b/packages/editor/src/__tests__/fixtures/emails/mixed-marks.html new file mode 100644 index 0000000000..788c5e5213 --- /dev/null +++ b/packages/editor/src/__tests__/fixtures/emails/mixed-marks.html @@ -0,0 +1,2 @@ +

Some bold, italic, underline, and strike text.

+

And both bold and italic together.

diff --git a/packages/editor/src/__tests__/fixtures/emails/nested-table.html b/packages/editor/src/__tests__/fixtures/emails/nested-table.html new file mode 100644 index 0000000000..401ca99211 --- /dev/null +++ b/packages/editor/src/__tests__/fixtures/emails/nested-table.html @@ -0,0 +1,9 @@ + + + + + + + + +
Cell ACell B
Spans both
diff --git a/packages/editor/src/__tests__/fixtures/emails/notification-with-footer.html b/packages/editor/src/__tests__/fixtures/emails/notification-with-footer.html new file mode 100644 index 0000000000..d8680ba2db --- /dev/null +++ b/packages/editor/src/__tests__/fixtures/emails/notification-with-footer.html @@ -0,0 +1,7 @@ +

Hi Sarah,

+

James Liu commented on your pull request feat: rate-limited webhook retries:

+
Could we use the same backoff schedule as the SMS retry path? Otherwise this is great — ship it.
+

View comment

+
+

Acme · 100 Market St, San Francisco, CA 94103

+

You're receiving this because you authored the PR. Manage notification preferences · Unsubscribe

diff --git a/packages/editor/src/__tests__/fixtures/emails/order-receipt.html b/packages/editor/src/__tests__/fixtures/emails/order-receipt.html new file mode 100644 index 0000000000..1aff65ac14 --- /dev/null +++ b/packages/editor/src/__tests__/fixtures/emails/order-receipt.html @@ -0,0 +1,21 @@ +

Thanks for your order

+

Order #AC-10847 · Placed on May 11, 2026

+ + + + + + + + + + + + + + + + + +
Espresso Beans, 1lb2 × $18.00
Stainless steel French press$42.00
Shipping$5.00
Total$83.00
+

Tracking will be emailed when your order ships, usually within two business days.

diff --git a/packages/editor/src/__tests__/fixtures/emails/password-reset.html b/packages/editor/src/__tests__/fixtures/emails/password-reset.html new file mode 100644 index 0000000000..b11ac09c17 --- /dev/null +++ b/packages/editor/src/__tests__/fixtures/emails/password-reset.html @@ -0,0 +1,4 @@ +

Reset your password

+

We received a request to reset the password for sarah@example.com. Click the button below to choose a new one. This link expires in 30 minutes.

+

Reset password

+

Didn't request this? You can ignore this email — your password won't change.

diff --git a/packages/editor/src/__tests__/fixtures/emails/simple-paragraph.html b/packages/editor/src/__tests__/fixtures/emails/simple-paragraph.html new file mode 100644 index 0000000000..3217db9e03 --- /dev/null +++ b/packages/editor/src/__tests__/fixtures/emails/simple-paragraph.html @@ -0,0 +1 @@ +

Hello world.

diff --git a/packages/editor/src/__tests__/fixtures/emails/team-invite.html b/packages/editor/src/__tests__/fixtures/emails/team-invite.html new file mode 100644 index 0000000000..da9f8d3b5a --- /dev/null +++ b/packages/editor/src/__tests__/fixtures/emails/team-invite.html @@ -0,0 +1,5 @@ +

Sarah invited you to Acme

+

sarah@example.com wants you to join the Engineering workspace on Acme.

+

Accept invitation

+

You'll be able to see Engineering's projects, threads, and shared files. You can leave the workspace at any time from your profile.

+

This invitation expires in 7 days.

diff --git a/packages/editor/src/__tests__/fixtures/emails/welcome-confirm.html b/packages/editor/src/__tests__/fixtures/emails/welcome-confirm.html new file mode 100644 index 0000000000..8eec1b775c --- /dev/null +++ b/packages/editor/src/__tests__/fixtures/emails/welcome-confirm.html @@ -0,0 +1,6 @@ +

Welcome to Acme, Sarah

+

Thanks for signing up. Your account is ready to go. Confirm your email to start sending.

+

Confirm email

+

Or paste this link into your browser: https://acme.example/confirm?token=abc123

+
+

If you didn't sign up, you can safely ignore this email.

diff --git a/packages/editor/src/__tests__/fixtures/load-fixture.ts b/packages/editor/src/__tests__/fixtures/load-fixture.ts new file mode 100644 index 0000000000..5ad1e2a28d --- /dev/null +++ b/packages/editor/src/__tests__/fixtures/load-fixture.ts @@ -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(); + +/** + * 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; +} diff --git a/packages/editor/src/__tests__/fixtures/paste-sources/apple-mail.html b/packages/editor/src/__tests__/fixtures/paste-sources/apple-mail.html new file mode 100644 index 0000000000..09a3f89012 --- /dev/null +++ b/packages/editor/src/__tests__/fixtures/paste-sources/apple-mail.html @@ -0,0 +1 @@ +
Hello,

Quoted text:
Original message text.
diff --git a/packages/editor/src/__tests__/fixtures/paste-sources/chatgpt.html b/packages/editor/src/__tests__/fixtures/paste-sources/chatgpt.html new file mode 100644 index 0000000000..bf535a7dff --- /dev/null +++ b/packages/editor/src/__tests__/fixtures/paste-sources/chatgpt.html @@ -0,0 +1 @@ +

Here's a draft welcome email for your beta users. Tone is friendly but concise.


Subject: Welcome to Acme — let's get you set up

Hi {{firstName}},

Thanks for joining the Acme beta! You're one of the first 100 people to try the new mobile app, and your feedback is going to shape what we build next.

Three things to try this week:

  1. Set up your first audience — paste a CSV or connect your CRM.
  2. Send a test broadcast — see how delivery feels with our infrastructure.
  3. Reply to this email with anything that surprised you, good or bad.

Real humans read every reply. I'll personally respond within 24 hours.

— Sarah, founder

P.S. If you'd rather opt out of beta-only emails, use this link.

diff --git a/packages/editor/src/__tests__/fixtures/paste-sources/github-markdown.html b/packages/editor/src/__tests__/fixtures/paste-sources/github-markdown.html new file mode 100644 index 0000000000..bd12b74c81 --- /dev/null +++ b/packages/editor/src/__tests__/fixtures/paste-sources/github-markdown.html @@ -0,0 +1,3 @@ +

Changelog v2.4.0

New

  • Granular API key permissions: scope a key to a single domain or audience.
  • 2048-bit DKIM keys are now the default for new domains.

Fixed

  • Pasted content from Word no longer leaks Mso* classes into the editor.
  • Image upload errors now surface a toast instead of failing silently.
curl -X POST https://api.acme.example/v1/emails \
+  -H "Authorization: Bearer $API_KEY" \
+  --data '{"to":"hi@example.com","from":"team@acme.example","subject":"Hi"}'
diff --git a/packages/editor/src/__tests__/fixtures/paste-sources/gmail.html b/packages/editor/src/__tests__/fixtures/paste-sources/gmail.html new file mode 100644 index 0000000000..39e1f1a706 --- /dev/null +++ b/packages/editor/src/__tests__/fixtures/paste-sources/gmail.html @@ -0,0 +1 @@ +
Hi team,

Please review the attached document.

Thanks,
Sender
diff --git a/packages/editor/src/__tests__/fixtures/paste-sources/linear-comment.html b/packages/editor/src/__tests__/fixtures/paste-sources/linear-comment.html new file mode 100644 index 0000000000..b34515f310 --- /dev/null +++ b/packages/editor/src/__tests__/fixtures/paste-sources/linear-comment.html @@ -0,0 +1 @@ +

Quick triage notes from this morning's review:

  • The image-upload regression looks like an abort-controller issue when the editor unmounts mid-upload. Repro in Safari is ~1 in 4.

  • Slash command flicker is fixed on the latest main.

For Safari specifically — does the new event-bus path solve it, or do we still need to land the imperative theme reconfigure?

cc @sarah — want to pair on this Thursday?

diff --git a/packages/editor/src/__tests__/fixtures/paste-sources/notion.html b/packages/editor/src/__tests__/fixtures/paste-sources/notion.html new file mode 100644 index 0000000000..94e4832a71 --- /dev/null +++ b/packages/editor/src/__tests__/fixtures/paste-sources/notion.html @@ -0,0 +1 @@ +

Q2 Launch Plan

Owner: Sarah · Status: In progress

💡

Goal: cut activation time from 14 days to under 5 by shipping the mobile app + onboarding rewrite.

Milestones

Risks

  • Mobile push notification setup may slip; iOS review cycles are unpredictable.
  • Liveblocks limits at the current tier — we'll cross that line around 250 concurrent rooms.

See full launch plan for sub-tasks.

diff --git a/packages/editor/src/__tests__/fixtures/paste-sources/outlook-web.html b/packages/editor/src/__tests__/fixtures/paste-sources/outlook-web.html new file mode 100644 index 0000000000..550e5b549a --- /dev/null +++ b/packages/editor/src/__tests__/fixtures/paste-sources/outlook-web.html @@ -0,0 +1 @@ +

Hi team,

 

Quick update on the migration. We finished the audit on Friday and the report is attached. Headline numbers:

·      p95 latency down 38%

·      error rate held steady at 0.02%

Thanks,

Sarah

diff --git a/packages/editor/src/__tests__/fixtures/paste-sources/view-source.html b/packages/editor/src/__tests__/fixtures/paste-sources/view-source.html new file mode 100644 index 0000000000..e8603484d3 --- /dev/null +++ b/packages/editor/src/__tests__/fixtures/paste-sources/view-source.html @@ -0,0 +1 @@ +x
Spans two
AB
x diff --git a/packages/editor/src/__tests__/fixtures/paste-sources/vscode.html b/packages/editor/src/__tests__/fixtures/paste-sources/vscode.html new file mode 100644 index 0000000000..8588480ee3 --- /dev/null +++ b/packages/editor/src/__tests__/fixtures/paste-sources/vscode.html @@ -0,0 +1 @@ +
function greet(name: string) {
return `Hello, ${name}`;
}
diff --git a/packages/editor/src/__tests__/fixtures/paste-sources/word.html b/packages/editor/src/__tests__/fixtures/paste-sources/word.html new file mode 100644 index 0000000000..1181f82df5 --- /dev/null +++ b/packages/editor/src/__tests__/fixtures/paste-sources/word.html @@ -0,0 +1 @@ +

Hello from Word

Important note with mixed formatting.

diff --git a/packages/editor/src/__tests__/test-helpers.spec.ts b/packages/editor/src/__tests__/test-helpers.spec.ts new file mode 100644 index 0000000000..c9f49310cd --- /dev/null +++ b/packages/editor/src/__tests__/test-helpers.spec.ts @@ -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 | 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(' { + return (view: EditorView, event: ClipboardEvent, _slice: Slice): boolean => { const text = event.clipboardData?.getData('text/plain'); if (text && onPaste?.(text, view)) { @@ -34,10 +34,6 @@ export function createPasteHandler({ } } - if (slice.content.childCount === 1) { - return false; - } - if (event.clipboardData?.getData?.('text/html')) { event.preventDefault(); const html = event.clipboardData.getData('text/html'); diff --git a/packages/editor/src/core/is-document-visually-empty.spec.ts b/packages/editor/src/core/is-document-visually-empty.spec.ts index fa2ee03a33..77a182c77e 100644 --- a/packages/editor/src/core/is-document-visually-empty.spec.ts +++ b/packages/editor/src/core/is-document-visually-empty.spec.ts @@ -6,6 +6,11 @@ const schema = new Schema({ nodes: { doc: { content: 'block+' }, paragraph: { group: 'block', content: 'inline*' }, + heading: { + group: 'block', + content: 'inline*', + attrs: { level: { default: 1 } }, + }, text: { group: 'inline' }, globalContent: { group: 'block', atom: true }, container: { group: 'block', content: 'block+' }, @@ -89,6 +94,49 @@ describe('isDocumentVisuallyEmpty', () => { expect(isDocumentVisuallyEmpty(doc)).toBe(false); }); + + // Empty headings used to render the placeholder predicate as non-empty, + // which broke the "Press / for commands" hint when the doc started as + // a heading. See email-editor.tsx:153 TODO. + it('returns true when document contains a single empty heading', () => { + const doc = schema.node('doc', null, [schema.node('heading')]); + + expect(isDocumentVisuallyEmpty(doc)).toBe(true); + }); + + it('returns true when global content precedes a single empty heading', () => { + const doc = schema.node('doc', null, [ + schema.node('globalContent'), + schema.node('heading'), + ]); + + expect(isDocumentVisuallyEmpty(doc)).toBe(true); + }); + + it('returns false when heading has text', () => { + const doc = schema.node('doc', null, [ + schema.node('heading', null, [schema.text('title')]), + ]); + + expect(isDocumentVisuallyEmpty(doc)).toBe(false); + }); + + it('returns false when heading contains a variable node', () => { + const doc = schema.node('doc', null, [ + schema.node('heading', null, [schema.node('variable')]), + ]); + + expect(isDocumentVisuallyEmpty(doc)).toBe(false); + }); + + it('returns false when document has an empty heading and a variable paragraph', () => { + const doc = schema.node('doc', null, [ + schema.node('heading'), + schema.node('paragraph', null, [schema.node('variable')]), + ]); + + expect(isDocumentVisuallyEmpty(doc)).toBe(false); + }); }); describe('with container', () => { @@ -147,5 +195,23 @@ describe('isDocumentVisuallyEmpty', () => { expect(isDocumentVisuallyEmpty(doc)).toBe(false); }); + + it('returns true when container holds one empty heading', () => { + const doc = schema.node('doc', null, [ + schema.node('container', null, [schema.node('heading')]), + ]); + + expect(isDocumentVisuallyEmpty(doc)).toBe(true); + }); + + it('returns false when container holds heading with text', () => { + const doc = schema.node('doc', null, [ + schema.node('container', null, [ + schema.node('heading', null, [schema.text('hi')]), + ]), + ]); + + expect(isDocumentVisuallyEmpty(doc)).toBe(false); + }); }); }); diff --git a/packages/editor/src/core/is-document-visually-empty.ts b/packages/editor/src/core/is-document-visually-empty.ts index 99562c8f8c..dd320a45d9 100644 --- a/packages/editor/src/core/is-document-visually-empty.ts +++ b/packages/editor/src/core/is-document-visually-empty.ts @@ -27,13 +27,13 @@ export function isDocumentVisuallyEmpty(doc: Node): boolean { } if (firstNonGlobalNode!.type.name === 'container') { - return hasOnlyEmptyParagraph(firstNonGlobalNode!); + return hasOnlyEmptyTextBlock(firstNonGlobalNode!); } - return isEmptyParagraph(firstNonGlobalNode!); + return isEmptyTextBlock(firstNonGlobalNode!); } -function hasOnlyEmptyParagraph(node: Node): boolean { +function hasOnlyEmptyTextBlock(node: Node): boolean { if (node.childCount === 0) { return true; } @@ -42,9 +42,11 @@ function hasOnlyEmptyParagraph(node: Node): boolean { return false; } - return isEmptyParagraph(node.child(0)); + return isEmptyTextBlock(node.child(0)); } -function isEmptyParagraph(node: Node): boolean { - return node.type.name === 'paragraph' && node.content.size === 0; +const EMPTY_TEXT_BLOCK_TYPES = new Set(['paragraph', 'heading']); + +function isEmptyTextBlock(node: Node): boolean { + return EMPTY_TEXT_BLOCK_TYPES.has(node.type.name) && node.content.size === 0; } diff --git a/packages/editor/src/core/paste-drop-handlers.spec.ts b/packages/editor/src/core/paste-drop-handlers.spec.ts index 5cbe7b71b2..ad75d12d6e 100644 --- a/packages/editor/src/core/paste-drop-handlers.spec.ts +++ b/packages/editor/src/core/paste-drop-handlers.spec.ts @@ -2,7 +2,17 @@ import { describe, expect, it, vi } from 'vitest'; import { createDropHandler } from './create-drop-handler'; import { createPasteHandler } from './create-paste-handler'; +// Stub out generateJSON: it needs a real schema that includes a `doc` node. +// We're testing the handler's control flow, not JSON generation. +vi.mock('@tiptap/html', () => ({ + generateJSON: vi.fn().mockReturnValue({ type: 'doc', content: [] }), +})); + describe('createDropHandler', () => { + function makeDataTransfer({ files = [] as File[] }: { files?: File[] } = {}) { + return { files }; + } + it('consumes the drop when onPaste accepts it', () => { const handler = createDropHandler({ onPaste: () => true, @@ -13,11 +23,11 @@ describe('createDropHandler', () => { state: { doc: { textContent: '' } }, } as never, { - dataTransfer: { + dataTransfer: makeDataTransfer({ files: [ new File([''], 'template.html', { type: 'text/html' }), ], - }, + }), preventDefault, } as unknown as DragEvent, null, @@ -39,9 +49,9 @@ describe('createDropHandler', () => { posAtCoords: vi.fn().mockReturnValue({ pos: 5 }), } as never, { - dataTransfer: { + dataTransfer: makeDataTransfer({ files: [new File(['image'], 'photo.png', { type: 'image/png' })], - }, + }), preventDefault, clientX: 10, clientY: 20, @@ -56,6 +66,40 @@ describe('createDropHandler', () => { }); describe('createPasteHandler', () => { + function makeView(spy: ReturnType) { + const fakeNode = { type: 'paragraph' }; + return { + state: { + doc: { textContent: '' }, + selection: { from: 2 }, + schema: { nodeFromJSON: vi.fn().mockReturnValue(fakeNode) }, + tr: { replaceSelectionWith: vi.fn().mockReturnThis() }, + }, + dispatch: spy, + } as never; + } + + function makeClipboardEvent({ + text = '', + html = '', + files = [] as File[], + preventDefault = vi.fn(), + }: { + text?: string; + html?: string; + files?: File[]; + preventDefault?: ReturnType; + } = {}) { + return { + clipboardData: { + getData: (type: string) => + type === 'text/plain' ? text : type === 'text/html' ? html : '', + files, + }, + preventDefault, + } as unknown as ClipboardEvent; + } + it('lets plain text fall through when the caller explicitly declines it', () => { const preventDefault = vi.fn(); const handler = createPasteHandler({ @@ -69,17 +113,82 @@ describe('createPasteHandler', () => { selection: { from: 2 }, }, } as never, - { - clipboardData: { - getData: (type: string) => - type === 'text/plain' ? 'hello world' : '', - files: [], - }, + makeClipboardEvent({ text: 'hello world', preventDefault }), + { content: { childCount: 1 } } as never, + ); + + expect(handled).toBe(false); + expect(preventDefault).not.toHaveBeenCalled(); + }); + + it('short-circuits and prevents default when onPaste consumes plain text', () => { + const preventDefault = vi.fn(); + const handler = createPasteHandler({ + onPaste: () => true, + extensions: [], + }); + const handled = handler( + {} as never, + makeClipboardEvent({ text: 'hello', preventDefault }), + { content: { childCount: 1 } } as never, + ); + + expect(handled).toBe(true); + expect(preventDefault).toHaveBeenCalledOnce(); + }); + + it('routes file payloads through onPaste', () => { + const preventDefault = vi.fn(); + const onPaste = vi.fn().mockReturnValue(true); + const file = new File(['x'], 'x.png', { type: 'image/png' }); + const handler = createPasteHandler({ onPaste, extensions: [] }); + const handled = handler( + {} as never, + makeClipboardEvent({ files: [file], preventDefault }), + { content: { childCount: 1 } } as never, + ); + + expect(handled).toBe(true); + expect(onPaste).toHaveBeenCalledWith(file, expect.anything()); + expect(preventDefault).toHaveBeenCalledOnce(); + }); + + it('sanitizes single-node text/html pastes (no childCount short-circuit)', () => { + const preventDefault = vi.fn(); + const dispatch = vi.fn(); + const view = makeView(dispatch); + const handler = createPasteHandler({ extensions: [] }); + const handled = handler( + view, + makeClipboardEvent({ + html: '

x

', preventDefault, - } as unknown as ClipboardEvent, - { - content: { childCount: 1 }, - } as never, + }), + { content: { childCount: 1 } } as never, + ); + + expect(handled).toBe(true); + expect(preventDefault).toHaveBeenCalledOnce(); + expect(dispatch).toHaveBeenCalledOnce(); + }); + + it('dispatches exactly one transaction per text/html paste', () => { + const dispatch = vi.fn(); + const view = makeView(dispatch); + const handler = createPasteHandler({ extensions: [] }); + handler(view, makeClipboardEvent({ html: '

hello

' }), { + content: { childCount: 3 }, + } as never); + expect(dispatch).toHaveBeenCalledOnce(); + }); + + it('returns false and does not preventDefault when clipboardData is missing', () => { + const preventDefault = vi.fn(); + const handler = createPasteHandler({ extensions: [] }); + const handled = handler( + {} as never, + { clipboardData: undefined, preventDefault } as unknown as ClipboardEvent, + { content: { childCount: 0 } } as never, ); expect(handled).toBe(false); diff --git a/packages/editor/src/core/serializer/__snapshots__/compose-react-email.spec.tsx.snap b/packages/editor/src/core/serializer/__snapshots__/compose-react-email.spec.tsx.snap new file mode 100644 index 0000000000..6c2f4b091c --- /dev/null +++ b/packages/editor/src/core/serializer/__snapshots__/compose-react-email.spec.tsx.snap @@ -0,0 +1,1148 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`round-trip corpus > blockquote-and-code.html renders deterministically through composeReactEmail 1`] = ` +" + + + + + + + + + + + + + + + + + +
+ + + + + + +
+

To be, or not to be.

+

Inline code() mid-sentence.

+
const ‍​x ‍​= ‍​1;
const ‍​y ‍​= ‍​2;

+


+
+
+ + + +" +`; + +exports[`round-trip corpus > digest-newsletter.html renders deterministically through composeReactEmail 1`] = ` +" + + + + + + + + + + + + + + + + + +
+ + + + + + +
+

+ The Weekly Digest +

+

+ Issue #84 · May 11, 2026 +

+

+ What's new +

+

+ We shipped granular API key permissions this week. You can + now scope a key to a single domain or audience. + Read the changelog → +

+

+ From the blog +

+ +

+ Customer spotlight +

+
+

+ "Switching to Acme cut our delivery latency by + 60% on the first day. Setup took less time than + reading our last vendor's docs." +

+
+

+ — Priya N., CTO at Linear-not-actually-Linear +

+
+
+ + + +" +`; + +exports[`round-trip corpus > headings-and-lists.html renders deterministically through composeReactEmail 1`] = ` +" + + + + + + + + + + + + + + + + + +
+ + + + + + +
+

Welcome

+

Subheader

+

Intro paragraph.

+
    +
  • One

  • +
  • Two

  • +
+
    +
  1. First

  2. +
  3. Second

  4. +
+


+
+
+ + + +" +`; + +exports[`round-trip corpus > inline-styled-typography.html renders deterministically through composeReactEmail 1`] = ` +" + + + + + + + + + + + + + + + + + +
+ + + + + + +
+

+ Red bold paragraph. +

+

Heading with color.

+

Plain after.

+
+
+ + + +" +`; + +exports[`round-trip corpus > links-and-images.html renders deterministically through composeReactEmail 1`] = ` +" + + + + + + + + + + + + + + + + + +
+ + + + + + +
+

+ Visit + our site + for more. +

+


+
+
+ + + +" +`; + +exports[`round-trip corpus > magic-link.html renders deterministically through composeReactEmail 1`] = ` +" + + + + + + + + + + + + + + + + + +
+ + + + + + +
+

+ Hi Sarah, +

+

+ Click below to sign in to Acme. The link is good for the + next 10 minutes. +

+

+ Sign in to Acme +

+

+ If you didn't try to sign in, you can ignore this + email. No action is needed. +

+

+ For security reasons, this link expires after one use. +

+
+
+ + + +" +`; + +exports[`round-trip corpus > marketing-announcement.html renders deterministically through composeReactEmail 1`] = ` +" + + + + + + + + + + + + + + + + + +
+ + + + + + +
+


+

+ Introducing Acme Mobile +

+

+ Everything you love about Acme, now on iPhone and Android. + Same workspace, same teammates, same data — wherever you + are. +

+

+ Download the app +

+

+ What you get +

+
    +
  • +

    + Real-time push notifications for replies and mentions +

    +
  • +
  • +

    + Offline drafting that syncs the moment you reconnect +

    +
  • +
  • Biometric sign-in (Face ID / fingerprint)

  • +
+


+
+
+ + + +" +`; + +exports[`round-trip corpus > mixed-marks.html renders deterministically through composeReactEmail 1`] = ` +" + + + + + + + + + + + + + + + + + +
+ + + + + + +
+

+ Some bold, italic, + underline, and strike text. +

+

+ And + both bold and italic together. +

+
+
+ + + +" +`; + +exports[`round-trip corpus > nested-table.html renders deterministically through composeReactEmail 1`] = ` +" + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + +

Cell A

Cell B

+

Spans both

+
+


+
+
+ + + +" +`; + +exports[`round-trip corpus > notification-with-footer.html renders deterministically through composeReactEmail 1`] = ` +" + + + + + + + + + + + + + + + + + +
+ + + + + + +
+

+ Hi Sarah, +

+

+ James Liu commented on your pull request + feat: rate-limited webhook retries: +

+
+

+ Could we use the same backoff schedule as the SMS retry + path? Otherwise this is great — ship it. +

+
+

+ View comment +

+
+

+ Acme · 100 Market St, San Francisco, CA 94103 +

+

+ You're receiving this because you authored the PR. + Manage notification preferences + · + Unsubscribe +

+
+
+ + + +" +`; + +exports[`round-trip corpus > order-receipt.html renders deterministically through composeReactEmail 1`] = ` +" + + + + + + + + + + + + + + + + + +
+ + + + + + +
+

+ Thanks for your order +

+

+ Order #AC-10847 · Placed on May 11, 2026 +

+ + + + + + + + + + + + + + + + + + + +
+

Espresso Beans, 1lb

+
+

2 × $18.00

+
+

Stainless steel French press

+
+

$42.00

+
+

Shipping

+
+

$5.00

+
+

Total

+
+

$83.00

+
+

+ Tracking will be emailed when your order ships, usually + within two business days. +

+
+
+ + + +" +`; + +exports[`round-trip corpus > password-reset.html renders deterministically through composeReactEmail 1`] = ` +" + + + + + + + + + + + + + + + + + +
+ + + + + + +
+

+ Reset your password +

+

+ We received a request to reset the password for + sarah@example.com. Click the button below + to choose a new one. This link expires in 30 minutes. +

+

+ Reset password +

+

+ Didn't request this? You can ignore this email — your + password won't change. +

+
+
+ + + +" +`; + +exports[`round-trip corpus > simple-paragraph.html renders deterministically through composeReactEmail 1`] = ` +" + + + + + + + + + + + + + + + + + +
+ + + + + + +

Hello world.

+
+ + + +" +`; + +exports[`round-trip corpus > team-invite.html renders deterministically through composeReactEmail 1`] = ` +" + + + + + + + + + + + + + + + + + +
+ + + + + + +
+

+ Sarah invited you to Acme +

+

+ sarah@example.com wants you to join the + Engineering workspace on Acme. +

+

+ Accept invitation +

+

+ You'll be able to see Engineering's projects, + threads, and shared files. You can leave the workspace at + any time from your profile. +

+

+ This invitation expires in 7 days. +

+
+
+ + + +" +`; + +exports[`round-trip corpus > welcome-confirm.html renders deterministically through composeReactEmail 1`] = ` +" + + + + + + + + + + + + + + + + + +
+ + + + + + +
+

+ Welcome to Acme, Sarah +

+

+ Thanks for signing up. Your account is ready to go. + Confirm your email to start sending. +

+

+ Confirm email +

+

+ Or paste this link into your browser: + https://acme.example/confirm?token=abc123 +

+
+

+ If you didn't sign up, you can safely ignore this + email. +

+
+
+ + + +" +`; diff --git a/packages/editor/src/core/serializer/compose-react-email.spec.tsx b/packages/editor/src/core/serializer/compose-react-email.spec.tsx index 93df6eeb96..eb2c2a38e3 100644 --- a/packages/editor/src/core/serializer/compose-react-email.spec.tsx +++ b/packages/editor/src/core/serializer/compose-react-email.spec.tsx @@ -496,3 +496,70 @@ describe('Button and image reset styles', () => { expect(result.html).toContain('test image'); }); }); + +// HTML fixtures live in src/__tests__/fixtures/emails/. Each one is parsed +// through @tiptap/html, run through composeReactEmail, and snapshotted to +// pin down editor ↔ rendered-email parity. + +import { generateJSON } from '@tiptap/html'; +import * as fc from 'fast-check'; +import { proseMirrorDocArbitrary } from '../../__tests__/arbitraries'; +import { loadFixture } from '../../__tests__/fixtures/load-fixture'; + +describe('round-trip corpus', () => { + const FIXTURES = [ + 'simple-paragraph.html', + 'mixed-marks.html', + 'headings-and-lists.html', + 'links-and-images.html', + 'blockquote-and-code.html', + 'inline-styled-typography.html', + 'nested-table.html', + 'welcome-confirm.html', + 'password-reset.html', + 'magic-link.html', + 'order-receipt.html', + 'marketing-announcement.html', + 'team-invite.html', + 'digest-newsletter.html', + 'notification-with-footer.html', + ]; + + it.each( + FIXTURES, + )('%s renders deterministically through composeReactEmail', async (fixture) => { + const html = loadFixture(`emails/${fixture}`); + const json = generateJSON(html, [ + StarterKit, + EmailTheming.configure({ theme: 'basic' }), + ]) as JSONContent; + const wrapped = docWithGlobalContent(json.content ?? []); + const ed = createEditorWithContent(wrapped); + const result = await composeReactEmail({ editor: ed, preview: '' }); + expect(result.html).toMatchSnapshot(); + }); +}); + +describe('round-trip property', () => { + it('composeReactEmail does not throw on arbitrary valid JSON', async () => { + await fc.assert( + fc.asyncProperty( + proseMirrorDocArbitrary({ maxBlocks: 4 }), + async (doc) => { + const wrapped = docWithGlobalContent(doc.content ?? []); + const ed = createEditorWithContent(wrapped); + try { + const result = await composeReactEmail({ + editor: ed, + preview: '', + }); + expect(typeof result.html).toBe('string'); + } finally { + ed.destroy(); + } + }, + ), + { numRuns: 25 }, + ); + }); +}); diff --git a/packages/editor/src/core/serializer/email-mark.spec.ts b/packages/editor/src/core/serializer/email-mark.spec.ts index b8e52b9887..ba817ae5eb 100644 --- a/packages/editor/src/core/serializer/email-mark.spec.ts +++ b/packages/editor/src/core/serializer/email-mark.spec.ts @@ -60,4 +60,33 @@ describe('EmailMark', () => { expect(configured.config.renderToReactEmail).toBe(Component); expect(configured.name).toBe(CustomHighlight.name); }); + + // Covers the second @ts-expect-error path in email-mark.ts:77 — extend() + // must keep the EmailMark identity and the renderToReactEmail component + // so subclasses don't lose their renderer at the type/runtime boundary. + it('preserves EmailMark identity through extend()', () => { + const Component = vi.fn(() => 'rendered'); + const Base = EmailMark.from(Highlight, Component); + + const Extended = Base.extend({ + addOptions() { + return { HTMLAttributes: { class: 'extended-mark' } }; + }, + }); + + expect(Extended).toBeInstanceOf(EmailMark); + expect(Extended.config).toHaveProperty('renderToReactEmail'); + expect(Extended.config.renderToReactEmail).toBe(Component); + }); + + it('configure() can be called twice without losing renderToReactEmail', () => { + const Component = vi.fn(() => 'rendered'); + const Base = EmailMark.from(Highlight, Component); + + const a = Base.configure({ HTMLAttributes: { class: 'a' } }); + const b = a.configure({ HTMLAttributes: { class: 'b' } }); + + expect(b).toBeInstanceOf(EmailMark); + expect(b.config.renderToReactEmail).toBe(Component); + }); }); diff --git a/packages/editor/src/core/serializer/email-node.spec.ts b/packages/editor/src/core/serializer/email-node.spec.ts index 9411dcb597..64ea7d7c77 100644 --- a/packages/editor/src/core/serializer/email-node.spec.ts +++ b/packages/editor/src/core/serializer/email-node.spec.ts @@ -42,4 +42,32 @@ describe('EmailNode', () => { expect(configured.config.renderToReactEmail).toBe(Component); expect(configured.name).toBe(CustomHeader.name); }); + + // Covers the second @ts-expect-error path in email-node.ts:75 — extend() + // must keep the EmailNode identity and the renderToReactEmail component. + it('preserves EmailNode identity through extend()', () => { + const Component = vi.fn(() => 'rendered'); + const Base = EmailNode.from(Heading, Component); + + const Extended = Base.extend({ + addOptions() { + return { levels: [1, 2, 3] as const, HTMLAttributes: {} }; + }, + }); + + expect(Extended).toBeInstanceOf(EmailNode); + expect(Extended.config).toHaveProperty('renderToReactEmail'); + expect(Extended.config.renderToReactEmail).toBe(Component); + }); + + it('configure() can be called twice without losing renderToReactEmail', () => { + const Component = vi.fn(() => 'rendered'); + const Base = EmailNode.from(Heading, Component); + + const a = Base.configure({ levels: [1, 2] }); + const b = a.configure({ levels: [1, 2, 3] }); + + expect(b).toBeInstanceOf(EmailNode); + expect(b.config.renderToReactEmail).toBe(Component); + }); }); diff --git a/packages/editor/src/email-editor/email-editor.spec.tsx b/packages/editor/src/email-editor/email-editor.spec.tsx new file mode 100644 index 0000000000..1affa976c2 --- /dev/null +++ b/packages/editor/src/email-editor/email-editor.spec.tsx @@ -0,0 +1,66 @@ +import { cleanup, render } from '@testing-library/react'; +import { createRef } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { EmailEditor, type EmailEditorRef } from './email-editor'; + +vi.mock('@/actions/ai', () => ({ + uploadImageViaAI: vi.fn(), +})); + +describe('EmailEditor', () => { + afterEach(() => { + cleanup(); + }); + + it('exposes the imperative ref API', async () => { + const ref = createRef(); + const onReady = vi.fn(); + + render(); + + // Ready bridge runs in a layout effect. + await vi.waitFor(() => expect(onReady).toHaveBeenCalled()); + + expect(ref.current).not.toBeNull(); + expect(typeof ref.current?.getEmail).toBe('function'); + expect(typeof ref.current?.getEmailHTML).toBe('function'); + expect(typeof ref.current?.getEmailText).toBe('function'); + expect(typeof ref.current?.getJSON).toBe('function'); + // editor may briefly be null while initializing + const json = ref.current?.getJSON(); + expect(json?.type).toBe('doc'); + }); + + it('fires onReady exactly once after mount', async () => { + const onReady = vi.fn(); + render(); + await vi.waitFor(() => expect(onReady).toHaveBeenCalled()); + expect(onReady).toHaveBeenCalledTimes(1); + }); + + it('calls onUpdate when the document changes', async () => { + const ref = createRef(); + const onUpdate = vi.fn(); + render( + , + ); + await vi.waitFor(() => expect(ref.current?.editor).toBeTruthy()); + + const editor = ref.current?.editor; + expect(editor).toBeTruthy(); + editor?.commands.insertContent(' more'); + + await vi.waitFor(() => expect(onUpdate).toHaveBeenCalled()); + // The ref passed to onUpdate must reflect the current editor state. + const lastRef = onUpdate.mock.calls.at(-1)?.[0] as EmailEditorRef; + const html = await lastRef.getEmailHTML(); + expect(html).toContain('more'); + }); + + it('respects editable=false', async () => { + const ref = createRef(); + render(); + await vi.waitFor(() => expect(ref.current?.editor).toBeTruthy()); + expect(ref.current?.editor?.isEditable).toBe(false); + }); +}); diff --git a/packages/editor/src/extensions/__snapshots__/blockquote.spec.tsx.snap b/packages/editor/src/extensions/__snapshots__/blockquote.spec.tsx.snap new file mode 100644 index 0000000000..8a78c286d3 --- /dev/null +++ b/packages/editor/src/extensions/__snapshots__/blockquote.spec.tsx.snap @@ -0,0 +1,11 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Blockquote Node > renders the snapshot 1`] = ` +" + +
+ a quote +
+ +" +`; diff --git a/packages/editor/src/extensions/__snapshots__/bullet-list.spec.tsx.snap b/packages/editor/src/extensions/__snapshots__/bullet-list.spec.tsx.snap new file mode 100644 index 0000000000..104d15cc6f --- /dev/null +++ b/packages/editor/src/extensions/__snapshots__/bullet-list.spec.tsx.snap @@ -0,0 +1,11 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`BulletList Node > renders the snapshot 1`] = ` +" + +
    + items +
+ +" +`; diff --git a/packages/editor/src/extensions/__snapshots__/code.spec.tsx.snap b/packages/editor/src/extensions/__snapshots__/code.spec.tsx.snap new file mode 100644 index 0000000000..10efe7a63e --- /dev/null +++ b/packages/editor/src/extensions/__snapshots__/code.spec.tsx.snap @@ -0,0 +1,8 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Code Mark > renders the snapshot 1`] = ` +" +inline() +" +`; diff --git a/packages/editor/src/extensions/__snapshots__/hard-break.spec.tsx.snap b/packages/editor/src/extensions/__snapshots__/hard-break.spec.tsx.snap new file mode 100644 index 0000000000..502cd5ee40 --- /dev/null +++ b/packages/editor/src/extensions/__snapshots__/hard-break.spec.tsx.snap @@ -0,0 +1,7 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`HardBreak Node > renders the snapshot 1`] = ` +" +
+" +`; diff --git a/packages/editor/src/extensions/__snapshots__/italic.spec.tsx.snap b/packages/editor/src/extensions/__snapshots__/italic.spec.tsx.snap new file mode 100644 index 0000000000..bf5775433d --- /dev/null +++ b/packages/editor/src/extensions/__snapshots__/italic.spec.tsx.snap @@ -0,0 +1,8 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Italic Mark > renders the snapshot 1`] = ` +" +italic text +" +`; diff --git a/packages/editor/src/extensions/__snapshots__/list-item.spec.tsx.snap b/packages/editor/src/extensions/__snapshots__/list-item.spec.tsx.snap new file mode 100644 index 0000000000..cf9d0715f7 --- /dev/null +++ b/packages/editor/src/extensions/__snapshots__/list-item.spec.tsx.snap @@ -0,0 +1,9 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ListItem Node > renders the snapshot 1`] = ` +" + +
  • item
  • + +" +`; diff --git a/packages/editor/src/extensions/__snapshots__/ordered-list.spec.tsx.snap b/packages/editor/src/extensions/__snapshots__/ordered-list.spec.tsx.snap new file mode 100644 index 0000000000..171cb4b8ff --- /dev/null +++ b/packages/editor/src/extensions/__snapshots__/ordered-list.spec.tsx.snap @@ -0,0 +1,11 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`OrderedList Node > renders the snapshot 1`] = ` +" + +
      + items +
    + +" +`; diff --git a/packages/editor/src/extensions/__snapshots__/paragraph.spec.tsx.snap b/packages/editor/src/extensions/__snapshots__/paragraph.spec.tsx.snap new file mode 100644 index 0000000000..866f82ec6d --- /dev/null +++ b/packages/editor/src/extensions/__snapshots__/paragraph.spec.tsx.snap @@ -0,0 +1,25 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Paragraph Node > renders the snapshot when empty 1`] = ` +" + +


    + +" +`; + +exports[`Paragraph Node > renders the snapshot with class and content 1`] = ` +" + +

    hello

    + +" +`; + +exports[`Paragraph Node > renders the snapshot with inline style and alignment 1`] = ` +" + +

    centered

    + +" +`; diff --git a/packages/editor/src/extensions/__snapshots__/strike.spec.tsx.snap b/packages/editor/src/extensions/__snapshots__/strike.spec.tsx.snap new file mode 100644 index 0000000000..144bd512eb --- /dev/null +++ b/packages/editor/src/extensions/__snapshots__/strike.spec.tsx.snap @@ -0,0 +1,8 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Strike Mark > renders the snapshot 1`] = ` +" +crossed +" +`; diff --git a/packages/editor/src/extensions/__snapshots__/sup.spec.tsx.snap b/packages/editor/src/extensions/__snapshots__/sup.spec.tsx.snap new file mode 100644 index 0000000000..f83d14887b --- /dev/null +++ b/packages/editor/src/extensions/__snapshots__/sup.spec.tsx.snap @@ -0,0 +1,8 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Sup Mark > renders the snapshot 1`] = ` +" +th +" +`; diff --git a/packages/editor/src/extensions/__snapshots__/underline.spec.tsx.snap b/packages/editor/src/extensions/__snapshots__/underline.spec.tsx.snap new file mode 100644 index 0000000000..4994905610 --- /dev/null +++ b/packages/editor/src/extensions/__snapshots__/underline.spec.tsx.snap @@ -0,0 +1,8 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Underline Mark > renders the snapshot 1`] = ` +" +underlined +" +`; diff --git a/packages/editor/src/extensions/__tests__/extension-test-helpers.tsx b/packages/editor/src/extensions/__tests__/extension-test-helpers.tsx new file mode 100644 index 0000000000..09b6075e56 --- /dev/null +++ b/packages/editor/src/extensions/__tests__/extension-test-helpers.tsx @@ -0,0 +1,42 @@ +import { render } from 'react-email'; +import { expect } from 'vitest'; + +interface SnapshotExtensionRenderArgs { + /** The extension whose `config.renderToReactEmail` is exercised. */ + extension: { + name?: string; + config: { renderToReactEmail?: React.ComponentType }; + }; + /** ProseMirror node JSON shape passed as the `node` prop. */ + node: { type: string; attrs?: Record; content?: unknown[] }; + /** Inline style passed to the rendered component. */ + style?: React.CSSProperties; + /** Optional children passed to the rendered component (used by marks). */ + children?: React.ReactNode; +} + +/** + * Renders an extension's React Email component to HTML and snapshots it. + * Mirrors the pattern from extensions/heading.spec.tsx so all extension + * snapshots stay consistent and reviewable. + */ +export async function snapshotExtensionRender({ + extension, + node, + style, + children, +}: SnapshotExtensionRenderArgs): Promise { + const Component = extension.config.renderToReactEmail; + expect(Component).toBeDefined(); + + const html = await render( + // The extension type is loose because each extension declares its own + // node/mark shape; we only care that `node` and `style` reach it. + + {children} + , + { pretty: true }, + ); + + expect(html).toMatchSnapshot(); +} diff --git a/packages/editor/src/extensions/blockquote.spec.tsx b/packages/editor/src/extensions/blockquote.spec.tsx new file mode 100644 index 0000000000..5080d00c12 --- /dev/null +++ b/packages/editor/src/extensions/blockquote.spec.tsx @@ -0,0 +1,20 @@ +import { describe, it } from 'vitest'; +import { snapshotExtensionRender } from './__tests__/extension-test-helpers'; +import { Blockquote } from './blockquote'; + +describe('Blockquote Node', () => { + it('renders the snapshot', async () => { + await snapshotExtensionRender({ + extension: Blockquote as unknown as Parameters< + typeof snapshotExtensionRender + >[0]['extension'], + node: { + type: 'blockquote', + attrs: { class: 'node-blockquote', style: '' }, + content: [{}], + }, + style: { borderLeft: '3px solid' }, + children: 'a quote', + }); + }); +}); diff --git a/packages/editor/src/extensions/bullet-list.spec.tsx b/packages/editor/src/extensions/bullet-list.spec.tsx new file mode 100644 index 0000000000..b5ca848bd1 --- /dev/null +++ b/packages/editor/src/extensions/bullet-list.spec.tsx @@ -0,0 +1,19 @@ +import { describe, it } from 'vitest'; +import { snapshotExtensionRender } from './__tests__/extension-test-helpers'; +import { BulletList } from './bullet-list'; + +describe('BulletList Node', () => { + it('renders the snapshot', async () => { + await snapshotExtensionRender({ + extension: BulletList as unknown as Parameters< + typeof snapshotExtensionRender + >[0]['extension'], + node: { + type: 'bulletList', + attrs: { class: 'node-list node-bulletList', style: '' }, + content: [{}], + }, + children: 'items', + }); + }); +}); diff --git a/packages/editor/src/extensions/code.spec.tsx b/packages/editor/src/extensions/code.spec.tsx new file mode 100644 index 0000000000..b25b4d5b01 --- /dev/null +++ b/packages/editor/src/extensions/code.spec.tsx @@ -0,0 +1,16 @@ +import { describe, it } from 'vitest'; +import { snapshotExtensionRender } from './__tests__/extension-test-helpers'; +import { Code } from './code'; + +describe('Code Mark', () => { + it('renders the snapshot', async () => { + await snapshotExtensionRender({ + extension: Code as unknown as Parameters< + typeof snapshotExtensionRender + >[0]['extension'], + node: { type: 'text' }, + style: { fontFamily: 'monospace' }, + children: 'inline()', + }); + }); +}); diff --git a/packages/editor/src/extensions/hard-break.spec.tsx b/packages/editor/src/extensions/hard-break.spec.tsx new file mode 100644 index 0000000000..cdd279328f --- /dev/null +++ b/packages/editor/src/extensions/hard-break.spec.tsx @@ -0,0 +1,15 @@ +import { describe, it } from 'vitest'; +import { snapshotExtensionRender } from './__tests__/extension-test-helpers'; +import { HardBreak } from './hard-break'; + +describe('HardBreak Node', () => { + it('renders the snapshot', async () => { + await snapshotExtensionRender({ + extension: HardBreak as unknown as Parameters< + typeof snapshotExtensionRender + >[0]['extension'], + node: { type: 'hardBreak' }, + style: {}, + }); + }); +}); diff --git a/packages/editor/src/extensions/italic.spec.tsx b/packages/editor/src/extensions/italic.spec.tsx new file mode 100644 index 0000000000..46e9f50f50 --- /dev/null +++ b/packages/editor/src/extensions/italic.spec.tsx @@ -0,0 +1,16 @@ +import { describe, it } from 'vitest'; +import { snapshotExtensionRender } from './__tests__/extension-test-helpers'; +import { Italic } from './italic'; + +describe('Italic Mark', () => { + it('renders the snapshot', async () => { + await snapshotExtensionRender({ + extension: Italic as unknown as Parameters< + typeof snapshotExtensionRender + >[0]['extension'], + node: { type: 'text' }, + style: { fontStyle: 'italic' }, + children: 'italic text', + }); + }); +}); diff --git a/packages/editor/src/extensions/list-item.spec.tsx b/packages/editor/src/extensions/list-item.spec.tsx new file mode 100644 index 0000000000..a99c30d223 --- /dev/null +++ b/packages/editor/src/extensions/list-item.spec.tsx @@ -0,0 +1,20 @@ +import { describe, it } from 'vitest'; +import { snapshotExtensionRender } from './__tests__/extension-test-helpers'; +import { ListItem } from './list-item'; + +describe('ListItem Node', () => { + it('renders the snapshot', async () => { + await snapshotExtensionRender({ + extension: ListItem as unknown as Parameters< + typeof snapshotExtensionRender + >[0]['extension'], + node: { + type: 'listItem', + attrs: { class: 'node-listItem', style: '' }, + content: [{}], + }, + style: { margin: '4px 0' }, + children: 'item', + }); + }); +}); diff --git a/packages/editor/src/extensions/ordered-list.spec.tsx b/packages/editor/src/extensions/ordered-list.spec.tsx new file mode 100644 index 0000000000..de8026f415 --- /dev/null +++ b/packages/editor/src/extensions/ordered-list.spec.tsx @@ -0,0 +1,19 @@ +import { describe, it } from 'vitest'; +import { snapshotExtensionRender } from './__tests__/extension-test-helpers'; +import { OrderedList } from './ordered-list'; + +describe('OrderedList Node', () => { + it('renders the snapshot', async () => { + await snapshotExtensionRender({ + extension: OrderedList as unknown as Parameters< + typeof snapshotExtensionRender + >[0]['extension'], + node: { + type: 'orderedList', + attrs: { class: 'node-list node-orderedList', style: '' }, + content: [{}], + }, + children: 'items', + }); + }); +}); diff --git a/packages/editor/src/extensions/paragraph.spec.tsx b/packages/editor/src/extensions/paragraph.spec.tsx new file mode 100644 index 0000000000..b4d0f1adba --- /dev/null +++ b/packages/editor/src/extensions/paragraph.spec.tsx @@ -0,0 +1,42 @@ +import { describe, it } from 'vitest'; +import { snapshotExtensionRender } from './__tests__/extension-test-helpers'; +import { Paragraph } from './paragraph'; + +describe('Paragraph Node', () => { + it('renders the snapshot when empty', async () => { + await snapshotExtensionRender({ + extension: Paragraph as unknown as Parameters< + typeof snapshotExtensionRender + >[0]['extension'], + node: { type: 'paragraph', attrs: { class: '', style: '' } }, + }); + }); + + it('renders the snapshot with class and content', async () => { + await snapshotExtensionRender({ + extension: Paragraph as unknown as Parameters< + typeof snapshotExtensionRender + >[0]['extension'], + node: { + type: 'paragraph', + attrs: { class: 'node-paragraph', style: '' }, + content: [{}], + }, + children: 'hello', + }); + }); + + it('renders the snapshot with inline style and alignment', async () => { + await snapshotExtensionRender({ + extension: Paragraph as unknown as Parameters< + typeof snapshotExtensionRender + >[0]['extension'], + node: { + type: 'paragraph', + attrs: { style: 'color: red', alignment: 'center' }, + content: [{}], + }, + children: 'centered', + }); + }); +}); diff --git a/packages/editor/src/extensions/strike.spec.tsx b/packages/editor/src/extensions/strike.spec.tsx new file mode 100644 index 0000000000..d52cfa677f --- /dev/null +++ b/packages/editor/src/extensions/strike.spec.tsx @@ -0,0 +1,16 @@ +import { describe, it } from 'vitest'; +import { snapshotExtensionRender } from './__tests__/extension-test-helpers'; +import { Strike } from './strike'; + +describe('Strike Mark', () => { + it('renders the snapshot', async () => { + await snapshotExtensionRender({ + extension: Strike as unknown as Parameters< + typeof snapshotExtensionRender + >[0]['extension'], + node: { type: 'text' }, + style: { textDecoration: 'line-through' }, + children: 'crossed', + }); + }); +}); diff --git a/packages/editor/src/extensions/sup.spec.tsx b/packages/editor/src/extensions/sup.spec.tsx new file mode 100644 index 0000000000..ae311988f6 --- /dev/null +++ b/packages/editor/src/extensions/sup.spec.tsx @@ -0,0 +1,16 @@ +import { describe, it } from 'vitest'; +import { snapshotExtensionRender } from './__tests__/extension-test-helpers'; +import { Sup } from './sup'; + +describe('Sup Mark', () => { + it('renders the snapshot', async () => { + await snapshotExtensionRender({ + extension: Sup as unknown as Parameters< + typeof snapshotExtensionRender + >[0]['extension'], + node: { type: 'text' }, + style: {}, + children: 'th', + }); + }); +}); diff --git a/packages/editor/src/extensions/underline.spec.tsx b/packages/editor/src/extensions/underline.spec.tsx new file mode 100644 index 0000000000..db8b4dc7bb --- /dev/null +++ b/packages/editor/src/extensions/underline.spec.tsx @@ -0,0 +1,16 @@ +import { describe, it } from 'vitest'; +import { snapshotExtensionRender } from './__tests__/extension-test-helpers'; +import { Underline } from './underline'; + +describe('Underline Mark', () => { + it('renders the snapshot', async () => { + await snapshotExtensionRender({ + extension: Underline as unknown as Parameters< + typeof snapshotExtensionRender + >[0]['extension'], + node: { type: 'text' }, + style: { textDecoration: 'underline' }, + children: 'underlined', + }); + }); +}); diff --git a/packages/editor/src/plugins/email-theming/css-transforms.spec.ts b/packages/editor/src/plugins/email-theming/css-transforms.spec.ts index 1282d74956..cb2e7bd355 100644 --- a/packages/editor/src/plugins/email-theming/css-transforms.spec.ts +++ b/packages/editor/src/plugins/email-theming/css-transforms.spec.ts @@ -501,3 +501,92 @@ describe('injectGlobalPlainCss', () => { expect(document.getElementById(STYLE_ID)).toBeNull(); }); }); + +// --------------------------------------------------------------------------- +// Coverage for branches the original spec missed +// --------------------------------------------------------------------------- + +import { mergeCssJs } from './css-transforms'; + +describe('transformToCssJs h-padding legacy branch', () => { + it('maps legacy h-padding to paddingLeft and paddingRight', () => { + const styleArray = [ + { + inputs: [ + { + prop: 'h-padding', + value: 16, + unit: 'px', + classReference: 'body', + }, + ], + }, + ] as unknown as Parameters[0]; + + const result = transformToCssJs(styleArray, DEFAULT_INBOX_FONT_SIZE_PX); + + expect(result.body).toBeDefined(); + expect(result.body!.paddingLeft).toBe('16px'); + expect(result.body!.paddingRight).toBe('16px'); + }); + + it('skips inputs without classReference', () => { + const styleArray = [ + { + inputs: [ + { prop: 'color', value: 'red' }, + { prop: 'fontSize', value: 14, unit: 'px', classReference: 'body' }, + ], + }, + ] as unknown as Parameters[0]; + + const result = transformToCssJs(styleArray, DEFAULT_INBOX_FONT_SIZE_PX); + expect(result.body!.fontSize).toBeDefined(); + // The color entry lacked classReference, so it shouldn't be assigned anywhere. + expect( + Object.values(result).some( + (v) => v && (v as { color?: unknown }).color === 'red', + ), + ).toBe(false); + }); +}); + +describe('mergeCssJs', () => { + it('returns the original when newCssJs is empty', () => { + const original = { body: { color: 'red' } } as unknown as CssJs; + const merged = mergeCssJs(original, {} as CssJs); + expect(merged).toEqual(original); + }); + + it('merges per-key objects so child wins and parent fills gaps', () => { + const original = { + body: { color: 'red', fontSize: '14px' }, + } as unknown as CssJs; + const update = { body: { color: 'blue' } } as unknown as CssJs; + const merged = mergeCssJs(original, update); + expect((merged.body as { color: string }).color).toBe('blue'); + expect((merged.body as { fontSize: string }).fontSize).toBe('14px'); + }); + + it('adds new keys without touching existing ones', () => { + const original = { body: { color: 'red' } } as unknown as CssJs; + const update = { heading: { color: 'blue' } } as unknown as CssJs; + const merged = mergeCssJs(original, update); + expect((merged.body as { color: string }).color).toBe('red'); + expect((merged.heading as { color: string }).color).toBe('blue'); + }); + + it('overwrites primitive values when new value is not an object', () => { + const original = { foo: 'bar' } as unknown as CssJs; + const update = { foo: 'baz' } as unknown as CssJs; + const merged = mergeCssJs(original, update); + expect((merged as unknown as { foo: string }).foo).toBe('baz'); + }); + + it('does not mutate the original input', () => { + const original = { body: { color: 'red' } } as unknown as CssJs; + const snapshot = JSON.parse(JSON.stringify(original)); + mergeCssJs(original, { body: { color: 'blue' } } as unknown as CssJs); + expect(original).toEqual(snapshot); + }); +}); diff --git a/packages/editor/src/plugins/image/file-handler.spec.ts b/packages/editor/src/plugins/image/file-handler.spec.ts new file mode 100644 index 0000000000..0282b622fb --- /dev/null +++ b/packages/editor/src/plugins/image/file-handler.spec.ts @@ -0,0 +1,129 @@ +import type { Editor } from '@tiptap/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createImageFileHandlerPlugin } from './file-handler'; + +const executeUploadFlowMock = vi.fn(); +vi.mock('./upload-flow', () => ({ + executeUploadFlow: (...args: unknown[]) => executeUploadFlowMock(...args), +})); + +describe('createImageFileHandlerPlugin', () => { + const fakeEditor = {} as Editor; + const uploadImage = vi.fn(); + + afterEach(() => { + executeUploadFlowMock.mockClear(); + uploadImage.mockClear(); + }); + + function getProps() { + const plugin = createImageFileHandlerPlugin(fakeEditor, uploadImage); + // Plugin internals are private; we exercise the props directly. + return ( + plugin as unknown as { + props: { + handlePaste: ( + view: unknown, + event: { clipboardData: unknown; preventDefault: () => void }, + ) => boolean; + handleDrop: ( + view: unknown, + event: { dataTransfer: unknown; preventDefault: () => void }, + slice: unknown, + moved: boolean, + ) => boolean; + }; + } + ).props; + } + + describe('handlePaste', () => { + it('triggers the upload flow for image files', () => { + const file = new File(['x'], 'pic.png', { type: 'image/png' }); + const preventDefault = vi.fn(); + const handled = getProps().handlePaste({} as never, { + clipboardData: { files: [file] }, + preventDefault, + }); + + expect(handled).toBe(true); + expect(preventDefault).toHaveBeenCalledOnce(); + expect(executeUploadFlowMock).toHaveBeenCalledWith( + expect.objectContaining({ editor: fakeEditor, file, uploadImage }), + ); + }); + + it('skips non-image MIME types', () => { + const file = new File(['x'], 'doc.pdf', { type: 'application/pdf' }); + const handled = getProps().handlePaste({} as never, { + clipboardData: { files: [file] }, + preventDefault: vi.fn(), + }); + expect(handled).toBe(false); + expect(executeUploadFlowMock).not.toHaveBeenCalled(); + }); + + it('skips when clipboardData is missing', () => { + const handled = getProps().handlePaste({} as never, { + clipboardData: null, + preventDefault: vi.fn(), + }); + expect(handled).toBe(false); + }); + + it('skips when there are no files', () => { + const handled = getProps().handlePaste({} as never, { + clipboardData: { files: [] }, + preventDefault: vi.fn(), + }); + expect(handled).toBe(false); + }); + }); + + describe('handleDrop', () => { + it('triggers the upload flow for image drops', () => { + const file = new File(['x'], 'pic.png', { type: 'image/png' }); + const handled = getProps().handleDrop( + {} as never, + { dataTransfer: { files: [file] }, preventDefault: vi.fn() }, + null, + false, + ); + expect(handled).toBe(true); + expect(executeUploadFlowMock).toHaveBeenCalledOnce(); + }); + + it('skips internal block reorders (moved=true)', () => { + const file = new File(['x'], 'pic.png', { type: 'image/png' }); + const handled = getProps().handleDrop( + {} as never, + { dataTransfer: { files: [file] }, preventDefault: vi.fn() }, + null, + true, + ); + expect(handled).toBe(false); + expect(executeUploadFlowMock).not.toHaveBeenCalled(); + }); + + it('skips non-image MIME types on drop', () => { + const file = new File(['x'], 'doc.pdf', { type: 'application/pdf' }); + const handled = getProps().handleDrop( + {} as never, + { dataTransfer: { files: [file] }, preventDefault: vi.fn() }, + null, + false, + ); + expect(handled).toBe(false); + }); + + it('skips when dataTransfer is missing', () => { + const handled = getProps().handleDrop( + {} as never, + { dataTransfer: null, preventDefault: vi.fn() }, + null, + false, + ); + expect(handled).toBe(false); + }); + }); +}); diff --git a/packages/editor/src/ui/inspector/__tests__/context-helpers.ts b/packages/editor/src/ui/inspector/__tests__/context-helpers.ts new file mode 100644 index 0000000000..59be856417 --- /dev/null +++ b/packages/editor/src/ui/inspector/__tests__/context-helpers.ts @@ -0,0 +1,62 @@ +import { vi } from 'vitest'; +import type { InspectorNodeContext } from '../node'; + +interface BuildContextOptions { + nodeType?: string; + styles?: Record; + attrs?: Record; + themeDefaults?: Record; +} + +/** + * Builds an `InspectorNodeContext` with vi-mocked callbacks. The `styles` + * map is mutated by `setStyle` / `batchSetStyle` so a test can assert on + * the resulting state without inspecting call arguments. + * + * Usage: + * const ctx = buildInspectorContext({ styles: { paddingTop: 8 } }); + * render(); + * // exercise UI... + * expect(ctx.setStyle).toHaveBeenCalledWith('paddingTop', 16); + * expect(ctx.styles.paddingTop).toBe(16); + */ +export function buildInspectorContext({ + nodeType = 'paragraph', + styles = {}, + attrs = {}, + themeDefaults = {}, +}: BuildContextOptions = {}): InspectorNodeContext & { + styles: Record; + attrs: Record; +} { + const stylesMap = { ...styles }; + const attrsMap = { ...attrs }; + + const setStyle = vi.fn((prop: string, value: string | number) => { + stylesMap[prop] = value; + }); + const batchSetStyle = vi.fn( + (changes: Array<{ prop: string; value: string | number }>) => { + for (const c of changes) stylesMap[c.prop] = c.value; + }, + ); + const setAttr = vi.fn((name: string, value: unknown) => { + attrsMap[name] = value; + }); + + return { + nodeType, + nodePos: { pos: 0, inside: 0 }, + getStyle: (prop) => stylesMap[prop as string], + setStyle: setStyle as unknown as InspectorNodeContext['setStyle'], + batchSetStyle: + batchSetStyle as unknown as InspectorNodeContext['batchSetStyle'], + getAttr: (name) => attrsMap[name], + setAttr, + themeDefaults, + presetColors: ['#000000', '#ffffff', '#0670DB'], + // Expose the mutable maps for assertions. + styles: stylesMap, + attrs: attrsMap, + }; +} diff --git a/packages/editor/src/ui/inspector/sections/attributes.spec.tsx b/packages/editor/src/ui/inspector/sections/attributes.spec.tsx new file mode 100644 index 0000000000..16ef2fcd95 --- /dev/null +++ b/packages/editor/src/ui/inspector/sections/attributes.spec.tsx @@ -0,0 +1,21 @@ +import { render } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { buildInspectorContext } from '../__tests__/context-helpers'; +import { AttributesSection } from './attributes'; + +vi.mock('@/actions/ai', () => ({ uploadImageViaAI: vi.fn() })); + +describe('AttributesSection', () => { + afterEach(() => vi.clearAllMocks()); + + it('renders nothing when no visible attributes are present', () => { + const ctx = buildInspectorContext({ nodeType: 'paragraph' }); + const { container } = render(); + expect(container.textContent).toBe(''); + }); + + it('does not throw on unknown node types', () => { + const ctx = buildInspectorContext({ nodeType: 'unknownNodeType' }); + expect(() => render()).not.toThrow(); + }); +}); diff --git a/packages/editor/src/ui/inspector/sections/background.spec.tsx b/packages/editor/src/ui/inspector/sections/background.spec.tsx new file mode 100644 index 0000000000..d91441fa73 --- /dev/null +++ b/packages/editor/src/ui/inspector/sections/background.spec.tsx @@ -0,0 +1,43 @@ +import { fireEvent, render } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { buildInspectorContext } from '../__tests__/context-helpers'; +import { BackgroundSection } from './background'; + +vi.mock('@/actions/ai', () => ({ uploadImageViaAI: vi.fn() })); + +describe('BackgroundSection', () => { + afterEach(() => vi.clearAllMocks()); + + it('renders the Background section with the current color', () => { + const ctx = buildInspectorContext({ + styles: { backgroundColor: '#ffffff' }, + }); + const { container } = render(); + expect(container.textContent).toContain('Background'); + const hex = container.querySelector( + '[data-re-inspector-color-hex]', + ); + expect(hex?.value).toBe('#ffffff'); + }); + + it('renders an empty hex value when backgroundColor is missing', () => { + const ctx = buildInspectorContext({}); + const { container } = render(); + const hex = container.querySelector( + '[data-re-inspector-color-hex]', + ); + expect(hex?.value).toBe(''); + }); + + it('typing into the hex input forwards the value to setStyle', () => { + const ctx = buildInspectorContext({}); + const { container } = render(); + const hex = container.querySelector( + '[data-re-inspector-color-hex]', + ); + expect(hex).not.toBeNull(); + fireEvent.change(hex!, { target: { value: '#0670DB' } }); + expect(ctx.setStyle).toHaveBeenCalledWith('backgroundColor', '#0670DB'); + expect(ctx.styles.backgroundColor).toBe('#0670DB'); + }); +}); diff --git a/packages/editor/src/ui/inspector/sections/border.spec.tsx b/packages/editor/src/ui/inspector/sections/border.spec.tsx new file mode 100644 index 0000000000..aff59b0eb9 --- /dev/null +++ b/packages/editor/src/ui/inspector/sections/border.spec.tsx @@ -0,0 +1,120 @@ +import { fireEvent, render } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { buildInspectorContext } from '../__tests__/context-helpers'; +import { BorderSection } from './border'; + +vi.mock('@/actions/ai', () => ({ uploadImageViaAI: vi.fn() })); + +describe('BorderSection', () => { + afterEach(() => vi.clearAllMocks()); + + it('renders the Border title with current uniform values', () => { + const ctx = buildInspectorContext({ + styles: { + borderWidth: 2, + borderColor: '#0670DB', + borderStyle: 'solid', + borderRadius: 4, + }, + }); + const { container } = render(); + expect(container.textContent).toContain('Border'); + const widthInput = container.querySelector( + 'input[data-type="number"]', + ); + expect(widthInput?.value).toBe('2'); + }); + + it('commits a uniform borderWidth change via setStyle when the input blurs', () => { + const ctx = buildInspectorContext({ + styles: { + borderWidth: 0, + borderColor: '#000000', + borderStyle: 'solid', + }, + }); + const { container } = render(); + const widthInput = container.querySelector( + 'input[data-type="number"]', + ); + fireEvent.focus(widthInput!); + fireEvent.change(widthInput!, { target: { value: '3' } }); + fireEvent.blur(widthInput!); + + expect(ctx.setStyle).toHaveBeenCalledWith('borderWidth', 3); + expect(ctx.styles.borderWidth).toBe(3); + }); + + it('commits a uniform borderColor change via setStyle from the hex input', () => { + const ctx = buildInspectorContext({ + styles: { + borderWidth: 1, + borderColor: '#000000', + borderStyle: 'solid', + }, + }); + const { container } = render(); + const hex = container.querySelector( + '[data-re-inspector-color-hex]', + ); + fireEvent.change(hex!, { target: { value: '#0670DB' } }); + + expect(ctx.setStyle).toHaveBeenCalledWith('borderColor', '#0670DB'); + expect(ctx.styles.borderColor).toBe('#0670DB'); + }); + + it('per-side mutation does not reset the other three sides', () => { + const ctx = buildInspectorContext({ + styles: { + borderTopWidth: 1, + borderRightWidth: 1, + borderBottomWidth: 1, + borderLeftWidth: 1, + borderTopColor: '#000000', + borderRightColor: '#000000', + borderBottomColor: '#000000', + borderLeftColor: '#000000', + borderTopStyle: 'solid', + borderRightStyle: 'solid', + borderBottomStyle: 'solid', + borderLeftStyle: 'solid', + // styles differ so per-side mode auto-activates + borderTopWidth_marker: 'mixed', + }, + }); + // Mismatch to force individual mode: + ctx.styles.borderTopWidth = 1; + ctx.styles.borderRightWidth = 2; + + const { container } = render(); + const widthInputs = Array.from( + container.querySelectorAll('input[data-type="number"]'), + ); + expect(widthInputs.length).toBeGreaterThanOrEqual(4); + + fireEvent.focus(widthInputs[0]); + fireEvent.change(widthInputs[0], { target: { value: '6' } }); + fireEvent.blur(widthInputs[0]); + + expect(ctx.styles.borderTopWidth).toBe(6); + expect(ctx.styles.borderRightWidth).toBe(2); + expect(ctx.styles.borderBottomWidth).toBe(1); + expect(ctx.styles.borderLeftWidth).toBe(1); + }); + + it('commits borderRadius via setStyle when the radius input blurs', () => { + const ctx = buildInspectorContext({ styles: { borderRadius: 0 } }); + const { container } = render(); + const numberInputs = Array.from( + container.querySelectorAll('input[data-type="number"]'), + ); + // The last numeric input belongs to BorderRadiusPicker. + const radiusInput = numberInputs[numberInputs.length - 1]; + fireEvent.focus(radiusInput); + fireEvent.change(radiusInput, { target: { value: '12' } }); + fireEvent.blur(radiusInput); + + expect(ctx.setStyle).toHaveBeenCalledWith('borderRadius', '12px'); + expect(ctx.styles.borderRadius).toBe('12px'); + }); +}); diff --git a/packages/editor/src/ui/inspector/sections/column-spacing.spec.tsx b/packages/editor/src/ui/inspector/sections/column-spacing.spec.tsx new file mode 100644 index 0000000000..450e5d18e3 --- /dev/null +++ b/packages/editor/src/ui/inspector/sections/column-spacing.spec.tsx @@ -0,0 +1,80 @@ +import { fireEvent, render } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { buildInspectorContext } from '../__tests__/context-helpers'; +import { ColumnSpacingSection } from './column-spacing'; + +vi.mock('@/actions/ai', () => ({ uploadImageViaAI: vi.fn() })); + +describe('ColumnSpacingSection', () => { + afterEach(() => vi.clearAllMocks()); + + it('renders for column-parent node types with current cellspacing', () => { + const ctx = buildInspectorContext({ + nodeType: 'twoColumns', + attrs: { cellspacing: 8 }, + }); + const { container } = render(); + expect(container.textContent).toContain('Column spacing'); + const input = container.querySelector( + 'input[data-type="number"]', + ); + expect(input?.value).toBe('8'); + }); + + it('renders nothing for non-column node types', () => { + const ctx = buildInspectorContext({ nodeType: 'paragraph' }); + const { container } = render(); + expect(container.textContent).toBe(''); + }); + + it.each([ + undefined, + null, + '', + ])('shows 0 in the input when cellspacing is %p', (raw) => { + const ctx = buildInspectorContext({ + nodeType: 'twoColumns', + attrs: { cellspacing: raw as unknown }, + }); + const { container } = render(); + const input = container.querySelector( + 'input[data-type="number"]', + ); + expect(input?.value).toBe('0'); + }); + + it('commits cellspacing via setAttr when the input blurs', () => { + const ctx = buildInspectorContext({ + nodeType: 'threeColumns', + attrs: { cellspacing: 0 }, + }); + const { container } = render(); + const input = container.querySelector( + 'input[data-type="number"]', + ); + expect(input).not.toBeNull(); + fireEvent.focus(input!); + fireEvent.change(input!, { target: { value: '12' } }); + fireEvent.blur(input!); + + expect(ctx.setAttr).toHaveBeenCalledWith('cellspacing', 12); + expect(ctx.attrs.cellspacing).toBe(12); + }); + + it('coerces an empty input back to 0 via setAttr', () => { + const ctx = buildInspectorContext({ + nodeType: 'fourColumns', + attrs: { cellspacing: 8 }, + }); + const { container } = render(); + const input = container.querySelector( + 'input[data-type="number"]', + ); + fireEvent.focus(input!); + fireEvent.change(input!, { target: { value: '' } }); + fireEvent.blur(input!); + + expect(ctx.setAttr).toHaveBeenCalledWith('cellspacing', 0); + expect(ctx.attrs.cellspacing).toBe(0); + }); +}); diff --git a/packages/editor/src/ui/inspector/sections/link.spec.tsx b/packages/editor/src/ui/inspector/sections/link.spec.tsx new file mode 100644 index 0000000000..047561d86a --- /dev/null +++ b/packages/editor/src/ui/inspector/sections/link.spec.tsx @@ -0,0 +1,79 @@ +import { fireEvent, render } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { LinkSection } from './link'; + +vi.mock('@/actions/ai', () => ({ uploadImageViaAI: vi.fn() })); + +describe('LinkSection', () => { + afterEach(() => vi.clearAllMocks()); + + it('renders nothing when no link mark is active', () => { + const setLinkColor = vi.fn(); + const { container } = render( + , + ); + expect(container.textContent).toBe(''); + }); + + it('renders URL and Color rows when a link is active', () => { + const setLinkColor = vi.fn(); + const { container } = render( + , + ); + expect(container.textContent).toContain('Link'); + expect(container.textContent).toContain('URL'); + expect(container.textContent).toContain('Color'); + }); + + it('typing into the hex input forwards the value to setLinkColor', () => { + const setLinkColor = vi.fn(); + const { container } = render( + , + ); + + const hexInput = container.querySelector( + '[data-re-inspector-color-hex]', + ); + expect(hexInput).not.toBeNull(); + fireEvent.change(hexInput!, { target: { value: '#ff0000' } }); + expect(setLinkColor).toHaveBeenCalledWith('#ff0000'); + }); + + it('picking from the native color input forwards the value to setLinkColor', () => { + const setLinkColor = vi.fn(); + const { container } = render( + , + ); + + const colorTrigger = container.querySelector( + '[data-re-inspector-color-trigger]', + ); + expect(colorTrigger).not.toBeNull(); + fireEvent.change(colorTrigger!, { target: { value: '#0670db' } }); + expect(setLinkColor).toHaveBeenCalledWith('#0670db'); + }); +}); diff --git a/packages/editor/src/ui/inspector/sections/padding.spec.tsx b/packages/editor/src/ui/inspector/sections/padding.spec.tsx new file mode 100644 index 0000000000..f1107d2817 --- /dev/null +++ b/packages/editor/src/ui/inspector/sections/padding.spec.tsx @@ -0,0 +1,99 @@ +import { fireEvent, render } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { buildInspectorContext } from '../__tests__/context-helpers'; +import { PaddingSection } from './padding'; + +vi.mock('@/actions/ai', () => ({ uploadImageViaAI: vi.fn() })); + +describe('PaddingSection', () => { + afterEach(() => vi.clearAllMocks()); + + it('renders the Spacing section title', () => { + const ctx = buildInspectorContext({ + styles: { + paddingTop: 8, + paddingRight: 12, + paddingBottom: 8, + paddingLeft: 12, + }, + }); + const { container } = render(); + expect(container.textContent).toContain('Spacing'); + }); + + it('shows the current padding values in the inputs', () => { + const ctx = buildInspectorContext({ + styles: { + paddingTop: 16, + paddingRight: 4, + paddingBottom: 16, + paddingLeft: 4, + }, + }); + const { container } = render(); + const inputs = Array.from( + container.querySelectorAll('input[data-type="number"]'), + ); + const values = inputs.map((i) => i.value); + expect(values).toEqual(expect.arrayContaining(['16', '4'])); + }); + + it('coerces non-numeric padding to 0 in the rendered input', () => { + const ctx = buildInspectorContext({ + styles: { paddingTop: 'invalid' }, + }); + const { container } = render(); + const inputs = Array.from( + container.querySelectorAll('input[data-type="number"]'), + ); + expect(inputs.every((i) => i.value === '0')).toBe(true); + }); + + it('updates all four sides when in uniform mode (all values equal)', () => { + const ctx = buildInspectorContext({ + styles: { + paddingTop: 8, + paddingRight: 8, + paddingBottom: 8, + paddingLeft: 8, + }, + }); + const { container } = render(); + const input = container.querySelector( + 'input[data-type="number"]', + ); + expect(input).not.toBeNull(); + fireEvent.focus(input!); + fireEvent.change(input!, { target: { value: '16' } }); + fireEvent.blur(input!); + + expect(ctx.styles.paddingTop).toBe(16); + expect(ctx.styles.paddingRight).toBe(16); + expect(ctx.styles.paddingBottom).toBe(16); + expect(ctx.styles.paddingLeft).toBe(16); + }); + + it('updates only the changed side when sides are individually set', () => { + const ctx = buildInspectorContext({ + styles: { + paddingTop: 8, + paddingRight: 4, + paddingBottom: 8, + paddingLeft: 4, + }, + }); + const { container } = render(); + const inputs = Array.from( + container.querySelectorAll('input[data-type="number"]'), + ); + expect(inputs.length).toBe(4); + fireEvent.focus(inputs[0]); + fireEvent.change(inputs[0], { target: { value: '24' } }); + fireEvent.blur(inputs[0]); + + expect(ctx.styles.paddingTop).toBe(24); + expect(ctx.styles.paddingRight).toBe(4); + expect(ctx.styles.paddingBottom).toBe(8); + expect(ctx.styles.paddingLeft).toBe(4); + }); +}); diff --git a/packages/editor/src/ui/inspector/sections/size.spec.tsx b/packages/editor/src/ui/inspector/sections/size.spec.tsx new file mode 100644 index 0000000000..c4896fe700 --- /dev/null +++ b/packages/editor/src/ui/inspector/sections/size.spec.tsx @@ -0,0 +1,69 @@ +import { fireEvent, render } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { buildInspectorContext } from '../__tests__/context-helpers'; +import { SizeSection } from './size'; + +vi.mock('@/actions/ai', () => ({ uploadImageViaAI: vi.fn() })); + +describe('SizeSection', () => { + afterEach(() => vi.clearAllMocks()); + + it('shows width/height read from styles for non-image nodes', () => { + const ctx = buildInspectorContext({ + nodeType: 'paragraph', + styles: { width: 200, height: 100 }, + }); + const { container } = render(); + const inputs = Array.from( + container.querySelectorAll('input[data-type="number"]'), + ); + expect(inputs.map((i) => i.value)).toEqual(['200', '100']); + }); + + it('shows width/height read from attrs for image nodes', () => { + const ctx = buildInspectorContext({ + nodeType: 'image', + attrs: { width: 320, height: 240 }, + }); + const { container } = render(); + const inputs = Array.from( + container.querySelectorAll('input[data-type="number"]'), + ); + expect(inputs.map((i) => i.value)).toEqual(['320', '240']); + }); + + it('commits width changes via setStyle for non-image nodes', () => { + const ctx = buildInspectorContext({ + nodeType: 'paragraph', + styles: { width: 200 }, + }); + const { container } = render(); + const widthInput = container.querySelector( + 'input[data-type="number"]', + ); + fireEvent.focus(widthInput!); + fireEvent.change(widthInput!, { target: { value: '320' } }); + fireEvent.blur(widthInput!); + + expect(ctx.setStyle).toHaveBeenCalledWith('width', 320); + expect(ctx.styles.width).toBe(320); + expect(ctx.attrs.width).toBeUndefined(); + }); + + it('commits width changes via setAttr for image nodes', () => { + const ctx = buildInspectorContext({ + nodeType: 'image', + attrs: { width: 200 }, + }); + const { container } = render(); + const widthInput = container.querySelector( + 'input[data-type="number"]', + ); + fireEvent.focus(widthInput!); + fireEvent.change(widthInput!, { target: { value: '480' } }); + fireEvent.blur(widthInput!); + + expect(ctx.setAttr).toHaveBeenCalledWith('width', 480); + expect(ctx.attrs.width).toBe(480); + }); +}); diff --git a/packages/editor/src/ui/inspector/sections/typography.spec.tsx b/packages/editor/src/ui/inspector/sections/typography.spec.tsx new file mode 100644 index 0000000000..2971801625 --- /dev/null +++ b/packages/editor/src/ui/inspector/sections/typography.spec.tsx @@ -0,0 +1,70 @@ +import { render } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { buildInspectorContext } from '../__tests__/context-helpers'; +import { TypographySection } from './typography'; + +vi.mock('@/actions/ai', () => ({ uploadImageViaAI: vi.fn() })); + +describe('TypographySection', () => { + afterEach(() => vi.clearAllMocks()); + + it('reads color, fontSize, lineHeight from getStyle', () => { + const ctx = buildInspectorContext({ + styles: { + color: '#0670DB', + fontSize: 14, + lineHeight: 150, + }, + }); + const { container } = render(); + expect(container.textContent).toContain('Typography'); + }); + + it('color mutation goes through setStyle', () => { + const ctx = buildInspectorContext({ styles: { color: '#000000' } }); + render(); + ctx.setStyle('color', '#0670DB'); + expect(ctx.setStyle).toHaveBeenCalledWith('color', '#0670DB'); + expect(ctx.styles.color).toBe('#0670DB'); + }); + + it('fontSize mutation goes through setStyle', () => { + const ctx = buildInspectorContext({ styles: { fontSize: 14 } }); + render(); + ctx.setStyle('fontSize', 18); + expect(ctx.styles.fontSize).toBe(18); + }); + + it('lineHeight mutation goes through setStyle', () => { + const ctx = buildInspectorContext({ styles: { lineHeight: 150 } }); + render(); + ctx.setStyle('lineHeight', 175); + expect(ctx.styles.lineHeight).toBe(175); + }); + + it('renders text marks UI when marks + toggleMark are provided', () => { + const ctx = buildInspectorContext(); + const toggleMark = vi.fn(); + const { container } = render( + , + ); + expect(container.textContent).toContain('Format'); + }); + + it('renders alignment UI when alignment + setAlignment are provided', () => { + const ctx = buildInspectorContext(); + const setAlignment = vi.fn(); + const { container } = render( + , + ); + expect(container.textContent).toContain('Align'); + }); +}); diff --git a/packages/editor/src/ui/slash-command/commands.spec.ts b/packages/editor/src/ui/slash-command/commands.spec.ts new file mode 100644 index 0000000000..dd2b44630a --- /dev/null +++ b/packages/editor/src/ui/slash-command/commands.spec.ts @@ -0,0 +1,118 @@ +import type { Editor } from '@tiptap/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createTestEditor } from '../../__tests__/editor-test-helpers'; +import { + BULLET_LIST, + BUTTON, + CODE, + DIVIDER, + defaultSlashCommands, + FOUR_COLUMNS, + H1, + H2, + H3, + NUMBERED_LIST, + QUOTE, + SECTION, + TEXT, + THREE_COLUMNS, + TWO_COLUMNS, +} from './commands'; +import type { SlashCommandItem } from './types'; + +vi.mock('@/actions/ai', () => ({ uploadImageViaAI: vi.fn() })); + +interface NodeShape { + type?: string; + attrs?: Record; + content?: NodeShape[]; +} + +function findNodeOfType(editor: Editor, type: string): NodeShape | undefined { + const walk = (nodes: NodeShape[] | undefined): NodeShape | undefined => { + if (!nodes) return undefined; + for (const n of nodes) { + if (n.type === type) return n; + const inner = walk(n.content); + if (inner) return inner; + } + return undefined; + }; + return walk((editor.getJSON() as NodeShape).content); +} + +function hasNodeOfType(editor: Editor, type: string): boolean { + return findNodeOfType(editor, type) !== undefined; +} + +const COMMAND_TABLE: Array<{ + name: string; + cmd: SlashCommandItem; + expectedNode: string; +}> = [ + { name: 'TEXT', cmd: TEXT, expectedNode: 'paragraph' }, + { name: 'H1', cmd: H1, expectedNode: 'heading' }, + { name: 'H2', cmd: H2, expectedNode: 'heading' }, + { name: 'H3', cmd: H3, expectedNode: 'heading' }, + { name: 'BULLET_LIST', cmd: BULLET_LIST, expectedNode: 'bulletList' }, + { name: 'NUMBERED_LIST', cmd: NUMBERED_LIST, expectedNode: 'orderedList' }, + { name: 'QUOTE', cmd: QUOTE, expectedNode: 'blockquote' }, + { name: 'CODE', cmd: CODE, expectedNode: 'codeBlock' }, + { name: 'BUTTON', cmd: BUTTON, expectedNode: 'button' }, + { name: 'DIVIDER', cmd: DIVIDER, expectedNode: 'horizontalRule' }, + { name: 'SECTION', cmd: SECTION, expectedNode: 'section' }, + { name: 'TWO_COLUMNS', cmd: TWO_COLUMNS, expectedNode: 'twoColumns' }, + { name: 'THREE_COLUMNS', cmd: THREE_COLUMNS, expectedNode: 'threeColumns' }, + { name: 'FOUR_COLUMNS', cmd: FOUR_COLUMNS, expectedNode: 'fourColumns' }, +]; + +describe.each(COMMAND_TABLE)('slash command $name', ({ cmd, expectedNode }) => { + let editor: Editor | null = null; + afterEach(() => { + editor?.destroy(); + editor = null; + }); + + it('has the required SlashCommandItem shape', () => { + expect(cmd.title).toBeTruthy(); + expect(cmd.description).toBeTruthy(); + expect(typeof cmd.command).toBe('function'); + expect(Array.isArray(cmd.searchTerms)).toBe(true); + expect(cmd.searchTerms.length).toBeGreaterThan(0); + }); + + it(`inserts a ${expectedNode} node into the editor`, () => { + editor = createTestEditor(); + const { from, to } = editor.state.selection; + cmd.command({ editor, range: { from, to } }); + expect(hasNodeOfType(editor, expectedNode)).toBe(true); + }); +}); + +describe('slash commands — heading levels', () => { + let editor: Editor | null = null; + afterEach(() => { + editor?.destroy(); + editor = null; + }); + + it.each([ + { cmd: H1, level: 1 }, + { cmd: H2, level: 2 }, + { cmd: H3, level: 3 }, + ])('$cmd.title sets heading level to $level', ({ cmd, level }) => { + editor = createTestEditor(); + const { from, to } = editor.state.selection; + cmd.command({ editor, range: { from, to } }); + const heading = findNodeOfType(editor, 'heading'); + expect(heading?.attrs?.level).toBe(level); + }); +}); + +describe('defaultSlashCommands', () => { + it('includes all canonical commands', () => { + for (const { cmd } of COMMAND_TABLE) { + expect(defaultSlashCommands).toContain(cmd); + } + }); +}); diff --git a/packages/editor/src/ui/slash-command/utils.spec.ts b/packages/editor/src/ui/slash-command/utils.spec.ts new file mode 100644 index 0000000000..3ca5c1efb0 --- /dev/null +++ b/packages/editor/src/ui/slash-command/utils.spec.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { updateScrollView } from './utils'; + +describe('updateScrollView', () => { + function makeRect(top: number, bottom: number) { + return { + top, + bottom, + left: 0, + right: 0, + width: 0, + height: 0, + x: 0, + y: 0, + toJSON: () => '', + } as DOMRect; + } + + function makeElement(rect: DOMRect, scrollTop = 0): HTMLElement { + const el = { + scrollTop, + getBoundingClientRect: () => rect, + } as unknown as HTMLElement; + return el; + } + + it('scrolls up when item is above the visible area', () => { + const container = makeElement(makeRect(100, 200), 50); + const item = makeElement(makeRect(80, 100)); + updateScrollView(container, item); + // item.top (80) < container.top (100); diff = 20, scrollTop -= 20 + expect(container.scrollTop).toBe(30); + }); + + it('scrolls down when item is below the visible area', () => { + const container = makeElement(makeRect(100, 200), 50); + const item = makeElement(makeRect(180, 220)); + updateScrollView(container, item); + // item.bottom (220) > container.bottom (200); diff = 20, scrollTop += 20 + expect(container.scrollTop).toBe(70); + }); + + it('does nothing when item is fully visible', () => { + const container = makeElement(makeRect(100, 200), 50); + const item = makeElement(makeRect(120, 180)); + updateScrollView(container, item); + expect(container.scrollTop).toBe(50); + }); + + it('handles boundary case where item.top equals container.top', () => { + const container = makeElement(makeRect(100, 200), 50); + const item = makeElement(makeRect(100, 150)); + updateScrollView(container, item); + expect(container.scrollTop).toBe(50); + }); +}); diff --git a/packages/editor/src/utils/__snapshots__/paste-sanitizer.spec.ts.snap b/packages/editor/src/utils/__snapshots__/paste-sanitizer.spec.ts.snap new file mode 100644 index 0000000000..464fe51660 --- /dev/null +++ b/packages/editor/src/utils/__snapshots__/paste-sanitizer.spec.ts.snap @@ -0,0 +1,53 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`sanitizePastedHtml > paste-source fixtures > sanitizes apple-mail.html deterministically 1`] = ` +"
    Hello,

    Quoted text:
    Original message text.
    +" +`; + +exports[`sanitizePastedHtml > paste-source fixtures > sanitizes chatgpt.html deterministically 1`] = ` +"

    Here's a draft welcome email for your beta users. Tone is friendly but concise.


    Subject: Welcome to Acme — let's get you set up

    Hi {{firstName}},

    Thanks for joining the Acme beta! You're one of the first 100 people to try the new mobile app, and your feedback is going to shape what we build next.

    Three things to try this week:

    1. Set up your first audience — paste a CSV or connect your CRM.
    2. Send a test broadcast — see how delivery feels with our infrastructure.
    3. Reply to this email with anything that surprised you, good or bad.

    Real humans read every reply. I'll personally respond within 24 hours.

    — Sarah, founder

    P.S. If you'd rather opt out of beta-only emails, use this link.

    +" +`; + +exports[`sanitizePastedHtml > paste-source fixtures > sanitizes github-markdown.html deterministically 1`] = ` +"

    Changelog v2.4.0

    New

    • Granular API key permissions: scope a key to a single domain or audience.
    • 2048-bit DKIM keys are now the default for new domains.

    Fixed

    • Pasted content from Word no longer leaks Mso* classes into the editor.
    • Image upload errors now surface a toast instead of failing silently.
    curl -X POST https://api.acme.example/v1/emails \\
    +  -H "Authorization: Bearer $API_KEY" \\
    +  --data '{"to":"hi@example.com","from":"team@acme.example","subject":"Hi"}'
    +" +`; + +exports[`sanitizePastedHtml > paste-source fixtures > sanitizes gmail.html deterministically 1`] = ` +"
    Hi team,

    Please review the attached document.

    Thanks,
    Sender
    +" +`; + +exports[`sanitizePastedHtml > paste-source fixtures > sanitizes linear-comment.html deterministically 1`] = ` +"

    Quick triage notes from this morning's review:

    • The image-upload regression looks like an abort-controller issue when the editor unmounts mid-upload. Repro in Safari is ~1 in 4.

    • Slash command flicker is fixed on the latest main.

    For Safari specifically — does the new event-bus path solve it, or do we still need to land the imperative theme reconfigure?

    cc @sarah — want to pair on this Thursday?

    +" +`; + +exports[`sanitizePastedHtml > paste-source fixtures > sanitizes notion.html deterministically 1`] = ` +"

    Q2 Launch Plan

    Owner: Sarah · Status: In progress

    💡

    Goal: cut activation time from 14 days to under 5 by shipping the mobile app + onboarding rewrite.

    Milestones

    Risks

    • Mobile push notification setup may slip; iOS review cycles are unpredictable.
    • Liveblocks limits at the current tier — we'll cross that line around 250 concurrent rooms.

    See full launch plan for sub-tasks.

    +" +`; + +exports[`sanitizePastedHtml > paste-source fixtures > sanitizes outlook-web.html deterministically 1`] = ` +"

    Hi team,

     

    Quick update on the migration. We finished the audit on Friday and the report is attached. Headline numbers:

    ·      p95 latency down 38%

    ·      error rate held steady at 0.02%

    Thanks,

    Sarah

    +" +`; + +exports[`sanitizePastedHtml > paste-source fixtures > sanitizes view-source.html deterministically 1`] = ` +"
    Spans two
    AB
    x +" +`; + +exports[`sanitizePastedHtml > paste-source fixtures > sanitizes vscode.html deterministically 1`] = ` +"
    function greet(name: string) {
    return \`Hello, \${name}\`;
    }
    +" +`; + +exports[`sanitizePastedHtml > paste-source fixtures > sanitizes word.html deterministically 1`] = ` +"

    Hello from Word

    Important note with mixed formatting.

    +" +`; diff --git a/packages/editor/src/utils/paste-sanitizer.spec.ts b/packages/editor/src/utils/paste-sanitizer.spec.ts new file mode 100644 index 0000000000..be255a33a5 --- /dev/null +++ b/packages/editor/src/utils/paste-sanitizer.spec.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from 'vitest'; +import { loadFixture } from '../__tests__/fixtures/load-fixture'; +import { sanitizePastedHtml } from './paste-sanitizer'; + +describe('sanitizePastedHtml', () => { + describe('editor-origin HTML', () => { + it('passes through unchanged when a node-* class is present', () => { + const html = '

    Hello

    '; + expect(sanitizePastedHtml(html)).toBe(html); + }); + + it('detects editor origin even when node-* is not the first class', () => { + const html = + '

    Hello

    '; + expect(sanitizePastedHtml(html)).toBe(html); + }); + }); + + describe('external HTML — attribute stripping', () => { + it('strips style and class on plain elements', () => { + const out = sanitizePastedHtml( + '

    Hello

    ', + ); + expect(out).not.toContain('class='); + expect(out).not.toContain('style='); + expect(out).toContain('Hello'); + }); + + it('strips data-* attributes', () => { + const out = sanitizePastedHtml( + '

    Hello

    ', + ); + expect(out).not.toContain('data-'); + }); + + it('preserves href, target, rel on ', () => { + const out = sanitizePastedHtml( + 'link', + ); + expect(out).toContain('href="https://example.com"'); + expect(out).toContain('target="_blank"'); + expect(out).toContain('rel="noopener"'); + expect(out).not.toContain('class='); + }); + + it('preserves src, alt, width, height on ', () => { + const out = sanitizePastedHtml( + 'x', + ); + expect(out).toContain('src="https://example.com/x.png"'); + expect(out).toContain('alt="x"'); + expect(out).toContain('width="100"'); + expect(out).toContain('height="50"'); + expect(out).not.toContain('style='); + }); + + it('preserves colspan/rowspan on ', () => { + const out = sanitizePastedHtml( + '
    x
    ', + ); + expect(out).toContain('colspan="2"'); + expect(out).toContain('rowspan="3"'); + expect(out).not.toContain('style='); + }); + + it('preserves border/cellpadding/cellspacing on ', () => { + const out = sanitizePastedHtml( + '
    ', + ); + expect(out).toContain('border="1"'); + expect(out).toContain('cellpadding="4"'); + expect(out).toContain('cellspacing="0"'); + }); + + it('preserves id globally', () => { + const out = sanitizePastedHtml('
    y
    '); + expect(out).toContain('id="anchor"'); + expect(out).not.toContain('class='); + }); + }); + + describe('unsafe attributes never leak', () => { + it('strips inline event handlers like onclick', () => { + const out = sanitizePastedHtml( + '', + ); + expect(out).not.toMatch(/onclick/i); + }); + + it('strips onerror on ', () => { + const out = sanitizePastedHtml( + '', + ); + expect(out).not.toMatch(/onerror/i); + }); + + it('strips srcdoc on ', + ); + expect(out).not.toMatch(/srcdoc/i); + }); + + it('strips formaction on ', + ); + expect(out).not.toMatch(/formaction/i); + }); + + it('rejects javascript: URLs in href', () => { + const out = sanitizePastedHtml('x'); + expect(out).not.toMatch(/javascript:/i); + }); + + it('rejects javascript: URLs in src', () => { + const out = sanitizePastedHtml('x'); + expect(out).not.toMatch(/javascript:/i); + }); + }); + + describe('robustness', () => { + it('does not throw on empty input', () => { + expect(() => sanitizePastedHtml('')).not.toThrow(); + expect(sanitizePastedHtml('')).toBe(''); + }); + + it('does not throw on malformed HTML', () => { + expect(() => sanitizePastedHtml('

    unterminated')).not.toThrow(); + }); + + it('handles deeply nested tags', () => { + const deep = `${'

    '.repeat(50)}leaf${'
    '.repeat(50)}`; + const out = sanitizePastedHtml(deep); + expect(out).toContain('leaf'); + }); + }); + + describe('paste-source fixtures', () => { + it.each([ + 'word.html', + 'gmail.html', + 'notion.html', + 'vscode.html', + 'apple-mail.html', + 'view-source.html', + 'outlook-web.html', + 'chatgpt.html', + 'github-markdown.html', + 'linear-comment.html', + ])('sanitizes %s deterministically', (name) => { + const html = loadFixture(`paste-sources/${name}`); + const out = sanitizePastedHtml(html); + // No style or class survives external paste. + expect(out).not.toContain('style="'); + expect(out).not.toContain('class="'); + // No data-* attributes survive. + expect(out).not.toMatch(/\sdata-[a-z-]+=/); + // No event handlers (on*). + expect(out).not.toMatch(/\son[a-z]+=/i); + // No javascript: URLs. + expect(out).not.toMatch(/javascript:/i); + // Snapshot locks behavior; update only with a justification. + expect(out).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/editor/src/utils/paste-sanitizer.ts b/packages/editor/src/utils/paste-sanitizer.ts index 6e7ef90127..36bba46c3b 100644 --- a/packages/editor/src/utils/paste-sanitizer.ts +++ b/packages/editor/src/utils/paste-sanitizer.ts @@ -22,6 +22,25 @@ const PRESERVED_ATTRIBUTES: Record = { '*': ['id'], }; +/** + * Attributes whose value carries a URL. Stripped if the value uses an unsafe + * scheme (javascript:, vbscript:, data:, file:). Whitespace and C0 control + * characters are dropped before the prefix check so attackers can't bypass + * with e.g. "\tjavascript:" or embedded NULLs. + */ +const URL_ATTRIBUTES: Record> = { + a: new Set(['href']), + img: new Set(['src']), +}; + +const UNSAFE_URL_SCHEMES = /^(?:javascript|vbscript|data|file):/i; +// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional defense against scheme-bypass via C0 control chars. +const URL_IGNORED_CHARS = /[\s\u0000-\u001f]/g; + +function hasUnsafeUrl(value: string): boolean { + return UNSAFE_URL_SCHEMES.test(value.replace(URL_IGNORED_CHARS, '')); +} + function isFromEditor(html: string): boolean { return EDITOR_CLASS_PATTERN.test(html); } @@ -56,6 +75,7 @@ function sanitizeElement(el: HTMLElement): void { const allowedForTag = PRESERVED_ATTRIBUTES[tagName] || []; const allowedGlobal = PRESERVED_ATTRIBUTES['*'] || []; const allowed = new Set([...allowedForTag, ...allowedGlobal]); + const urlAttrs = URL_ATTRIBUTES[tagName]; const attributesToRemove: string[] = []; @@ -67,6 +87,11 @@ function sanitizeElement(el: HTMLElement): void { if (!allowed.has(attr.name)) { attributesToRemove.push(attr.name); + continue; + } + + if (urlAttrs?.has(attr.name) && hasUnsafeUrl(attr.value)) { + attributesToRemove.push(attr.name); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b398357937..772532ee5f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -621,6 +621,9 @@ importers: '@vitest/browser-playwright': specifier: 4.1.4 version: 4.1.4(playwright@1.59.1)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.37.0)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.4) + fast-check: + specifier: 3.23.2 + version: 3.23.2 playwright: specifier: 1.59.1 version: 1.59.1 @@ -4040,6 +4043,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@use-gesture/core@10.3.1': resolution: {integrity: sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==} @@ -5231,6 +5235,10 @@ packages: engines: {node: '>= 10.17.0'} hasBin: true + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + fast-content-type-parse@3.0.0: resolution: {integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==} @@ -7269,6 +7277,9 @@ packages: deprecated: < 24.15.0 is no longer supported hasBin: true + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + qs@6.15.1: resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} engines: {node: '>=0.6'} @@ -13598,6 +13609,10 @@ snapshots: transitivePeerDependencies: - supports-color + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + fast-content-type-parse@3.0.0: {} fast-deep-equal@3.1.3: {} @@ -16084,6 +16099,8 @@ snapshots: - typescript - utf-8-validate + pure-rand@6.1.0: {} + qs@6.15.1: dependencies: side-channel: 1.1.0