diff --git a/.gitignore b/.gitignore index 08f6840b..d2d73b2d 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,8 @@ node_modules .env .env.local .env.*.local + +# Playwright test artifacts +test-results/ +playwright-report/ +tests/e2e/fixtures/.ipynb_checkpoints diff --git a/package-lock.json b/package-lock.json index 9cd46ba9..158ad258 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "@deck.gl/core": "^9.2.1", "@deck.gl/extensions": "^9.2.1", "@deck.gl/layers": "^9.2.1", + "@deck.gl/mapbox": "^9.2.1", "@deck.gl/react": "^9.2.1", "@geoarrow/deck.gl-layers": "^0.4.0-beta.1", "@geoarrow/geoarrow-js": "^0.3.2", @@ -33,6 +34,7 @@ "@anywidget/types": "^0.2.0", "@eslint/js": "^9.36.0", "@jupyter-widgets/base": "^6.0.10", + "@playwright/test": "^1.55.1", "@statelyai/inspect": "^0.4.0", "@types/lodash": "^4.17.13", "@types/lodash.debounce": "^4.0.9", @@ -1396,6 +1398,22 @@ "@luma.gl/engine": "^9.2.2" } }, + "node_modules/@deck.gl/mapbox": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@deck.gl/mapbox/-/mapbox-9.2.1.tgz", + "integrity": "sha512-6JsXV8TQxoucWFZDCSNaTf09kWGV8J63ImebqMMrAOuBi1Hpa98+PEcTNp90cnOrOymQmNJjefw7dgTYrRhavQ==", + "license": "MIT", + "dependencies": { + "@luma.gl/constants": "^9.2.2", + "@math.gl/web-mercator": "^4.1.0" + }, + "peerDependencies": { + "@deck.gl/core": "^9.1.0", + "@luma.gl/constants": "^9.2.2", + "@luma.gl/core": "^9.2.2", + "@math.gl/web-mercator": "^4.1.0" + } + }, "node_modules/@deck.gl/mesh-layers": { "version": "9.2.1", "resolved": "https://registry.npmjs.org/@deck.gl/mesh-layers/-/mesh-layers-9.2.1.tgz", @@ -4768,6 +4786,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.0.tgz", + "integrity": "sha512-Tzh95Twig7hUwwNe381/K3PggZBZblKUe2wv25oIpzWLr6Z0m4KgV1ZVIjnR6GM9ANEqjZD7XsZEa6JL/7YEgg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.56.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@probe.gl/env": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@probe.gl/env/-/env-4.1.0.tgz", @@ -11445,6 +11479,53 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0.tgz", + "integrity": "sha512-X5Q1b8lOdWIE4KAoHpW3SE8HvUB+ZZsUoN64ZhjnN8dOb1UpujxBtENGiZFE+9F/yhzJwYa+ca3u43FeLbboHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.56.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.0.tgz", + "integrity": "sha512-1SXl7pMfemAMSDn5rkPeZljxOCYAmQnYLBTExuh6E8USHXGSX3dx6lYZN/xPpTz1vimXmPA9CDnILvmJaB8aSQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index fe310a9d..031c3317 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "@deck.gl/core": "^9.2.1", "@deck.gl/extensions": "^9.2.1", "@deck.gl/layers": "^9.2.1", + "@deck.gl/mapbox": "^9.2.1", "@deck.gl/react": "^9.2.1", "@geoarrow/deck.gl-layers": "^0.4.0-beta.1", "@geoarrow/geoarrow-js": "^0.3.2", @@ -32,6 +33,7 @@ "@anywidget/types": "^0.2.0", "@eslint/js": "^9.36.0", "@jupyter-widgets/base": "^6.0.10", + "@playwright/test": "^1.55.1", "@statelyai/inspect": "^0.4.0", "@types/lodash.debounce": "^4.0.9", "@types/lodash.throttle": "^4.1.9", @@ -75,7 +77,11 @@ "prettier:check": "prettier './src/**/*.{ts,tsx,css}' --check", "prettier": "prettier './src/**/*.{ts,tsx,css}' --write", "lint": "eslint src", - "test": "vitest run" + "test": "vitest run", + "pretest:e2e": "npm run build", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "jupyter:test": "uv run --group dev jupyter lab --no-browser --port=8889 --notebook-dir=tests/e2e/fixtures --IdentityProvider.token=''" }, "volta": { "node": "22.20.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..68cd0269 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,37 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + testMatch: '**/*.spec.ts', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + timeout: 60000, + expect: { + timeout: 30000, + }, + reporter: 'list', + + use: { + baseURL: 'http://localhost:8889', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + navigationTimeout: 30000, + browserName: 'chromium', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + + webServer: { + command: 'uv run --group dev jupyter lab --no-browser --port=8889 --notebook-dir=tests/e2e/fixtures --IdentityProvider.token=""', + url: 'http://localhost:8889', + reuseExistingServer: false, // Always restart for clean state + timeout: 30000, + }, +}); diff --git a/src/index.tsx b/src/index.tsx index 8610df72..bc4aff25 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { useEffect, useCallback, useState } from "react"; +import { useEffect, useCallback, useState, useRef } from "react"; import { createRender, useModelState, useModel } from "@anywidget/react"; import type { Initialize, Render } from "@anywidget/types"; import Map from "react-map-gl/maplibre"; @@ -24,6 +24,7 @@ import Toolbar from "./toolbar.js"; import throttle from "lodash.throttle"; import SidePanel from "./sidepanel/index"; import { getTooltip } from "./tooltip/index.js"; +import { DeckGLRef } from "@deck.gl/react"; await initParquetWasm(); @@ -94,6 +95,15 @@ function App() { const [justClicked, setJustClicked] = useState(false); + // Expose DeckGL instance on window for Playwright e2e tests + const deckRef = useRef(null); + useEffect(() => { + if (deckRef.current && typeof window !== "undefined") { + (window as unknown as Record).__deck = + deckRef.current.deck; + } + }, [deckRef.current]); + const model = useModel(); const [mapStyle] = useModelState("basemap_style"); @@ -243,6 +253,7 @@ function App() { )}
diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 00000000..68d9d932 --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,33 @@ +# End-to-End Tests + +Playwright tests for Lonboard widgets in JupyterLab. + +## Running Tests + +```bash +npm run test:e2e # Run all tests +npm run test:e2e:ui # Run with UI mode +npm run jupyter:test # Start test JupyterLab manually (port 8889) +``` + +## Architecture + +- Tests run on port 8889 (isolated from dev on 8888) +- Fresh JupyterLab server per test run +- Fixtures in `tests/e2e/fixtures/` + +## DeckGL Canvas Interactions + +Playwright mouse events don't trigger DeckGL handlers. Use helpers from `helpers/deckgl/`: + +```typescript +import { deckPointerEvent } from "./helpers/deckgl"; + +// Use canvas-relative coordinates (pixels from canvas top-left corner) +await deckPointerEvent(page, "click", 200, 300); +await deckPointerEvent(page, "hover", 400, 500); +``` + +The helpers automatically convert pixel coordinates to geographic coordinates and invoke DeckGL event handlers. See JSDoc comments in `helpers/deckgl/interactions.ts` for implementation details. + +Example: `bbox-select.spec.ts` diff --git a/tests/e2e/bbox-select.spec.ts b/tests/e2e/bbox-select.spec.ts new file mode 100644 index 00000000..7bcd286a --- /dev/null +++ b/tests/e2e/bbox-select.spec.ts @@ -0,0 +1,64 @@ +import { test, expect, Page } from "@playwright/test"; +import { deckPointerEvent } from "./helpers/deckgl"; +import { + openNotebookFresh, + runFirstNCells, + executeCellAndWaitForOutput, +} from "./helpers/notebook"; +import { validateBounds } from "./helpers/assertions"; + +/** + * Draws a bounding box on the DeckGL canvas by clicking start and end positions. + */ +async function drawBbox( + page: Page, + start: { x: number; y: number }, + end: { x: number; y: number }, +) { + // Click to set bbox start position + await deckPointerEvent(page, "click", start.x, start.y); + await page.waitForTimeout(300); + + // Hover to preview bbox size + await deckPointerEvent(page, "hover", end.x, end.y); + await page.waitForTimeout(300); + + // Click to set bbox end position and complete drawing + await deckPointerEvent(page, "click", end.x, end.y); + await page.waitForTimeout(500); +} + +test.describe("BBox selection", () => { + test("draws bbox and syncs selected_bounds to Python", async ({ page }) => { + const { notebookRoot } = await openNotebookFresh(page, "simple-map.ipynb", { + workspaceId: `bbox-${Date.now()}`, + }); + await runFirstNCells(page, notebookRoot, 2); + await page.waitForTimeout(2000); + + // Start bbox selection mode + const bboxButton = page.getByRole("button", { name: "Select BBox" }); + await expect(bboxButton).toBeVisible({ timeout: 10000 }); + await bboxButton.click(); + + // Verify drawing mode is active + const cancelButton = page.getByRole("button", { name: "Cancel drawing" }); + await expect(cancelButton).toBeVisible({ timeout: 5000 }); + + // Draw bbox using canvas-relative coordinates (pixels from canvas top-left) + await drawBbox(page, { x: 200, y: 200 }, { x: 400, y: 400 }); + + // Verify bbox was drawn + const clearButton = page.getByRole("button", { + name: "Clear bounding box", + }); + await expect(clearButton).toBeVisible({ timeout: 2000 }); + + // Execute cell to check selected bounds + const output = await executeCellAndWaitForOutput(notebookRoot, 2); + + // Verify bounds are valid geographic coordinates + const outputText = await output.textContent(); + validateBounds(outputText); + }); +}); diff --git a/tests/e2e/fixtures/simple-map.ipynb b/tests/e2e/fixtures/simple-map.ipynb new file mode 100644 index 00000000..38af05c1 --- /dev/null +++ b/tests/e2e/fixtures/simple-map.ipynb @@ -0,0 +1,83 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import geopandas as gpd\n", + "from shapely.geometry import Point\n", + "\n", + "from lonboard import Map, ScatterplotLayer" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Simple test data - 4 points in a grid\n", + "points = [\n", + " Point(-10, -10),\n", + " Point(-10, 10),\n", + " Point(10, -10),\n", + " Point(10, 10),\n", + "]\n", + "gdf = gpd.GeoDataFrame(geometry=points, crs=\"EPSG:4326\")\n", + "\n", + "layer = ScatterplotLayer.from_geopandas(\n", + " gdf,\n", + " get_fill_color=[255, 0, 0],\n", + " get_radius=100000,\n", + ")\n", + "\n", + "m = Map(layer, view_state={\"longitude\": 0, \"latitude\": 0, \"zoom\": 2})\n", + "m" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Check selected bounds (run after bbox selection)\nf\"Selected bounds: {m.selected_bounds}\"" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.6" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/tests/e2e/helpers/assertions.ts b/tests/e2e/helpers/assertions.ts new file mode 100644 index 00000000..c5eb8a05 --- /dev/null +++ b/tests/e2e/helpers/assertions.ts @@ -0,0 +1,45 @@ +import { expect } from "@playwright/test"; + +/** + * Validates that bounds text contains valid geographic coordinates. + * Expects format: "Selected bounds: (minLon, minLat, maxLon, maxLat)" + * + * @param boundsText - Text output containing bounds coordinates + * @throws AssertionError if bounds are invalid + */ +export function validateBounds(boundsText: string | null) { + expect(boundsText, "Expected output to contain coordinates").not.toContain( + "None", + ); + expect(boundsText, "Expected output to not be zeros").not.toContain( + "(0, 0, 0, 0)", + ); + + // Extract and validate the actual bounds values (handles scientific notation and decimals) + const boundsMatch = boundsText?.match( + /Selected bounds: \(([-\deE.]+), ([-\deE.]+), ([-\deE.]+), ([-\deE.]+)\)/, + ); + expect( + boundsMatch, + `Expected to find bounds pattern in: ${boundsText}`, + ).toBeTruthy(); + if (boundsMatch) { + const [, minLon, minLat, maxLon, maxLat] = boundsMatch.map(Number); + + // Verify we have valid coordinate ranges + expect(minLon, "minLon should not be zero").not.toBe(0); + expect(minLat, "minLat should not be zero").not.toBe(0); + expect(maxLon, "maxLon should not be zero").not.toBe(0); + expect(maxLat, "maxLat should not be zero").not.toBe(0); + + // Verify min < max for both dimensions + expect(minLon, "minLon should be less than maxLon").toBeLessThan(maxLon); + expect(minLat, "minLat should be less than maxLat").toBeLessThan(maxLat); + + // Verify coordinates are within valid lat/lon ranges + expect(minLon, "minLon should be >= -180").toBeGreaterThanOrEqual(-180); + expect(maxLon, "maxLon should be <= 180").toBeLessThanOrEqual(180); + expect(minLat, "minLat should be >= -90").toBeGreaterThanOrEqual(-90); + expect(maxLat, "maxLat should be <= 90").toBeLessThanOrEqual(90); + } +} diff --git a/tests/e2e/helpers/deckgl/index.ts b/tests/e2e/helpers/deckgl/index.ts new file mode 100644 index 00000000..df349128 --- /dev/null +++ b/tests/e2e/helpers/deckgl/index.ts @@ -0,0 +1,6 @@ +/** + * DeckGL-specific test helpers for Playwright e2e tests. + */ + +export * from "./types"; +export * from "./interactions"; diff --git a/tests/e2e/helpers/deckgl/interactions.ts b/tests/e2e/helpers/deckgl/interactions.ts new file mode 100644 index 00000000..771b6468 --- /dev/null +++ b/tests/e2e/helpers/deckgl/interactions.ts @@ -0,0 +1,207 @@ +import type { Page } from "@playwright/test"; +import type { + PickingInfo, + DeckGLInstance, + WindowWithDeck, + Coordinate, + DeckGLEvent, +} from "./types"; + +/** + * @fileoverview DeckGL canvas interaction helpers for Playwright e2e tests. + * + * These helpers work by accessing the DeckGL instance via `window.__deck`, which is + * exposed in src/index.tsx:100 for testing purposes. This allows tests to directly + * invoke DeckGL's event handlers (onClick, onHover, etc.) with properly constructed + * event payloads, bypassing Playwright's DOM event system which doesn't reliably + * trigger DeckGL's event pipeline. + * + * All coordinate parameters use canvas-relative pixel coordinates (offsets from the + * canvas element's top-left corner), NOT page-relative coordinates. + * + * ## Common patterns used in browser context (inside page.evaluate): + * + * 1. **Access DeckGL instance:** + * ```ts + * const deck = (window as any).__deck; + * if (!deck) throw new Error("No deck instance on window"); + * ``` + * + * 2. **Unproject pixel coordinates to geographic coordinates:** + * ```ts + * const coordinate = (() => { + * if (info?.coordinate) return info.coordinate; + * try { + * const viewport = deck.getViewports()?.[0]; + * return viewport?.unproject?.([x, y]); + * } catch (e) { + * console.error("Failed to unproject coordinates:", e); + * } + * })(); + * ``` + */ + +/** + * Simulates pointer interactions on the DeckGL canvas at the specified coordinates. + * + * @param page - Playwright page object + * @param action - Type of pointer event: "click" or "hover" + * @param x - X coordinate in canvas-relative pixels (offset from canvas left edge) + * @param y - Y coordinate in canvas-relative pixels (offset from canvas top edge) + * + * @remarks + * Coordinates are canvas-relative, meaning they are pixel offsets from the top-left + * corner of the canvas element itself, NOT from the page. Even if the canvas is + * scrolled or positioned elsewhere in the page, always use canvas-relative coordinates. + * + * The function works by: + * 1. Using DeckGL's pickObject() to get object info at the position + * 2. Converting pixel coordinates to geographic coordinates via viewport.unproject() + * 3. Building a proper PickingInfo object with coordinate data + * 4. Invoking the appropriate DeckGL event handler (onClick or onHover) + * + * This approach bypasses the DOM event system entirely and works directly with + * DeckGL's event handlers, which is necessary because Playwright's mouse events + * don't reliably trigger DeckGL's event pipeline. + * + * @example + * ```typescript + * // Simulate a click + * await deckPointerEvent(page, "click", 200, 300); + * + * // Simulate a hover + * await deckPointerEvent(page, "hover", 400, 500); + * ``` + */ +export async function deckPointerEvent( + page: Page, + action: "click" | "hover", + x: number, + y: number, +): Promise { + await page.evaluate( + (args) => { + const { action, x, y } = args; + const win = window as unknown as WindowWithDeck; + const deck: DeckGLInstance = win.__deck; + + if (!deck) { + throw new Error("No deck instance on window"); + } + + let info: PickingInfo | undefined = deck.pickObject?.({ + x, + y, + radius: 2, + }); + + const coordinate: Coordinate | undefined = (() => { + if (info?.coordinate) return info.coordinate; + try { + const viewport = deck.getViewports?.()?.[0]; + return viewport?.unproject?.([x, y]); + } catch (e) { + console.error("Failed to unproject coordinates:", e); + return undefined; + } + })(); + + if (!info) { + info = { + x, + y, + object: null, + layer: null, + index: -1, + coordinate: action === "click" ? coordinate || [0, 0] : coordinate, + ...(action === "click" ? { pixel: [x, y] as [number, number] } : {}), + }; + } else if (!info.coordinate && coordinate) { + info.coordinate = coordinate; + } + + const isClick = action === "click"; + const type = isClick ? "click" : "pointermove"; + const buttons = isClick ? 1 : 0; + const srcEvent = new PointerEvent(type, { + clientX: x, + clientY: y, + buttons, + pointerType: "mouse", + bubbles: true, + }); + const evt: DeckGLEvent = { + type, + srcEvent, + center: [x, y], + offsetCenter: { x, y }, + }; + + if (isClick) { + deck.props.onClick?.(info, evt); + } else { + deck.props.onHover?.(info, evt); + } + }, + { action, x, y }, + ); +} + +export async function deckDrag( + page: Page, + start: { x: number; y: number }, + end: { x: number; y: number }, + steps: number = 5, +) { + await page.evaluate( + (args) => { + const { start, end, steps } = args; + const win = window as unknown as WindowWithDeck; + const deck: DeckGLInstance = win.__deck; + + if (!deck) { + throw new Error("No deck instance on window"); + } + + const makeEvent = ( + type: string, + x: number, + y: number, + buttons: number, + ): DeckGLEvent => { + const srcEvent = new PointerEvent(type, { + clientX: x, + clientY: y, + buttons, + pointerType: "mouse", + bubbles: true, + }); + return { + type, + srcEvent, + center: [x, y], + offsetCenter: { x, y }, + }; + }; + + if (deck.props.onDragStart) { + deck.props.onDragStart(makeEvent("pointerdown", start.x, start.y, 1)); + } + + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const x = start.x + (end.x - start.x) * t; + const y = start.y + (end.y - start.y) * t; + + if (deck.props.onDrag) { + deck.props.onDrag(makeEvent("pointermove", x, y, 1)); + } + } + + if (deck.props.onDragEnd) { + deck.props.onDragEnd(makeEvent("pointerup", end.x, end.y, 0)); + } + }, + { start, end, steps }, + ); +} diff --git a/tests/e2e/helpers/deckgl/types.ts b/tests/e2e/helpers/deckgl/types.ts new file mode 100644 index 00000000..7c12431d --- /dev/null +++ b/tests/e2e/helpers/deckgl/types.ts @@ -0,0 +1,78 @@ +/** + * @fileoverview Type definitions for DeckGL structures used in e2e tests. + * + * These types describe the runtime DeckGL objects accessed via window.__deck. + * They are not exhaustive - only the properties we use in tests are typed. + */ + +/** + * Geographic coordinate [longitude, latitude] + */ +export type Coordinate = [number, number]; + +/** + * Pixel coordinate [x, y] + */ +export type PixelCoordinate = [number, number]; + +/** + * Information about what was picked at a given screen coordinate. + * Returned by deck.pickObject() and passed to event handlers. + */ +export interface PickingInfo { + x: number; + y: number; + object: unknown | null; + layer: unknown | null; + index: number; + coordinate?: Coordinate; + pixel?: PixelCoordinate; +} + +/** + * DeckGL viewport for coordinate projection/unprojection + */ +export interface Viewport { + unproject?: (pixelCoords: PixelCoordinate) => Coordinate; +} + +/** + * DeckGL event handlers + */ +export interface DeckGLProps { + onClick?: (info: PickingInfo, event: DeckGLEvent) => void; + onHover?: (info: PickingInfo, event: DeckGLEvent) => void; + onDragStart?: (event: DeckGLEvent) => void; + onDrag?: (event: DeckGLEvent) => void; + onDragEnd?: (event: DeckGLEvent) => void; +} + +/** + * DeckGL event payload passed to handlers + */ +export interface DeckGLEvent { + type: string; + srcEvent: PointerEvent; + center: PixelCoordinate; + offsetCenter: { x: number; y: number }; +} + +/** + * The DeckGL instance exposed on window for testing + */ +export interface DeckGLInstance { + pickObject?: (options: { + x: number; + y: number; + radius?: number; + }) => PickingInfo | undefined; + getViewports?: () => Viewport[] | undefined; + props: DeckGLProps; +} + +/** + * Window with DeckGL instance attached for testing + */ +export interface WindowWithDeck extends Window { + __deck: DeckGLInstance; +} diff --git a/tests/e2e/helpers/notebook.ts b/tests/e2e/helpers/notebook.ts new file mode 100644 index 00000000..9b1b7450 --- /dev/null +++ b/tests/e2e/helpers/notebook.ts @@ -0,0 +1,238 @@ +/** + * @fileoverview JupyterLab notebook helpers for Playwright e2e tests. + * + * These helpers provide utilities for interacting with JupyterLab notebooks, + * including opening notebooks, executing cells, and waiting for map widgets + * to be ready for interaction. + */ + +import { expect, type Page, type Locator } from "@playwright/test"; +import type { WindowWithDeck } from "./deckgl"; + +/** + * Opens a notebook in JupyterLab and waits for it to be visible. + * + * @param page - Playwright page object + * @param notebookPath - Path to the notebook relative to JupyterLab root + * @returns Locator for the notebook element + * + * @example + * ```typescript + * const notebook = await openNotebook(page, "simple-map.ipynb"); + * ``` + */ +export async function openNotebook( + page: Page, + notebookPath: string, +): Promise { + await page.goto(`/lab/tree/${notebookPath}`); + + // Wait for notebook to load and get the visible one + // After navigation, JupyterLab should show the requested notebook + const notebook = page.locator(".jp-Notebook").first(); + await expect(notebook).toBeVisible({ timeout: 30000 }); + + return notebook; +} + +export type OpenNotebookOptions = { + workspaceId?: string; + timeoutMs?: number; +}; + +/** + * Robustly opens a notebook in a fresh JupyterLab workspace using command registry. + * Avoids hidden tabs and layout restore flakiness. + */ +export async function openNotebookFresh( + page: Page, + notebookPath: string, + opts: OpenNotebookOptions = {}, +): Promise<{ notebookRoot: Locator }> { + const { + workspaceId = `e2e-${Date.now()}-${Math.random().toString(36).slice(2)}`, + timeoutMs = 60000, + } = opts; + + // Load a unique workspace and directly open the target notebook URL within it + await page.goto( + `/lab/workspaces/${encodeURIComponent(workspaceId)}/tree/${notebookPath}`, + { waitUntil: "networkidle" }, + ); + + // Wait for the visible notebook element + const notebookRoot = page.locator(".jp-Notebook").first(); + await expect(notebookRoot).toBeVisible({ timeout: timeoutMs }); + return { notebookRoot }; +} + +/** Runs the first N cells via JupyterLab APIs to avoid keybinding flakiness. */ +export async function runFirstNCells( + page: Page, + notebook: Locator, + n = 2, +): Promise { + await notebook.locator(".jp-Cell").first().click(); + for (let i = 0; i < n; i++) { + await notebook.page().keyboard.press("Shift+Enter"); + } +} + +/** Filters known benign widget noise from console output. */ +export function ignoreKnownWidgetNoise(page: Page): void { + page.on("console", (msg) => { + const t = msg.text() || ""; + if (t.includes("Error displaying widget: model not found")) return; + if (t.includes("Widget model not found")) return; + // Forward other logs for debugging + console.log("[browser]", msg.type(), t); + }); +} + +/** + * Executes a single notebook cell by clicking it and pressing Shift+Enter. + * + * @param notebook - The notebook locator + * @param cellIndex - Zero-based index of the cell to execute + * + * @example + * ```typescript + * await runCell(notebook, 0); // Run first cell + * ``` + */ +export async function runCell( + notebook: Locator, + cellIndex: number, +): Promise { + await notebook.locator(".jp-Cell").nth(cellIndex).click(); + await notebook.page().keyboard.press("Shift+Enter"); +} + +/** + * Executes multiple consecutive notebook cells starting from a given index. + * + * @param notebook - The notebook locator + * @param startIndex - Zero-based index of the first cell to execute + * @param count - Number of cells to execute + * + * @example + * ```typescript + * await runCells(notebook, 0, 3); // Run cells 0, 1, and 2 + * ``` + */ +export async function runCells( + notebook: Locator, + startIndex: number, + count: number, +): Promise { + await notebook.locator(".jp-Cell").nth(startIndex).click(); + for (let i = 0; i < count; i++) { + await notebook.page().keyboard.press("Shift+Enter"); + } +} + +/** + * Waits for the DeckGL instance to be available on the window object. + * + * @param page - Playwright page object + * @param timeout - Maximum time to wait in milliseconds (default: 10000) + */ +export async function waitForDeck( + page: Page, + timeout: number = 10000, +): Promise { + await page.waitForFunction( + () => { + const win = window as unknown as WindowWithDeck; + return typeof win.__deck !== "undefined"; + }, + { timeout }, + ); +} + +/** + * Waits for a Lonboard map widget to be fully ready for interaction. + * + * @param page - Playwright page object + * + * @remarks + * This function performs multiple checks to ensure the map is ready: + * 1. Waits for the map root element with context menu suppression + * 2. Waits for the DeckGL canvas overlay to be visible + * 3. Polls until the canvas has non-zero dimensions + * 4. Waits for the DeckGL instance to be available on window.__deck + * + * @example + * ```typescript + * await runCells(notebook, 0, 2); + * await waitForMapReady(page); + * // Now safe to interact with the map + * ``` + */ +export async function waitForMapReady(page: Page): Promise { + // Accept either DeckGL overlay or MapLibre canvas as readiness signal + const canvas = page + .locator("canvas#deckgl-overlay, canvas.maplibregl-canvas") + .first(); + await expect(canvas).toBeVisible({ timeout: 30000 }); + + await expect + .poll( + async () => { + const box = await canvas.boundingBox(); + return box && box.width > 0 && box.height > 0; + }, + { timeout: 10000 }, + ) + .toBe(true); + + await waitForDeck(page); +} + +/** + * Gets the output area locator for a specific notebook cell. + * + * @param notebook - The notebook locator + * @param cellIndex - Zero-based index of the cell + * @returns Locator for the cell's last output area + * + * @example + * ```typescript + * const output = getCellOutput(notebook, 2); + * const text = await output.textContent(); + * ``` + */ +export function getCellOutput(notebook: Locator, cellIndex: number): Locator { + return notebook + .locator(".jp-Cell") + .nth(cellIndex) + .locator(".jp-OutputArea-output") + .last(); +} + +/** + * Executes a notebook cell and waits for its output to appear. + * + * @param notebook - The notebook locator + * @param cellIndex - Zero-based index of the cell to execute + * @returns Locator for the cell's output area + */ +export async function executeCellAndWaitForOutput( + notebook: Locator, + cellIndex: number, +): Promise { + await notebook.locator(".jp-Cell").nth(cellIndex).click(); + await notebook.page().keyboard.press("Shift+Enter"); + + // Wait for the cell to execute and produce output + await notebook.page().waitForTimeout(1000); + + const output = notebook + .locator(".jp-Cell") + .nth(cellIndex) + .locator(".jp-OutputArea-output") + .first(); + await expect(output).toBeVisible({ timeout: 5000 }); + + return output; +} diff --git a/tests/e2e/notebook-load.spec.ts b/tests/e2e/notebook-load.spec.ts new file mode 100644 index 00000000..112849ff --- /dev/null +++ b/tests/e2e/notebook-load.spec.ts @@ -0,0 +1,17 @@ +import { test, expect } from "@playwright/test"; +import { openNotebookFresh } from "./helpers/notebook"; + +test.describe("Notebook Load", () => { + test("JupyterLab starts and loads notebook", async ({ page }) => { + await openNotebookFresh(page, "simple-map.ipynb", { + workspaceId: `load-${Date.now()}`, + }); + + await expect( + page.locator('.jp-mod-current[role="tab"]:has-text("simple-map.ipynb")'), + ).toBeVisible({ timeout: 10000 }); + await expect(page.locator("text=/Python 3.*Idle/")).toBeVisible({ + timeout: 30000, + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..0948d873 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + exclude: [ + "**/node_modules/**", + "**/dist/**", + "**/cypress/**", + "**/.{idea,git,cache,output,temp}/**", + "**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*", + "**/tests/e2e/**", // Exclude Playwright e2e tests + ], + }, +});