@createiq/htmldiff 1.0.5-beta.4 → 1.1.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/test/HtmlDiff.tables.spec.ts +206 -0
package/package.json
CHANGED
|
@@ -1147,6 +1147,66 @@ describe('HtmlDiff — tables', () => {
|
|
|
1147
1147
|
// containing comments, CDATA, mixed-case tags, foreign attribute values
|
|
1148
1148
|
// that look like class= patterns, etc.
|
|
1149
1149
|
describe('hostile / adversarial inputs', () => {
|
|
1150
|
+
it('handles a processing instruction (<?xml?>) in cell content', () => {
|
|
1151
|
+
// parseOpeningTagAt has explicit handling for `<?...?>`. Pin
|
|
1152
|
+
// that path so a future refactor can't break it.
|
|
1153
|
+
const oldHtml = '<table><tr><td>A</td></tr></table>'
|
|
1154
|
+
const newHtml = '<table><tr><td>A</td></tr><tr><td><?xml version="1.0"?>text</td></tr></table>'
|
|
1155
|
+
|
|
1156
|
+
expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
|
|
1157
|
+
'<table>' +
|
|
1158
|
+
'<tr><td>A</td></tr>' +
|
|
1159
|
+
"<tr class='diffins'><td class='diffins'>" +
|
|
1160
|
+
'<?xml version="1.0"?>' +
|
|
1161
|
+
"<ins class='diffins'>text</ins>" +
|
|
1162
|
+
'</td></tr>' +
|
|
1163
|
+
'</table>'
|
|
1164
|
+
)
|
|
1165
|
+
})
|
|
1166
|
+
|
|
1167
|
+
it('handles an unquoted class attribute value when injecting diffins', () => {
|
|
1168
|
+
// findClassAttribute's unquoted-value branch wasn't exercised —
|
|
1169
|
+
// most generators emit quoted values. HTML5 permits unquoted
|
|
1170
|
+
// simple values, so support them.
|
|
1171
|
+
const oldHtml = '<table><tr><td>A</td></tr></table>'
|
|
1172
|
+
const newHtml = '<table><tr><td>A</td></tr><tr><td class=existing>B</td></tr></table>'
|
|
1173
|
+
|
|
1174
|
+
const result = HtmlDiff.execute(oldHtml, newHtml)
|
|
1175
|
+
// The existing unquoted class is preserved; the injected class
|
|
1176
|
+
// appends. Exact form depends on injectClass's writeback (it
|
|
1177
|
+
// rewrites the attribute value at its parsed range).
|
|
1178
|
+
expect(result).toContain('class=existing')
|
|
1179
|
+
expect(result).toContain('diffins')
|
|
1180
|
+
})
|
|
1181
|
+
|
|
1182
|
+
it('passes content through verbatim when a cell contains a lone `<` (malformed)', () => {
|
|
1183
|
+
// wrapInlineTextRuns sees `<` and calls parseOpeningTagAt, which
|
|
1184
|
+
// returns null for a lone `<` with no closing `>`. The function
|
|
1185
|
+
// then bails by pushing the rest verbatim. The output isn't
|
|
1186
|
+
// pretty but it's predictable and doesn't crash.
|
|
1187
|
+
const oldHtml = '<table><tr><td>A</td></tr></table>'
|
|
1188
|
+
const newHtml = '<table><tr><td>A</td></tr><tr><td>fee < cost</td></tr></table>'
|
|
1189
|
+
|
|
1190
|
+
const result = HtmlDiff.execute(oldHtml, newHtml)
|
|
1191
|
+
// The inserted row should still be marked.
|
|
1192
|
+
expect(result).toContain("<tr class='diffins'>")
|
|
1193
|
+
// The literal `<` (with no closing >) should appear in the output.
|
|
1194
|
+
expect(result).toContain('fee')
|
|
1195
|
+
expect(result).toContain('cost')
|
|
1196
|
+
})
|
|
1197
|
+
|
|
1198
|
+
it('handles a malformed table tag missing its closing > (no crash, falls back)', () => {
|
|
1199
|
+
// findTopLevelTables → parseOpeningTagAt returns null → scanner
|
|
1200
|
+
// increments i and continues. preprocessTables ends up with no
|
|
1201
|
+
// valid tables and falls through to the word-level diff.
|
|
1202
|
+
const oldHtml = '<p>before</p><table<tr><td>A</td></tr></table><p>after</p>'
|
|
1203
|
+
const newHtml = '<p>before</p><table<tr><td>B</td></tr></table><p>after</p>'
|
|
1204
|
+
|
|
1205
|
+
const result = HtmlDiff.execute(oldHtml, newHtml)
|
|
1206
|
+
// Should not crash. Should produce *some* del/ins markers.
|
|
1207
|
+
expect(result.length).toBeGreaterThan(0)
|
|
1208
|
+
})
|
|
1209
|
+
|
|
1150
1210
|
it('passes an HTML comment with embedded > through cell content unmolested', () => {
|
|
1151
1211
|
// Word-exported HTML routinely has comments with `>` inside (e.g.
|
|
1152
1212
|
// conditional comments). Before the parser fix, the scanner cut
|
|
@@ -1513,6 +1573,152 @@ describe('HtmlDiff — tables', () => {
|
|
|
1513
1573
|
})
|
|
1514
1574
|
})
|
|
1515
1575
|
|
|
1576
|
+
// Coverage gaps surfaced by the v8 report: the cell-LCS fallback path
|
|
1577
|
+
// (diffStructurallyAlignedRow + cellKey + pairSimilarUnmatchedCells) is
|
|
1578
|
+
// only entered when the per-row column delta exceeds MAX_COLUMN_DELTA
|
|
1579
|
+
// (6) or the row's logical width exceeds MAX_COLUMN_SEARCH_WIDTH (40).
|
|
1580
|
+
// None of the existing tests trigger that. These tests exercise the
|
|
1581
|
+
// fallback and pin its behaviour.
|
|
1582
|
+
describe('cell-LCS fallback for very-wide column changes', () => {
|
|
1583
|
+
it('handles 8 columns inserted alongside existing cells (delta > MAX_COLUMN_DELTA)', () => {
|
|
1584
|
+
// Old: 3 cells. New: 11 cells (8 columns added). Exact-LCS finds
|
|
1585
|
+
// A, B, C as matches; the 8 unmatched new cells are inserted.
|
|
1586
|
+
const oldHtml = '<table><tr><td>A</td><td>B</td><td>C</td></tr></table>'
|
|
1587
|
+
const newHtml =
|
|
1588
|
+
'<table><tr><td>A</td>' +
|
|
1589
|
+
'<td>X1</td><td>X2</td><td>X3</td><td>X4</td>' +
|
|
1590
|
+
'<td>X5</td><td>X6</td><td>X7</td><td>X8</td>' +
|
|
1591
|
+
'<td>B</td><td>C</td></tr></table>'
|
|
1592
|
+
|
|
1593
|
+
expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
|
|
1594
|
+
'<table><tr>' +
|
|
1595
|
+
'<td>A</td>' +
|
|
1596
|
+
"<td class='diffins'><ins class='diffins'>X1</ins></td>" +
|
|
1597
|
+
"<td class='diffins'><ins class='diffins'>X2</ins></td>" +
|
|
1598
|
+
"<td class='diffins'><ins class='diffins'>X3</ins></td>" +
|
|
1599
|
+
"<td class='diffins'><ins class='diffins'>X4</ins></td>" +
|
|
1600
|
+
"<td class='diffins'><ins class='diffins'>X5</ins></td>" +
|
|
1601
|
+
"<td class='diffins'><ins class='diffins'>X6</ins></td>" +
|
|
1602
|
+
"<td class='diffins'><ins class='diffins'>X7</ins></td>" +
|
|
1603
|
+
"<td class='diffins'><ins class='diffins'>X8</ins></td>" +
|
|
1604
|
+
'<td>B</td>' +
|
|
1605
|
+
'<td>C</td>' +
|
|
1606
|
+
'</tr></table>'
|
|
1607
|
+
)
|
|
1608
|
+
})
|
|
1609
|
+
|
|
1610
|
+
it('handles 8 columns inserted alongside a content edit (cell fuzzy match in fallback)', () => {
|
|
1611
|
+
// The fallback path's pairSimilarUnmatchedCells should pair the
|
|
1612
|
+
// edited cell (OldText → NewText) by content similarity so it
|
|
1613
|
+
// emits as one content-edit cell, not as delete + insert.
|
|
1614
|
+
const oldHtml = '<table><tr><td>A</td><td>OldText</td><td>C</td></tr></table>'
|
|
1615
|
+
const newHtml =
|
|
1616
|
+
'<table><tr><td>A</td>' +
|
|
1617
|
+
'<td>X1</td><td>X2</td><td>X3</td><td>X4</td>' +
|
|
1618
|
+
'<td>X5</td><td>X6</td><td>X7</td>' +
|
|
1619
|
+
'<td>NewText</td>' +
|
|
1620
|
+
'<td>C</td></tr></table>'
|
|
1621
|
+
|
|
1622
|
+
expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
|
|
1623
|
+
'<table><tr>' +
|
|
1624
|
+
'<td>A</td>' +
|
|
1625
|
+
"<td class='diffins'><ins class='diffins'>X1</ins></td>" +
|
|
1626
|
+
"<td class='diffins'><ins class='diffins'>X2</ins></td>" +
|
|
1627
|
+
"<td class='diffins'><ins class='diffins'>X3</ins></td>" +
|
|
1628
|
+
"<td class='diffins'><ins class='diffins'>X4</ins></td>" +
|
|
1629
|
+
"<td class='diffins'><ins class='diffins'>X5</ins></td>" +
|
|
1630
|
+
"<td class='diffins'><ins class='diffins'>X6</ins></td>" +
|
|
1631
|
+
"<td class='diffins'><ins class='diffins'>X7</ins></td>" +
|
|
1632
|
+
"<td><del class='diffmod'>OldText</del><ins class='diffmod'>NewText</ins></td>" +
|
|
1633
|
+
'<td>C</td>' +
|
|
1634
|
+
'</tr></table>'
|
|
1635
|
+
)
|
|
1636
|
+
})
|
|
1637
|
+
|
|
1638
|
+
it('handles many columns deleted (delta < -MAX_COLUMN_DELTA)', () => {
|
|
1639
|
+
const oldHtml =
|
|
1640
|
+
'<table><tr><td>A</td>' +
|
|
1641
|
+
'<td>X1</td><td>X2</td><td>X3</td><td>X4</td>' +
|
|
1642
|
+
'<td>X5</td><td>X6</td><td>X7</td><td>X8</td>' +
|
|
1643
|
+
'<td>B</td><td>C</td></tr></table>'
|
|
1644
|
+
const newHtml = '<table><tr><td>A</td><td>B</td><td>C</td></tr></table>'
|
|
1645
|
+
|
|
1646
|
+
expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
|
|
1647
|
+
'<table><tr>' +
|
|
1648
|
+
'<td>A</td>' +
|
|
1649
|
+
"<td class='diffdel'><del class='diffdel'>X1</del></td>" +
|
|
1650
|
+
"<td class='diffdel'><del class='diffdel'>X2</del></td>" +
|
|
1651
|
+
"<td class='diffdel'><del class='diffdel'>X3</del></td>" +
|
|
1652
|
+
"<td class='diffdel'><del class='diffdel'>X4</del></td>" +
|
|
1653
|
+
"<td class='diffdel'><del class='diffdel'>X5</del></td>" +
|
|
1654
|
+
"<td class='diffdel'><del class='diffdel'>X6</del></td>" +
|
|
1655
|
+
"<td class='diffdel'><del class='diffdel'>X7</del></td>" +
|
|
1656
|
+
"<td class='diffdel'><del class='diffdel'>X8</del></td>" +
|
|
1657
|
+
'<td>B</td>' +
|
|
1658
|
+
'<td>C</td>' +
|
|
1659
|
+
'</tr></table>'
|
|
1660
|
+
)
|
|
1661
|
+
})
|
|
1662
|
+
|
|
1663
|
+
it('preserves whitespace between inline elements in a fully-inserted cell (no spurious <ins>)', () => {
|
|
1664
|
+
// wrapInlineTextRuns walks content; when it encounters
|
|
1665
|
+
// whitespace-only text between two inline elements (e.g. the
|
|
1666
|
+
// space between `<strong>` and `<em>`), it passes the whitespace
|
|
1667
|
+
// through unwrapped — the body of the `else` branch on the
|
|
1668
|
+
// text-run path.
|
|
1669
|
+
const oldHtml = '<table><tr><td>A</td></tr></table>'
|
|
1670
|
+
const newHtml = '<table><tr><td>A</td></tr><tr><td><strong>a</strong> <em>b</em></td></tr></table>'
|
|
1671
|
+
|
|
1672
|
+
expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
|
|
1673
|
+
'<table>' +
|
|
1674
|
+
'<tr><td>A</td></tr>' +
|
|
1675
|
+
"<tr class='diffins'><td class='diffins'>" +
|
|
1676
|
+
"<strong><ins class='diffins'>a</ins></strong> <em><ins class='diffins'>b</ins></em>" +
|
|
1677
|
+
'</td></tr>' +
|
|
1678
|
+
'</table>'
|
|
1679
|
+
)
|
|
1680
|
+
})
|
|
1681
|
+
|
|
1682
|
+
it('handles a colspan-changed row where some cells have matching colspans', () => {
|
|
1683
|
+
// diffColspanChangedRow walks cells; when oSpan === nSpan for a
|
|
1684
|
+
// pair, it emits a content diff for that cell pair. This branch
|
|
1685
|
+
// wasn't exercised — needs a row with BOTH a colspan change AND
|
|
1686
|
+
// matching-colspan cells in the same row.
|
|
1687
|
+
const oldHtml =
|
|
1688
|
+
'<table><tr>' + '<td>FirstA</td>' + '<td>MidA</td><td>MidB</td>' + '<td>LastA</td>' + '</tr></table>'
|
|
1689
|
+
const newHtml =
|
|
1690
|
+
'<table><tr>' + '<td>FirstB</td>' + '<td colspan="2">Merged AB</td>' + '<td>LastB</td>' + '</tr></table>'
|
|
1691
|
+
|
|
1692
|
+
const result = HtmlDiff.execute(oldHtml, newHtml)
|
|
1693
|
+
// First and last cells should diff content cell-by-cell (matching
|
|
1694
|
+
// colspans = 1 on both sides); middle two old cells merge into
|
|
1695
|
+
// one colspan=2 cell tagged 'mod colspan'.
|
|
1696
|
+
expect(result).toContain("<del class='diffmod'>FirstA</del>")
|
|
1697
|
+
expect(result).toContain("<ins class='diffmod'>FirstB</ins>")
|
|
1698
|
+
expect(result).toContain('colspan="2" class=\'mod colspan\'')
|
|
1699
|
+
expect(result).toContain("<del class='diffmod'>LastA</del>")
|
|
1700
|
+
expect(result).toContain("<ins class='diffmod'>LastB</ins>")
|
|
1701
|
+
})
|
|
1702
|
+
|
|
1703
|
+
it('handles row wider than MAX_COLUMN_SEARCH_WIDTH (40 cells) — fallback to cell-LCS', () => {
|
|
1704
|
+
// 50-cell row in old, 51-cell row in new (1 column added at
|
|
1705
|
+
// start). MAX_COLUMN_SEARCH_WIDTH guard prevents the
|
|
1706
|
+
// combinatorial search; fallback to cell-LCS which finds 50
|
|
1707
|
+
// exact matches and the 1 new cell as an insertion.
|
|
1708
|
+
const oldCells = Array.from({ length: 50 }, (_, i) => `<td>c${i}</td>`).join('')
|
|
1709
|
+
const newCells = `<td>NEW</td>${oldCells}`
|
|
1710
|
+
const oldHtml = `<table><tr>${oldCells}</tr></table>`
|
|
1711
|
+
const newHtml = `<table><tr>${newCells}</tr></table>`
|
|
1712
|
+
|
|
1713
|
+
const result = HtmlDiff.execute(oldHtml, newHtml)
|
|
1714
|
+
// We should see exactly one inserted cell and 50 preserved cells.
|
|
1715
|
+
expect(result).toContain("<td class='diffins'><ins class='diffins'>NEW</ins></td>")
|
|
1716
|
+
// Sanity: total td count is 51 (no phantoms).
|
|
1717
|
+
const tdCount = (result.match(/<td[\s>]/g) || []).length
|
|
1718
|
+
expect(tdCount).toBe(51)
|
|
1719
|
+
})
|
|
1720
|
+
})
|
|
1721
|
+
|
|
1516
1722
|
describe('attribute edge cases', () => {
|
|
1517
1723
|
it('does not introduce a leading space when the existing class attribute is empty', () => {
|
|
1518
1724
|
const oldHtml = '<table><tr><td>A</td></tr></table>'
|