Skip to content

Commit 7b3d179

Browse files
committed
[#1156] Add unit testing to validate tree-shaking
1 parent 91b0957 commit 7b3d179

2 files changed

Lines changed: 143 additions & 1 deletion

File tree

packages/melonjs/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,10 @@
4343
"types": "./build/index.d.ts",
4444
"module": "./build/index.js",
4545
"exports": {
46-
".": "./build/index.js"
46+
".": {
47+
"types": "./build/index.d.ts",
48+
"import": "./build/index.js"
49+
}
4750
},
4851
"files": [
4952
"build",
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/**
2+
* Tree-shaking validation tests.
3+
*
4+
* Simulates what a consumer's bundler does: imports a subset of exports
5+
* from the built melonJS output and verifies unused code is eliminated.
6+
*
7+
* Run with: node --test tests/treeshaking.test.mjs
8+
* Requires: pnpm build (or npx turbo run build --filter=melonjs) first
9+
*
10+
* @see https://github.com/melonjs/melonJS/issues/1156
11+
*/
12+
import assert from "node:assert";
13+
import { existsSync } from "node:fs";
14+
import { dirname, resolve } from "node:path";
15+
import { describe, it, todo } from "node:test";
16+
import { fileURLToPath } from "node:url";
17+
import esbuild from "esbuild";
18+
19+
const __dirname = dirname(fileURLToPath(import.meta.url));
20+
const buildDir = resolve(__dirname, "../build");
21+
const buildEntry = resolve(buildDir, "index.js");
22+
23+
// verify the build exists before running
24+
assert.ok(
25+
existsSync(buildEntry),
26+
`Built output not found at ${buildEntry}. Run 'pnpm build' first.`,
27+
);
28+
29+
const commonOptions = {
30+
bundle: true,
31+
write: false,
32+
format: "esm",
33+
target: "es2022",
34+
treeShaking: true,
35+
minify: true,
36+
metafile: true,
37+
external: ["howler"],
38+
};
39+
40+
/**
41+
* Bundle a code snippet that imports from the built melonJS entry point.
42+
* Returns { size, output, metafile }.
43+
*/
44+
async function bundle(code) {
45+
const result = await esbuild.build({
46+
...commonOptions,
47+
stdin: { contents: code, resolveDir: buildDir, loader: "js" },
48+
});
49+
const text = result.outputFiles[0].text;
50+
return { size: text.length, output: text, metafile: result.metafile };
51+
}
52+
53+
describe("Tree-shaking", async () => {
54+
// build all variants up front in parallel
55+
const [full, vector2dOnly, mathOnly, geometryOnly] = await Promise.all([
56+
bundle(`import * as me from "./index.js"; globalThis.me = me;`),
57+
bundle(
58+
`import { Vector2d } from "./index.js"; globalThis.v = new Vector2d(1, 2);`,
59+
),
60+
bundle(`import { math } from "./index.js"; globalThis.m = math;`),
61+
bundle(
62+
`import { Rect, Polygon, Ellipse } from "./index.js"; globalThis.r = new Rect(0,0,10,10); globalThis.p = new Polygon(0,0,[]); globalThis.e = new Ellipse(0,0,5,5);`,
63+
),
64+
]);
65+
66+
console.log(` Full library: ${(full.size / 1024).toFixed(1)} KB`);
67+
console.log(
68+
` Vector2d only: ${(vector2dOnly.size / 1024).toFixed(1)} KB (${((vector2dOnly.size / full.size) * 100).toFixed(1)}%)`,
69+
);
70+
console.log(
71+
` math only: ${(mathOnly.size / 1024).toFixed(1)} KB (${((mathOnly.size / full.size) * 100).toFixed(1)}%)`,
72+
);
73+
console.log(
74+
` Geometry only: ${(geometryOnly.size / 1024).toFixed(1)} KB (${((geometryOnly.size / full.size) * 100).toFixed(1)}%)`,
75+
);
76+
77+
it("full library bundle should be non-trivial", () => {
78+
assert.ok(
79+
full.size > 50_000,
80+
`Full bundle should be >50KB, got ${full.size}B`,
81+
);
82+
});
83+
84+
it("partial imports should not be larger than the full library", () => {
85+
assert.ok(
86+
vector2dOnly.size <= full.size,
87+
`Vector2d bundle should not exceed full bundle`,
88+
);
89+
assert.ok(
90+
mathOnly.size <= full.size,
91+
`math bundle should not exceed full bundle`,
92+
);
93+
assert.ok(
94+
geometryOnly.size <= full.size,
95+
`Geometry bundle should not exceed full bundle`,
96+
);
97+
});
98+
99+
// ---------------------------------------------------------------------------
100+
// Aspirational targets — these document the ideal tree-shaking behavior.
101+
// They currently fail because the build output is a single pre-bundled file
102+
// with top-level side effects (new Application(), onReady(), boot()) that
103+
// prevent consumer bundlers from eliminating unused code.
104+
//
105+
// To make these pass, the library would need to:
106+
// 1. Preserve individual ES modules in the build output (no bundling)
107+
// 2. Remove top-level side effects from the entry point (lazy init)
108+
//
109+
// Track progress: as tree-shaking improves, convert these from todo to it.
110+
// ---------------------------------------------------------------------------
111+
112+
todo("Vector2d-only import should be <50% of the full library", () => {
113+
const ratio = vector2dOnly.size / full.size;
114+
assert.ok(
115+
ratio < 0.5,
116+
`Vector2d: ${(ratio * 100).toFixed(1)}% of full bundle`,
117+
);
118+
});
119+
120+
todo("Vector2d-only import should not contain WebGL shader code", () => {
121+
assert.ok(
122+
!vector2dOnly.output.includes("gl_Position"),
123+
"WebGL vertex shader code should be tree-shaken out",
124+
);
125+
});
126+
127+
todo("math-only import should be <50% of the full library", () => {
128+
const ratio = mathOnly.size / full.size;
129+
assert.ok(ratio < 0.5, `math: ${(ratio * 100).toFixed(1)}% of full bundle`);
130+
});
131+
132+
todo("geometry-only import should be <50% of the full library", () => {
133+
const ratio = geometryOnly.size / full.size;
134+
assert.ok(
135+
ratio < 0.5,
136+
`Geometry: ${(ratio * 100).toFixed(1)}% of full bundle`,
137+
);
138+
});
139+
});

0 commit comments

Comments
 (0)