Files
oxc-project__oxc/napi/minify/test/minify.test.ts
Samuel Attard 9ef028c2ad feat(codegen): add ascii_only option (#25994)
Closes #17068. Supersedes #18095 / #18118.

Adds `CodegenOptions::ascii_only` (and `codegen.asciiOnly` in
`oxc_minify_napi`): escape every non-ASCII character in string literals,
untagged template literals, regexp literals, directives and identifier
names so the output is 7-bit clean — like esbuild `--charset=ascii` /
terser `format.ascii_only`. Off by default; the default path is
untouched (codegen bench and conformance unchanged).

```js
// Input
let café = "naïve 😀";
let re = /[–—]/u;

// Output with ascii_only: true
let caf\u00E9 = "na\u00EFve \u{1F600}";
let re = /[\u2013\u2014]/u;
```

Characters above U+FFFF use ES2015 code-point escapes. Regex patterns
use escaped surrogate pairs instead; RegExp.prototype.source reflects
the escaped spelling.
Tagged template quasis, JSX names/text/attribute strings, hashbangs and
preserved comments may still contain non-ASCII characters. JavaScript
expressions inside tagged templates and JSX are escaped normally.

Performance; with the option disabled, codegen codspeed benchmarks are
withing noise (<1%). With the option on, performance is only affected
for files that contain non ascii characters.

---

Our use case is a large Vite/Rolldown app served through proxies that
mangle non-ASCII JS (previously handled by terser's `ascii_only`);
#17068 also lists deno_core snapshots and Chrome extension content
scripts.

AI-assisted (Claude Code); I reviewed the code, wrote the tests, and
have been running this in production builds via a vendored
`oxc_codegen`.

---------

Co-authored-by: Samuel Attard <6634592+MarshallOfSound@users.noreply.github.com>
Co-authored-by: Cameron Clark <cameron.clark@hey.com>
Co-authored-by: Dunqing <dengqing0821@gmail.com>
2026-09-10 17:10:02 +01:00

371 lines
12 KiB
TypeScript

import { Worker } from "node:worker_threads";
import { describe, expect, it } from "vitest";
import { minify, minifySync } from "../index";
describe("simple", () => {
const code = "/*! legal comment */\nfunction foo() { var bar; bar(undefined) } foo();";
it("matches output", () => {
const ret = minifySync("test.js", code, { sourcemap: true });
expect(ret.code).toEqual("function foo(){var e;e(void 0)}foo();");
expect(ret.errors.length).toBe(0);
expect(ret.map).toMatchObject({
names: ["bar"],
sources: ["test.js"],
sourcesContent: [code],
version: 3,
});
});
it("can turn off everything", () => {
const ret = minifySync("test.js", code, {
compress: false,
mangle: false,
codegen: { removeWhitespace: false },
});
expect(ret.code).toBe("function foo() {\n\tvar bar;\n\tbar(undefined);\n}\nfoo();\n");
});
it.each([
// spellchecker:disable-next-line
[true, undefined, "export let caf\\u00E9=`na\\u00EFve \\u2615`;"],
// spellchecker:disable-next-line
[true, true, "export let caf\\u00E9=`na\\u00EFve \\u2615`;"],
// spellchecker:disable-next-line
[true, false, 'export let caf\\u00E9 = "na\\u00EFve \\u2615";\n'],
[false, true, "export let café=`naïve ☕`;"],
[false, false, 'export let café = "naïve ☕";\n'],
[undefined, true, "export let café=`naïve ☕`;"],
[undefined, false, 'export let café = "naïve ☕";\n'],
] as const)(
"codegen.asciiOnly=%s, removeWhitespace=%s",
(asciiOnly, removeWhitespace, expected) => {
const ret = minifySync("test.js", "export let café = 'naïve ☕';", {
codegen: { asciiOnly, removeWhitespace },
});
expect(ret.code).toBe(expected);
expect(ret.errors.length).toBe(0);
},
);
it("preserves Unicode legal comments with codegen.asciiOnly", () => {
const ret = minifySync("test.js", "/*! café 😀 */\nexport let x = 'é';", {
codegen: { asciiOnly: true, legalComments: "inline" },
});
expect(ret.code).toBe("/*! café 😀 */\nexport let x=`\\u00E9`;");
expect(ret.errors.length).toBe(0);
});
it("defaults to esnext", () => {
const code = "try { foo } catch (e) {}";
const ret = minifySync("test.js", code);
expect(ret.code).toBe("try{foo}catch{}");
});
it("returns parser error", () => {
const code = "const";
const ret = minifySync("test.js", code);
expect(ret.code).toBe("");
expect(ret.errors.length).toBe(1);
});
it("supports drop_labels option", () => {
const code = "PURE: { foo(); bar(); } OTHER: { baz(); }";
const ret = minifySync("test.js", code, {
compress: { dropLabels: ["PURE"] },
});
expect(ret.code).toBe("OTHER:baz();");
expect(ret.errors.length).toBe(0);
});
});
describe("treeshake options", () => {
it("respects annotations by default", () => {
const code = "/* @__PURE__ */ foo(); bar();";
const ret = minifySync("test.js", code, {
compress: {},
});
// The @__PURE__ annotated call should be removed
expect(ret.code).toBe("bar();");
expect(ret.errors.length).toBe(0);
});
it("can disable annotations", () => {
const code = "/* @__PURE__ */ foo(); bar();";
const ret = minifySync("test.js", code, {
compress: {
treeshake: {
annotations: false,
},
},
});
// With annotations disabled, @__PURE__ is not respected
expect(ret.code).toBe("foo(),bar();");
expect(ret.errors.length).toBe(0);
});
it("supports manual pure functions", () => {
const code = "foo(); bar(); baz();";
const ret = minifySync("test.js", code, {
compress: {
treeshake: {
manualPureFunctions: ["foo", "baz"],
},
},
});
// foo and baz should be removed as they're marked as pure, bar should remain
expect(ret.code).toBe("bar();");
expect(ret.errors.length).toBe(0);
});
it("supports propertyReadSideEffects as boolean", () => {
const code = "const x = obj.prop; foo();";
const ret = minifySync("test.js", code, {
compress: {
treeshake: {
propertyReadSideEffects: false,
},
},
});
expect(ret.errors.length).toBe(0);
});
it('supports propertyReadSideEffects as "always"', () => {
const code = "const x = obj.prop; foo();";
const ret = minifySync("test.js", code, {
compress: {
treeshake: {
propertyReadSideEffects: "always",
},
},
});
expect(ret.errors.length).toBe(0);
});
it("rejects invalid propertyReadSideEffects string value", () => {
const code = "const x = obj.prop; foo();";
const ret = minifySync("test.js", code, {
compress: {
treeshake: {
propertyReadSideEffects: "invalid" as any,
},
},
});
expect(ret.errors.length).toBe(1);
expect(ret.errors[0].message).toContain("Invalid propertyReadSideEffects value");
});
});
describe("async minify", () => {
const code = "/*! legal comment */\nfunction foo() { var bar; bar(undefined) } foo();";
it("produces same result as sync", async () => {
const syncResult = minifySync("test.js", code, { sourcemap: true });
const asyncResult = await minify("test.js", code, { sourcemap: true });
expect(asyncResult.code).toEqual(syncResult.code);
expect(asyncResult.errors.length).toBe(syncResult.errors.length);
expect(asyncResult.map).toMatchObject(syncResult.map!);
});
});
describe("identifier mangling", () => {
it("mangles class private members", () => {
const ret = minifySync(
"test.js",
"class Foo { #privateField = 1; method() { return this.#privateField; } }",
{ compress: false, mangle: true },
);
expect(ret.errors).toHaveLength(0);
expect(ret.code).toBe("class Foo{#e=1;method(){return this.#e}}");
});
});
describe("property mangling", () => {
it("is independent from identifier mangling and returns a reusable cache", () => {
const ret = minifySync("test.js", "let local; obj._often; obj._rare; obj._often;", {
compress: false,
mangle: false,
mangleProps: { include: /^_/ },
});
expect(ret.errors).toHaveLength(0);
expect(ret.code).toBe("let local;obj.e;obj.t;obj.e;");
expect(ret.mangleCache).toEqual({ _often: "e", _rare: "t" });
});
it("keeps quoted occurrences unless quoted is enabled", () => {
const source = "obj._field; obj['_field'];";
expect(
minifySync("test.js", source, {
compress: false,
mangle: false,
mangleProps: { include: /^_/ },
}).code,
).toBe("obj.e;obj[`_field`];");
expect(
minifySync("test.js", source, {
compress: false,
mangle: false,
mangleProps: { include: /^_/, quoted: true },
}).code,
).toBe("obj.e;obj.e;");
});
it("honors false entries and duplicate custom targets", () => {
const ret = minifySync("test.js", "obj._first; obj._second; obj._keep;", {
compress: false,
mangle: false,
mangleProps: {
include: /^_/,
cache: { _first: "A", _second: "A", _keep: false },
},
});
expect(ret.code).toBe("obj.A;obj.A;obj._keep;");
expect(ret.mangleCache).toEqual({ _first: "A", _second: "A", _keep: false });
});
it("keeps cache keys out of automatic targets across round trips", () => {
const first = minifySync("test.js", "obj.foo;", {
compress: false,
mangle: false,
mangleProps: { include: /./, cache: { e: false } },
});
expect(first.code).toBe("obj.t;");
expect(first.mangleCache).toEqual({ e: false, foo: "t" });
const second = minifySync("test.js", "obj.e; obj.foo;", {
compress: false,
mangle: false,
mangleProps: { include: /./, cache: first.mangleCache },
});
expect(second.code).toBe("obj.e;obj.t;");
expect(second.mangleCache).toEqual(first.mangleCache);
});
it("keeps every property readable in debug mode", () => {
const ret = minifySync("test.js", "obj._alpha; obj._beta; obj._gamma;", {
compress: false,
mangle: false,
mangleProps: { include: /^_/, debug: true },
});
expect(ret.code).toBe("obj._$_alpha$_;obj._$_beta$_;obj._$_gamma$_;");
});
it("honors exclude and reserved names", () => {
const ret = minifySync("test.js", "obj._skipOne; obj._reserved; obj._mangle;", {
compress: false,
mangle: false,
mangleProps: {
include: /^_/,
exclude: /^_skip(?:One|Two)$/,
reserved: ["_reserved"],
},
});
expect(ret.errors).toHaveLength(0);
expect(ret.code).toBe("obj._skipOne;obj._reserved;obj.e;");
});
it("preserves the case-insensitive RegExp flag", () => {
const ret = minifySync("test.js", "obj._FIELD; obj._other;", {
compress: false,
mangle: false,
mangleProps: { include: /^_field$/i },
});
expect(ret.errors).toHaveLength(0);
expect(ret.code).toBe("obj.e;obj._other;");
});
it("works through the async API", async () => {
const ret = await minify("test.js", "obj._field; obj._field;", {
compress: false,
mangle: false,
mangleProps: { include: /^_/ },
});
expect(ret.errors).toHaveLength(0);
expect(ret.code).toBe("obj.e;obj.e;");
expect(ret.mangleCache).toEqual({ _field: "e" });
});
it("does not mangle properties when parsing has errors", () => {
const ret = minifySync("test.js", "const invalid; obj._field;", {
compress: false,
mangle: false,
mangleProps: { include: /^_/ },
});
expect(ret.errors).toHaveLength(1);
expect(ret.code).toBe("const invalid;obj._field;");
expect(ret.mangleCache).toBeUndefined();
});
it("returns cache entries in stable lexical order", () => {
const ret = minifySync(
"test.js",
"obj._zeta; obj._alpha; obj._middle; obj._beta; obj._omega;",
{
compress: false,
mangle: false,
mangleProps: { include: /^_/ },
},
);
expect(Object.keys(ret.mangleCache!)).toEqual([
"_alpha",
"_beta",
"_middle",
"_omega",
"_zeta",
]);
});
it("requires RegExp filters", () => {
expect(() =>
minifySync("test.js", "obj._field;", {
mangleProps: { include: "^_" as any },
}),
).toThrow(/RegExp/);
});
it.each([
[{ include: /^(?!_keep)/ }, "Invalid mangleProps.include regex"],
[{ include: /^_/g }, "Invalid mangleProps.include regex"],
[{ include: /^_/, cache: { _field: true as any } }, "expected a string or false"],
[
{ include: /^_/, cache: { ["__proto__"]: false } },
"this original name cannot be used as a cache key",
],
[{ include: /^_/, cache: { _field: "not-valid" } }, "must be an IdentifierName"],
[{ include: /^_/, cache: { _field: "__proto__" } }, "must be an IdentifierName"],
[
{ include: /^_/, cache: { _field: "constructor" } },
"other than '__proto__', 'constructor', or 'prototype'",
],
[
{ include: /^_/, cache: { _field: "prototype" } },
"other than '__proto__', 'constructor', or 'prototype'",
],
])("rejects invalid options", (mangleProps, message) => {
const ret = minifySync("test.js", "obj._field;", { mangleProps: mangleProps as any });
expect(ret.errors).toHaveLength(1);
expect(ret.errors[0].message).toContain(message);
expect(ret.mangleCache).toBeUndefined();
});
});
describe("worker", () => {
it("should run", async () => {
const code = await new Promise((resolve, reject) => {
const worker = new Worker("./test/worker.mjs");
worker.on("error", (err) => {
reject(err);
});
worker.on("exit", (code) => {
resolve(code);
});
});
expect(code).toBe(0);
});
});