Precise DOM morphing
morphing typescript dom

Browser tests

+382 -2
+3 -1
AGENTS.md
··· 1 - Don’t create a summary document. 2 - - Running all the tests with `bun run test` is cheap, so do it all the time. 3 - Try to maintain 100% test coverage. Use `bun run test --coverage`. 4 - I’m using `jj` so you can use that to look at your diff, but please don’t commit unless I ask you to.
··· 1 + - I’m using bun to manage packages. 2 - Don’t create a summary document. 3 + - Running all the tests with `bun run test` is cheap, so do it all the time. Don’t do too much before running tests. 4 - Try to maintain 100% test coverage. Use `bun run test --coverage`. 5 - I’m using `jj` so you can use that to look at your diff, but please don’t commit unless I ask you to. 6 + - Make sure you leave things in a good state. No diagnostics warnings. No type errors.
bun.lockb

This is a binary file and will not be displayed.

+8 -1
package.json
··· 19 "build": "bun run build.ts", 20 "test": "vitest run", 21 "test:watch": "vitest", 22 - "test:ui": "vitest --ui" 23 }, 24 "devDependencies": { 25 "@types/bun": "^1.3.1", 26 "@vitest/coverage-v8": "^4.0.5", 27 "@vitest/ui": "^4.0.5", 28 "gzip-size-cli": "^5.1.0", 29 "happy-dom": "^20.0.10", 30 "prettier": "^3.2.5", 31 "terser": "^5.28.1", 32 "typescript": "^5.4.2",
··· 19 "build": "bun run build.ts", 20 "test": "vitest run", 21 "test:watch": "vitest", 22 + "test:ui": "vitest --ui", 23 + "test:browser": "vitest run -c vitest.config.browser.ts", 24 + "test:browser:watch": "vitest -c vitest.config.browser.ts", 25 + "test:all": "vitest run && vitest run -c vitest.config.browser.ts" 26 }, 27 "devDependencies": { 28 + "@playwright/test": "^1.56.1", 29 "@types/bun": "^1.3.1", 30 + "@vitest/browser": "^4.0.5", 31 + "@vitest/browser-playwright": "^4.0.5", 32 "@vitest/coverage-v8": "^4.0.5", 33 "@vitest/ui": "^4.0.5", 34 "gzip-size-cli": "^5.1.0", 35 "happy-dom": "^20.0.10", 36 + "playwright": "^1.56.1", 37 "prettier": "^3.2.5", 38 "terser": "^5.28.1", 39 "typescript": "^5.4.2",
+336
test/morphlex.browser.test.ts
···
··· 1 + import { describe, it, expect, beforeEach, afterEach } from "vitest" 2 + import { morph, morphInner } from "../src/morphlex" 3 + 4 + describe("Morphlex Browser Tests", () => { 5 + let container: HTMLElement 6 + 7 + beforeEach(() => { 8 + container = document.createElement("div") 9 + container.id = "test-container" 10 + document.body.appendChild(container) 11 + }) 12 + 13 + afterEach(() => { 14 + if (container && container.parentNode) { 15 + container.parentNode.removeChild(container) 16 + } 17 + }) 18 + 19 + describe("Browser-specific DOM interactions", () => { 20 + it("should handle real browser events after morphing", async () => { 21 + const original = document.createElement("button") 22 + original.textContent = "Click me" 23 + let clicked = false 24 + 25 + original.addEventListener("click", () => { 26 + clicked = true 27 + }) 28 + 29 + container.appendChild(original) 30 + 31 + // Morph with new text but preserve the element 32 + const reference = document.createElement("button") 33 + reference.textContent = "Updated button" 34 + 35 + morph(original, reference) 36 + 37 + // Verify the button text changed 38 + expect(original.textContent).toBe("Updated button") 39 + 40 + // Click the button in the real browser 41 + original.click() 42 + 43 + // Event listener should still work 44 + expect(clicked).toBe(true) 45 + }) 46 + 47 + it("should handle CSS transitions in real browser", async () => { 48 + const original = document.createElement("div") 49 + original.style.cssText = "width: 100px; transition: width 0.1s;" 50 + container.appendChild(original) 51 + 52 + // Force browser to compute styles 53 + const computedStyle = getComputedStyle(original) 54 + expect(computedStyle.width).toBe("100px") 55 + 56 + // Morph with new styles 57 + const reference = document.createElement("div") 58 + reference.style.cssText = "width: 200px; transition: width 0.1s;" 59 + 60 + morph(original, reference) 61 + 62 + // Verify styles were updated 63 + expect(original.style.width).toBe("200px") 64 + }) 65 + 66 + it("should handle focus state correctly", () => { 67 + const original = document.createElement("input") 68 + original.type = "text" 69 + original.value = "initial" 70 + container.appendChild(original) 71 + 72 + // Focus the input 73 + original.focus() 74 + expect(document.activeElement).toBe(original) 75 + 76 + // Morph with new attributes 77 + const reference = document.createElement("input") 78 + reference.type = "text" 79 + reference.value = "updated" 80 + reference.placeholder = "Enter text" 81 + 82 + morph(original, reference) 83 + 84 + // Focus should be preserved on the same element 85 + expect(document.activeElement).toBe(original) 86 + expect(original.value).toBe("updated") 87 + expect(original.placeholder).toBe("Enter text") 88 + }) 89 + 90 + it("should handle complex nested structures", () => { 91 + container.innerHTML = ` 92 + <div class="parent"> 93 + <h1>Title</h1> 94 + <ul> 95 + <li>Item 1</li> 96 + <li>Item 2</li> 97 + <li>Item 3</li> 98 + </ul> 99 + </div> 100 + ` 101 + 102 + const original = container.firstElementChild as HTMLElement 103 + const originalH1 = original.querySelector("h1") 104 + 105 + const referenceHTML = ` 106 + <div class="parent modified"> 107 + <h1>Updated Title</h1> 108 + <ul> 109 + <li>Item 1 - Modified</li> 110 + <li>Item 2</li> 111 + <li>New Item 3</li> 112 + <li>Item 4</li> 113 + </ul> 114 + </div> 115 + ` 116 + 117 + morph(original, referenceHTML) 118 + 119 + // Check the structure is updated 120 + expect(original.className).toBe("parent modified") 121 + expect(originalH1?.textContent).toBe("Updated Title") 122 + 123 + const newItems = Array.from(original.querySelectorAll("li")) 124 + expect(newItems.length).toBe(4) 125 + expect(newItems[0].textContent).toBe("Item 1 - Modified") 126 + expect(newItems[3].textContent).toBe("Item 4") 127 + }) 128 + 129 + it("should handle SVG elements in real browser", () => { 130 + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg") 131 + svg.setAttribute("width", "100") 132 + svg.setAttribute("height", "100") 133 + 134 + const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle") 135 + circle.setAttribute("cx", "50") 136 + circle.setAttribute("cy", "50") 137 + circle.setAttribute("r", "40") 138 + circle.setAttribute("fill", "red") 139 + 140 + svg.appendChild(circle) 141 + container.appendChild(svg) 142 + 143 + const referenceSVG = document.createElementNS("http://www.w3.org/2000/svg", "svg") 144 + referenceSVG.setAttribute("width", "200") 145 + referenceSVG.setAttribute("height", "200") 146 + 147 + const referenceCircle = document.createElementNS("http://www.w3.org/2000/svg", "circle") 148 + referenceCircle.setAttribute("cx", "100") 149 + referenceCircle.setAttribute("cy", "100") 150 + referenceCircle.setAttribute("r", "80") 151 + referenceCircle.setAttribute("fill", "blue") 152 + 153 + referenceSVG.appendChild(referenceCircle) 154 + 155 + morph(svg, referenceSVG) 156 + 157 + expect(svg.getAttribute("width")).toBe("200") 158 + expect(svg.getAttribute("height")).toBe("200") 159 + 160 + const morphedCircle = svg.querySelector("circle") 161 + expect(morphedCircle?.getAttribute("cx")).toBe("100") 162 + expect(morphedCircle?.getAttribute("cy")).toBe("100") 163 + expect(morphedCircle?.getAttribute("r")).toBe("80") 164 + expect(morphedCircle?.getAttribute("fill")).toBe("blue") 165 + }) 166 + 167 + it("should handle form inputs and maintain state", () => { 168 + const form = document.createElement("form") 169 + form.innerHTML = ` 170 + <input type="text" name="username" value="john"> 171 + <input type="checkbox" name="remember" checked> 172 + <select name="country"> 173 + <option value="us">United States</option> 174 + <option value="uk" selected>United Kingdom</option> 175 + </select> 176 + ` 177 + container.appendChild(form) 178 + 179 + const textInput = form.querySelector('input[name="username"]') as HTMLInputElement 180 + const checkbox = form.querySelector('input[name="remember"]') as HTMLInputElement 181 + const select = form.querySelector('select[name="country"]') as HTMLSelectElement 182 + 183 + // Modify the values in the browser 184 + textInput.value = "jane" 185 + checkbox.checked = false 186 + select.value = "us" 187 + 188 + // Create reference with different structure but same form fields 189 + const referenceForm = document.createElement("form") 190 + referenceForm.className = "updated-form" 191 + referenceForm.innerHTML = ` 192 + <div class="form-group"> 193 + <input type="text" name="username" value="john" placeholder="Username"> 194 + </div> 195 + <div class="form-group"> 196 + <input type="checkbox" name="remember" checked> 197 + <label>Remember me</label> 198 + </div> 199 + <div class="form-group"> 200 + <select name="country" class="country-select"> 201 + <option value="us">United States</option> 202 + <option value="uk" selected>United Kingdom</option> 203 + <option value="ca">Canada</option> 204 + </select> 205 + </div> 206 + ` 207 + 208 + morph(form, referenceForm) 209 + 210 + // Form should have new structure 211 + expect(form.className).toBe("updated-form") 212 + expect(form.querySelectorAll(".form-group").length).toBe(3) 213 + 214 + // The form elements should be the same instances (preserved) 215 + const newTextInput = form.querySelector('input[name="username"]') as HTMLInputElement 216 + const newCheckbox = form.querySelector('input[name="remember"]') as HTMLInputElement 217 + const newSelect = form.querySelector('select[name="country"]') as HTMLSelectElement 218 + 219 + // Values from reference should be applied (morph doesn't preserve user modifications by default) 220 + expect(newTextInput.value).toBe("john") 221 + expect(newCheckbox.checked).toBe(true) 222 + expect(newSelect.value).toBe("uk") 223 + 224 + // New attributes should be applied 225 + expect(newTextInput.placeholder).toBe("Username") 226 + expect(newSelect.className).toBe("country-select") 227 + }) 228 + 229 + it("should handle morphInner with browser content", () => { 230 + const testContainer = document.createElement("div") 231 + testContainer.innerHTML = ` 232 + <p>Old paragraph</p> 233 + <button>Old button</button> 234 + ` 235 + document.body.appendChild(testContainer) 236 + 237 + const referenceContainer = document.createElement("div") 238 + referenceContainer.innerHTML = ` 239 + <h2>New heading</h2> 240 + <p>New paragraph</p> 241 + <button>New button</button> 242 + <span>New span</span> 243 + ` 244 + 245 + morphInner(testContainer, referenceContainer) 246 + 247 + expect(testContainer.children.length).toBe(4) 248 + expect(testContainer.querySelector("h2")?.textContent).toBe("New heading") 249 + expect(testContainer.querySelector("p")?.textContent).toBe("New paragraph") 250 + expect(testContainer.querySelector("button")?.textContent).toBe("New button") 251 + expect(testContainer.querySelector("span")?.textContent).toBe("New span") 252 + 253 + testContainer.remove() 254 + }) 255 + 256 + it("should handle custom elements if supported", () => { 257 + // Skip if custom elements are not supported 258 + if (!window.customElements) { 259 + return 260 + } 261 + 262 + // Define a simple custom element 263 + class TestElement extends HTMLElement { 264 + connectedCallback() { 265 + this.innerHTML = "<span>Custom content</span>" 266 + } 267 + } 268 + 269 + // Register it if not already registered 270 + if (!customElements.get("test-element")) { 271 + customElements.define("test-element", TestElement) 272 + } 273 + 274 + const original = document.createElement("div") 275 + original.innerHTML = `<test-element id="custom"></test-element>` 276 + container.appendChild(original) 277 + 278 + // Wait for custom element to be upgraded 279 + const customEl = original.querySelector("#custom") 280 + expect(customEl).toBeTruthy() 281 + 282 + const reference = document.createElement("div") 283 + reference.innerHTML = `<test-element id="custom" data-updated="true"></test-element>` 284 + 285 + morph(original, reference) 286 + 287 + const morphedCustom = original.querySelector("#custom") as HTMLElement 288 + expect(morphedCustom).toBeTruthy() 289 + expect(morphedCustom.getAttribute("data-updated")).toBe("true") 290 + }) 291 + 292 + it("should handle real browser viewport and scroll position", () => { 293 + // Create a scrollable container 294 + const scrollContainer = document.createElement("div") 295 + scrollContainer.style.cssText = "height: 200px; overflow-y: scroll; position: relative;" 296 + scrollContainer.innerHTML = ` 297 + <div style="height: 500px;"> 298 + <p id="p1">Paragraph 1</p> 299 + <p id="p2" style="margin-top: 200px;">Paragraph 2</p> 300 + <p id="p3" style="margin-top: 200px;">Paragraph 3</p> 301 + </div> 302 + ` 303 + container.appendChild(scrollContainer) 304 + 305 + // Scroll to middle 306 + scrollContainer.scrollTop = 100 307 + const initialScrollTop = scrollContainer.scrollTop 308 + 309 + // Morph with new content 310 + const referenceContainer = document.createElement("div") 311 + referenceContainer.style.cssText = "height: 200px; overflow-y: scroll; position: relative;" 312 + referenceContainer.innerHTML = ` 313 + <div style="height: 500px;"> 314 + <p id="p1" class="updated">Updated Paragraph 1</p> 315 + <p id="p2" style="margin-top: 200px;">Updated Paragraph 2</p> 316 + <p id="p3" style="margin-top: 200px;">Updated Paragraph 3</p> 317 + <p id="p4" style="margin-top: 200px;">New Paragraph 4</p> 318 + </div> 319 + ` 320 + 321 + morph(scrollContainer, referenceContainer) 322 + 323 + // Scroll position should be preserved 324 + expect(scrollContainer.scrollTop).toBe(initialScrollTop) 325 + 326 + // Content should be updated 327 + const p1 = scrollContainer.querySelector("#p1") 328 + expect(p1?.className).toBe("updated") 329 + expect(p1?.textContent).toBe("Updated Paragraph 1") 330 + 331 + const p4 = scrollContainer.querySelector("#p4") 332 + expect(p4).toBeTruthy() 333 + expect(p4?.textContent).toBe("New Paragraph 4") 334 + }) 335 + }) 336 + })
+35
vitest.config.browser.ts
···
··· 1 + import { defineConfig } from "vitest/config" 2 + import { playwright } from "@vitest/browser-playwright" 3 + 4 + export default defineConfig({ 5 + test: { 6 + browser: { 7 + enabled: true, 8 + provider: playwright(), 9 + instances: [ 10 + { 11 + browser: "chromium", 12 + }, 13 + { 14 + browser: "firefox", 15 + }, 16 + { 17 + browser: "webkit", 18 + }, 19 + ], 20 + // Enable headless mode by default, can be overridden with --browser.headless=false 21 + headless: true, 22 + // Screenshot on failure 23 + screenshotFailures: true, 24 + }, 25 + // Increase timeouts for browser tests 26 + testTimeout: 30000, 27 + hookTimeout: 30000, 28 + // Don't use globals in browser tests to avoid pollution 29 + globals: false, 30 + // Retry failed tests once in browser mode 31 + retry: 1, 32 + // Include only browser-specific tests 33 + include: ["test/**/*.browser.test.ts"], 34 + }, 35 + })