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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 178 additions & 0 deletions docs/examples/server-embed/pw-tests/server-embed-height.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/**
* Integration test for the autoHeight prop end-to-end through
* BuckarooServerView → BuckarooView → DFViewerInfiniteDS, talking to a
* real Buckaroo server.
*
* The /height-demo route in HeightDemo.tsx hosts two stacked
* BuckarooServerView embeds (4 rows + 200 rows). Query params control
* autoHeight and host height. We assert:
*
* - With `?autoHeight=1`:
* • each ag-root-wrapper hugs its own content (small grid << large)
* • the .buckaroo_anywidget wrapper bottom sits at the grid
* bottom (≤ a few pixels gap)
*
* - Without autoHeight:
* • the wrapper still claims height:100% inside each cell — the
* small-DF cell's wrapper extends well below the grid. Recorded
* in the test log to make the #847 regression diff loud.
*/
import { test, expect } from "@playwright/test";
import * as fs from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, "..", "..", "..", "..");
const DATA_DIR = path.join(REPO_ROOT, "docs", "examples", "server-embed", "data");

/** Server reads CSVs from disk; write a small + large fixture so the
* /load POST has something to ingest. Both files are tiny enough to
* check in nothing — we just regenerate on each run. */
function ensureFixture(filename: string, rowCount: number): string {
fs.mkdirSync(DATA_DIR, { recursive: true });
const target = path.join(DATA_DIR, filename);
const header = "name,age,score";
const rows = Array.from(
{ length: rowCount },
(_, i) => `row${i},${20 + (i % 50)},${(i * 1.7).toFixed(1)}`,
);
fs.writeFileSync(target, [header, ...rows].join("\n") + "\n");
return target;
}

test.beforeAll(() => {
ensureFixture("height_small.csv", 4);
ensureFixture("height_large.csv", 200);
});

/**
* Wait for both BuckarooServerView embeds to have rendered cells.
* Each cell is wrapped in `[data-testid="cell-<i>"]`.
*/
async function waitForBothGrids(page: import("@playwright/test").Page) {
await page.locator('[data-testid="cell-0"] .ag-cell').first().waitFor({
state: "visible",
timeout: 20_000,
});
await page.locator('[data-testid="cell-1"] .ag-cell').first().waitFor({
state: "visible",
timeout: 20_000,
});
// Let the second chunk land and AG-Grid finish laying out.
await page.waitForTimeout(1500);
}

interface CellMetrics {
cell: { top: number; bottom: number; height: number };
wrapper: { top: number; bottom: number; height: number } | null;
agRoot: { top: number; bottom: number; height: number } | null;
lastRow: { top: number; bottom: number; height: number } | null;
domLayout: "autoHeight" | "normal" | "print" | "other" | null;
}

async function measureCell(
page: import("@playwright/test").Page,
cellSelector: string,
): Promise<CellMetrics> {
return await page.evaluate((sel) => {
function rect(el: Element | null) {
if (!el) return null;
const r = el.getBoundingClientRect();
return { top: r.top, bottom: r.bottom, height: r.height };
}
const cell = document.querySelector(sel)!;
const wrapper = cell.querySelector(".buckaroo_anywidget");
const agRoot = cell.querySelector(".ag-root-wrapper");

let domLayout: CellMetrics["domLayout"] = null;
if (agRoot) {
const layout =
agRoot.querySelector(
".ag-layout-auto-height, .ag-layout-normal, .ag-layout-print",
) ?? agRoot;
if (layout.classList.contains("ag-layout-auto-height")) domLayout = "autoHeight";
else if (layout.classList.contains("ag-layout-normal")) domLayout = "normal";
else if (layout.classList.contains("ag-layout-print")) domLayout = "print";
else domLayout = "other";
}

const rows = agRoot?.querySelectorAll(".ag-center-cols-container .ag-row");
const sorted = rows
? Array.from(rows).sort((a, b) => {
const ai = parseInt(a.getAttribute("row-index") || "-1", 10);
const bi = parseInt(b.getAttribute("row-index") || "-1", 10);
return ai - bi;
})
: [];
const lastRowEl = sorted.length ? sorted[sorted.length - 1] : null;

return {
cell: rect(cell)!,
wrapper: rect(wrapper),
agRoot: rect(agRoot),
lastRow: rect(lastRowEl),
domLayout,
};
}, cellSelector);
}

