From 353c53b669be1507cf234b84c7c03ead7f581c3c Mon Sep 17 00:00:00 2001 From: Andrew Stein Date: Thu, 24 Sep 2026 16:26:43 -0400 Subject: [PATCH 1/4] Fix column value escape in `GenericSQLModel` header Signed-off-by: Andrew Stein --- .../rust/virtual_server/generic_sql_model.rs | 7 +- .../generic_sql_model/table_make_view.rs | 6 +- .../virtual_server/generic_sql_model/tests.rs | 119 ++++++++++++++++++ .../test/js/duckdb/client.spec.js | 1 + .../test/js/duckdb/min_max.spec.js | 16 +++ rust/perspective-js/test/js/duckdb/setup.js | 23 ++++ .../test/js/duckdb/split_by.spec.js | 53 ++++++++ 7 files changed, 221 insertions(+), 4 deletions(-) diff --git a/rust/perspective-client/src/rust/virtual_server/generic_sql_model.rs b/rust/perspective-client/src/rust/virtual_server/generic_sql_model.rs index bee333ba38..b068e35f33 100644 --- a/rust/perspective-client/src/rust/virtual_server/generic_sql_model.rs +++ b/rust/perspective-client/src/rust/virtual_server/generic_sql_model.rs @@ -417,7 +417,11 @@ impl GenericSQLVirtualServerModel { let all_columns: Vec = group_by_cols .into_iter() - .chain(data_columns.iter().map(|col| format!("\"{}\"", col))) + .chain( + data_columns + .iter() + .map(|col| format!("\"{}\"", quote_ident(col))), + ) .collect(); Ok(format!( @@ -477,6 +481,7 @@ impl GenericSQLVirtualServerModel { "" }; + let column_name = quote_ident(column_name); Ok(format!( "SELECT MIN(\"{}\"), MAX(\"{}\") FROM {}{}", column_name, column_name, view_id, where_clause diff --git a/rust/perspective-client/src/rust/virtual_server/generic_sql_model/table_make_view.rs b/rust/perspective-client/src/rust/virtual_server/generic_sql_model/table_make_view.rs index c5702bbfcb..619a916bcf 100644 --- a/rust/perspective-client/src/rust/virtual_server/generic_sql_model/table_make_view.rs +++ b/rust/perspective-client/src/rust/virtual_server/generic_sql_model/table_make_view.rs @@ -332,7 +332,7 @@ impl<'a> ViewQueryContext<'a> { expressions .get(col) .cloned() - .unwrap_or_else(|| format!("\"{}\"", col)) + .unwrap_or_else(|| format!("\"{}\"", quote_ident(col))) }; let row_id_expr = model.0.row_id_expr.as_deref().unwrap_or("rowid"); @@ -702,7 +702,7 @@ impl<'a> ViewQueryContext<'a> { .0 .get(col) .cloned() - .unwrap_or_else(|| format!("\"{}\"", col)) + .unwrap_or_else(|| format!("\"{}\"", quote_ident(col))) } fn get_aggregate(&self, col: &str) -> String { @@ -991,7 +991,7 @@ impl<'a> ViewQueryContext<'a> { self.config .split_by .iter() - .map(|c| format!("\"{}\"", c)) + .map(|c| format!("\"{}\"", quote_ident(c))) .collect::>() .join(", ") } diff --git a/rust/perspective-client/src/rust/virtual_server/generic_sql_model/tests.rs b/rust/perspective-client/src/rust/virtual_server/generic_sql_model/tests.rs index efcdc44ca8..4f27d88a51 100644 --- a/rust/perspective-client/src/rust/virtual_server/generic_sql_model/tests.rs +++ b/rust/perspective-client/src/rust/virtual_server/generic_sql_model/tests.rs @@ -1384,3 +1384,122 @@ fn test_expression_describe_uses_template() { "SELECT * FROM (SELECT \"x\" + 1 FROM t) AS __psp_describe__ LIMIT 0" ); } + +#[test] +fn test_view_get_data_escapes_double_quotes_in_column_paths() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.columns = vec![Some("amount".to_string())]; + config.split_by = vec!["item_title".to_string()]; + let viewport = ViewPort::default(); + + let mut schema = IndexMap::new(); + schema.insert("plain|amount".to_string(), ColumnType::Integer); + schema.insert("say \"hi\"|amount".to_string(), ColumnType::Integer); + let sql = builder + .view_get_data("my_view", &config, &viewport, &schema) + .unwrap(); + + assert_eq!( + sql, + "SELECT \"plain|amount\", \"say \"\"hi\"\"|amount\" FROM my_view" + ); +} + +#[test] +fn test_view_get_min_max_escapes_double_quotes_in_column_name() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let config = ViewConfig::default(); + + assert_eq!( + builder + .view_get_min_max("my_view", "say \"hi\"|amount", &config) + .unwrap(), + "SELECT MIN(\"say \"\"hi\"\"|amount\"), MAX(\"say \"\"hi\"\"|amount\") FROM my_view" + ); +} + +#[test] +fn test_table_make_view_escapes_double_quotes_in_column_names() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.columns = vec![Some("a\"b".to_string())]; + let sql = builder + .table_make_view("source_table", "dest_view", &config, &IndexMap::new()) + .unwrap(); + + assert!( + sql.contains("\"a\"\"b\" as \"a\"\"b\""), + "expected escaped column identifier: {}", + sql + ); + assert!( + !sql.contains("\"a\"b\""), + "expected no unescaped column identifier: {}", + sql + ); +} + +#[test] +fn test_table_make_view_escapes_double_quotes_in_group_by() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.columns = vec![Some("value".to_string())]; + config.group_by = vec!["ca\"t".to_string()]; + config.group_rollup_mode = GroupRollupMode::Flat; + let sql = builder + .table_make_view("source_table", "dest_view", &config, &IndexMap::new()) + .unwrap(); + + assert!( + sql.contains("GROUP BY \"ca\"\"t\""), + "expected escaped group_by identifier: {}", + sql + ); +} + +#[test] +fn test_table_make_view_escapes_double_quotes_in_split_by() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.columns = vec![Some("value".to_string())]; + config.group_by = vec!["category".to_string()]; + config.split_by = vec!["sta\"te".to_string()]; + let sql = builder + .table_make_view("source_table", "dest_view", &config, &IndexMap::new()) + .unwrap(); + + assert!( + sql.contains("\"sta\"\"te\" || '|value'"), + "expected escaped split_by identifier in ON expression: {}", + sql + ); + assert!( + !sql.contains("\"sta\"te\""), + "expected no unescaped split_by identifier: {}", + sql + ); +} + +#[test] +fn test_view_get_data_orders_column_paths_containing_separator() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.columns = vec![Some("amount".to_string()), Some("qty".to_string())]; + config.split_by = vec!["item_title".to_string()]; + let viewport = ViewPort::default(); + + let mut schema = IndexMap::new(); + schema.insert("b|c|qty".to_string(), ColumnType::Integer); + schema.insert("a|b|amount".to_string(), ColumnType::Integer); + schema.insert("b|c|amount".to_string(), ColumnType::Integer); + schema.insert("a|b|qty".to_string(), ColumnType::Integer); + let sql = builder + .view_get_data("my_view", &config, &viewport, &schema) + .unwrap(); + + assert_eq!( + sql, + "SELECT \"a|b|amount\", \"a|b|qty\", \"b|c|amount\", \"b|c|qty\" FROM my_view" + ); +} diff --git a/rust/perspective-js/test/js/duckdb/client.spec.js b/rust/perspective-js/test/js/duckdb/client.spec.js index 7ac44a377b..14b0341da1 100644 --- a/rust/perspective-js/test/js/duckdb/client.spec.js +++ b/rust/perspective-js/test/js/duckdb/client.spec.js @@ -19,6 +19,7 @@ describeDuckDB("client", (getClient) => { const tables = await client.get_hosted_table_names(); expect(tables).toEqual([ "memory.coerce_types", + "memory.quoted_test", "memory.superstore", "memory.temporal_test", "memory.underscore_test", diff --git a/rust/perspective-js/test/js/duckdb/min_max.spec.js b/rust/perspective-js/test/js/duckdb/min_max.spec.js index 18a8465e97..28febdc505 100644 --- a/rust/perspective-js/test/js/duckdb/min_max.spec.js +++ b/rust/perspective-js/test/js/duckdb/min_max.spec.js @@ -66,4 +66,20 @@ describeDuckDB("min_max", (getClient) => { expect(result[1]).toBe(14); await view.delete(); }); + + // https://github.com/perspective-dev/perspective/issues/3237 + test("get_min_max() on a column path containing double quotes", async function () { + const table = await getClient().open_table("memory.quoted_test"); + const view = await table.view({ + columns: ["amount"], + split_by: ["item_title"], + group_by: ['we"ird'], + aggregates: { amount: "sum" }, + }); + + const result = await view.get_min_max('say "hi"|amount'); + expect(result[0]).toBe(1); + expect(result[1]).toBe(8); + await view.delete(); + }); }); diff --git a/rust/perspective-js/test/js/duckdb/setup.js b/rust/perspective-js/test/js/duckdb/setup.js index 73cb18bbf8..81a4d2e810 100644 --- a/rust/perspective-js/test/js/duckdb/setup.js +++ b/rust/perspective-js/test/js/duckdb/setup.js @@ -92,6 +92,28 @@ async function loadUnderscoreData(db) { `); } +// Column names AND `split_by` values contain double quotes, which SQL +// identifier quoting must escape. https://github.com/perspective-dev/perspective/issues/3237 +async function loadQuotedData(db) { + await db.query(` + CREATE TABLE quoted_test ( + "we""ird" VARCHAR, + item_title VARCHAR, + amount DOUBLE + ); + `); + + await db.query(` + INSERT INTO quoted_test VALUES + ('g1', 'say "hi"', 1.0), + ('g1', 'plain', 2.0), + ('g1', 'a_b', 4.0), + ('g2', 'say "hi"', 8.0), + ('g2', 'plain', 16.0), + ('g2', 'a_b', 32.0); + `); +} + async function loadCoerceTypesData(db) { await db.query(`CREATE TYPE mood AS ENUM ('happy', 'sad')`); await db.query(` @@ -152,6 +174,7 @@ export function describeDuckDB(name, fn) { client = await perspective.worker(server); await loadSuperstoreData(db); await loadUnderscoreData(db); + await loadQuotedData(db); await loadCoerceTypesData(db); await loadTemporalData(db); }); diff --git a/rust/perspective-js/test/js/duckdb/split_by.spec.js b/rust/perspective-js/test/js/duckdb/split_by.spec.js index a80b6736a8..58ffa51e44 100644 --- a/rust/perspective-js/test/js/duckdb/split_by.spec.js +++ b/rust/perspective-js/test/js/duckdb/split_by.spec.js @@ -121,4 +121,57 @@ describeDuckDB("split_by", (getClient) => { expect(paths.some((c) => c.includes("Technology"))).toBe(true); await view.delete(); }); + + // https://github.com/perspective-dev/perspective/issues/3237 + test("split_by on values containing double quotes", async function () { + const table = await getClient().open_table("memory.quoted_test"); + const view = await table.view({ + columns: ["amount"], + split_by: ["item_title"], + group_by: ['we"ird'], + aggregates: { amount: "sum" }, + }); + + expect(await view.column_paths()).toEqual([ + "a_b|amount", + "plain|amount", + 'say "hi"|amount', + ]); + + expect(await view.to_json()).toEqual([ + { + __ROW_PATH__: [], + "a_b|amount": 36, + "plain|amount": 18, + 'say "hi"|amount': 9, + }, + { + __ROW_PATH__: ["g1"], + "a_b|amount": 4, + "plain|amount": 2, + 'say "hi"|amount': 1, + }, + { + __ROW_PATH__: ["g2"], + "a_b|amount": 32, + "plain|amount": 16, + 'say "hi"|amount': 8, + }, + ]); + await view.delete(); + }); + + // https://github.com/perspective-dev/perspective/issues/3237 + test("split_by on a column name containing double quotes", async function () { + const table = await getClient().open_table("memory.quoted_test"); + const view = await table.view({ + columns: ["amount"], + split_by: ['we"ird'], + group_by: ["item_title"], + aggregates: { amount: "sum" }, + }); + + expect(await view.column_paths()).toEqual(["g1|amount", "g2|amount"]); + await view.delete(); + }); }); From 6d8a64c1f809527e4eeb929dde05bba84267dd31 Mon Sep 17 00:00:00 2001 From: Andrew Stein Date: Thu, 24 Sep 2026 17:27:38 -0400 Subject: [PATCH 2/4] Fix ts imports Signed-off-by: Andrew Stein --- .../viewer-charts/src/ts/charts/chart-base.ts | 2 +- packages/viewer-charts/src/ts/event-detail.ts | 2 +- .../src/ts/plugin/format-defaults.ts | 2 +- .../src/ts/custom_elements/datagrid.ts | 2 +- .../src/ts/data_listener/formatter_cache.ts | 2 +- packages/viewer-datagrid/src/ts/types.ts | 2 +- rust/perspective-viewer/build.mjs | 9 ++++ rust/perspective-viewer/package.json | 10 ++++ rust/perspective-viewer/src/ts/extensions.ts | 39 ++------------- .../src/ts/perspective-viewer.ts | 2 +- .../src/ts/select-detail.ts | 48 +++++++++++++++++++ 11 files changed, 77 insertions(+), 43 deletions(-) create mode 100644 rust/perspective-viewer/src/ts/select-detail.ts diff --git a/packages/viewer-charts/src/ts/charts/chart-base.ts b/packages/viewer-charts/src/ts/charts/chart-base.ts index 4d3ca727ce..d9e384d915 100644 --- a/packages/viewer-charts/src/ts/charts/chart-base.ts +++ b/packages/viewer-charts/src/ts/charts/chart-base.ts @@ -18,7 +18,7 @@ import { sourceColumn, type NumberFormatConfig, type DateFormatConfig, -} from "@perspective-dev/viewer/src/ts/column-format.js"; +} from "@perspective-dev/viewer/column-format"; import type { ColumnDataMap } from "../data/view-reader"; import { LazyRowFetcher } from "../data/lazy-row"; import { diff --git a/packages/viewer-charts/src/ts/event-detail.ts b/packages/viewer-charts/src/ts/event-detail.ts index a29f660924..a0f7df51c0 100644 --- a/packages/viewer-charts/src/ts/event-detail.ts +++ b/packages/viewer-charts/src/ts/event-detail.ts @@ -49,4 +49,4 @@ export interface PerspectiveClickDetail { panel?: string; } -export { PerspectiveSelectDetail } from "@perspective-dev/viewer/src/ts/extensions.js"; +export { PerspectiveSelectDetail } from "@perspective-dev/viewer/select-detail"; diff --git a/packages/viewer-charts/src/ts/plugin/format-defaults.ts b/packages/viewer-charts/src/ts/plugin/format-defaults.ts index 145da5237b..50d0190006 100644 --- a/packages/viewer-charts/src/ts/plugin/format-defaults.ts +++ b/packages/viewer-charts/src/ts/plugin/format-defaults.ts @@ -13,7 +13,7 @@ import type { DateFormatConfig, NumberFormatConfig, -} from "@perspective-dev/viewer/src/ts/column-format.js"; +} from "@perspective-dev/viewer/column-format"; /** * The chart plugin's default per-column formats, declared to the viewer in diff --git a/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts b/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts index 677a662697..3e2fe499d1 100644 --- a/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts +++ b/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts @@ -26,7 +26,7 @@ import column_config_schema, { import plugin_config_schema from "../plugin/plugin_config_schema.js"; import datagridStyles from "../../../dist/css/perspective-viewer-datagrid.css"; import { format_raw } from "../data_listener/format_cell.js"; -import { sourceColumn } from "@perspective-dev/viewer/src/ts/column-format.js"; +import { sourceColumn } from "@perspective-dev/viewer/column-format"; import type { View, ViewWindow } from "@perspective-dev/client"; import type { diff --git a/packages/viewer-datagrid/src/ts/data_listener/formatter_cache.ts b/packages/viewer-datagrid/src/ts/data_listener/formatter_cache.ts index 7983a44cb1..1633398dbe 100644 --- a/packages/viewer-datagrid/src/ts/data_listener/formatter_cache.ts +++ b/packages/viewer-datagrid/src/ts/data_listener/formatter_cache.ts @@ -15,7 +15,7 @@ import { createDateFormatter, createDatetimeFormatter, createNumberFormatter, -} from "@perspective-dev/viewer/src/ts/column-format.js"; +} from "@perspective-dev/viewer/column-format"; import type { ColumnConfig } from "../types.js"; export interface Formatter { diff --git a/packages/viewer-datagrid/src/ts/types.ts b/packages/viewer-datagrid/src/ts/types.ts index deccd878d8..07a66fdb1f 100644 --- a/packages/viewer-datagrid/src/ts/types.ts +++ b/packages/viewer-datagrid/src/ts/types.ts @@ -592,7 +592,7 @@ export interface PerspectiveClickDetail { panel?: string; } -export { PerspectiveSelectDetail } from "@perspective-dev/viewer/src/ts/extensions.js"; +export { PerspectiveSelectDetail } from "@perspective-dev/viewer/select-detail"; // Mouse event with handled flag export interface HandledMouseEvent extends MouseEvent { diff --git a/rust/perspective-viewer/build.mjs b/rust/perspective-viewer/build.mjs index ce74b41f02..97a2dccb2a 100644 --- a/rust/perspective-viewer/build.mjs +++ b/rust/perspective-viewer/build.mjs @@ -110,6 +110,15 @@ export async function build_all() { }), ], }, + // Dependency-free leaf modules, published as the `./select-detail` + // and `./column-format` subpath exports. Plugin packages import these + // instead of the package root, which would link the entire viewer and + // its wasm asset into a plugin's standalone `dist/cdn` bundle. + { + entryPoints: ["src/ts/select-detail.ts", "src/ts/column-format.ts"], + format: "esm", + outdir: "dist/esm", + }, // WASM assets linked to relative path via `fetch()`. This efficiently // loading build is great for ` - - - - - - - - diff --git a/rust/perspective-viewer/test/js/superstore/inline.spec.ts b/rust/perspective-viewer/test/js/superstore/inline.spec.ts deleted file mode 100644 index 02e209f842..0000000000 --- a/rust/perspective-viewer/test/js/superstore/inline.spec.ts +++ /dev/null @@ -1,47 +0,0 @@ -// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ -// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ -// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ -// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ -// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ -// ┃ Copyright (c) 2017, the Perspective Authors. ┃ -// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ -// ┃ This file is part of the Perspective library, distributed under the terms ┃ -// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ -// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - -import { test } from "../helpers.ts"; -import { run_standard_tests } from "../helpers/standard_tests.ts"; - -async function get_contents(page) { - return await page.evaluate(async () => { - const viewer = document.querySelector( - "perspective-viewer perspective-viewer-plugin", - ); - - // Don't format - light DOM is CSV in a
 tag.
-        return viewer.innerHTML;
-    });
-}
-
-test.describe("Superstore Inline", () => {
-    test.beforeEach(async function init({ page }) {
-        await page.goto(
-            "/node_modules/@perspective-dev/viewer/test/html/superstore-inline.html",
-        );
-
-        await page.evaluate(async () => {
-            while (!window["__TEST_PERSPECTIVE_READY__"]) {
-                await new Promise((x) => setTimeout(x, 10));
-            }
-        });
-
-        await page.evaluate(async () => {
-            await document.querySelector("perspective-viewer").restore({
-                plugin: "Debug",
-            });
-        });
-    });
-
-    run_standard_tests("superstore inline", get_contents);
-});
diff --git a/rust/perspective-viewer/test/js/windows.spec.ts b/rust/perspective-viewer/test/js/windows.spec.ts
index 7a13159832..2ced1a00c1 100644
--- a/rust/perspective-viewer/test/js/windows.spec.ts
+++ b/rust/perspective-viewer/test/js/windows.spec.ts
@@ -14,9 +14,7 @@ import { test, expect, compareInnerHTMLToSnapshot } from "./helpers.ts";
 
 test.describe("Window columns", () => {
     test.beforeEach(async function init({ page }) {
-        await page.goto(
-            "/rust/perspective-viewer/test/html/superstore-inline.html",
-        );
+        await page.goto("/rust/perspective-viewer/test/html/superstore.html");
 
         await page.evaluate(async () => {
             while (!window["__TEST_PERSPECTIVE_READY__"]) {
diff --git a/tools/bench/puppeteer_suite.mjs b/tools/bench/puppeteer_suite.mjs
index ab9e29e2ad..fe4e372c57 100644
--- a/tools/bench/puppeteer_suite.mjs
+++ b/tools/bench/puppeteer_suite.mjs
@@ -32,6 +32,27 @@ const VERSIONS = [
     "perspective-2-10-0",
 ];
 
+const WORKSPACE_CLIENT = "@perspective-dev/client";
+
+/**
+ * The module a candidate version loads from, plus the client wasm it must
+ * bootstrap itself with, since only published versions ship an inline build.
+ */
+function candidate_urls(version) {
+    if (version !== WORKSPACE_CLIENT) {
+        return {
+            module: `/tools/bench/node_modules/${version}/dist/esm/perspective.inline.js`,
+            client_wasm: null,
+        };
+    }
+
+    return {
+        module: "/node_modules/@perspective-dev/client/dist/cdn/perspective.js",
+        client_wasm:
+            "/node_modules/@perspective-dev/client/dist/wasm/perspective-js.wasm",
+    };
+}
+
 perspective_bench.suite(
     [...VERSIONS],
     path.join(__dirname, "dist/benchmark-js.arrow"),
@@ -48,10 +69,12 @@ perspective_bench.suite(
 
         async function test_suite(suite) {
             const items = await page.evaluate(
-                async ([version, suite, version_idx]) => {
-                    const { default: perspective } = await import(
-                        `/tools/bench/node_modules/${version}/dist/esm/perspective.inline.js`
-                    );
+                async ([version, suite, version_idx, urls]) => {
+                    const { default: perspective } = await import(urls.module);
+                    if (urls.client_wasm) {
+                        perspective.init_client(fetch(urls.client_wasm));
+                    }
+
                     const benchmarks = await import(
                         "/tools/bench/cross_platform_suite.mjs"
                     );
@@ -69,7 +92,7 @@ perspective_bench.suite(
                     await benchmarks[suite](client, metadata);
                     return total;
                 },
-                [path, suite, version_idx],
+                [path, suite, version_idx, candidate_urls(path)],
             );
 
             for (const { obs_records, stats } of items) {

From 9807131865de8cfb4c25b19e45485f6b8e5cc129 Mon Sep 17 00:00:00 2001
From: Andrew Stein 
Date: Thu, 24 Sep 2026 20:51:45 -0400
Subject: [PATCH 4/4] Fix tooltip + axis rendered value

Signed-off-by: Andrew Stein 
---
 .../src/ts/charts/cartesian/glyphs/lines.ts   | 36 +-------
 packages/viewer-charts/test/ts/helpers.ts     | 88 +++++++++++++++++++
 .../test/ts/tooltip-source-values.spec.ts     | 65 ++++++++++++++
 3 files changed, 156 insertions(+), 33 deletions(-)
 create mode 100644 packages/viewer-charts/test/ts/tooltip-source-values.spec.ts

diff --git a/packages/viewer-charts/src/ts/charts/cartesian/glyphs/lines.ts b/packages/viewer-charts/src/ts/charts/cartesian/glyphs/lines.ts
index f398b74b24..d07c850e84 100644
--- a/packages/viewer-charts/src/ts/charts/cartesian/glyphs/lines.ts
+++ b/packages/viewer-charts/src/ts/charts/cartesian/glyphs/lines.ts
@@ -20,7 +20,7 @@ import {
 } from "../../../webgl/instanced-attrs";
 import { compileProgram } from "../../../webgl/program-cache";
 import { colorRangePivot } from "../../../theme/gradient";
-import { formatTickValue, formatDateTickValue } from "../../../layout/ticks";
+import { buildPointRowTooltipLines } from "../tooltip-lines";
 import lineVert from "../../../shaders/line.vert.glsl";
 import lineFrag from "../../../shaders/line.frag.glsl";
 
@@ -122,41 +122,11 @@ export class LineGlyph implements Glyph {
 
     //  helpers
 
-    async buildTooltipLines(
+    buildTooltipLines(
         chart: CartesianChart,
         flatIdx: number,
     ): Promise {
-        const grid: string[][] = [];
-        if (!chart._xData || !chart._yData) {
-            return grid;
-        }
-
-        if (chart._splitGroups.length > 0 && chart._seriesCapacity > 0) {
-            const seriesIdx = Math.floor(flatIdx / chart._seriesCapacity);
-            const sg = chart._splitGroups[seriesIdx];
-            if (sg) {
-                grid.push([sg.prefix]);
-            }
-        }
-
-        const xVal = chart._xData[flatIdx];
-        const yVal = chart._yData[flatIdx];
-
-        const xType = chart._columnTypes[chart._xLabel] || "";
-        const xIsDate = xType === "date" || xType === "datetime";
-        const xFormatted = xIsDate
-            ? formatDateTickValue(xVal)
-            : formatTickValue(xVal);
-        grid.push([chart._xLabel || "Row", xFormatted]);
-
-        const yType = chart._columnTypes[chart._yLabel] || "";
-        const yIsDate = yType === "date" || yType === "datetime";
-        const yFormatted = yIsDate
-            ? formatDateTickValue(yVal)
-            : formatTickValue(yVal);
-        grid.push([chart._yLabel, yFormatted]);
-
-        return grid;
+        return buildPointRowTooltipLines(chart, flatIdx);
     }
 
     tooltipOptions() {
diff --git a/packages/viewer-charts/test/ts/helpers.ts b/packages/viewer-charts/test/ts/helpers.ts
index 756c8212c9..cb944120df 100644
--- a/packages/viewer-charts/test/ts/helpers.ts
+++ b/packages/viewer-charts/test/ts/helpers.ts
@@ -764,3 +764,91 @@ export function assertViewerQuiescent(
         }
     }
 }
+
+export async function readPinnedTooltip(page: Page): Promise {
+    return await page.evaluate(() => {
+        const host = document.querySelector(
+            'perspective-viewer > [slot]:not([slot^="tab-"])',
+        );
+
+        const tip = host?.shadowRoot?.querySelector(".webgl-tooltip");
+        return tip
+            ? [...tip.children].map((c) => (c.textContent ?? "").trim())
+            : null;
+    });
+}
+
+export function tooltipValue(
+    cells: string[],
+    label: string,
+): string | undefined {
+    const i = cells.indexOf(label);
+    return i >= 0 ? cells[i + 1] : undefined;
+}
+
+export async function sweepPinnedTooltips(
+    page: Page,
+    step: { x: number; y: number } = { x: 0.02, y: 0.05 },
+): Promise {
+    const box = await page.evaluate(() => {
+        const host = document.querySelector(
+            'perspective-viewer > [slot]:not([slot^="tab-"])',
+        );
+
+        if (!host) {
+            return null;
+        }
+
+        const r = host.getBoundingClientRect();
+        return { x: r.x, y: r.y, w: r.width, h: r.height };
+    });
+
+    if (!box) {
+        return [];
+    }
+
+    const seen = new Map();
+    for (let fx = 0.05; fx <= 0.96; fx += step.x) {
+        for (let fy = 0.05; fy <= 0.96; fy += step.y) {
+            await page.mouse.move(box.x + box.w * fx, box.y + box.h * fy);
+            await page.mouse.click(box.x + box.w * fx, box.y + box.h * fy);
+            const cells = await readPinnedTooltip(page);
+            if (cells && cells.length > 0) {
+                seen.set(JSON.stringify(cells), cells);
+            }
+        }
+    }
+
+    return [...seen.values()];
+}
+
+export async function viewYearRange(
+    page: Page,
+    suffix: string,
+): Promise<[number, number]> {
+    return await page.evaluate(async (s) => {
+        const viewer = document.querySelector("perspective-viewer")!;
+        const view = await (viewer as any).getView({ mode: "clone" });
+        try {
+            const columns = await view.to_columns();
+            const years: number[] = [];
+            for (const [name, values] of Object.entries(columns)) {
+                if (!name.endsWith(s)) {
+                    continue;
+                }
+
+                for (const value of values as (number | null)[]) {
+                    if (value === null || value === undefined) {
+                        continue;
+                    }
+
+                    years.push(new Date(Number(value)).getFullYear());
+                }
+            }
+
+            return [Math.min(...years), Math.max(...years)] as [number, number];
+        } finally {
+            await view.delete();
+        }
+    }, suffix);
+}
diff --git a/packages/viewer-charts/test/ts/tooltip-source-values.spec.ts b/packages/viewer-charts/test/ts/tooltip-source-values.spec.ts
new file mode 100644
index 0000000000..4712907028
--- /dev/null
+++ b/packages/viewer-charts/test/ts/tooltip-source-values.spec.ts
@@ -0,0 +1,65 @@
+// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+// ┃ ██████ ██████ ██████       █      █      █      █      █ █▄  ▀███ █       ┃
+// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█  ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄  ▀█ █ ▀▀▀▀▀ ┃
+// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄   █ ▄▄▄▄▄ ┃
+// ┃ █      ██████ █  ▀█▄       █ ██████      █      ███▌▐███ ███████▄ █       ┃
+// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
+// ┃ Copyright (c) 2017, the Perspective Authors.                              ┃
+// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
+// ┃ This file is part of the Perspective library, distributed under the terms ┃
+// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
+// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+
+import type { ViewerConfigUpdate } from "@perspective-dev/viewer";
+import { expect, test } from "@perspective-dev/test";
+import {
+    gotoBasic,
+    restoreChart,
+    sweepPinnedTooltips,
+    tooltipValue,
+    viewYearRange,
+} from "./helpers";
+
+const DATETIME_X: ViewerConfigUpdate = {
+    columns: ["Order Date", "Sales"],
+    group_by: [],
+    split_by: ["Region"],
+    sort: [["Order Date", "asc"]],
+} as ViewerConfigUpdate;
+
+const YEAR = /\b(\d{4})\b/;
+
+test.describe("Tooltip source values", () => {
+    test.beforeEach(async ({ page }) => {
+        await gotoBasic(page);
+    });
+
+    for (const plugin of ["X/Y Scatter", "X/Y Line"]) {
+        test(`${plugin} pins dates and values from the source row`, async ({
+            page,
+        }) => {
+            await restoreChart(page, {
+                ...DATETIME_X,
+                plugin,
+            } as ViewerConfigUpdate);
+
+            const [minYear, maxYear] = await viewYearRange(page, "Order Date");
+            expect(minYear).toBeGreaterThan(1970);
+
+            const tooltips = await sweepPinnedTooltips(page);
+            expect(tooltips.length).toBeGreaterThan(4);
+
+            for (const cells of tooltips) {
+                const date = tooltipValue(cells, "Order Date");
+                const sales = tooltipValue(cells, "Sales");
+                expect(date).toBeDefined();
+                expect(sales).toBeDefined();
+
+                const year = Number(YEAR.exec(date!)?.[1]);
+                expect(year).toBeGreaterThanOrEqual(minYear - 1);
+                expect(year).toBeLessThanOrEqual(maxYear + 1);
+                expect(sales!.startsWith("-")).toBe(false);
+            }
+        });
+    }
+});