// Copyright (c) 2026 Probo Inc . // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. package prosemirror import ( "encoding/json" "os" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func loadTestDocument(t *testing.T) Node { t.Helper() data, err := os.ReadFile("testdata/document.json") require.NoError(t, err) var doc Node require.NoError(t, json.Unmarshal(data, &doc)) return doc } func TestUnmarshalDocument(t *testing.T) { t.Parallel() doc := loadTestDocument(t) assert.Equal(t, NodeDoc, doc.Type) require.Len(t, doc.Content, 14) t.Run( "heading level 1", func(t *testing.T) { t.Parallel() h1 := doc.Content[0] assert.Equal(t, NodeHeading, h1.Type) attrs, err := h1.HeadingAttrs() require.NoError(t, err) assert.Equal(t, 1, attrs.Level) require.Len(t, h1.Content, 1) assert.Equal(t, NodeText, h1.Content[0].Type) require.NotNil(t, h1.Content[0].Text) assert.Equal(t, "Heading 1", *h1.Content[0].Text) }, ) t.Run( "paragraph with mixed marks", func(t *testing.T) { t.Parallel() p := doc.Content[1] assert.Equal(t, NodeParagraph, p.Type) require.True(t, len(p.Content) > 5) // Bold text boldNode := p.Content[1] require.Len(t, boldNode.Marks, 1) assert.Equal(t, MarkStrong, boldNode.Marks[0].Type) require.NotNil(t, boldNode.Text) assert.Equal(t, "with some bold", *boldNode.Text) // Italic text italicNode := p.Content[3] require.Len(t, italicNode.Marks, 1) assert.Equal(t, MarkEm, italicNode.Marks[0].Type) // Underline text underlineNode := p.Content[5] require.Len(t, underlineNode.Marks, 1) assert.Equal(t, MarkUnderline, underlineNode.Marks[0].Type) // Hard break assert.Equal(t, NodeHardBreak, p.Content[7].Type) // Strikethrough strikeNode := p.Content[9] require.Len(t, strikeNode.Marks, 1) assert.Equal(t, MarkStrike, strikeNode.Marks[0].Type) // Inline code codeNode := p.Content[12] require.Len(t, codeNode.Marks, 1) assert.Equal(t, MarkCode, codeNode.Marks[0].Type) // Link linkNode := p.Content[14] require.Len(t, linkNode.Marks, 1) assert.Equal(t, MarkLink, linkNode.Marks[0].Type) linkAttrs, err := linkNode.Marks[0].LinkAttrs() require.NoError(t, err) assert.Equal(t, "https://probo.com", linkAttrs.Href) require.NotNil(t, linkAttrs.Target) assert.Equal(t, "_blank", *linkAttrs.Target) require.NotNil(t, linkAttrs.Rel) assert.Equal(t, "noopener noreferrer nofollow", *linkAttrs.Rel) assert.Nil(t, linkAttrs.Class) assert.Nil(t, linkAttrs.Title) }, ) t.Run( "heading level 2", func(t *testing.T) { t.Parallel() h2 := doc.Content[2] assert.Equal(t, NodeHeading, h2.Type) attrs, err := h2.HeadingAttrs() require.NoError(t, err) assert.Equal(t, 2, attrs.Level) }, ) t.Run( "code block", func(t *testing.T) { t.Parallel() cb := doc.Content[4] assert.Equal(t, NodeCodeBlock, cb.Type) attrs, err := cb.CodeBlockAttrs() require.NoError(t, err) assert.Nil(t, attrs.Language) require.Len(t, cb.Content, 1) require.NotNil(t, cb.Content[0].Text) assert.Equal(t, "code block", *cb.Content[0].Text) }, ) t.Run( "heading level 3", func(t *testing.T) { t.Parallel() h3 := doc.Content[5] attrs, err := h3.HeadingAttrs() require.NoError(t, err) assert.Equal(t, 3, attrs.Level) }, ) t.Run( "bullet list", func(t *testing.T) { t.Parallel() bl := doc.Content[6] assert.Equal(t, NodeBulletList, bl.Type) require.Len(t, bl.Content, 3) for _, item := range bl.Content { assert.Equal(t, NodeListItem, item.Type) require.Len(t, item.Content, 1) assert.Equal(t, NodeParagraph, item.Content[0].Type) } }, ) t.Run( "horizontal rule", func(t *testing.T) { t.Parallel() assert.Equal(t, NodeHorizontalRule, doc.Content[7].Type) assert.Equal(t, NodeHorizontalRule, doc.Content[9].Type) assert.Equal(t, NodeHorizontalRule, doc.Content[11].Type) }, ) t.Run( "ordered list", func(t *testing.T) { t.Parallel() ol := doc.Content[8] assert.Equal(t, NodeOrderedList, ol.Type) require.Len(t, ol.Content, 3) attrs, err := ol.OrderedListAttrs() require.NoError(t, err) assert.Equal(t, 1, attrs.Start) assert.Nil(t, attrs.Type) }, ) t.Run( "blockquote", func(t *testing.T) { t.Parallel() bq := doc.Content[10] assert.Equal(t, NodeBlockquote, bq.Type) require.Len(t, bq.Content, 1) assert.Equal(t, NodeParagraph, bq.Content[0].Type) // Verify hard break inside blockquote bqPara := bq.Content[0] require.True(t, len(bqPara.Content) >= 3) assert.Equal(t, NodeHardBreak, bqPara.Content[1].Type) }, ) t.Run( "table", func(t *testing.T) { t.Parallel() table := doc.Content[12] assert.Equal(t, NodeTable, table.Type) require.Len(t, table.Content, 3) // Header row headerRow := table.Content[0] assert.Equal(t, NodeTableRow, headerRow.Type) require.Len(t, headerRow.Content, 4) for _, cell := range headerRow.Content { assert.Equal(t, NodeTableHeader, cell.Type) } // Last header has colwidth th4 := headerRow.Content[3] thAttrs, err := th4.TableCellAttrs() require.NoError(t, err) assert.Equal(t, 1, thAttrs.Colspan) assert.Equal(t, 1, thAttrs.Rowspan) assert.Equal(t, []int{61}, thAttrs.Colwidth) // First header has null colwidth th1 := headerRow.Content[0] th1Attrs, err := th1.TableCellAttrs() require.NoError(t, err) assert.Nil(t, th1Attrs.Colwidth) // Data rows for _, row := range table.Content[1:] { assert.Equal(t, NodeTableRow, row.Type) require.Len(t, row.Content, 4) for _, cell := range row.Content { assert.Equal(t, NodeTableCell, cell.Type) } } // Nested bullet list in table cell nestedBL := table.Content[1].Content[3] require.Len(t, nestedBL.Content, 1) assert.Equal(t, NodeBulletList, nestedBL.Content[0].Type) // Nested ordered list in table cell nestedOL := table.Content[2].Content[3] require.Len(t, nestedOL.Content, 1) assert.Equal(t, NodeOrderedList, nestedOL.Content[0].Type) }, ) t.Run( "trailing empty paragraph", func(t *testing.T) { t.Parallel() emptyP := doc.Content[13] assert.Equal(t, NodeParagraph, emptyP.Type) assert.Empty(t, emptyP.Content) }, ) } func TestMarshalRoundtrip(t *testing.T) { t.Parallel() doc := loadTestDocument(t) marshaled, err := json.Marshal(doc) require.NoError(t, err) var roundtripped Node require.NoError(t, json.Unmarshal(marshaled, &roundtripped)) // Re-marshal both to compact JSON for comparison, since the original // testdata file is pretty-printed and RawMessage preserves whitespace. expected, err := json.Marshal(roundtripped) require.NoError(t, err) assert.JSONEq(t, string(marshaled), string(expected)) } func TestHeadingAttrs(t *testing.T) { t.Parallel() raw := `{"type":"heading","attrs":{"level":3},"content":[{"type":"text","text":"Hello"}]}` var n Node require.NoError(t, json.Unmarshal([]byte(raw), &n)) attrs, err := n.HeadingAttrs() require.NoError(t, err) assert.Equal(t, 3, attrs.Level) } func TestCodeBlockAttrs(t *testing.T) { t.Parallel() t.Run( "with language", func(t *testing.T) { t.Parallel() raw := `{"type":"codeBlock","attrs":{"language":"go"},"content":[{"type":"text","text":"fmt.Println()"}]}` var n Node require.NoError(t, json.Unmarshal([]byte(raw), &n)) attrs, err := n.CodeBlockAttrs() require.NoError(t, err) require.NotNil(t, attrs.Language) assert.Equal(t, "go", *attrs.Language) }, ) t.Run( "with null language", func(t *testing.T) { t.Parallel() raw := `{"type":"codeBlock","attrs":{"language":null}}` var n Node require.NoError(t, json.Unmarshal([]byte(raw), &n)) attrs, err := n.CodeBlockAttrs() require.NoError(t, err) assert.Nil(t, attrs.Language) }, ) } func TestOrderedListAttrs(t *testing.T) { t.Parallel() raw := `{"type":"orderedList","attrs":{"start":5,"type":null}}` var n Node require.NoError(t, json.Unmarshal([]byte(raw), &n)) attrs, err := n.OrderedListAttrs() require.NoError(t, err) assert.Equal(t, 5, attrs.Start) assert.Nil(t, attrs.Type) } func TestImageAttrs(t *testing.T) { t.Parallel() raw := `{"type":"image","attrs":{"src":"https://example.com/img.png","alt":"An image","title":null}}` var n Node require.NoError(t, json.Unmarshal([]byte(raw), &n)) attrs, err := n.ImageAttrs() require.NoError(t, err) assert.Equal(t, "https://example.com/img.png", attrs.Src) require.NotNil(t, attrs.Alt) assert.Equal(t, "An image", *attrs.Alt) assert.Nil(t, attrs.Title) } func TestTableCellAttrs(t *testing.T) { t.Parallel() t.Run( "with colwidth", func(t *testing.T) { t.Parallel() raw := `{"type":"tableCell","attrs":{"colspan":2,"rowspan":1,"colwidth":[100,200]}}` var n Node require.NoError(t, json.Unmarshal([]byte(raw), &n)) attrs, err := n.TableCellAttrs() require.NoError(t, err) assert.Equal(t, 2, attrs.Colspan) assert.Equal(t, 1, attrs.Rowspan) assert.Equal(t, []int{100, 200}, attrs.Colwidth) }, ) t.Run( "with null colwidth", func(t *testing.T) { t.Parallel() raw := `{"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null}}` var n Node require.NoError(t, json.Unmarshal([]byte(raw), &n)) attrs, err := n.TableCellAttrs() require.NoError(t, err) assert.Equal(t, 1, attrs.Colspan) assert.Equal(t, 1, attrs.Rowspan) assert.Nil(t, attrs.Colwidth) }, ) } func TestLinkAttrs(t *testing.T) { t.Parallel() raw := `{"type":"link","attrs":{"href":"https://example.com","target":"_blank","rel":"noopener","class":null,"title":"Example"}}` var m Mark require.NoError(t, json.Unmarshal([]byte(raw), &m)) attrs, err := m.LinkAttrs() require.NoError(t, err) assert.Equal(t, "https://example.com", attrs.Href) require.NotNil(t, attrs.Target) assert.Equal(t, "_blank", *attrs.Target) require.NotNil(t, attrs.Rel) assert.Equal(t, "noopener", *attrs.Rel) assert.Nil(t, attrs.Class) require.NotNil(t, attrs.Title) assert.Equal(t, "Example", *attrs.Title) } func TestTextLength(t *testing.T) { t.Parallel() t.Run( "empty doc", func(t *testing.T) { t.Parallel() n := Node{Type: NodeDoc} assert.Equal(t, 0, n.TextLength()) }, ) t.Run( "single text node", func(t *testing.T) { t.Parallel() text := "hello" n := Node{Type: NodeText, Text: &text} assert.Equal(t, 5, n.TextLength()) }, ) t.Run( "paragraph with text", func(t *testing.T) { t.Parallel() raw := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello world"}]}]}` doc, err := Parse(raw) require.NoError(t, err) assert.Equal(t, 11, doc.TextLength()) }, ) t.Run( "multiple paragraphs", func(t *testing.T) { t.Parallel() raw := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"aaa"}]},{"type":"paragraph","content":[{"type":"text","text":"bb"}]}]}` doc, err := Parse(raw) require.NoError(t, err) assert.Equal(t, 5, doc.TextLength()) }, ) t.Run( "formatted text counts only text", func(t *testing.T) { t.Parallel() raw := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"plain "},{"type":"text","marks":[{"type":"bold"}],"text":"bold"}]}]}` doc, err := Parse(raw) require.NoError(t, err) assert.Equal(t, 10, doc.TextLength()) }, ) t.Run( "nested list structure", func(t *testing.T) { t.Parallel() raw := `{"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item 1"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item 2"}]}]}]}]}` doc, err := Parse(raw) require.NoError(t, err) assert.Equal(t, 12, doc.TextLength()) }, ) t.Run( "multi-byte unicode characters", func(t *testing.T) { t.Parallel() raw := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"café résumé"}]}]}` doc, err := Parse(raw) require.NoError(t, err) assert.Equal(t, 11, doc.TextLength()) }, ) t.Run( "testdata document", func(t *testing.T) { t.Parallel() doc := loadTestDocument(t) assert.Greater(t, doc.TextLength(), 0) }, ) } func TestNodeWithNoAttrs(t *testing.T) { t.Parallel() raw := `{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}` var n Node require.NoError(t, json.Unmarshal([]byte(raw), &n)) assert.Equal(t, NodeParagraph, n.Type) assert.Nil(t, n.Attrs) require.Len(t, n.Content, 1) require.NotNil(t, n.Content[0].Text) assert.Equal(t, "Hello", *n.Content[0].Text) }