Files
2026-07-13 12:25:07 +08:00

765 lines
27 KiB
TypeScript

// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { Page } from "@playwright/test";
import { expect, test } from "@perspective-dev/test";
import type { ViewerConfigUpdate } from "@perspective-dev/viewer";
/**
* Default pixel tolerance for chart screenshots. SwiftShader is
* deterministic on a given machine, but a handful of sub-pixel AA
* decisions still wiggle across Chromium versions.
*/
// @ts-ignore
const DEFAULT_MAX_DIFF_PIXEL_RATIO = process.env.CI ? 0.01 : 0;
const DEFAULT_THRESHOLD = 0;
/**
* Load the shared `basic-test.html` shell and block until the test
* harness signals that perspective is ready. All specs start here.
*/
export async function gotoBasic(page: Page): Promise<void> {
await page.goto("/tools/test/src/html/basic-test.html");
await page.evaluate(async () => {
while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) {
await new Promise((x) => setTimeout(x, 10));
}
});
}
export async function restoreChart(
page: Page,
config: ViewerConfigUpdate,
): Promise<void> {
await page.evaluate(
async (c) => {
const viewer = document.querySelector("perspective-viewer")!;
await viewer.restore(c);
},
config as unknown as Record<string, unknown>,
);
await waitOneFrame(page);
}
/** Await a single RAF in the page context. */
export async function waitOneFrame(page: Page): Promise<void> {
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => resolve()),
),
);
}
/**
* Take a screenshot of the viewer element (not the whole page) and
* compare to `name`'s baseline. Cropping to the viewer excludes page
* scrollbars / viewport chrome that would add pixel noise.
*/
export async function expectViewerScreenshot(
page: Page,
options: { maxDiffPixelRatio?: number } = {},
): Promise<void> {
const viewer = page.locator("perspective-viewer");
const snapshotName =
test
.info()
.titlePath.slice(1)
.map((s) =>
s
.trim()
.replace(/[^a-z0-9]+/gi, "-")
.toLowerCase(),
)
.join("-") + ".png";
await expect(viewer).toHaveScreenshot(snapshotName, {
threshold: DEFAULT_THRESHOLD,
maxDiffPixelRatio:
options.maxDiffPixelRatio ?? DEFAULT_MAX_DIFF_PIXEL_RATIO,
});
}
/**
* Full-flow convenience: go to the test page, restore the chart with
* `config`, wait for the render, and screenshot. The snapshot filename
* is derived from the describe path and test title.
*/
export async function renderAndCapture(
page: Page,
config: ViewerConfigUpdate,
options?: { maxDiffPixelRatio?: number },
): Promise<void> {
await restoreChart(page, config);
await expectViewerScreenshot(page, options);
}
/**
* One per-frame measurement, captured during the action passed to
* `captureFrames`. `plotPixels` counts non-background pixels inside
* `plotRegionFrac` (default = central 80% of the visible canvas).
*/
export interface FrameSample {
timestampMs: number;
plotPixels: number;
canvasWidth: number;
canvasHeight: number;
}
/**
* The sub-region of the visible canvas, expressed as fractions of
* canvas width/height, that the capture loop counts pixels in.
*/
export interface PlotRegionFrac {
x: number;
y: number;
w: number;
h: number;
}
const DEFAULT_PLOT_REGION: PlotRegionFrac = {
x: 0.1,
y: 0.1,
w: 0.8,
h: 0.8,
};
/**
* Pixel-color match threshold for "is this pixel part of the chart
* background?"
*/
const DEFAULT_BG_TOLERANCE = 30;
/**
* Run `action` while a per-frame capture loop reads the visible
* canvas's plot region. Returns one `FrameSample` per browser RAF
* that fired between `start` and `stop`. The capture loop is
* installed and torn down inside this helper — no global state
* leaks between tests.
*/
export async function captureFrames(
page: Page,
action: () => Promise<void>,
options: { plotRegionFrac?: PlotRegionFrac; bgTolerance?: number } = {},
): Promise<FrameSample[]> {
const region = options.plotRegionFrac ?? DEFAULT_PLOT_REGION;
const tolerance = options.bgTolerance ?? DEFAULT_BG_TOLERANCE;
await page.evaluate(
({ region, tolerance }) => {
type Sample = {
timestampMs: number;
plotPixels: number;
canvasWidth: number;
canvasHeight: number;
};
const w = window as unknown as {
__captureFrames?: Sample[];
__captureRunning?: boolean;
__captureRAF?: number;
};
w.__captureFrames = [];
w.__captureRunning = true;
const findCanvas = (): HTMLCanvasElement | null => {
const visit = (
root: Document | ShadowRoot,
): HTMLCanvasElement | null => {
const direct = root.querySelector(
".webgl-canvas",
) as HTMLCanvasElement | null;
if (direct) {
return direct;
}
const all = root.querySelectorAll("*");
for (const el of Array.from(all)) {
const sr = (el as Element & { shadowRoot?: ShadowRoot })
.shadowRoot;
if (sr) {
const found = visit(sr);
if (found) {
return found;
}
}
}
return null;
};
return visit(document);
};
let cachedCanvas: HTMLCanvasElement | null = null;
// Sampler canvas: the visible `.webgl-canvas` may have
// any of three context modes:
// - blit mode: 2D context (host blits worker bitmaps
// onto it). `getImageData` works directly.
// - direct mode: `transferControlToOffscreen` —
// placeholder for the worker's WebGL OffscreenCanvas.
// Host has *no* context on this canvas;
// `getImageData` impossible.
// - in-process mode: WebGL context owned by main
// thread. `getContext("2d")` returns null.
const sampler = document.createElement("canvas");
const samplerCtx = sampler.getContext("2d", {
willReadFrequently: true,
});
if (!samplerCtx) {
throw new Error(
"captureFrames: sampler canvas 2D context unavailable",
);
}
const tick = () => {
if (!w.__captureRunning) {
return;
}
if (!cachedCanvas || !cachedCanvas.isConnected) {
cachedCanvas = findCanvas();
}
const canvas = cachedCanvas;
if (canvas && canvas.width > 0 && canvas.height > 0) {
const x0 = Math.max(0, Math.round(region.x * canvas.width));
const y0 = Math.max(
0,
Math.round(region.y * canvas.height),
);
const rw = Math.max(
1,
Math.min(
canvas.width - x0,
Math.round(region.w * canvas.width),
),
);
const rh = Math.max(
1,
Math.min(
canvas.height - y0,
Math.round(region.h * canvas.height),
),
);
if (sampler.width !== rw) {
sampler.width = rw;
}
if (sampler.height !== rh) {
sampler.height = rh;
}
samplerCtx.clearRect(0, 0, rw, rh);
try {
samplerCtx.drawImage(
canvas,
x0,
y0,
rw,
rh,
0,
0,
rw,
rh,
);
} catch {
// `drawImage` can throw on transient zero-
// size sources during plugin reconnect;
// skip this frame and try again next RAF.
w.__captureRAF = requestAnimationFrame(tick);
return;
}
const data = samplerCtx.getImageData(0, 0, rw, rh).data;
let nonBg = 0;
// The GL canvas is cleared with
// `clearColor(0,0,0,0)` and glyphs paint with
// `a > 0`; in blit mode the host blits with
// `globalCompositeOperation = "copy"`, so post-
// blit pixels are either glyph (`a > 0`) or
// fully transparent (`a == 0`). In direct/in-
// process modes the WebGL canvas itself follows
// the same alpha convention. So a simple alpha
// test is the correct invariant for "glyph
// fragment landed here" in every mode.
void tolerance;
for (let i = 3; i < data.length; i += 4) {
if (data[i] > 0) {
nonBg++;
}
}
w.__captureFrames!.push({
timestampMs: performance.now(),
plotPixels: nonBg,
canvasWidth: canvas.width,
canvasHeight: canvas.height,
});
}
w.__captureRAF = requestAnimationFrame(tick);
};
w.__captureRAF = requestAnimationFrame(tick);
},
{ region, tolerance },
);
try {
await action();
// One trailing RAF so the final post-action paint is captured.
await waitOneFrame(page);
} finally {
await page.evaluate(() => {
const w = window as unknown as {
__captureRunning?: boolean;
__captureRAF?: number;
};
w.__captureRunning = false;
if (w.__captureRAF !== undefined) {
cancelAnimationFrame(w.__captureRAF);
w.__captureRAF = undefined;
}
});
}
const frames = await page.evaluate(() => {
const w = window as unknown as { __captureFrames?: FrameSample[] };
const out = w.__captureFrames ?? [];
w.__captureFrames = undefined;
return out;
});
return frames;
}
/**
* Take one quiescent-state pixel sample of the chart's plot region
* to use as the calibration baseline for `assertPlotNeverBlank`.
*/
export async function calibratePlotBaseline(
page: Page,
options: { plotRegionFrac?: PlotRegionFrac; bgTolerance?: number } = {},
): Promise<number> {
const samples = await captureFrames(
page,
async () => {
await waitOneFrame(page);
await waitOneFrame(page);
},
options,
);
// The first frame can lag the prior render; pick the median of
// the captured frames as a stable baseline.
if (samples.length === 0) {
throw new Error("calibratePlotBaseline: no frames captured");
}
const sorted = samples.map((s) => s.plotPixels).sort((a, b) => a - b);
return sorted[Math.floor(sorted.length / 2)];
}
/**
* Primary invariant assertion. For every frame in `frames`,
* `plotPixels` must be ≥ `minPixels`.
*/
export function assertPlotNeverBlank(
frames: FrameSample[],
minPixels: number,
): void {
if (frames.length === 0) {
throw new Error("assertPlotNeverBlank: no frames captured");
}
for (let i = 0; i < frames.length; i++) {
if (frames[i].plotPixels < minPixels) {
const ctxStart = Math.max(0, i - 3);
const ctxEnd = Math.min(frames.length, i + 4);
const ctxLines = frames
.slice(ctxStart, ctxEnd)
.map((f, j) => {
const idx = ctxStart + j;
const marker = idx === i ? " ← BLANK" : "";
return ` [${idx}] plotPixels=${f.plotPixels}${marker}`;
})
.join("\n");
throw new Error(
`assertPlotNeverBlank: frame ${i} of ${frames.length} ` +
`had ${frames[i].plotPixels} non-background pixels ` +
`(threshold ${minPixels}). Surrounding frames:\n${ctxLines}`,
);
}
}
}
// ╭──────────────╮
// │ │
// Multi-viewer helpers │ │
// ╰──────────────╯
//
// Variants of the single-viewer helpers above scoped to a specific
// `<perspective-viewer>` index, plus a parallel-capture entry point
// that returns one `FrameSample[]` per viewer in DOM order. Used by
// `multi-chart.spec.ts` to verify cross-chart isolation: pan on
// viewer A must not blank viewer B; both panning concurrently must
// leave each chart's invariant intact; streaming-update on the
// shared table must surface as live data on both without blanking.
//
// All multi-viewer helpers walk the document for *all*
// `<perspective-viewer>` elements (not the single one
// `gotoBasic` assumes). The order is `document.querySelectorAll`
// tree order, which matches the order viewers are placed in
// `two-chart-test.html`.
/**
* Two-viewer page entry. Same shape as `gotoBasic` but loads the
* two-chart fixture and waits for both viewers to be bound to the
* shared table.
*/
export async function gotoTwoChart(page: Page): Promise<void> {
await page.goto("/tools/test/src/html/two-chart-test.html");
await page.evaluate(async () => {
while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) {
await new Promise((x) => setTimeout(x, 10));
}
});
}
/**
* Restore a specific viewer (by 0-based DOM-order index). Mirrors
* `restoreChart` for the multi-viewer fixture. Awaits one RAF so
* the chart's first paint has fired by the time this returns.
*/
export async function restoreChartAt(
page: Page,
viewerIndex: number,
config: ViewerConfigUpdate,
): Promise<void> {
await page.evaluate(
async ({ viewerIndex, c }) => {
const viewers = document.querySelectorAll("perspective-viewer");
const viewer = viewers[viewerIndex];
if (!viewer) {
throw new Error(
`restoreChartAt: no viewer at index ${viewerIndex} (` +
`found ${viewers.length})`,
);
}
await (viewer as any).restore(c);
},
{ viewerIndex, c: config as unknown as Record<string, unknown> },
);
await waitOneFrame(page);
}
/**
* Run `action` while sampling every viewer's plot region every RAF.
* Returns one `FrameSample[]` per viewer in DOM order.
*
* Implementation parallels `captureFrames` but walks every shadow
* root for *all* `.webgl-canvas` elements, samples each one's plot
* region per tick, and tracks per-canvas logs in arrays indexed by
* the canvas's discovery order. Cached canvas list is invalidated
* if any cached canvas becomes detached (plugin reconnect during
* capture); the rebuild is O(n) over reachable elements but only
* runs when the cache is stale.
*/
export async function captureFramesAllViewers(
page: Page,
action: () => Promise<void>,
options: { plotRegionFrac?: PlotRegionFrac } = {},
): Promise<FrameSample[][]> {
const region = options.plotRegionFrac ?? DEFAULT_PLOT_REGION;
await page.evaluate(
({ region }) => {
type Sample = {
timestampMs: number;
plotPixels: number;
canvasWidth: number;
canvasHeight: number;
};
const w = window as unknown as {
__captureMultiFrames?: Sample[][];
__captureRunning?: boolean;
__captureRAF?: number;
};
w.__captureMultiFrames = [];
w.__captureRunning = true;
const findAllCanvases = (): HTMLCanvasElement[] => {
const out: HTMLCanvasElement[] = [];
const visit = (root: Document | ShadowRoot): void => {
const direct = root.querySelectorAll(".webgl-canvas");
for (const c of Array.from(direct)) {
out.push(c as HTMLCanvasElement);
}
const all = root.querySelectorAll("*");
for (const el of Array.from(all)) {
const sr = (el as Element & { shadowRoot?: ShadowRoot })
.shadowRoot;
if (sr) {
visit(sr);
}
}
};
visit(document);
return out;
};
let cachedCanvases: HTMLCanvasElement[] = [];
const cacheStale = (): boolean => {
if (cachedCanvases.length === 0) {
return true;
}
for (const c of cachedCanvases) {
if (!c.isConnected) {
return true;
}
}
return false;
};
// Single shared sampler canvas — resized per-viewer
// inside the loop. Same rationale as in `captureFrames`:
// routing the read through a 2D sampler via `drawImage`
// works whether the source canvas is in blit mode (2D
// context), direct mode (`transferControlToOffscreen`
// placeholder), or in-process mode (WebGL on main
// thread). `getImageData` directly on the source canvas
// would fail in two of those three modes.
const sampler = document.createElement("canvas");
const samplerCtx = sampler.getContext("2d");
if (!samplerCtx) {
throw new Error(
"captureFramesAllViewers: sampler canvas 2D context unavailable",
);
}
const tick = () => {
if (!w.__captureRunning) {
return;
}
if (cacheStale()) {
cachedCanvases = findAllCanvases();
// Make sure the per-viewer logs array has slots
// for every discovered canvas; preserve any
// existing prefix for viewers that survived the
// rebuild.
while (
w.__captureMultiFrames!.length < cachedCanvases.length
) {
w.__captureMultiFrames!.push([]);
}
}
const tMs = performance.now();
for (let vi = 0; vi < cachedCanvases.length; vi++) {
const canvas = cachedCanvases[vi];
if (!canvas || canvas.width === 0 || canvas.height === 0) {
continue;
}
const x0 = Math.max(0, Math.round(region.x * canvas.width));
const y0 = Math.max(
0,
Math.round(region.y * canvas.height),
);
const rw = Math.max(
1,
Math.min(
canvas.width - x0,
Math.round(region.w * canvas.width),
),
);
const rh = Math.max(
1,
Math.min(
canvas.height - y0,
Math.round(region.h * canvas.height),
),
);
if (sampler.width !== rw) {
sampler.width = rw;
}
if (sampler.height !== rh) {
sampler.height = rh;
}
samplerCtx.clearRect(0, 0, rw, rh);
try {
samplerCtx.drawImage(
canvas,
x0,
y0,
rw,
rh,
0,
0,
rw,
rh,
);
} catch {
continue;
}
const data = samplerCtx.getImageData(0, 0, rw, rh).data;
let nonBg = 0;
for (let i = 3; i < data.length; i += 4) {
if (data[i] > 0) {
nonBg++;
}
}
w.__captureMultiFrames![vi].push({
timestampMs: tMs,
plotPixels: nonBg,
canvasWidth: canvas.width,
canvasHeight: canvas.height,
});
}
w.__captureRAF = requestAnimationFrame(tick);
};
w.__captureRAF = requestAnimationFrame(tick);
},
{ region },
);
try {
await action();
await waitOneFrame(page);
} finally {
await page.evaluate(() => {
const w = window as unknown as {
__captureRunning?: boolean;
__captureRAF?: number;
};
w.__captureRunning = false;
if (w.__captureRAF !== undefined) {
cancelAnimationFrame(w.__captureRAF);
w.__captureRAF = undefined;
}
});
}
const frames = await page.evaluate(() => {
const w = window as unknown as {
__captureMultiFrames?: FrameSample[][];
};
const out = w.__captureMultiFrames ?? [];
w.__captureMultiFrames = undefined;
return out;
});
return frames;
}
/**
* Quiescent baselines for every viewer. One median value per
* viewer, in DOM order. Used by `multi-chart.spec.ts` to set
* per-viewer blank thresholds before driving the action.
*/
export async function calibrateAllBaselines(
page: Page,
options: { plotRegionFrac?: PlotRegionFrac } = {},
): Promise<number[]> {
const samples = await captureFramesAllViewers(
page,
async () => {
await waitOneFrame(page);
await waitOneFrame(page);
},
options,
);
if (samples.length === 0) {
throw new Error("calibrateAllBaselines: no viewers found");
}
return samples.map((perViewer, idx) => {
if (perViewer.length === 0) {
throw new Error(
`calibrateAllBaselines: viewer ${idx} captured no frames`,
);
}
const sorted = perViewer.map((s) => s.plotPixels).sort((a, b) => a - b);
return sorted[Math.floor(sorted.length / 2)];
});
}
/**
* Cross-chart isolation invariant: viewer at `viewerIndex` should
* have stayed quiescent across `frames` (no rendered changes from
* actions targeting other viewers). Allows ±`tolerance` pixels
* around the median to absorb anti-aliasing noise from the host's
* compositor; rejects on a single frame whose `plotPixels` deviates
* by more than that.
*
* Use when the test drives an interaction on a different viewer and
* needs to verify the un-interacted viewer was not collateral
* damage.
*/
export function assertViewerQuiescent(
frames: FrameSample[],
tolerance: number,
): void {
if (frames.length === 0) {
throw new Error("assertViewerQuiescent: no frames captured");
}
const counts = frames.map((f) => f.plotPixels);
const sorted = [...counts].sort((a, b) => a - b);
const median = sorted[Math.floor(sorted.length / 2)];
for (let i = 0; i < counts.length; i++) {
if (Math.abs(counts[i] - median) > tolerance) {
const ctxStart = Math.max(0, i - 3);
const ctxEnd = Math.min(counts.length, i + 4);
const ctxLines = counts
.slice(ctxStart, ctxEnd)
.map((c, j) => {
const idx = ctxStart + j;
const marker = idx === i ? " ← DEVIANT" : "";
return ` [${idx}] plotPixels=${c}${marker}`;
})
.join("\n");
throw new Error(
`assertViewerQuiescent: frame ${i} of ${counts.length} ` +
`had ${counts[i]} non-background pixels, median=${median}, ` +
`tolerance=±${tolerance}. Surrounding frames:\n${ctxLines}`,
);
}
}
}