Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions Sources/SwiflowCLI/EmbeddedDriver.swift

Large diffs are not rendered by default.

62 changes: 62 additions & 0 deletions Tests/playwright/autocomplete.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
29 changes: 29 additions & 0 deletions Tests/playwright/gridboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
1 change: 1 addition & 0 deletions Tests/playwright/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
],
Expand Down
2 changes: 1 addition & 1 deletion Tests/playwright/playwright.swiflowui.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
11 changes: 11 additions & 0 deletions examples/AsyncFetch/swiflow-driver.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions examples/EdgeCases/swiflow-driver.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions examples/HelloWorld/swiflow-driver.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions examples/QueryDemo/swiflow-driver.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions examples/RegionDemo/swiflow-driver.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions examples/SwiflowUIDemo/swiflow-driver.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions examples/TodoCRUD/swiflow-driver.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions js-driver/swiflow-driver.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading