diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ed39c7ed..5ded488ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -213,20 +213,12 @@ jobs: with: name: sqlpage-linux-debug path: ${{ runner.temp }}/sqlpage-bin - - name: Start official site and wait for it to be ready - timeout-minutes: 1 + - name: Run Playwright tests run: | chmod +x "${{ runner.temp }}/sqlpage-bin/sqlpage" - "${{ runner.temp }}/sqlpage-bin/sqlpage" 2>/tmp/stderrlog & - tail -f /tmp/stderrlog | grep -q "started successfully" - working-directory: ./examples/official-site - - name: Run Playwright tests - run: npx playwright test - - name: show server logs - if: failure() - run: cat /tmp/stderrlog + SQLPAGE_BINARY="${{ runner.temp }}/sqlpage-bin/sqlpage" npx playwright test - uses: actions/upload-artifact@v7 - if: always() + if: ${{ !cancelled() }} with: name: playwright-report path: ./tests/end-to-end/playwright-report/ diff --git a/AGENTS.md b/AGENTS.md index 6b6c19ba7..22032c12c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,7 +121,7 @@ DATABASE_URL='mssql://root:Password123!@localhost/sqlpage' cargo test ODBC tests require the database-specific ODBC driver on the host; starting the container is not sufficient. See the PostgreSQL ODBC and Oracle matrix entries in [CI](./.github/workflows/ci.yml) for driver setup and connection strings. On Linux and macOS, `cargo test --features odbc-static` matches CI's static unixODBC linking. -For dynamic frontend changes, run the Playwright tests under `tests/end-to-end/` as described in [CONTRIBUTING.md](./CONTRIBUTING.md). For examples containing `test.hurl`, run `scripts/test-examples-hurl.sh `. +For dynamic frontend changes, run the Playwright tests under `tests/end-to-end/` as described in [CONTRIBUTING.md](./CONTRIBUTING.md). Component browser tests belong in `tests/end-to-end/fixtures//{index.sql,test.ts}` and must import the shared `fixture.ts` harness. Exercise components through SQL fixtures and normal page initialization; do not inject synthetic component DOM or call private initialization functions. Prefer Playwright locators and web-first assertions. For examples containing `test.hurl`, run `scripts/test-examples-hurl.sh `. ### Documentation @@ -143,5 +143,6 @@ official documentation website sql tables: - SQLPage functions: one `async fn` module under `src/webserver/database/sqlpage_functions/functions/`, declared with `mod` and registered with `sqlpage_functions!` in `functions.rs`. See its [README](./src/webserver/database/sqlpage_functions/README.md). - [Configuration](./configuration.md): see [AppConfig](./src/app_config.rs) - Routing: file-based in `src/webserver/routing.rs`. Missing paths use the nearest ancestor `404.sql`; without one, HTML uses `src/default_404.sql` and other formats receive a plain-text 404. +- Playwright component suites: `tests/end-to-end/fixtures//{index.sql,test.ts}` using `tests/end-to-end/fixture.ts`; official-site smoke tests remain in `tests/end-to-end/*.spec.ts`. - Follow patterns from similar modules before introducing new abstractions. - frontend: see [css](./sqlpage/sqlpage.css) and [js](./sqlpage/sqlpage.js) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f764460e2..699c72f9e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -100,6 +100,17 @@ cargo test We use Playwright for end-to-end testing of dynamic frontend features. Tests are located in [`tests/end-to-end/`](./tests/end-to-end/). Key areas covered include: +Component tests use deterministic SQL applications under `tests/end-to-end/fixtures//`. +Each suite contains an `index.sql` page and a `test.ts` file. Import `test` and `expect` from +`../../fixture`; the shared fixture opens the matching SQL page before every test and waits for +all SQLPage components to initialize. Prefer role, label, and text locators over CSS selectors +when the assertion does not specifically concern generated markup. + +Keep official-site smoke and integration tests in root-level `*.spec.ts` files. Component behavior +tests should render real components through their SQL fixture; do not inject component markup or +invoke SQLPage's JavaScript initialization functions directly. Parameterized fixtures may accept +request variables when several tests need the same component with different data. + #### Start a sqlpage instance pointed to the official site source code ```bash @@ -118,6 +129,9 @@ npx playwright install chromium npm run test ``` +Playwright starts the component fixture server on port 8081 automatically. The official-site +server on port 8080 must still be started separately as shown above. + ## Documentation ### Component Documentation diff --git a/tests/end-to-end/fixture-server/sqlpage.json b/tests/end-to-end/fixture-server/sqlpage.json new file mode 100644 index 000000000..e5e68b390 --- /dev/null +++ b/tests/end-to-end/fixture-server/sqlpage.json @@ -0,0 +1,4 @@ +{ + "database_url": "sqlite::memory:", + "port": 8081 +} diff --git a/tests/end-to-end/fixture.ts b/tests/end-to-end/fixture.ts new file mode 100644 index 000000000..c975b9488 --- /dev/null +++ b/tests/end-to-end/fixture.ts @@ -0,0 +1,34 @@ +import path from "node:path"; +import { test as base, expect } from "@playwright/test"; + +const fixturesDirectory = path.resolve(__dirname, "fixtures"); + +export const test = base.extend({ + page: async ({ page }, use, testInfo) => { + const fixture = path + .relative(fixturesDirectory, path.dirname(testInfo.file)) + .split(path.sep) + .join("/"); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + + const response = await page.goto(`/${fixture}/`); + expect(response).not.toBeNull(); + expect(response?.ok(), `loading ${response?.url()}`).toBe(true); + await expect( + page.getByRole("heading", { name: "An error occurred" }), + "SQL fixture rendered without errors", + ).toHaveCount(0); + await expect( + page.locator("[data-pre-init]"), + "component initialization", + ).toHaveCount(0); + + await use(page); + + expect(errors, "uncaught browser errors").toEqual([]); + }, +}); + +export type { Page } from "@playwright/test"; +export { expect }; diff --git a/tests/end-to-end/fixtures/chart/area-categories.sql b/tests/end-to-end/fixtures/chart/area-categories.sql new file mode 100644 index 000000000..ef2b96bf9 --- /dev/null +++ b/tests/end-to-end/fixtures/chart/area-categories.sql @@ -0,0 +1,2 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'area' AS type, 4 AS marker; +WITH points(series, x, y) AS (VALUES ('A', 'Q1', 1), ('A', 'Q2', 2), ('A', 'Q3', 3), ('B', 'Q2', 20), ('B', 'Q3', 30)) SELECT * FROM points; diff --git a/tests/end-to-end/fixtures/chart/bubble-categories.sql b/tests/end-to-end/fixtures/chart/bubble-categories.sql new file mode 100644 index 000000000..dbda4330b --- /dev/null +++ b/tests/end-to-end/fixtures/chart/bubble-categories.sql @@ -0,0 +1,2 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'bubble' AS type, 4 AS marker; +WITH points(series, x, y, z) AS (VALUES ('A', 'Q1', 1, 30), ('A', 'Q2', 2, 30), ('B', 'Q2', 5, 70)) SELECT * FROM points; diff --git a/tests/end-to-end/fixtures/chart/chart-and-row-colors.sql b/tests/end-to-end/fixtures/chart/chart-and-row-colors.sql new file mode 100644 index 000000000..94fb42529 --- /dev/null +++ b/tests/end-to-end/fixtures/chart/chart-and-row-colors.sql @@ -0,0 +1,2 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'bar' AS type, 'azure' AS color, 4 AS marker; +WITH points(series, x, y, color) AS (VALUES ('A', 'Q1', 1, NULL), ('A', 'Q2', 2, 'red')) SELECT * FROM points; diff --git a/tests/end-to-end/fixtures/chart/colored-area.sql b/tests/end-to-end/fixtures/chart/colored-area.sql new file mode 100644 index 000000000..3ce37d13c --- /dev/null +++ b/tests/end-to-end/fixtures/chart/colored-area.sql @@ -0,0 +1,2 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'area' AS type, 4 AS marker; +WITH points(series, x, y, color) AS (VALUES ('A', 'Q1', 1, 'red'), ('A', 'Q2', 2, 'green')) SELECT * FROM points; diff --git a/tests/end-to-end/fixtures/chart/colored-bar.sql b/tests/end-to-end/fixtures/chart/colored-bar.sql new file mode 100644 index 000000000..d37e9ea3d --- /dev/null +++ b/tests/end-to-end/fixtures/chart/colored-bar.sql @@ -0,0 +1,2 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'bar' AS type, 4 AS marker; +WITH points(series, x, y, color) AS (VALUES ('A', 'Q1', 1, 'red'), ('A', 'Q2', 2, 'green')) SELECT * FROM points; diff --git a/tests/end-to-end/fixtures/chart/colored-bubble.sql b/tests/end-to-end/fixtures/chart/colored-bubble.sql new file mode 100644 index 000000000..006bb571f --- /dev/null +++ b/tests/end-to-end/fixtures/chart/colored-bubble.sql @@ -0,0 +1,2 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'bubble' AS type, 4 AS marker; +WITH points(series, x, y, color, z) AS (VALUES ('A', 'Q1', 1, 'red', 30), ('A', 'Q2', 2, 'green', 30)) SELECT * FROM points; diff --git a/tests/end-to-end/fixtures/chart/colored-column.sql b/tests/end-to-end/fixtures/chart/colored-column.sql new file mode 100644 index 000000000..9057e1d78 --- /dev/null +++ b/tests/end-to-end/fixtures/chart/colored-column.sql @@ -0,0 +1,2 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'column' AS type, 4 AS marker; +WITH points(series, x, y, color) AS (VALUES ('A', 'Q1', 1, 'red'), ('A', 'Q2', 2, 'green')) SELECT * FROM points; diff --git a/tests/end-to-end/fixtures/chart/colored-heatmap.sql b/tests/end-to-end/fixtures/chart/colored-heatmap.sql new file mode 100644 index 000000000..e8caef2df --- /dev/null +++ b/tests/end-to-end/fixtures/chart/colored-heatmap.sql @@ -0,0 +1,2 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'heatmap' AS type, 4 AS marker; +WITH points(series, x, y, color) AS (VALUES ('A', 'Q1', 1, 'red'), ('A', 'Q2', 2, 'green')) SELECT * FROM points; diff --git a/tests/end-to-end/fixtures/chart/colored-horizontal-bar.sql b/tests/end-to-end/fixtures/chart/colored-horizontal-bar.sql new file mode 100644 index 000000000..524e816b2 --- /dev/null +++ b/tests/end-to-end/fixtures/chart/colored-horizontal-bar.sql @@ -0,0 +1,2 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'bar' AS type, TRUE AS horizontal, 4 AS marker; +WITH points(series, x, y, color) AS (VALUES ('Accounts', '30 days', 100, 'red'), ('Accounts', '60 days', 200, 'orange'), ('Accounts', '90 days', 300, 'green')) SELECT * FROM points; diff --git a/tests/end-to-end/fixtures/chart/colored-line.sql b/tests/end-to-end/fixtures/chart/colored-line.sql new file mode 100644 index 000000000..b5a7a46e2 --- /dev/null +++ b/tests/end-to-end/fixtures/chart/colored-line.sql @@ -0,0 +1,2 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'line' AS type, 4 AS marker; +WITH points(series, x, y, color) AS (VALUES ('A', 'Q1', 1, 'red'), ('A', 'Q2', 2, 'green')) SELECT * FROM points; diff --git a/tests/end-to-end/fixtures/chart/colored-pie.sql b/tests/end-to-end/fixtures/chart/colored-pie.sql new file mode 100644 index 000000000..11d41ebd6 --- /dev/null +++ b/tests/end-to-end/fixtures/chart/colored-pie.sql @@ -0,0 +1,2 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'pie' AS type, 4 AS marker; +WITH points(series, x, y, color) AS (VALUES ('A', 'Q1', 1, 'red'), ('A', 'Q2', 2, 'green')) SELECT * FROM points; diff --git a/tests/end-to-end/fixtures/chart/colored-rangeBar.sql b/tests/end-to-end/fixtures/chart/colored-rangeBar.sql new file mode 100644 index 000000000..e7674f9d7 --- /dev/null +++ b/tests/end-to-end/fixtures/chart/colored-rangeBar.sql @@ -0,0 +1 @@ +SELECT 'dynamic' AS component, '[{"component":"chart","id":"test-chart","title":"Chart test fixture","type":"rangeBar","time":true,"marker":4},{"series":"A","x":"one","y":["2024-03-01","2024-03-05"],"color":"red"},{"series":"A","x":"two","y":["2024-03-04","2024-03-09"],"color":"green"}]' AS properties; diff --git a/tests/end-to-end/fixtures/chart/colored-reference-line.sql b/tests/end-to-end/fixtures/chart/colored-reference-line.sql new file mode 100644 index 000000000..de1a62445 --- /dev/null +++ b/tests/end-to-end/fixtures/chart/colored-reference-line.sql @@ -0,0 +1,2 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'line' AS type, 100 AS ymax, 4 AS marker; +WITH points(series, x, y, yline, label, color) AS (VALUES (NULL, NULL, NULL, 70, 'target', 'green'), ('A', 'Q1', 1, NULL, NULL, NULL), ('A', 'Q2', 2, NULL, NULL, NULL)) SELECT * FROM points; diff --git a/tests/end-to-end/fixtures/chart/colored-scatter.sql b/tests/end-to-end/fixtures/chart/colored-scatter.sql new file mode 100644 index 000000000..10a8e3378 --- /dev/null +++ b/tests/end-to-end/fixtures/chart/colored-scatter.sql @@ -0,0 +1,2 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'scatter' AS type, 4 AS marker; +WITH points(series, x, y, color) AS (VALUES ('A', 'Q1', 1, 'red'), ('A', 'Q2', 2, 'green')) SELECT * FROM points; diff --git a/tests/end-to-end/fixtures/chart/colored-treemap.sql b/tests/end-to-end/fixtures/chart/colored-treemap.sql new file mode 100644 index 000000000..6c46ef2bc --- /dev/null +++ b/tests/end-to-end/fixtures/chart/colored-treemap.sql @@ -0,0 +1,2 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'treemap' AS type, 4 AS marker; +WITH points(series, x, y, color) AS (VALUES ('A', 'Q1', 1, 'red'), ('A', 'Q2', 2, 'green')) SELECT * FROM points; diff --git a/tests/end-to-end/fixtures/chart/column.sql b/tests/end-to-end/fixtures/chart/column.sql new file mode 100644 index 000000000..fb3fc9a63 --- /dev/null +++ b/tests/end-to-end/fixtures/chart/column.sql @@ -0,0 +1,2 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'column' AS type, 4 AS marker; +WITH points(series, x, y) AS (VALUES ('Coding', 'Mon', 6), ('Coding', 'Tue', 4), ('Coding', 'Wed', 7)) SELECT * FROM points; diff --git a/tests/end-to-end/fixtures/chart/disjoint-categories.sql b/tests/end-to-end/fixtures/chart/disjoint-categories.sql new file mode 100644 index 000000000..731d1a1b7 --- /dev/null +++ b/tests/end-to-end/fixtures/chart/disjoint-categories.sql @@ -0,0 +1,2 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'bar' AS type, 4 AS marker; +WITH points(series, x, y) AS (VALUES ('A', 'X2', 10), ('A', 'X3', 30), ('B', 'X1', 25), ('B', 'X2', 20)) SELECT * FROM points; diff --git a/tests/end-to-end/fixtures/chart/heatmap-categories.sql b/tests/end-to-end/fixtures/chart/heatmap-categories.sql new file mode 100644 index 000000000..0a4930b09 --- /dev/null +++ b/tests/end-to-end/fixtures/chart/heatmap-categories.sql @@ -0,0 +1,2 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'heatmap' AS type, 4 AS marker; +WITH points(series, x, y) AS (VALUES ('A', 'Q1', 1), ('A', 'Q2', 2), ('A', 'Q3', 3), ('B', 'Q2', 20), ('B', 'Q3', 30)) SELECT * FROM points; diff --git a/tests/end-to-end/fixtures/chart/index.sql b/tests/end-to-end/fixtures/chart/index.sql new file mode 100644 index 000000000..fb3fc9a63 --- /dev/null +++ b/tests/end-to-end/fixtures/chart/index.sql @@ -0,0 +1,2 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'column' AS type, 4 AS marker; +WITH points(series, x, y) AS (VALUES ('Coding', 'Mon', 6), ('Coding', 'Tue', 4), ('Coding', 'Wed', 7)) SELECT * FROM points; diff --git a/tests/end-to-end/fixtures/chart/labeled-reference-line.sql b/tests/end-to-end/fixtures/chart/labeled-reference-line.sql new file mode 100644 index 000000000..bec230086 --- /dev/null +++ b/tests/end-to-end/fixtures/chart/labeled-reference-line.sql @@ -0,0 +1,2 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'line' AS type, 4 AS marker; +WITH points(series, x, y, yline, label) AS (VALUES ('A', 'Q1', 1, NULL, NULL), ('A', 'Q2', 2, NULL, NULL), ('A', 'Q3', 3, NULL, NULL), (NULL, NULL, NULL, 2, 'limit')) SELECT * FROM points; diff --git a/tests/end-to-end/fixtures/chart/line-categories.sql b/tests/end-to-end/fixtures/chart/line-categories.sql new file mode 100644 index 000000000..8a86577bc --- /dev/null +++ b/tests/end-to-end/fixtures/chart/line-categories.sql @@ -0,0 +1,2 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'line' AS type, 4 AS marker; +WITH points(series, x, y) AS (VALUES ('A', 'Q1', 1), ('A', 'Q2', 2), ('A', 'Q3', 3), ('B', 'Q2', 20), ('B', 'Q3', 30)) SELECT * FROM points; diff --git a/tests/end-to-end/fixtures/chart/mixed-colors.sql b/tests/end-to-end/fixtures/chart/mixed-colors.sql new file mode 100644 index 000000000..d056f4086 --- /dev/null +++ b/tests/end-to-end/fixtures/chart/mixed-colors.sql @@ -0,0 +1,2 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'bar' AS type, 4 AS marker; +WITH points(series, x, y, color) AS (VALUES ('A', 'Q1', 1, NULL), ('A', 'Q2', 2, 'red')) SELECT * FROM points; diff --git a/tests/end-to-end/fixtures/chart/out-of-order.sql b/tests/end-to-end/fixtures/chart/out-of-order.sql new file mode 100644 index 000000000..784379411 --- /dev/null +++ b/tests/end-to-end/fixtures/chart/out-of-order.sql @@ -0,0 +1,2 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'bar' AS type, 4 AS marker; +WITH points(series, x, y) AS (VALUES ('A', 'Q3', 3), ('A', 'Q1', 1), ('A', 'Q2', 2)) SELECT * FROM points; diff --git a/tests/end-to-end/fixtures/chart/range-bar.sql b/tests/end-to-end/fixtures/chart/range-bar.sql new file mode 100644 index 000000000..450be4b1f --- /dev/null +++ b/tests/end-to-end/fixtures/chart/range-bar.sql @@ -0,0 +1 @@ +SELECT 'dynamic' AS component, '[{"component":"chart","id":"test-chart","title":"Chart test fixture","type":"rangeBar","time":true,"marker":4},{"series":"Design","x":"Alice","y":["2024-03-01","2024-03-05"]},{"series":"Build","x":"Bob","y":["2024-03-04","2024-03-09"]}]' AS properties; diff --git a/tests/end-to-end/fixtures/chart/scatter-categories.sql b/tests/end-to-end/fixtures/chart/scatter-categories.sql new file mode 100644 index 000000000..91446146f --- /dev/null +++ b/tests/end-to-end/fixtures/chart/scatter-categories.sql @@ -0,0 +1,2 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'scatter' AS type, 4 AS marker; +WITH points(series, x, y) AS (VALUES ('A', 'Q1', 1), ('A', 'Q2', 2), ('A', 'Q3', 3), ('B', 'Q2', 20), ('B', 'Q3', 30)) SELECT * FROM points; diff --git a/tests/end-to-end/fixtures/chart/stacked-categories.sql b/tests/end-to-end/fixtures/chart/stacked-categories.sql new file mode 100644 index 000000000..532f93c35 --- /dev/null +++ b/tests/end-to-end/fixtures/chart/stacked-categories.sql @@ -0,0 +1,2 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'bar' AS type, TRUE AS stacked, 4 AS marker; +WITH points(series, x, y) AS (VALUES ('A', 'Q1', 1), ('A', 'Q2', 2), ('A', 'Q3', 3), ('B', 'Q2', 20), ('B', 'Q3', 30)) SELECT * FROM points; diff --git a/tests/end-to-end/fixtures/chart/stacked-range-bar.sql b/tests/end-to-end/fixtures/chart/stacked-range-bar.sql new file mode 100644 index 000000000..9b2ed3d46 --- /dev/null +++ b/tests/end-to-end/fixtures/chart/stacked-range-bar.sql @@ -0,0 +1 @@ +SELECT 'dynamic' AS component, '[{"component":"chart","id":"test-chart","title":"Chart test fixture","type":"rangeBar","stacked":true,"time":true,"marker":4},{"series":"Design","x":"Alice","y":["2024-03-01","2024-03-05"]},{"series":"Build","x":"Bob","y":["2024-03-04","2024-03-09"]}]' AS properties; diff --git a/tests/end-to-end/fixtures/chart/stacked-time-series.sql b/tests/end-to-end/fixtures/chart/stacked-time-series.sql new file mode 100644 index 000000000..3f83a6176 --- /dev/null +++ b/tests/end-to-end/fixtures/chart/stacked-time-series.sql @@ -0,0 +1,2 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'area' AS type, TRUE AS stacked, TRUE AS time, 4 AS marker; +WITH points(series, x, y) AS (VALUES ('CPU', '2024-01-01T00:00:00Z', 10), ('CPU', '2024-01-01T00:01:00Z', 20), ('CPU', '2024-01-01T00:02:00Z', 30), ('CPU', '2024-01-01T00:03:00Z', 40), ('GPU', '2024-01-01T00:01:00Z', 50), ('GPU', '2024-01-01T00:02:00Z', 50), ('GPU', '2024-01-01T00:03:00Z', 50)) SELECT * FROM points; diff --git a/tests/end-to-end/chart-component.spec.ts b/tests/end-to-end/fixtures/chart/test.ts similarity index 57% rename from tests/end-to-end/chart-component.spec.ts rename to tests/end-to-end/fixtures/chart/test.ts index 3918b2a58..ff3d5f176 100644 --- a/tests/end-to-end/chart-component.spec.ts +++ b/tests/end-to-end/fixtures/chart/test.ts @@ -1,6 +1,4 @@ -import { expect, type Page, test } from "@playwright/test"; - -const BASE = process.env.SQLPAGE_TEST_BASE ?? "http://localhost:8080/"; +import { expect, type Page, test } from "../../fixture"; type ChartPoint = { x: string | number | Date; y: number | null }; @@ -15,137 +13,30 @@ declare global { }; }[]; } - function sqlpage_chart(): void; } -type Row = [ - series: string, - x: unknown, - y: unknown, - color?: unknown, - z?: unknown, -]; - const MARKS = - ".apexcharts-bar-area, .apexcharts-rangebar-area, .apexcharts-treemap-rect, .apexcharts-pie-area, .apexcharts-heatmap-rect, .apexcharts-marker"; - -type ReferenceRow = { - xline?: string | number; - yline?: number; - label?: string; - color?: string; -}; - -const A_DAY_OF_WORK: Row[] = [ - ["Coding", "Mon", 6], - ["Coding", "Tue", 4], - ["Coding", "Wed", 7], -]; - -const TASKS_OVER_TIME: Row[] = [ - ["Design", "Alice", ["2024-03-01", "2024-03-05"]], - ["Build", "Bob", ["2024-03-04", "2024-03-09"]], -]; - -const CPU_AT_EVERY_MINUTE: Row[] = [ - ["CPU", "2024-01-01T00:00:00Z", 10], - ["CPU", "2024-01-01T00:01:00Z", 20], - ["CPU", "2024-01-01T00:02:00Z", 30], - ["CPU", "2024-01-01T00:03:00Z", 40], -]; - -const GPU_ONLY_ONCE_THE_RENDER_STARTED: Row[] = [ - ["GPU", "2024-01-01T00:01:00Z", 50], - ["GPU", "2024-01-01T00:02:00Z", 50], - ["GPU", "2024-01-01T00:03:00Z", 50], -]; - -const A_IN_EVERY_QUARTER: Row[] = [ - ["A", "Q1", 1], - ["A", "Q2", 2], - ["A", "Q3", 3], -]; - -const B_MISSING_THE_FIRST_QUARTER: Row[] = [ - ["B", "Q2", 20], - ["B", "Q3", 30], -]; - -const A_QUARTERS_OUT_OF_ORDER: Row[] = [ - ["A", "Q3", 3], - ["A", "Q1", 1], - ["A", "Q2", 2], -]; - -const EXPIRING_ACCOUNTS: Row[] = [ - ["Accounts", "30 days", 100, "red"], - ["Accounts", "60 days", 200, "orange"], - ["Accounts", "90 days", 300, "green"], -]; + ".apexcharts-bar-area, .apexcharts-rangebar-area, .apexcharts-treemap-rect, .apexcharts-pie-area, .apexcharts-heatmap-rect, .apexcharts-series .apexcharts-marker"; const RED = "#f03e3e"; const ORANGE = "#f76707"; const GREEN = "#37b24d"; +async function renderChart(page: Page, fixture: string) { + const failures: string[] = []; + const recordError = (message: { type(): string; text(): string }) => { + if (message.type() === "error") failures.push(message.text()); + }; + page.on("console", recordError); + const response = await page.goto(`/chart/${fixture}.sql`); + expect(response?.ok(), `loading ${response?.url()}`).toBe(true); + await expect(page.locator("#test-chart .apexcharts-canvas")).toBeVisible(); + page.off("console", recordError); -const A_RED_ROW_AND_A_GREEN_ROW: Row[] = [ - ["A", "Q1", 1, "red"], - ["A", "Q2", 2, "green"], -]; - -const THE_SAME_ROWS_UNCOLORED: Row[] = [ - ["A", "Q1", 1], - ["A", "Q2", 2], -]; - -const COLORED_ROWS_OF: Record = { - rangeBar: [ - ["A", "one", ["2024-03-01", "2024-03-05"], "red"], - ["A", "two", ["2024-03-04", "2024-03-09"], "green"], - ], - bubble: [ - ["A", "Q1", 1, "red", 30], - ["A", "Q2", 2, "green", 30], - ], -}; - -const A_FROM_THE_SECOND_CATEGORY: Row[] = [ - ["A", "X2", 10], - ["A", "X3", 30], -]; - -const B_UNTIL_THE_SECOND_CATEGORY: Row[] = [ - ["B", "X1", 25], - ["B", "X2", 20], -]; - -async function renderChart( - page: Page, - chart: Record, - rows: (Row | ReferenceRow)[], -) { return page.evaluate( - ({ chart, rows, marks }) => { - document.getElementById("test-chart")?.remove(); - const container = document.createElement("div"); - container.id = "test-chart"; - container.setAttribute("data-pre-init", "chart"); - const payload = JSON.stringify({ - colors: [], - marker: 4, - ...chart, - points: rows, - }); - container.innerHTML = `
`; - document.body.appendChild(container); - - const failures: string[] = []; - const reportError = console.error; - console.error = (...args) => failures.push(args.map(String).join(" ")); - const before = window.charts?.length ?? 0; - sqlpage_chart(); - console.error = reportError; - - const rendered = window.charts?.[before]; + ({ failures, marks }) => { + const container = document.getElementById("test-chart"); + if (!container) throw new Error("Chart fixture did not render"); + const rendered = window.charts?.[0]; const series = (rendered?.w.config.series ?? []).map((s) => ({ name: s.name, points: (s.data ?? []).map((p) => [ @@ -199,7 +90,7 @@ async function renderChart( referenceLines, }; }, - { chart, rows, marks: MARKS }, + { failures, marks: MARKS }, ); } @@ -213,13 +104,8 @@ const fills = (chart: Awaited>) => return `#${hex.join("")}`; }); -test.beforeEach(async ({ page }) => { - await page.goto(`${BASE}/documentation.sql?component=chart#component`); - await page.waitForSelector(".apexcharts-canvas"); -}); - test("draws a column chart as a vertical bar chart", async ({ page }) => { - const chart = await renderChart(page, { type: "column" }, A_DAY_OF_WORK); + const chart = await renderChart(page, "column"); expect(chart.failures).toEqual([]); expect(chart.shapes).toHaveLength(3); @@ -232,11 +118,7 @@ test("draws a column chart as a vertical bar chart", async ({ page }) => { test("gives a stacked series a zero at every x it did not measure", async ({ page, }) => { - const chart = await renderChart( - page, - { type: "area", stacked: true, time: true }, - [...CPU_AT_EVERY_MINUTE, ...GPU_ONLY_ONCE_THE_RENDER_STARTED], - ); + const chart = await renderChart(page, "stacked-time-series"); expect(chart.failures).toEqual([]); expect(chart.series.map((s) => s.name)).toEqual(["CPU", "GPU"]); @@ -249,11 +131,7 @@ test("gives a stacked series a zero at every x it did not measure", async ({ }); test("stacks a series above the one it shares an x with", async ({ page }) => { - const chart = await renderChart( - page, - { type: "area", stacked: true, time: true }, - [...CPU_AT_EVERY_MINUTE, ...GPU_ONLY_ONCE_THE_RENDER_STARTED], - ); + const chart = await renderChart(page, "stacked-time-series"); const [cpu, gpu] = chart.drawnPerSeries; expect(gpu.heights).toHaveLength(4); @@ -264,11 +142,7 @@ test("stacks a series above the one it shares an x with", async ({ page }) => { test("keeps a lone series in the order the query returned it (#930)", async ({ page, }) => { - const chart = await renderChart( - page, - { type: "bar" }, - A_QUARTERS_OUT_OF_ORDER, - ); + const chart = await renderChart(page, "out-of-order"); expect(chart.failures).toEqual([]); expect(chart.series[0].points).toEqual([ @@ -281,10 +155,7 @@ test("keeps a lone series in the order the query returned it (#930)", async ({ test("orders by name the categories two bar series do not share (#951)", async ({ page, }) => { - const chart = await renderChart(page, { type: "bar" }, [ - ...A_FROM_THE_SECOND_CATEGORY, - ...B_UNTIL_THE_SECOND_CATEGORY, - ]); + const chart = await renderChart(page, "disjoint-categories"); expect(chart.failures).toEqual([]); expect(chart.series[0].points).toEqual([ @@ -302,10 +173,7 @@ test("orders by name the categories two bar series do not share (#951)", async ( test("leaves the points of a chart that does not stack alone", async ({ page, }) => { - const chart = await renderChart(page, { type: "area", time: true }, [ - ...CPU_AT_EVERY_MINUTE, - ...GPU_ONLY_ONCE_THE_RENDER_STARTED, - ]); + const chart = await renderChart(page, "unstacked-time-series"); expect(chart.failures).toEqual([]); expect(chart.series[1].points).toEqual([ @@ -316,10 +184,7 @@ test("leaves the points of a chart that does not stack alone", async ({ }); test("stacks a bar series on the categories it skipped", async ({ page }) => { - const chart = await renderChart(page, { type: "bar", stacked: true }, [ - ...A_IN_EVERY_QUARTER, - ...B_MISSING_THE_FIRST_QUARTER, - ]); + const chart = await renderChart(page, "stacked-categories"); expect(chart.failures).toEqual([]); expect(chart.series[1].points).toEqual([ @@ -332,10 +197,7 @@ test("stacks a bar series on the categories it skipped", async ({ page }) => { test("lines an unstacked series up with the categories it skipped", async ({ page, }) => { - const chart = await renderChart(page, { type: "line" }, [ - ...A_IN_EVERY_QUARTER, - ...B_MISSING_THE_FIRST_QUARTER, - ]); + const chart = await renderChart(page, "line-categories"); expect(chart.failures).toEqual([]); expect(chart.series[1].points).toEqual([ @@ -348,10 +210,7 @@ test("lines an unstacked series up with the categories it skipped", async ({ test("draws nothing where an unstacked series has no value", async ({ page, }) => { - const chart = await renderChart(page, { type: "line" }, [ - ...A_IN_EVERY_QUARTER, - ...B_MISSING_THE_FIRST_QUARTER, - ]); + const chart = await renderChart(page, "line-categories"); const [a, b] = chart.drawnPerSeries; expect(a.lefts).toHaveLength(3); @@ -359,11 +218,7 @@ test("draws nothing where an unstacked series has no value", async ({ }); test("keeps a measured zero apart from a missing value", async ({ page }) => { - const chart = await renderChart(page, { type: "line" }, [ - ...A_IN_EVERY_QUARTER, - ["B", "Q2", 0], - ["B", "Q3", 30], - ]); + const chart = await renderChart(page, "zero-and-missing"); const [a, b] = chart.drawnPerSeries; expect(chart.series[1].points).toEqual([ @@ -378,10 +233,7 @@ for (const type of ["area", "scatter", "heatmap"]) { test(`lines up the series of a ${type} chart on a category axis`, async ({ page, }) => { - const chart = await renderChart(page, { type }, [ - ...A_IN_EVERY_QUARTER, - ...B_MISSING_THE_FIRST_QUARTER, - ]); + const chart = await renderChart(page, `${type}-categories`); expect(chart.failures).toEqual([]); expect(chart.series[1].points.map((p) => p[0])).toEqual(["Q1", "Q2", "Q3"]); @@ -389,11 +241,7 @@ for (const type of ["area", "scatter", "heatmap"]) { } test("keeps the bubble size of the points it lined up", async ({ page }) => { - const chart = await renderChart(page, { type: "bubble" }, [ - ["A", "Q1", 1, null, 30], - ["A", "Q2", 2, null, 30], - ["B", "Q2", 5, null, 70], - ]); + const chart = await renderChart(page, "bubble-categories"); expect(chart.failures).toEqual([]); expect(chart.series[1].points).toEqual([ @@ -403,34 +251,21 @@ test("keeps the bubble size of the points it lined up", async ({ page }) => { }); test("leaves a rangeBar chart on a category axis alone", async ({ page }) => { - const chart = await renderChart( - page, - { type: "rangeBar", time: true }, - TASKS_OVER_TIME, - ); + const chart = await renderChart(page, "range-bar"); expect(chart.failures).toEqual([]); expect(chart.shapes).toHaveLength(2); }); test("leaves a treemap chart alone", async ({ page }) => { - const chart = await renderChart(page, { type: "treemap" }, [ - ["North America", "United States", 35], - ["North America", "Canada", 15], - ["Europe", "France", 30], - ["Europe", "Germany", 55], - ]); + const chart = await renderChart(page, "treemap"); expect(chart.failures).toEqual([]); expect(chart.shapes).toHaveLength(4); }); test("draws a rangeBar chart that asks to be stacked", async ({ page }) => { - const chart = await renderChart( - page, - { type: "rangeBar", stacked: true, time: true }, - TASKS_OVER_TIME, - ); + const chart = await renderChart(page, "stacked-range-bar"); expect(chart.failures).toEqual([]); expect(chart.shapes).toHaveLength(2); @@ -440,7 +275,7 @@ test("draws a rangeBar chart that asks to be stacked", async ({ page }) => { test("gives the tooltip title the color of the tooltip around it", async ({ page, }) => { - await renderChart(page, { type: "line" }, A_DAY_OF_WORK); + await renderChart(page, "tooltip"); await page.locator("#test-chart .apexcharts-inner").hover({ force: true }); const title = page.locator("#test-chart .apexcharts-tooltip-title"); @@ -454,11 +289,7 @@ test("gives the tooltip title the color of the tooltip around it", async ({ }); test("draws a reference line that carries no label", async ({ page }) => { - const chart = await renderChart(page, { type: "line" }, [ - ...A_IN_EVERY_QUARTER, - { yline: 2 }, - { xline: "Q2" }, - ]); + const chart = await renderChart(page, "unlabeled-reference-lines"); expect(chart.failures).toEqual([]); expect(chart.referenceLines.lines).toBe(2); @@ -469,10 +300,7 @@ test("draws a reference line that carries no label", async ({ page }) => { test("draws a box behind the label of a reference line that carries one", async ({ page, }) => { - const chart = await renderChart(page, { type: "line" }, [ - ...A_IN_EVERY_QUARTER, - { yline: 2, label: "limit" }, - ]); + const chart = await renderChart(page, "labeled-reference-line"); expect(chart.failures).toEqual([]); expect(chart.referenceLines.lines).toBe(1); @@ -494,11 +322,7 @@ for (const type of [ test(`colors every mark of a ${type} chart from its own row`, async ({ page, }) => { - const chart = await renderChart( - page, - { type, time: type === "rangeBar" }, - COLORED_ROWS_OF[type] ?? A_RED_ROW_AND_A_GREEN_ROW, - ); + const chart = await renderChart(page, `colored-${type}`); expect(chart.failures).toEqual([]); expect(fills(chart)).toEqual([RED, GREEN]); @@ -508,11 +332,7 @@ for (const type of [ test("colors each bar of a horizontal bar chart from its own row (#1228)", async ({ page, }) => { - const chart = await renderChart( - page, - { type: "bar", horizontal: true }, - EXPIRING_ACCOUNTS, - ); + const chart = await renderChart(page, "colored-horizontal-bar"); expect(chart.failures).toEqual([]); expect(fills(chart)).toEqual([RED, ORANGE, GREEN]); @@ -521,16 +341,8 @@ test("colors each bar of a horizontal bar chart from its own row (#1228)", async test("leaves a heatmap, which shades its cells from their own value, alone", async ({ page, }) => { - const shaded = await renderChart( - page, - { type: "heatmap" }, - THE_SAME_ROWS_UNCOLORED, - ); - const colored = await renderChart( - page, - { type: "heatmap" }, - A_RED_ROW_AND_A_GREEN_ROW, - ); + const shaded = await renderChart(page, "uncolored-heatmap"); + const colored = await renderChart(page, "colored-heatmap"); expect(colored.failures).toEqual([]); expect(fills(colored)).toEqual(fills(shaded)); @@ -539,15 +351,8 @@ test("leaves a heatmap, which shades its cells from their own value, alone", asy test("leaves a row without a color on the color of its series", async ({ page, }) => { - const plain = await renderChart( - page, - { type: "bar" }, - THE_SAME_ROWS_UNCOLORED, - ); - const mixed = await renderChart(page, { type: "bar" }, [ - THE_SAME_ROWS_UNCOLORED[0], - ["A", "Q2", 2, "red"], - ]); + const plain = await renderChart(page, "uncolored-bar"); + const mixed = await renderChart(page, "mixed-colors"); expect(mixed.failures).toEqual([]); expect(fills(mixed)).toEqual([fills(plain)[0], RED]); @@ -556,10 +361,7 @@ test("leaves a row without a color on the color of its series", async ({ test("lets a row color override the color given to the whole chart", async ({ page, }) => { - const chart = await renderChart(page, { type: "bar", colors: ["azure"] }, [ - ["A", "Q1", 1], - ["A", "Q2", 2, "red"], - ]); + const chart = await renderChart(page, "chart-and-row-colors"); expect(chart.failures).toEqual([]); expect(fills(chart)).toEqual(["#339af0", RED]); @@ -568,25 +370,15 @@ test("lets a row color override the color given to the whole chart", async ({ test("keeps the color of the series when a row names a color SQLPage does not know", async ({ page, }) => { - const plain = await renderChart( - page, - { type: "bar" }, - THE_SAME_ROWS_UNCOLORED, - ); - const unknown = await renderChart(page, { type: "bar" }, [ - ["A", "Q1", 1, "#ff0000"], - ["A", "Q2", 2, "chartreuse"], - ]); + const plain = await renderChart(page, "uncolored-bar"); + const unknown = await renderChart(page, "unknown-colors"); expect(unknown.failures).toEqual([]); expect(fills(unknown)).toEqual(fills(plain)); }); test("keeps coloring reference lines from their own row", async ({ page }) => { - const chart = await renderChart(page, { type: "line", ymax: 100 }, [ - { yline: 70, label: "target", color: "green" }, - ...THE_SAME_ROWS_UNCOLORED, - ]); + const chart = await renderChart(page, "colored-reference-line"); expect(chart.failures).toEqual([]); expect(chart.referenceLines.strokes).toEqual([GREEN]); diff --git a/tests/end-to-end/fixtures/chart/tooltip.sql b/tests/end-to-end/fixtures/chart/tooltip.sql new file mode 100644 index 000000000..8fac0a28a --- /dev/null +++ b/tests/end-to-end/fixtures/chart/tooltip.sql @@ -0,0 +1,2 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'line' AS type, 4 AS marker; +WITH points(series, x, y) AS (VALUES ('Coding', 'Mon', 6), ('Coding', 'Tue', 4), ('Coding', 'Wed', 7)) SELECT * FROM points; diff --git a/tests/end-to-end/fixtures/chart/treemap.sql b/tests/end-to-end/fixtures/chart/treemap.sql new file mode 100644 index 000000000..3c08f1886 --- /dev/null +++ b/tests/end-to-end/fixtures/chart/treemap.sql @@ -0,0 +1,2 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'treemap' AS type, 4 AS marker; +WITH points(series, x, y) AS (VALUES ('North America', 'United States', 35), ('North America', 'Canada', 15), ('Europe', 'France', 30), ('Europe', 'Germany', 55)) SELECT * FROM points; diff --git a/tests/end-to-end/fixtures/chart/uncolored-bar.sql b/tests/end-to-end/fixtures/chart/uncolored-bar.sql new file mode 100644 index 000000000..111deb19f --- /dev/null +++ b/tests/end-to-end/fixtures/chart/uncolored-bar.sql @@ -0,0 +1,2 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'bar' AS type, 4 AS marker; +WITH points(series, x, y) AS (VALUES ('A', 'Q1', 1), ('A', 'Q2', 2)) SELECT * FROM points; diff --git a/tests/end-to-end/fixtures/chart/uncolored-heatmap.sql b/tests/end-to-end/fixtures/chart/uncolored-heatmap.sql new file mode 100644 index 000000000..2b4f28dab --- /dev/null +++ b/tests/end-to-end/fixtures/chart/uncolored-heatmap.sql @@ -0,0 +1,2 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'heatmap' AS type, 4 AS marker; +WITH points(series, x, y) AS (VALUES ('A', 'Q1', 1), ('A', 'Q2', 2)) SELECT * FROM points; diff --git a/tests/end-to-end/fixtures/chart/unknown-colors.sql b/tests/end-to-end/fixtures/chart/unknown-colors.sql new file mode 100644 index 000000000..7126597f0 --- /dev/null +++ b/tests/end-to-end/fixtures/chart/unknown-colors.sql @@ -0,0 +1,2 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'bar' AS type, 4 AS marker; +WITH points(series, x, y, color) AS (VALUES ('A', 'Q1', 1, '#ff0000'), ('A', 'Q2', 2, 'chartreuse')) SELECT * FROM points; diff --git a/tests/end-to-end/fixtures/chart/unlabeled-reference-lines.sql b/tests/end-to-end/fixtures/chart/unlabeled-reference-lines.sql new file mode 100644 index 000000000..28c8364be --- /dev/null +++ b/tests/end-to-end/fixtures/chart/unlabeled-reference-lines.sql @@ -0,0 +1,2 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'line' AS type, 4 AS marker; +WITH points(series, x, y, xline, yline) AS (VALUES ('A', 'Q1', 1, NULL, NULL), ('A', 'Q2', 2, NULL, NULL), ('A', 'Q3', 3, NULL, NULL), (NULL, NULL, NULL, NULL, 2), (NULL, NULL, NULL, 'Q2', NULL)) SELECT * FROM points; diff --git a/tests/end-to-end/fixtures/chart/unstacked-time-series.sql b/tests/end-to-end/fixtures/chart/unstacked-time-series.sql new file mode 100644 index 000000000..728007c75 --- /dev/null +++ b/tests/end-to-end/fixtures/chart/unstacked-time-series.sql @@ -0,0 +1,2 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'area' AS type, TRUE AS time, 4 AS marker; +WITH points(series, x, y) AS (VALUES ('CPU', '2024-01-01T00:00:00Z', 10), ('CPU', '2024-01-01T00:01:00Z', 20), ('CPU', '2024-01-01T00:02:00Z', 30), ('CPU', '2024-01-01T00:03:00Z', 40), ('GPU', '2024-01-01T00:01:00Z', 50), ('GPU', '2024-01-01T00:02:00Z', 50), ('GPU', '2024-01-01T00:03:00Z', 50)) SELECT * FROM points; diff --git a/tests/end-to-end/fixtures/chart/zero-and-missing.sql b/tests/end-to-end/fixtures/chart/zero-and-missing.sql new file mode 100644 index 000000000..9f213f81e --- /dev/null +++ b/tests/end-to-end/fixtures/chart/zero-and-missing.sql @@ -0,0 +1,2 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'line' AS type, 4 AS marker; +WITH points(series, x, y) AS (VALUES ('A', 'Q1', 1), ('A', 'Q2', 2), ('A', 'Q3', 3), ('B', 'Q2', 0), ('B', 'Q3', 30)) SELECT * FROM points; diff --git a/tests/end-to-end/fixtures/map/index.sql b/tests/end-to-end/fixtures/map/index.sql new file mode 100644 index 000000000..50c6b7d86 --- /dev/null +++ b/tests/end-to-end/fixtures/map/index.sql @@ -0,0 +1 @@ +SELECT 'map' AS component, 'Map test fixture' AS title, 200 AS height, '' AS tile_source, 48.85 AS latitude, 2.35 AS longitude; diff --git a/tests/end-to-end/fixtures/map/invalid-and-valid-markers.sql b/tests/end-to-end/fixtures/map/invalid-and-valid-markers.sql new file mode 100644 index 000000000..d6801fb67 --- /dev/null +++ b/tests/end-to-end/fixtures/map/invalid-and-valid-markers.sql @@ -0,0 +1,5 @@ +SELECT 'map' AS component, 'Map test fixture' AS title, 200 AS height, '' AS tile_source, 48.85 AS latitude, 2.35 AS longitude +UNION ALL +SELECT NULL, 'Half of Paris', NULL, NULL, 48.85, '' +UNION ALL +SELECT NULL, 'Paris', NULL, NULL, 48.85, 2.35; diff --git a/tests/end-to-end/fixtures/map/invalid-center.sql b/tests/end-to-end/fixtures/map/invalid-center.sql new file mode 100644 index 000000000..cb03b448f --- /dev/null +++ b/tests/end-to-end/fixtures/map/invalid-center.sql @@ -0,0 +1 @@ +SELECT 'map' AS component, 'Map test fixture' AS title, 200 AS height, '' AS tile_source, 'somewhere nice' AS latitude, '' AS longitude; diff --git a/tests/end-to-end/fixtures/map/missing-center-longitude.sql b/tests/end-to-end/fixtures/map/missing-center-longitude.sql new file mode 100644 index 000000000..b56c9a315 --- /dev/null +++ b/tests/end-to-end/fixtures/map/missing-center-longitude.sql @@ -0,0 +1 @@ +SELECT 'map' AS component, 'Map test fixture' AS title, 200 AS height, '' AS tile_source, 48.85 AS latitude, '' AS longitude; diff --git a/tests/end-to-end/fixtures/map/test.ts b/tests/end-to-end/fixtures/map/test.ts new file mode 100644 index 000000000..f6c875661 --- /dev/null +++ b/tests/end-to-end/fixtures/map/test.ts @@ -0,0 +1,77 @@ +import { expect, type Page, test } from "../../fixture"; + +const PARIS_WITHOUT_ITS_LONGITUDE = "48.85,"; +const NOT_COORDINATES = "somewhere nice"; + +async function renderMap(page: Page, fixture: string, markerCount = 0) { + const errors: string[] = []; + const logged: string[] = []; + const recordPageError = (error: Error) => errors.push(error.message); + const recordConsoleError = (message: { type(): string; text(): string }) => { + if (message.type() === "error") logged.push(message.text()); + }; + page.on("pageerror", recordPageError); + page.on("console", recordConsoleError); + const response = await page.goto(`/map/${fixture}.sql`); + expect(response?.ok(), `loading ${response?.url()}`).toBe(true); + await expect(page.locator("[data-pre-init='map']")).toHaveCount(0); + await expect(page.locator(".leaflet-map-pane")).toBeAttached(); + await expect(page.locator(".leaflet-marker-icon")).toHaveCount(markerCount); + page.off("pageerror", recordPageError); + page.off("console", recordConsoleError); + + return page.locator(".leaflet").evaluate( + (container, messages) => ({ + ...messages, + markers: container.querySelectorAll(".leaflet-marker-icon").length, + initialized: !!container.querySelector(".leaflet-map-pane"), + }), + { errors, logged }, + ); +} + +test("centers the map on a pair of coordinates", async ({ page }) => { + const map = await renderMap(page, "valid-center"); + + expect(map.errors).toEqual([]); + expect(map.logged).toEqual([]); + expect(map.initialized).toBe(true); +}); + +test("reports a center whose longitude is missing", async ({ page }) => { + const map = await renderMap(page, "missing-center-longitude"); + + expect(map.errors).toEqual([]); + expect(map.logged).toEqual([ + expect.stringContaining(PARIS_WITHOUT_ITS_LONGITUDE), + ]); + expect(map.initialized).toBe(true); +}); + +test("reports a center that is not a pair of numbers", async ({ page }) => { + const map = await renderMap(page, "invalid-center"); + + expect(map.errors).toEqual([]); + expect(map.logged).toEqual([expect.stringContaining(NOT_COORDINATES)]); + expect(map.initialized).toBe(true); +}); + +test("draws a marker at a pair of coordinates", async ({ page }) => { + const map = await renderMap(page, "valid-marker", 1); + + expect(map.errors).toEqual([]); + expect(map.logged).toEqual([]); + expect(map.markers).toBe(1); +}); + +test("reports a marker whose longitude is missing, keeping the others", async ({ + page, +}) => { + const map = await renderMap(page, "invalid-and-valid-markers", 1); + + expect(map.errors).toEqual([]); + expect(map.logged).toEqual([ + expect.stringContaining(PARIS_WITHOUT_ITS_LONGITUDE), + ]); + expect(map.markers).toBe(1); +}); diff --git a/tests/end-to-end/fixtures/map/valid-center.sql b/tests/end-to-end/fixtures/map/valid-center.sql new file mode 100644 index 000000000..50c6b7d86 --- /dev/null +++ b/tests/end-to-end/fixtures/map/valid-center.sql @@ -0,0 +1 @@ +SELECT 'map' AS component, 'Map test fixture' AS title, 200 AS height, '' AS tile_source, 48.85 AS latitude, 2.35 AS longitude; diff --git a/tests/end-to-end/fixtures/map/valid-marker.sql b/tests/end-to-end/fixtures/map/valid-marker.sql new file mode 100644 index 000000000..d66bd9a5c --- /dev/null +++ b/tests/end-to-end/fixtures/map/valid-marker.sql @@ -0,0 +1,3 @@ +SELECT 'map' AS component, 'Map test fixture' AS title, 200 AS height, '' AS tile_source, 48.85 AS latitude, 2.35 AS longitude +UNION ALL +SELECT NULL, 'Paris', NULL, NULL, 48.85, 2.35; diff --git a/tests/end-to-end/map-component.spec.ts b/tests/end-to-end/map-component.spec.ts deleted file mode 100644 index 58bb66818..000000000 --- a/tests/end-to-end/map-component.spec.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { expect, type Page, test } from "@playwright/test"; - -const BASE = process.env.SQLPAGE_TEST_BASE ?? "http://localhost:8080/"; - -declare global { - function sqlpage_map(): void; -} - -type Marker = { coords?: string; title: string }; - -const PARIS = "48.85,2.35"; -const PARIS_WITHOUT_ITS_LONGITUDE = "48.85,"; -const NOT_COORDINATES = "somewhere nice"; - -async function renderMap( - page: Page, - center: string | null, - markers: Marker[] = [], -) { - return page.evaluate( - async ({ center, markers }) => { - document.getElementById("test-map")?.remove(); - const container = document.createElement("div"); - container.id = "test-map"; - container.className = "leaflet"; - container.style.height = "200px"; - container.dataset.zoom = "5"; - container.dataset.max_zoom = "18"; - if (center !== null) container.dataset.center = center; - container.innerHTML = markers - .map( - (m) => - `

