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

318 lines
12 KiB
JavaScript

// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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, expect } from "@perspective-dev/test";
import perspective from "./perspective_client";
import * as fs from "node:fs";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const arr = fs.readFileSync(
require.resolve("superstore-arrow/superstore.lz4.arrow"),
).buffer;
/**
* Run a function in a loop, comparing before-and-after wasm heap for leaks.
* Emscripten allocates in pages, and Perspective is hard coded to start at
* 16777216b, so rather than check `end - start`, we just test that any
* allocation ever occurs.
* @param {*} test
* @param {*} num_iterations
*/
async function leak_test(test, num_iterations = 10_000) {
// warmup
await test();
await test();
// TODO Playwright uses the same host instance so this may have grown by
// the time the suite runs. Could fix with a nod eagent (and test in other
// browsers).
const start = (await perspective.system_info()).heap_size;
const start_used = (await perspective.system_info()).used_size;
for (var i = 0; i < num_iterations; i++) {
await test();
}
const final_used = (await perspective.system_info()).used_size;
expect((await perspective.system_info()).heap_size).toEqual(start);
expect(Number(final_used) / Number(start_used)).toBeGreaterThanOrEqual(0.8);
expect(Number(final_used) / Number(start_used)).toBeLessThanOrEqual(1.5);
}
/**
* Given columns a (int), b (float), c (string) and d (datetime),
* generate expressions that use all columns and scalar values.
*/
function generate_expressions() {
const expressions = {
"concat('abcd', \"c\", 'efg')": "concat('abcd', \"c\", 'efg')",
};
for (const op of ["+", "-", "*", "/", "^", "%"]) {
expressions[`("a" ${op} "b") + ${Math.floor(Math.random() * 100)}`] =
`("a" ${op} "b") + ${Math.floor(Math.random() * 100)}`;
}
for (const fn of ["sqrt", "log10", "deg2rad"]) {
expressions[`${fn}("b")`] = `${fn}("b")`;
}
for (const fn of ["upper", "lower", "length"]) {
expressions[`${fn}("c")`] = `${fn}("c")`;
}
for (const unit of ["m", "D"]) {
expressions[`bucket("d", '${unit}')`] = `bucket("d", '${unit}')`;
}
const rand =
Object.keys(expressions)[
Math.floor(Math.random() * Object.keys(expressions).length)
];
return { [rand]: expressions[rand] };
}
test.describe("leaks", function () {
test.describe("view", function () {
test.describe("1-sided", function () {
test("to_json does not leak", async () => {
test.setTimeout(60000);
const table = await perspective.table(arr.slice());
const view = await table.view({ group_by: ["State"] });
await leak_test(async function () {
let json = await view.to_json();
expect(json.length).toEqual(50);
});
await view.delete();
await table.delete();
});
test.skip("OG - to_columns_string does not leak", async () => {
const table = await perspective.table(arr.slice());
const view = await table.view({ group_by: ["State"] });
await leak_test(async function () {
let json = await view.to_columns_string();
expect(json.length).toEqual(6722);
});
await view.delete();
await table.delete();
});
// The length of this string changed due to
// some of the trailing fractional digits in the floats.
test("to_columns_string does not leak", async () => {
const table = await perspective.table(arr.slice());
const view = await table.view({ group_by: ["State"] });
await leak_test(async function () {
let json = await view.to_columns_string();
expect(json.length).toEqual(6669);
});
await view.delete();
await table.delete();
});
});
});
test.describe("table", function () {
test("update does not leak", async () => {
const table = await perspective.table(
{ x: "integer", y: "string" },
{ index: "x" },
);
let count = 0;
const view = await table.view();
view.on_update(function () {
count += 1;
});
await table.update([{ x: 1, y: "TestTestTest" }]);
await leak_test(async function () {
table.update([{ x: 1, y: "TestTestTest" }]);
expect(await table.size()).toEqual(1);
});
// await table.update([{ x: 1, y: "TestTestTest" }]);
expect(count).toBeGreaterThan(0);
await view.delete();
await table.delete();
});
test.skip("csv loading does not leak", async () => {
const table = await perspective.table(arr.slice());
const view = await table.view();
const csv = await view.to_csv({ end_row: 10 });
await view.delete();
await table.delete();
await leak_test(async function () {
const table = await perspective.table(csv);
expect(await table.size()).toEqual(10);
await table.delete();
});
});
});
test.describe("expression columns", function () {
test("0 sided does not leak", async () => {
test.setTimeout(60000);
const table = await perspective.table({
a: [1, 2, 3, 4],
b: [1.5, 2.5, 3.5, 4.5],
c: ["a", "b", "c", "d"],
d: [new Date(), new Date(), new Date(), new Date()],
});
const expressions = generate_expressions();
await leak_test(async () => {
const view = await table.view({
expressions,
});
const expression_schema = await view.expression_schema();
expect(Object.keys(expression_schema).length).toEqual(
Object.keys(expressions).length,
);
await view.delete();
});
await table.delete();
});
/**
* Because the expression vocab and the regex cache is per-table and
* not per-view, we should be able to leak test the table creation
* and view creation.
*/
test.skip("0 sided regex does not leak", async () => {
const expressions = {
"match(\"a\", '.{1}')": "match(\"a\", '.{1}')",
"match_all(\"a\", '[a-z]{1}')": "match_all(\"a\", '[a-z]{1}')",
"search(\"a\", '.')": "search(\"a\", '.')",
};
await leak_test(async () => {
const table = await perspective.table({
a: "abcdefghijklmnopqrstuvwxyz".split(""),
});
const view = await table.view({
expressions,
});
const expression_schema = await view.expression_schema();
expect(Object.keys(expression_schema).length).toEqual(
Object.keys(expressions).length,
);
await view.delete();
await table.delete();
});
});
test.skip("0 sided string does not leak", async () => {
const table = await perspective.table({
a: "abcdefghijklmnopqrstuvwxyz".split(""),
});
const expressions = {
"var x := 'abcdefghijklmnopqrstuvwxyz'; concat(\"a\", x, 'abc')":
"var x := 'abcdefghijklmnopqrstuvwxyz'; concat(\"a\", x, 'abc')",
"var x := 'abcdefghijklmnopqrstuvwxyz'; var y := 'defhijklmnopqrst'; concat(\"a\", x, 'abc', y)":
"var x := 'abcdefghijklmnopqrstuvwxyz'; var y := 'defhijklmnopqrst'; concat(\"a\", x, 'abc', y)",
};
await leak_test(async () => {
const view = await table.view({
expressions,
});
const expression_schema = await view.expression_schema();
expect(Object.keys(expression_schema).length).toEqual(
Object.keys(expressions).length,
);
await view.delete();
});
await table.delete();
});
test("1 sided does not leak", async () => {
const table = await perspective.table({
a: [1, 2, 3, 4],
b: [1.5, 2.5, 3.5, 4.5],
c: ["a", "b", "c", "d"],
d: [new Date(), new Date(), new Date(), new Date()],
});
const columns = ["a", "b", "c", "d"];
const expressions = generate_expressions();
await leak_test(async () => {
const view = await table.view({
group_by: [
columns[Math.floor(Math.random() * columns.length)],
],
expressions,
});
const expression_schema = await view.expression_schema();
expect(Object.keys(expression_schema).length).toEqual(
Object.keys(expressions).length,
);
await view.delete();
}, 3000);
await table.delete();
});
test("2 sided does not leak", async () => {
const table = await perspective.table({
a: [1, 2, 3, 4],
b: [1.5, 2.5, 3.5, 4.5],
c: ["a", "b", "c", "d"],
d: [new Date(), new Date(), new Date(), new Date()],
});
const columns = ["a", "b", "c", "d"];
const expressions = generate_expressions();
await leak_test(async () => {
const view = await table.view({
group_by: [
columns[Math.floor(Math.random() * columns.length)],
],
split_by: [
columns[Math.floor(Math.random() * columns.length)],
],
expressions,
});
const expression_schema = await view.expression_schema();
expect(Object.keys(expression_schema).length).toEqual(
Object.keys(expressions).length,
);
await view.delete();
}, 3000);
await table.delete();
});
});
});