1
0
mirror of https://github.com/lancedikson/bowser synced 2026-09-22 12:05:23 +00:00

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.
This commit is contained in:
naorpeled
2026-08-30 21:57:22 +03:00
parent 2a2ba39ddb
commit 4f59e5d25e
8 changed files with 288 additions and 2 deletions

View File

@@ -128,3 +128,13 @@ jobs:
# of bowser, and preserved here on purpose.
- name: Are the types wrong?
run: pnpm exec attw --pack . --profile node16 --entrypoints .
# attw checks that the types *resolve* to the right file per condition.
# This compiles a real consumer against the packed tarball in every
# module resolution mode, which is what catches a declaration that
# resolves fine but does not match the runtime.
- name: Type-check consumers
run: |
mkdir -p tarball
npm pack --pack-destination ./tarball
node test/types/check.mjs tarball/*.tgz

View File

@@ -118,7 +118,8 @@
],
"files": [
"test/**/*.js",
"!test/package/**"
"!test/package/**",
"!test/types/**"
]
},
"bugs": {
@@ -138,7 +139,8 @@
"test:watch": "ava --watch",
"test:package": "node test/package/smoke.cjs",
"coverage": "nyc report --reporter=text-lcov | coveralls",
"generate-docs": "jsdoc -c jsdoc.json"
"generate-docs": "jsdoc -c jsdoc.json",
"test:types": "node test/types/check.mjs"
},
"license": "MIT",
"packageManager": "pnpm@11.18.0"

View File

@@ -0,0 +1,88 @@
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));
});
});
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');
});

View File

@@ -77,6 +77,14 @@ check('constant maps are exposed', function () {
// The src/*.js files are ES module sources, so they resolve but do not execute
// under require(). Bundlers are the real consumer here. Assert resolution only.
//
// This is also why publint warns on every `pkg.exports["./src/*"]` entry, and
// why these paths fail under Yarn PnP (ERR_REQUIRE_CYCLE_MODULE) and on Node
// below 20.19, which has no module-syntax detection. Verified identical on
// 2.14.1, so it is longstanding rather than new. The obvious fix — a nested
// `src/package.json` with `"type": "module"` — would stop `@babel/register`
// from loading `src/` and take the whole AVA suite with it, so the wart stays
// until the test tooling moves off `require()` hooks.
[
'bowser.js', 'constants.js', 'parser.js', 'parser-browsers.js',
'parser-engines.js', 'parser-os.js', 'parser-platforms.js', 'utils.js',

135
test/types/check.mjs Normal file
View File

@@ -0,0 +1,135 @@
/**
* Type-checks a real consumer against the *packed* package under every module
* resolution mode TypeScript offers.
*
* node test/types/check.mjs <path-to-bowser-x.y.z.tgz>
*
* `attw` already runs in CI, but it answers a narrower question: whether the
* types *resolve* to the right file for each condition. It does not compile
* anything, so it cannot catch a declaration that resolves fine and then fails
* to describe the runtime — a missing member, a wrong signature, or a named
* export declared in `index.d.mts` that `bowser.mjs` does not actually have.
*
* Both directions are asserted. The negative cases matter as much as the
* positive ones: `index.d.mts` deliberately omits `BROWSER_MAP` and friends as
* named exports because importing them that way throws at runtime, and only a
* compile that is expected to *fail* can hold that line.
*
* Run against the tarball rather than the repo so the exports map, the
* `types`/`typesVersions` fields and the published file list are all exercised
* exactly as a consumer sees them.
*/
import cp from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.join(here, '..', '..');
const tsc = path.join(repoRoot, 'node_modules', '.bin', 'tsc');
const tarball = process.argv[2];
if (!tarball || !fs.existsSync(tarball)) {
console.error('usage: node test/types/check.mjs <path-to-tarball.tgz>');
process.exit(1);
}
/** Each mode a real consumer can be configured with. */
const MODES = [
{ name: 'node10 (CJS)', moduleResolution: 'node10', module: 'commonjs', type: 'commonjs', fixture: 'consumer-cjs.ts' },
{ name: 'node16 (CJS)', moduleResolution: 'node16', module: 'node16', type: 'commonjs', fixture: 'consumer-cjs.ts' },
{ name: 'node16 (ESM)', moduleResolution: 'node16', module: 'node16', type: 'module', fixture: 'consumer-esm.ts' },
{ name: 'nodenext (ESM)', moduleResolution: 'nodenext', module: 'nodenext', type: 'module', fixture: 'consumer-esm.ts' },
{ name: 'bundler', moduleResolution: 'bundler', module: 'esnext', type: 'module', fixture: 'consumer-esm.ts' },
];
/**
* Imports that must NOT compile, because the runtime does not provide them.
* Keeps `index.d.mts` honest — if any of these starts compiling, the types are
* promising something `bowser.mjs` will not deliver.
*/
const MUST_NOT_COMPILE = [
['BROWSER_MAP is not a named export', 'import { BROWSER_MAP } from "bowser"; export default BROWSER_MAP;'],
['OS_MAP is not a named export', 'import { OS_MAP } from "bowser"; export default OS_MAP;'],
['ENGINE_MAP is not a named export', 'import { ENGINE_MAP } from "bowser"; export default ENGINE_MAP;'],
['PLATFORMS_MAP is not a named export', 'import { PLATFORMS_MAP } from "bowser"; export default PLATFORMS_MAP;'],
['Parser is a type, not a value', 'import { Parser } from "bowser"; export default new Parser("x");'],
];
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bowser-types-'));
const modules = path.join(tmp, 'node_modules');
fs.mkdirSync(modules, { recursive: true });
cp.execFileSync('tar', ['xzf', path.resolve(tarball), '-C', modules]);
fs.renameSync(path.join(modules, 'package'), path.join(modules, 'bowser'));
/** Runs tsc over a single file in a project configured for `mode`. */
function typeCheck(mode, fileName, source) {
// Nested one level under `tmp`, so resolution walks up into tmp/node_modules
// the way a real consumer's does. `paths` would short-circuit the exports map.
const dir = path.join(tmp, `case-${Math.abs(hash(mode.name + fileName))}`);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'app.ts'), source);
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({
name: 'consumer', version: '1.0.0', private: true, type: mode.type,
}));
fs.writeFileSync(path.join(dir, 'tsconfig.json'), JSON.stringify({
compilerOptions: {
strict: true,
noEmit: true,
// Do not skip: a broken .d.ts in the package itself must fail the check.
skipLibCheck: false,
target: 'es2020',
module: mode.module,
moduleResolution: mode.moduleResolution,
esModuleInterop: true,
resolveJsonModule: true,
types: [],
},
files: ['app.ts'],
}));
const result = cp.spawnSync(tsc, ['-p', 'tsconfig.json'], { cwd: dir, encoding: 'utf8' });
return { ok: result.status === 0, output: `${result.stdout || ''}${result.stderr || ''}`.trim() };
}
function hash(s) {
let h = 0;
for (let i = 0; i < s.length; i += 1) { h = ((h << 5) - h + s.charCodeAt(i)) | 0; }
return h;
}
let failures = 0;
console.log(`Type-checking consumers against ${path.basename(tarball)}\n`);
for (const mode of MODES) {
const source = fs.readFileSync(path.join(here, 'fixtures', mode.fixture), 'utf8');
const { ok, output } = typeCheck(mode, mode.fixture, source);
if (ok) {
console.log(` ok ${mode.name.padEnd(16)} ${mode.fixture}`);
} else {
failures += 1;
console.log(` FAIL ${mode.name.padEnd(16)} ${mode.fixture}\n${output.replace(/^/gm, ' ')}`);
}
}
console.log('');
// Only needs one ESM-shaped mode; the .d.mts is what is under test.
const negativeMode = MODES.find((m) => m.name === 'node16 (ESM)');
for (const [label, source] of MUST_NOT_COMPILE) {
const { ok } = typeCheck(negativeMode, `negative-${label}`, source);
if (ok) {
failures += 1;
console.log(` FAIL types accept something the runtime rejects: ${label}`);
} else {
console.log(` ok rejected: ${label}`);
}
}
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* best effort */ }
console.log('');
if (failures) {
console.error(`${failures} type check(s) failed`);
process.exit(1);
}
console.log(`all ${MODES.length + MUST_NOT_COMPILE.length} type checks passed`);

View File

@@ -0,0 +1,15 @@
// A CommonJS consumer: default import via esModuleInterop, types reached
// through the `export =` namespace. This is what `moduleResolution: node10`
// and `node16` (from CJS) consumers write.
import Bowser from 'bowser';
const UA = 'Mozilla/5.0 (Macintosh) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
const result: Bowser.Parser.ParsedResult = Bowser.parse(UA);
const parser: Bowser.Parser.Parser = Bowser.getParser(UA);
const name: string | undefined = result.browser.name;
const satisfies: boolean | undefined = parser.satisfies({ chrome: '>100' });
const hints: Bowser.ClientHints = { mobile: false };
const maps: Record<string, string> = Bowser.BROWSER_MAP;
export { name, satisfies, hints, maps };

View File

@@ -0,0 +1,17 @@
// An ES module consumer: default plus named imports, types imported as types.
// This is `moduleResolution: node16` (from ESM), `nodenext` and `bundler`.
import Bowser, { parse, getParser } from 'bowser';
import type { ParsedResult, Parser, ClientHints, checkTree } from 'bowser';
const UA = 'Mozilla/5.0 (Macintosh) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
const result: ParsedResult = parse(UA);
const parser: Parser = getParser(UA);
const tree: checkTree = { chrome: '>100' };
const satisfies: boolean | undefined = parser.satisfies(tree);
const hints: ClientHints = { mobile: false };
// BROWSER_MAP is deliberately not a named export of bowser.mjs — it only
// exists as a static on the class. Reaching it any other way must not compile.
const maps: Record<string, string> = Bowser.BROWSER_MAP;
export { result, satisfies, hints, maps };

View File

@@ -23,6 +23,17 @@ const legacyTargets = {
/**
* `useBuiltIns: false` for `es5.js` (syntax transpilation only) and `'entry'`
* for `bundled.js`, which expands the `core-js/stable` import in its entry.
*
* `'entry'` is why `bundled.js` grew from 124 kB to 174 kB when it stopped
* being built from the deprecated `@babel/polyfill`. That package was core-js
* **2**; `core-js/stable` is core-js **3**, whose stable surface is genuinely
* larger — `globalThis`, `Object.fromEntries` and `URLSearchParams` are all
* new here. The extra weight is the upgrade, not waste.
*
* Switching to `useBuiltIns: 'usage'` would shrink the bundle a long way, and
* would be wrong: the README tells consumers to reach for `bundled.js`
* precisely when they have no polyfills of their own, so it has to keep
* shipping the full payload rather than only what bowser itself calls.
*/
const legacyBabel = (useBuiltIns: false | 'entry') => babel({
presets: [['@babel/preset-env', {