From 03c4623fad163d91c6bf5ce00bfd29cacccc3789 Mon Sep 17 00:00:00 2001 From: Ophir Lojkine Date: Wed, 7 Oct 2026 09:47:50 +0000 Subject: [PATCH 1/5] Initialize SQLPage fragments incrementally and only once --- CHANGELOG.md | 1 + .../migrations/31_card_docs_update.sql | 7 +- frontend/src/apexcharts.ts | 8 +- frontend/src/init.ts | 24 +- frontend/src/sqlpage.ts | 83 +++--- frontend/src/tomselect.ts | 7 +- .../fixtures/fragment-loaded/chart.sql | 2 + .../fixtures/fragment-loaded/form.sql | 5 + .../fixtures/fragment-loaded/index.sql | 8 +- .../fixtures/fragment-loaded/map.sql | 1 + .../fixtures/fragment-loaded/table.sql | 2 + .../fixtures/fragment-loaded/test.ts | 249 ++++++++++++++++-- .../fixtures/fragment-loaded/tooltip.sql | 2 + .../fixtures/fragment-loaded/widgets.sql | 18 ++ 14 files changed, 339 insertions(+), 78 deletions(-) create mode 100644 tests/end-to-end/fixtures/fragment-loaded/chart.sql create mode 100644 tests/end-to-end/fixtures/fragment-loaded/form.sql create mode 100644 tests/end-to-end/fixtures/fragment-loaded/map.sql create mode 100644 tests/end-to-end/fixtures/fragment-loaded/table.sql create mode 100644 tests/end-to-end/fixtures/fragment-loaded/tooltip.sql create mode 100644 tests/end-to-end/fixtures/fragment-loaded/widgets.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 10ad050af..a17c14411 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # CHANGELOG.md ## v0.47.0 (unreleased) +- Loading embedded card content no longer adds duplicate form submission or file validation handlers to existing fields. Fragment initialization also includes the element announcing the fragment. - **Mac users:** the downloadable `sqlpage-macos.tgz` now runs natively on Apple silicon (M-series Macs) and no longer runs on Intel Macs. Homebrew remains the recommended and easiest installation method. On an Intel Mac, [install Homebrew](https://brew.sh/) if needed, then run `brew install sqlpage` (or `brew update` followed by `brew upgrade sqlpage` if you already installed it with Homebrew). Open Terminal in your existing website folder and run `sqlpage` instead of `./sqlpage.bin`; keep your SQL files, database, and `sqlpage` configuration folder in place. Intel installations may build from source and take longer; see the [macOS installation guide](https://sql-page.com/your-first-sql-website/?os=macos#download) for setup and older macOS requirements. - Chart data points can now include a `link`. Clicking a point or its tooltip value opens that URL; a text x value in the tooltip title links to it too. The tooltip remains open while the pointer moves onto the link. ApexCharts was updated to [v7.6.0](https://github.com/apexcharts/apexcharts.js/releases/tag/v7.6.0) to support this. - Updated sqlx-oldapi to v0.6.57 to fix SQL Server fallback expressions such as `ISNULL($missing, 'default')` truncating defaults or failing for date values when the bound variable is `NULL`. diff --git a/examples/official-site/sqlpage/migrations/31_card_docs_update.sql b/examples/official-site/sqlpage/migrations/31_card_docs_update.sql index 2cdaa11ad..1b813b8a9 100644 --- a/examples/official-site/sqlpage/migrations/31_card_docs_update.sql +++ b/examples/official-site/sqlpage/migrations/31_card_docs_update.sql @@ -39,7 +39,12 @@ INSERT INTO parameter(component, name, description_md, type, top_level, optional ('embed', 'A url whose contents will be fetched and injected into the body of this card. This can be used to inject arbitrary html content, but is especially useful for injecting the output of other sql files rendered by SQLPage. For the latter case you can pass the - `?_sqlpage_embed` query parameter, which will skip the shell layout', 'TEXT', FALSE, TRUE), + `?_sqlpage_embed` query parameter, which will skip the shell layout. + Components inside the fetched content initialize automatically. Custom scripts that insert + SQLPage fragments should dispatch a bubbling `fragment-loaded` event on the element containing + the new content. Initialization includes that element and its descendants; dispatching the + event on `document` initializes pending components throughout the page. Repeated events + preserve existing form and widget handlers', 'TEXT', FALSE, TRUE), ('embed_mode', 'Set to ''iframe'' to embed the target (specified through embed property) in an iframe. Unless this is explicitly set, the embed target is fetched and injected within the parent page. If embed_mode is set to iframe, You can also set height and width parameters to configure the appearance and the sandbox and allow parameters to configure diff --git a/frontend/src/apexcharts.ts b/frontend/src/apexcharts.ts index 3098f755c..8cfd29978 100644 --- a/frontend/src/apexcharts.ts +++ b/frontend/src/apexcharts.ts @@ -7,7 +7,7 @@ import { type Series, xaxis_type_for, } from "./chart_series.ts"; -import { add_init_fn } from "./init.ts"; +import { add_init_fn, type InitRoot, select_all } from "./init.ts"; type DataPoint = { name: string; @@ -51,10 +51,8 @@ const numberLabel = (value: string | number) => value == null ? "" : value.toLocaleString?.() || String(value); const sqlpage_chart = (() => { - function sqlpage_chart() { - const charts = document.querySelectorAll( - "[data-pre-init=chart]", - ); + function sqlpage_chart(root: InitRoot) { + const charts = select_all(root, "[data-pre-init=chart]"); for (const c of charts) { try { build_sqlpage_chart(c); diff --git a/frontend/src/init.ts b/frontend/src/init.ts index 4c3452219..028cb3981 100644 --- a/frontend/src/init.ts +++ b/frontend/src/init.ts @@ -1,5 +1,21 @@ -export function add_init_fn(f: () => void) { - document.addEventListener("DOMContentLoaded", f); - document.addEventListener("fragment-loaded", f); - if (document.readyState !== "loading") setTimeout(f, 0); +export type InitRoot = Element | Document; + +/** Select matching descendants, including the fragment root itself. */ +export function select_all( + root: InitRoot, + selector: string, +): T[] { + const descendants = [...root.querySelectorAll(selector)]; + if (root instanceof Element && root.matches(selector)) { + descendants.unshift(root as T); + } + return descendants; +} + +export function add_init_fn(f: (root: InitRoot) => void) { + document.addEventListener("DOMContentLoaded", () => f(document)); + document.addEventListener("fragment-loaded", ({ target }) => { + if (target instanceof Element || target instanceof Document) f(target); + }); + if (document.readyState !== "loading") setTimeout(() => f(document), 0); } diff --git a/frontend/src/sqlpage.ts b/frontend/src/sqlpage.ts index f06685a7e..2452a7eeb 100644 --- a/frontend/src/sqlpage.ts +++ b/frontend/src/sqlpage.ts @@ -1,6 +1,6 @@ import { bootstrap as bundled_bootstrap } from "@tabler/core"; import type * as Leaflet from "leaflet"; -import { add_init_fn } from "./init.ts"; +import { add_init_fn, type InitRoot, select_all } from "./init.ts"; // A page may load its own Bootstrap; prefer it over the bundled copy. const page_bootstrap = () => window.bootstrap ?? bundled_bootstrap; @@ -14,8 +14,8 @@ type ModalWidget = InstanceType; const nonce = (document.currentScript as HTMLScriptElement).nonce; -function sqlpage_card() { - const cards = document.querySelectorAll("[data-pre-init=card]"); +function sqlpage_card(root: InitRoot) { + const cards = select_all(root, "[data-pre-init=card]"); for (const c of cards) { c.removeAttribute("data-pre-init"); if (!c.dataset.embed) continue; @@ -173,10 +173,8 @@ function setup_sort_behavior( }); } -function sqlpage_table() { - const tables = document.querySelectorAll( - "[data-pre-init=table]", - ); +function sqlpage_table(root: InitRoot) { + const tables = select_all(root, "[data-pre-init=table]"); for (const r of tables) { r.removeAttribute("data-pre-init"); try { @@ -199,8 +197,8 @@ type MarkerStyle = Leaflet.MarkerOptions & let is_leaflet_injected = false; let is_leaflet_loaded = false; -function sqlpage_map() { - const first_map = document.querySelector("[data-pre-init=map]"); +function sqlpage_map(root: InitRoot) { + const first_map = select_all(root, "[data-pre-init=map]")[0]; const leaflet_base_url = "https://cdn.jsdelivr.net/npm/leaflet@1.9.4"; if (first_map && !is_leaflet_injected) { // Add the leaflet js and css to the page @@ -217,12 +215,14 @@ function sqlpage_map() { "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="; leaflet_js.crossOrigin = "anonymous"; leaflet_js.nonce = nonce; - leaflet_js.onload = onLeafletLoad; + // More fragments may arrive while Leaflet is loading. Initialize all + // pending maps when the shared dependency first becomes available. + leaflet_js.onload = () => onLeafletLoad(document); document.head.appendChild(leaflet_js); is_leaflet_injected = true; } if (first_map && is_leaflet_loaded) { - onLeafletLoad(); + onLeafletLoad(root); } function parseCoords( coords: string | undefined, @@ -237,9 +237,9 @@ function sqlpage_map() { } return [parsed[0], parsed[1]]; } - function onLeafletLoad() { + function onLeafletLoad(map_root: InitRoot) { is_leaflet_loaded = true; - const maps = document.querySelectorAll("[data-pre-init=map]"); + const maps = select_all(map_root, "[data-pre-init=map]"); for (const m of maps) { const tile_source = m.dataset.tile_source; const maxZoom = Number(m.dataset.max_zoom); @@ -344,11 +344,17 @@ function sqlpage_map() { } } -function sqlpage_form() { - const file_inputs = document.querySelectorAll( +const initialized_file_inputs = new WeakSet(); +const initialized_auto_submit_forms = new WeakSet(); + +function sqlpage_form(root: InitRoot) { + const file_inputs = select_all( + root, "input[type=file][data-max-size]", ); for (const input of file_inputs) { + if (initialized_file_inputs.has(input)) continue; + initialized_file_inputs.add(input); const max_size = Number(input.dataset.maxSize); input.addEventListener("change", () => { input.classList.remove("is-invalid"); @@ -364,10 +370,13 @@ function sqlpage_form() { }); } - const auto_submit_forms = document.querySelectorAll( + const auto_submit_forms = select_all( + root, "form[data-auto-submit]", ); for (const form of auto_submit_forms) { + if (initialized_auto_submit_forms.has(form)) continue; + initialized_auto_submit_forms.add(form); form.addEventListener("change", () => form.submit()); } } @@ -378,8 +387,8 @@ function get_tabler_color(name: string) { ); } -function load_scripts() { - const addjs = document.querySelectorAll("[data-sqlpage-js]"); +function load_scripts(root: InitRoot) { + const addjs = select_all(root, "[data-sqlpage-js]"); const existing_scripts = new Set( [...document.querySelectorAll("script")].map((s) => s.src), ); @@ -429,13 +438,11 @@ function restore_focus_after_toast(toast: HTMLElement, container: HTMLElement) { main.focus({ preventScroll: true }); } -function sqlpage_toast() { +function sqlpage_toast(root: InitRoot) { const Toast = page_bootstrap().Toast; const initialized_toasts: HTMLElement[] = []; - const toasts = document.querySelectorAll( - '[data-pre-init="toast"]', - ); + const toasts = select_all(root, '[data-pre-init="toast"]'); for (const toast of toasts) { const source_container = toast.parentElement; if (!source_container) continue; @@ -479,7 +486,7 @@ function sqlpage_toast() { open_toasts_for_hash(initialized_toasts); } -function sqlpage_modal() { +function sqlpage_modal(root: InitRoot) { // Bootstrap modals use position: fixed and are documented to live as // direct children of // (https://getbootstrap.com/docs/5.3/components/modal/#how-it-works). @@ -488,7 +495,7 @@ function sqlpage_modal() { // .page instead of the viewport. The modal then scrolls with the page // content and ends up behind its own backdrop, so its buttons cannot be // clicked. Moving modals to keeps them viewport-fixed. - for (const modal of document.querySelectorAll("body .page .modal")) { + for (const modal of select_all(root, "body .page .modal")) { document.body.appendChild(modal); } } @@ -506,34 +513,32 @@ window.addEventListener("hashchange", () => ), ); -function init_bootstrap_components(fragment: Element | Document) { +function init_bootstrap_components(root: InitRoot) { const bootstrap = page_bootstrap(); - for (const el of fragment.querySelectorAll( + for (const el of select_all( + root, '[data-bs-toggle="tooltip"]', )) { - new bootstrap.Tooltip(el); + bootstrap.Tooltip.getOrCreateInstance(el); } - for (const el of fragment.querySelectorAll( + for (const el of select_all( + root, '[data-bs-toggle="popover"]', )) { - new bootstrap.Popover(el); + bootstrap.Popover.getOrCreateInstance(el); } - for (const el of fragment.querySelectorAll( + for (const el of select_all( + root, '[data-bs-toggle="dropdown"]', )) { - new bootstrap.Dropdown(el); + bootstrap.Dropdown.getOrCreateInstance(el); } - for (const el of fragment.querySelectorAll( - '[data-bs-ride="carousel"]', - )) { - new bootstrap.Carousel(el); + for (const el of select_all(root, '[data-bs-ride="carousel"]')) { + bootstrap.Carousel.getOrCreateInstance(el); } } -document.addEventListener("fragment-loaded", ({ target }) => { - if (target instanceof Element || target instanceof Document) - init_bootstrap_components(target); -}); +add_init_fn(init_bootstrap_components); function open_modal_for_hash() { const hash = window.location.hash.substring(1); diff --git a/frontend/src/tomselect.ts b/frontend/src/tomselect.ts index a7ceaacfe..21b01bc16 100644 --- a/frontend/src/tomselect.ts +++ b/frontend/src/tomselect.ts @@ -1,8 +1,9 @@ import TomSelect from "tom-select/popular"; -import { add_init_fn } from "./init.ts"; +import { add_init_fn, type InitRoot, select_all } from "./init.ts"; -function sqlpage_select_dropdown() { - const selects = document.querySelectorAll( +function sqlpage_select_dropdown(root: InitRoot) { + const selects = select_all( + root, "[data-pre-init=select-dropdown]", ); for (const s of selects) { diff --git a/tests/end-to-end/fixtures/fragment-loaded/chart.sql b/tests/end-to-end/fixtures/fragment-loaded/chart.sql new file mode 100644 index 000000000..fbb0e1a38 --- /dev/null +++ b/tests/end-to-end/fixtures/fragment-loaded/chart.sql @@ -0,0 +1,2 @@ +SELECT 'chart' AS component, 'root-chart' AS id, 'column' AS type; +SELECT 'A' AS x, 1 AS y; diff --git a/tests/end-to-end/fixtures/fragment-loaded/form.sql b/tests/end-to-end/fixtures/fragment-loaded/form.sql new file mode 100644 index 000000000..4eb3415d3 --- /dev/null +++ b/tests/end-to-end/fixtures/fragment-loaded/form.sql @@ -0,0 +1,5 @@ +SELECT 'form' AS component, 'root-form' AS id, 'Fragment form' AS title, TRUE AS auto_submit; +SELECT 'root_value' AS name, 'Root value' AS label; +SELECT 'root_file' AS name, 'Root file' AS label, 'file' AS type; +SELECT 'root_select' AS name, 'Root select' AS label, 'select' AS type, TRUE AS searchable, + '[{"label":"One","value":"one"},{"label":"Two","value":"two"}]' AS options; diff --git a/tests/end-to-end/fixtures/fragment-loaded/index.sql b/tests/end-to-end/fixtures/fragment-loaded/index.sql index 59b04c74a..d8db2c13e 100644 --- a/tests/end-to-end/fixtures/fragment-loaded/index.sql +++ b/tests/end-to-end/fixtures/fragment-loaded/index.sql @@ -1 +1,7 @@ -SELECT 'text' AS component, 'Markup added after load is initialized when a fragment is announced.' AS contents; +SELECT 'form' AS component, 'existing-form' AS id, 'Existing form' AS title, TRUE AS auto_submit; +SELECT 'existing_value' AS name, 'Existing value' AS label; +SELECT 'existing_file' AS name, 'Existing file' AS label, 'file' AS type; + +SELECT 'card' AS component; +SELECT 'First fragment' AS title, '/fragment-loaded/widgets.sql?instance=first' AS embed; +SELECT 'Second fragment' AS title, '/fragment-loaded/widgets.sql?instance=second' AS embed; diff --git a/tests/end-to-end/fixtures/fragment-loaded/map.sql b/tests/end-to-end/fixtures/fragment-loaded/map.sql new file mode 100644 index 000000000..5504a11c5 --- /dev/null +++ b/tests/end-to-end/fixtures/fragment-loaded/map.sql @@ -0,0 +1 @@ +SELECT 'map' AS component, coalesce($instance, 'root-map') AS id, 51.5 AS latitude, 10 AS longitude, 4 AS zoom; diff --git a/tests/end-to-end/fixtures/fragment-loaded/table.sql b/tests/end-to-end/fixtures/fragment-loaded/table.sql new file mode 100644 index 000000000..66771976f --- /dev/null +++ b/tests/end-to-end/fixtures/fragment-loaded/table.sql @@ -0,0 +1,2 @@ +SELECT 'table' AS component, 'root-table' AS id, TRUE AS search, TRUE AS sort; +SELECT 'Beta' AS name UNION ALL SELECT 'Alpha' AS name; diff --git a/tests/end-to-end/fixtures/fragment-loaded/test.ts b/tests/end-to-end/fixtures/fragment-loaded/test.ts index b82c3298e..87f256346 100644 --- a/tests/end-to-end/fixtures/fragment-loaded/test.ts +++ b/tests/end-to-end/fixtures/fragment-loaded/test.ts @@ -1,40 +1,239 @@ import { expect, type Page, test } from "../../fixture.ts"; -const INJECTED_HINT = "injected hint"; - -async function addTooltip(page: Page) { - await page.evaluate((hint) => { - const span = document.createElement("span"); - span.id = "added"; - span.textContent = "added"; - span.setAttribute("data-bs-toggle", "tooltip"); - span.setAttribute("title", hint); - document.querySelector("main")?.appendChild(span); - }, INJECTED_HINT); +/** Load server-rendered SQL output as a custom fragment consumer would. */ +async function appendFragment( + page: Page, + file: string, + root?: string, + instance?: string, +) { + await page.evaluate( + async ({ file, root, instance }) => { + const url = new URL(`/fragment-loaded/${file}.sql`, location.href); + url.searchParams.set("_sqlpage_embed", "1"); + if (instance) url.searchParams.set("instance", instance); + const response = await fetch(url); + if (!response.ok) + throw new Error(`Fragment request failed: ${response.status}`); + const fragment = document.createElement("template"); + fragment.innerHTML = await response.text(); + document.querySelector("main")?.append(fragment.content); + if (root) { + const target = + root === "document" ? document : document.querySelector(root); + if (!target) throw new Error(`Missing fragment root: ${root}`); + target.dispatchEvent( + new CustomEvent("fragment-loaded", { bubbles: true }), + ); + } + }, + { file, root, instance }, + ); +} + +async function countSubmissions(page: Page, selector: string) { + await page.locator(selector).evaluate((element) => { + const form = element as HTMLFormElement; + form.dataset.submissions = "0"; + form.submit = () => { + form.dataset.submissions = String(Number(form.dataset.submissions) + 1); + }; + }); +} + +// Card fetches and their chart/select scripts are asynchronous. Wait until +// those scripts complete their initial document pass before testing local events. +test.beforeEach(async ({ page }) => { + await expect(page.locator(".apexcharts-canvas")).toHaveCount(2); + await expect(page.locator(".ts-wrapper")).toHaveCount(2); +}); + +for (const root of ["document", "main", "#added"]) { + test(`initializes a tooltip when ${root} announces a fragment`, async ({ + page, + }) => { + await appendFragment(page, "tooltip", root); + // Re-announcing a fragment must retain the existing Bootstrap instance. + await page.evaluate(() => + document.dispatchEvent(new CustomEvent("fragment-loaded")), + ); + await page.locator("#added").hover(); + await expect(page.locator(".tooltip")).toHaveText("injected hint"); + await page.getByRole("heading", { name: "Existing form" }).hover(); + await expect(page.locator(".tooltip")).toHaveCount(0); + }); } -test("shows a tooltip added before the document announces a fragment", async ({ +test("initializes card fragments and keeps existing form handlers singular", async ({ page, }) => { - await addTooltip(page); - await page.evaluate(() => - document.dispatchEvent(new CustomEvent("fragment-loaded")), + await expect(page.locator(".apexcharts-canvas")).toHaveCount(2); + await expect(page.locator(".ts-wrapper")).toHaveCount(2); + await expect(page.locator("body > #first-modal")).toBeAttached(); + await expect(page.locator("body > #second-modal")).toBeAttached(); + await expect( + page.getByText("first notification", { exact: true }), + ).toBeVisible(); + await expect( + page.getByText("second notification", { exact: true }), + ).toBeVisible(); + await countSubmissions(page, "#existing-form"); + await countSubmissions(page, "#first-form"); + await page.getByLabel("Existing value", { exact: true }).fill("one change"); + await page.getByLabel("Existing value", { exact: true }).blur(); + await expect(page.locator("#existing-form")).toHaveAttribute( + "data-submissions", + "1", + ); + + await page.evaluate(() => { + for (let i = 0; i < 3; i++) + document.dispatchEvent(new CustomEvent("fragment-loaded")); + const input = document.querySelector( + 'input[name="existing_file"]', + ); + if (!input) throw new Error("Missing file input"); + const setValidity = input.setCustomValidity.bind(input); + input.dataset.validations = "0"; + input.setCustomValidity = (message) => { + input.dataset.validations = String(Number(input.dataset.validations) + 1); + setValidity(message); + }; + }); + await page.getByLabel("Existing file", { exact: true }).setInputFiles({ + name: "small.txt", + mimeType: "text/plain", + buffer: Buffer.from("small"), + }); + await expect( + page.getByLabel("Existing file", { exact: true }), + ).toHaveAttribute("data-validations", "1"); + await expect(page.locator("#existing-form")).toHaveAttribute( + "data-submissions", + "2", + ); + await page.getByLabel("first value", { exact: true }).fill("changed"); + await page.getByLabel("first value", { exact: true }).blur(); + await expect(page.locator("#first-form")).toHaveAttribute( + "data-submissions", + "1", + ); + + await page.locator("#first-table input.search").fill("Alpha"); + await expect(page.locator("#first-table tbody tr:visible")).toHaveCount(1); + await page.locator("#first-table input.search").fill(""); + await page.locator("#first-table button.sort").click(); + await expect(page.locator("#first-table tbody tr").first()).toContainText( + "Alpha", ); - await page.locator("#added").hover(); + await page.getByRole("button", { name: "first choices" }).click(); + await expect(page.locator(".dropdown-menu.show")).toBeVisible(); + await page.getByRole("button", { name: "first choices" }).click(); + await expect(page.locator(".dropdown-menu.show")).toHaveCount(0); + await page.evaluate(() => { + window.location.hash = "first-modal"; + }); + await expect(page.getByRole("dialog")).toBeVisible(); + await page + .getByRole("dialog") + .getByRole("button", { name: "close", exact: true }) + .click(); + await expect(page.getByRole("dialog")).toHaveCount(0); +}); - await expect(page.locator(".tooltip")).toHaveText(INJECTED_HINT); +test("initializes a form root and its searchable select", async ({ page }) => { + await appendFragment(page, "form", "#root-form"); + await expect(page.locator("#root-form .ts-wrapper")).toBeAttached(); + await countSubmissions(page, "#root-form"); + await page.getByLabel("Root value", { exact: true }).fill("changed"); + await page.getByLabel("Root value", { exact: true }).blur(); + await expect(page.locator("#root-form")).toHaveAttribute( + "data-submissions", + "1", + ); }); -test("shows a tooltip added before an element announces a fragment", async ({ +test("initializes a file input root without initializing its surrounding form", async ({ page, }) => { - await addTooltip(page); - await page.evaluate(() => - document - .querySelector("main") - ?.dispatchEvent(new CustomEvent("fragment-loaded", { bubbles: true })), + await appendFragment(page, "form", 'input[name="root_file"]'); + await countSubmissions(page, "#root-form"); + await page.getByLabel("Root file", { exact: true }).setInputFiles({ + name: "small.txt", + mimeType: "text/plain", + buffer: Buffer.from("small"), + }); + await expect(page.locator("#root-form")).toHaveAttribute( + "data-submissions", + "0", + ); + await expect( + page.locator("#root-form [data-pre-init=select-dropdown]"), + ).toBeAttached(); + const input = page.getByLabel("Root file", { exact: true }); + const maxSize = await input.getAttribute("data-max-size"); + await input.setInputFiles({ + name: "large.txt", + mimeType: "text/plain", + buffer: Buffer.alloc(Number(maxSize) + 1), + }); + await expect(input).toHaveClass(/is-invalid/); + await expect(input).toHaveJSProperty( + "validationMessage", + `File size must be less than ${Number(maxSize) / 1000} kB.`, ); - await page.locator("#added").hover(); +}); + +test("initializes the root table and chart without initializing unrelated fragments", async ({ + page, +}) => { + await appendFragment(page, "form"); + await appendFragment(page, "table", "#root-table [data-pre-init=table]"); + await page.locator("#root-table input.search").fill("Alpha"); + await expect(page.locator("#root-table tbody tr:visible")).toHaveCount(1); + await appendFragment(page, "chart", "#root-chart"); + await expect(page.locator("#root-chart .apexcharts-canvas")).toBeAttached(); + await expect( + page.locator("#root-form [data-pre-init=select-dropdown]"), + ).toBeAttached(); + await page.locator("#root-form select").evaluate((element) => { + element.dispatchEvent( + new CustomEvent("fragment-loaded", { bubbles: true }), + ); + }); + await expect(page.locator("#root-form .ts-wrapper")).toBeAttached(); +}); + +test("initializes a map root after loading Leaflet", async ({ page }) => { + await appendFragment(page, "map", "#root-map .leaflet"); + await expect(page.locator("#root-map .leaflet-map-pane")).toBeAttached(); + await page.locator("#root-map").evaluate((element) => { + element.dispatchEvent( + new CustomEvent("fragment-loaded", { bubbles: true }), + ); + }); + await expect(page.locator("#root-map .leaflet-map-pane")).toHaveCount(1); +}); - await expect(page.locator(".tooltip")).toHaveText(INJECTED_HINT); +test("initializes maps arriving while their shared dependency is pending", async ({ + page, +}) => { + let releaseLeaflet = () => {}; + const dependencyReady = new Promise((resolve) => { + releaseLeaflet = resolve; + }); + await page.route("**/dist/leaflet.js", async (route) => { + await dependencyReady; + await route.continue(); + }); + try { + await appendFragment(page, "map", "#first-map .leaflet", "first-map"); + await appendFragment(page, "map", "#second-map .leaflet", "second-map"); + } finally { + releaseLeaflet(); + } + await expect(page.locator(".leaflet-map-pane")).toHaveCount(2); + // With Leaflet loaded, a later event should initialize only its own root. + await appendFragment(page, "map", "#third-map .leaflet", "third-map"); + await expect(page.locator(".leaflet-map-pane")).toHaveCount(3); }); diff --git a/tests/end-to-end/fixtures/fragment-loaded/tooltip.sql b/tests/end-to-end/fixtures/fragment-loaded/tooltip.sql new file mode 100644 index 000000000..bf07e821b --- /dev/null +++ b/tests/end-to-end/fixtures/fragment-loaded/tooltip.sql @@ -0,0 +1,2 @@ +SELECT 'html' AS component; +SELECT 'added' AS html; diff --git a/tests/end-to-end/fixtures/fragment-loaded/widgets.sql b/tests/end-to-end/fixtures/fragment-loaded/widgets.sql new file mode 100644 index 000000000..4e462c20e --- /dev/null +++ b/tests/end-to-end/fixtures/fragment-loaded/widgets.sql @@ -0,0 +1,18 @@ +SELECT 'form' AS component, concat($instance, '-form') AS id, concat($instance, ' form') AS title, TRUE AS auto_submit; +SELECT concat($instance, '_value') AS name, concat($instance, ' value') AS label; +SELECT concat($instance, '_select') AS name, concat($instance, ' select') AS label, + 'select' AS type, TRUE AS searchable, '[{"label":"One","value":"one"}]' AS options; + +SELECT 'table' AS component, concat($instance, '-table') AS id, TRUE AS search, TRUE AS sort; +SELECT 'Beta' AS name UNION ALL SELECT 'Alpha' AS name; + +SELECT 'chart' AS component, concat($instance, '-chart') AS id, 'column' AS type; +SELECT 'A' AS x, 2 AS y; + +SELECT 'facet' AS component, TRUE AS compact, concat($instance, ' choices') AS dropdown_title; +SELECT 'One' AS title, '#one' AS link; + +SELECT 'modal' AS component, concat($instance, '-modal') AS id, concat($instance, ' dialog') AS title; +SELECT 'Fragment dialog contents' AS contents; + +SELECT 'toast' AS component, concat($instance, '-toast') AS id, concat($instance, ' notification') AS title, 0 AS duration; From 5bd9e21d9d857ace1739b7527548240979bf6c1c Mon Sep 17 00:00:00 2001 From: Ophir Lojkine Date: Wed, 7 Oct 2026 10:10:29 +0000 Subject: [PATCH 2/5] Reuse existing component fixtures for fragment lifecycle coverage --- tests/end-to-end/fixture.ts | 31 ++- tests/end-to-end/fixtures/form/index.sql | 7 +- .../fixtures/fragment-loaded/chart.sql | 2 - .../fixtures/fragment-loaded/form.sql | 5 - .../fixtures/fragment-loaded/index.sql | 10 +- .../fixtures/fragment-loaded/map.sql | 1 - .../fixtures/fragment-loaded/table.sql | 2 - .../fixtures/fragment-loaded/test.ts | 258 ++++++------------ .../fixtures/fragment-loaded/widgets.sql | 18 -- tests/end-to-end/official-site.spec.ts | 40 ++- 10 files changed, 154 insertions(+), 220 deletions(-) delete mode 100644 tests/end-to-end/fixtures/fragment-loaded/chart.sql delete mode 100644 tests/end-to-end/fixtures/fragment-loaded/form.sql delete mode 100644 tests/end-to-end/fixtures/fragment-loaded/map.sql delete mode 100644 tests/end-to-end/fixtures/fragment-loaded/table.sql delete mode 100644 tests/end-to-end/fixtures/fragment-loaded/widgets.sql diff --git a/tests/end-to-end/fixture.ts b/tests/end-to-end/fixture.ts index c6addb1c0..f4c60aca2 100644 --- a/tests/end-to-end/fixture.ts +++ b/tests/end-to-end/fixture.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { test as base, expect } from "@playwright/test"; +import { test as base, expect, type Page } from "@playwright/test"; const fixturesDirectory = path.resolve(import.meta.dirname, "fixtures"); @@ -32,3 +32,32 @@ export const test = base.extend({ export type { Page } from "@playwright/test"; export { expect }; + +/** Insert server-rendered SQL output, then announce it like a custom fragment consumer. */ +export async function loadFragment( + page: Page, + url: string, + root: string | null = "main", +) { + await page.evaluate( + async ({ url, root }) => { + const request = new URL(url, location.href); + request.searchParams.set("_sqlpage_embed", "1"); + const response = await fetch(request); + if (!response.ok) + throw new Error(`Fragment request failed: ${response.status}`); + const fragment = document.createElement("template"); + fragment.innerHTML = await response.text(); + document.querySelector("main")?.append(fragment.content); + if (root) { + const target = + root === "document" ? document : document.querySelector(root); + if (!target) throw new Error(`Missing fragment root: ${root}`); + target.dispatchEvent( + new CustomEvent("fragment-loaded", { bubbles: true }), + ); + } + }, + { url, root }, + ); +} diff --git a/tests/end-to-end/fixtures/form/index.sql b/tests/end-to-end/fixtures/form/index.sql index 441647a02..43e993a05 100644 --- a/tests/end-to-end/fixtures/form/index.sql +++ b/tests/end-to-end/fixtures/form/index.sql @@ -1,7 +1,7 @@ -- Exercise every form layout that renders a field description: -- standard controls, the legacy checkbox/radio controls, and switches. SELECT - 'form' AS component, + 'form' AS component, $id AS id, $fragment AS auto_submit, 'Form description markdown' AS title, '' AS validate; @@ -19,7 +19,7 @@ SELECT SELECT 'modern_select' AS name, 'Modern select' AS label, - 'select' AS type, + 'select' AS type, $fragment AS searchable, '[{"label":"Option","value":"option"}]' AS options, '**Bold select** and *italic select*.' AS description_md; @@ -43,3 +43,6 @@ SELECT 'switch' AS type, 'switch' AS value, '**Bold switch** and *italic switch*.' AS description_md; + +-- The lifecycle suite reuses this form with searchable and upload fields. +SELECT 'upload' AS name, 'Upload' AS label, 'file' AS type WHERE $fragment; diff --git a/tests/end-to-end/fixtures/fragment-loaded/chart.sql b/tests/end-to-end/fixtures/fragment-loaded/chart.sql deleted file mode 100644 index fbb0e1a38..000000000 --- a/tests/end-to-end/fixtures/fragment-loaded/chart.sql +++ /dev/null @@ -1,2 +0,0 @@ -SELECT 'chart' AS component, 'root-chart' AS id, 'column' AS type; -SELECT 'A' AS x, 1 AS y; diff --git a/tests/end-to-end/fixtures/fragment-loaded/form.sql b/tests/end-to-end/fixtures/fragment-loaded/form.sql deleted file mode 100644 index 4eb3415d3..000000000 --- a/tests/end-to-end/fixtures/fragment-loaded/form.sql +++ /dev/null @@ -1,5 +0,0 @@ -SELECT 'form' AS component, 'root-form' AS id, 'Fragment form' AS title, TRUE AS auto_submit; -SELECT 'root_value' AS name, 'Root value' AS label; -SELECT 'root_file' AS name, 'Root file' AS label, 'file' AS type; -SELECT 'root_select' AS name, 'Root select' AS label, 'select' AS type, TRUE AS searchable, - '[{"label":"One","value":"one"},{"label":"Two","value":"two"}]' AS options; diff --git a/tests/end-to-end/fixtures/fragment-loaded/index.sql b/tests/end-to-end/fixtures/fragment-loaded/index.sql index d8db2c13e..e02742e41 100644 --- a/tests/end-to-end/fixtures/fragment-loaded/index.sql +++ b/tests/end-to-end/fixtures/fragment-loaded/index.sql @@ -1,7 +1,5 @@ -SELECT 'form' AS component, 'existing-form' AS id, 'Existing form' AS title, TRUE AS auto_submit; -SELECT 'existing_value' AS name, 'Existing value' AS label; -SELECT 'existing_file' AS name, 'Existing file' AS label, 'file' AS type; - +SELECT 'dynamic' AS component, + sqlpage.run_sql('form/index.sql', '{"fragment":"1","id":"existing-form"}') AS properties; SELECT 'card' AS component; -SELECT 'First fragment' AS title, '/fragment-loaded/widgets.sql?instance=first' AS embed; -SELECT 'Second fragment' AS title, '/fragment-loaded/widgets.sql?instance=second' AS embed; +SELECT 'Embedded form' AS title, '/form/?fragment=1&id=first-form' AS embed; +SELECT 'Embedded chart' AS title, '/chart/' AS embed; diff --git a/tests/end-to-end/fixtures/fragment-loaded/map.sql b/tests/end-to-end/fixtures/fragment-loaded/map.sql deleted file mode 100644 index 5504a11c5..000000000 --- a/tests/end-to-end/fixtures/fragment-loaded/map.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT 'map' AS component, coalesce($instance, 'root-map') AS id, 51.5 AS latitude, 10 AS longitude, 4 AS zoom; diff --git a/tests/end-to-end/fixtures/fragment-loaded/table.sql b/tests/end-to-end/fixtures/fragment-loaded/table.sql deleted file mode 100644 index 66771976f..000000000 --- a/tests/end-to-end/fixtures/fragment-loaded/table.sql +++ /dev/null @@ -1,2 +0,0 @@ -SELECT 'table' AS component, 'root-table' AS id, TRUE AS search, TRUE AS sort; -SELECT 'Beta' AS name UNION ALL SELECT 'Alpha' AS name; diff --git a/tests/end-to-end/fixtures/fragment-loaded/test.ts b/tests/end-to-end/fixtures/fragment-loaded/test.ts index 87f256346..7c47f8f4b 100644 --- a/tests/end-to-end/fixtures/fragment-loaded/test.ts +++ b/tests/end-to-end/fixtures/fragment-loaded/test.ts @@ -1,50 +1,32 @@ -import { expect, type Page, test } from "../../fixture.ts"; +import { expect, loadFragment, type Page, test } from "../../fixture.ts"; -/** Load server-rendered SQL output as a custom fragment consumer would. */ -async function appendFragment( - page: Page, - file: string, - root?: string, - instance?: string, -) { - await page.evaluate( - async ({ file, root, instance }) => { - const url = new URL(`/fragment-loaded/${file}.sql`, location.href); - url.searchParams.set("_sqlpage_embed", "1"); - if (instance) url.searchParams.set("instance", instance); - const response = await fetch(url); - if (!response.ok) - throw new Error(`Fragment request failed: ${response.status}`); - const fragment = document.createElement("template"); - fragment.innerHTML = await response.text(); - document.querySelector("main")?.append(fragment.content); - if (root) { - const target = - root === "document" ? document : document.querySelector(root); - if (!target) throw new Error(`Missing fragment root: ${root}`); - target.dispatchEvent( - new CustomEvent("fragment-loaded", { bubbles: true }), - ); - } - }, - { file, root, instance }, +const rootForm = "/form/?fragment=1&id=root-form"; +const announceDocument = (page: Page) => + page.evaluate(() => + document.dispatchEvent(new CustomEvent("fragment-loaded")), ); -} -async function countSubmissions(page: Page, selector: string) { +async function trackHandlers(page: Page, selector: string) { await page.locator(selector).evaluate((element) => { const form = element as HTMLFormElement; form.dataset.submissions = "0"; form.submit = () => { form.dataset.submissions = String(Number(form.dataset.submissions) + 1); }; + const input = form.querySelector('input[type="file"]'); + if (!input) throw new Error("Missing file input"); + const setValidity = input.setCustomValidity.bind(input); + input.dataset.validations = "0"; + input.setCustomValidity = (message) => { + input.dataset.validations = String(Number(input.dataset.validations) + 1); + setValidity(message); + }; }); } -// Card fetches and their chart/select scripts are asynchronous. Wait until -// those scripts complete their initial document pass before testing local events. +// Card fetches and their chart/select scripts must finish the initial document pass. test.beforeEach(async ({ page }) => { - await expect(page.locator(".apexcharts-canvas")).toHaveCount(2); + await expect(page.locator(".apexcharts-canvas")).toHaveCount(1); await expect(page.locator(".ts-wrapper")).toHaveCount(2); }); @@ -52,170 +34,87 @@ for (const root of ["document", "main", "#added"]) { test(`initializes a tooltip when ${root} announces a fragment`, async ({ page, }) => { - await appendFragment(page, "tooltip", root); - // Re-announcing a fragment must retain the existing Bootstrap instance. - await page.evaluate(() => - document.dispatchEvent(new CustomEvent("fragment-loaded")), - ); + await loadFragment(page, "/fragment-loaded/tooltip.sql", root); + await announceDocument(page); await page.locator("#added").hover(); await expect(page.locator(".tooltip")).toHaveText("injected hint"); - await page.getByRole("heading", { name: "Existing form" }).hover(); + await page.getByRole("heading", { name: "Embedded form" }).hover(); await expect(page.locator(".tooltip")).toHaveCount(0); }); } -test("initializes card fragments and keeps existing form handlers singular", async ({ +test("card and document events keep existing and embedded form handlers singular", async ({ page, }) => { - await expect(page.locator(".apexcharts-canvas")).toHaveCount(2); - await expect(page.locator(".ts-wrapper")).toHaveCount(2); - await expect(page.locator("body > #first-modal")).toBeAttached(); - await expect(page.locator("body > #second-modal")).toBeAttached(); - await expect( - page.getByText("first notification", { exact: true }), - ).toBeVisible(); - await expect( - page.getByText("second notification", { exact: true }), - ).toBeVisible(); - await countSubmissions(page, "#existing-form"); - await countSubmissions(page, "#first-form"); - await page.getByLabel("Existing value", { exact: true }).fill("one change"); - await page.getByLabel("Existing value", { exact: true }).blur(); - await expect(page.locator("#existing-form")).toHaveAttribute( - "data-submissions", - "1", - ); - - await page.evaluate(() => { - for (let i = 0; i < 3; i++) - document.dispatchEvent(new CustomEvent("fragment-loaded")); - const input = document.querySelector( - 'input[name="existing_file"]', - ); - if (!input) throw new Error("Missing file input"); - const setValidity = input.setCustomValidity.bind(input); - input.dataset.validations = "0"; - input.setCustomValidity = (message) => { - input.dataset.validations = String(Number(input.dataset.validations) + 1); - setValidity(message); - }; - }); - await page.getByLabel("Existing file", { exact: true }).setInputFiles({ + await trackHandlers(page, "#existing-form"); + await trackHandlers(page, "#first-form"); + const existing = page.locator("#existing-form"); + await existing.getByLabel("Modern text field").fill("before repeated events"); + await existing.getByLabel("Modern text field").blur(); + await expect(existing).toHaveAttribute("data-submissions", "1"); + for (let i = 0; i < 3; i++) await announceDocument(page); + const upload = existing.getByLabel("Upload", { exact: true }); + await upload.setInputFiles({ name: "small.txt", mimeType: "text/plain", buffer: Buffer.from("small"), }); - await expect( - page.getByLabel("Existing file", { exact: true }), - ).toHaveAttribute("data-validations", "1"); - await expect(page.locator("#existing-form")).toHaveAttribute( - "data-submissions", - "2", - ); - await page.getByLabel("first value", { exact: true }).fill("changed"); - await page.getByLabel("first value", { exact: true }).blur(); - await expect(page.locator("#first-form")).toHaveAttribute( - "data-submissions", - "1", - ); - - await page.locator("#first-table input.search").fill("Alpha"); - await expect(page.locator("#first-table tbody tr:visible")).toHaveCount(1); - await page.locator("#first-table input.search").fill(""); - await page.locator("#first-table button.sort").click(); - await expect(page.locator("#first-table tbody tr").first()).toContainText( - "Alpha", - ); - await page.getByRole("button", { name: "first choices" }).click(); - await expect(page.locator(".dropdown-menu.show")).toBeVisible(); - await page.getByRole("button", { name: "first choices" }).click(); - await expect(page.locator(".dropdown-menu.show")).toHaveCount(0); - await page.evaluate(() => { - window.location.hash = "first-modal"; - }); - await expect(page.getByRole("dialog")).toBeVisible(); - await page - .getByRole("dialog") - .getByRole("button", { name: "close", exact: true }) - .click(); - await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(upload).toHaveAttribute("data-validations", "1"); + await expect(existing).toHaveAttribute("data-submissions", "2"); + const embedded = page.locator("#first-form"); + await embedded.getByLabel("Modern text field").fill("changed"); + await embedded.getByLabel("Modern text field").blur(); + await expect(embedded).toHaveAttribute("data-submissions", "1"); }); -test("initializes a form root and its searchable select", async ({ page }) => { - await appendFragment(page, "form", "#root-form"); - await expect(page.locator("#root-form .ts-wrapper")).toBeAttached(); - await countSubmissions(page, "#root-form"); - await page.getByLabel("Root value", { exact: true }).fill("changed"); - await page.getByLabel("Root value", { exact: true }).blur(); - await expect(page.locator("#root-form")).toHaveAttribute( - "data-submissions", - "1", - ); -}); - -test("initializes a file input root without initializing its surrounding form", async ({ - page, -}) => { - await appendFragment(page, "form", 'input[name="root_file"]'); - await countSubmissions(page, "#root-form"); - await page.getByLabel("Root file", { exact: true }).setInputFiles({ - name: "small.txt", - mimeType: "text/plain", - buffer: Buffer.from("small"), - }); - await expect(page.locator("#root-form")).toHaveAttribute( - "data-submissions", - "0", - ); - await expect( - page.locator("#root-form [data-pre-init=select-dropdown]"), - ).toBeAttached(); - const input = page.getByLabel("Root file", { exact: true }); - const maxSize = await input.getAttribute("data-max-size"); - await input.setInputFiles({ - name: "large.txt", - mimeType: "text/plain", - buffer: Buffer.alloc(Number(maxSize) + 1), +for (const root of ["#root-form", '#root-form input[type="file"]']) { + test(`initializes ${root} without adding handlers outside the root`, async ({ + page, + }) => { + await loadFragment(page, rootForm, root); + await trackHandlers(page, "#root-form"); + const form = page.locator("#root-form"); + const isForm = root === "#root-form"; + await expect(form.locator(".ts-wrapper")).toHaveCount(isForm ? 1 : 0); + await form.getByLabel("Modern text field").fill("changed"); + await form.getByLabel("Modern text field").blur(); + await expect(form).toHaveAttribute("data-submissions", isForm ? "1" : "0"); + const upload = form.getByLabel("Upload", { exact: true }); + await upload.setInputFiles({ + name: "small.txt", + mimeType: "text/plain", + buffer: Buffer.from("small"), + }); + await expect(form).toHaveAttribute("data-submissions", isForm ? "2" : "0"); + const maxSize = Number(await upload.getAttribute("data-max-size")); + await upload.setInputFiles({ + name: "large.txt", + mimeType: "text/plain", + buffer: Buffer.alloc(maxSize + 1), + }); + await expect(upload).toHaveClass(/is-invalid/); + await expect(upload).toHaveJSProperty( + "validationMessage", + `File size must be less than ${maxSize / 1000} kB.`, + ); }); - await expect(input).toHaveClass(/is-invalid/); - await expect(input).toHaveJSProperty( - "validationMessage", - `File size must be less than ${Number(maxSize) / 1000} kB.`, - ); -}); +} -test("initializes the root table and chart without initializing unrelated fragments", async ({ +test("chart and select roots leave unrelated fragments pending", async ({ page, }) => { - await appendFragment(page, "form"); - await appendFragment(page, "table", "#root-table [data-pre-init=table]"); - await page.locator("#root-table input.search").fill("Alpha"); - await expect(page.locator("#root-table tbody tr:visible")).toHaveCount(1); - await appendFragment(page, "chart", "#root-chart"); - await expect(page.locator("#root-chart .apexcharts-canvas")).toBeAttached(); + await loadFragment(page, rootForm, null); + await loadFragment(page, "/chart/", "main > #test-chart"); await expect( - page.locator("#root-form [data-pre-init=select-dropdown]"), + page.locator("main > #test-chart .apexcharts-canvas"), ).toBeAttached(); - await page.locator("#root-form select").evaluate((element) => { - element.dispatchEvent( - new CustomEvent("fragment-loaded", { bubbles: true }), - ); - }); + const select = page.locator("#root-form select"); + await expect(select).toHaveAttribute("data-pre-init", "select-dropdown"); + await select.dispatchEvent("fragment-loaded", { bubbles: true }); await expect(page.locator("#root-form .ts-wrapper")).toBeAttached(); }); -test("initializes a map root after loading Leaflet", async ({ page }) => { - await appendFragment(page, "map", "#root-map .leaflet"); - await expect(page.locator("#root-map .leaflet-map-pane")).toBeAttached(); - await page.locator("#root-map").evaluate((element) => { - element.dispatchEvent( - new CustomEvent("fragment-loaded", { bubbles: true }), - ); - }); - await expect(page.locator("#root-map .leaflet-map-pane")).toHaveCount(1); -}); - -test("initializes maps arriving while their shared dependency is pending", async ({ +test("map roots initialize before and after their shared dependency loads", async ({ page, }) => { let releaseLeaflet = () => {}; @@ -226,14 +125,15 @@ test("initializes maps arriving while their shared dependency is pending", async await dependencyReady; await route.continue(); }); + const root = "main > .card:last-child .leaflet"; try { - await appendFragment(page, "map", "#first-map .leaflet", "first-map"); - await appendFragment(page, "map", "#second-map .leaflet", "second-map"); + for (let i = 0; i < 2; i++) await loadFragment(page, "/map/", root); } finally { releaseLeaflet(); } await expect(page.locator(".leaflet-map-pane")).toHaveCount(2); - // With Leaflet loaded, a later event should initialize only its own root. - await appendFragment(page, "map", "#third-map .leaflet", "third-map"); + await loadFragment(page, "/map/", root); + await expect(page.locator(".leaflet-map-pane")).toHaveCount(3); + await page.locator(root).dispatchEvent("fragment-loaded", { bubbles: true }); await expect(page.locator(".leaflet-map-pane")).toHaveCount(3); }); diff --git a/tests/end-to-end/fixtures/fragment-loaded/widgets.sql b/tests/end-to-end/fixtures/fragment-loaded/widgets.sql deleted file mode 100644 index 4e462c20e..000000000 --- a/tests/end-to-end/fixtures/fragment-loaded/widgets.sql +++ /dev/null @@ -1,18 +0,0 @@ -SELECT 'form' AS component, concat($instance, '-form') AS id, concat($instance, ' form') AS title, TRUE AS auto_submit; -SELECT concat($instance, '_value') AS name, concat($instance, ' value') AS label; -SELECT concat($instance, '_select') AS name, concat($instance, ' select') AS label, - 'select' AS type, TRUE AS searchable, '[{"label":"One","value":"one"}]' AS options; - -SELECT 'table' AS component, concat($instance, '-table') AS id, TRUE AS search, TRUE AS sort; -SELECT 'Beta' AS name UNION ALL SELECT 'Alpha' AS name; - -SELECT 'chart' AS component, concat($instance, '-chart') AS id, 'column' AS type; -SELECT 'A' AS x, 2 AS y; - -SELECT 'facet' AS component, TRUE AS compact, concat($instance, ' choices') AS dropdown_title; -SELECT 'One' AS title, '#one' AS link; - -SELECT 'modal' AS component, concat($instance, '-modal') AS id, concat($instance, ' dialog') AS title; -SELECT 'Fragment dialog contents' AS contents; - -SELECT 'toast' AS component, concat($instance, '-toast') AS id, concat($instance, ' notification') AS title, 0 AS duration; diff --git a/tests/end-to-end/official-site.spec.ts b/tests/end-to-end/official-site.spec.ts index f0d5bb372..c1f645718 100644 --- a/tests/end-to-end/official-site.spec.ts +++ b/tests/end-to-end/official-site.spec.ts @@ -1,4 +1,14 @@ import { expect, type Locator, type Page, test } from "@playwright/test"; +import { loadFragment } from "./fixture.ts"; + +async function componentFragment( + page: Page, + component: string, + root: string | null = "main", +) { + await page.goto("/examples/show_variables.sql"); + await loadFragment(page, `/component.sql?component=${component}`, root); +} test("Open documentation", async ({ page }) => { await page.goto("/"); @@ -142,7 +152,7 @@ test("map", async ({ page }) => { test("toast notifications initialize, stack, dismiss, and render safely", async ({ page, }) => { - await page.goto("/documentation.sql?component=toast#component"); + await componentFragment(page, "toast"); const automatic = page.locator("#toast-auto"); await expect(automatic).toBeVisible(); @@ -370,11 +380,15 @@ test("Authentication example", async ({ page }) => { }); test("table filtering", async ({ page }) => { - await page.goto("/documentation.sql?component=table"); + await componentFragment(page, "table", null); const tableSection = page.locator(".card", { has: page.getByRole("cell", { name: "Chart", exact: true }), }); + await tableSection + .locator(".card-body") + .dispatchEvent("fragment-loaded", { bubbles: true }); + await expect(page.locator('[data-pre-init="table"]')).not.toHaveCount(0); const searchInput = tableSection.getByPlaceholder("Search…"); await searchInput.fill("chart"); const chartCell = tableSection.getByRole("cell", { name: "Chart" }); @@ -387,7 +401,7 @@ test("table filtering", async ({ page }) => { }); const sortableTable = async (page: Page) => { - await page.goto("/documentation.sql?component=table"); + await componentFragment(page, "table", "document"); return page.locator(".table-responsive", { has: page.getByRole("cell", { name: "31456" }), }); @@ -629,7 +643,9 @@ test("form type=select searchable=true", async ({ page }) => { }); test("modal", async ({ page }) => { - await page.goto("/documentation.sql?component=modal#component"); + await componentFragment(page, "modal"); + await expect(page.locator("body > #my_modal")).toBeAttached(); + await expect(page.locator("body > #my_embed_form_modal")).toBeAttached(); const openButton = page.getByRole("button", { name: "Open a simple modal" }); await openButton.click(); @@ -728,3 +744,19 @@ test("table action buttons - disabled action", async ({ page }) => { await expect(emptyActionButton).toHaveAttribute("href", "null"); await expect(emptyActionButton).toHaveAttribute("title", "Action"); }); + +test("fragment dropdown keeps one Bootstrap instance after repeated events", async ({ + page, +}) => { + await componentFragment(page, "facet"); + await page.evaluate(() => + document.dispatchEvent(new CustomEvent("fragment-loaded")), + ); + const toggle = page + .locator('[data-bs-toggle="dropdown"]') + .filter({ hasText: "Constitution" }); + await toggle.click(); + await expect(page.locator(".dropdown-menu.show")).toBeVisible(); + await toggle.click(); + await expect(page.locator(".dropdown-menu.show")).toHaveCount(0); +}); From 8d53afaa1d6e2b63d8048438c4b8d6f4bcd57eb3 Mon Sep 17 00:00:00 2001 From: Ophir Lojkine Date: Wed, 7 Oct 2026 10:23:54 +0000 Subject: [PATCH 3/5] Preserve initial-page coverage alongside fragment test cases --- tests/end-to-end/official-site.spec.ts | 115 +++++++++++++++---------- 1 file changed, 71 insertions(+), 44 deletions(-) diff --git a/tests/end-to-end/official-site.spec.ts b/tests/end-to-end/official-site.spec.ts index c1f645718..8764951f4 100644 --- a/tests/end-to-end/official-site.spec.ts +++ b/tests/end-to-end/official-site.spec.ts @@ -1,13 +1,34 @@ import { expect, type Locator, type Page, test } from "@playwright/test"; import { loadFragment } from "./fixture.ts"; -async function componentFragment( +type ComponentMode = "page" | "fragment"; + +function componentTest(name: string) { + return ( + run: (context: { page: Page; mode: ComponentMode }) => Promise, + ) => { + for (const mode of ["page", "fragment"] as const) { + test(`${name} (${mode})`, async ({ page }) => { + await run({ page, mode }); + }); + } + }; +} + +async function openComponent( page: Page, component: string, + mode: ComponentMode, root: string | null = "main", ) { - await page.goto("/examples/show_variables.sql"); - await loadFragment(page, `/component.sql?component=${component}`, root); + if (mode === "page") { + await page.goto( + `/documentation.sql?component=${component}${component === "table" ? "" : "#component"}`, + ); + } else { + await page.goto("/examples/show_variables.sql"); + await loadFragment(page, `/component.sql?component=${component}`, root); + } } test("Open documentation", async ({ page }) => { @@ -149,10 +170,10 @@ test("map", async ({ page }) => { await expect(page.locator(".leaflet-marker-icon").first()).toBeVisible(); }); -test("toast notifications initialize, stack, dismiss, and render safely", async ({ - page, -}) => { - await componentFragment(page, "toast"); +componentTest( + "toast notifications initialize, stack, dismiss, and render safely", +)(async ({ page, mode }) => { + await openComponent(page, "toast", mode); const automatic = page.locator("#toast-auto"); await expect(automatic).toBeVisible(); @@ -379,16 +400,18 @@ test("Authentication example", async ({ page }) => { await expect(page.getByText("You are logged in as admin")).toBeVisible(); }); -test("table filtering", async ({ page }) => { - await componentFragment(page, "table", null); +componentTest("table filtering")(async ({ page, mode }) => { + await openComponent(page, "table", mode, null); const tableSection = page.locator(".card", { has: page.getByRole("cell", { name: "Chart", exact: true }), }); - await tableSection - .locator(".card-body") - .dispatchEvent("fragment-loaded", { bubbles: true }); - await expect(page.locator('[data-pre-init="table"]')).not.toHaveCount(0); + if (mode === "fragment") { + await tableSection + .locator(".card-body") + .dispatchEvent("fragment-loaded", { bubbles: true }); + await expect(page.locator('[data-pre-init="table"]')).not.toHaveCount(0); + } const searchInput = tableSection.getByPlaceholder("Search…"); await searchInput.fill("chart"); const chartCell = tableSection.getByRole("cell", { name: "Chart" }); @@ -400,8 +423,8 @@ test("table filtering", async ({ page }) => { ).not.toBeVisible(); }); -const sortableTable = async (page: Page) => { - await componentFragment(page, "table", "document"); +const sortableTable = async (page: Page, mode: ComponentMode) => { + await openComponent(page, "table", mode, "document"); return page.locator(".table-responsive", { has: page.getByRole("cell", { name: "31456" }), }); @@ -415,32 +438,36 @@ const numbersInColumn = async (table: Locator, cells: string) => { const ascending = (values: number[]) => [...values].sort((a, b) => a - b); -test("table sorts a column when its header is clicked", async ({ page }) => { - const table = await sortableTable(page); - await table.getByRole("button", { name: "id" }).click(); - - const ids = await numbersInColumn(table, "td._col_id"); - expect(ids).toEqual(ascending(ids)); -}); - -test("table reverses the sort when the header is clicked again", async ({ - page, -}) => { - const table = await sortableTable(page); - await table.getByRole("button", { name: "id" }).click(); - await table.getByRole("button", { name: "id" }).click(); - - const ids = await numbersInColumn(table, "td._col_id"); - expect(ids).toEqual(ascending(ids).reverse()); -}); - -test("table sorts a column of formatted numbers by value", async ({ page }) => { - const table = await sortableTable(page); - await table.getByRole("button", { name: "Amount in stock" }).click(); - - const amounts = await numbersInColumn(table, "td._col_Amount_in_stock"); - expect(amounts).toEqual(ascending(amounts)); -}); +componentTest("table sorts a column when its header is clicked")( + async ({ page, mode }) => { + const table = await sortableTable(page, mode); + await table.getByRole("button", { name: "id" }).click(); + + const ids = await numbersInColumn(table, "td._col_id"); + expect(ids).toEqual(ascending(ids)); + }, +); + +componentTest("table reverses the sort when the header is clicked again")( + async ({ page, mode }) => { + const table = await sortableTable(page, mode); + await table.getByRole("button", { name: "id" }).click(); + await table.getByRole("button", { name: "id" }).click(); + + const ids = await numbersInColumn(table, "td._col_id"); + expect(ids).toEqual(ascending(ids).reverse()); + }, +); + +componentTest("table sorts a column of formatted numbers by value")( + async ({ page, mode }) => { + const table = await sortableTable(page, mode); + await table.getByRole("button", { name: "Amount in stock" }).click(); + + const amounts = await numbersInColumn(table, "td._col_Amount_in_stock"); + expect(amounts).toEqual(ascending(amounts)); + }, +); async function checkNoConsoleErrors(page: Page, component: string) { const errors: string[] = []; @@ -642,8 +669,8 @@ test("form type=select searchable=true", async ({ page }) => { await expect(page.getByText(":region = SA", { exact: true })).toBeVisible(); }); -test("modal", async ({ page }) => { - await componentFragment(page, "modal"); +componentTest("modal")(async ({ page, mode }) => { + await openComponent(page, "modal", mode); await expect(page.locator("body > #my_modal")).toBeAttached(); await expect(page.locator("body > #my_embed_form_modal")).toBeAttached(); const openButton = page.getByRole("button", { name: "Open a simple modal" }); @@ -748,7 +775,7 @@ test("table action buttons - disabled action", async ({ page }) => { test("fragment dropdown keeps one Bootstrap instance after repeated events", async ({ page, }) => { - await componentFragment(page, "facet"); + await openComponent(page, "facet", "fragment"); await page.evaluate(() => document.dispatchEvent(new CustomEvent("fragment-loaded")), ); From 26761753d15429c455726cbd3e801f7f8d25bb70 Mon Sep 17 00:00:00 2001 From: Ophir Lojkine Date: Wed, 7 Oct 2026 11:14:32 +0000 Subject: [PATCH 4/5] Preserve fragment roots through lazy initialization and modal moves --- frontend/src/init.ts | 20 ++++- frontend/src/sqlpage.ts | 43 +++++++--- tests/end-to-end/fixture.ts | 10 ++- tests/end-to-end/fixtures/facet/index.sql | 2 + tests/end-to-end/fixtures/facet/test.ts | 19 ++++ .../fixtures/fragment-loaded/index.sql | 9 +- .../fixtures/fragment-loaded/modal.sql | 2 + .../fixtures/fragment-loaded/test.ts | 86 ++++++++++++++++++- tests/end-to-end/official-site.spec.ts | 16 ---- 9 files changed, 171 insertions(+), 36 deletions(-) create mode 100644 tests/end-to-end/fixtures/facet/index.sql create mode 100644 tests/end-to-end/fixtures/facet/test.ts create mode 100644 tests/end-to-end/fixtures/fragment-loaded/modal.sql diff --git a/frontend/src/init.ts b/frontend/src/init.ts index 028cb3981..1f9fac1a4 100644 --- a/frontend/src/init.ts +++ b/frontend/src/init.ts @@ -1,5 +1,10 @@ export type InitRoot = Element | Document; +// The script element carries roots across independently bundled initializers. +export type InitScript = HTMLScriptElement & { + sqlpage_init_roots?: Set; +}; + /** Select matching descendants, including the fragment root itself. */ export function select_all( root: InitRoot, @@ -13,9 +18,20 @@ export function select_all( } export function add_init_fn(f: (root: InitRoot) => void) { - document.addEventListener("DOMContentLoaded", () => f(document)); + const script = document.currentScript as InitScript | null; + const pending_roots = script?.sqlpage_init_roots; + const initialize = () => { + if (script) delete script.sqlpage_init_roots; + for (const root of pending_roots ?? [document]) { + if (root instanceof Document || root.isConnected) f(root); + } + }; + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initialize, { once: true }); + } else { + setTimeout(initialize, 0); + } document.addEventListener("fragment-loaded", ({ target }) => { if (target instanceof Element || target instanceof Document) f(target); }); - if (document.readyState !== "loading") setTimeout(() => f(document), 0); } diff --git a/frontend/src/sqlpage.ts b/frontend/src/sqlpage.ts index 2452a7eeb..efd315751 100644 --- a/frontend/src/sqlpage.ts +++ b/frontend/src/sqlpage.ts @@ -1,6 +1,11 @@ import { bootstrap as bundled_bootstrap } from "@tabler/core"; import type * as Leaflet from "leaflet"; -import { add_init_fn, type InitRoot, select_all } from "./init.ts"; +import { + add_init_fn, + type InitRoot, + type InitScript, + select_all, +} from "./init.ts"; // A page may load its own Bootstrap; prefer it over the bundled copy. const page_bootstrap = () => window.bootstrap ?? bundled_bootstrap; @@ -196,9 +201,14 @@ type MarkerStyle = Leaflet.MarkerOptions & let is_leaflet_injected = false; let is_leaflet_loaded = false; +const pending_maps = new Set(); function sqlpage_map(root: InitRoot) { - const first_map = select_all(root, "[data-pre-init=map]")[0]; + const maps = select_all(root, "[data-pre-init=map]"); + const first_map = maps[0]; + if (!is_leaflet_loaded) { + for (const map of maps) pending_maps.add(map); + } const leaflet_base_url = "https://cdn.jsdelivr.net/npm/leaflet@1.9.4"; if (first_map && !is_leaflet_injected) { // Add the leaflet js and css to the page @@ -215,9 +225,14 @@ function sqlpage_map(root: InitRoot) { "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="; leaflet_js.crossOrigin = "anonymous"; leaflet_js.nonce = nonce; - // More fragments may arrive while Leaflet is loading. Initialize all - // pending maps when the shared dependency first becomes available. - leaflet_js.onload = () => onLeafletLoad(document); + // Preserve each announced map, including maps arriving during loading. + leaflet_js.onload = () => { + is_leaflet_loaded = true; + for (const map of pending_maps) { + if (map.isConnected) onLeafletLoad(map); + } + pending_maps.clear(); + }; document.head.appendChild(leaflet_js); is_leaflet_injected = true; } @@ -389,15 +404,20 @@ function get_tabler_color(name: string) { function load_scripts(root: InitRoot) { const addjs = select_all(root, "[data-sqlpage-js]"); - const existing_scripts = new Set( - [...document.querySelectorAll("script")].map((s) => s.src), + const existing_scripts = new Map( + [...document.querySelectorAll("script")].map((s) => [s.src, s]), ); for (const el of addjs) { if (!el.dataset.sqlpageJs) continue; const js = new URL(el.dataset.sqlpageJs, window.location.href).href; - if (existing_scripts.has(js)) continue; - existing_scripts.add(js); - const script = document.createElement("script"); + const existing = existing_scripts.get(js); + if (existing) { + existing.sqlpage_init_roots?.add(el); + continue; + } + const script: InitScript = document.createElement("script"); + script.sqlpage_init_roots = new Set([el]); + existing_scripts.set(js, script); script.src = js; document.head.appendChild(script); } @@ -497,6 +517,9 @@ function sqlpage_modal(root: InitRoot) { // clicked. Moving modals to keeps them viewport-fixed. for (const modal of select_all(root, "body .page .modal")) { document.body.appendChild(modal); + // The modal leaves the original root; announce its subtree separately so + // every initializer, including later-loaded bundles, can still see it. + modal.dispatchEvent(new CustomEvent("fragment-loaded", { bubbles: true })); } } diff --git a/tests/end-to-end/fixture.ts b/tests/end-to-end/fixture.ts index f4c60aca2..5e754b37f 100644 --- a/tests/end-to-end/fixture.ts +++ b/tests/end-to-end/fixture.ts @@ -38,9 +38,10 @@ export async function loadFragment( page: Page, url: string, root: string | null = "main", + parent = "main", ) { await page.evaluate( - async ({ url, root }) => { + async ({ url, root, parent }) => { const request = new URL(url, location.href); request.searchParams.set("_sqlpage_embed", "1"); const response = await fetch(request); @@ -48,7 +49,10 @@ export async function loadFragment( throw new Error(`Fragment request failed: ${response.status}`); const fragment = document.createElement("template"); fragment.innerHTML = await response.text(); - document.querySelector("main")?.append(fragment.content); + const destination = document.querySelector(parent); + if (!destination) + throw new Error(`Missing fragment destination: ${parent}`); + destination.append(fragment.content); if (root) { const target = root === "document" ? document : document.querySelector(root); @@ -58,6 +62,6 @@ export async function loadFragment( ); } }, - { url, root }, + { url, root, parent }, ); } diff --git a/tests/end-to-end/fixtures/facet/index.sql b/tests/end-to-end/fixtures/facet/index.sql new file mode 100644 index 000000000..2a1e36f7a --- /dev/null +++ b/tests/end-to-end/fixtures/facet/index.sql @@ -0,0 +1,2 @@ +SELECT 'facet' AS component, TRUE AS compact, 'Constitution' AS dropdown_title; +SELECT 'One' AS title, '#one' AS link; diff --git a/tests/end-to-end/fixtures/facet/test.ts b/tests/end-to-end/fixtures/facet/test.ts new file mode 100644 index 000000000..ca26a1a0e --- /dev/null +++ b/tests/end-to-end/fixtures/facet/test.ts @@ -0,0 +1,19 @@ +import { expect, test } from "../../fixture.ts"; + +test("fragment dropdown keeps one Bootstrap instance after repeated events", async ({ + page, +}) => { + await page + .locator("main") + .dispatchEvent("fragment-loaded", { bubbles: true }); + await page.evaluate(() => + document.dispatchEvent(new CustomEvent("fragment-loaded")), + ); + const toggle = page + .locator('[data-bs-toggle="dropdown"]') + .filter({ hasText: "Constitution" }); + await toggle.click(); + await expect(page.locator(".dropdown-menu.show")).toBeVisible(); + await toggle.click(); + await expect(page.locator(".dropdown-menu.show")).toHaveCount(0); +}); diff --git a/tests/end-to-end/fixtures/fragment-loaded/index.sql b/tests/end-to-end/fixtures/fragment-loaded/index.sql index e02742e41..4dcde2a37 100644 --- a/tests/end-to-end/fixtures/fragment-loaded/index.sql +++ b/tests/end-to-end/fixtures/fragment-loaded/index.sql @@ -1,5 +1,6 @@ SELECT 'dynamic' AS component, - sqlpage.run_sql('form/index.sql', '{"fragment":"1","id":"existing-form"}') AS properties; -SELECT 'card' AS component; -SELECT 'Embedded form' AS title, '/form/?fragment=1&id=first-form' AS embed; -SELECT 'Embedded chart' AS title, '/chart/' AS embed; + sqlpage.run_sql('form/index.sql', '{"fragment":"1","id":"existing-form"}') AS properties WHERE $lazy IS NULL; +SELECT 'card' AS component WHERE $lazy IS NULL; +SELECT 'Embedded form' AS title, '/form/?fragment=1&id=first-form' AS embed WHERE $lazy IS NULL; +SELECT 'Embedded chart' AS title, '/chart/' AS embed WHERE $lazy IS NULL; +SELECT 'text' AS component, 'Fragment lifecycle' AS contents WHERE $lazy IS NOT NULL; diff --git a/tests/end-to-end/fixtures/fragment-loaded/modal.sql b/tests/end-to-end/fixtures/fragment-loaded/modal.sql new file mode 100644 index 000000000..e8e068163 --- /dev/null +++ b/tests/end-to-end/fixtures/fragment-loaded/modal.sql @@ -0,0 +1,2 @@ +SELECT 'modal' AS component, 'nested-modal' AS id, 'Nested widgets' AS title WHERE $notification IS NULL; +SELECT 'toast' AS component, 'nested-toast' AS id, 'Nested notification' AS title, 0 AS duration WHERE $notification IS NOT NULL; diff --git a/tests/end-to-end/fixtures/fragment-loaded/test.ts b/tests/end-to-end/fixtures/fragment-loaded/test.ts index 7c47f8f4b..f56f19520 100644 --- a/tests/end-to-end/fixtures/fragment-loaded/test.ts +++ b/tests/end-to-end/fixtures/fragment-loaded/test.ts @@ -35,9 +35,10 @@ for (const root of ["document", "main", "#added"]) { page, }) => { await loadFragment(page, "/fragment-loaded/tooltip.sql", root); - await announceDocument(page); await page.locator("#added").hover(); await expect(page.locator(".tooltip")).toHaveText("injected hint"); + await announceDocument(page); + await expect(page.locator(".tooltip")).toHaveText("injected hint"); await page.getByRole("heading", { name: "Embedded form" }).hover(); await expect(page.locator(".tooltip")).toHaveCount(0); }); @@ -125,6 +126,8 @@ test("map roots initialize before and after their shared dependency loads", asyn await dependencyReady; await route.continue(); }); + await loadFragment(page, "/map/", null); + const pending = page.locator("main > .card > .card-body > .leaflet").first(); const root = "main > .card:last-child .leaflet"; try { for (let i = 0; i < 2; i++) await loadFragment(page, "/map/", root); @@ -132,8 +135,89 @@ test("map roots initialize before and after their shared dependency loads", asyn releaseLeaflet(); } await expect(page.locator(".leaflet-map-pane")).toHaveCount(2); + await expect(pending).toHaveAttribute("data-pre-init", "map"); await loadFragment(page, "/map/", root); await expect(page.locator(".leaflet-map-pane")).toHaveCount(3); await page.locator(root).dispatchEvent("fragment-loaded", { bubbles: true }); await expect(page.locator(".leaflet-map-pane")).toHaveCount(3); + await expect(pending).toHaveAttribute("data-pre-init", "map"); + await pending.dispatchEvent("fragment-loaded", { bubbles: true }); + await expect(page.locator(".leaflet-map-pane")).toHaveCount(4); }); + +for (const bundle of ["apexcharts", "tomselect"]) { + test(`${bundle} initializes only announced roots while its bundle loads`, async ({ + page, + }) => { + await page.goto("/fragment-loaded/?lazy=1"); + const select = bundle === "tomselect"; + const url = select ? "/form/?fragment=1" : "/chart/"; + const component = select ? "form" : "[data-sqlpage-js]"; + const ready = select ? ".ts-wrapper" : ".apexcharts-canvas"; + await loadFragment(page, url, null); + const pending = page.locator(`main > ${component}`).first(); + let releaseBundle = () => {}; + const gate = new Promise((resolve) => { + releaseBundle = resolve; + }); + await page.route(`**/${bundle}.*.js`, async (route) => { + await gate; + await route.continue(); + }); + try { + for (let i = 0; i < 2; i++) + await loadFragment(page, url, `main > ${component}:last-child`); + } finally { + releaseBundle(); + } + await expect(page.locator(ready)).toHaveCount(2); + await expect(select ? pending.locator("select") : pending).toHaveAttribute( + "data-pre-init", + select ? "select-dropdown" : "chart", + ); + await pending.dispatchEvent("fragment-loaded", { bubbles: true }); + await expect(page.locator(ready)).toHaveCount(3); + }); +} + +for (const lazy of [false, true]) { + test(`relocated modals initialize their contents with ${lazy ? "lazy" : "loaded"} bundles`, async ({ + page, + }) => { + if (lazy) await page.goto("/fragment-loaded/?lazy=1"); + await loadFragment(page, "/fragment-loaded/modal.sql", null); + const parent = "#nested-modal .modal-body"; + for (const url of [ + rootForm, + "/chart/", + "/fragment-loaded/tooltip.sql", + "/facet/", + "/fragment-loaded/modal.sql?notification=1", + ]) { + await loadFragment(page, url, null, parent); + } + await page + .locator("main") + .dispatchEvent("fragment-loaded", { bubbles: true }); + await expect(page.locator("body > #nested-modal")).toBeAttached(); + await expect(page.locator("#nested-modal .ts-wrapper")).toBeAttached(); + await expect( + page.locator("#nested-modal .apexcharts-canvas"), + ).toBeAttached(); + await expect(page.locator("#nested-toast")).toBeVisible(); + await page.evaluate(() => { + window.location.hash = "nested-modal"; + }); + await expect(page.getByRole("dialog")).toBeVisible(); + await page.locator("#added").hover(); + await expect(page.locator(".tooltip")).toHaveText("injected hint"); + await page + .getByRole("button", { name: "Constitution", exact: true }) + .click(); + await expect(page.locator(".dropdown-menu.show")).toBeVisible(); + await page + .getByRole("button", { name: "Constitution", exact: true }) + .click(); + await expect(page.locator(".dropdown-menu.show")).toHaveCount(0); + }); +} diff --git a/tests/end-to-end/official-site.spec.ts b/tests/end-to-end/official-site.spec.ts index 8764951f4..2a917b0f7 100644 --- a/tests/end-to-end/official-site.spec.ts +++ b/tests/end-to-end/official-site.spec.ts @@ -771,19 +771,3 @@ test("table action buttons - disabled action", async ({ page }) => { await expect(emptyActionButton).toHaveAttribute("href", "null"); await expect(emptyActionButton).toHaveAttribute("title", "Action"); }); - -test("fragment dropdown keeps one Bootstrap instance after repeated events", async ({ - page, -}) => { - await openComponent(page, "facet", "fragment"); - await page.evaluate(() => - document.dispatchEvent(new CustomEvent("fragment-loaded")), - ); - const toggle = page - .locator('[data-bs-toggle="dropdown"]') - .filter({ hasText: "Constitution" }); - await toggle.click(); - await expect(page.locator(".dropdown-menu.show")).toBeVisible(); - await toggle.click(); - await expect(page.locator(".dropdown-menu.show")).toHaveCount(0); -}); From b46038795b35303fec64725a436d207ca791a4ee Mon Sep 17 00:00:00 2001 From: Ophir Lojkine Date: Wed, 7 Oct 2026 11:24:38 +0000 Subject: [PATCH 5/5] Move fragment component scenarios into fixture suites --- tests/end-to-end/component-assertions.ts | 245 ++++++++++++++++ tests/end-to-end/fixtures/modal/index.sql | 11 + tests/end-to-end/fixtures/modal/test.ts | 7 + tests/end-to-end/fixtures/table/index.sql | 13 + tests/end-to-end/fixtures/table/test.ts | 25 ++ tests/end-to-end/fixtures/toast/index.sql | 24 ++ tests/end-to-end/fixtures/toast/test.ts | 9 + tests/end-to-end/official-site.spec.ts | 327 +++------------------- 8 files changed, 370 insertions(+), 291 deletions(-) create mode 100644 tests/end-to-end/component-assertions.ts create mode 100644 tests/end-to-end/fixtures/modal/index.sql create mode 100644 tests/end-to-end/fixtures/modal/test.ts create mode 100644 tests/end-to-end/fixtures/table/index.sql create mode 100644 tests/end-to-end/fixtures/table/test.ts create mode 100644 tests/end-to-end/fixtures/toast/index.sql create mode 100644 tests/end-to-end/fixtures/toast/test.ts diff --git a/tests/end-to-end/component-assertions.ts b/tests/end-to-end/component-assertions.ts new file mode 100644 index 000000000..4a4350fea --- /dev/null +++ b/tests/end-to-end/component-assertions.ts @@ -0,0 +1,245 @@ +import { expect, type Locator, type Page } from "@playwright/test"; + +// Shared assertions keep documentation page smokes and component fragments aligned. +export async function checkToastNotifications(page: Page) { + const automatic = page.locator("#toast-auto"); + await expect(automatic).toBeVisible(); + await expect(automatic).toHaveAttribute("data-bs-delay", "5000"); + await expect(automatic).toHaveAttribute("data-bs-autohide", "true"); + await expect( + automatic.getByRole("button", { name: "Close notification" }), + ).toBeVisible(); + await expect(page.locator(".toast.show")).toHaveCount(1); + const automaticHandle = await automatic.elementHandle(); + await automatic.getByRole("button", { name: "Close notification" }).click(); + await expect(automatic).toBeHidden(); + await expect(page.locator("main")).toBeFocused(); + + const stackOne = page.locator("#toast-stack-one"); + const stackTwo = page.locator("#toast-stack-two"); + await expect(stackOne).toBeHidden(); + await page.evaluate(() => { + document.addEventListener("shown.bs.toast", (event) => { + const toast = event.target as HTMLElement; + toast.dataset.shownCount = String( + Number(toast.dataset.shownCount ?? 0) + 1, + ); + }); + }); + await page.getByRole("button", { name: "Show queued notifications" }).click(); + await expect(stackOne).toBeVisible(); + await expect(stackTwo).toBeVisible(); + await expect(stackOne).toHaveAttribute("data-shown-count", "1"); + await expect(stackTwo).toHaveAttribute("data-shown-count", "1"); + await expect(page.locator("#toast-short")).toHaveAttribute( + "data-shown-count", + "1", + ); + await page.evaluate(() => + document.dispatchEvent(new CustomEvent("fragment-loaded")), + ); + await page.waitForTimeout(250); + await expect(page.locator("#toast-short")).toHaveAttribute( + "data-shown-count", + "1", + ); + expect(decodeURIComponent(new URL(page.url()).hash)).toBe( + "#queued notifications", + ); + const stackContainer = stackOne.locator("xpath=.."); + await expect(stackContainer).toHaveAttribute( + "data-sqlpage-toast-position", + "top-end", + ); + await expect(stackContainer).toHaveClass(/\bmh-100\b/); + await expect(stackContainer).toHaveClass(/\boverflow-auto\b/); + expect( + await stackContainer.evaluate( + (container) => container.parentElement === document.body, + ), + ).toBe(true); + expect( + await stackTwo + .locator("xpath=..") + .getAttribute("data-sqlpage-toast-position"), + ).toBe("top-end"); + const firstBox = await stackOne.boundingBox(); + const secondBox = await stackTwo.boundingBox(); + expect(firstBox).not.toBeNull(); + expect(secondBox).not.toBeNull(); + expect(secondBox?.y).toBeGreaterThanOrEqual( + (firstBox?.y ?? 0) + (firstBox?.height ?? 0), + ); + + const temporary = page.locator("#toast-short"); + await expect(temporary).toHaveAttribute("data-bs-delay", "2000"); + await expect(temporary).toBeVisible(); + await expect(temporary).toBeHidden({ timeout: 5000 }); + await expect(stackOne).toBeVisible(); + await expect(automatic).toBeHidden({ timeout: 7000 }); + expect( + await automaticHandle?.evaluate((element) => element.isConnected), + ).toBe(false); + + const dismissible = page.locator("#toast-dismissible"); + await expect(dismissible).toHaveAttribute("data-bs-delay", "0"); + await expect(dismissible).toHaveAttribute("data-bs-autohide", "false"); + await page.getByRole("button", { name: "Show dismissible error" }).click(); + const closeButton = dismissible.getByRole("button", { + name: "Close notification", + }); + await expect(closeButton).toBeVisible(); + const closeStyle = await closeButton.evaluate((button) => { + const style = getComputedStyle(button); + const toastStyle = getComputedStyle( + button.closest(".toast") as HTMLElement, + ); + return { + backgroundColor: style.backgroundColor, + color: style.color, + filter: style.filter, + maskImage: style.maskImage, + toastColor: toastStyle.color, + }; + }); + expect(closeStyle.filter).toBe("none"); + expect(closeStyle.maskImage).not.toBe("none"); + expect(closeStyle.color).toBe(closeStyle.toastColor); + expect(closeStyle.backgroundColor).toBe(closeStyle.toastColor); + await page.evaluate(() => { + window.history.replaceState({ toastTest: true }, "", window.location.href); + }); + await dismissible.getByRole("button", { name: "Close notification" }).click(); + await expect(dismissible).toBeHidden(); + expect(await page.evaluate(() => window.history.state)).toEqual({ + toastTest: true, + }); + await page.getByRole("button", { name: "Show dismissible error" }).click(); + await expect(dismissible).toBeVisible(); + await dismissible.getByRole("button", { name: "Close notification" }).click(); + await page + .getByRole("button", { name: "Show non-dismissible status" }) + .click(); + await expect( + page.locator("#toast-nondismissible").getByRole("button"), + ).toHaveCount(0); + + await page.getByRole("button", { name: "Show rich notifications" }).click(); + await expect(page.locator("#toast-markdown strong")).toHaveText("2.0"); + await expect(page.locator("#toast-markdown a")).toHaveAttribute( + "href", + "https://example.com/releases", + ); + const linkStyle = await page + .locator("#toast-markdown a") + .evaluate((link) => ({ + color: getComputedStyle(link).color, + parentColor: getComputedStyle(link.parentElement as HTMLElement).color, + textDecorationLine: getComputedStyle(link).textDecorationLine, + })); + expect(linkStyle.color).toBe(linkStyle.parentColor); + expect(linkStyle.textDecorationLine).toBe("underline"); + await expect(page.locator("#toast-plain strong")).toHaveCount(0); + await expect(page.locator("#toast-plain")).toContainText( + "Plain text stays escaped", + ); + const whiteToast = page.locator("#toast-plain"); + const whiteToastStyle = await whiteToast.evaluate((toast) => { + const style = getComputedStyle(toast); + const closeStyle = getComputedStyle( + toast.querySelector(".btn-close") as HTMLElement, + ); + const rgba = (color: string) => { + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d"); + if (!context) throw new Error("Canvas 2D context is unavailable"); + context.fillStyle = color; + context.fillRect(0, 0, 1, 1); + return Array.from(context.getImageData(0, 0, 1, 1).data); + }; + return { + backgroundColor: rgba(style.backgroundColor), + closeColor: rgba(closeStyle.backgroundColor), + color: rgba(style.color), + }; + }); + expect(whiteToastStyle.backgroundColor).toEqual([255, 255, 255, 255]); + expect(whiteToastStyle.color).toEqual([31, 41, 55, 255]); + expect(whiteToastStyle.closeColor).toEqual(whiteToastStyle.color); + + const bottomContainer = page.locator( + '[data-sqlpage-toast-position="bottom-center"]', + ); + await page.getByRole("button", { name: "Show bottom notification" }).click(); + await expect(page.locator("#toast-bottom-center")).toBeVisible(); + await expect(bottomContainer).toHaveClass(/\bbottom-0\b/); + await expect(bottomContainer).toHaveClass(/\bstart-50\b/); + await expect(bottomContainer).toHaveClass(/\btranslate-middle-x\b/); +} + +export async function checkTableFiltering(page: Page, fragment = false) { + const tableSection = page.locator(".card", { + has: page.getByRole("cell", { name: "Chart", exact: true }), + }); + + if (fragment) { + await tableSection + .locator(".card-body") + .dispatchEvent("fragment-loaded", { bubbles: true }); + await expect(page.locator('[data-pre-init="table"]')).not.toHaveCount(0); + } + const searchInput = tableSection.getByPlaceholder("Search…"); + await searchInput.fill("chart"); + const chartCell = tableSection.getByRole("cell", { name: "Chart" }); + await expect(chartCell).toBeVisible(); + await expect(chartCell).toHaveClass(/\b_col_name\b/); + await expect(chartCell).toHaveCSS("vertical-align", "middle"); + await expect( + tableSection.getByRole("cell", { name: "Table" }), + ).not.toBeVisible(); +} + +const numbersInColumn = async (table: Locator, cells: string) => { + const texts = await table.locator(cells).allInnerTexts(); + expect(texts.length).toBeGreaterThan(1); + return texts.map((text) => Number.parseInt(text.replace(/[^0-9]/g, ""), 10)); +}; + +const ascending = (values: number[]) => [...values].sort((a, b) => a - b); + +export async function checkTableSort( + page: Page, + column: "id" | "Amount in stock", + reverse = false, +) { + const table = page.locator(".table-responsive", { + has: page.getByRole("cell", { name: "31456" }), + }); + await table.getByRole("button", { name: column }).click(); + if (reverse) await table.getByRole("button", { name: column }).click(); + const values = await numbersInColumn( + table, + column === "id" ? "td._col_id" : "td._col_Amount_in_stock", + ); + expect(values).toEqual( + reverse ? ascending(values).reverse() : ascending(values), + ); +} + +export async function checkModal(page: Page) { + await expect(page.locator("body > #my_modal")).toBeAttached(); + await expect(page.locator("body > #my_embed_form_modal")).toBeAttached(); + const openButton = page.getByRole("button", { name: "Open a simple modal" }); + await openButton.click(); + + const modal = page.getByRole("dialog", { name: "A modal box" }); + await expect(modal).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(modal).not.toBeVisible(); + + await openButton.click(); + await expect(modal).toBeVisible(); + await modal.getByRole("button", { name: "Close" }).first().click(); + await expect(modal).not.toBeVisible(); +} diff --git a/tests/end-to-end/fixtures/modal/index.sql b/tests/end-to-end/fixtures/modal/index.sql new file mode 100644 index 000000000..8cb6e6e2f --- /dev/null +++ b/tests/end-to-end/fixtures/modal/index.sql @@ -0,0 +1,11 @@ +SELECT 'text' AS component, 'Component lifecycle' AS contents WHERE $fragment IS NULL; + +-- The simple modal and embedded form from 63_modal.sql, reusing the form fixture. +SELECT 'dynamic' AS component, JSON('[ + {"component":"modal","id":"my_modal","title":"A modal box","close":"Close"}, + {"contents":"I''m a modal window, and I allow you to display additional information or help for the user."}, + {"component":"button"}, + {"title":"Open a simple modal","link":"#my_modal"}, + {"component":"modal","id":"my_embed_form_modal","title":"Embeded form content","large":true,"embed":"/form/?_sqlpage_embed=1"} +]') AS properties +WHERE $fragment IS NOT NULL; diff --git a/tests/end-to-end/fixtures/modal/test.ts b/tests/end-to-end/fixtures/modal/test.ts new file mode 100644 index 000000000..02870bf85 --- /dev/null +++ b/tests/end-to-end/fixtures/modal/test.ts @@ -0,0 +1,7 @@ +import { checkModal } from "../../component-assertions.ts"; +import { loadFragment, test } from "../../fixture.ts"; + +test("modal (fragment)", async ({ page }) => { + await loadFragment(page, "/modal/?fragment=1"); + await checkModal(page); +}); diff --git a/tests/end-to-end/fixtures/table/index.sql b/tests/end-to-end/fixtures/table/index.sql new file mode 100644 index 000000000..1eee9d203 --- /dev/null +++ b/tests/end-to-end/fixtures/table/index.sql @@ -0,0 +1,13 @@ +SELECT 'text' AS component, 'Component lifecycle' AS contents WHERE $fragment IS NULL; + +-- Only the filtering and numeric sorting examples needed from 01_documentation.sql. +SELECT 'dynamic' AS component, JSON('[ + {"component":"table","markdown":"Name","icon":"icon","search":true}, + {"icon":"table","name":"[Table](?component=table)","description":"Displays SQL results as a searchable table.","_sqlpage_color":"red"}, + {"icon":"timeline","name":"[Chart](?component=chart)","description":"Show graphs based on numeric data."}, + {"component":"table","sort":true,"align_right":["Price","Amount in stock"],"align_center":["part_no"],"raw_numbers":["id"],"currency":"USD","money":["Price"]}, + {"id":31456,"part_no":"SQL-TABLE-856-G","Price":12,"Amount in stock":5}, + {"id":996,"part_no":"SQL-FORMS-86-M","Price":1,"Amount in stock":1234}, + {"id":131456,"part_no":"SQL-CARDS-56-K","Price":127,"Amount in stock":98} +]') AS properties +WHERE $fragment IS NOT NULL; diff --git a/tests/end-to-end/fixtures/table/test.ts b/tests/end-to-end/fixtures/table/test.ts new file mode 100644 index 000000000..028555500 --- /dev/null +++ b/tests/end-to-end/fixtures/table/test.ts @@ -0,0 +1,25 @@ +import { + checkTableFiltering, + checkTableSort, +} from "../../component-assertions.ts"; +import { loadFragment, test } from "../../fixture.ts"; + +test("table filtering (fragment)", async ({ page }) => { + await loadFragment(page, "/table/?fragment=1", null); + await checkTableFiltering(page, true); +}); + +for (const [name, column, reverse] of [ + ["table sorts a column when its header is clicked", "id", false], + ["table reverses the sort when the header is clicked again", "id", true], + [ + "table sorts a column of formatted numbers by value", + "Amount in stock", + false, + ], +] as const) { + test(`${name} (fragment)`, async ({ page }) => { + await loadFragment(page, "/table/?fragment=1", "document"); + await checkTableSort(page, column, reverse); + }); +} diff --git a/tests/end-to-end/fixtures/toast/index.sql b/tests/end-to-end/fixtures/toast/index.sql new file mode 100644 index 000000000..4623f7445 --- /dev/null +++ b/tests/end-to-end/fixtures/toast/index.sql @@ -0,0 +1,24 @@ +SELECT 'text' AS component, 'Component lifecycle' AS contents WHERE $fragment IS NULL; + +-- Properties exercised by the toast documentation smoke assertions (76_toast.sql). +SELECT 'dynamic' AS component, JSON('[ + {"component":"toast","id":"toast-auto","title":"This is a SQLPage toast","description":"This toast will open automatically when the page loads.","icon":"check","color":"green"}, + {"component":"toast","id":"toast-dismissible","trigger":"persistent-error","title":"Could not save","description":"Review the highlighted fields and try again.","icon":"alert-triangle","color":"black","duration":0,"dismissible":true}, + {"component":"toast","id":"toast-nondismissible","trigger":"persistent-status","title":"Connection unavailable","description":"This persistent notification has no manual close control.","duration":0,"dismissible":false}, + {"component":"button"}, + {"title":"Show dismissible error","link":"#persistent-error","color":"red"}, + {"title":"Show non-dismissible status","link":"#persistent-status"}, + {"component":"toast","id":"toast-markdown","trigger":"rich-notifications","title":"Release available","description":"This fallback stays escaped","description_md":"Version **2.0** is ready. [Read the notes](https://example.com/releases).","color":"blue","duration":0}, + {"component":"toast","id":"toast-plain","trigger":"rich-notifications","description":"Plain text stays escaped","color":"white","duration":0}, + {"component":"button"}, + {"title":"Show rich notifications","link":"#rich-notifications"}, + {"component":"toast","id":"toast-stack-one","trigger":"queued notifications","title":"Import started","description":"Preparing records.","duration":0}, + {"component":"toast","id":"toast-stack-two","trigger":"queued notifications","title":"Import running","description":"Processing records.","duration":0}, + {"component":"toast","id":"toast-short","trigger":"queued notifications","title":"Temporary update","description":"This message closes shortly.","duration":2000}, + {"component":"button"}, + {"title":"Show queued notifications","link":"#queued notifications"}, + {"component":"toast","id":"toast-bottom-center","trigger":"bottom-notification","title":"Download ready","description":"Your export is ready.","position":"bottom-center","duration":0}, + {"component":"button"}, + {"title":"Show bottom notification","link":"#bottom-notification"} +]') AS properties +WHERE $fragment IS NOT NULL; diff --git a/tests/end-to-end/fixtures/toast/test.ts b/tests/end-to-end/fixtures/toast/test.ts new file mode 100644 index 000000000..fc97f850c --- /dev/null +++ b/tests/end-to-end/fixtures/toast/test.ts @@ -0,0 +1,9 @@ +import { checkToastNotifications } from "../../component-assertions.ts"; +import { loadFragment, test } from "../../fixture.ts"; + +test("toast notifications initialize, stack, dismiss, and render safely (fragment)", async ({ + page, +}) => { + await loadFragment(page, "/toast/?fragment=1"); + await checkToastNotifications(page); +}); diff --git a/tests/end-to-end/official-site.spec.ts b/tests/end-to-end/official-site.spec.ts index 2a917b0f7..8b1a483b7 100644 --- a/tests/end-to-end/official-site.spec.ts +++ b/tests/end-to-end/official-site.spec.ts @@ -1,35 +1,10 @@ import { expect, type Locator, type Page, test } from "@playwright/test"; -import { loadFragment } from "./fixture.ts"; - -type ComponentMode = "page" | "fragment"; - -function componentTest(name: string) { - return ( - run: (context: { page: Page; mode: ComponentMode }) => Promise, - ) => { - for (const mode of ["page", "fragment"] as const) { - test(`${name} (${mode})`, async ({ page }) => { - await run({ page, mode }); - }); - } - }; -} - -async function openComponent( - page: Page, - component: string, - mode: ComponentMode, - root: string | null = "main", -) { - if (mode === "page") { - await page.goto( - `/documentation.sql?component=${component}${component === "table" ? "" : "#component"}`, - ); - } else { - await page.goto("/examples/show_variables.sql"); - await loadFragment(page, `/component.sql?component=${component}`, root); - } -} +import { + checkModal, + checkTableFiltering, + checkTableSort, + checkToastNotifications, +} from "./component-assertions.ts"; test("Open documentation", async ({ page }) => { await page.goto("/"); @@ -170,184 +145,11 @@ test("map", async ({ page }) => { await expect(page.locator(".leaflet-marker-icon").first()).toBeVisible(); }); -componentTest( - "toast notifications initialize, stack, dismiss, and render safely", -)(async ({ page, mode }) => { - await openComponent(page, "toast", mode); - - const automatic = page.locator("#toast-auto"); - await expect(automatic).toBeVisible(); - await expect(automatic).toHaveAttribute("data-bs-delay", "5000"); - await expect(automatic).toHaveAttribute("data-bs-autohide", "true"); - await expect( - automatic.getByRole("button", { name: "Close notification" }), - ).toBeVisible(); - await expect(page.locator(".toast.show")).toHaveCount(1); - const automaticHandle = await automatic.elementHandle(); - await automatic.getByRole("button", { name: "Close notification" }).click(); - await expect(automatic).toBeHidden(); - await expect(page.locator("main")).toBeFocused(); - - const stackOne = page.locator("#toast-stack-one"); - const stackTwo = page.locator("#toast-stack-two"); - await expect(stackOne).toBeHidden(); - await page.evaluate(() => { - document.addEventListener("shown.bs.toast", (event) => { - const toast = event.target as HTMLElement; - toast.dataset.shownCount = String( - Number(toast.dataset.shownCount ?? 0) + 1, - ); - }); - }); - await page.getByRole("button", { name: "Show queued notifications" }).click(); - await expect(stackOne).toBeVisible(); - await expect(stackTwo).toBeVisible(); - await expect(stackOne).toHaveAttribute("data-shown-count", "1"); - await expect(stackTwo).toHaveAttribute("data-shown-count", "1"); - await expect(page.locator("#toast-short")).toHaveAttribute( - "data-shown-count", - "1", - ); - await page.evaluate(() => - document.dispatchEvent(new CustomEvent("fragment-loaded")), - ); - await page.waitForTimeout(250); - await expect(page.locator("#toast-short")).toHaveAttribute( - "data-shown-count", - "1", - ); - expect(decodeURIComponent(new URL(page.url()).hash)).toBe( - "#queued notifications", - ); - const stackContainer = stackOne.locator("xpath=.."); - await expect(stackContainer).toHaveAttribute( - "data-sqlpage-toast-position", - "top-end", - ); - await expect(stackContainer).toHaveClass(/\bmh-100\b/); - await expect(stackContainer).toHaveClass(/\boverflow-auto\b/); - expect( - await stackContainer.evaluate( - (container) => container.parentElement === document.body, - ), - ).toBe(true); - expect( - await stackTwo - .locator("xpath=..") - .getAttribute("data-sqlpage-toast-position"), - ).toBe("top-end"); - const firstBox = await stackOne.boundingBox(); - const secondBox = await stackTwo.boundingBox(); - expect(firstBox).not.toBeNull(); - expect(secondBox).not.toBeNull(); - expect(secondBox?.y).toBeGreaterThanOrEqual( - (firstBox?.y ?? 0) + (firstBox?.height ?? 0), - ); - - const temporary = page.locator("#toast-short"); - await expect(temporary).toHaveAttribute("data-bs-delay", "2000"); - await expect(temporary).toBeVisible(); - await expect(temporary).toBeHidden({ timeout: 5000 }); - await expect(stackOne).toBeVisible(); - await expect(automatic).toBeHidden({ timeout: 7000 }); - expect( - await automaticHandle?.evaluate((element) => element.isConnected), - ).toBe(false); - - const dismissible = page.locator("#toast-dismissible"); - await expect(dismissible).toHaveAttribute("data-bs-delay", "0"); - await expect(dismissible).toHaveAttribute("data-bs-autohide", "false"); - await page.getByRole("button", { name: "Show dismissible error" }).click(); - const closeButton = dismissible.getByRole("button", { - name: "Close notification", - }); - await expect(closeButton).toBeVisible(); - const closeStyle = await closeButton.evaluate((button) => { - const style = getComputedStyle(button); - const toastStyle = getComputedStyle( - button.closest(".toast") as HTMLElement, - ); - return { - backgroundColor: style.backgroundColor, - color: style.color, - filter: style.filter, - maskImage: style.maskImage, - toastColor: toastStyle.color, - }; - }); - expect(closeStyle.filter).toBe("none"); - expect(closeStyle.maskImage).not.toBe("none"); - expect(closeStyle.color).toBe(closeStyle.toastColor); - expect(closeStyle.backgroundColor).toBe(closeStyle.toastColor); - await page.evaluate(() => { - window.history.replaceState({ toastTest: true }, "", window.location.href); - }); - await dismissible.getByRole("button", { name: "Close notification" }).click(); - await expect(dismissible).toBeHidden(); - expect(await page.evaluate(() => window.history.state)).toEqual({ - toastTest: true, - }); - await page.getByRole("button", { name: "Show dismissible error" }).click(); - await expect(dismissible).toBeVisible(); - await dismissible.getByRole("button", { name: "Close notification" }).click(); - await page - .getByRole("button", { name: "Show non-dismissible status" }) - .click(); - await expect( - page.locator("#toast-nondismissible").getByRole("button"), - ).toHaveCount(0); - - await page.getByRole("button", { name: "Show rich notifications" }).click(); - await expect(page.locator("#toast-markdown strong")).toHaveText("2.0"); - await expect(page.locator("#toast-markdown a")).toHaveAttribute( - "href", - "https://example.com/releases", - ); - const linkStyle = await page - .locator("#toast-markdown a") - .evaluate((link) => ({ - color: getComputedStyle(link).color, - parentColor: getComputedStyle(link.parentElement as HTMLElement).color, - textDecorationLine: getComputedStyle(link).textDecorationLine, - })); - expect(linkStyle.color).toBe(linkStyle.parentColor); - expect(linkStyle.textDecorationLine).toBe("underline"); - await expect(page.locator("#toast-plain strong")).toHaveCount(0); - await expect(page.locator("#toast-plain")).toContainText( - "Plain text stays escaped", - ); - const whiteToast = page.locator("#toast-plain"); - const whiteToastStyle = await whiteToast.evaluate((toast) => { - const style = getComputedStyle(toast); - const closeStyle = getComputedStyle( - toast.querySelector(".btn-close") as HTMLElement, - ); - const rgba = (color: string) => { - const canvas = document.createElement("canvas"); - const context = canvas.getContext("2d"); - if (!context) throw new Error("Canvas 2D context is unavailable"); - context.fillStyle = color; - context.fillRect(0, 0, 1, 1); - return Array.from(context.getImageData(0, 0, 1, 1).data); - }; - return { - backgroundColor: rgba(style.backgroundColor), - closeColor: rgba(closeStyle.backgroundColor), - color: rgba(style.color), - }; - }); - expect(whiteToastStyle.backgroundColor).toEqual([255, 255, 255, 255]); - expect(whiteToastStyle.color).toEqual([31, 41, 55, 255]); - expect(whiteToastStyle.closeColor).toEqual(whiteToastStyle.color); - - const bottomContainer = page.locator( - '[data-sqlpage-toast-position="bottom-center"]', - ); - await page.getByRole("button", { name: "Show bottom notification" }).click(); - await expect(page.locator("#toast-bottom-center")).toBeVisible(); - await expect(bottomContainer).toHaveClass(/\bbottom-0\b/); - await expect(bottomContainer).toHaveClass(/\bstart-50\b/); - await expect(bottomContainer).toHaveClass(/\btranslate-middle-x\b/); +test("toast notifications initialize, stack, dismiss, and render safely (page)", async ({ + page, +}) => { + await page.goto("/documentation.sql?component=toast#component"); + await checkToastNotifications(page); }); test("form example", async ({ page }) => { @@ -400,74 +202,31 @@ test("Authentication example", async ({ page }) => { await expect(page.getByText("You are logged in as admin")).toBeVisible(); }); -componentTest("table filtering")(async ({ page, mode }) => { - await openComponent(page, "table", mode, null); - const tableSection = page.locator(".card", { - has: page.getByRole("cell", { name: "Chart", exact: true }), - }); +test("table filtering (page)", async ({ page }) => { + await page.goto("/documentation.sql?component=table"); + await checkTableFiltering(page); +}); - if (mode === "fragment") { - await tableSection - .locator(".card-body") - .dispatchEvent("fragment-loaded", { bubbles: true }); - await expect(page.locator('[data-pre-init="table"]')).not.toHaveCount(0); - } - const searchInput = tableSection.getByPlaceholder("Search…"); - await searchInput.fill("chart"); - const chartCell = tableSection.getByRole("cell", { name: "Chart" }); - await expect(chartCell).toBeVisible(); - await expect(chartCell).toHaveClass(/\b_col_name\b/); - await expect(chartCell).toHaveCSS("vertical-align", "middle"); - await expect( - tableSection.getByRole("cell", { name: "Table" }), - ).not.toBeVisible(); +test("table sorts a column when its header is clicked (page)", async ({ + page, +}) => { + await page.goto("/documentation.sql?component=table"); + await checkTableSort(page, "id"); }); -const sortableTable = async (page: Page, mode: ComponentMode) => { - await openComponent(page, "table", mode, "document"); - return page.locator(".table-responsive", { - has: page.getByRole("cell", { name: "31456" }), - }); -}; - -const numbersInColumn = async (table: Locator, cells: string) => { - const texts = await table.locator(cells).allInnerTexts(); - expect(texts.length).toBeGreaterThan(1); - return texts.map((text) => Number.parseInt(text.replace(/[^0-9]/g, ""), 10)); -}; - -const ascending = (values: number[]) => [...values].sort((a, b) => a - b); - -componentTest("table sorts a column when its header is clicked")( - async ({ page, mode }) => { - const table = await sortableTable(page, mode); - await table.getByRole("button", { name: "id" }).click(); - - const ids = await numbersInColumn(table, "td._col_id"); - expect(ids).toEqual(ascending(ids)); - }, -); - -componentTest("table reverses the sort when the header is clicked again")( - async ({ page, mode }) => { - const table = await sortableTable(page, mode); - await table.getByRole("button", { name: "id" }).click(); - await table.getByRole("button", { name: "id" }).click(); - - const ids = await numbersInColumn(table, "td._col_id"); - expect(ids).toEqual(ascending(ids).reverse()); - }, -); - -componentTest("table sorts a column of formatted numbers by value")( - async ({ page, mode }) => { - const table = await sortableTable(page, mode); - await table.getByRole("button", { name: "Amount in stock" }).click(); - - const amounts = await numbersInColumn(table, "td._col_Amount_in_stock"); - expect(amounts).toEqual(ascending(amounts)); - }, -); +test("table reverses the sort when the header is clicked again (page)", async ({ + page, +}) => { + await page.goto("/documentation.sql?component=table"); + await checkTableSort(page, "id", true); +}); + +test("table sorts a column of formatted numbers by value (page)", async ({ + page, +}) => { + await page.goto("/documentation.sql?component=table"); + await checkTableSort(page, "Amount in stock"); +}); async function checkNoConsoleErrors(page: Page, component: string) { const errors: string[] = []; @@ -669,23 +428,9 @@ test("form type=select searchable=true", async ({ page }) => { await expect(page.getByText(":region = SA", { exact: true })).toBeVisible(); }); -componentTest("modal")(async ({ page, mode }) => { - await openComponent(page, "modal", mode); - await expect(page.locator("body > #my_modal")).toBeAttached(); - await expect(page.locator("body > #my_embed_form_modal")).toBeAttached(); - const openButton = page.getByRole("button", { name: "Open a simple modal" }); - await openButton.click(); - - const modal = page.getByRole("dialog", { name: "A modal box" }); - await expect(modal).toBeVisible(); - - await page.keyboard.press("Escape"); - await expect(modal).not.toBeVisible(); - - await openButton.click(); - await expect(modal).toBeVisible(); - await modal.getByRole("button", { name: "Close" }).first().click(); - await expect(modal).not.toBeVisible(); +test("modal (page)", async ({ page }) => { + await page.goto("/documentation.sql?component=modal#component"); + await checkModal(page); }); test("table action buttons - edit_url and delete_url", async ({ page }) => {