${m.title}

`, - ) - .join(""); - container.dataset.preInit = "map"; - document.body.appendChild(container); - - const errors: string[] = []; - const record = (e: ErrorEvent) => errors.push(e.message); - window.addEventListener("error", record); - - const logged: string[] = []; - const console_error = console.error; - console.error = (...args) => logged.push(args.join(" ")); - - sqlpage_map(); - await new Promise((resolve) => setTimeout(resolve, 500)); - - console.error = console_error; - window.removeEventListener("error", record); - - return { - errors, - logged, - markers: container.querySelectorAll(".leaflet-marker-icon").length, - initialized: !!container.querySelector(".leaflet-map-pane"), - }; - }, - { center, markers }, - ); -} - -test.beforeEach(async ({ page }) => { - await page.goto(`${BASE}/documentation.sql?component=map#component`); - await page.waitForFunction(() => "L" in window); -}); - -test("centers the map on a pair of coordinates", async ({ page }) => { - const map = await renderMap(page, PARIS); - - expect(map.errors).toEqual([]); - expect(map.logged).toEqual([]); - expect(map.initialized).toBe(true); -}); - -test("reports a center whose longitude is missing", async ({ page }) => { - const map = await renderMap(page, PARIS_WITHOUT_ITS_LONGITUDE); - - expect(map.errors).toEqual([]); - expect(map.logged).toEqual([ - expect.stringContaining(PARIS_WITHOUT_ITS_LONGITUDE), - ]); - expect(map.initialized).toBe(true); -}); - -test("reports a center that is not a pair of numbers", async ({ page }) => { - const map = await renderMap(page, NOT_COORDINATES); - - expect(map.errors).toEqual([]); - expect(map.logged).toEqual([expect.stringContaining(NOT_COORDINATES)]); - expect(map.initialized).toBe(true); -}); - -test("draws a marker at a pair of coordinates", async ({ page }) => { - const map = await renderMap(page, PARIS, [{ coords: PARIS, title: "Paris" }]); - - expect(map.errors).toEqual([]); - expect(map.logged).toEqual([]); - expect(map.markers).toBe(1); -}); - -test("reports a marker whose longitude is missing, keeping the others", async ({ - page, -}) => { - const map = await renderMap(page, PARIS, [ - { coords: PARIS_WITHOUT_ITS_LONGITUDE, title: "Half of Paris" }, - { coords: PARIS, title: "Paris" }, - ]); - - expect(map.errors).toEqual([]); - expect(map.logged).toEqual([ - expect.stringContaining(PARIS_WITHOUT_ITS_LONGITUDE), - ]); - expect(map.markers).toBe(1); -}); diff --git a/tests/end-to-end/official-site.spec.ts b/tests/end-to-end/official-site.spec.ts index 58d56956e..b9ef2ec21 100644 --- a/tests/end-to-end/official-site.spec.ts +++ b/tests/end-to-end/official-site.spec.ts @@ -1,9 +1,7 @@ import { expect, type Locator, type Page, test } from "@playwright/test"; -const BASE = process.env.SQLPAGE_TEST_BASE ?? "http://localhost:8080/"; - test("Open documentation", async ({ page }) => { - await page.goto(BASE); + await page.goto("/"); await expect(page).toHaveTitle(/SQLPage.*/); @@ -17,13 +15,13 @@ test("Open documentation", async ({ page }) => { }); test("chart", async ({ page }) => { - await page.goto(`${BASE}/documentation.sql?component=chart#component`); + await page.goto("/documentation.sql?component=chart#component"); await expect(page.getByText("Loading...")).not.toBeVisible(); await expect(page.locator(".apexcharts-canvas").first()).toBeVisible(); }); test("chart supports hiding legend", async ({ page }) => { - await page.goto(`${BASE}/documentation.sql?component=chart#component`); + await page.goto("/documentation.sql?component=chart#component"); const expensesChart = page.locator(".card", { has: page.getByRole("heading", { name: "Expenses" }), @@ -51,7 +49,7 @@ const drawnPoints = (card: Locator, series: string) => test("stacked chart draws every series at every x of the chart", async ({ page, }) => { - await page.goto(`${BASE}/documentation.sql?component=chart#component`); + await page.goto("/documentation.sql?component=chart#component"); const powerChart = chartCard(page, "Power draw"); await expect(powerChart.locator(".apexcharts-canvas")).toBeVisible(); @@ -65,7 +63,7 @@ test("stacked chart draws every series at every x of the chart", async ({ test("stacked chart raises a series only where it has a value", async ({ page, }) => { - await page.goto(`${BASE}/documentation.sql?component=chart#component`); + await page.goto("/documentation.sql?component=chart#component"); const powerChart = chartCard(page, "Power draw"); await expect(powerChart.locator(".apexcharts-canvas")).toBeVisible(); @@ -79,7 +77,7 @@ test("stacked chart raises a series only where it has a value", async ({ test("chart draws a yline as a line and a yline_end as a band", async ({ page, }) => { - await page.goto(`${BASE}/documentation.sql?component=chart#component`); + await page.goto("/documentation.sql?component=chart#component"); const temperature = page.locator(".card", { has: page.getByRole("heading", { name: "CPU temperature" }), @@ -99,7 +97,7 @@ test("chart draws a yline as a line and a yline_end as a band", async ({ test("chart draws an xline as a line and an xline_end as a band", async ({ page, }) => { - await page.goto(`${BASE}/documentation.sql?component=chart#component`); + await page.goto("/documentation.sql?component=chart#component"); const latency = page.locator(".card", { has: page.getByRole("heading", { name: "Request latency" }), @@ -119,7 +117,7 @@ test("chart draws an xline as a line and an xline_end as a band", async ({ test("horizontal chart draws a yline down it and an xline across it", async ({ page, }) => { - await page.goto(`${BASE}/documentation.sql?component=chart#component`); + await page.goto("/documentation.sql?component=chart#component"); const disks = page.locator(".card", { has: page.getByRole("heading", { name: "Disk usage" }), @@ -136,7 +134,7 @@ test("horizontal chart draws a yline down it and an xline across it", async ({ }); test("map", async ({ page }) => { - await page.goto(`${BASE}/documentation.sql?component=map#component`); + await page.goto("/documentation.sql?component=map#component"); await expect(page.getByText("Loading...")).not.toBeVisible(); await expect(page.locator(".leaflet-marker-icon").first()).toBeVisible(); }); @@ -144,7 +142,7 @@ test("map", async ({ page }) => { test("toast notifications initialize, stack, dismiss, and render safely", async ({ page, }) => { - await page.goto(`${BASE}/documentation.sql?component=toast#component`); + await page.goto("/documentation.sql?component=toast#component"); const automatic = page.locator("#toast-auto"); await expect(automatic).toBeVisible(); @@ -331,7 +329,7 @@ test("toast notifications initialize, stack, dismiss, and render safely", async }); test("form example", async ({ page }) => { - await page.goto(`${BASE}/examples/multistep-form`); + await page.goto("/examples/multistep-form"); // Single selection matching the value or label await page.getByLabel("From").selectOption("Paris"); await page.getByText("Next").click(); @@ -345,7 +343,7 @@ test("form example", async ({ page }) => { }); test("File upload", async ({ page }) => { - await page.goto(`${BASE}/your-first-sql-website`); + await page.goto("/your-first-sql-website"); await page.getByRole("button", { name: "Examples", exact: true }).click(); await page.getByText("File uploads").click(); const my_svg = 'Hello World'; @@ -362,7 +360,7 @@ test("File upload", async ({ page }) => { }); test("Authentication example", async ({ page }) => { - await page.goto(`${BASE}/examples/authentication/login.sql`); + await page.goto("/examples/authentication/login.sql"); await expect(page.locator("h1", { hasText: "Authentication" })).toBeVisible(); const usernameInput = page.getByLabel("Username"); @@ -381,7 +379,7 @@ test("Authentication example", async ({ page }) => { }); test("table filtering", async ({ page }) => { - await page.goto(`${BASE}/documentation.sql?component=table`); + await page.goto("/documentation.sql?component=table"); const tableSection = page.locator(".card", { has: page.getByRole("cell", { name: "Chart", exact: true }), }); @@ -398,7 +396,7 @@ test("table filtering", async ({ page }) => { }); const sortableTable = async (page: Page) => { - await page.goto(`${BASE}/documentation.sql?component=table`); + await page.goto("/documentation.sql?component=table"); return page.locator(".table-responsive", { has: page.getByRole("cell", { name: "31456" }), }); @@ -447,7 +445,7 @@ async function checkNoConsoleErrors(page: Page, component: string) { } }); - await page.goto(`${BASE}/documentation.sql?component=${component}`); + await page.goto(`/documentation.sql?component=${component}`); await page.waitForLoadState(); expect(errors).toHaveLength(0); @@ -470,7 +468,7 @@ test("no console errors on card page", async ({ page }) => { }); test("CSP issues unique nonces per request", async ({ page }) => { - const csp1 = await (await page.goto(BASE))?.headerValue( + const csp1 = await (await page.goto("/"))?.headerValue( "content-security-policy", ); const csp2 = await (await page.reload())?.headerValue( @@ -481,7 +479,7 @@ test("CSP issues unique nonces per request", async ({ page }) => { }); test("form component documentation", async ({ page }) => { - await page.goto(`${BASE}/component.sql?component=form`); + await page.goto("/component.sql?component=form"); const componentForm = page.locator("form", { has: page.getByRole("radio", { name: "Chart" }), @@ -504,7 +502,7 @@ test("form component documentation", async ({ page }) => { test("form select combines initial options with remote search results", async ({ page, }) => { - await page.goto(`${BASE}/component.sql?component=form`); + await page.goto("/component.sql?component=form"); const select = page .locator( @@ -586,7 +584,7 @@ test("form select combines initial options with remote search results", async ({ }); test("form type=select searchable=true", async ({ page }) => { - await page.goto(`${BASE}/examples/form`); + await page.goto("/examples/form"); const form = page.locator("form").filter({ has: page.locator('select[name="region"]'), @@ -640,7 +638,7 @@ test("form type=select searchable=true", async ({ page }) => { }); test("modal", async ({ page }) => { - await page.goto(`${BASE}/documentation.sql?component=modal#component`); + await page.goto("/documentation.sql?component=modal#component"); const openButton = page.getByRole("button", { name: "Open a simple modal" }); await openButton.click(); @@ -657,7 +655,7 @@ test("modal", async ({ page }) => { }); test("table action buttons - edit_url and delete_url", async ({ page }) => { - await page.goto(`${BASE}/documentation.sql?component=table`); + await page.goto("/documentation.sql?component=table"); const tableSection = page.locator(".table-responsive", { has: page.getByRole("cell", { name: "PharmaCo" }), }); @@ -675,7 +673,7 @@ test("table action buttons - edit_url and delete_url", async ({ page }) => { }); test("table action buttons - custom_actions", async ({ page }) => { - await page.goto(`${BASE}/documentation.sql?component=table`); + await page.goto("/documentation.sql?component=table"); const tableSection = page.locator(".table-responsive", { has: page.getByRole("cell", { name: "PharmaCo" }), }); @@ -691,7 +689,7 @@ test("table action buttons - custom_actions", async ({ page }) => { }); test("table action buttons - _sqlpage_actions", async ({ page }) => { - await page.goto(`${BASE}/documentation.sql?component=table`); + await page.goto("/documentation.sql?component=table"); const tableSection = page.locator(".table-responsive", { has: page.getByRole("cell", { name: "PharmaCo" }), }); @@ -722,7 +720,7 @@ test("table action buttons - _sqlpage_actions", async ({ page }) => { }); test("table action buttons - disabled action", async ({ page }) => { - await page.goto(`${BASE}/documentation.sql?component=table`); + await page.goto("/documentation.sql?component=table"); const tableSection = page.locator(".table-responsive", { has: page.getByRole("cell", { name: "PharmaCo" }), }); diff --git a/tests/end-to-end/playwright.config.ts b/tests/end-to-end/playwright.config.ts index 5b36286ae..9bac5c4d7 100644 --- a/tests/end-to-end/playwright.config.ts +++ b/tests/end-to-end/playwright.config.ts @@ -1,78 +1,48 @@ import { defineConfig, devices } from "@playwright/test"; -/** - * Read environment variables from file. - * https://github.com/motdotla/dotenv - */ -// import dotenv from 'dotenv'; -// dotenv.config({ path: path.resolve(__dirname, '.env') }); +const fixtureBaseURL = + process.env.SQLPAGE_FIXTURE_BASE ?? "http://127.0.0.1:8081"; +const sqlpage = + process.env.SQLPAGE_BINARY ?? "cargo run --manifest-path ../../Cargo.toml --"; -/** - * See https://playwright.dev/docs/test-configuration. - */ export default defineConfig({ - testDir: "./.", - /* Run tests in files in parallel */ + testDir: ".", fullyParallel: true, - /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, - /* Retry on CI only */ retries: process.env.CI ? 2 : 0, - /* Opt out of parallel tests on CI. */ workers: process.env.CI ? 1 : undefined, - /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: "html", - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { - /* Base URL to use in actions like `await page.goto('/')`. */ - // baseURL: 'http://127.0.0.1:3000', - - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: "on-first-retry", }, - - /* Configure projects for major browsers */ projects: [ { - name: "chromium", - use: { ...devices["Desktop Chrome"] }, + name: "official-site", + testMatch: "*.spec.ts", + use: { + ...devices["Desktop Chrome"], + baseURL: process.env.SQLPAGE_TEST_BASE ?? "http://127.0.0.1:8080", + }, + }, + { + name: "fixtures", + testMatch: "fixtures/**/test.ts", + use: { ...devices["Desktop Chrome"], baseURL: fixtureBaseURL }, + }, + ], + webServer: [ + { + command: sqlpage, + cwd: "../../examples/official-site", + url: "http://127.0.0.1:8080", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + { + command: `${sqlpage} --web-root fixtures --config-dir fixture-server`, + url: fixtureBaseURL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, }, - - // { - // name: 'firefox', - // use: { ...devices['Desktop Firefox'] }, - // }, - - // { - // name: 'webkit', - // use: { ...devices['Desktop Safari'] }, - // }, - - /* Test against mobile viewports. */ - // { - // name: 'Mobile Chrome', - // use: { ...devices['Pixel 5'] }, - // }, - // { - // name: 'Mobile Safari', - // use: { ...devices['iPhone 12'] }, - // }, - - /* Test against branded browsers. */ - // { - // name: 'Microsoft Edge', - // use: { ...devices['Desktop Edge'], channel: 'msedge' }, - // }, - // { - // name: 'Google Chrome', - // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, - // }, ], - - /* Run your local dev server before starting the tests */ - // webServer: { - // command: 'npm run start', - // url: 'http://127.0.0.1:3000', - // reuseExistingServer: !process.env.CI, - // }, }); diff --git a/tests/end-to-end/tsconfig.json b/tests/end-to-end/tsconfig.json index 0a1260782..f6bccf8fe 100644 --- a/tests/end-to-end/tsconfig.json +++ b/tests/end-to-end/tsconfig.json @@ -5,5 +5,5 @@ "moduleResolution": "nodenext", "types": ["node"] }, - "include": ["*.spec.ts", "*.config.ts", "globals.d.ts"] + "include": ["**/*.ts"] }