diff --git a/CHANGELOG.md b/CHANGELOG.md index 2584a569..97f3b443 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - `sqlpage.send_mail` now supports rich email bodies. Use `body_html` for a caller-provided HTML alternative, or `body_md` to render Markdown as HTML. Messages retain a plain-text alternative; `body` may be omitted when `body_md` is used, and `body_md` and `body_html` cannot be combined. - Form `options_source` URLs now preserve existing query parameters when adding the dynamic `search` parameter. - Map coordinates that are not a pair of numbers, like a latitude with no longitude, are now reported in the browser console and skipped, instead of breaking the whole map. + - Stacked charts now stack their series by `x` value instead of by point order, which used to give wrong totals when a series was missing a point. - `column` charts now display vertical bars instead of nothing at all. - `stacked` is now ignored on chart types that cannot stack, instead of displaying an empty chart. - Screen readers now announce the title of the modal component instead of an unnamed dialog. diff --git a/examples/official-site/sqlpage/migrations/01_documentation.sql b/examples/official-site/sqlpage/migrations/01_documentation.sql index 93f03219..9e4ce254 100644 --- a/examples/official-site/sqlpage/migrations/01_documentation.sql +++ b/examples/official-site/sqlpage/migrations/01_documentation.sql @@ -664,7 +664,7 @@ INSERT INTO parameter(component, name, description, type, top_level, optional) S ('marker', 'Marker size', 'REAL', TRUE, TRUE), ('labels', 'Whether to show the data labels on the chart or not.', 'BOOLEAN', TRUE, TRUE), ('color', 'The name of a color in which to display the chart. If there are multiple series in the chart, this parameter can be repeated multiple times.', 'COLOR', TRUE, TRUE), - ('stacked', 'Whether to cumulate values from different series. Supported by the "line", "area" and "bar" chart types, and ignored by the others.', 'BOOLEAN', TRUE, TRUE), + ('stacked', 'Whether to cumulate values from different series. Supported by the "line", "area" and "bar" chart types, and ignored by the others. Series are aligned on their x values, and a series that has no value for a given x counts as zero there.', 'BOOLEAN', TRUE, TRUE), ('toolbar', 'Whether to display a toolbar at the top right of the chart, that offers downloading the data as CSV.', 'BOOLEAN', TRUE, TRUE), ('show_legend', 'Whether to display the legend listing all chart series. Defaults to true.', 'BOOLEAN', TRUE, TRUE), ('logarithmic', 'Display the y-axis in logarithmic scale.', 'BOOLEAN', TRUE, TRUE), @@ -717,6 +717,20 @@ INSERT INTO example(component, description, properties) VALUES '{"series": "Marketing", "x": 2022, "value": 15}, '|| '{"series": "Human resources", "x": 2021, "value": 30}, '|| '{"series": "Human resources", "x": 2022, "value": 55}]')), + ('chart', 'A stacked area chart, showing how each series contributes to a total. +The `stacked` property also works with the `line` and `bar` chart types. + +Series are aligned on their `x` values, and a series that has no value for a given `x` counts as zero there: +below, the graphics card draws no power outside of the render. +If a missing value does not mean zero in your data, make all the series share the same `x` values, +for instance by rounding timestamps to a common interval.', + json('[{"component":"chart", "title": "Power draw", "type": "area", "stacked": true, "time": true, "ytitle": "watts", "color": ["blue", "teal"], "marker": 4}, '|| + '{"series": "CPU", "x": "2024-03-01T10:00:00Z", "value": 45}, '|| + '{"series": "CPU", "x": "2024-03-01T10:15:00Z", "value": 52}, '|| + '{"series": "CPU", "x": "2024-03-01T10:30:00Z", "value": 48}, '|| + '{"series": "CPU", "x": "2024-03-01T10:45:00Z", "value": 44}, '|| + '{"series": "GPU", "x": "2024-03-01T10:15:00Z", "value": 120}, '|| + '{"series": "GPU", "x": "2024-03-01T10:30:00Z", "value": 140}]')), ('chart', 'A line chart with multiple series. One of the most common types of charts, often used to show trends over time. Also demonstrates the use of the `toolbar` attribute to allow the user to download the graph as an image or the data as a CSV file.', json('[{"component":"chart", "title": "Revenue", "ymin": 0, "toolbar": true}, diff --git a/package.json b/package.json index 924c4e4d..b69851a1 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "sqlpage", "version": "1.0.0", "scripts": { - "test": "biome check .", + "test": "biome check . && node --test \"tests/js/**/*.spec.ts\"", "format": "biome format --write .", "fix": "biome check --fix --unsafe ." }, diff --git a/sqlpage/apexcharts.js b/sqlpage/apexcharts.js index e5ee4efe..f4f0cc61 100644 --- a/sqlpage/apexcharts.js +++ b/sqlpage/apexcharts.js @@ -39,61 +39,57 @@ sqlpage_chart = (() => { const STACKABLE_CHART_TYPES = ["line", "area", "bar"]; const APEXCHARTS_TYPE_ALIASES = { column: "bar" }; - /** @typedef { { [name:string]: {data:{x:number|string|Date,y:number}[], name:string} } } Series */ + /** @typedef {number|string|Date} XValue */ + /** @typedef { {name:string, data:{x:XValue,y:number|null,z?:number}[]} } ChartSeries */ + /** @typedef { { [name:string]: ChartSeries } } Series */ + + /** @param {XValue} x @returns {number|string} equal x values share a key */ + const x_key = (x) => (x instanceof Date ? x.getTime() : x); /** - * Aligns series data points by their x-axis categories, ensuring all series have data points - * for each unique category. Missing values are filled with zeros. - * Categories are ordered by their name. - * - * @example - * // Input series: - * const series = [ - * { name: "A", data: [{x: "X2", y: 10}, {x: "X3", y: 30}] }, - * { name: "B", data: [{x: "X1", y: 25}, {x: "X2", y: 20}] } - * ]; - * - * // Output after align_categories (orderedCategories will be ["X1","X2", "X3"]): - * // [ - * // { name: "A", data: [{x: "X1", y: 0}, {x: "X2", y: 10}, {x: "X3", y: 30}] }, - * // { name: "B", data: [{x: "X1", y: 25}, {x: "X2", y: 20}, {x: "X3", y: 0}] } - * // ] - * - * @param {(Series[string])[]} series - Array of series objects, each containing name and data points - * @returns {Series[string][]} Aligned series with consistent categories across all series + * @param {ChartSeries[]} series + * @returns {XValue[]} every x the series hold, in their own order where they + * agree and in ascending order where they diverge */ - function align_categories(series) { - const categoriesSet = new Set(); - const pointers = series.map((_) => 0); // Index of current data point in each series - const x_at = (series_idx) => - series[series_idx].data[pointers[series_idx]].x; - const series_idxs = series.flatMap((s, i) => (s.data.length ? i : [])); - while (series_idxs.length > 0) { - let idx_of_xmin = series_idxs[0]; - for (const series_idx of series_idxs) { - if (x_at(series_idx) < x_at(idx_of_xmin)) idx_of_xmin = series_idx; - } - - const new_category = x_at(idx_of_xmin); - if (!categoriesSet.has(new_category)) categoriesSet.add(new_category); - pointers[idx_of_xmin]++; - if (pointers[idx_of_xmin] >= series[idx_of_xmin].data.length) { - series_idxs.splice(series_idxs.indexOf(idx_of_xmin), 1); - } + function merged_x_values(series) { + const unread = series.map(({ data }) => data.map(({ x }) => x)); + const merged = new Map(); + while (unread.some((xs) => xs.length > 0)) { + const with_lowest_x = unread + .filter((xs) => xs.length > 0) + .reduce((a, b) => (b[0] < a[0] ? b : a)); + const x = with_lowest_x.shift(); + merged.set(x_key(x), x); } - // Create a map of category -> value for each series and rebuild - return series.map((s) => { - const valueMap = new Map(s.data.map((point) => [point.x, point.y])); + return [...merged.values()]; + } + + /** + * ApexCharts pairs points across series by index rather than by x, so a + * series that skips an x stacks onto the wrong one. Give every series the + * same x values, counting an x it never measured as zero. + * + * @param {ChartSeries[]} series + * @returns {ChartSeries[]} + */ + function align_series(series) { + const all_x = merged_x_values(series); + return series.map(({ name, data }) => { + const by_x = new Map(data.map((point) => [x_key(point.x), point])); return { - name: s.name, - data: Array.from(categoriesSet, (category) => ({ - x: category, - y: valueMap.get(category) || 0, - })), + name, + data: all_x.map((x) => { + const point = by_x.get(x_key(x)); + return { ...point, x, y: point?.y || 0 }; + }), }; }); } + // The unit tests load this file as a CommonJS module; browsers have no `module`. + if (typeof module !== "undefined") + module.exports = { align_series, merged_x_values }; + /** @param {HTMLElement} c */ function build_sqlpage_chart(c) { const [data_element] = c.getElementsByTagName("data"); @@ -138,8 +134,11 @@ sqlpage_chart = (() => { if (chart_type === "pie") { labels = data.points.map(([name, x, _y]) => x || name); series = data.points.map(([_name, _x, y]) => Number.parseFloat(y)); - } else if (categories && chart_type === "bar" && series.length > 1) - series = align_categories(series); + } else if ( + series.length > 1 && + (is_stacked || (categories && chart_type === "bar")) + ) + series = align_series(series); const options = { chart: { diff --git a/tests/end-to-end/chart-component.spec.ts b/tests/end-to-end/chart-component.spec.ts index 55724888..8792a230 100644 --- a/tests/end-to-end/chart-component.spec.ts +++ b/tests/end-to-end/chart-component.spec.ts @@ -2,10 +2,17 @@ import { expect, type Page, test } from "@playwright/test"; const BASE = process.env.SQLPAGE_TEST_BASE ?? "http://localhost:8080/"; +type ChartPoint = { x: string | number | Date; y: number | null }; + declare global { interface Window { charts?: { - w: { config: { chart: { type: string; stacked: boolean } } }; + w: { + config: { + chart: { type: string; stacked: boolean }; + series: { name: string; data: ChartPoint[] }[]; + }; + }; }[]; } function sqlpage_chart(): void; @@ -24,6 +31,46 @@ const TASKS_OVER_TIME: Row[] = [ ["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 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, @@ -52,6 +99,21 @@ async function renderChart( console.error = reportError; const rendered = window.charts?.[before]; + const series = (rendered?.w.config.series ?? []).map((s) => ({ + name: s.name, + points: s.data.map((p) => [ + p.x instanceof Date ? p.x.toISOString() : p.x, + p.y, + ]), + })); + const drawnPerSeries = series.map(({ name }) => ({ + name, + heights: [ + ...container.querySelectorAll( + `.apexcharts-series[seriesName='${name}'] .apexcharts-marker`, + ), + ].map((m) => Math.round(m.getBBox().y)), + })); const shapes = [ ...container.querySelectorAll( ".apexcharts-bar-area, .apexcharts-rangebar-area", @@ -65,6 +127,8 @@ async function renderChart( failures, type: rendered?.w.config.chart.type ?? null, stacked: rendered?.w.config.chart.stacked ?? null, + series, + drawnPerSeries, shapes, }; }, @@ -88,6 +152,106 @@ test("draws a column chart as a vertical bar chart", async ({ page }) => { expect(new Set(chart.shapes.map((s) => s.height)).size).toBe(3); }); +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], + ); + + expect(chart.failures).toEqual([]); + expect(chart.series.map((s) => s.name)).toEqual(["CPU", "GPU"]); + expect(chart.series[1].points).toEqual([ + ["2024-01-01T00:00:00.000Z", 0], + ["2024-01-01T00:01:00.000Z", 50], + ["2024-01-01T00:02:00.000Z", 50], + ["2024-01-01T00:03:00.000Z", 50], + ]); +}); + +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 [cpu, gpu] = chart.drawnPerSeries; + + expect(gpu.heights).toHaveLength(4); + expect(gpu.heights[0]).toBe(cpu.heights[0]); + expect(gpu.heights[1]).toBeLessThan(cpu.heights[1]); +}); + +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, + ); + + expect(chart.failures).toEqual([]); + expect(chart.series[0].points).toEqual([ + ["Q3", 3], + ["Q1", 1], + ["Q2", 2], + ]); +}); + +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, + ]); + + expect(chart.failures).toEqual([]); + expect(chart.series[0].points).toEqual([ + ["X1", 0], + ["X2", 10], + ["X3", 30], + ]); + expect(chart.series[1].points).toEqual([ + ["X1", 25], + ["X2", 20], + ["X3", 0], + ]); +}); + +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, + ]); + + expect(chart.failures).toEqual([]); + expect(chart.series[1].points).toEqual([ + ["2024-01-01T00:01:00.000Z", 50], + ["2024-01-01T00:02:00.000Z", 50], + ["2024-01-01T00:03:00.000Z", 50], + ]); +}); + +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, + ]); + + expect(chart.failures).toEqual([]); + expect(chart.series[1].points).toEqual([ + ["Q1", 0], + ["Q2", 20], + ["Q3", 30], + ]); +}); + test("draws a rangeBar chart that asks to be stacked", async ({ page }) => { const chart = await renderChart( page, diff --git a/tests/end-to-end/official-site.spec.ts b/tests/end-to-end/official-site.spec.ts index d3a14de3..c81117c3 100644 --- a/tests/end-to-end/official-site.spec.ts +++ b/tests/end-to-end/official-site.spec.ts @@ -1,4 +1,4 @@ -import { expect, type Page, test } from "@playwright/test"; +import { expect, type Locator, type Page, test } from "@playwright/test"; const BASE = process.env.SQLPAGE_TEST_BASE ?? "http://localhost:8080/"; @@ -35,6 +35,49 @@ test("chart supports hiding legend", async ({ page }) => { await expect(expensesChart.locator(".apexcharts-legend")).toBeHidden(); }); +const DATA_POINT_MARKERS = ".apexcharts-series-markers > .apexcharts-marker"; + +const chartCard = (page: Page, title: string) => + page.locator(".card", { has: page.getByRole("heading", { name: title }) }); + +const drawnPoints = (card: Locator, series: string) => + card + .locator(`.apexcharts-series[seriesName='${series}'] ${DATA_POINT_MARKERS}`) + .evaluateAll((markers) => + markers.map((m) => ({ + x: m.getAttribute("cx"), + y: m.getAttribute("cy"), + })), + ); + +test("stacked chart draws every series at every x of the chart", async ({ + page, +}) => { + await page.goto(`${BASE}/documentation.sql?component=chart#component`); + const powerChart = chartCard(page, "Power draw"); + await expect(powerChart.locator(".apexcharts-canvas")).toBeVisible(); + + const cpu = await drawnPoints(powerChart, "CPU"); + const gpu = await drawnPoints(powerChart, "GPU"); + + expect(cpu).toHaveLength(4); + expect(gpu.map((p) => p.x)).toEqual(cpu.map((p) => p.x)); +}); + +test("stacked chart raises a series only where it has a value", async ({ + page, +}) => { + await page.goto(`${BASE}/documentation.sql?component=chart#component`); + const powerChart = chartCard(page, "Power draw"); + await expect(powerChart.locator(".apexcharts-canvas")).toBeVisible(); + + const cpu = await drawnPoints(powerChart, "CPU"); + const gpu = await drawnPoints(powerChart, "GPU"); + + expect([gpu[0], gpu[3]]).toEqual([cpu[0], cpu[3]]); + expect(Number(gpu[1].y)).toBeLessThan(Number(cpu[1].y)); +}); + test("map", async ({ page }) => { await page.goto(`${BASE}/documentation.sql?component=map#component`); await expect(page.getByText("Loading...")).not.toBeVisible(); diff --git a/tests/js/chart_series.spec.ts b/tests/js/chart_series.spec.ts new file mode 100644 index 00000000..9f91d34a --- /dev/null +++ b/tests/js/chart_series.spec.ts @@ -0,0 +1,180 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import test from "node:test"; + +const browser_globals_apexcharts_reads_when_it_loads = { + document: { body: null }, + add_init_fn: () => {}, +}; +Object.assign(globalThis, browser_globals_apexcharts_reads_when_it_loads); + +const require = createRequire(import.meta.url); +const { + align_series, + merged_x_values, +} = require("../../sqlpage/apexcharts.js"); + +type XValue = number | string | Date; +type Point = { x: XValue; y: number | string | null; z?: number }; +type Series = { name: string; data: Point[] }; + +const series = (name: string, ...data: Point[]): Series => ({ name, data }); +const xs = (s: Series) => s.data.map((p) => p.x); + +test("merged_x_values keeps the order the series agree on", () => { + const merged = merged_x_values([ + series("a", { x: "Q1", y: 1 }, { x: "Q2", y: 2 }, { x: "Q3", y: 3 }), + series("b", { x: "Q1", y: 4 }, { x: "Q2", y: 5 }, { x: "Q3", y: 6 }), + ]); + + assert.deepEqual(merged, ["Q1", "Q2", "Q3"]); +}); + +test("merged_x_values orders by name the x values the series do not share (#951)", () => { + const merged = merged_x_values([ + series("a", { x: "X2", y: 10 }, { x: "X3", y: 30 }), + series("b", { x: "X1", y: 25 }, { x: "X2", y: 20 }), + ]); + + assert.deepEqual(merged, ["X1", "X2", "X3"]); +}); + +test("merged_x_values compares numbers as numbers, not as text", () => { + const merged = merged_x_values([ + series("a", { x: 2, y: 1 }, { x: 10, y: 1 }), + series("b", { x: 9, y: 1 }), + ]); + + assert.deepEqual(merged, [2, 9, 10]); +}); + +test("merged_x_values matches equal dates written as different objects", () => { + const merged = merged_x_values([ + series("a", { x: new Date("2024-03-01"), y: 1 }), + series("b", { x: new Date("2024-03-01"), y: 2 }), + ]); + + assert.equal(merged.length, 1); +}); + +test("merged_x_values ignores series that hold no points", () => { + const merged = merged_x_values([ + series("empty"), + series("a", { x: 1, y: 1 }), + ]); + + assert.deepEqual(merged, [1]); +}); + +test("align_series gives every series a point at every x (#727)", () => { + const [a, b] = align_series([ + series("a", { x: "Q1", y: 1 }, { x: "Q2", y: 2 }), + series("b", { x: "Q2", y: 3 }), + ]); + + assert.deepEqual(xs(a), ["Q1", "Q2"]); + assert.deepEqual(xs(b), ["Q1", "Q2"]); +}); + +test("align_series counts an x a series skipped as zero (#727)", () => { + const [, b] = align_series([ + series("a", { x: "Q1", y: 1 }, { x: "Q2", y: 2 }), + series("b", { x: "Q2", y: 3 }), + ]); + + assert.deepEqual(b.data, [ + { x: "Q1", y: 0 }, + { x: "Q2", y: 3 }, + ]); +}); + +test("align_series counts a null value as a value the series never measured", () => { + const [, b] = align_series([ + series("a", { x: "Q1", y: 1 }), + series("b", { x: "Q1", y: null }), + ]); + + assert.deepEqual(b.data, [{ x: "Q1", y: 0 }]); +}); + +test("align_series counts a blank value as zero", () => { + const [, b] = align_series([ + series("a", { x: "Q1", y: 1 }), + series("b", { x: "Q1", y: "" }), + ]); + + assert.deepEqual(b.data, [{ x: "Q1", y: 0 }]); +}); + +test("align_series keeps a value the series wrote as text", () => { + const [, b] = align_series([ + series("a", { x: "Q1", y: 1 }), + series("b", { x: "Q1", y: "7" }), + ]); + + assert.deepEqual(b.data, [{ x: "Q1", y: "7" }]); +}); + +test("align_series keeps the third dimension of points it did not fill in", () => { + const [a] = align_series([ + series("a", { x: "Q1", y: 1, z: 42 }), + series("b", { x: "Q2", y: 2 }), + ]); + + assert.equal(a.data[0].z, 42); +}); + +test("align_series matches dates by value rather than by identity", () => { + const [a, b] = align_series([ + series("a", { x: new Date("2024-03-01"), y: 1 }), + series("b", { x: new Date("2024-03-01"), y: 2 }), + ]); + + assert.equal(a.data.length, 1); + assert.equal(b.data.length, 1); + assert.equal(b.data[0].y, 2); +}); + +test("align_series leaves a lone series in the order it arrived (#930)", () => { + const [only] = align_series([ + series("a", { x: "Q2", y: 1 }, { x: "Q1", y: 2 }), + ]); + + assert.deepEqual(only.data, [ + { x: "Q2", y: 1 }, + { x: "Q1", y: 2 }, + ]); +}); + +test("align_series returns series that already share every x unchanged", () => { + const given = [ + series("a", { x: "Q3", y: 1 }, { x: "Q1", y: 2 }, { x: "Q2", y: 3 }), + series("b", { x: "Q3", y: 4 }, { x: "Q1", y: 5 }, { x: "Q2", y: 6 }), + ]; + + assert.deepEqual(align_series(given), given); +}); + +test("align_series does not mutate the series it is given", () => { + const given = [ + series("a", { x: "Q1", y: 1 }), + series("b", { x: "Q2", y: 2 }), + ]; + const before = JSON.stringify(given); + + align_series(given); + + assert.equal(JSON.stringify(given), before); +}); + +test("align_series keeps the last of duplicated x values", () => { + const [a] = align_series([ + series("a", { x: "Q1", y: 1 }, { x: "Q1", y: 9 }), + series("b", { x: "Q2", y: 2 }), + ]); + + assert.deepEqual(a.data, [ + { x: "Q1", y: 9 }, + { x: "Q2", y: 0 }, + ]); +}); diff --git a/tests/js/package.json b/tests/js/package.json new file mode 100644 index 00000000..3dbc1ca5 --- /dev/null +++ b/tests/js/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +}