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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -886,6 +886,7 @@ jobs:
run: pnpm run test -- --fetch-snapshots
env:
PACKAGE: "server,client,viewer,viewer-datagrid,viewer-charts,react"
PSP_BROWSER_SUITE: chrome
PSP_SNAPSHOT_REPO: ${{ vars.PSP_SNAPSHOT_REPO }}
PSP_SNAPSHOT_TOKEN: ${{ secrets.PSP_SNAPSHOT_TOKEN }}
PSP_SNAPSHOT_REF: ${{ github.head_ref || github.ref_name }}
Expand All @@ -900,6 +901,69 @@ jobs:
if-no-files-found: ignore
overwrite: true

# Firefox coverage for the browser UI suites only (viewer, datagrid,
# charts — the node/client suites are browser-independent), run in
# parallel with the release-grade matrix above: tags, `master`, or a
# `ci-full` dispatch (the same gate as the wasm64/node-26 entries).
test_js_firefox:
needs: [build_js, build_js_wasm64]
if: ${{ startsWith(github.ref, 'refs/tags/v') || github.ref_name == 'master' || github.event.inputs.ci-full }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os:
- ubuntu-22.04
node-version: [22.x]

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Config
id: config-step
uses: ./.github/actions/config

- name: Initialize Build
uses: ./.github/actions/install-deps
with:
python: "false"
rust: "false"
cpp: "false"
playwright: "true"
skip_cache: ${{ steps.config-step.outputs.SKIP_CACHE }}

- uses: actions/download-artifact@v4
with:
name: perspective-js-dist
path: .

- uses: actions/download-artifact@v4
with:
name: perspective-server-wasm64-dist
path: rust/perspective-server/dist/wasm

- name: Run Tests
id: run_tests
run: pnpm run test -- --fetch-snapshots
env:
# TODO(texodus): no GL firefox on GHA
# PACKAGE: "viewer,viewer-datagrid,viewer-charts"
PACKAGE: "viewer,viewer-datagrid"
PSP_BROWSER_SUITE: firefox
PSP_SNAPSHOT_REPO: ${{ vars.PSP_SNAPSHOT_REPO }}
PSP_SNAPSHOT_TOKEN: ${{ secrets.PSP_SNAPSHOT_TOKEN }}
PSP_SNAPSHOT_REF: ${{ github.head_ref || github.ref_name }}

- name: Upload Test Failures
if: ${{ failure() && steps.run_tests.outcome == 'failure' }}
uses: actions/upload-artifact@v4
with:
name: perspective-js-test-results-firefox
path: tools/test/dist/results
if-no-files-found: ignore
overwrite: true

# ,--,--' . .-,--. . .
# `- | ,-. ,-. |- '|__/ . . |- |-. ,-. ,-.
# , | |-' `-. | ,| | | | | | | | | |
Expand Down
2 changes: 0 additions & 2 deletions Cargo.lock

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
"postinstall": "npm-run-all postinstall:*",
"postinstall:binaryen": "node tools/scripts/install_binaryen.mjs",
"postinstall:emsdk": "node tools/scripts/install_emsdk.mjs",
"postinstall:playwright": "pnpm exec playwright install --with-deps chromium",
"postinstall:playwright": "pnpm exec playwright install --with-deps chromium firefox",
"postinstall:vscode": "cp -n ./.vscode/settings.default.json ./.vscode/settings.json || true",
"install_llvm": "node tools/scripts/install_llvm.mjs",
"install_pyodide": "node tools/scripts/install_pyodide.mjs",
Expand Down
10 changes: 4 additions & 6 deletions packages/viewer-charts/src/ts/render/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,18 +380,16 @@ async function present(entry: Entry): Promise<void> {
// on resize. Failures fall through to the same present-failed
// path as the fence.
entry.render2D();
entry.glManager.endFrame();
await entry.glManager.endFrame();
for (const w of entry.waiters) {
w.resolve();
}
} catch (err) {
console.error("scheduler: present failed", err);
// Still call `endFrame` so the canvas state is consistent —
// `transferToImageBitmap` clears the offscreen, and skipping
// it would leave a stale image bound to a context the host
// already considers presented.
// Still ship a frame so the host does not hold a stale image for
// a context it already considers presented.
try {
entry.glManager.endFrame();
await entry.glManager.endFrame();
} catch {
// Swallow: already in a failure path.
}
Expand Down
30 changes: 16 additions & 14 deletions packages/viewer-charts/src/ts/webgl/context-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,8 @@ export class WebGLContextManager {
this._cssHeight = cssHeight;
this._dpr = dpr;

const width = Math.round(cssWidth * dpr);
const height = Math.round(cssHeight * dpr);
const width = Math.max(1, Math.round(cssWidth * dpr));
const height = Math.max(1, Math.round(cssHeight * dpr));
const canvas = this._ctx.canvas;
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
Expand Down Expand Up @@ -284,19 +284,19 @@ export class WebGLContextManager {
}

/**
* Ship the current canvas contents as an `ImageBitmap` to the host
* when a frame callback is registered and the surface is an
* Ship a copy of the current canvas contents as an `ImageBitmap` to
* the host when a frame callback is registered and the surface is an
* `OffscreenCanvas`. Otherwise no-op (direct mode paints straight to
* the visible drawing buffer).
*/
endFrame(): void {
async endFrame(): Promise<void> {
if (!this._frameCallback) {
return;
}

// A context lost mid-frame (our own `destroy`, or a browser
// eviction between paint and present) makes
// `transferToImageBitmap` throw. Bail quietly.
// eviction between paint and present) makes the bitmap copy
// throw. Bail quietly.
if (this.isContextLost()) {
return;
}
Expand All @@ -311,15 +311,15 @@ export class WebGLContextManager {
return;
}

const bitmap = (canvas as OffscreenCanvas).transferToImageBitmap();
const bitmap = await createImageBitmap(canvas as OffscreenCanvas);
this._frameCallback(bitmap);
}

/**
* Resolve when every GL command submitted up to this call has been
* executed by the GPU. WebGL2 issues a `fenceSync` and polls
* `clientWaitSync` (yielding to the task queue between polls); WebGL1
* falls back to a blocking `gl.finish()` (acceptable in a worker).
* executed by the GPU, polling a WebGL2 fence with a flush on every
* poll and falling back to a blocking `gl.finish()` on WebGL1 or on
* `WAIT_FAILED`.
*/
async awaitGpuFence(): Promise<void> {
if (!this._ctx.isWebGL2) {
Expand All @@ -335,9 +335,12 @@ export class WebGLContextManager {
}

try {
let flags: GLbitfield = gl.SYNC_FLUSH_COMMANDS_BIT;
while (true) {
const status = gl.clientWaitSync(fence, flags, 0);
const status = gl.clientWaitSync(
fence,
gl.SYNC_FLUSH_COMMANDS_BIT,
0,
);
if (
status === gl.ALREADY_SIGNALED ||
status === gl.CONDITION_SATISFIED
Expand All @@ -350,7 +353,6 @@ export class WebGLContextManager {
return;
}

flags = 0;
await this._yieldToTask();

if (this._destroyed) {
Expand Down
94 changes: 9 additions & 85 deletions packages/viewer-charts/test/ts/gradient-legend.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,81 +19,15 @@
* colors its legend never showed. Fixed by shipping the sign-pivot
* `u_color_range` (`colorRangePivot`) for numeric columns, making the
* shaders' linear branch reproduce `colorValueToT` exactly.
*
* Probe: count "warm" pixels — red channel dominant over blue — across
* the visible composite canvas. The default theme's diverging gradient
* keeps its negative half in browns / oranges / yellows and its
* positive half in neutral → green → blue, so warm ink implies
* negative-side colors. The zero-crossing control test proves the
* probe's sensitivity: raw superstore `Profit` HAS negatives, so warm
* ink must appear.
*/

import type { Page } from "@playwright/test";
import { expect, test } from "@perspective-dev/test";
import { gotoBasic, restoreChart, waitOneFrame } from "./helpers";

/**
* Count pixels whose red channel dominates blue — the negative
* (brown / orange / yellow) half of the default diverging gradient.
* Excludes the positive half (`#f0f0f0` neutral pivot, greens with
* `r ≈ b + ~20`, blue-dominant blues) and grayscale chrome. The
* darkest browns (`r < 140`) are deliberately missed; the mid-orange
* band is more than enough signal for both directions of assertion.
*/
async function countWarmPixels(page: Page): Promise<number> {
return await page.evaluate(() => {
const visit = (
root: Document | ShadowRoot,
): HTMLCanvasElement | null => {
const direct = root.querySelector(
".webgl-canvas",
) as HTMLCanvasElement | null;
if (direct) {
return direct;
}

for (const el of Array.from(root.querySelectorAll("*"))) {
const sr = (el as Element & { shadowRoot?: ShadowRoot })
.shadowRoot;
if (sr) {
const found = visit(sr);
if (found) {
return found;
}
}
}

return null;
};

const canvas = visit(document);
if (!canvas || canvas.width === 0 || canvas.height === 0) {
throw new Error("countWarmPixels: no .webgl-canvas found");
}

const sampler = document.createElement("canvas");
sampler.width = canvas.width;
sampler.height = canvas.height;
const ctx = sampler.getContext("2d", { willReadFrequently: true });
if (!ctx) {
throw new Error("countWarmPixels: sampler 2D context unavailable");
}

ctx.drawImage(canvas, 0, 0);
const data = ctx.getImageData(0, 0, sampler.width, sampler.height).data;
let warm = 0;
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const b = data[i + 2];
if (r > 140 && r > b + 48) {
warm += 1;
}
}

return warm;
});
}
import { test } from "@perspective-dev/test";
import {
expectViewerScreenshot,
gotoBasic,
restoreChart,
waitOneFrame,
} from "./helpers";

/** Columns are `[X Axis, Y Axis, Color]` for the X/Y families. */
const ALL_POSITIVE_COLOR = ["Sales", "Profit", "Quantity"];
Expand All @@ -114,11 +48,7 @@ test.describe("gradient color mapping (regression)", () => {
});
await waitOneFrame(page);
await waitOneFrame(page);
const warm = await countWarmPixels(page);

// AA slack only — pre-fix this counted thousands of
// orange/brown pixels for `Quantity` ∈ [1, 14].
expect(warm).toBeLessThan(50);
await expectViewerScreenshot(page);
});

test(`${plugin} zero-crossing color column spans both halves (probe control)`, async ({
Expand All @@ -130,13 +60,7 @@ test.describe("gradient color mapping (regression)", () => {
});
await waitOneFrame(page);
await waitOneFrame(page);
const warm = await countWarmPixels(page);

// Raw superstore `Profit` has negative rows, so both the
// plot AND the legend bar must show warm negative-side
// ink. A probe regression (theme change, warm-threshold
// rot) fails HERE first, not in the assertion above.
expect(warm).toBeGreaterThan(500);
await expectViewerScreenshot(page);
});
}
});
48 changes: 46 additions & 2 deletions packages/viewer-charts/test/ts/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -786,10 +786,51 @@ export function tooltipValue(
return i >= 0 ? cells[i + 1] : undefined;
}

/** Poll for a pinned tooltip for up to `timeoutMs`, since the pin lands only after the source row resolves. */
export async function waitPinnedTooltip(
page: Page,
timeoutMs: number,
): Promise<string[] | null> {
return await page.evaluate(async (timeoutMs) => {
const read = () => {
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;
};

const deadline = performance.now() + timeoutMs;
for (;;) {
const cells = read();
if (cells && cells.length > 0) {
return cells;
}

if (performance.now() >= deadline) {
return null;
}

await new Promise((resolve) => setTimeout(resolve, 5));
}
}, timeoutMs);
}

/** Click a grid over the plot and collect distinct pinned tooltips, stopping once `minDistinct` are found. */
export async function sweepPinnedTooltips(
page: Page,
step: { x: number; y: number } = { x: 0.02, y: 0.05 },
options: {
step?: { x: number; y: number };
minDistinct?: number;
settleMs?: number;
} = {},
): Promise<string[][]> {
const step = options.step ?? { x: 0.02, y: 0.05 };
const minDistinct = options.minDistinct ?? 8;
const settleMs = options.settleMs ?? 100;
const box = await page.evaluate(() => {
const host = document.querySelector(
'perspective-viewer > [slot]:not([slot^="tab-"])',
Expand All @@ -812,9 +853,12 @@ export async function sweepPinnedTooltips(
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);
const cells = await waitPinnedTooltip(page, settleMs);
if (cells && cells.length > 0) {
seen.set(JSON.stringify(cells), cells);
if (seen.size >= minDistinct) {
return [...seen.values()];
}
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/viewer-charts/test/ts/map-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ export async function pollMeanColor(
page: Page,
region: PlotRegionFrac,
pred: (c: MeanColor) => boolean,
timeoutMs = 8000,
timeoutMs = 15_000,
): Promise<MeanColor> {
const start = Date.now();
for (;;) {
Expand Down
Loading
Loading