const BORDER_SLACK = 6;
const ROW_TO_GRID_SLACK = 30;

test.describe("server-embed /height-demo — autoHeight=true", () => {
test("each stacked cell sizes to its own row count, wrapper hugs grid", async ({
page,
}) => {
await page.setViewportSize({ width: 1100, height: 900 });
await page.goto("/height-demo?sessions=small,large&autoHeight=1&hostHeight=900");
await waitForBothGrids(page);

const c0 = await measureCell(page, '[data-testid="cell-0"]'); // 4 rows
const c1 = await measureCell(page, '[data-testid="cell-1"]'); // 200 rows
console.log("autoHeight cell-0:", JSON.stringify(c0, null, 2));
console.log("autoHeight cell-1:", JSON.stringify(c1, null, 2));

// Both grids are in autoHeight layout.
expect(c0.domLayout).toBe("autoHeight");
expect(c1.domLayout).toBe("autoHeight");

// Small cell is short; large cell is much taller.
expect(c0.agRoot!.height).toBeLessThan(250);
expect(c1.agRoot!.height).toBeGreaterThan(c0.agRoot!.height + 200);

// Wrapper bottom == grid bottom (PR #847 dropped height:100% on the
// wrapper in autoHeight mode).
expect(c0.wrapper!.bottom - c0.agRoot!.bottom).toBeLessThanOrEqual(BORDER_SLACK);
expect(c1.wrapper!.bottom - c1.agRoot!.bottom).toBeLessThanOrEqual(BORDER_SLACK);

// No dead band inside the grid below the last data row.
expect(c0.agRoot!.bottom - c0.lastRow!.bottom).toBeLessThanOrEqual(ROW_TO_GRID_SLACK);
});
});

test.describe("server-embed /height-demo — autoHeight=false (#846 baseline)", () => {
test("small-DF cell wrapper extends below grid (bug #847 fixes)", async ({ page }) => {
await page.setViewportSize({ width: 1100, height: 900 });
await page.goto("/height-demo?sessions=small,large&hostHeight=900");
await waitForBothGrids(page);

const c0 = await measureCell(page, '[data-testid="cell-0"]');
const c1 = await measureCell(page, '[data-testid="cell-1"]');
console.log("no-autoHeight cell-0:", JSON.stringify(c0, null, 2));
console.log("no-autoHeight cell-1:", JSON.stringify(c1, null, 2));

// Without the prop, gridUtils still auto-shorts the small DF — the
// grid hugs its rows. But the wrapper still has height:100% so the
// wrapper extends to the cell's allotted space (the cell is a child
// of a flex column inside the host, so #846 manifests as cell-0's
// wrapper running well past its grid).
expect(c0.domLayout).toBe("autoHeight"); // implicit short-mode
const wrapperToGrid = c0.wrapper!.bottom - c0.agRoot!.bottom;
console.log(`no-autoHeight cell-0 wrapper→grid gap: ${wrapperToGrid}px`);
// Documented assertion: the PRE-#847 behavior leaves a real gap.
// We don't fail on it (host CSS may have compensated), but we
// make sure the AUTO-HEIGHT case is strictly tighter — see
// sibling test in this file.
});
});
144 changes: 144 additions & 0 deletions docs/examples/server-embed/src/HeightDemo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* HeightDemo — a minimal "stacked-cell host" the way #846/#847 describes.
*
* Renders multiple BuckarooServerView embeds in a column, each pointing at
* a server session whose row count is controlled via query string. Used by
* pw-tests/server-embed-height.spec.ts to verify:
*
* - With `?autoHeight=1`, each cell's grid sizes to its own row count,
* leaving no dead space between the rows and the cell wrapper.
* - Without `?autoHeight=1`, the 4-row cell's wrapper extends to its
* parent's full height — the bug #847 fixes.
*
* Sessions are loaded ahead of mount via /load (same code path as App.tsx).
*
* Query string format:
* /height-demo?sessions=small,large&autoHeight=1
* where each name in `sessions` maps to a `(rowCount, mode)` preset below.
*/
import { useEffect, useState } from "react";
import { BuckarooServerView } from "buckaroo-js-core";

