diff --git a/.eslintrc.yml b/.eslintrc.yml deleted file mode 100644 index 570fefd..0000000 --- a/.eslintrc.yml +++ /dev/null @@ -1,123 +0,0 @@ -env: - amd: true - browser: true - commonjs: true - -rules: - accessor-pairs: 2 - array-callback-return: 2 - block-scoped-var: 0 - class-methods-use-this: 2 - complexity: 0 - consistent-return: 2 - curly: 0 - default-case: 2 - dot-location: 2 - dot-notation: 0 - eqeqeq: 2 - for-direction: 2 - getter-return: 2 - guard-for-in: 0 - init-declarations: 0 - no-alert: 2 - no-await-in-loop: 2 - no-caller: 2 - no-case-declarations: 2 - no-catch-shadow: 2 - no-compare-neg-zero: 2 - no-cond-assign: 2 - no-console: 0 - no-constant-condition: 2 - no-control-regex: 0 - no-debugger: 2 - no-delete-var: 2 - no-div-regex: 2 - no-dupe-args: 2 - no-dupe-keys: 2 - no-duplicate-case: 2 - no-else-return: 2 - no-empty-character-class: 2 - no-empty-function: 2 - no-empty-pattern: 2 - no-empty: 2 - no-eq-null: 2 - no-eval: 2 - no-ex-assign: 2 - no-extend-native: 2 - no-extra-bind: 2 - no-extra-boolean-cast: 2 - no-extra-label: 2 - no-extra-parens: 2 - no-extra-semi: 2 - no-fallthrough: 2 - no-floating-decimal: 2 - no-func-assign: 2 - no-global-assign: 2 - no-implicit-coercion: 2 - no-implicit-globals: 2 - no-implied-eval: 2 - no-inner-declarations: 2 - no-invalid-regexp: 2 - no-invalid-this: 2 - no-irregular-whitespace: 2 - no-iterator: 2 - no-label-var: 2 - no-labels: 2 - no-lone-blocks: 2 - no-loop-func: 2 - no-magic-numbers: 0 - no-multi-spaces: 2 - no-multi-str: 2 - no-new-func: 0 - no-new-wrappers: 2 - no-new: 2 - no-obj-calls: 2 - no-octal-escape: 2 - no-octal: 2 - no-param-reassign: 0 - no-proto: 0 - no-prototype-builtins: 2 - no-redeclare: 2 - no-regex-spaces: 2 - no-restricted-globals: 2 - no-restricted-properties: 2 - no-return-assign: 2 - no-return-await: 2 - no-script-url: 2 - no-self-assign: 2 - no-self-compare: 2 - no-sequences: 2 - no-shadow-restricted-names: 2 - no-shadow: 0 - no-sparse-arrays: 2 - no-template-curly-in-string: 2 - no-throw-literal: 2 - no-undef-init: 2 - no-undef: 2 - no-undefined: 0 - no-unexpected-multiline: 2 - no-unmodified-loop-condition: 2 - no-unreachable: 2 - no-unsafe-finally: 2 - no-unsafe-negation: 2 - no-unused-expressions: 0 - no-unused-labels: 2 - no-unused-vars: 2 - no-use-before-define: 0 - no-useless-call: 2 - no-useless-concat: 2 - no-useless-escape: 2 - no-useless-return: 2 - no-void: 2 - no-warning-comments: 2 - no-with: 2 - prefer-promise-reject-errors: 2 - radix: 2 - require-await: 2 - strict: 0 - use-isnan: 2 - valid-jsdoc: 2 - valid-typeof: 2 - vars-on-top: 0 - wrap-iife: 0 - yoda: 2 diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 0873017..7cf62dc 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -3,26 +3,42 @@ name: nwsapi on: push: branches: - - main + - master pull_request: branches: - - main + - master jobs: - build: + test: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v7 + + - name: Setup pnpm + uses: pnpm/action-setup@v6 - name: Setup Node.js - uses: actions/setup-node@v2 + uses: actions/setup-node@v7 with: - node-version: '14' + node-version: '24' + cache: pnpm - name: Install dependencies - run: npm install + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm run lint + + - name: Clone upstream test suites (sparse + shallow) + run: pnpm run upstream:clone + + - name: Verify upstream pins + run: pnpm run upstream:verify + + - name: Install Playwright browsers + run: pnpm exec playwright install --with-deps chromium - name: Run tests - run: npm testIs the NodeJS yaml configuration needed in my "nwsapi" repository ? + run: pnpm test diff --git a/.gitignore b/.gitignore index 5be2eea..2d1ec32 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,14 @@ node_modules/ package-lock.json yarn.lock + +# pristine upstream checkouts (pin of record lives in .gitmodules) +# anchored to repo root so test/upstream/ stays tracked +/upstream/ + +# playwright artifacts +test-results/ +playwright-report/ + +# build output +dist/nwsapi.min.js diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..a1d05f7 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,11 @@ +# wpt-7aed663 sha256:9954c30501783f0c12ea5026a94cf988c8c5d2ac3ed51279cbfdd7476f92c393 +[submodule "upstream/wpt"] + ignore = dirty + ref = 7aed6630812b20e6eec2a2e40594f8dfda036e00 + path = upstream/wpt + url = https://github.com/web-platform-tests/wpt.git + branch = master + shallow = true + sparse-checkout = resources common dom/nodes css/selectors + verify = pnpm run test:upstream +# no-release-tag: web-platform-tests publishes no release tags; pinned by ref on master diff --git a/README.md b/README.md index dd25e01..8985a69 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Fast CSS Selectors API Engine -![](https://img.shields.io/npm/v/nwsapi.svg?colorB=orange&style=flat) ![](https://img.shields.io/github/tag/dperini/nwsapi.svg?style=flat) ![](https://img.shields.io/npm/dw/nwshttps://ko-fi.com/dperiniapi.svg?style=flat) ![](https://img.shields.io/github/issues/dperini/nwsapi.svg?style=flat) +![](https://img.shields.io/npm/v/nwsapi.svg?colorB=orange&style=flat) ![](https://img.shields.io/github/tag/dperini/nwsapi.svg?style=flat) ![](https://img.shields.io/npm/dw/nwsapi.svg?style=flat) ![](https://img.shields.io/github/issues/dperini/nwsapi.svg?style=flat) NWSAPI is the development progress of [NWMATCHER](https://github.com/dperini/nwmatcher) aiming at [Selectors Level 4](https://www.w3.org/TR/selectors-4/) conformance. It has been completely reworked to be easily extended and maintained. It is a right-to-left selector parser and compiler written in pure Javascript with no external dependencies. It was initially thought as a cross browser library to improve event delegation and web page scraping in various frameworks but it has become a popular replacement of the native CSS selection and matching functionality in newer browsers and headless environments. @@ -10,8 +10,8 @@ It uses [regular expressions](https://en.wikipedia.org/wiki/Regular_expression) ## Installation -To include NWSAPI in a standard web page:https://ko-fi.com/dperini -Support & Sponsoring +To include NWSAPI in a standard web page: + ```html ``` @@ -29,20 +29,19 @@ $ npm install nwsapi ``` NWSAPI currently supports browsers (as a global, `NW.Dom`) and headless environments (as a CommonJS module). -Support & Sponsoring - -NWSAPI is an open-source project created and maintained with dedication. If you use this library in your apps, headless environments, or frameworks, please consider supporting its ongoing development! - - GitHub Sponsors: Sponsor monthly tiers directly through GitHub. - Buy Me a Coffee: Make a one-time tip or contribution. - -Your support helps cover development time, maintain full conformance with W3C Selectors specifications, and keep NWSAPI fast and dependency-https://ko-fi.com/dperinifree. -Support & Sponsoring ## Supported Selectors Here is a list of all the CSS2/CSS3/CSS4 [Supported selectors](https://github.com/dperini/nwsapi/wiki/CSS-supported-selectors). +State pseudo-classes from [Selectors Level 4](https://www.w3.org/TR/selectors-4/): + +* `:open` — matches `
`/`` elements having the `open` attribute; open `` pickers are rendering states with no DOM reflection, so they never match here (per spec, matching is host/rendering defined). +* `:modal` and `:fullscreen` — match the document's `fullscreenElement` when the host exposes one; the `` "is modal" flag has no DOM reflection. +* `:picture-in-picture` — matches the document's `pictureInPictureElement` when the host exposes one. +* `:current`, `:past`, `:future` — parsed as valid but match nothing: these moved to Selectors Level 5, which mandates they must not match when no timeline is defined (a static DOM has none). Only the bare forms are supported; the functional `:current()` form (which no browser ships) is a parse error. +* `:closed` is intentionally **not** supported: the CSSWG removed it (only `:open` shipped; Chrome dropped `:closed` in 122), so like current browsers it is a parse error — use `:not(:open)`. + ## Features and Compliance @@ -57,15 +56,7 @@ You can read more about NWSAPI [features and compliance](https://github.com/dper Returns a reference to the nearest ancestor element matching `selector`, starting at `context`. Returns `null` if no element is found. If `callback` is provided, it is invoked for the matched element. -#### `first( selector, context, callback )`Support & Sponsoring - -NWSAPI is an open-source project created and maintained with dedication. If you use this library in your apps, headless environments, or frameworks, please consider supporting its ongoing development! - - GitHub Sponsors: Sponsor monthly tiers directly through GitHub. -Support & Sponsoring - Buy Me a Coffee: Make a one-time tip or contribution. -https://ko-fi.com/dperini -Your support helps cover development time, maintain full conformance with W3C Selectors specifications, and keep NWSAPI fast and dependency-free. +#### `first( selector, context, callback )` Returns a reference to the first element matching `selector`, starting at `context`. Returns `null` if no element matches. If `callback` is provided, it is invoked for the matched element. @@ -86,7 +77,7 @@ Returns a reference to the first element with ID `id`, optionally filtered to de #### `byTag( tag, from )` -Returns an array of elements having the specified tag name `tag`, optionally filtered to descendants of theSupport & Sponsoring element `from`. +Returns an array of elements having the specified tag name `tag`, optionally filtered to descendants of the element `from`. #### `byClass( class, from )` @@ -106,7 +97,7 @@ The following is the list of currently available configuration options, their de ### Examples on extending the basic functionalities -Support & Sponsoringhttps://ko-fi.com/dperini + #### `configure( { : [ true | false ] } )` Disable logging errors/warnings to console, disallow duplicate ids. Example: @@ -129,13 +120,13 @@ NW.Dom.registerCombinator( '^', 'e.parentElement' ); Registers a new symbol and its matching resolver in the attribute operators table. Example: ```js -NW.Dom.registerOperator( '!=', { p1: '^', p2: '$', p3: 'false' } );https://ko-fi.com/dperini +NW.Dom.registerOperator( '!=', { p1: '^', p2: '$', p3: 'false' } ); ``` #### `registerSelector( name, rexp, func )` Registers a new selector, the matching RE and the resolver function, in the selectors table. Example: -Support & Sponsoring + ```js NW.Dom.registerSelector('Controls', /^\:(control)(.*)/i, (function(global) { @@ -147,6 +138,39 @@ NW.Dom.registerSelector('Controls', /^\:(control)(.*)/i, })(this)); ``` +## Development + +Requires Node.js >= 24 and [pnpm](https://pnpm.io) (the version pinned in `packageManager`). + +```sh +pnpm install # install pinned dev dependencies +pnpm run lint # eslint (flat config) +pnpm run min # build dist/nwsapi.min.js (terser) + +# upstream web-platform-tests (sparse + shallow, pinned in .gitmodules) +pnpm run upstream:clone # materialize upstream/wpt at the pinned ref +pnpm run upstream:verify # verify ref, sparse patterns and manifest hash + +# run upstream WPT selector tests against src/nwsapi.js (Playwright) +pnpm exec playwright install chromium +pnpm run test:upstream # full suite +WPT_FILTER='Attribute presence' pnpm run test:upstream # individual selectors +WPT_SECTION='Combinators' pnpm run test:upstream # a whole section + +# benchmarks (mitata + jsdom), ported from the legacy test/speed suite +pnpm run bench # all preset groups +pnpm run bench -- --preset default # one group +pnpm run bench -- --selector 'div:not(.example)' # a single selector + +# serve the WPT checkout + repo for interactive debugging via portless +pnpm run serve # -> https://nwsapi.localhost (proxies $PORT) +# first run on a new machine: `pnpm exec portless trust` once (sudo prompt) +# to install the local CA and start the HTTPS proxy +``` + +The `upstream/` directory is git-ignored on purpose: the pin of record is the +`ref` field in `.gitmodules` (see `docs/upstream.md`). + ## 💖 Support & Sponsoring **NWSAPI** powers millions of builds, web scrapers, and testing suites every single day—including key infrastructure like [jsdom](https://github.com/jsdom/jsdom). diff --git a/bench/README.md b/bench/README.md new file mode 100644 index 0000000..6ad72fb --- /dev/null +++ b/bench/README.md @@ -0,0 +1,93 @@ +# nwsapi benchmarks + +This directory is the Node.js port of the legacy browser benchmark harness +that lived in `test/speed` (Benchmark.js v1.0.0 driving fresh iframes). The +same nine selector preset groups are preserved verbatim in +[`presets.mjs`](./presets.mjs), and the same fixture page is used: +`test/speed/example/selectors.html`, a saved copy of the W3C Selectors +Level 4 spec page (~413 KB of real-world markup). + +## What is compared + +For every `(preset, selector)` pair, one [mitata](https://github.com/evanwashere/mitata) +group times two engines against the same shared jsdom document: + +- **nwsapi** — `NW.select(selector, document)` using `src/nwsapi.js` from + this checkout. +- **jsdom qsa** — `document.querySelectorAll(selector)`. + +> **Note:** jsdom 30 does **not** use nwsapi internally — it resolves +> selectors with `@asamuzakjp/dom-selector`. That makes `querySelectorAll` +> a genuine second engine, not nwsapi benchmarking itself. + +Like the old harness, each selector gets a one-shot correctness probe +before timing: the number of elements returned by each engine is compared, +and any mismatch or thrown error is collected into a +`result mismatches / errors` table printed after the run (the old harness's +yellow/FAILED highlighting). Selectors that throw in **both** engines are +skipped from timing with a note; if only one engine throws, the pair is +still timed so the healthy engine gets a score and mitata reports the other +as an error. + +A single JSDOM instance (and a single nwsapi instance bound to it) is shared +by the whole process. See the comment in `selectors.bench.mjs` for the +cache-warm tradeoff: this matches the steady-state numbers the old +iframe-based harness effectively measured, but hides per-selector +cold-start (first parse/compile) costs. + +## Usage + +```sh +# everything (all 9 presets, ~200 selectors -- takes several minutes) +pnpm run bench + +# list preset names and selector counts +pnpm run bench -- --list + +# one preset group +pnpm run bench -- --preset default + +# several presets (repeat the flag or comma-separate) +pnpm run bench -- --preset descendants_only,comma-separated_group_or_selector_list + +# a single selector (substring match against the preset selectors) +pnpm run bench -- --selector 'div:not(.example)' + +# selectors matching a regular expression +pnpm run bench -- --selector '/^div:nth-child/' + +# machine-readable output +pnpm run bench -- --json > results.json 2> issues.json +``` + +The `--` separator is optional with pnpm — it forwards unrecognized flags +after the script name either way, so both `pnpm run bench -- --list` and +`pnpm run bench --list` work. The script can also be run directly: +`node bench/selectors.bench.mjs --list`. + +With `--json`, mitata's JSON results go to **stdout** while the +mismatch/error records are emitted as JSON on **stderr**, so stdout stays +parseable. + +## Caveats + +- The two `negation_tree-structural*` preset groups largely exercise + nwsapi's **error path** today: nwsapi cannot parse `:not()` with nested + functional pseudo-classes such as `div:not(:nth-child(2n))` (a + pre-existing engine limitation), so most selectors in those groups throw + in nwsapi while jsdom resolves them. The mismatch/error table after the + run reports them; treat those timings as error-handling throughput, not + selector-matching speed. + +## Tips + +- Running node with `--expose-gc` improves mitata's output quality: it lets + mitata trigger garbage collection between samples and report per-iteration + heap statistics. + + ```sh + node --expose-gc bench/selectors.bench.mjs --preset default + ``` + +- A full run across all presets is long; use `--preset`/`--selector` to + narrow the run while iterating on engine changes. diff --git a/bench/presets.mjs b/bench/presets.mjs new file mode 100644 index 0000000..520b115 --- /dev/null +++ b/bench/presets.mjs @@ -0,0 +1,232 @@ +/* + * Selector preset groups for the nwsapi benchmarks. + * + * Ported VERBATIM from the legacy browser harness object `default_presets` + * in test/speed/presets/presets.js -- every selector string and every group + * key name is preserved exactly (including the duplicate "div, a" entry in + * the comma-separated group, which the old harness also ran twice). + */ + +export const presets = { + 'default': [ + '#title', + 'h1#title', + 'div #title', + '#changes + #contents', + '.note', + 'div.note', + 'body .note', + '.no-toc + .toc', + 'div.example', + '.title', + '.toc', + 'p + .note', + 'div.example, p.note', + 'body', + 'div', + 'body div', + 'div p', + 'div > p', + 'div + p', + 'div ~ p', + 'div[class^=exa][class$=mple]', + 'p a', + 'div p a', + 'div > p > a', + 'div.example > p > a', + 'div + p + ul', + 'div ~ p ~ ul', + 'div, ul, a', + 'h1, h2, h3, h4, h5, h6', + 'a[href][lang][class]', + 'div[class]', + 'div[class=example]', + 'div[class^=exa]', + 'div[class$=mple]', + 'div[class*=e]', + 'div[class~=example]', + 'div:not(.example)', + 'div:nth-child(even)', + 'div:nth-child(2n)', + 'div:nth-child(odd)', + 'div:nth-child(2n+1)', + 'div:nth-child(n)', + 'p:only-child', + 'p:last-child', + 'p:first-child' + ], + 'descendants_only': [ + 'ul li a span', + 'ul li span', + 'li a span', + 'ul a span', + 'ul li a', + 'ul li', + 'li a', + 'ul a', + 'dl dt code', + 'dl dd a', + 'dl dd', + 'dl dt', + 'table tr td', + 'table tr', + 'tr td a', + 'td a' + ], + 'descendant_and_siblings': [ + 'div ul li a span', + 'div ul li a', + 'div ul li', + 'div ul', + 'ul li a span', + 'ul li a', + 'ul li', + 'li a', + 'div > ul > li > a > span', + 'div > ul > li > a', + 'div > ul > li', + 'div > ul', + 'ul > li > a > span', + 'ul > li > a', + 'ul > li', + 'li > a', + 'h2 + p + ul + p', + 'h2 + p + ul', + 'p + ul + p', + 'li + li', + 'h2 + ul', + 'h2 + p', + 'ul + p', + 'p + p', + 'h2 ~ p ~ ul ~ p', + 'h2 ~ p ~ ul', + 'p ~ ul ~ p', + 'li ~ li', + 'h2 ~ ul', + 'h2 ~ p', + 'ul ~ p', + 'p ~ p' + ], + 'tree-structural_child-indexed': [ + 'div:last-child', + 'div:only-child', + 'div:first-child', + 'div:nth-child(n)', + 'div:nth-child(3)', + 'div:nth-child(n+3)', + 'div:nth-child(+n-3)', + 'div:nth-child(2n)', + 'div:nth-child(odd)', + 'div:nth-child(2n+1)', + 'div:nth-child(even)', + 'div:nth-last-child(n)', + 'div:nth-last-child(3)', + 'div:nth-last-child(n+3)', + 'div:nth-last-child(+n-3)', + 'div:nth-last-child(2n)', + 'div:nth-last-child(odd)', + 'div:nth-last-child(2n+1)', + 'div:nth-last-child(even)' + ], + 'tree-structural_typed_child-indexed': [ + 'div:last-of-type', + 'div:only-of-type', + 'div:first-of-type', + 'div:nth-of-type(n)', + 'div:nth-of-type(3)', + 'div:nth-of-type(n+3)', + 'div:nth-of-type(+n-3)', + 'div:nth-of-type(2n)', + 'div:nth-of-type(odd)', + 'div:nth-of-type(2n+1)', + 'div:nth-of-type(even)', + 'div:nth-last-of-type(n)', + 'div:nth-last-of-type(3)', + 'div:nth-last-of-type(n+3)', + 'div:nth-last-of-type(+n-3)', + 'div:nth-last-of-type(2n)', + 'div:nth-last-of-type(odd)', + 'div:nth-last-of-type(2n+1)', + 'div:nth-last-of-type(even)' + ], + 'comma-separated_group_or_selector_list': [ + 'div, ul, li, a', + 'div, ul, li', + 'ul, li, a', + 'div, li', + 'div, a', + 'ul, li', + 'ul, a', + 'li, a', + 'div, p, a, em', + 'div, p, em', + 'div, p, a', + 'div, p', + 'div, a', + 'p, em', + 'a, em', + 'p, a' + ], + 'negation_tree-structural_child-indexed': [ + 'div:not(:last-child)', + 'div:not(:only-child)', + 'div:not(:first-child)', + 'div:not(:nth-child(n))', + 'div:not(:nth-child(3))', + 'div:not(:nth-child(n+3))', + 'div:not(:nth-child(+n-3))', + 'div:not(:nth-child(2n))', + 'div:not(:nth-child(odd))', + 'div:not(:nth-child(2n+1))', + 'div:not(:nth-child(even))', + 'div:not(:nth-last-child(n))', + 'div:not(:nth-last-child(3))', + 'div:not(:nth-last-child(n+3))', + 'div:not(:nth-last-child(+n-3))', + 'div:not(:nth-last-child(2n))', + 'div:not(:nth-last-child(odd))', + 'div:not(:nth-last-child(2n+1))', + 'div:not(:nth-last-child(even))' + ], + 'negation_tree-structural_typed_child-indexed': [ + 'div:not(:last-of-type)', + 'div:not(:only-of-type)', + 'div:not(:first-of-type)', + 'div:not(:nth-of-type(n))', + 'div:not(:nth-of-type(3))', + 'div:not(:nth-of-type(n+3))', + 'div:not(:nth-of-type(+n-3))', + 'div:not(:nth-of-type(2n))', + 'div:not(:nth-of-type(odd))', + 'div:not(:nth-of-type(2n+1))', + 'div:not(:nth-of-type(even))', + 'div:not(:nth-last-of-type(n))', + 'div:not(:nth-last-of-type(3))', + 'div:not(:nth-last-of-type(n+3))', + 'div:not(:nth-last-of-type(+n-3))', + 'div:not(:nth-last-of-type(2n))', + 'div:not(:nth-last-of-type(odd))', + 'div:not(:nth-last-of-type(2n+1))', + 'div:not(:nth-last-of-type(even))' + ], + 'matches_and_negation_dynamic_and_user_actions': [ + ':lang(fr)', + ':visited', + ':target', + ':active', + ':hover', + ':focus', + ':empty', + ':link', + ':not(:lang(fr))', + ':not(:visited)', + ':not(:target)', + ':not(:active)', + ':not(:hover)', + ':not(:focus)', + ':not(:empty)', + ':not(:link)' + ] +}; + +export default presets; diff --git a/bench/selectors.bench.mjs b/bench/selectors.bench.mjs new file mode 100644 index 0000000..b6906dd --- /dev/null +++ b/bench/selectors.bench.mjs @@ -0,0 +1,281 @@ +/* + * nwsapi selector benchmarks. + * + * A port of the legacy browser harness (test/speed, Benchmark.js v1.0.0) to + * mitata running under Node.js + jsdom. For every (preset, selector) pair it + * times NW.select(selector, document) against document.querySelectorAll( + * selector). jsdom 30 resolves selectors with @asamuzakjp/dom-selector, NOT + * nwsapi, so querySelectorAll is a genuine second engine. + * + * Like the old harness it also cross-checks the number of elements each + * engine returns for every selector before timing it, and prints a + * "result mismatches / errors" table at the end (the old yellow/FAILED + * highlighting). Selectors that throw in BOTH engines are skipped from + * timing with a note. + * + * Usage: node bench/selectors.bench.mjs [--list] [--json] + * [--preset [,...]] [--selector ] + */ + +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { parseArgs } from 'node:util'; +import { fileURLToPath } from 'node:url'; + +import { JSDOM } from 'jsdom'; +import { bench, do_not_optimize, group, run, summary } from 'mitata'; + +// src/nwsapi.js is CommonJS; its module.exports is the Factory function. +import nwsapiFactory from '../src/nwsapi.js'; +import presets from './presets.mjs'; + +const benchDir = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURE_PATH = path.join( + benchDir, + '..', + 'test', + 'speed', + 'example', + 'selectors.html' +); + +const USAGE = `nwsapi selector benchmarks (mitata + jsdom) + +Usage: + node bench/selectors.bench.mjs [options] + +Options: + --preset Run only the named preset group. Repeatable, and each + value may be a comma-separated list. Default: all. + --selector Run only selectors whose text contains , or, + when is written /like-this/i, selectors that + match the regular expression. + --list Print preset names with selector counts and exit. + --json Have mitata emit JSON results on stdout; mismatch and + error records are then emitted as JSON on stderr so + stdout stays machine-readable. + --help Show this help. +`; + +function fail(message) { + console.error(message); + process.exit(1); +} + +function parseCli(argv) { + // pnpm forwards a literal "--" separator (e.g. `pnpm run bench -- --list`); + // strip it so the flags after it stay flags instead of positionals + const args = argv.filter((arg, i) => !(arg === '--' && argv.indexOf('--') === i)); + try { + return parseArgs({ + args, + allowPositionals: false, + options: { + preset: { type: 'string', multiple: true }, + selector: { type: 'string' }, + list: { type: 'boolean', default: false }, + json: { type: 'boolean', default: false }, + help: { type: 'boolean', default: false }, + }, + }).values; + } catch (error) { + fail(`${error.message}\n\n${USAGE}`); + } +} + +function resolvePresetNames(rawValues) { + const available = Object.keys(presets); + const requested = (rawValues ?? []) + .flatMap((value) => value.split(',')) + .map((value) => value.trim()) + .filter(Boolean); + if (requested.length === 0) { + return available; + } + const unknown = requested.filter((name) => !Object.hasOwn(presets, name)); + if (unknown.length > 0) { + fail( + `Unknown preset(s): ${unknown.join(', ')}\n` + + `Available presets:\n ${available.join('\n ')}` + ); + } + // De-duplicate while preserving the order the user asked for. + return [...new Set(requested)]; +} + +function buildSelectorMatcher(raw) { + if (raw === undefined) { + return () => true; + } + const asRegExp = /^\/(.+)\/([a-z]*)$/.exec(raw); + if (asRegExp) { + let regexp; + try { + regexp = new RegExp(asRegExp[1], asRegExp[2]); + } catch (error) { + fail(`Invalid --selector regular expression: ${error.message}`); + } + return (selector) => regexp.test(selector); + } + return (selector) => selector.includes(raw); +} + +function printList() { + const names = Object.keys(presets); + const width = Math.max(...names.map((name) => name.length)); + let total = 0; + for (const name of names) { + const count = presets[name].length; + total += count; + console.log(`${name.padEnd(width)} ${String(count).padStart(3)} selectors`); + } + console.log(`${'total'.padEnd(width)} ${String(total).padStart(3)} selectors`); +} + +function describeProbe(count, error) { + return error ? `ERROR: ${error.message}` : `${count} found`; +} + +function printIssueTable(issues) { + if (issues.length === 0) { + console.log('\nresult check: no mismatches or engine errors.'); + return; + } + console.log('\nresult mismatches / errors'); + const rows = issues.map((issue) => [ + issue.preset, + issue.selector, + issue.nwsapi, + issue.jsdom, + issue.status, + ]); + const head = ['preset', 'selector', 'nwsapi', 'jsdom qsa', 'status']; + const widths = head.map((label, i) => + Math.max(label.length, ...rows.map((row) => row[i].length)) + ); + const line = (cells) => + cells.map((cell, i) => cell.padEnd(widths[i])).join(' '); + console.log(line(head)); + console.log(line(widths.map((width) => '-'.repeat(width)))); + for (const row of rows) { + console.log(line(row)); + } +} + +async function main() { + const values = parseCli(process.argv.slice(2)); + + if (values.help) { + console.log(USAGE); + return; + } + if (values.list) { + printList(); + return; + } + + const presetNames = resolvePresetNames(values.preset); + const matchesSelector = buildSelectorMatcher(values.selector); + + // One shared JSDOM instance (and one nwsapi instance bound to it) for the + // whole process. The legacy harness loaded the fixture into fresh iframes, + // but by the time Benchmark.js finished its warmup cycles both engines were + // running against a fully parsed, warm document anyway -- so sharing a + // single warmed document matches what the old numbers actually measured + // (steady-state selection speed, with each engine's internal selector + // caches primed). The tradeoff: cold-start costs (first parse/compile of a + // selector) are amortized away and are not visible in these results. + // + // runScripts is deliberately NOT enabled: the saved fixture page ends with + // spec-toolchain scripts that throw when executed. + const html = readFileSync(FIXTURE_PATH, 'utf8'); + const dom = new JSDOM(html); + const { document } = dom.window; + const NW = nwsapiFactory({ + document, + DOMException: dom.window.DOMException, + }); + + const plan = []; + const issues = []; + + for (const presetName of presetNames) { + for (const selector of presets[presetName]) { + if (!matchesSelector(selector)) { + continue; + } + + // One-shot correctness probe before timing: compare the number of + // elements returned by each engine (the old harness's cross-check). + let nwCount = null; + let nwError = null; + let qsaCount = null; + let qsaError = null; + try { + nwCount = NW.select(selector, document).length; + } catch (error) { + nwError = error; + } + try { + qsaCount = document.querySelectorAll(selector).length; + } catch (error) { + qsaError = error; + } + + const record = { + preset: presetName, + selector, + nwsapi: describeProbe(nwCount, nwError), + jsdom: describeProbe(qsaCount, qsaError), + }; + + if (nwError && qsaError) { + // Both engines reject the selector (the old harness expected + // failures here, e.g. dynamic pseudo-classes): nothing to time. + issues.push({ ...record, status: 'skipped (both engines threw)' }); + continue; + } + if (nwError || qsaError) { + // One engine fails: record it, but still time the pair so the + // healthy engine gets a score (mitata reports the other as error, + // like the old FAILED column). + issues.push({ ...record, status: 'engine error' }); + } else if (nwCount !== qsaCount) { + issues.push({ ...record, status: 'result mismatch' }); + } + + plan.push({ presetName, selector }); + } + } + + if (plan.length === 0) { + fail( + 'No selectors matched the given --preset/--selector filters ' + + '(and none were timeable).' + ); + } + + for (const { presetName, selector } of plan) { + group(`${presetName} ▸ ${selector}`, () => { + summary(() => { + bench('nwsapi', () => { + do_not_optimize(NW.select(selector, document)); + }); + bench('jsdom qsa', () => { + do_not_optimize(document.querySelectorAll(selector)); + }); + }); + }); + } + + await run(values.json ? { format: 'json' } : {}); + + if (values.json) { + // Keep stdout pure mitata JSON; ship the correctness report on stderr. + process.stderr.write(`${JSON.stringify({ issues }, null, 2)}\n`); + } else { + printIssueTable(issues); + } +} + +await main(); diff --git a/dist/lint.log b/dist/lint.log deleted file mode 100644 index e69de29..0000000 diff --git a/docs/upstream.md b/docs/upstream.md new file mode 100644 index 0000000..5f7ebca --- /dev/null +++ b/docs/upstream.md @@ -0,0 +1,87 @@ +# Pristine upstream checkouts + +`upstream/wpt` is a sparse (cone-mode), shallow, `blob:none` partial clone of +[web-platform-tests/wpt](https://github.com/web-platform-tests/wpt), limited to +`resources common dom/nodes css/selectors` and detached at a pinned commit. + +## Doctrine + +- **No gitlink.** The checkout is never registered as a real git submodule — + there is no `160000` index entry, and `upstream/` is gitignored. +- **The `ref` field in `.gitmodules` is the pin of record.** `.gitmodules` is + committed and uses the wheelhouse format: a header comment carrying the label + and integrity hash (`# wpt-