diff --git a/Sources/SwiflowCLI/EmbeddedDriver.swift b/Sources/SwiflowCLI/EmbeddedDriver.swift index 42bbe162..62f5d296 100644 --- a/Sources/SwiflowCLI/EmbeddedDriver.swift +++ b/Sources/SwiflowCLI/EmbeddedDriver.swift @@ -424,6 +424,17 @@ enum EmbeddedDriver { } window.swiflow = { + /** Test/diagnostic introspection: live sizes of the driver's node, + * listener, and mount-root maps. Leak soaks assert these stay bounded + * under animation-rate rendering — the driver has no other window into + * its retention. Not API; shape may change. */ + __stats: function () { + return { + nodes: nodes.size, + listeners: listeners.size, + mountedRoots: mountedRoots.size, + }; + }, /** Called by Swift each frame with a JSArray of patch objects. * Each patch is applied in its own try/catch: one bad handle must not * abort the rest of the frame (a half-applied batch is strictly worse @@ -903,13 +914,13 @@ enum EmbeddedDriver { """# static let javascriptSourceMinified: String = #""" -(function(){"use strict";const o=new Map,m=new Map;let y=null;const x=new Map,k="./.build/plugins/PackageToJS/outputs/Package/App.wasm";function b(e){const t=document.createElement("template");if(t.innerHTML=e,t.content.childNodes.length===1)return t.content.firstChild;const n=document.createElement("span");for(;t.content.firstChild;)n.appendChild(t.content.firstChild);return n}function C(e){const t=e.target,n=t instanceof Element?t:t&&t.parentElement||null;if(!n||!n.closest||!e.currentTarget)return!1;const r=n.closest("a[href],button,input,select,textarea,summary,label,[contenteditable]:not([contenteditable='false'])");return!!(r&&r!==e.currentTarget&&e.currentTarget.contains&&e.currentTarget.contains(r))}function E(e){const t=e.target,n=t&&"value"in t?String(t.value):null,r=t&&"checked"in t?!!t.checked:null,l=typeof e.key=="string"?e.key:null;return{type:e.type,targetValue:n,targetChecked:r,isSelfTarget:t===e.currentTarget,fromInteractiveDescendant:C(e),key:l,shiftKey:!!e.shiftKey,ctrlKey:!!e.ctrlKey,altKey:!!e.altKey,metaKey:!!e.metaKey,detail:e.detail!==null&&typeof e.detail=="object"?JSON.stringify(e.detail):null}}const T=new Set(["svg","path","g","circle","ellipse","rect","line","polyline","polygon","text","tspan","defs","linearGradient","radialGradient","stop","marker","clipPath","mask","pattern","use","symbol"]);function _(e,t){for(const n of Array.from(m.keys())){const r=n.indexOf(":");if(r<0||Number(n.slice(0,r))!==e)continue;const l=n.slice(r+1),u=m.get(n);t!==void 0&&u!==void 0&&t.removeEventListener(l,u),m.delete(n)}}function S(e){switch(e.op){case"createElement":o.set(e.handle,T.has(e.tag)?document.createElementNS("http://www.w3.org/2000/svg",e.tag):document.createElement(e.tag));return;case"createText":o.set(e.handle,document.createTextNode(e.text));return;case"createRawHTML":{o.set(e.handle,b(e.html));return}case"destroyNode":{_(e.handle,o.get(e.handle)),o.delete(e.handle);return}case"animateExit":{const t=o.get(e.handle),n=o.get(e.parentHandle);if(!t)return;_(e.handle,t),t.style.animation=e.animation,setTimeout(function(){n&&t.parentNode===n?n.removeChild(t):t.parentNode&&t.parentNode.removeChild(t),o.delete(e.handle)},e.durationMs);return}case"appendChild":o.get(e.parent).appendChild(o.get(e.child));return;case"insertBefore":o.get(e.parent).insertBefore(o.get(e.child),o.get(e.beforeChild));return;case"removeChild":o.get(e.parent).removeChild(o.get(e.child));return;case"setAttribute":o.get(e.handle).setAttribute(e.name,e.value);return;case"removeAttribute":o.get(e.handle).removeAttribute(e.name);return;case"setProperty":if(e.name==="innerHTML")throw new Error("swiflow: setProperty refuses to write the innerHTML property; use VNode.rawHTML(_:) instead");o.get(e.handle)[e.name]=e.value;return;case"removeProperty":delete o.get(e.handle)[e.name];return;case"setStyle":e.name.startsWith("--")?o.get(e.handle).style.setProperty(e.name,e.value):o.get(e.handle).style[e.name]=e.value;return;case"removeStyle":o.get(e.handle).style.removeProperty(e.name);return;case"setText":{const t=o.get(e.handle);t.data!==void 0?t.data=e.text:t.textContent=e.text;return}case"setRawHTML":{const t=b(e.html),n=o.get(e.handle);n&&n.parentNode&&n.parentNode.replaceChild(t,n),o.set(e.handle,t);return}case"addHandler":{const t=e.handlerId,n=e.handle+":"+e.event,r=m.get(n);r!==void 0&&o.get(e.handle).removeEventListener(e.event,r);const l=function(u){window.__swiflowDispatch(t,E(u))};o.get(e.handle).addEventListener(e.event,l),m.set(n,l);return}case"removeHandler":{const t=e.handle+":"+e.event,n=m.get(t);n!==void 0&&(o.get(e.handle).removeEventListener(e.event,n),m.delete(t));return}case"replaceMount":{const t=document.querySelector(e.selector);if(t===null)throw new Error("swiflow-driver: replaceMount target '"+e.selector+"' not found");const n=x.get(e.selector);n!==void 0&&n.parentNode===t&&t.removeChild(n);const r=o.get(e.newHandle);x.set(e.selector,r),t.appendChild(r);return}default:console.error("swiflow-driver: unknown opcode",e.op,e);return}}async function v(e){const t=await fetch(e);if(!t.ok)throw new Error("swiflow: fetch "+e+" failed ("+t.status+")");const n=parseInt(t.headers.get("Content-Length")||"",10),r=t.body&&t.body.getReader?t.body.getReader():null;if(!r)return document.documentElement.dataset.swiflowProgress="100",t;const l=[];let u=0;const h=Number.isFinite(n)&&n>0;try{for(;;){const{done:i,value:s}=await r.read();if(i)break;if(l.push(s),u+=s.byteLength,h){const a=Math.floor(u/n*100);document.documentElement.dataset.swiflowProgress=String(Math.min(a,99))}}}catch(i){try{await r.cancel()}catch{}throw i}document.documentElement.dataset.swiflowProgress="100";const g=new Blob(l,{type:t.headers.get("Content-Type")||"application/wasm"});return new Response(g,{headers:t.headers,status:t.status})}if(window.swiflow={applyPatches:function(e){let t=!0;for(let n=0;n0;try{for(;;){const{done:i,value:a}=await r.read();if(i)break;if(l.push(a),u+=a.byteLength,g){const s=Math.floor(u/n*100);document.documentElement.dataset.swiflowProgress=String(Math.min(s,99))}}}catch(i){try{await r.cancel()}catch{}throw i}document.documentElement.dataset.swiflowProgress="100";const p=new Blob(l,{type:t.headers.get("Content-Type")||"application/wasm"});return new Response(p,{headers:t.headers,status:t.status})}if(window.swiflow={__stats:function(){return{nodes:o.size,listeners:m.size,mountedRoots:h.size}},applyPatches:function(e){let t=!0;for(let n=0;nimport(d)),{init:w}=await c(i.jsURL);await w({module:v(i.wasmURL)});const f=(performance.now()-s).toFixed(1);console.log("[swiflow] hmr-swap took "+f+"ms")}catch(a){console.warn("[swiflow] HMR swap failed, falling back to full reload:",a),location.reload();return}finally{l=!1}if(u!==null){const a=u;u=null,g(a)}}h()}window.swiflow.__boot=async function({swiflowDev:t}){if("serviceWorker"in navigator){if(t){const n=await navigator.serviceWorker.getRegistrations();for(const r of n)if(((r.active||r.installing||r.waiting)?.scriptURL??"").endsWith("/swiflow-service-worker.js"))try{await r.unregister()}catch{}if(typeof caches<"u")try{const r=await caches.keys();await Promise.all(r.filter(l=>l.startsWith("swiflow-")).map(l=>caches.delete(l)))}catch{}return}try{await navigator.serviceWorker.register("swiflow-service-worker.js")}catch(n){console.warn("swiflow: service worker registration failed",n)}}},window.swiflow.__bootForTest=window.swiflow.__boot,window.swiflow.__test_fetchWithProgress=v,(async()=>{if(!window.__SWIFLOW_SKIP_BOOT&&(await window.swiflow.__boot({swiflowDev:!!window.SWIFLOW_DEV}),!window.swiflow.__inited)){window.swiflow.__inited=!0;try{const{init:e}=await import("./.build/plugins/PackageToJS/outputs/Package/index.js");await e({module:v(k)})}catch(e){console.error("swiflow: WASM init failed",e)}}})()})(); +`+(i&&i.stack?i.stack:String(i)));const a=document.getElementById("__swiflow-error-overlay");a&&a.remove();const s=document.createElement("div");s.id="__swiflow-error-overlay",s.style.cssText="position:fixed;inset:0;z-index:999999;background:rgba(0,0,0,0.85);color:#fff;font-family:monospace;font-size:14px;padding:24px;overflow:auto;white-space:pre-wrap;word-break:break-word;";const c=document.createElement("div");c.style.cssText="font-size:18px;font-weight:bold;margin-bottom:16px;color:#ff6b6b;",c.textContent="\u26A0 Swiflow render error \u2014 WASM execution stopped";const w=document.createElement("pre");w.style.cssText="margin:0 0 16px;",w.textContent=i&&i.stack?i.stack:String(i);const f=document.createElement("div");f.style.cssText="color:#aaa;font-size:12px;margin-bottom:4px;",f.textContent="Install the Chrome C/C++ DevTools Extension to see Swift file:line in the stack above:";const d=document.createElement("a");d.href="https://goo.gle/wasm-debugging-extension",d.target="_blank",d.style.cssText="color:#4dabf7;font-size:12px;display:block;margin-bottom:16px;",d.textContent="https://goo.gle/wasm-debugging-extension";const y=document.createElement("button");y.style.cssText="padding:8px 16px;background:#444;color:#fff;border:none;cursor:pointer;font-size:14px;border-radius:4px;",y.textContent="Dismiss (app is frozen \u2014 reload to continue)",y.onclick=function(){s.remove()},s.appendChild(c),s.appendChild(w),s.appendChild(f),s.appendChild(d),s.appendChild(y),(document.body||document.documentElement).appendChild(s)},typeof window.requestAnimationFrame=="function"){var L=window.requestAnimationFrame.bind(window);window.requestAnimationFrame=function(i){return L(function(a){try{i(a)}catch(s){window.__swiflowDevError(s)}})}}let n=250;const r=5e3;let l=!1,u=null;async function p(i){if(l){u=i;return}l=!0;const a=performance.now();try{const s=window.__swiflow&&window.__swiflow.hmrSnapshot?window.__swiflow.hmrSnapshot():null;window.__swiflowPendingSnapshot=s;try{window.__swiflow&&typeof window.__swiflow.hmrTeardown=="function"&&window.__swiflow.hmrTeardown()}catch(d){console.warn("[swiflow] hmr teardown of previous module failed:",d)}if(o.clear(),m.clear(),x){const d=document.querySelector(x);d&&d.replaceChildren()}document.querySelectorAll('style[id^="swiflow-"]').forEach(function(d){d.remove()});const c=window.swiflow&&window.swiflow.__importOverride||(d=>import(d)),{init:w}=await c(i.jsURL);await w({module:v(i.wasmURL)});const f=(performance.now()-a).toFixed(1);console.log("[swiflow] hmr-swap took "+f+"ms")}catch(s){console.warn("[swiflow] HMR swap failed, falling back to full reload:",s),location.reload();return}finally{l=!1}if(u!==null){const s=u;u=null,p(s)}}g()}window.swiflow.__boot=async function({swiflowDev:t}){if("serviceWorker"in navigator){if(t){const n=await navigator.serviceWorker.getRegistrations();for(const r of n)if(((r.active||r.installing||r.waiting)?.scriptURL??"").endsWith("/swiflow-service-worker.js"))try{await r.unregister()}catch{}if(typeof caches<"u")try{const r=await caches.keys();await Promise.all(r.filter(l=>l.startsWith("swiflow-")).map(l=>caches.delete(l)))}catch{}return}try{await navigator.serviceWorker.register("swiflow-service-worker.js")}catch(n){console.warn("swiflow: service worker registration failed",n)}}},window.swiflow.__bootForTest=window.swiflow.__boot,window.swiflow.__test_fetchWithProgress=v,(async()=>{if(!window.__SWIFLOW_SKIP_BOOT&&(await window.swiflow.__boot({swiflowDev:!!window.SWIFLOW_DEV}),!window.swiflow.__inited)){window.swiflow.__inited=!0;try{const{init:e}=await import("./.build/plugins/PackageToJS/outputs/Package/index.js");await e({module:v(k)})}catch(e){console.error("swiflow: WASM init failed",e)}}})()})(); """# diff --git a/Tests/playwright/autocomplete.spec.ts b/Tests/playwright/autocomplete.spec.ts new file mode 100644 index 00000000..8b23f815 --- /dev/null +++ b/Tests/playwright/autocomplete.spec.ts @@ -0,0 +1,62 @@ +// Tests/playwright/autocomplete.spec.ts +// +// Autocomplete's behavior is the kit's most imperative surface — +// showPopover()/hidePopover() + scrollIntoView driven from refs, which no +// host-layer test can see (the harness renders the DECLARED tree only). +// Runs under the swiflowui config (SwiflowUIDemo catalog). +import { test, expect } from "@playwright/test"; + +test.describe("Autocomplete (imperative popover surface)", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/#/component/autocomplete"); + await expect(page.locator(".sw-ac input").first()).toBeVisible({ timeout: 120_000 }); + }); + + test("typing opens the listbox popover; Escape closes it", async ({ page }) => { + const input = page.locator(".sw-ac input").first(); + const listbox = page.locator(".sw-ac [role=listbox]").first(); + await input.click(); + await input.pressSequentially("a"); + await expect(listbox).toBeVisible(); + // The listbox opens through the native Popover API, not display toggling. + const isPopoverOpen = await listbox.evaluate((el) => el.matches(":popover-open")); + expect(isPopoverOpen).toBe(true); + await input.press("Escape"); + await expect(listbox).toBeHidden(); + }); + + test("ArrowDown moves the active option and keeps it scrolled into view", async ({ page }) => { + const input = page.locator(".sw-ac input").first(); + await input.click(); + await input.pressSequentially("a"); + const listbox = page.locator(".sw-ac [role=listbox]").first(); + await expect(listbox).toBeVisible(); + + const optionCount = await listbox.locator("[role=option]").count(); + expect(optionCount).toBeGreaterThan(1); + // Walk past the fold; the active option must stay visible in the + // listbox's scrollport (the ref-driven scrollIntoView). + for (let i = 0; i < optionCount; i++) await input.press("ArrowDown"); + const active = listbox.locator('[role=option][aria-selected="true"], [role=option].sw-ac__option--active').first(); + await expect(active).toBeVisible(); + const inView = await active.evaluate((el) => { + const opt = el.getBoundingClientRect(); + const box = el.closest("[role=listbox]")!.getBoundingClientRect(); + return opt.top >= box.top - 1 && opt.bottom <= box.bottom + 1; + }); + expect(inView).toBe(true); + }); + + test("selecting an option closes the popover and fills the input", async ({ page }) => { + const input = page.locator(".sw-ac input").first(); + await input.click(); + await input.pressSequentially("a"); + const listbox = page.locator(".sw-ac [role=listbox]").first(); + await expect(listbox).toBeVisible(); + const first = listbox.locator("[role=option]").first(); + const label = (await first.innerText()).trim(); + await first.click(); + await expect(listbox).toBeHidden(); + await expect(input).toHaveValue(label); + }); +}); diff --git a/Tests/playwright/gridboard.spec.ts b/Tests/playwright/gridboard.spec.ts index 6eeea811..ca4fd9b0 100644 --- a/Tests/playwright/gridboard.spec.ts +++ b/Tests/playwright/gridboard.spec.ts @@ -27,3 +27,32 @@ test.describe("GridBoard smoke", () => { await expect(readout).not.toHaveText(before); }); }); + +test.describe("GridBoard leak soak", () => { + test("driver listener/node maps stay bounded during playback", async ({ page }) => { + await page.goto("/"); + await expect(page.locator("svg.gb-map")).toBeVisible({ timeout: 120_000 }); + + const stats = () => + page.evaluate(() => (window as any).swiflow.__stats() as { + nodes: number; listeners: number; mountedRoots: number; + }); + + // Start playback (the ▶ button), soak at animation-rate rendering, + // and require the driver's retention maps to stay flat: the memoKey + // handler leak and the animateExit listener leak both grew exactly + // these counters, render over render. + await page.locator("button", { hasText: "▶" }).first().click(); + await page.waitForTimeout(2_000); + const early = await stats(); + await page.waitForTimeout(6_000); + const late = await stats(); + await page.locator("button", { hasText: "❚❚" }).first().click(); + + // Identical UI, ~360 re-renders later: allow a few transient nodes + // (toasts/lens), never monotonic growth. + expect(late.listeners - early.listeners).toBeLessThanOrEqual(5); + expect(late.nodes - early.nodes).toBeLessThanOrEqual(20); + expect(late.mountedRoots).toBe(early.mountedRoots); + }); +}); diff --git a/Tests/playwright/playwright.config.ts b/Tests/playwright/playwright.config.ts index 063c1a55..9059b58e 100644 --- a/Tests/playwright/playwright.config.ts +++ b/Tests/playwright/playwright.config.ts @@ -44,6 +44,7 @@ export default defineConfig({ "overlay.spec.ts", // SwiflowUIDemo — npm run test:swiflowui "tabs.spec.ts", // SwiflowUIDemo — npm run test:swiflowui "catalog-nav.spec.ts", // SwiflowUIDemo — npm run test:swiflowui + "autocomplete.spec.ts",// SwiflowUIDemo — npm run test:swiflowui "gridboard.spec.ts", // GridBoard — npm run test:gridboard (:3009) "todocrud.spec.ts", // real Bun+SQLite backend — run-e2e-backend job ], diff --git a/Tests/playwright/playwright.swiflowui.config.ts b/Tests/playwright/playwright.swiflowui.config.ts index fc55302c..f56fbbcc 100644 --- a/Tests/playwright/playwright.swiflowui.config.ts +++ b/Tests/playwright/playwright.swiflowui.config.ts @@ -13,7 +13,7 @@ ensureCli(); export default defineConfig({ testDir: ".", - testMatch: ["dropdown.spec.ts", "datatable.spec.ts", "overlay.spec.ts", "tabs.spec.ts", "catalog-nav.spec.ts"], + testMatch: ["dropdown.spec.ts", "datatable.spec.ts", "overlay.spec.ts", "tabs.spec.ts", "catalog-nav.spec.ts", "autocomplete.spec.ts"], fullyParallel: false, reporter: process.env.CI ? "github" : "list", use: { baseURL: "http://127.0.0.1:3005", trace: "on-first-retry" }, diff --git a/examples/AsyncFetch/swiflow-driver.js b/examples/AsyncFetch/swiflow-driver.js index 51ed49a2..390f8535 100644 --- a/examples/AsyncFetch/swiflow-driver.js +++ b/examples/AsyncFetch/swiflow-driver.js @@ -414,6 +414,17 @@ } window.swiflow = { + /** Test/diagnostic introspection: live sizes of the driver's node, + * listener, and mount-root maps. Leak soaks assert these stay bounded + * under animation-rate rendering — the driver has no other window into + * its retention. Not API; shape may change. */ + __stats: function () { + return { + nodes: nodes.size, + listeners: listeners.size, + mountedRoots: mountedRoots.size, + }; + }, /** Called by Swift each frame with a JSArray of patch objects. * Each patch is applied in its own try/catch: one bad handle must not * abort the rest of the frame (a half-applied batch is strictly worse diff --git a/examples/EdgeCases/swiflow-driver.js b/examples/EdgeCases/swiflow-driver.js index 51ed49a2..390f8535 100644 --- a/examples/EdgeCases/swiflow-driver.js +++ b/examples/EdgeCases/swiflow-driver.js @@ -414,6 +414,17 @@ } window.swiflow = { + /** Test/diagnostic introspection: live sizes of the driver's node, + * listener, and mount-root maps. Leak soaks assert these stay bounded + * under animation-rate rendering — the driver has no other window into + * its retention. Not API; shape may change. */ + __stats: function () { + return { + nodes: nodes.size, + listeners: listeners.size, + mountedRoots: mountedRoots.size, + }; + }, /** Called by Swift each frame with a JSArray of patch objects. * Each patch is applied in its own try/catch: one bad handle must not * abort the rest of the frame (a half-applied batch is strictly worse diff --git a/examples/HelloWorld/swiflow-driver.js b/examples/HelloWorld/swiflow-driver.js index 51ed49a2..390f8535 100644 --- a/examples/HelloWorld/swiflow-driver.js +++ b/examples/HelloWorld/swiflow-driver.js @@ -414,6 +414,17 @@ } window.swiflow = { + /** Test/diagnostic introspection: live sizes of the driver's node, + * listener, and mount-root maps. Leak soaks assert these stay bounded + * under animation-rate rendering — the driver has no other window into + * its retention. Not API; shape may change. */ + __stats: function () { + return { + nodes: nodes.size, + listeners: listeners.size, + mountedRoots: mountedRoots.size, + }; + }, /** Called by Swift each frame with a JSArray of patch objects. * Each patch is applied in its own try/catch: one bad handle must not * abort the rest of the frame (a half-applied batch is strictly worse diff --git a/examples/QueryDemo/swiflow-driver.js b/examples/QueryDemo/swiflow-driver.js index 51ed49a2..390f8535 100644 --- a/examples/QueryDemo/swiflow-driver.js +++ b/examples/QueryDemo/swiflow-driver.js @@ -414,6 +414,17 @@ } window.swiflow = { + /** Test/diagnostic introspection: live sizes of the driver's node, + * listener, and mount-root maps. Leak soaks assert these stay bounded + * under animation-rate rendering — the driver has no other window into + * its retention. Not API; shape may change. */ + __stats: function () { + return { + nodes: nodes.size, + listeners: listeners.size, + mountedRoots: mountedRoots.size, + }; + }, /** Called by Swift each frame with a JSArray of patch objects. * Each patch is applied in its own try/catch: one bad handle must not * abort the rest of the frame (a half-applied batch is strictly worse diff --git a/examples/RegionDemo/swiflow-driver.js b/examples/RegionDemo/swiflow-driver.js index 51ed49a2..390f8535 100644 --- a/examples/RegionDemo/swiflow-driver.js +++ b/examples/RegionDemo/swiflow-driver.js @@ -414,6 +414,17 @@ } window.swiflow = { + /** Test/diagnostic introspection: live sizes of the driver's node, + * listener, and mount-root maps. Leak soaks assert these stay bounded + * under animation-rate rendering — the driver has no other window into + * its retention. Not API; shape may change. */ + __stats: function () { + return { + nodes: nodes.size, + listeners: listeners.size, + mountedRoots: mountedRoots.size, + }; + }, /** Called by Swift each frame with a JSArray of patch objects. * Each patch is applied in its own try/catch: one bad handle must not * abort the rest of the frame (a half-applied batch is strictly worse diff --git a/examples/SwiflowUIDemo/swiflow-driver.js b/examples/SwiflowUIDemo/swiflow-driver.js index 51ed49a2..390f8535 100644 --- a/examples/SwiflowUIDemo/swiflow-driver.js +++ b/examples/SwiflowUIDemo/swiflow-driver.js @@ -414,6 +414,17 @@ } window.swiflow = { + /** Test/diagnostic introspection: live sizes of the driver's node, + * listener, and mount-root maps. Leak soaks assert these stay bounded + * under animation-rate rendering — the driver has no other window into + * its retention. Not API; shape may change. */ + __stats: function () { + return { + nodes: nodes.size, + listeners: listeners.size, + mountedRoots: mountedRoots.size, + }; + }, /** Called by Swift each frame with a JSArray of patch objects. * Each patch is applied in its own try/catch: one bad handle must not * abort the rest of the frame (a half-applied batch is strictly worse diff --git a/examples/TodoCRUD/swiflow-driver.js b/examples/TodoCRUD/swiflow-driver.js index 51ed49a2..390f8535 100644 --- a/examples/TodoCRUD/swiflow-driver.js +++ b/examples/TodoCRUD/swiflow-driver.js @@ -414,6 +414,17 @@ } window.swiflow = { + /** Test/diagnostic introspection: live sizes of the driver's node, + * listener, and mount-root maps. Leak soaks assert these stay bounded + * under animation-rate rendering — the driver has no other window into + * its retention. Not API; shape may change. */ + __stats: function () { + return { + nodes: nodes.size, + listeners: listeners.size, + mountedRoots: mountedRoots.size, + }; + }, /** Called by Swift each frame with a JSArray of patch objects. * Each patch is applied in its own try/catch: one bad handle must not * abort the rest of the frame (a half-applied batch is strictly worse diff --git a/js-driver/swiflow-driver.js b/js-driver/swiflow-driver.js index 51ed49a2..390f8535 100644 --- a/js-driver/swiflow-driver.js +++ b/js-driver/swiflow-driver.js @@ -414,6 +414,17 @@ } window.swiflow = { + /** Test/diagnostic introspection: live sizes of the driver's node, + * listener, and mount-root maps. Leak soaks assert these stay bounded + * under animation-rate rendering — the driver has no other window into + * its retention. Not API; shape may change. */ + __stats: function () { + return { + nodes: nodes.size, + listeners: listeners.size, + mountedRoots: mountedRoots.size, + }; + }, /** Called by Swift each frame with a JSArray of patch objects. * Each patch is applied in its own try/catch: one bad handle must not * abort the rest of the frame (a half-applied batch is strictly worse diff --git a/js-driver/test/opcodes.test.js b/js-driver/test/opcodes.test.js index 1e466abe..0671d8ba 100644 --- a/js-driver/test/opcodes.test.js +++ b/js-driver/test/opcodes.test.js @@ -46,19 +46,35 @@ describe("driver opcodes", () => { assert.match(div.innerHTML, /bold<\/b>.*italic<\/i>/); }); - test("destroyNode drops the map entry; re-destroying is a no-op", () => { - const { swiflow, document } = setupDriver(); + test("destroyNode detaches tracked listeners and drops the map entries; re-destroying is a no-op", () => { + const { swiflow, window, document } = setupDriver(); + let callCount = 0; + window.__swiflowDispatch = () => { callCount += 1; }; swiflow.applyPatches([ { op: "createElement", handle: 1, tag: "div" }, - { op: "createElement", handle: 2, tag: "span" }, + { op: "createElement", handle: 2, tag: "button" }, { op: "appendChild", parent: 1, child: 2 }, + { op: "addHandler", handle: 2, event: "click", handlerId: 2 }, + // Handle 12 shares the "2" digit prefix: the key parse must be + // numeric, or destroying handle 2 would also sweep 12's listener. + { op: "createElement", handle: 12, tag: "button" }, + { op: "appendChild", parent: 1, child: 12 }, + { op: "addHandler", handle: 12, event: "click", handlerId: 12 }, ]); swiflow.mount(1, "#app"); - assert.equal(document.querySelector("div").children.length, 1); - swiflow.applyPatches([{ op: "destroyNode", handle: 2 }]); + const [btn2, btn12] = document.querySelectorAll("button"); + btn2.click(); + btn12.click(); + assert.equal(callCount, 2); swiflow.applyPatches([{ op: "destroyNode", handle: 2 }]); - // No DOM-removal assertion — destroyNode drops the driver's map - // entry only. removeChild is what removes from DOM. + // destroyNode does NOT remove from the DOM (removeChild does) — but its + // listener must be gone even while the node is still attached… + btn2.click(); + assert.equal(callCount, 2, "destroyed handle's listener must not fire"); + // …and the numeric prefix parse must leave handle 12's listener alive. + btn12.click(); + assert.equal(callCount, 3, "handle 12 must survive destroying handle 2"); + swiflow.applyPatches([{ op: "destroyNode", handle: 2 }]); // no-op }); test("animateExit detaches listeners immediately, removes the node after the window", async () => { @@ -491,3 +507,162 @@ describe("driver opcodes", () => { assert.equal(document.querySelector("a").namespaceURI, "http://www.w3.org/1999/xhtml"); }); }); + +// serializeEvent field fidelity + the innerHTML defence + select mount order — +// the "declared vs applied" holes: each of these is observable ONLY at the +// driver layer (VNode-dict tests can't see what the DOM actually received). +describe("driver event serialization and application order", () => { + + test("serializeEvent carries targetValue from an input event", () => { + const { swiflow, window, document } = setupDriver(); + const payloads = []; + window.__swiflowDispatch = (_id, p) => payloads.push(p); + swiflow.applyPatches([ + { op: "createElement", handle: 1, tag: "input" }, + { op: "addHandler", handle: 1, event: "input", handlerId: 1 }, + ]); + swiflow.mount(1, "#app"); + const input = document.querySelector("input"); + input.value = "hello"; + input.dispatchEvent(new window.Event("input", { bubbles: true })); + assert.equal(payloads.length, 1); + assert.equal(payloads[0].targetValue, "hello"); + assert.equal(payloads[0].isSelfTarget, true); + assert.equal(payloads[0].type, "input"); + }); + + test("serializeEvent carries targetChecked from a checkbox change", () => { + const { swiflow, window, document } = setupDriver(); + const payloads = []; + window.__swiflowDispatch = (_id, p) => payloads.push(p); + swiflow.applyPatches([ + { op: "createElement", handle: 1, tag: "input" }, + { op: "setAttribute", handle: 1, name: "type", value: "checkbox" }, + { op: "addHandler", handle: 1, event: "change", handlerId: 1 }, + ]); + swiflow.mount(1, "#app"); + const box = document.querySelector("input"); + box.checked = true; + box.dispatchEvent(new window.Event("change", { bubbles: true })); + assert.equal(payloads[0].targetChecked, true); + }); + + test("serializeEvent: isSelfTarget is false for a bubbled event, true on the target", () => { + const { swiflow, window, document } = setupDriver(); + const payloads = []; + window.__swiflowDispatch = (_id, p) => payloads.push(p); + swiflow.applyPatches([ + { op: "createElement", handle: 1, tag: "div" }, + { op: "createElement", handle: 2, tag: "span" }, + { op: "appendChild", parent: 1, child: 2 }, + { op: "addHandler", handle: 1, event: "click", handlerId: 1 }, + ]); + swiflow.mount(1, "#app"); + // NB: querySelector("div") would match #app itself — resolve the + // mounted div through the span instead. + const span = document.querySelector("span"); + const div = span.parentNode; + // Click the CHILD: the div's handler sees a bubbled event (backdrop- + // click dismissal is built on exactly this distinction). + span.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); + div.dispatchEvent(new window.MouseEvent("click", { bubbles: false })); + assert.equal(payloads.length, 2); + assert.equal(payloads[0].isSelfTarget, false); + assert.equal(payloads[1].isSelfTarget, true); + }); + + test("serializeEvent carries key and all four modifier flags", () => { + const { swiflow, window, document } = setupDriver(); + const payloads = []; + window.__swiflowDispatch = (_id, p) => payloads.push(p); + swiflow.applyPatches([ + { op: "createElement", handle: 1, tag: "input" }, + { op: "addHandler", handle: 1, event: "keydown", handlerId: 1 }, + ]); + swiflow.mount(1, "#app"); + document.querySelector("input").dispatchEvent(new window.KeyboardEvent("keydown", { + key: "Enter", shiftKey: true, ctrlKey: true, altKey: false, metaKey: true, + bubbles: true, + })); + const p = payloads[0]; + assert.equal(p.key, "Enter"); + assert.equal(p.shiftKey, true); + assert.equal(p.ctrlKey, true); + assert.equal(p.altKey, false); + assert.equal(p.metaKey, true); + }); + + test("setProperty refuses innerHTML: the patch fails, the batch reports false", () => { + const { swiflow, document } = setupDriver(); + swiflow.applyPatches([{ op: "createElement", handle: 1, tag: "div" }]); + swiflow.mount(1, "#app"); + const ok = swiflow.applyPatches([ + { op: "setProperty", handle: 1, name: "innerHTML", value: "" }, + ]); + assert.equal(ok, false, "the batch must report the refused patch"); + assert.equal(document.querySelector("#app").firstElementChild.innerHTML, "", + "no markup may land through setProperty"); + }); + + test("select: a value property applied before options loses to the first option (the mount-order gotcha); the selected attribute wins", () => { + const { swiflow, document } = setupDriver(); + // Arm 1 — value-before-options: the property write happens while the + // select has no options, so the browser keeps the first option once + // options land. This pins WHY Select must emit `selected` on the chosen + // option instead of relying on the bound value property. + swiflow.applyPatches([ + { op: "createElement", handle: 1, tag: "select" }, + { op: "setProperty", handle: 1, name: "value", value: "b" }, + { op: "createElement", handle: 2, tag: "option" }, + { op: "setAttribute", handle: 2, name: "value", value: "a" }, + { op: "createElement", handle: 3, tag: "option" }, + { op: "setAttribute", handle: 3, name: "value", value: "b" }, + { op: "appendChild", parent: 1, child: 2 }, + { op: "appendChild", parent: 1, child: 3 }, + ]); + swiflow.mount(1, "#app"); + assert.equal(document.querySelector("select").value, "a", + "value-before-options: first option wins — the documented gotcha"); + + // Arm 2 — the fix's mechanism: `selected` on the intended option. + const { swiflow: sw2, document: doc2 } = setupDriver(); + sw2.applyPatches([ + { op: "createElement", handle: 1, tag: "select" }, + { op: "createElement", handle: 2, tag: "option" }, + { op: "setAttribute", handle: 2, name: "value", value: "a" }, + { op: "createElement", handle: 3, tag: "option" }, + { op: "setAttribute", handle: 3, name: "value", value: "b" }, + { op: "setAttribute", handle: 3, name: "selected", value: "" }, + { op: "appendChild", parent: 1, child: 2 }, + { op: "appendChild", parent: 1, child: 3 }, + ]); + sw2.mount(1, "#app"); + assert.equal(doc2.querySelector("select").value, "b", + "the selected attribute picks the bound option regardless of order"); + }); + + test("__stats reports live map sizes and shrinks after teardown", async () => { + const { swiflow, window } = setupDriver(); + window.__swiflowDispatch = () => {}; + swiflow.applyPatches([ + { op: "createElement", handle: 1, tag: "div" }, + { op: "createElement", handle: 2, tag: "button" }, + { op: "appendChild", parent: 1, child: 2 }, + { op: "addHandler", handle: 2, event: "click", handlerId: 1 }, + ]); + swiflow.mount(1, "#app"); + // Field-by-field: the stats object crosses the jsdom vm realm, so + // deepEqual would reject it on prototype identity alone. + const before = swiflow.__stats(); + assert.equal(before.nodes, 2); + assert.equal(before.listeners, 1); + assert.equal(before.mountedRoots, 1); + swiflow.applyPatches([ + { op: "animateExit", handle: 2, parentHandle: 1, animation: "out 0.1s", durationMs: 0 }, + ]); + await new Promise((r) => setTimeout(r, 0)); + const after = swiflow.__stats(); + assert.equal(after.nodes, 1); + assert.equal(after.listeners, 0, "the exit-animated node's listener entry is evicted"); + }); +});