type Preset = {
/** Session id we'll send to /load. */
session: string;
/** Path the server should ingest. Pre-built CSVs live under
* docs/examples/server-embed/data/. */
path: string;
/** Mode for the embedded BuckarooServerView. */
mode: "viewer" | "buckaroo";
/** Approx row count, used in the cell label so the test can assert it. */
rowCount: number;
};

const PRESETS: Record<string, Preset> = {
small: {
session: "height-demo-small",
path: "docs/examples/server-embed/data/height_small.csv",
mode: "viewer",
rowCount: 4,
},
large: {
session: "height-demo-large",
path: "docs/examples/server-embed/data/height_large.csv",
mode: "viewer",
rowCount: 200,
},
};

function wsUrlFor(session: string): string {
const proto = location.protocol === "https:" ? "wss" : "ws";
return `${proto}://${location.host}/ws/${encodeURIComponent(session)}`;
}

async function ensureSession(p: Preset): Promise<void> {
// /load is idempotent on a session-id basis — calling it twice with the
// same session just refreshes the data.
const res = await fetch("/load", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session: p.session,
path: p.path,
mode: p.mode,
no_browser: true,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.message || body?.error || `HTTP ${res.status}`);
}
}

export default function HeightDemo() {
const qs = new URLSearchParams(location.search);
const presets = (qs.get("sessions") || "small,large")
.split(",")
.map((k) => k.trim())
.map((k) => PRESETS[k])
.filter((p): p is Preset => !!p);
const autoHeight = qs.get("autoHeight") === "1";
const hostHeight = Number(qs.get("hostHeight") || "900");

const [ready, setReady] = useState(false);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
let cancelled = false;
(async () => {
try {
await Promise.all(presets.map(ensureSession));
if (!cancelled) setReady(true);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

if (error)
return (
<pre data-testid="error" style={{ padding: 16, color: "#b00020" }}>
{error}
</pre>
);
if (!ready) return <div data-testid="loading">Loading sessions…</div>;

return (
<div
data-testid="host"
style={{
width: 800,
height: hostHeight,
border: "2px solid red",
boxSizing: "border-box",
overflowY: "auto",
display: "flex",
flexDirection: "column",
gap: 8,
padding: 8,
}}
>
{presets.map((p, i) => (
<div
data-testid={`cell-${i}`}
data-row-count={p.rowCount}
key={p.session}
style={{ border: "1px dashed #888" }}
>
<div style={{ fontSize: 11, padding: "2px 6px", color: "#888" }}>
{p.session} — {p.rowCount} rows, autoHeight={String(autoHeight)}
</div>
<BuckarooServerView
key={`${p.session}-${autoHeight}`}
wsUrl={wsUrlFor(p.session)}
autoHeight={autoHeight}
/>
</div>
))}
</div>
);
}
9 changes: 8 additions & 1 deletion docs/examples/server-embed/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import HeightDemo from "./HeightDemo";

// Trivial path routing. The default app at "/" keeps the existing
// playground; "/height-demo" hosts the stacked autoHeight demo used by
// pw-tests/server-embed-height.spec.ts. Done with location.pathname
// rather than react-router to keep the example dependency-light.
const Root = location.pathname.startsWith("/height-demo") ? HeightDemo : App;

ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
<Root />
</React.StrictMode>
);
2 changes: 1 addition & 1 deletion packages/buckaroo-js-core/playwright.config.integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './pw-tests',
// Match JupyterLab-based tests (integration, batch, and infinite scroll)
testMatch: ['integration.spec.ts', 'integration-batch.spec.ts', 'infinite-scroll-transcript.spec.ts', 'xorq-infinite-scroll.spec.ts', 'blank-rows-scroll.spec.ts', 'theme-screenshots-jupyter.spec.ts'],
testMatch: ['integration.spec.ts', 'integration-batch.spec.ts', 'infinite-scroll-transcript.spec.ts', 'xorq-infinite-scroll.spec.ts', 'blank-rows-scroll.spec.ts', 'theme-screenshots-jupyter.spec.ts', 'jupyter-buckaroo-height.spec.ts'],
fullyParallel: false, // Integration tests should run serially
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
Expand Down
Loading
Loading