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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,8 @@ node_modules
.env
.env.local
.env.*.local

# Playwright test artifacts
test-results/
playwright-report/
tests/e2e/fixtures/.ipynb_checkpoints
81 changes: 81 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
37 changes: 37 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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,
},
});
13 changes: 12 additions & 1 deletion src/index.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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();

Expand Down Expand Up @@ -94,6 +95,15 @@ function App() {

const [justClicked, setJustClicked] = useState<boolean>(false);

// Expose DeckGL instance on window for Playwright e2e tests
const deckRef = useRef<DeckGLRef | null>(null);
useEffect(() => {
if (deckRef.current && typeof window !== "undefined") {
(window as unknown as Record<string, unknown>).__deck =
deckRef.current.deck;
}
}, [deckRef.current]);

const model = useModel();

const [mapStyle] = useModelState<string>("basemap_style");
Expand Down Expand Up @@ -243,6 +253,7 @@ function App() {
)}
<div className="bg-red-800 h-full w-full relative">
<DeckGL
ref={deckRef}
style={{ width: "100%", height: "100%" }}
initialViewState={
["longitude", "latitude", "zoom"].every((key) =>
Expand Down
33 changes: 33 additions & 0 deletions tests/e2e/README.md
Original file line number Diff line number Diff line change
@@ -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`
64 changes: 64 additions & 0 deletions tests/e2e/bbox-select.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading