test: guard ES5 runtime APIs and type-check consumers in CI
Follow-up to the ES5 syntax fix, closing the gaps that investigation left.
An ES5-only runtime sandbox. The acorn check catches syntax, but syntax is
only half the contract: preset-env lowers syntax and never polyfills library
calls, so one `Array.prototype.includes` in the parser source compiles
cleanly, passes every test on modern Node, and throws on the browsers es5.js
exists for. The new test runs both legacy bundles in a vm context with the
post-ES5.1 globals, statics and prototype methods deleted, and asserts
bundled.js additionally installs the polyfills its README entry promises.
Includes a test that the sandbox really strips, so it cannot quietly pass
against a modern global.
A consumer type-check across every module resolution mode, run in CI against
the packed tarball. attw already checks that types *resolve* per condition;
it compiles nothing, so it cannot catch a declaration that resolves correctly
and then misdescribes the runtime. Negative cases are asserted too — the maps
must stay non-importable as named exports, which is the line index.d.mts
draws deliberately and only a failing compile can hold.
Also documents two findings that were investigated and deliberately left
alone: the bundled.js size increase is the core-js 2 -> 3 upgrade rather than
waste, and `useBuiltIns: 'usage'` would shrink it by breaking the documented
"all needed polyfills" contract; and the src/*.js ESM-in-CJS wart (publint
warnings, Yarn PnP, Node < 20.19) is longstanding and identical on 2.14.1,
with the nested-package.json fix blocked on @babel/register.
Verified by breaking each guard in turn: an ES6 API call injected into es5.js
fails the sandbox test, and an index.d.mts with its `parse` export removed
fails all three ESM resolution modes while the CJS modes correctly still pass.
2026-08-30 21:57:22 +03:00
|
|
|
import test from 'ava';
|
|
|
|
|
import fs from 'fs';
|
|
|
|
|
import path from 'path';
|
|
|
|
|
import vm from 'vm';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Runs the legacy bundles on a global object stripped back to ES5.1.
|
|
|
|
|
*
|
|
|
|
|
* `test-es5-conformance.js` checks *syntax*. This checks *runtime APIs*, which
|
|
|
|
|
* is a separate failure mode babel cannot protect against: `@babel/preset-env`
|
|
|
|
|
* lowers syntax, but without `useBuiltIns` it never polyfills library calls. A
|
|
|
|
|
* single `Array.prototype.includes` or `Object.assign` in the parser source
|
|
|
|
|
* compiles cleanly, passes every test on modern Node, and then throws
|
|
|
|
|
* `TypeError: undefined is not a function` on the old browsers `es5.js` targets.
|
|
|
|
|
*
|
|
|
|
|
* `es5.js` ships with no polyfills at all, so it has to survive here on its own.
|
|
|
|
|
* `bundled.js` carries core-js and has to install what it needs and still work.
|
|
|
|
|
*
|
|
|
|
|
* These are build outputs — run `pnpm build` before `pnpm test`.
|
|
|
|
|
*/
|
|
|
|
|
const root = path.join(__dirname, '..', '..');
|
|
|
|
|
|
|
|
|
|
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 '
|
|
|
|
|
+ '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
|
|
|
|
|
|
|
|
|
// Everything below postdates ES5.1. Not exhaustive — it covers the APIs a UA
|
|
|
|
|
// parser plausibly reaches for, which is what makes it a useful tripwire.
|
|
|
|
|
const ES6_GLOBALS = ['Promise', 'Symbol', 'Map', 'Set', 'WeakMap', 'WeakSet', 'Proxy', 'Reflect', 'globalThis', 'BigInt'];
|
|
|
|
|
const ES6_STATICS = {
|
|
|
|
|
Object: ['assign', 'entries', 'values', 'fromEntries', 'getOwnPropertySymbols', 'setPrototypeOf'],
|
|
|
|
|
Array: ['from', 'of'],
|
|
|
|
|
String: ['raw', 'fromCodePoint'],
|
|
|
|
|
Number: ['isInteger', 'isNaN', 'parseFloat', 'isFinite', 'EPSILON'],
|
|
|
|
|
Math: ['trunc', 'sign', 'log2', 'clz32'],
|
|
|
|
|
};
|
|
|
|
|
const ES6_PROTOS = {
|
|
|
|
|
Array: ['includes', 'find', 'findIndex', 'flat', 'flatMap', 'fill', 'copyWithin', 'at'],
|
|
|
|
|
String: ['includes', 'startsWith', 'endsWith', 'repeat', 'padStart', 'padEnd', 'trimStart', 'trimEnd', 'matchAll', 'at', 'normalize', 'codePointAt'],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function createEs5Context() {
|
|
|
|
|
const context = vm.createContext({});
|
|
|
|
|
// UMD bundles look for a global; `self` is the browser-shaped one.
|
|
|
|
|
vm.runInContext('this.self = this;', context);
|
|
|
|
|
const deletions = []
|
|
|
|
|
.concat(ES6_GLOBALS.map((g) => `this.${g}`))
|
|
|
|
|
.concat(...Object.entries(ES6_STATICS).map(([o, keys]) => keys.map((k) => `${o}.${k}`)))
|
|
|
|
|
.concat(...Object.entries(ES6_PROTOS).map(([o, keys]) => keys.map((k) => `${o}.prototype.${k}`)))
|
|
|
|
|
.map((ref) => `try { delete ${ref}; } catch (e) {}`)
|
|
|
|
|
.join('\n');
|
|
|
|
|
vm.runInContext(deletions, context);
|
|
|
|
|
return context;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test('the ES5 sandbox actually strips the modern APIs', (t) => {
|
|
|
|
|
// Guards the guard: if stripping silently stopped working, every assertion
|
|
|
|
|
// below would pass against a fully modern global and prove nothing.
|
|
|
|
|
const context = createEs5Context();
|
|
|
|
|
t.is(vm.runInContext('typeof Promise', context), 'undefined');
|
|
|
|
|
t.is(vm.runInContext('typeof Object.assign', context), 'undefined');
|
|
|
|
|
t.is(vm.runInContext('typeof [].includes', context), 'undefined');
|
|
|
|
|
t.is(vm.runInContext('typeof "".startsWith', context), 'undefined');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
['es5.js', 'bundled.js'].forEach((file) => {
|
|
|
|
|
test(`${file} runs on an ES5-only global`, (t) => {
|
|
|
|
|
const context = createEs5Context();
|
|
|
|
|
const source = fs.readFileSync(path.join(root, file), 'utf8');
|
|
|
|
|
|
|
|
|
|
t.notThrows(() => vm.runInContext(source, context), `${file} threw while loading`);
|
|
|
|
|
t.is(vm.runInContext('typeof this.bowser', context), 'function');
|
|
|
|
|
|
|
|
|
|
context.__ua = UA;
|
|
|
|
|
t.is(vm.runInContext('this.bowser.parse(this.__ua).browser.name', context), 'Chrome');
|
|
|
|
|
t.is(vm.runInContext('this.bowser.parse(this.__ua).os.name', context), 'macOS');
|
|
|
|
|
t.true(vm.runInContext('this.bowser.getParser(this.__ua).satisfies({ chrome: ">100" })', context));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
fix: getBrandVersion throws on ES5 browsers; make test:types runnable
Both from Qodo review on #632.
`Parser.getBrandVersion()` called `Array.prototype.find` directly. That is
ES6, and `es5.js` ships no polyfills, so a documented public API threw
`TypeError: this._hints.brands.find is not a function` on exactly the
browsers that bundle exists to serve. `bundled.js` was unaffected because
core-js polyfills it. Every other lookup in parser.js already goes through
`Utils.find`, which guards on `Array.prototype.find` and falls back to a
loop; this one call site had missed it. `hasBrand()` next to it uses
`Array.prototype.some`, which is ES5, and is fine.
The ES5 runtime guard added alongside it did not catch this because nothing
exercised the Client Hints path. It now does, for both bundles.
That test builds its hints object from a script evaluated *inside* the vm
context rather than assigning one onto it. A first attempt assigned a
host-realm object and passed against the live bug: an array created in the
host realm keeps the host's `Array.prototype`, so its `find` survives the
sandbox's delete. A real browser hands the parser a same-realm array.
`pnpm test:types` passed no tarball, and check.mjs exited 1 when the argument
was absent, so the advertised command could never run — CI only passed
because it packs and invokes the file directly. The argument is now optional:
without one the package is assembled from the `files` allowlist in the
working tree. `npm pack` cannot be used for this, as package.json carries no
`version` until release time and npm refuses to pack without one. Missing
build output is reported as such rather than as a type error.
Verified by reverting the parser fix: the es5.js Client Hints test fails with
the original TypeError while the bundled.js one still passes, which is the
correct split. Parse output is unchanged — all four artifacts still agree
across the 270-UA corpus, and Client Hints still resolve on modern runtimes.
2026-08-30 22:38:01 +03:00
|
|
|
/**
|
|
|
|
|
* Client Hints go down a different code path than `parse()` — `isBrandVersion`
|
|
|
|
|
* and `getBrandVersion` reach into `_hints.brands` directly — so the checks
|
|
|
|
|
* above never touch them. `getBrandVersion` used `Array.prototype.find`, which
|
|
|
|
|
* is ES6, and threw on exactly the browsers `es5.js` exists for.
|
|
|
|
|
*
|
|
|
|
|
* The inputs are built by a script evaluated *inside* the context rather than
|
|
|
|
|
* assigned onto it. An array created in the host realm keeps the host's
|
|
|
|
|
* `Array.prototype`, so its `find` survives the sandbox's delete and the test
|
|
|
|
|
* passes against a bug that is still there. A real browser hands the parser a
|
|
|
|
|
* same-realm array, which is what this reproduces.
|
|
|
|
|
*/
|
|
|
|
|
['es5.js', 'bundled.js'].forEach((file) => {
|
|
|
|
|
test(`${file} handles Client Hints on an ES5-only global`, (t) => {
|
|
|
|
|
const context = createEs5Context();
|
|
|
|
|
vm.runInContext(fs.readFileSync(path.join(root, file), 'utf8'), context);
|
|
|
|
|
|
|
|
|
|
const result = vm.runInContext(`
|
|
|
|
|
var ua = ${JSON.stringify(UA)};
|
|
|
|
|
var hints = {
|
|
|
|
|
brands: [
|
|
|
|
|
{ brand: 'Chromium', version: '131' },
|
|
|
|
|
{ brand: 'Google Chrome', version: '131' },
|
|
|
|
|
],
|
|
|
|
|
mobile: false,
|
|
|
|
|
platform: 'macOS',
|
|
|
|
|
};
|
|
|
|
|
var parser = this.bowser.getParser(ua, false, hints);
|
|
|
|
|
({
|
|
|
|
|
brandVersion: parser.getBrandVersion('Google Chrome'),
|
|
|
|
|
missingBrand: parser.getBrandVersion('Firefox'),
|
|
|
|
|
hasBrand: parser.hasBrand('Google Chrome'),
|
|
|
|
|
hasOtherBrand: parser.hasBrand('Firefox'),
|
|
|
|
|
hints: !!parser.getHints(),
|
|
|
|
|
})
|
|
|
|
|
`, context);
|
|
|
|
|
|
|
|
|
|
t.is(result.brandVersion, '131');
|
|
|
|
|
t.is(result.missingBrand, undefined);
|
|
|
|
|
t.true(result.hasBrand);
|
|
|
|
|
t.false(result.hasOtherBrand);
|
|
|
|
|
t.true(result.hints);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
test: guard ES5 runtime APIs and type-check consumers in CI
Follow-up to the ES5 syntax fix, closing the gaps that investigation left.
An ES5-only runtime sandbox. The acorn check catches syntax, but syntax is
only half the contract: preset-env lowers syntax and never polyfills library
calls, so one `Array.prototype.includes` in the parser source compiles
cleanly, passes every test on modern Node, and throws on the browsers es5.js
exists for. The new test runs both legacy bundles in a vm context with the
post-ES5.1 globals, statics and prototype methods deleted, and asserts
bundled.js additionally installs the polyfills its README entry promises.
Includes a test that the sandbox really strips, so it cannot quietly pass
against a modern global.
A consumer type-check across every module resolution mode, run in CI against
the packed tarball. attw already checks that types *resolve* per condition;
it compiles nothing, so it cannot catch a declaration that resolves correctly
and then misdescribes the runtime. Negative cases are asserted too — the maps
must stay non-importable as named exports, which is the line index.d.mts
draws deliberately and only a failing compile can hold.
Also documents two findings that were investigated and deliberately left
alone: the bundled.js size increase is the core-js 2 -> 3 upgrade rather than
waste, and `useBuiltIns: 'usage'` would shrink it by breaking the documented
"all needed polyfills" contract; and the src/*.js ESM-in-CJS wart (publint
warnings, Yarn PnP, Node < 20.19) is longstanding and identical on 2.14.1,
with the nested-package.json fix blocked on @babel/register.
Verified by breaking each guard in turn: an ES6 API call injected into es5.js
fails the sandbox test, and an index.d.mts with its `parse` export removed
fails all three ESM resolution modes while the CJS modes correctly still pass.
2026-08-30 21:57:22 +03:00
|
|
|
test('bundled.js installs the polyfills it promises', (t) => {
|
|
|
|
|
// The README tells consumers to reach for bundled.js when they have no
|
|
|
|
|
// polyfills of their own, so it has to actually populate the environment.
|
|
|
|
|
const context = createEs5Context();
|
|
|
|
|
vm.runInContext(fs.readFileSync(path.join(root, 'bundled.js'), 'utf8'), context);
|
|
|
|
|
t.is(vm.runInContext('typeof Promise', context), 'function');
|
|
|
|
|
t.is(vm.runInContext('typeof Object.assign', context), 'function');
|
|
|
|
|
t.is(vm.runInContext('typeof [].includes', context), 'function');
|
|
|
|
|
});
|