Precise DOM morphing
morphing typescript dom
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Improve test coverage with AI

+553 -3
+3 -3
src/morphlex.ts
··· 538 538 if ( 539 539 isElement(candidate) && 540 540 element.localName === candidate.localName && 541 - ((name !== "" && name === candidate.getAttribute("name")) || 542 - (href !== "" && href === candidate.getAttribute("href")) || 543 - (src !== "" && src === candidate.getAttribute("src"))) 541 + ((name && name === candidate.getAttribute("name")) || 542 + (href && href === candidate.getAttribute("href")) || 543 + (src && src === candidate.getAttribute("src"))) 544 544 ) { 545 545 matches[i] = candidate 546 546 candidateElements.delete(candidate)
+42
test/ai-gen-coverage/attribute-removal.browser.test.ts
··· 1 + import { test, expect, describe } from "vitest" 2 + import { morph } from "../../src/morphlex" 3 + import { dom } from "../new/utils" 4 + 5 + describe("attribute removal edge cases", () => { 6 + test("removing selected attribute from option with multiple options selected", () => { 7 + const a = dom(`<select multiple><option value="a" selected>A</option><option value="b" selected>B</option></select>`) as HTMLSelectElement 8 + const b = dom(`<select multiple><option value="a">A</option><option value="b" selected>B</option></select>`) as HTMLSelectElement 9 + 10 + morph(a, b, { preserveChanges: true }) 11 + 12 + // Line 417-418: when selected === defaultSelected, we set selected = false 13 + expect(a.options[0].hasAttribute("selected")).toBe(false) 14 + expect(a.options[0].selected).toBe(false) 15 + }) 16 + 17 + test("removing selected attribute preserves user selection when it differs from default", () => { 18 + const a = dom(`<select><option value="a">A</option><option value="b" selected>B</option></select>`) as HTMLSelectElement 19 + const b = dom(`<select><option value="a">A</option><option value="b">B</option></select>`) as HTMLSelectElement 20 + 21 + // User changes selection - now selected !== defaultSelected 22 + a.options[0].selected = true 23 + 24 + morph(a, b, { preserveChanges: true }) 25 + 26 + // Should preserve the user's selection 27 + expect(a.options[1].hasAttribute("selected")).toBe(false) 28 + expect(a.options[0].selected).toBe(true) 29 + }) 30 + 31 + test("removing checked attribute from checkbox when preserveChanges disabled", () => { 32 + const a = dom(`<input type="checkbox" checked>`) as HTMLInputElement 33 + const b = dom(`<input type="checkbox">`) as HTMLInputElement 34 + 35 + a.checked = false 36 + 37 + morph(a, b, { preserveChanges: false }) 38 + 39 + expect(a.hasAttribute("checked")).toBe(false) 40 + expect(a.checked).toBe(false) 41 + }) 42 + })
+79
test/ai-gen-coverage/input-localname-matching.browser.test.ts
··· 1 + import { test, expect } from "vitest" 2 + import { morph } from "../../src/morphlex" 3 + import { dom } from "../new/utils" 4 + 5 + test("morphing inputs by localName with same types matches correctly", () => { 6 + // This test ensures lines 566-568 are covered (the non-continue path) 7 + // Inputs without id or name attributes fall through to localName matching 8 + const a = dom(`<form><input type="email" class="first"><input type="email" class="second"></form>`) as HTMLElement 9 + const b = dom(`<form><input type="email" placeholder="a"><input type="email" placeholder="b"></form>`) as HTMLElement 10 + 11 + const first = a.children[0] as HTMLInputElement 12 + const second = a.children[1] as HTMLInputElement 13 + 14 + morph(a, b) 15 + 16 + // Same type inputs should be reused via localName matching 17 + expect(a.children[0]).toBe(first) 18 + expect(a.children[1]).toBe(second) 19 + expect((a.children[0] as HTMLInputElement).placeholder).toBe("a") 20 + expect((a.children[1] as HTMLInputElement).placeholder).toBe("b") 21 + }) 22 + 23 + test("morphing inputs with different types by localName skips mismatched types", () => { 24 + // This test specifically targets lines 562-564 in morphlex.ts (the continue path) 25 + // We need inputs without IDs or name attributes, so they fall through to localName matching 26 + const a = dom( 27 + `<div><input type="text" class="a"><input type="checkbox" class="b"><input type="text" class="c"></div>`, 28 + ) as HTMLElement 29 + const b = dom( 30 + `<div><input type="checkbox" class="x"><input type="text" class="y"><input type="text" class="z"></div>`, 31 + ) as HTMLElement 32 + 33 + morph(a, b) 34 + 35 + // The first input (text) can't match the first target (checkbox) due to type mismatch (line 563-564) 36 + // So different elements should be created/replaced 37 + const inputs = Array.from(a.children) as HTMLInputElement[] 38 + 39 + expect(inputs[0].type).toBe("checkbox") 40 + expect(inputs[0].className).toBe("x") 41 + expect(inputs[1].type).toBe("text") 42 + expect(inputs[1].className).toBe("y") 43 + expect(inputs[2].type).toBe("text") 44 + expect(inputs[2].className).toBe("z") 45 + }) 46 + 47 + test("morphing option with selected attribute removed when matches default", () => { 48 + // This test targets line 418 in morphlex.ts 49 + const a = dom( 50 + `<select multiple><option value="a" selected>A</option><option value="b" selected>B</option><option value="c">C</option></select>`, 51 + ) as HTMLSelectElement 52 + const b = dom( 53 + `<select multiple><option value="a">A</option><option value="b" selected>B</option><option value="c">C</option></select>`, 54 + ) as HTMLSelectElement 55 + 56 + // First option has selected attribute, so selected === defaultSelected (both true) 57 + const firstOption = a.options[0] 58 + expect(firstOption.selected).toBe(true) 59 + 60 + morph(a, b, { preserveChanges: true }) 61 + 62 + // Line 418: since selected === defaultSelected, we set selected = false 63 + expect(a.options[0].selected).toBe(false) 64 + expect(a.options[0].hasAttribute("selected")).toBe(false) 65 + }) 66 + 67 + test("morphing option with selected attribute removed with preserveChanges false", () => { 68 + // This test targets line 418 with preserveChanges: false branch 69 + const a = dom(`<select><option value="a" selected>A</option><option value="b">B</option></select>`) as HTMLSelectElement 70 + const b = dom(`<select><option value="a">A</option><option value="b">B</option></select>`) as HTMLSelectElement 71 + 72 + // First option has selected attribute 73 + expect(a.options[0].selected).toBe(true) 74 + 75 + morph(a, b, { preserveChanges: false }) 76 + 77 + // Line 418: with preserveChanges false, we set selected = false 78 + expect(a.options[0].hasAttribute("selected")).toBe(false) 79 + })
+152
test/ai-gen-coverage/input-type-continue.browser.test.ts
··· 1 + import { test, expect } from "vitest" 2 + import { morph } from "../../src/morphlex" 3 + import { dom } from "../new/utils" 4 + 5 + test("input type mismatch triggers continue - then finds matching type", () => { 6 + // This test ensures we hit the continue statement on line 564 7 + // by having two input candidates where: 8 + // 1. First candidate has wrong type (continue is executed) 9 + // 2. Second candidate has correct type (match succeeds) 10 + const a = dom( 11 + `<div> 12 + <input type="text" data-marker="1"> 13 + <input type="checkbox" data-marker="2"> 14 + </div>`, 15 + ) as HTMLElement 16 + 17 + const b = dom( 18 + `<div> 19 + <input type="checkbox" data-marker="new"> 20 + </div>`, 21 + ) as HTMLElement 22 + 23 + morph(a, b) 24 + 25 + // The checkbox should be reused, text input removed 26 + expect(a.children.length).toBe(1) 27 + expect((a.children[0] as HTMLInputElement).type).toBe("checkbox") 28 + expect((a.children[0] as HTMLInputElement).getAttribute("data-marker")).toBe("new") 29 + }) 30 + 31 + test("input type mismatch with multiple wrong types before match", () => { 32 + // Multiple candidates with wrong types, continue is executed multiple times 33 + const a = dom( 34 + `<div> 35 + <input type="text"> 36 + <input type="radio"> 37 + <input type="number"> 38 + <input type="email" data-id="target"> 39 + </div>`, 40 + ) as HTMLElement 41 + 42 + const b = dom( 43 + `<div> 44 + <input type="email" data-id="new"> 45 + </div>`, 46 + ) as HTMLElement 47 + 48 + morph(a, b) 49 + 50 + // Text, radio, and number inputs trigger continue, email matches 51 + expect(a.children.length).toBe(1) 52 + expect((a.children[0] as HTMLInputElement).type).toBe("email") 53 + expect((a.children[0] as HTMLInputElement).getAttribute("data-id")).toBe("new") 54 + }) 55 + 56 + test("input type mismatch with no matching type - all trigger continue", () => { 57 + // All candidates have wrong type, continue is executed for all, no match found 58 + const a = dom( 59 + `<div> 60 + <input type="text"> 61 + <input type="checkbox"> 62 + <input type="radio"> 63 + </div>`, 64 + ) as HTMLElement 65 + 66 + const b = dom( 67 + `<div> 68 + <input type="email"> 69 + </div>`, 70 + ) as HTMLElement 71 + 72 + morph(a, b) 73 + 74 + // No type matches, so new element is created, old ones removed 75 + expect(a.children.length).toBe(1) 76 + expect((a.children[0] as HTMLInputElement).type).toBe("email") 77 + }) 78 + 79 + test("input with matching type does not trigger continue", () => { 80 + // When types match, the continue branch is NOT taken 81 + const a = dom( 82 + `<div> 83 + <input type="text" data-value="old"> 84 + <input type="text" data-value="old2"> 85 + </div>`, 86 + ) as HTMLElement 87 + 88 + const b = dom( 89 + `<div> 90 + <input type="text" data-value="new"> 91 + </div>`, 92 + ) as HTMLElement 93 + 94 + const firstInput = a.children[0] 95 + 96 + morph(a, b) 97 + 98 + // First text input matches without triggering continue 99 + expect(a.children.length).toBe(1) 100 + expect(a.children[0]).toBe(firstInput) 101 + expect((a.children[0] as HTMLInputElement).getAttribute("data-value")).toBe("new") 102 + }) 103 + 104 + test("non-input elements skip the type check entirely", () => { 105 + // isInputElement checks prevent non-inputs from entering the type check 106 + const a = dom( 107 + `<div> 108 + <button data-test="1">A</button> 109 + <button data-test="2">B</button> 110 + </div>`, 111 + ) as HTMLElement 112 + 113 + const b = dom( 114 + `<div> 115 + <button data-test="new">C</button> 116 + </div>`, 117 + ) as HTMLElement 118 + 119 + const firstButton = a.children[0] 120 + 121 + morph(a, b) 122 + 123 + // Buttons match by localName without any type checking 124 + expect(a.children.length).toBe(1) 125 + expect(a.children[0]).toBe(firstButton) 126 + expect(a.children[0].getAttribute("data-test")).toBe("new") 127 + }) 128 + 129 + test("mixed inputs and non-inputs in localName matching", () => { 130 + // Ensure the logic handles both inputs and non-inputs correctly 131 + const a = dom( 132 + `<div> 133 + <input type="text"> 134 + <button>Button</button> 135 + <input type="email" class="target"> 136 + </div>`, 137 + ) as HTMLElement 138 + 139 + const b = dom( 140 + `<div> 141 + <input type="email" class="new"> 142 + <button>New Button</button> 143 + </div>`, 144 + ) as HTMLElement 145 + 146 + morph(a, b) 147 + 148 + // Email input and button should both be matched 149 + expect(a.children.length).toBe(2) 150 + expect((a.children[0] as HTMLInputElement).type).toBe("email") 151 + expect(a.children[1].tagName).toBe("BUTTON") 152 + })
+105
test/ai-gen-coverage/input-type-mismatch.browser.test.ts
··· 1 + import { test, expect, describe } from "vitest" 2 + import { morph } from "../../src/morphlex" 3 + import { dom } from "../new/utils" 4 + 5 + describe("input type mismatch", () => { 6 + test("morphing inputs with different types treats them as different elements", () => { 7 + const a = dom(`<div><input type="text" id="a"><input type="checkbox" id="b"></div>`) as HTMLElement 8 + const b = dom(`<div><input type="checkbox" id="a"><input type="text" id="b"></div>`) as HTMLElement 9 + 10 + morph(a, b) 11 + 12 + const firstInput = a.querySelector("#a") as HTMLInputElement 13 + const secondInput = a.querySelector("#b") as HTMLInputElement 14 + 15 + expect(firstInput.type).toBe("checkbox") 16 + expect(secondInput.type).toBe("text") 17 + }) 18 + 19 + test("morphing inputs with same type but different order", () => { 20 + const a = dom(`<div><input type="text" id="a" value="first"><input type="text" id="b" value="second"></div>`) as HTMLElement 21 + const b = dom(`<div><input type="text" id="b" value="second"><input type="text" id="a" value="first"></div>`) as HTMLElement 22 + 23 + morph(a, b) 24 + 25 + const firstInput = a.firstElementChild as HTMLInputElement 26 + const secondInput = a.lastElementChild as HTMLInputElement 27 + 28 + expect(firstInput.id).toBe("b") 29 + expect(firstInput.value).toBe("second") 30 + expect(secondInput.id).toBe("a") 31 + expect(secondInput.value).toBe("first") 32 + }) 33 + 34 + test("morphing text input to number input creates new element", () => { 35 + const a = dom(`<div><input type="text" value="hello"></div>`) as HTMLElement 36 + const b = dom(`<div><input type="number" value="123"></div>`) as HTMLElement 37 + 38 + const originalInput = a.firstElementChild 39 + morph(a, b) 40 + const newInput = a.firstElementChild as HTMLInputElement 41 + 42 + // Different types should result in element replacement 43 + expect(newInput.type).toBe("number") 44 + expect(newInput.value).toBe("123") 45 + }) 46 + 47 + test("morphing checkbox to radio creates new element", () => { 48 + const a = dom(`<div><input type="checkbox" checked></div>`) as HTMLElement 49 + const b = dom(`<div><input type="radio" name="test"></div>`) as HTMLElement 50 + 51 + morph(a, b) 52 + const newInput = a.firstElementChild as HTMLInputElement 53 + 54 + expect(newInput.type).toBe("radio") 55 + expect(newInput.name).toBe("test") 56 + }) 57 + 58 + test("morphing inputs with same type uses localName matching", () => { 59 + const a = dom(`<div><input type="text" value="a"><input type="text" value="b"></div>`) as HTMLElement 60 + const b = dom(`<div><input type="text" value="x"><input type="text" value="y"></div>`) as HTMLElement 61 + 62 + const firstInput = a.children[0] as HTMLInputElement 63 + const secondInput = a.children[1] as HTMLInputElement 64 + 65 + morph(a, b) 66 + 67 + // Lines 566-568: inputs match by localName and type, so they're reused 68 + expect(a.children[0]).toBe(firstInput) 69 + expect(a.children[1]).toBe(secondInput) 70 + expect((a.children[0] as HTMLInputElement).value).toBe("x") 71 + expect((a.children[1] as HTMLInputElement).value).toBe("y") 72 + }) 73 + 74 + test("morphing mixed inputs where some types match and some don't", () => { 75 + const a = dom(`<div><input type="text" id="1"><input type="checkbox" id="2"><input type="text" id="3"></div>`) as HTMLElement 76 + const b = dom(`<div><input type="text" id="a"><input type="radio" id="b"><input type="text" id="c"></div>`) as HTMLElement 77 + 78 + morph(a, b) 79 + 80 + // First and third should reuse text inputs, middle should be replaced 81 + const inputs = Array.from(a.children) as HTMLInputElement[] 82 + expect(inputs[0].type).toBe("text") 83 + expect(inputs[0].id).toBe("a") 84 + expect(inputs[1].type).toBe("radio") 85 + expect(inputs[1].id).toBe("b") 86 + expect(inputs[2].type).toBe("text") 87 + expect(inputs[2].id).toBe("c") 88 + }) 89 + 90 + test("morphing inputs without IDs triggers localName matching with type check", () => { 91 + const a = dom(`<div><input type="text" class="a"><input type="number" class="b"></div>`) as HTMLElement 92 + const b = dom(`<div><input type="text" class="x"><input type="number" class="y"></div>`) as HTMLElement 93 + 94 + const firstInput = a.children[0] as HTMLInputElement 95 + const secondInput = a.children[1] as HTMLInputElement 96 + 97 + morph(a, b) 98 + 99 + // Lines 566-568: same-type inputs are matched and reused via localName 100 + expect(a.children[0]).toBe(firstInput) 101 + expect(a.children[1]).toBe(secondInput) 102 + expect((a.children[0] as HTMLInputElement).className).toBe("x") 103 + expect((a.children[1] as HTMLInputElement).className).toBe("y") 104 + }) 105 + })
+133
test/ai-gen-coverage/localname-matching.browser.test.ts
··· 1 + import { test, expect } from "vitest" 2 + import { morph } from "../../src/morphlex" 3 + import { dom } from "../new/utils" 4 + 5 + test("morphing inputs by localName without any matching attributes", () => { 6 + // Lines 566-568: inputs match by localName when types are the same 7 + // Remove all id, name, href, src attributes to force localName matching 8 + const a = dom(`<div><input type="text"><input type="text"></div>`) as HTMLElement 9 + const b = dom(`<div><input type="text" placeholder="first"><input type="text" placeholder="second"></div>`) as HTMLElement 10 + 11 + const firstInput = a.children[0] as HTMLInputElement 12 + const secondInput = a.children[1] as HTMLInputElement 13 + 14 + morph(a, b) 15 + 16 + // Elements should be reused via localName matching 17 + expect(a.children[0]).toBe(firstInput) 18 + expect(a.children[1]).toBe(secondInput) 19 + expect(firstInput.placeholder).toBe("first") 20 + expect(secondInput.placeholder).toBe("second") 21 + }) 22 + 23 + test("morphing inputs with type mismatch skips candidate", () => { 24 + // Line 562-564: when types don't match, continue to next candidate 25 + const a = dom(`<div><input type="text"><input type="number"></div>`) as HTMLElement 26 + const b = dom(`<div><input type="number"><input type="text"></div>`) as HTMLElement 27 + 28 + morph(a, b) 29 + 30 + const inputs = Array.from(a.children) as HTMLInputElement[] 31 + expect(inputs[0].type).toBe("number") 32 + expect(inputs[1].type).toBe("text") 33 + }) 34 + 35 + test("morphing textarea with modified value preserves change", () => { 36 + // Line 190: textarea dirty flag 37 + const a = dom(`<div><textarea>original</textarea></div>`) as HTMLElement 38 + const b = dom(`<div><textarea>new</textarea></div>`) as HTMLElement 39 + 40 + const textarea = a.firstElementChild as HTMLTextAreaElement 41 + textarea.value = "user input" 42 + 43 + morph(a, b, { preserveChanges: true }) 44 + 45 + // User's input should be preserved 46 + expect(textarea.value).toBe("user input") 47 + expect(textarea.textContent).toBe("new") 48 + }) 49 + 50 + test("morphing buttons by localName", () => { 51 + // Another test for lines 566-568 with different element types 52 + const a = dom(`<div><button>A</button><button>B</button></div>`) as HTMLElement 53 + const b = dom(`<div><button>X</button><button>Y</button></div>`) as HTMLElement 54 + 55 + const first = a.children[0] 56 + const second = a.children[1] 57 + 58 + morph(a, b) 59 + 60 + expect(a.children[0]).toBe(first) 61 + expect(a.children[1]).toBe(second) 62 + expect(first.textContent).toBe("X") 63 + expect(second.textContent).toBe("Y") 64 + }) 65 + 66 + test("morphing spans by localName forces lines 566-568", () => { 67 + // Explicit test for lines 566-568: non-input elements matching by localName 68 + // Elements must NOT be equal by isEqualNode, so give them different initial content 69 + const a = dom(`<div><span>a</span><span>b</span><span>c</span></div>`) as HTMLElement 70 + const b = dom(`<div><span data-id="1">x</span><span data-id="2">y</span><span data-id="3">z</span></div>`) as HTMLElement 71 + 72 + const first = a.children[0] 73 + const second = a.children[1] 74 + const third = a.children[2] 75 + 76 + morph(a, b) 77 + 78 + // Lines 566-568: localName matches, not inputs, so matches[i] = candidate, delete, break 79 + expect(a.children[0]).toBe(first) 80 + expect(a.children[1]).toBe(second) 81 + expect(a.children[2]).toBe(third) 82 + }) 83 + 84 + test("morphing divs by localName", () => { 85 + // Another explicit test for lines 566-568 86 + // Elements must NOT be equal by isEqualNode 87 + const a = dom(`<section><div>content1</div><div>content2</div></section>`) as HTMLElement 88 + const b = dom(`<section><div title="a">new1</div><div title="b">new2</div></section>`) as HTMLElement 89 + 90 + const first = a.children[0] 91 + const second = a.children[1] 92 + 93 + morph(a, b) 94 + 95 + expect(a.children[0]).toBe(first) 96 + expect(a.children[1]).toBe(second) 97 + }) 98 + 99 + test("morphing elements with same tag but different attributes by localName", () => { 100 + // Forcing lines 566-568: elements that don't match by isEqualNode, id, idSet, or heuristics 101 + const a = dom(`<ul><li data-a="1">A</li><li data-a="2">B</li><li data-a="3">C</li></ul>`) as HTMLElement 102 + const b = dom(`<ul><li data-b="x">X</li><li data-b="y">Y</li><li data-b="z">Z</li></ul>`) as HTMLElement 103 + 104 + const first = a.children[0] 105 + const second = a.children[1] 106 + const third = a.children[2] 107 + 108 + morph(a, b) 109 + 110 + // Should match by localName (li) and reuse elements 111 + expect(a.children[0]).toBe(first) 112 + expect(a.children[1]).toBe(second) 113 + expect(a.children[2]).toBe(third) 114 + expect(first.textContent).toBe("X") 115 + expect(second.textContent).toBe("Y") 116 + expect(third.textContent).toBe("Z") 117 + }) 118 + 119 + test("morphing p elements by localName", () => { 120 + // Yet another test for lines 566-568 with paragraph elements 121 + const a = dom(`<article><p class="old">First</p><p class="old">Second</p></article>`) as HTMLElement 122 + const b = dom(`<article><p class="new">Changed1</p><p class="new">Changed2</p></article>`) as HTMLElement 123 + 124 + const first = a.children[0] 125 + const second = a.children[1] 126 + 127 + morph(a, b) 128 + 129 + expect(a.children[0]).toBe(first) 130 + expect(a.children[1]).toBe(second) 131 + expect(first.textContent).toBe("Changed1") 132 + expect(second.textContent).toBe("Changed2") 133 + })
+39
test_trace.js
··· 1 + import { morph } from "./src/morphlex.js" 2 + import { JSDOM } from "jsdom" 3 + 4 + const dom = new JSDOM() 5 + global.document = dom.window.document 6 + global.Element = dom.window.Element 7 + global.HTMLInputElement = dom.window.HTMLInputElement 8 + global.DOMParser = dom.window.DOMParser 9 + global.Node = dom.window.Node 10 + 11 + function domEl(html) { 12 + const tmp = document.createElement("div") 13 + tmp.innerHTML = html.trim() 14 + return tmp.firstChild 15 + } 16 + 17 + const a = domEl(`<div> 18 + <input type="text" value="wrong"> 19 + <input type="checkbox" value="right"> 20 + </div>`) 21 + 22 + const b = domEl(`<div> 23 + <input type="checkbox" value="new"> 24 + </div>`) 25 + 26 + const textInput = a.children[0] 27 + const checkboxInput = a.children[1] 28 + 29 + console.log("Before morph:") 30 + console.log("textInput:", textInput.outerHTML) 31 + console.log("checkboxInput:", checkboxInput.outerHTML) 32 + 33 + morph(a, b) 34 + 35 + console.log("\nAfter morph:") 36 + console.log("a.children.length:", a.children.length) 37 + console.log("a.children[0]:", a.children[0].outerHTML) 38 + console.log("a.children[0] === textInput:", a.children[0] === textInput) 39 + console.log("a.children[0] === checkboxInput:", a.children[0] === checkboxInput)