diff --git a/core/tests/01-conditional-exports.test.ts b/core/tests/01-conditional-exports.test.ts new file mode 100644 index 0000000..0ac87c6 --- /dev/null +++ b/core/tests/01-conditional-exports.test.ts @@ -0,0 +1,328 @@ +/** + * Scenario 01 — Conditional Exports + * + * Tests the modern `exports` field with nested conditions, multiple subpaths, + * and platform-specific branching. + * + * @see docs/scenarios/01-conditional-exports.md + * @module + */ + +import { describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; + +import { + resolveModern, + resolvePackageEntry, + getResolverConditions, + getLegacyMainFields, + manifest, + importArgs, + resolveOpts, + buildPackage, + getOutputText, + NETWORK_TIMEOUT, + UNIT_TIMEOUT, +} from "./helpers.ts"; + +// ============================================================================= +// Unit tests — resolveModern / resolvePackageEntry with synthetic manifests +// ============================================================================= + +describe("01 · Conditional Exports", () => { + describe("1.1 — Simple conditional exports (preact-like)", () => { + const pkg = manifest({ + exports: { + ".": { + types: "./src/index.d.ts", + browser: "./dist/preact.module.js", + import: "./dist/preact.mjs", + require: "./dist/preact.js", + }, + }, + }); + + it("browser build picks the 'browser' condition", () => { + const conds = getResolverConditions(importArgs(), resolveOpts({ platform: "browser" })); + const result = resolveModern(pkg, ".", conds); + + expect(result.success).toBe(true); + // "browser" should be active and match before "import" + expect(result.path).toBe("./dist/preact.module.js"); + }); + + it("node build picks 'import' (no browser condition)", () => { + const conds = getResolverConditions(importArgs(), resolveOpts({ platform: "node" })); + const result = resolveModern(pkg, ".", conds); + + expect(result.success).toBe(true); + expect(result.path).toBe("./dist/preact.mjs"); + }); + + it("CJS node build picks 'require'", () => { + const conds = getResolverConditions( + importArgs("entry-point"), + resolveOpts({ platform: "node", format: "cjs" }), + ); + const result = resolveModern(pkg, ".", conds); + + expect(result.success).toBe(true); + expect(result.path).toBe("./dist/preact.js"); + }); + }); + + describe("1.2 — Deeply nested conditions (solid-js-like)", () => { + const pkg = manifest({ + exports: { + ".": { + deno: { import: "./dist/server.js", require: "./dist/server.cjs" }, + node: { import: "./dist/server.js", require: "./dist/server.cjs" }, + worker: { import: "./dist/server.js", require: "./dist/server.cjs" }, + browser: { + development: { + import: "./dist/dev.js", + require: "./dist/dev.cjs", + }, + import: "./dist/solid.js", + require: "./dist/solid.cjs", + }, + import: "./dist/solid.js", + require: "./dist/solid.cjs", + }, + }, + }); + + it("browser production resolves to ./dist/solid.js", () => { + const conds = getResolverConditions(importArgs(), resolveOpts({ platform: "browser" })); + const result = resolveModern(pkg, ".", conds); + + expect(result.success).toBe(true); + expect(result.path).toBe("./dist/solid.js"); + }); + + it("browser + development resolves to ./dist/dev.js", () => { + const conds = getResolverConditions( + importArgs(), + resolveOpts({ platform: "browser", conditions: ["development"] }), + ); + const result = resolveModern(pkg, ".", conds); + + expect(result.success).toBe(true); + expect(result.path).toBe("./dist/dev.js"); + }); + + it("deno runtime resolves to ./dist/server.js", () => { + const conds = getResolverConditions( + importArgs(), + resolveOpts({ platform: "node", runtime: "deno" }), + ); + const result = resolveModern(pkg, ".", conds); + + expect(result.success).toBe(true); + expect(result.path).toBe("./dist/server.js"); + }); + }); + + describe("1.3 — Node-specific CJS/ESM nesting (vue-like)", () => { + const pkg = manifest({ + exports: { + ".": { + import: { + node: "./index.mjs", + types: "./dist/vue.d.mts", + default: "./dist/vue.runtime.esm-bundler.js", + }, + require: { + node: { + production: "./dist/vue.cjs.prod.js", + development: "./dist/vue.cjs.js", + default: "./index.js", + }, + types: "./dist/vue.d.ts", + default: "./index.js", + }, + }, + }, + }); + + it("browser ESM resolves to bundler entry", () => { + const conds = getResolverConditions(importArgs(), resolveOpts({ platform: "browser" })); + const result = resolveModern(pkg, ".", conds); + + expect(result.success).toBe(true); + expect(result.path).toBe("./dist/vue.runtime.esm-bundler.js"); + }); + + it("node ESM resolves to ./index.mjs", () => { + const conds = getResolverConditions(importArgs(), resolveOpts({ platform: "node" })); + const result = resolveModern(pkg, ".", conds); + + expect(result.success).toBe(true); + expect(result.path).toBe("./index.mjs"); + }); + + it("node CJS resolves through 'require' → 'node' → 'default'", () => { + const conds = getResolverConditions( + importArgs("entry-point"), + resolveOpts({ platform: "node", format: "cjs" }), + ); + const result = resolveModern(pkg, ".", conds); + + expect(result.success).toBe(true); + expect(result.path).toBe("./index.js"); + }); + }); + + describe("1.4 — 2×2 matrix: format × platform (uuid-like)", () => { + const pkg = manifest({ + exports: { + ".": { + node: { + import: "./dist/esm/index.js", + require: "./dist/cjs/index.js", + }, + browser: { + import: "./dist/esm-browser/index.js", + require: "./dist/cjs-browser/index.js", + }, + default: "./dist/esm-browser/index.js", + }, + }, + }); + + it("browser + ESM → esm-browser", () => { + const conds = getResolverConditions(importArgs(), resolveOpts({ platform: "browser" })); + const result = resolveModern(pkg, ".", conds); + expect(result.path).toBe("./dist/esm-browser/index.js"); + }); + + it("browser + CJS → cjs-browser", () => { + const conds = getResolverConditions( + importArgs("entry-point"), + resolveOpts({ platform: "browser", format: "cjs" }), + ); + const result = resolveModern(pkg, ".", conds); + expect(result.path).toBe("./dist/cjs-browser/index.js"); + }); + + it("node + ESM → esm", () => { + const conds = getResolverConditions(importArgs(), resolveOpts({ platform: "node" })); + const result = resolveModern(pkg, ".", conds); + expect(result.path).toBe("./dist/esm/index.js"); + }); + + it("node + CJS → cjs", () => { + const conds = getResolverConditions( + importArgs("entry-point"), + resolveOpts({ platform: "node", format: "cjs" }), + ); + const result = resolveModern(pkg, ".", conds); + expect(result.path).toBe("./dist/cjs/index.js"); + }); + }); + + describe("1.5 — Single-string exports (chalk-like)", () => { + const pkg = manifest({ + type: "module", + exports: "./source/index.js" as any, + }); + + it("resolves the string directly", () => { + const conds = getResolverConditions(importArgs(), resolveOpts()); + const result = resolveModern(pkg, ".", conds); + + expect(result.success).toBe(true); + expect(result.path).toBe("./source/index.js"); + }); + }); + + describe("1.6 — Array fallbacks (yargs-like)", () => { + const pkg = manifest({ + exports: { + ".": [ + { import: "./index.mjs", require: "./index.cjs" }, + "./index.cjs", + ], + }, + }); + + it("ESM picks import from the first array entry", () => { + const conds = getResolverConditions(importArgs(), resolveOpts()); + const result = resolveModern(pkg, ".", conds); + + expect(result.success).toBe(true); + expect(result.path).toBe("./index.mjs"); + }); + + it("CJS picks require from the first array entry", () => { + const conds = getResolverConditions( + importArgs("entry-point"), + resolveOpts({ format: "cjs" }), + ); + const result = resolveModern(pkg, ".", conds); + + expect(result.success).toBe(true); + expect(result.path).toBe("./index.cjs"); + }); + }); + + describe("1.7 — Explicit null exclusion", () => { + const pkg = manifest({ + exports: { + ".": "./index.js", + "./internal": null as any, + }, + }); + + it("root resolves normally", () => { + const conds = getResolverConditions(importArgs(), resolveOpts()); + const result = resolveModern(pkg, ".", conds); + + expect(result.success).toBe(true); + expect(result.path).toBe("./index.js"); + }); + + it("./internal produces a resolution failure (null exclusion)", () => { + const conds = getResolverConditions(importArgs(), resolveOpts()); + const result = resolveModern(pkg, "./internal", conds); + + // null exclusion → success should be false or path should be null + expect(result.path).toBeNull(); + expect(result.success).toBe(false); + }); + }); + + // =========================================================================== + // Integration tests — full build pipeline + // =========================================================================== + + describe("integration: real packages", () => { + it("preact@10.25.4 builds successfully for browser", async () => { + const result = await buildPackage("preact@10.25.4"); + + expect(result.contents.length).toBeGreaterThan(0); + expect(result.errors.length).toBe(0); + }, { timeout: NETWORK_TIMEOUT }); + + it("solid-js@1.9.4 builds for browser", async () => { + const result = await buildPackage("solid-js@1.9.4"); + + expect(result.contents.length).toBeGreaterThan(0); + expect(result.errors.length).toBe(0); + }, { timeout: NETWORK_TIMEOUT }); + + it("uuid@11.0.5 builds for browser", async () => { + const result = await buildPackage("uuid@11.0.5"); + + expect(result.contents.length).toBeGreaterThan(0); + expect(result.errors.length).toBe(0); + }, { timeout: NETWORK_TIMEOUT }); + + it("chalk@5.4.1 builds for browser", async () => { + const result = await buildPackage("chalk@5.4.1"); + + expect(result.contents.length).toBeGreaterThan(0); + expect(result.errors.length).toBe(0); + }, { timeout: NETWORK_TIMEOUT }); + }); +}); diff --git a/core/tests/02-subpath-patterns.test.ts b/core/tests/02-subpath-patterns.test.ts new file mode 100644 index 0000000..052e6bf --- /dev/null +++ b/core/tests/02-subpath-patterns.test.ts @@ -0,0 +1,246 @@ +/** + * Scenario 02 — Subpath Patterns and Subpath Imports + * + * Tests wildcard patterns in `exports`, deep subpath resolution, + * and the private `#` imports mechanism. + * + * @see docs/scenarios/02-subpath-patterns-and-imports.md + * @module + */ + +import { describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; + +import { + resolveModern, + resolvePackageEntry, + getResolverConditions, + getLegacyMainFields, + manifest, + importArgs, + resolveOpts, + buildPackage, + getOutputText, + outputContains, + NETWORK_TIMEOUT, +} from "./helpers.ts"; + +// ============================================================================= +// Unit tests — wildcard patterns +// ============================================================================= + +describe("02 · Subpath Patterns and Imports", () => { + describe("2.1 — Basic wildcard pattern (solid-js-like)", () => { + const pkg = manifest({ + exports: { + ".": { import: "./dist/solid.js" }, + "./web": { import: "./web/dist/web.js" }, + "./dist/*": "./dist/*", + "./web/dist/*": "./web/dist/*", + }, + }); + + it("./dist/solid.js matches the wildcard and substitutes", () => { + const conds = getResolverConditions(importArgs(), resolveOpts()); + const result = resolveModern(pkg, "./dist/solid.js", conds); + + expect(result.success).toBe(true); + expect(result.path).toBe("./dist/solid.js"); + }); + + it("./web/dist/web.js matches the web wildcard", () => { + const conds = getResolverConditions(importArgs(), resolveOpts()); + const result = resolveModern(pkg, "./web/dist/web.js", conds); + + expect(result.success).toBe(true); + expect(result.path).toBe("./web/dist/web.js"); + }); + }); + + describe("2.2 — Explicit key priority over wildcard", () => { + const pkg = manifest({ + exports: { + ".": { import: "./dist/solid.js" }, + "./web": { import: "./web/dist/web.js" }, + "./dist/*": "./dist/*", + }, + }); + + it("./web matches the explicit key, not a wildcard", () => { + const conds = getResolverConditions(importArgs(), resolveOpts()); + const result = resolveModern(pkg, "./web", conds); + + expect(result.success).toBe(true); + expect(result.path).toBe("./web/dist/web.js"); + }); + }); + + describe("2.3 — Wildcard with conditional exports (rxjs-like)", () => { + const pkg = manifest({ + exports: { + ".": { import: "./dist/esm5/index.js" }, + "./operators": { import: "./dist/esm5/operators/index.js" }, + "./internal/*": { + node: "./dist/cjs/internal/*.js", + default: "./dist/esm5/internal/*.js", + require: "./dist/cjs/internal/*.js", + }, + }, + }); + + it("browser default → esm5 path", () => { + const conds = getResolverConditions(importArgs(), resolveOpts({ platform: "browser" })); + const result = resolveModern(pkg, "./internal/operators/map", conds); + + expect(result.success).toBe(true); + expect(result.path).toBe("./dist/esm5/internal/operators/map.js"); + }); + + it("node build → cjs path", () => { + const conds = getResolverConditions(importArgs(), resolveOpts({ platform: "node" })); + const result = resolveModern(pkg, "./internal/operators/map", conds); + + expect(result.success).toBe(true); + expect(result.path).toBe("./dist/cjs/internal/operators/map.js"); + }); + }); + + describe("2.4 — Overlapping: explicit subpath vs wildcard", () => { + const pkg = manifest({ + exports: { + ".": { import: "./dist/esm5/index.js" }, + "./operators": { import: "./dist/esm5/operators/index.js" }, + "./internal/*": { default: "./dist/esm5/internal/*.js" }, + }, + }); + + it("./operators hits the explicit key", () => { + const conds = getResolverConditions(importArgs(), resolveOpts()); + const result = resolveModern(pkg, "./operators", conds); + + expect(result.success).toBe(true); + expect(result.path).toBe("./dist/esm5/operators/index.js"); + }); + + it("./internal/operators/map hits the wildcard", () => { + const conds = getResolverConditions(importArgs(), resolveOpts()); + const result = resolveModern(pkg, "./internal/operators/map", conds); + + expect(result.success).toBe(true); + expect(result.path).toBe("./dist/esm5/internal/operators/map.js"); + }); + }); + + // =========================================================================== + // Subpath Imports (#-prefix) + // =========================================================================== + + describe("2.5 — Conditional # import (chalk-like)", () => { + const pkg = manifest({ + imports: { + "#ansi-styles": "./source/vendor/ansi-styles/index.js", + "#supports-color": { + node: "./source/vendor/supports-color/index.js", + default: "./source/vendor/supports-color/browser.js", + }, + }, + }); + + it("browser build resolves #supports-color to browser.js", () => { + const conds = getResolverConditions(importArgs(), resolveOpts({ platform: "browser" })); + const result = resolveModern(pkg, "#supports-color", conds); + + expect(result.success).toBe(true); + expect(result.path).toBe("./source/vendor/supports-color/browser.js"); + }); + + it("node build resolves #supports-color to index.js", () => { + const conds = getResolverConditions(importArgs(), resolveOpts({ platform: "node" })); + const result = resolveModern(pkg, "#supports-color", conds); + + expect(result.success).toBe(true); + expect(result.path).toBe("./source/vendor/supports-color/index.js"); + }); + }); + + describe("2.6 — Unconditional # import", () => { + const pkg = manifest({ + imports: { + "#ansi-styles": "./source/vendor/ansi-styles/index.js", + }, + }); + + it("resolves to the single path regardless of platform", () => { + const browserConds = getResolverConditions(importArgs(), resolveOpts({ platform: "browser" })); + const nodeConds = getResolverConditions(importArgs(), resolveOpts({ platform: "node" })); + + const browserResult = resolveModern(pkg, "#ansi-styles", browserConds); + const nodeResult = resolveModern(pkg, "#ansi-styles", nodeConds); + + expect(browserResult.success).toBe(true); + expect(nodeResult.success).toBe(true); + expect(browserResult.path).toBe("./source/vendor/ansi-styles/index.js"); + expect(nodeResult.path).toBe("./source/vendor/ansi-styles/index.js"); + }); + }); + + describe("2.7 — Failed # import = hard error", () => { + const pkg = manifest({ + imports: { + "#exists": "./source/exists.js", + }, + }); + + it("#nonexistent does not resolve (hard failure)", () => { + const conds = getResolverConditions(importArgs(), resolveOpts()); + const result = resolveModern(pkg, "#nonexistent", conds); + + expect(result.success).toBe(false); + expect(result.path).toBeNull(); + }); + }); + + describe("2.8 — Self-referencing through exports (yargs-like)", () => { + const pkg = manifest({ + name: "yargs", + exports: { + ".": [ + { import: "./index.mjs", require: "./index.cjs" }, + "./index.cjs", + ], + "./helpers": { + import: "./helpers/helpers.mjs", + require: "./helpers/index.js", + }, + }, + }); + + it("./helpers resolves through exports", () => { + const conds = getResolverConditions(importArgs(), resolveOpts()); + const result = resolveModern(pkg, "./helpers", conds); + + expect(result.success).toBe(true); + expect(result.path).toBe("./helpers/helpers.mjs"); + }); + }); + + // =========================================================================== + // Integration tests + // =========================================================================== + + describe("integration: real packages", () => { + it("chalk@5.4.1 resolves # imports correctly", async () => { + const result = await buildPackage("chalk@5.4.1"); + + expect(result.contents.length).toBeGreaterThan(0); + expect(result.errors.length).toBe(0); + }, { timeout: NETWORK_TIMEOUT }); + + it("rxjs@7.8.1 builds a subpath import", async () => { + const result = await buildPackage("rxjs@7.8.1"); + + expect(result.contents.length).toBeGreaterThan(0); + expect(result.errors.length).toBe(0); + }, { timeout: NETWORK_TIMEOUT }); + }); +}); diff --git a/core/tests/03-legacy-fields.test.ts b/core/tests/03-legacy-fields.test.ts new file mode 100644 index 0000000..6bfa861 --- /dev/null +++ b/core/tests/03-legacy-fields.test.ts @@ -0,0 +1,194 @@ +/** + * Scenario 03 — Legacy Field Resolution + * + * Tests packages without `exports` that rely on `main`, `module`, + * `browser` (string form), `unpkg`, `bin`, and `./index.js` fallback. + * + * @see docs/scenarios/03-legacy-fields.md + * @module + */ + +import { describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; + +import { + resolveLegacy, + resolvePackageEntry, + getResolverConditions, + getLegacyMainFields, + manifest, + importArgs, + resolveOpts, + buildPackage, + NETWORK_TIMEOUT, +} from "./helpers.ts"; + +// ============================================================================= +// Unit tests — resolveLegacy with synthetic manifests +// ============================================================================= + +describe("03 · Legacy Field Resolution", () => { + describe("3.1 — Only main field (isarray-like)", () => { + const pkg = manifest({ main: "index.js" }); + + it("browser build resolves to main", () => { + const fields = ["browser", "module", "main"]; + const result = resolveLegacy(pkg, { browser: true }, fields); + + expect(result.excluded).toBe(false); + expect(result.entryPoint).toBe("index.js"); + }); + + it("node build resolves to main", () => { + const fields = ["module", "main"]; + const result = resolveLegacy(pkg, { browser: false }, fields); + + expect(result.excluded).toBe(false); + expect(result.entryPoint).toBe("index.js"); + }); + }); + + describe("3.2 — module field (lodash-es-like)", () => { + const pkg = manifest({ + main: "lodash.js", + module: "lodash.js", + type: "module", + sideEffects: false, + }); + + it("browser build picks module field", () => { + const fields = ["browser", "module", "main"]; + const result = resolveLegacy(pkg, { browser: true }, fields); + + // No browser field → falls to module + expect(result.excluded).toBe(false); + expect(result.entryPoint).toBe("lodash.js"); + }); + }); + + describe("3.3 — browser string form (signal-exit-like)", () => { + // Without exports so legacy path is taken + const pkg = manifest({ + main: "./dist/cjs/index.js", + module: "./dist/mjs/index.js", + browser: "./dist/mjs/browser.js", + }); + + it("browser build uses the browser string entry", () => { + const fields = ["browser", "module", "main"]; + const result = resolveLegacy(pkg, { browser: true }, fields); + + expect(result.excluded).toBe(false); + expect(result.entryPoint).toBe("./dist/mjs/browser.js"); + }); + + it("node build ignores browser → uses module", () => { + const fields = ["module", "main"]; + const result = resolveLegacy(pkg, { browser: false }, fields); + + expect(result.excluded).toBe(false); + expect(result.entryPoint).toBe("./dist/mjs/index.js"); + }); + }); + + describe("3.4 — No entry point fields at all", () => { + const pkg = manifest({}); + + it("resolvePackageEntry falls back to ./index.js", () => { + const conds = getResolverConditions(importArgs(), resolveOpts()); + const fields = getLegacyMainFields(pkg, importArgs(), resolveOpts()); + const result = resolvePackageEntry({ + manifest: pkg, + subpath: ".", + conditions: conds, + legacyFields: fields, + }); + + expect(result.path).toBe("./index.js"); + }); + }); + + describe("3.5 — unpkg / jsdelivr CDN fields", () => { + const pkg = manifest({ unpkg: "dist/global.js" }); + + it("falls to unpkg field when main/module are missing", () => { + const fields = ["module", "main"]; + const result = resolveLegacy(pkg, { browser: false }, fields); + + // main/module missing → should fall to unpkg + // resolveLegacy checks unpkg as a fallback + expect(result.entryPoint).toBe("dist/global.js"); + }); + }); + + describe("3.7 — jsnext:main is NOT recognized", () => { + const pkg = manifest({ + main: "./moment.js", + "jsnext:main": "./dist/moment.js", + } as any); + + it("resolveLegacy ignores jsnext:main, picks main", () => { + const fields = ["module", "main"]; + const result = resolveLegacy(pkg, { browser: false }, fields); + + expect(result.entryPoint).toBe("./moment.js"); + }); + }); + + describe("3.8 — exports takes precedence over legacy", () => { + const pkg = manifest({ + main: "./dist/cjs/index.js", + module: "./dist/mjs/index.js", + browser: "./dist/mjs/browser.js", + exports: { + ".": { + import: { default: "./dist/mjs/index.js" }, + require: { default: "./dist/cjs/index.js" }, + }, + }, + }); + + it("resolvePackageEntry uses modern resolution (exports) first", () => { + const conds = getResolverConditions(importArgs(), resolveOpts({ platform: "browser" })); + const fields = getLegacyMainFields(pkg, importArgs(), resolveOpts()); + const result = resolvePackageEntry({ + manifest: pkg, + subpath: ".", + conditions: conds, + legacyFields: fields, + }); + + // exports exists → should use modern resolution → import condition + expect(result.usedModern).toBe(true); + expect(result.path).toBe("./dist/mjs/index.js"); + // NOT ./dist/mjs/browser.js (which is the legacy browser field) + }); + }); + + // =========================================================================== + // Integration tests + // =========================================================================== + + describe("integration: real packages", () => { + it("isarray@2.0.5 builds with only main field", async () => { + const result = await buildPackage("isarray@2.0.5"); + + expect(result.contents.length).toBeGreaterThan(0); + expect(result.errors.length).toBe(0); + }, { timeout: NETWORK_TIMEOUT }); + + it("lodash-es@4.17.21 builds with module field", async () => { + const result = await buildPackage("lodash-es@4.17.21"); + + expect(result.contents.length).toBeGreaterThan(0); + expect(result.errors.length).toBe(0); + }, { timeout: NETWORK_TIMEOUT }); + + it("ms@2.1.3 builds with extensionless main", async () => { + const result = await buildPackage("ms@2.1.3"); + + expect(result.contents.length).toBeGreaterThan(0); + expect(result.errors.length).toBe(0); + }, { timeout: NETWORK_TIMEOUT }); + }); +}); diff --git a/core/tests/04-browser-remapping.test.ts b/core/tests/04-browser-remapping.test.ts new file mode 100644 index 0000000..8361fb9 --- /dev/null +++ b/core/tests/04-browser-remapping.test.ts @@ -0,0 +1,236 @@ +/** + * Scenario 04 — Browser Field Remapping + * + * Tests the `browser` field in its **object form** — the remapping layer + * that rewrites internal module paths for browser environments. + * + * This is the scenario that caught the original bug (4.7). + * + * @see docs/scenarios/04-browser-field-remapping.md + * @module + */ + +import { describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; + +import { + resolveLegacy, + applyPathRemapping, + applyManifestRemappings, + resolvePackageEntry, + getResolverConditions, + getLegacyMainFields, + manifest, + importArgs, + resolveOpts, + buildPackage, + getOutputText, + NETWORK_TIMEOUT, +} from "./helpers.ts"; + +// ============================================================================= +// Unit tests — applyPathRemapping, applyManifestRemappings +// ============================================================================= + +describe("04 · Browser Field Remapping", () => { + describe("4.1 — Internal path remapping (readable-stream-like)", () => { + const pkg = manifest({ + main: "./lib/ours/index.js", + browser: { + "util": "./lib/ours/util.js", + "./lib/ours/index.js": "./lib/ours/browser.js", + }, + }); + + it("browser build: entry remapped from index.js → browser.js", () => { + const fields = ["browser", "module", "main"]; + const result = resolveLegacy(pkg, { browser: true }, fields); + + expect(result.excluded).toBe(false); + // The object browser field stores remappings, entry comes from main + // Then applyPathRemapping rewrites the entry + expect(result.pathRemappings).not.toBeNull(); + expect(result.entryPoint).not.toBeNull(); + + // Simulate the remapping step + const remapped = applyPathRemapping(result.entryPoint!, result.pathRemappings); + expect(remapped).toBe("./lib/ours/browser.js"); + }); + + it("node build: browser field ignored, entry is main", () => { + const fields = ["module", "main"]; + const result = resolveLegacy(pkg, { browser: false }, fields); + + expect(result.excluded).toBe(false); + expect(result.entryPoint).toBe("./lib/ours/index.js"); + expect(result.pathRemappings).toBeNull(); + }); + }); + + describe("4.2 — Bare module exclusion (false mapping)", () => { + it("applyPathRemapping returns false for excluded modules", () => { + const remappings = { + "./lib/adapters/http.js": "./lib/helpers/null.js", + "./lib/platform/node/index.js": "./lib/platform/browser/index.js", + }; + + expect(applyPathRemapping("./lib/adapters/http.js", remappings)) + .toBe("./lib/helpers/null.js"); + expect(applyPathRemapping("./lib/platform/node/index.js", remappings)) + .toBe("./lib/platform/browser/index.js"); + }); + + it("fs → false excludes the module", () => { + const remappings = { fs: false as const }; + expect(applyPathRemapping("fs", remappings)).toBe(false); + }); + }); + + describe("4.3 — Remapping with ./ prefix variants", () => { + it("matches with ./ prefix when key is bare", () => { + const remappings = { "lib/node.js": "./lib/browser.js" }; + // applyPathRemapping tries multiple variants + expect(applyPathRemapping("./lib/node.js", remappings)).toBe("./lib/browser.js"); + }); + + it("matches bare import when key has ./ prefix", () => { + const remappings = { "./utils/fs": false as const }; + expect(applyPathRemapping("./utils/fs", remappings)).toBe(false); + }); + + it("path without prefix matches key without prefix", () => { + const remappings = { "lib/node.js": "./lib/browser.js" }; + expect(applyPathRemapping("lib/node.js", remappings)).toBe("./lib/browser.js"); + }); + }); + + describe("4.4 — All-false browser field = excluded package", () => { + const pkg = manifest({ + main: "./index.js", + browser: { + "./index.js": false, + "./lib/core.js": false, + }, + }); + + it("browser build detects all-false and excludes", () => { + const fields = ["browser", "module", "main"]; + const result = resolveLegacy(pkg, { browser: true }, fields); + + expect(result.excluded).toBe(true); + }); + }); + + describe("4.5 — Browser field ignored on non-browser platforms", () => { + const pkg = manifest({ + main: "./lib/ours/index.js", + browser: { + "./lib/ours/index.js": "./lib/ours/browser.js", + }, + }); + + it("node platform does not apply browser remapping", () => { + const fields = ["module", "main"]; + const result = resolveLegacy(pkg, { browser: false }, fields); + + expect(result.entryPoint).toBe("./lib/ours/index.js"); + expect(result.pathRemappings).toBeNull(); + }); + }); + + describe("4.6 — Edge runtimes: browser condition ≠ browserField", () => { + it("workerd: browser condition active, browserField disabled", () => { + const conds = getResolverConditions( + importArgs(), + resolveOpts({ platform: "browser", runtime: "workerd" }), + ); + + // "browser" is in conditions (for exports) + expect(conds.conditions).toContain("browser"); + // But browserField is false (no legacy field remapping) + expect(conds.browser).toBe(false); + }); + + it("edge-light: both browser condition and browserField active", () => { + const conds = getResolverConditions( + importArgs(), + resolveOpts({ platform: "browser", runtime: "edge-light" }), + ); + + expect(conds.conditions).toContain("browser"); + expect(conds.browser).toBe(true); + }); + }); + + describe("4.7 — Relative import remapping (HttpPlugin) — THE BUG SCENARIO", () => { + // This tests the applyManifestRemappings function which the HttpPlugin + // calls for relative imports within a package. + const pkg = manifest({ + main: "./index.js", + browser: { + "./fallback/platform.js": "./fallback/platform.browser.js", + "./fallback/utf8.auto.js": "./fallback/utf8.auto.browser.js", + }, + }); + + it("browser conditions: remaps ./fallback/platform.js → platform.browser.js", () => { + const conds = getResolverConditions(importArgs(), resolveOpts({ platform: "browser" })); + const result = applyManifestRemappings("./fallback/platform.js", pkg, conds); + + expect(result.excluded).toBe(false); + expect(result.matchedField).toBe("browser"); + expect(result.path).toBe("./fallback/platform.browser.js"); + }); + + it("node conditions: no remapping applied", () => { + const conds = getResolverConditions(importArgs(), resolveOpts({ platform: "node" })); + const result = applyManifestRemappings("./fallback/platform.js", pkg, conds); + + expect(result.matchedField).toBeNull(); + expect(result.path).toBe("./fallback/platform.js"); + }); + }); + + describe("4.8 — Relative import exclusion (false)", () => { + const pkg = manifest({ + browser: { + "./lib/native-impl.js": false, + }, + }); + + it("browser conditions: excludes the module", () => { + const conds = getResolverConditions(importArgs(), resolveOpts({ platform: "browser" })); + const result = applyManifestRemappings("./lib/native-impl.js", pkg, conds); + + expect(result.excluded).toBe(true); + expect(result.matchedField).toBe("browser"); + }); + }); + + // =========================================================================== + // Integration tests + // =========================================================================== + + describe("integration: real packages", () => { + it("@exodus/bytes@1.13.0 builds with browser remapping", async () => { + const result = await buildPackage("@exodus/bytes@1.13.0"); + + expect(result.contents.length).toBeGreaterThan(0); + expect(result.errors.length).toBe(0); + }, { timeout: NETWORK_TIMEOUT }); + + it("readable-stream@4.7.0 builds for browser", async () => { + const result = await buildPackage("readable-stream@4.7.0"); + + expect(result.contents.length).toBeGreaterThan(0); + expect(result.errors.length).toBe(0); + }, { timeout: NETWORK_TIMEOUT }); + + it("axios@1.7.9 builds with browser field swaps", async () => { + const result = await buildPackage("axios@1.7.9"); + + expect(result.contents.length).toBeGreaterThan(0); + expect(result.errors.length).toBe(0); + }, { timeout: NETWORK_TIMEOUT }); + }); +}); diff --git a/core/tests/05-platform-remapping.test.ts b/core/tests/05-platform-remapping.test.ts new file mode 100644 index 0000000..a7fe6a5 --- /dev/null +++ b/core/tests/05-platform-remapping.test.ts @@ -0,0 +1,198 @@ +/** + * Scenario 05 — Platform-Specific Remapping (react-native, electron) + * + * Tests manifest field remappings beyond the `browser` field — specifically + * the `react-native` and `electron` top-level fields. + * + * @see docs/scenarios/05-platform-remapping.md + * @module + */ + +import { describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; + +import { + applyManifestRemappings, + REMAPPING_FIELDS, + getResolverConditions, + getRuntimeDefaults, + manifest, + importArgs, + resolveOpts, +} from "./helpers.ts"; + +describe("05 · Platform-Specific Remapping", () => { + describe("REMAPPING_FIELDS ordering", () => { + it("is ordered: react-native → electron → browser", () => { + expect(REMAPPING_FIELDS[0].condition).toBe("react-native"); + expect(REMAPPING_FIELDS[1].condition).toBe("electron"); + expect(REMAPPING_FIELDS[2].condition).toBe("browser"); + }); + }); + + describe("5.2 — React Native object-form remapping (@exodus/bytes-like)", () => { + const pkg = manifest({ + browser: { + "./fallback/platform.js": "./fallback/platform.browser.js", + "./fallback/utf8.auto.js": "./fallback/utf8.auto.browser.js", + }, + "react-native": { + "./fallback/platform.js": "./fallback/platform.native.js", + "./fallback/utf8.auto.js": "./fallback/utf8.auto.native.js", + }, + } as any); + + it("react-native runtime → remaps to .native.js", () => { + const conds = getResolverConditions( + importArgs(), + resolveOpts({ platform: "node", runtime: "react-native" }), + ); + const result = applyManifestRemappings("./fallback/platform.js", pkg, conds); + + expect(result.matchedField).toBe("react-native"); + expect(result.path).toBe("./fallback/platform.native.js"); + }); + + it("browser runtime → remaps to .browser.js", () => { + const conds = getResolverConditions(importArgs(), resolveOpts({ platform: "browser" })); + const result = applyManifestRemappings("./fallback/platform.js", pkg, conds); + + expect(result.matchedField).toBe("browser"); + expect(result.path).toBe("./fallback/platform.browser.js"); + }); + }); + + describe("5.3 — Priority: react-native > browser when both conditions active", () => { + const pkg = manifest({ + browser: { "./lib/impl.js": "./lib/impl.browser.js" }, + "react-native": { "./lib/impl.js": "./lib/impl.native.js" }, + } as any); + + it("react-native wins over browser", () => { + // Simulate both conditions being active + const conds = getResolverConditions( + importArgs(), + resolveOpts({ platform: "browser", runtime: "react-native" }), + ); + const result = applyManifestRemappings("./lib/impl.js", pkg, conds); + + expect(result.matchedField).toBe("react-native"); + expect(result.path).toBe("./lib/impl.native.js"); + }); + }); + + describe("5.4 — Electron renderer remapping", () => { + const pkg = manifest({ + browser: { "./lib/crypto.js": "./lib/crypto.browser.js" }, + electron: { "./lib/crypto.js": "./lib/crypto.electron.js" }, + } as any); + + it("electron-renderer: electron field wins over browser", () => { + const conds = getResolverConditions( + importArgs(), + resolveOpts({ platform: "browser", runtime: "electron-renderer" }), + ); + const result = applyManifestRemappings("./lib/crypto.js", pkg, conds); + + expect(result.matchedField).toBe("electron"); + expect(result.path).toBe("./lib/crypto.electron.js"); + }); + + it("electron-main: electron field applies", () => { + const conds = getResolverConditions( + importArgs(), + resolveOpts({ platform: "node", runtime: "electron-main" }), + ); + const result = applyManifestRemappings("./lib/crypto.js", pkg, conds); + + expect(result.matchedField).toBe("electron"); + expect(result.path).toBe("./lib/crypto.electron.js"); + }); + + it("default browser build: browser field applies", () => { + const conds = getResolverConditions(importArgs(), resolveOpts({ platform: "browser" })); + const result = applyManifestRemappings("./lib/crypto.js", pkg, conds); + + expect(result.matchedField).toBe("browser"); + expect(result.path).toBe("./lib/crypto.browser.js"); + }); + }); + + describe("5.5 — No remapping field matches (pass-through)", () => { + const pkg = manifest({ + browser: { "./fallback/platform.js": "./fallback/platform.browser.js" }, + "react-native": { "./fallback/platform.js": "./fallback/platform.native.js" }, + } as any); + + it("deno runtime: no condition matches → pass-through", () => { + const conds = getResolverConditions( + importArgs(), + resolveOpts({ platform: "node", runtime: "deno" }), + ); + const result = applyManifestRemappings("./fallback/platform.js", pkg, conds); + + expect(result.matchedField).toBeNull(); + expect(result.path).toBe("./fallback/platform.js"); + }); + }); + + describe("5.6 — false exclusion for non-browser field", () => { + const pkg = manifest({ + "react-native": { + "./lib/dom-impl.js": false, + "./lib/platform.js": "./lib/platform.native.js", + }, + } as any); + + it("react-native build: false excludes the module", () => { + const conds = getResolverConditions( + importArgs(), + resolveOpts({ platform: "node", runtime: "react-native" }), + ); + const result = applyManifestRemappings("./lib/dom-impl.js", pkg, conds); + + expect(result.excluded).toBe(true); + expect(result.matchedField).toBe("react-native"); + }); + + it("react-native build: normal remapping still works", () => { + const conds = getResolverConditions( + importArgs(), + resolveOpts({ platform: "node", runtime: "react-native" }), + ); + const result = applyManifestRemappings("./lib/platform.js", pkg, conds); + + expect(result.excluded).toBe(false); + expect(result.matchedField).toBe("react-native"); + expect(result.path).toBe("./lib/platform.native.js"); + }); + }); + + describe("5.7 — getRuntimeDefaults covers all runtimes", () => { + it("react-native: browserField false", () => { + const d = getRuntimeDefaults("react-native"); + expect(d.conditions).toContain("react-native"); + expect(d.browserField).toBe(false); + }); + + it("electron-main: adds electron + node", () => { + const d = getRuntimeDefaults("electron-main"); + expect(d.conditions).toContain("electron"); + expect(d.conditions).toContain("node"); + expect(d.browserField).toBe(false); + }); + + it("electron-renderer: adds electron + browser, browserField true", () => { + const d = getRuntimeDefaults("electron-renderer"); + expect(d.conditions).toContain("electron"); + expect(d.conditions).toContain("browser"); + expect(d.browserField).toBe(true); + }); + + it("undefined runtime: empty conditions, null browserField", () => { + const d = getRuntimeDefaults(undefined); + expect(d.conditions).toHaveLength(0); + expect(d.browserField).toBeNull(); + }); + }); +}); diff --git a/core/tests/06-relative-imports.test.ts b/core/tests/06-relative-imports.test.ts new file mode 100644 index 0000000..cbc5dd3 --- /dev/null +++ b/core/tests/06-relative-imports.test.ts @@ -0,0 +1,64 @@ +/** + * Scenario 06 — Relative Imports, CDN Redirects, and Extension Probing + * + * Tests how the HttpPlugin resolves relative imports inside CDN-fetched + * modules, handles CDN redirects, and probes for missing extensions. + * + * @see docs/scenarios/06-relative-imports.md + * @module + */ + +import { describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; + +import { + buildPackage, + buildWithEntry, + getOutputText, + NETWORK_TIMEOUT, +} from "./helpers.ts"; + +// ============================================================================= +// Integration tests — these hit the network to verify real resolution paths +// ============================================================================= + +describe("06 · Relative Imports and CDN Behavior", () => { + describe("6.1 — Post-redirect URL as resolve base", () => { + it("react@19.0.0 via esm.sh resolves relative imports after redirect", async () => { + const result = await buildPackage("react@19.0.0", { + cdn: "esm.sh", + }); + + expect(result.contents.length).toBeGreaterThan(0); + expect(result.errors.length).toBe(0); + }, { timeout: NETWORK_TIMEOUT }); + }); + + describe("6.2 — Extension probing for extensionless imports", () => { + it("events@3.3.0 resolves extensionless internal imports", async () => { + const result = await buildPackage("events@3.3.0"); + + expect(result.contents.length).toBeGreaterThan(0); + expect(result.errors.length).toBe(0); + }, { timeout: NETWORK_TIMEOUT }); + }); + + describe("6.5 — Bare import inside HTTP module delegates to CdnPlugin", () => { + it("axios@1.7.9 resolves transitive bare imports", async () => { + const result = await buildPackage("axios@1.7.9"); + + expect(result.contents.length).toBeGreaterThan(0); + expect(result.errors.length).toBe(0); + }, { timeout: NETWORK_TIMEOUT }); + }); + + describe("6.8 — # imports inside HTTP modules", () => { + it("chalk@5.4.1 resolves # imports in CDN-fetched source", async () => { + const result = await buildPackage("chalk@5.4.1"); + + // chalk uses #supports-color and #ansi-styles + expect(result.contents.length).toBeGreaterThan(0); + expect(result.errors.length).toBe(0); + }, { timeout: NETWORK_TIMEOUT }); + }); +}); diff --git a/core/tests/07-tree-shaking.test.ts b/core/tests/07-tree-shaking.test.ts new file mode 100644 index 0000000..bc15171 --- /dev/null +++ b/core/tests/07-tree-shaking.test.ts @@ -0,0 +1,164 @@ +/** + * Scenario 07 — Tree-Shaking and Side Effects + * + * Tests how bundlejs reads the `sideEffects` field and passes it + * to esbuild for dead code elimination. + * + * @see docs/scenarios/07-tree-shaking.md + * @module + */ + +import { describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; + +import { + computeEsbuildSideEffects, + compileSideEffectsMatchers, + normalizeSideEffectsPattern, + normalizePkgRelPath, + isJsLikePath, + manifest, + buildPackage, + buildWithEntry, + getOutputText, + NETWORK_TIMEOUT, + UNIT_TIMEOUT, +} from "./helpers.ts"; + +// ============================================================================= +// Unit tests — sideEffects computation +// ============================================================================= + +describe("07 · Tree-Shaking and Side Effects", () => { + describe("sideEffects: false", () => { + it("returns false when manifest has sideEffects: false", () => { + const pkg = manifest({ sideEffects: false }); + const result = computeEsbuildSideEffects(pkg, "./index.js", { + packageId: "lodash-es@4.17.21", + }); + + expect(result).toBe(false); + }); + + it("returns undefined when sideEffects field is absent", () => { + const pkg = manifest({}); + const result = computeEsbuildSideEffects(pkg, "./index.js", { + packageId: "moment@2.30.1", + }); + + // No sideEffects field → conservative (undefined = keep all) + expect(result).toBeUndefined(); + }); + }); + + describe("sideEffects: glob patterns", () => { + it("JS file not in glob list returns false (tree-shakeable)", () => { + const pkg = manifest({ sideEffects: ["./src/nodes/**/*"] }); + const result = computeEsbuildSideEffects(pkg, "./src/math/Vector3.js", { + packageId: "three@0.171.0", + }); + + expect(result).toBe(false); + }); + + it("file matching glob returns undefined (has side effects)", () => { + const pkg = manifest({ sideEffects: ["./src/nodes/**/*"] }); + const result = computeEsbuildSideEffects(pkg, "./src/nodes/ShaderNode.js", { + packageId: "three@0.171.0", + }); + + expect(result).toBeUndefined(); + }); + + it("*.css glob keeps CSS files", () => { + const pkg = manifest({ sideEffects: ["*.css"] }); + const result = computeEsbuildSideEffects(pkg, "./styles/main.css", { + packageId: "test-pkg@1.0.0", + }); + + expect(result).toBeUndefined(); + }); + + it("*.css glob marks JS files as tree-shakeable", () => { + const pkg = manifest({ sideEffects: ["*.css"] }); + const result = computeEsbuildSideEffects(pkg, "./src/index.js", { + packageId: "test-pkg@1.0.0", + }); + + expect(result).toBe(false); + }); + }); + + describe("sideEffects: explicit file list", () => { + it("exact match returns undefined (has side effects)", () => { + const pkg = manifest({ + sideEffects: [ + "./src/reanimated2/core.js", + "./plugin.js", + ], + }); + const result = computeEsbuildSideEffects(pkg, "./src/reanimated2/core.js", { + packageId: "react-native-reanimated@3.16.7", + }); + + expect(result).toBeUndefined(); + }); + + it("non-matching file returns false (tree-shakeable)", () => { + const pkg = manifest({ + sideEffects: [ + "./src/reanimated2/core.js", + "./plugin.js", + ], + }); + const result = computeEsbuildSideEffects(pkg, "./src/utils/helpers.js", { + packageId: "react-native-reanimated@3.16.7", + }); + + expect(result).toBe(false); + }); + }); + + describe("path normalization utilities", () => { + it("normalizePkgRelPath strips leading ./", () => { + expect(normalizePkgRelPath("./src/index.js")).toBe("src/index.js"); + }); + + it("normalizePkgRelPath handles bare paths", () => { + expect(normalizePkgRelPath("src/index.js")).toBe("src/index.js"); + }); + + it("isJsLikePath recognizes JS extensions", () => { + expect(isJsLikePath("index.js")).toBe(true); + expect(isJsLikePath("index.mjs")).toBe(true); + expect(isJsLikePath("index.ts")).toBe(true); + expect(isJsLikePath("index.tsx")).toBe(true); + }); + + it("isJsLikePath rejects non-JS extensions", () => { + expect(isJsLikePath("styles.css")).toBe(false); + expect(isJsLikePath("image.png")).toBe(false); + }); + }); + + // =========================================================================== + // Integration tests + // =========================================================================== + + describe("integration: tree-shaking with real packages", () => { + it("lodash-es: selective import is smaller than full import", async () => { + // Full import + const full = await buildPackage("lodash-es@4.17.21"); + // Selective (tree-shaken) import + const selective = await buildWithEntry( + `export { debounce } from "lodash-es@4.17.21";`, + ); + + // Tree-shaken bundle should be significantly smaller + const fullSize = full.contents.reduce((s, f) => s + f.contents.byteLength, 0); + const selectiveSize = selective.contents.reduce((s, f) => s + f.contents.byteLength, 0); + + expect(selectiveSize).toBeLessThan(fullSize); + }, { timeout: NETWORK_TIMEOUT }); + }); +}); diff --git a/core/tests/08-non-npm-resolution.test.ts b/core/tests/08-non-npm-resolution.test.ts new file mode 100644 index 0000000..e9f7770 --- /dev/null +++ b/core/tests/08-non-npm-resolution.test.ts @@ -0,0 +1,58 @@ +/** + * Scenario 08 — Non-npm Resolution (JSR, Tarballs, Import Maps) + * + * Tests resolution paths that bypass the standard npm CDN pipeline — + * JSR registry, tarball extraction, and import map remapping. + * + * @see docs/scenarios/08-non-npm-resolution.md + * @module + */ + +import { describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; + +import { + buildPackage, + buildWithEntry, + getOutputText, + NETWORK_TIMEOUT, +} from "./helpers.ts"; + +// ============================================================================= +// Integration tests — JSR +// ============================================================================= + +describe("08 · Non-npm Resolution", () => { + describe("JSR resolution", () => { + it("8.1 — basic jsr:@std/path@1.0.0 resolves", async () => { + const result = await buildPackage("jsr:@std/path@1.0.0"); + + expect(result.contents.length).toBeGreaterThan(0); + expect(result.errors.length).toBe(0); + }, { timeout: NETWORK_TIMEOUT }); + + it("8.3 — JSR with subpath export (jsr:@std/path@1.0.0/posix)", async () => { + const result = await buildWithEntry( + `export * from "jsr:@std/path@1.0.0/posix";`, + ); + + expect(result.contents.length).toBeGreaterThan(0); + expect(result.errors.length).toBe(0); + }, { timeout: NETWORK_TIMEOUT }); + }); + + // =========================================================================== + // Integration tests — Tarballs + // =========================================================================== + + describe("Tarball extraction", () => { + it("8.6 — tarball from pkg.pr.new resolves", async () => { + const result = await buildWithEntry( + `export * from "https://pkg.pr.new/@tanstack/react-query@7988";`, + ); + + expect(result.contents.length).toBeGreaterThan(0); + expect(result.errors.length).toBe(0); + }, { timeout: NETWORK_TIMEOUT }); + }); +}); diff --git a/core/tests/09-builtins-polyfills.test.ts b/core/tests/09-builtins-polyfills.test.ts new file mode 100644 index 0000000..cb63800 --- /dev/null +++ b/core/tests/09-builtins-polyfills.test.ts @@ -0,0 +1,127 @@ +/** + * Scenario 09 — Node.js Builtins and Polyfills + * + * Tests how bundlejs handles Node.js built-in modules (`fs`, `path`, `crypto`, + * etc.) and the `node:` prefix — both in default exclusion mode and with + * polyfill mode enabled. + * + * @see docs/scenarios/09-builtins-and-polyfills.md + * @module + */ + +import { describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; + +import { + buildPackage, + buildWithEntry, + getOutputText, + outputContains, + outputMatches, + NETWORK_TIMEOUT, +} from "./helpers.ts"; + +// ============================================================================= +// Integration tests — builtin handling +// ============================================================================= + +describe("09 · Builtins and Polyfills", () => { + // --------------------------------------------------------------------------- + // 9.1 — Builtin exclusion (default behaviour, polyfill: false) + // --------------------------------------------------------------------------- + describe("9.1 — Builtin exclusion (default)", () => { + it("fs-extra@11.2.0 builds successfully without polyfills", async () => { + const result = await buildPackage("fs-extra@11.2.0"); + + expect(result.contents.length).toBeGreaterThan(0); + expect(result.errors.length).toBe(0); + }, { timeout: NETWORK_TIMEOUT }); + }); + + // --------------------------------------------------------------------------- + // 9.2 — Builtin polyfill mode + // --------------------------------------------------------------------------- + describe("9.2 — Builtin polyfill mode", () => { + it("fs-extra@11.2.0 with polyfill produces a larger bundle", async () => { + const without = await buildPackage("fs-extra@11.2.0"); + const withPoly = await buildPackage("fs-extra@11.2.0", { + polyfill: true, + }); + + // Polyfill bundles are significantly larger because they inline + // browser implementations of fs, path, crypto, etc. + const sizeWithout = getOutputText(without).length; + const sizeWith = getOutputText(withPoly).length; + + expect(sizeWith).toBeGreaterThan(sizeWithout); + }, { timeout: NETWORK_TIMEOUT }); + }); + + // --------------------------------------------------------------------------- + // 9.3 — node: prefix stripping + // --------------------------------------------------------------------------- + describe("9.3 — node: prefix stripping", () => { + it("@noble/hashes@1.7.1 resolves node:crypto as builtin", async () => { + const result = await buildPackage("@noble/hashes@1.7.1"); + + // Should build without trying to fetch "node:crypto" from npm + expect(result.contents.length).toBeGreaterThan(0); + expect(result.errors.length).toBe(0); + }, { timeout: NETWORK_TIMEOUT }); + }); + + // --------------------------------------------------------------------------- + // 9.4 — "fs" and "node:fs" resolve identically + // --------------------------------------------------------------------------- + describe("9.4 — fs vs node:fs equivalence", () => { + it("events@3.3.0 as npm package builds", async () => { + const result = await buildPackage("events@3.3.0"); + + expect(result.contents.length).toBeGreaterThan(0); + expect(result.errors.length).toBe(0); + }, { timeout: NETWORK_TIMEOUT }); + }); + + // --------------------------------------------------------------------------- + // 9.5 — Builtins inside CDN-fetched modules + // --------------------------------------------------------------------------- + describe("9.5 — Builtin inside CDN-fetched module", () => { + it("axios@1.7.9 internal `http`/`https` imports are excluded", async () => { + const result = await buildPackage("axios@1.7.9"); + + // axios uses http/https internally; these should be excluded (not + // fetched from CDN) via the ExternalPlugin. + expect(result.contents.length).toBeGreaterThan(0); + expect(result.errors.length).toBe(0); + }, { timeout: NETWORK_TIMEOUT }); + }); + + // --------------------------------------------------------------------------- + // 9.6 — Polyfill output format compatibility + // --------------------------------------------------------------------------- + describe("9.6 — Polyfill output format", () => { + it("CJS format wraps polyfill output in module.exports", async () => { + const result = await buildPackage("events@3.3.0", { + esbuild: { format: "cjs" }, + }); + + const text = getOutputText(result); + // CJS output should use CommonJS style exports + expect( + outputContains(result, "module.exports") || + outputContains(result, "exports.") || + outputMatches(result, /require\(/) + ).toBe(true); + }, { timeout: NETWORK_TIMEOUT }); + + it("IIFE format wraps output in a function wrapper", async () => { + const result = await buildPackage("events@3.3.0", { + esbuild: { format: "iife" }, + }); + + const text = getOutputText(result); + // IIFE output uses a self-invoking function — typically var or (()=>{})() + expect(text.length).toBeGreaterThan(0); + }, { timeout: NETWORK_TIMEOUT }); + }); +}); diff --git a/core/tests/10-dual-package.test.ts b/core/tests/10-dual-package.test.ts new file mode 100644 index 0000000..56f195e --- /dev/null +++ b/core/tests/10-dual-package.test.ts @@ -0,0 +1,209 @@ +/** + * Scenario 10 — Dual Package Hazard, Format, and Platform Matrix + * + * Tests packages that ship both CJS and ESM, the `type: "module"` flag, + * and the interaction between `format` and `platform` settings. + * Covers output wrapping, define replacements, target syntax, and minification. + * + * @see docs/scenarios/10-dual-package-hazard.md + * @module + */ + +import { describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; + +import { + resolveModern, + getResolverConditions, + isRequireContext, + manifest, + importArgs, + resolveOpts, + buildPackage, + buildWithEntry, + getOutputText, + outputContains, + outputMatches, + NETWORK_TIMEOUT, + UNIT_TIMEOUT, +} from "./helpers.ts"; + +// ============================================================================= +// Unit tests — CJS vs ESM condition resolution +// ============================================================================= + +describe("10 · Dual Package Hazard", () => { + // --------------------------------------------------------------------------- + // 10.1 — CJS vs ESM via conditional exports (uuid-like) + // --------------------------------------------------------------------------- + describe("10.1 — CJS vs ESM via conditional exports", () => { + /** + * uuid@11 ships a 2×2 matrix: + * node × {import,require} → ./dist/esm/index.js, ./dist/cjs/index.js + * browser × {import,require} → ./dist/esm-browser/index.js, ./dist/cjs-browser/index.js + */ + const pkg = manifest({ + name: "uuid", + version: "11.0.5", + exports: { + ".": { + node: { + import: "./dist/esm/index.js", + require: "./dist/cjs/index.js", + }, + browser: { + import: "./dist/esm-browser/index.js", + require: "./dist/cjs-browser/index.js", + }, + default: "./dist/esm-browser/index.js", + }, + }, + }); + + it("browser + ESM → esm-browser", () => { + const conds = getResolverConditions( + importArgs("import-statement"), + resolveOpts({ platform: "browser", format: "esm" }), + ); + const result = resolveModern(pkg, ".", conds); + expect(result.path).toBe("./dist/esm-browser/index.js"); + }); + + it("browser + CJS → cjs-browser", () => { + const conds = getResolverConditions( + importArgs("require-call"), + resolveOpts({ platform: "browser", format: "cjs" }), + ); + const result = resolveModern(pkg, ".", conds); + expect(result.path).toBe("./dist/cjs-browser/index.js"); + }); + + it("node + ESM → esm (node path)", () => { + const conds = getResolverConditions( + importArgs("import-statement"), + resolveOpts({ platform: "node", format: "esm" }), + ); + const result = resolveModern(pkg, ".", conds); + expect(result.path).toBe("./dist/esm/index.js"); + }); + + it("node + CJS → cjs (node path)", () => { + const conds = getResolverConditions( + importArgs("require-call"), + resolveOpts({ platform: "node", format: "cjs" }), + ); + const result = resolveModern(pkg, ".", conds); + expect(result.path).toBe("./dist/cjs/index.js"); + }); + }); + + // --------------------------------------------------------------------------- + // 10.2 — Pure ESM (type: module, single-string exports) + // --------------------------------------------------------------------------- + describe("10.2 — Pure ESM (chalk-like)", () => { + const pkg = manifest({ + name: "chalk", + version: "5.4.1", + type: "module", + exports: "./source/index.js", + }); + + it("ESM resolves single-string exports", () => { + const conds = getResolverConditions( + importArgs("import-statement"), + resolveOpts({ format: "esm" }), + ); + const result = resolveModern(pkg, ".", conds); + expect(result.path).toBe("./source/index.js"); + }); + + it("CJS also resolves single-string exports (no require guard)", () => { + const conds = getResolverConditions( + importArgs("require-call"), + resolveOpts({ format: "cjs" }), + ); + // Single-string exports are universal — accessible from both contexts + const result = resolveModern(pkg, ".", conds); + expect(result.path).toBe("./source/index.js"); + }); + }); + + // --------------------------------------------------------------------------- + // isRequireContext helper + // --------------------------------------------------------------------------- + describe("isRequireContext()", () => { + it("returns true for require-call + cjs", () => { + expect(isRequireContext( + importArgs("require-call"), + resolveOpts({ format: "cjs" }), + )).toBe(true); + }); + + it("returns false for import-statement + esm", () => { + expect(isRequireContext( + importArgs("import-statement"), + resolveOpts({ format: "esm" }), + )).toBe(false); + }); + + it("returns true for entry-point + cjs format", () => { + expect(isRequireContext( + importArgs("entry-point"), + resolveOpts({ format: "cjs" }), + )).toBe(true); + }); + }); + + // --------------------------------------------------------------------------- + // Integration tests — format and platform + // --------------------------------------------------------------------------- + describe("integration: format affects output wrapping", () => { + it("10.4 — ESM output uses export syntax", async () => { + const result = await buildPackage("preact@10.25.4", { + esbuild: { format: "esm" }, + }); + + const text = getOutputText(result); + expect(text).toMatch(/export/); + }, { timeout: NETWORK_TIMEOUT }); + + it("10.4 — CJS output uses module.exports or require", async () => { + const result = await buildPackage("preact@10.25.4", { + esbuild: { format: "cjs" }, + }); + + expect( + outputContains(result, "module.exports") || + outputContains(result, "exports.") || + outputMatches(result, /require\(/) + ).toBe(true); + }, { timeout: NETWORK_TIMEOUT }); + + it("10.4 — IIFE output wraps in function", async () => { + const result = await buildPackage("preact@10.25.4", { + esbuild: { format: "iife" }, + }); + + const text = getOutputText(result); + // esbuild IIFE output typically starts with (() => { or var + expect(text.length).toBeGreaterThan(0); + }, { timeout: NETWORK_TIMEOUT }); + }); + + describe("integration: minification toggle", () => { + it("10.7 — unminified output is larger", async () => { + const minified = await buildPackage("preact@10.25.4", { + esbuild: { minify: true }, + }); + const unminified = await buildPackage("preact@10.25.4", { + esbuild: { minify: false }, + }); + + const minSize = getOutputText(minified).length; + const unminSize = getOutputText(unminified).length; + + // Unminified should be larger (more whitespace, longer names) + expect(unminSize).toBeGreaterThan(minSize); + }, { timeout: NETWORK_TIMEOUT }); + }); +}); diff --git a/core/tests/11-edge-cases.test.ts b/core/tests/11-edge-cases.test.ts new file mode 100644 index 0000000..ec8a42c --- /dev/null +++ b/core/tests/11-edge-cases.test.ts @@ -0,0 +1,215 @@ +/** + * Scenario 11 — Edge Cases, Errors, and Stress Tests + * + * Tests error handling, aliasing, cyclic dependencies, deep nesting, + * VFS precedence, and stress scenarios with many transitive deps. + * + * @see docs/scenarios/11-edge-cases-and-errors.md + * @module + */ + +import { describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; + +import { + resolveModern, + getResolverConditions, + computePeerDependencies, + manifest, + importArgs, + resolveOpts, + buildPackage, + buildWithEntry, + getOutputText, + outputContains, + NETWORK_TIMEOUT, + UNIT_TIMEOUT, +} from "./helpers.ts"; + +// ============================================================================= +// Unit tests — deep nesting and edge cases +// ============================================================================= + +describe("11 · Edge Cases and Errors", () => { + // --------------------------------------------------------------------------- + // 11.14 — Very deep exports nesting + // --------------------------------------------------------------------------- + describe("11.14 — Very deep exports nesting", () => { + const pkg = manifest({ + exports: { + ".": { + browser: { + production: { + import: { + default: "./dist/browser.prod.esm.js", + }, + }, + default: "./dist/browser.esm.js", + }, + default: "./dist/index.js", + }, + }, + }); + + it("resolves through 4 levels of nesting with matching conditions", () => { + const conds = getResolverConditions( + importArgs("import-statement"), + resolveOpts({ + platform: "browser", + conditions: ["production"], + }), + ); + const result = resolveModern(pkg, ".", conds); + + // Should drill down: browser → production → import → default + expect(result.path).toBe("./dist/browser.prod.esm.js"); + }); + + it("stops at browser.default when production is not in conditions", () => { + const conds = getResolverConditions( + importArgs("import-statement"), + resolveOpts({ platform: "browser" }), + ); + const result = resolveModern(pkg, ".", conds); + + // Without "production" condition, falls to browser.default + expect(result.path).toBe("./dist/browser.esm.js"); + }); + + it("falls to top-level default when browser is not matched", () => { + const conds = getResolverConditions( + importArgs("import-statement"), + resolveOpts({ platform: "node" }), + ); + const result = resolveModern(pkg, ".", conds); + + expect(result.path).toBe("./dist/index.js"); + }); + }); + + // =========================================================================== + // Integration tests — aliasing + // =========================================================================== + + describe("11.5 — AliasPlugin rewrites before resolution", () => { + it("alias react → preact/compat uses preact (not React)", async () => { + const result = await buildWithEntry( + `export { useState } from "react";`, + { + alias: { react: "preact/compat" }, + }, + ); + + const text = getOutputText(result); + // The bundle should contain preact code, not React + expect(result.contents.length).toBeGreaterThan(0); + expect(result.errors.length).toBe(0); + }, { timeout: NETWORK_TIMEOUT }); + }); + + // =========================================================================== + // Integration tests — error handling + // =========================================================================== + + describe("11.9 — Nonexistent package", () => { + it("returns an error for a package that does not exist", async () => { + try { + const result = await buildPackage("this-package-does-not-exist-12345"); + // Should either throw or produce errors + expect(result.errors.length).toBeGreaterThan(0); + } catch (_e) { + // Throwing is an acceptable error response + expect(true).toBe(true); + } + }, { timeout: NETWORK_TIMEOUT }); + }); + + describe("11.10 — Nonexistent version", () => { + it("returns an error for a version that does not exist", async () => { + try { + const result = await buildPackage("react@999.0.0"); + expect(result.errors.length).toBeGreaterThan(0); + } catch (_e) { + expect(true).toBe(true); + } + }, { timeout: NETWORK_TIMEOUT }); + }); + + // =========================================================================== + // Integration tests — cyclic / peer dependencies + // =========================================================================== + + describe("11.7 — Peer dependency cycle", () => { + it("react-dom@19.0.0 builds despite peer-depending on react", async () => { + const result = await buildPackage("react-dom@19.0.0"); + + // Should complete without infinite loops + expect(result.contents.length).toBeGreaterThan(0); + expect(result.errors.length).toBe(0); + }, { timeout: NETWORK_TIMEOUT }); + }); + + // =========================================================================== + // Integration tests — special characters in package names + // =========================================================================== + + describe("11.15 — Scoped packages with special characters", () => { + it("@anthropic-ai/sdk resolves correctly (scoped + hyphens)", async () => { + // This tests that URL encoding handles @scope/name-with-hyphens + const result = await buildPackage("@anthropic-ai/sdk@0.39.0"); + + expect(result.contents.length).toBeGreaterThan(0); + expect(result.errors.length).toBe(0); + }, { timeout: NETWORK_TIMEOUT }); + }); + + // =========================================================================== + // Integration tests — VFS precedence + // =========================================================================== + + describe("11.16 — VFS file takes precedence over CDN", () => { + it("entry file in VFS resolves from memory", async () => { + const result = await buildWithEntry( + `export const hello = "world";`, + ); + + // The entry file in VFS is the only content — should not hit CDN + expect(result.errors.length).toBe(0); + expect(outputContains(result, "world")).toBe(true); + }, { timeout: NETWORK_TIMEOUT }); + }); + + describe("11.17 — Relative imports between VFS files", () => { + it("VFS-to-VFS relative imports resolve in memory", async () => { + // buildWithEntry writes /index.tsx; we can test that the entry itself + // resolves correctly without hitting the network. A full VFS-relative + // test would require setFile for both files, but this validates + // that VFS-based resolution does work for the entry point. + const result = await buildWithEntry( + `const msg: string = "hello from VFS";\nexport { msg };`, + ); + + expect(result.errors.length).toBe(0); + expect(outputContains(result, "hello from VFS")).toBe(true); + }, { timeout: NETWORK_TIMEOUT }); + }); + + // =========================================================================== + // Integration tests — multi-package queries + // =========================================================================== + + describe("11.13 — Multiple packages", () => { + it("separate builds for multiple packages all succeed", async () => { + // bundlejs resolves each package independently; here we verify + // that multiple packages can be built in succession without + // cross-contamination. + const r1 = await buildPackage("preact@10.25.4"); + const r2 = await buildPackage("chalk@5.4.1"); + + expect(r1.errors.length).toBe(0); + expect(r2.errors.length).toBe(0); + expect(r1.contents.length).toBeGreaterThan(0); + expect(r2.contents.length).toBeGreaterThan(0); + }, { timeout: NETWORK_TIMEOUT }); + }); +}); diff --git a/core/tests/12-runtime-conditions.test.ts b/core/tests/12-runtime-conditions.test.ts new file mode 100644 index 0000000..664f945 --- /dev/null +++ b/core/tests/12-runtime-conditions.test.ts @@ -0,0 +1,410 @@ +/** + * Scenario 12 — Runtime-Specific Condition Sets + * + * Tests that each supported runtime profile produces the correct + * condition set, and that conditions flow correctly through the + * entire resolution pipeline via `getResolverConditions()` and + * `getRuntimeDefaults()`. + * + * @see docs/scenarios/12-runtime-conditions.md + * @module + */ + +import { describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; + +import { + resolveModern, + getResolverConditions, + getRuntimeDefaults, + isRequireContext, + manifest, + importArgs, + resolveOpts, + buildPackage, + getOutputText, + NETWORK_TIMEOUT, + UNIT_TIMEOUT, +} from "./helpers.ts"; + +import type { ResolveRuntime } from "./helpers.ts"; + +// ============================================================================= +// Unit tests — condition sets for every runtime +// ============================================================================= + +describe("12 · Runtime-Specific Condition Sets", () => { + // --------------------------------------------------------------------------- + // 12.1 — Default browser conditions + // --------------------------------------------------------------------------- + describe("12.1 — Default browser conditions", () => { + it("produces import, browser, module, default", () => { + const conds = getResolverConditions( + importArgs("import-statement"), + resolveOpts({ platform: "browser", format: "esm" }), + ); + + expect(conds.conditions).toContain("import"); + expect(conds.conditions).toContain("browser"); + expect(conds.conditions).toContain("module"); + expect(conds.conditions).toContain("default"); + }); + + it("has browserField: true by default", () => { + const conds = getResolverConditions( + importArgs("import-statement"), + resolveOpts({ platform: "browser" }), + ); + + expect(conds.browserField).toBe(true); + }); + }); + + // --------------------------------------------------------------------------- + // 12.2 — Deno runtime + // --------------------------------------------------------------------------- + describe("12.2 — Deno runtime", () => { + it("includes deno + node conditions", () => { + const conds = getResolverConditions( + importArgs("import-statement"), + resolveOpts({ runtime: "deno" as ResolveRuntime }), + ); + + expect(conds.conditions).toContain("deno"); + expect(conds.conditions).toContain("node"); + }); + + it("disables browserField", () => { + const conds = getResolverConditions( + importArgs("import-statement"), + resolveOpts({ runtime: "deno" as ResolveRuntime }), + ); + + expect(conds.browserField).toBe(false); + }); + + it("getRuntimeDefaults reports deno", () => { + const defaults = getRuntimeDefaults("deno" as ResolveRuntime); + expect(defaults.conditions).toContain("deno"); + expect(defaults.conditions).toContain("node"); + expect(defaults.browserField).toBe(false); + }); + }); + + // --------------------------------------------------------------------------- + // 12.3 — Bun runtime + // --------------------------------------------------------------------------- + describe("12.3 — Bun runtime", () => { + it("includes bun + node conditions", () => { + const conds = getResolverConditions( + importArgs("import-statement"), + resolveOpts({ runtime: "bun" as ResolveRuntime }), + ); + + expect(conds.conditions).toContain("bun"); + expect(conds.conditions).toContain("node"); + }); + + it("disables browserField", () => { + const conds = getResolverConditions( + importArgs("import-statement"), + resolveOpts({ runtime: "bun" as ResolveRuntime }), + ); + + expect(conds.browserField).toBe(false); + }); + }); + + // --------------------------------------------------------------------------- + // 12.4 — Cloudflare Workers (workerd) + // --------------------------------------------------------------------------- + describe("12.4 — Cloudflare Workers (workerd)", () => { + it("includes workerd, worker, browser conditions", () => { + const conds = getResolverConditions( + importArgs("import-statement"), + resolveOpts({ runtime: "workerd" as ResolveRuntime }), + ); + + expect(conds.conditions).toContain("workerd"); + expect(conds.conditions).toContain("worker"); + expect(conds.conditions).toContain("browser"); + }); + + it("disables browserField (server-like)", () => { + const conds = getResolverConditions( + importArgs("import-statement"), + resolveOpts({ runtime: "workerd" as ResolveRuntime }), + ); + + // Workers want browser-optimized *exports* paths but should NOT + // apply legacy browser field remappings + expect(conds.browserField).toBe(false); + }); + }); + + // --------------------------------------------------------------------------- + // 12.5 — Vercel Edge (edge-light) + // --------------------------------------------------------------------------- + describe("12.5 — Vercel Edge (edge-light)", () => { + it("includes edge-light, worker, browser conditions", () => { + const conds = getResolverConditions( + importArgs("import-statement"), + resolveOpts({ runtime: "edge-light" as ResolveRuntime }), + ); + + expect(conds.conditions).toContain("edge-light"); + expect(conds.conditions).toContain("worker"); + expect(conds.conditions).toContain("browser"); + }); + + it("enables browserField (unlike workerd)", () => { + const conds = getResolverConditions( + importArgs("import-statement"), + resolveOpts({ runtime: "edge-light" as ResolveRuntime }), + ); + + expect(conds.browserField).toBe(true); + }); + }); + + // --------------------------------------------------------------------------- + // 12.6 — React Native + // --------------------------------------------------------------------------- + describe("12.6 — React Native", () => { + it("includes react-native condition", () => { + const conds = getResolverConditions( + importArgs("import-statement"), + resolveOpts({ runtime: "react-native" as ResolveRuntime }), + ); + + expect(conds.conditions).toContain("react-native"); + }); + + it("disables browserField", () => { + const conds = getResolverConditions( + importArgs("import-statement"), + resolveOpts({ runtime: "react-native" as ResolveRuntime }), + ); + + expect(conds.browserField).toBe(false); + }); + }); + + // --------------------------------------------------------------------------- + // 12.7 — Electron main process + // --------------------------------------------------------------------------- + describe("12.7 — Electron main", () => { + it("includes electron + node conditions", () => { + const conds = getResolverConditions( + importArgs("import-statement"), + resolveOpts({ runtime: "electron-main" as ResolveRuntime }), + ); + + expect(conds.conditions).toContain("electron"); + expect(conds.conditions).toContain("node"); + }); + + it("disables browserField", () => { + const conds = getResolverConditions( + importArgs("import-statement"), + resolveOpts({ runtime: "electron-main" as ResolveRuntime }), + ); + + expect(conds.browserField).toBe(false); + }); + }); + + // --------------------------------------------------------------------------- + // 12.8 — Electron renderer + // --------------------------------------------------------------------------- + describe("12.8 — Electron renderer", () => { + it("includes electron + browser conditions", () => { + const conds = getResolverConditions( + importArgs("import-statement"), + resolveOpts({ runtime: "electron-renderer" as ResolveRuntime }), + ); + + expect(conds.conditions).toContain("electron"); + expect(conds.conditions).toContain("browser"); + }); + + it("enables browserField", () => { + const conds = getResolverConditions( + importArgs("import-statement"), + resolveOpts({ runtime: "electron-renderer" as ResolveRuntime }), + ); + + expect(conds.browserField).toBe(true); + }); + }); + + // --------------------------------------------------------------------------- + // 12.9 — Custom user-provided conditions + // --------------------------------------------------------------------------- + describe("12.9 — Custom conditions", () => { + it("adds user conditions to the list", () => { + const conds = getResolverConditions( + importArgs("import-statement"), + resolveOpts({ + platform: "browser", + conditions: ["development", "react-server"], + }), + ); + + expect(conds.conditions).toContain("development"); + expect(conds.conditions).toContain("react-server"); + }); + + it("resolves solid-js development entry when development condition is present", () => { + const pkg = manifest({ + name: "solid-js", + version: "1.9.4", + exports: { + ".": { + browser: { + development: { + import: "./dist/dev.js", + }, + import: "./dist/solid.js", + }, + default: "./dist/solid.cjs", + }, + }, + }); + + const conds = getResolverConditions( + importArgs("import-statement"), + resolveOpts({ + platform: "browser", + conditions: ["development"], + }), + ); + const result = resolveModern(pkg, ".", conds); + expect(result.path).toBe("./dist/dev.js"); + }); + + it("resolves solid-js production entry without development condition", () => { + const pkg = manifest({ + name: "solid-js", + version: "1.9.4", + exports: { + ".": { + browser: { + development: { + import: "./dist/dev.js", + }, + import: "./dist/solid.js", + }, + default: "./dist/solid.cjs", + }, + }, + }); + + const conds = getResolverConditions( + importArgs("import-statement"), + resolveOpts({ platform: "browser" }), + ); + const result = resolveModern(pkg, ".", conds); + expect(result.path).toBe("./dist/solid.js"); + }); + }); + + // --------------------------------------------------------------------------- + // 12.10 — Condition deduplication + // --------------------------------------------------------------------------- + describe("12.10 — Condition deduplication", () => { + it("browser appears only once even when platform and runtime both add it", () => { + const conds = getResolverConditions( + importArgs("import-statement"), + resolveOpts({ + platform: "browser", + runtime: "electron-renderer" as ResolveRuntime, + }), + ); + + // Count occurrences of "browser" + const browserCount = conds.conditions.filter((c: string) => c === "browser").length; + expect(browserCount).toBe(1); + }); + + it("node appears only once for electron-main + platform:node", () => { + const conds = getResolverConditions( + importArgs("import-statement"), + resolveOpts({ + platform: "node", + runtime: "electron-main" as ResolveRuntime, + }), + ); + + const nodeCount = conds.conditions.filter((c: string) => c === "node").length; + expect(nodeCount).toBe(1); + }); + }); + + // --------------------------------------------------------------------------- + // 12.11 — require context from CJS format + // --------------------------------------------------------------------------- + describe("12.11 — require context from CJS format", () => { + it("CJS format + entry-point produces require context", () => { + expect( + isRequireContext( + importArgs("entry-point"), + resolveOpts({ format: "cjs" }), + ), + ).toBe(true); + }); + + it("ESM format + entry-point does NOT produce require context", () => { + expect( + isRequireContext( + importArgs("entry-point"), + resolveOpts({ format: "esm" }), + ), + ).toBe(false); + }); + + it("CJS format resolves uuid to require path", () => { + const pkg = manifest({ + name: "uuid", + exports: { + ".": { + node: { + import: "./dist/esm/index.js", + require: "./dist/cjs/index.js", + }, + default: "./dist/esm-browser/index.js", + }, + }, + }); + + const conds = getResolverConditions( + importArgs("require-call"), + resolveOpts({ platform: "node", format: "cjs" }), + ); + const result = resolveModern(pkg, ".", conds); + expect(result.path).toBe("./dist/cjs/index.js"); + }); + }); + + // =========================================================================== + // Integration tests — end-to-end condition flow + // =========================================================================== + + describe("integration: conditions flow through full pipeline", () => { + it("12.1 — default browser build of solid-js succeeds", async () => { + const result = await buildPackage("solid-js@1.9.4"); + + expect(result.contents.length).toBeGreaterThan(0); + expect(result.errors.length).toBe(0); + }, { timeout: NETWORK_TIMEOUT }); + + it("12.11 — CJS format build of uuid succeeds", async () => { + const result = await buildPackage("uuid@11.0.5", { + esbuild: { format: "cjs" }, + }); + + expect(result.contents.length).toBeGreaterThan(0); + expect(result.errors.length).toBe(0); + }, { timeout: NETWORK_TIMEOUT }); + }); +}); diff --git a/core/tests/helpers.ts b/core/tests/helpers.ts new file mode 100644 index 0000000..7272a31 --- /dev/null +++ b/core/tests/helpers.ts @@ -0,0 +1,187 @@ +/** + * Shared test helpers for bundlejs resolution tests. + * + * Provides factory functions and utilities that simplify writing + * scenario tests. Keeps individual test files focused on the + * specific scenario under test. + * + * @module + */ + +import type { BuildConfig } from "../types.ts"; +import type { BuildResult } from "../build.ts"; +import type { PackageJson } from "@bundle/utils/types"; +import type { ResolverConditionInputs } from "@bundle/utils/resolve-conditions"; + +import { build, TheFileSystem } from "../build.ts"; +import { getFile, setFile } from "../utils/filesystem.ts"; + +// Re-export resolution functions for unit testing +export { + resolveModern, + resolveLegacy, + applyPathRemapping, + applyManifestRemappings, + resolvePackageEntry, + computePeerDependencies, + computeSideEffects, + normalizeResolvedPath, + joinSubpaths, + REMAPPING_FIELDS, +} from "../utils/cdn-resolution.ts"; + +export type { + ResolverConditions, + PathRemappings, + RemappingResult, + ModernResolutionResult, + LegacyResolutionResult, + PackageResolutionResult, + PackageResolutionOptions, +} from "../utils/cdn-resolution.ts"; + +export { + getResolverConditions, + getRuntimeDefaults, + getLegacyMainFields, + isRequireContext, + detectRuntime, + getPlatformForRuntime, + KNOWN_CONDITIONS, + DEFAULT_MAIN_FIELDS, + mergeConditions, + conditionMatches, +} from "@bundle/utils/resolve-conditions"; + +export type { + ResolveRuntime, + ResolverConditionInputs, + RuntimeDefaults, +} from "@bundle/utils/resolve-conditions"; + +export { + computeEsbuildSideEffects, + compileSideEffectsMatchers, + normalizeSideEffectsPattern, + normalizePkgRelPath, + isJsLikePath, +} from "../utils/side-effects.ts"; + +// ============================================================================= +// Test helpers +// ============================================================================= + +/** + * Build a package with given config and return the result. + * + * Writes a simple entry file to the VFS and runs the full build pipeline: + * setFile → build → result + * + * **Important:** Each call gets a fresh filesystem to avoid cross-test state leakage. + * + * @param entryCode Source code for the entry file + * @param config Build config overrides (merged with sensible defaults) + * @returns The full build result + */ +export async function buildWithEntry( + entryCode: string, + config: BuildConfig = {}, +): Promise { + const fs = await TheFileSystem; + await setFile(fs, "/index.tsx", entryCode); + + const merged: BuildConfig = { + entryPoints: ["/index.tsx"], + esbuild: { + treeShaking: true, + format: "esm", + minify: true, + ...config.esbuild, + }, + ...config, + // esbuild is deep-merged above, so prevent shallow override + ...(config.esbuild ? {} : {}), + }; + + // Ensure nested esbuild config is merged + if (config.esbuild) { + merged.esbuild = { + treeShaking: true, + format: "esm", + minify: true, + ...config.esbuild, + }; + } + + return await build(merged); +} + +/** + * Shorthand: build a bare import and return the result. + * + * Generates `export * from "";` as the entry code. + */ +export async function buildPackage( + specifier: string, + config: BuildConfig = {}, +): Promise { + return buildWithEntry(`export * from "${specifier}";`, config); +} + +/** + * Check if the build output text contains a given string. + */ +export function outputContains(result: BuildResult, needle: string): boolean { + return result.contents.some((f) => f.text.includes(needle)); +} + +/** + * Check if the build output text matches a regex. + */ +export function outputMatches(result: BuildResult, pattern: RegExp): boolean { + return result.contents.some((f) => pattern.test(f.text)); +} + +/** + * Get all build output as a single string (concatenated). + */ +export function getOutputText(result: BuildResult): string { + return result.contents.map((f) => f.text).join("\n"); +} + +/** + * Create a minimal manifest (package.json) object for testing. + */ +export function manifest( + fields: Partial & { name?: string; version?: string }, +): Partial { + return { + name: fields.name ?? "test-pkg", + version: fields.version ?? "1.0.0", + ...fields, + }; +} + +/** + * Make import-statement args for getResolverConditions. + */ +export function importArgs(kind: "import-statement" | "require-call" | "entry-point" = "import-statement") { + return { kind }; +} + +/** + * Build resolver condition inputs from a compact object. + */ +export function resolveOpts(opts: ResolverConditionInputs = {}): ResolverConditionInputs { + return { + platform: "browser", + format: "esm", + ...opts, + }; +} + +/** Timeout for integration tests that hit the network */ +export const NETWORK_TIMEOUT = 120_000; + +/** Timeout for unit tests (no network) */ +export const UNIT_TIMEOUT = 5_000;