From e0345f19f13b162507ebbcee092c812efd0381fa Mon Sep 17 00:00:00 2001 From: jdalton Date: Sat, 5 Sep 2026 15:00:48 -0400 Subject: [PATCH 1/3] Add opt-in legacy DOM reads and regression coverage --- .config/vitest.config.mts | 2 +- docs/api.md | 62 +-- docs/legacy.md | 118 ++++++ package.json | 3 +- scripts/lib/tooling-scope.mts | 1 + src/nwsapi.mts | 740 +++++++++++++++++++++++++++++++--- test/node/legacy-host.mts | 226 +++++++++++ test/node/legacy.spec.mts | 674 +++++++++++++++++++++++++++++++ 8 files changed, 1730 insertions(+), 96 deletions(-) create mode 100644 docs/legacy.md create mode 100644 test/node/legacy-host.mts create mode 100644 test/node/legacy.spec.mts diff --git a/.config/vitest.config.mts b/.config/vitest.config.mts index 3f1e6ecb..7fa94d20 100644 --- a/.config/vitest.config.mts +++ b/.config/vitest.config.mts @@ -7,7 +7,7 @@ export default defineConfig({ server: { watch: { usePolling: process.env.CHOKIDAR_USEPOLLING === '1' } }, test: { watch: process.argv.includes('--watch'), - include: ['test/*.test.mts'], + include: ['test/*.test.mts', 'test/node/legacy.spec.mts'], globalSetup: ['.config/vitest.setup.mts'], forceRerunTriggers: ['../src/**/*.mts', '../scripts/build.mts', './**'].map( path => fileURLToPath(new URL(path, import.meta.url)), diff --git a/docs/api.md b/docs/api.md index 7821e791..10617e57 100644 --- a/docs/api.md +++ b/docs/api.md @@ -11,21 +11,21 @@ Query contexts default to the factory document when omitted. `closest()`, `first | Method | Result | | --- | --- | -| [`byClass(cls, context)`](../src/nwsapi.mts#L2972) | Returns elements with the class name. | -| [`byId(id, context)`](../src/nwsapi.mts#L2970) | Returns elements with the ID. Duplicate IDs are allowed by default. | -| [`byTag(tag, context)`](../src/nwsapi.mts#L2971) | Returns elements with the tag name. Use `*` for all elements. | -| [`closest(selectors, element, callback)`](../src/nwsapi.mts#L2978) | Returns the nearest match, starting with the element, or `null`. | -| [`compile(selector, mode, callback)`](../src/nwsapi.mts#L2980) | Compiles a selector into a resolver function. This is an advanced API. | -| [`configure(option, clear)`](../src/nwsapi.mts#L2981) | Reads or changes options. Pass `true` as the second argument to clear compiled selectors. | -| [`emit(message, proto)`](../src/nwsapi.mts#L2983) | Reports an error using the configured error policy. | -| [`first(selectors, context, callback)`](../src/nwsapi.mts#L2974) | Returns the first matching descendant, or `null`. | -| [`install(all)`](../src/nwsapi.mts#L2989) | Replaces native selector methods. Pass `true` to also replace collection methods. | -| [`match(selectors, element, callback)`](../src/nwsapi.mts#L2975) | Returns whether the element matches. | -| [`registerCombinator(combinator, resolver)`](../src/nwsapi.mts#L2996) | Adds a relationship between elements using trusted resolver code. | -| [`registerOperator(operator, resolver)`](../src/nwsapi.mts#L3021) | Adds an attribute operator using a resolver with `p1`, `p2`, and `p3` fields. | -| [`registerSelector(name, rexp, func)`](../src/nwsapi.mts#L3043) | Adds a selector pattern and a compiler callback that returns `source` and `status`. | -| [`select(selectors, context, callback)`](../src/nwsapi.mts#L2976) | Returns an array of matching descendants, or an empty array. | -| [`uninstall()`](../src/nwsapi.mts#L2990) | Restores the native methods saved by `install()`. | +| [`byClass(cls, context)`](../src/nwsapi.mts#L3586) | Returns elements with the class name. | +| [`byId(id, context)`](../src/nwsapi.mts#L3584) | Returns elements with the ID. Duplicate IDs are allowed by default. | +| [`byTag(tag, context)`](../src/nwsapi.mts#L3585) | Returns elements with the tag name. Use `*` for all elements. | +| [`closest(selectors, element, callback)`](../src/nwsapi.mts#L3592) | Returns the nearest match, starting with the element, or `null`. | +| [`compile(selector, mode, callback)`](../src/nwsapi.mts#L3594) | Compiles a selector into a resolver function. This is an advanced API. | +| [`configure(option, clear)`](../src/nwsapi.mts#L3595) | Reads or changes options. Pass `true` as the second argument to clear compiled selectors. | +| [`emit(message, proto)`](../src/nwsapi.mts#L3597) | Reports an error using the configured error policy. | +| [`first(selectors, context, callback)`](../src/nwsapi.mts#L3588) | Returns the first matching descendant, or `null`. | +| [`install(all)`](../src/nwsapi.mts#L3603) | Replaces native selector methods. Pass `true` to also replace collection methods. | +| [`match(selectors, element, callback)`](../src/nwsapi.mts#L3589) | Returns whether the element matches. | +| [`registerCombinator(combinator, resolver)`](../src/nwsapi.mts#L3610) | Adds a relationship between elements using trusted resolver code. | +| [`registerOperator(operator, resolver)`](../src/nwsapi.mts#L3635) | Adds an attribute operator using a resolver with `p1`, `p2`, and `p3` fields. | +| [`registerSelector(name, rexp, func)`](../src/nwsapi.mts#L3657) | Adds a selector pattern and a compiler callback that returns `source` and `status`. | +| [`select(selectors, context, callback)`](../src/nwsapi.mts#L3590) | Returns an array of matching descendants, or an empty array. | +| [`uninstall()`](../src/nwsapi.mts#L3604) | Restores the native methods saved by `install()`. |
Configuration @@ -58,22 +58,22 @@ These exports support extensions and debugging. Prefer query methods and `config | Member | Purpose | | --- | --- | -| [`CFG`](../src/nwsapi.mts#L2958) | Contains the compiler syntax settings. | -| [`Config`](../src/nwsapi.mts#L2984) | Contains the active options. Use `configure()` to change them. | -| [`M_BODY`](../src/nwsapi.mts#L2961) | Contains the matching resolver body template. | -| [`M_TEST`](../src/nwsapi.mts#L2965) | Contains the matching resolver test template. | -| [`matchLambdas`](../src/nwsapi.mts#L2950) | Caches compiled matching functions, not DOM results. | -| [`matchResolvers`](../src/nwsapi.mts#L2953) | Caches matching plans, not DOM results. | -| [`N_BODY`](../src/nwsapi.mts#L2962) | Exposes the matching resolver body template. | -| [`N_TEST`](../src/nwsapi.mts#L2966) | Contains the alternate resolver test template. | -| [`Operators`](../src/nwsapi.mts#L2992) | Contains registered attribute operators. | -| [`S_BODY`](../src/nwsapi.mts#L2960) | Contains the selection resolver body template. | -| [`S_TEST`](../src/nwsapi.mts#L2964) | Contains the selection resolver test template. | -| [`selectLambdas`](../src/nwsapi.mts#L2951) | Caches compiled selection functions, not DOM results. | -| [`Selectors`](../src/nwsapi.mts#L2993) | Contains registered selector extensions. | -| [`selectResolvers`](../src/nwsapi.mts#L2954) | Caches selection plans, not DOM results. | -| [`Snapshot`](../src/nwsapi.mts#L2985) | Contains the document state and helpers used by compiled selectors. | -| [`Version`](../src/nwsapi.mts#L2987) | Contains the engine version string. | +| [`CFG`](../src/nwsapi.mts#L3572) | Contains the compiler syntax settings. | +| [`Config`](../src/nwsapi.mts#L3598) | Contains the active options. Use `configure()` to change them. | +| [`M_BODY`](../src/nwsapi.mts#L3575) | Contains the matching resolver body template. | +| [`M_TEST`](../src/nwsapi.mts#L3579) | Contains the matching resolver test template. | +| [`matchLambdas`](../src/nwsapi.mts#L3564) | Caches compiled matching functions, not DOM results. | +| [`matchResolvers`](../src/nwsapi.mts#L3567) | Caches matching plans, not DOM results. | +| [`N_BODY`](../src/nwsapi.mts#L3576) | Exposes the matching resolver body template. | +| [`N_TEST`](../src/nwsapi.mts#L3580) | Contains the alternate resolver test template. | +| [`Operators`](../src/nwsapi.mts#L3606) | Contains registered attribute operators. | +| [`S_BODY`](../src/nwsapi.mts#L3574) | Contains the selection resolver body template. | +| [`S_TEST`](../src/nwsapi.mts#L3578) | Contains the selection resolver test template. | +| [`selectLambdas`](../src/nwsapi.mts#L3565) | Caches compiled selection functions, not DOM results. | +| [`Selectors`](../src/nwsapi.mts#L3607) | Contains registered selector extensions. | +| [`selectResolvers`](../src/nwsapi.mts#L3568) | Caches selection plans, not DOM results. | +| [`Snapshot`](../src/nwsapi.mts#L3599) | Contains the document state and helpers used by compiled selectors. | +| [`Version`](../src/nwsapi.mts#L3601) | Contains the engine version string. |
diff --git a/docs/legacy.md b/docs/legacy.md new file mode 100644 index 00000000..a5508313 --- /dev/null +++ b/docs/legacy.md @@ -0,0 +1,118 @@ +# Legacy DOM hosts + +`LEGACY` enables DOM compatibility handling. It is not a JavaScript syntax +fallback, does not transpile this package, and does not supply missing built-ins. +An older browser must first be able to run the source and its dependencies. +See [runtime initialization](legacy-runtime.md) for optional built-in handling. + +```js +NW.Dom.configure({ LEGACY: true }); +``` + +Modern documents leave the flag off. Document setup enables it when the host +lacks `hasAttribute`, `getElementsByClassName`, `firstElementChild`, or a string +`localName`. This is a small capability check, not exhaustive detection of +every historical DOM quirk. Set the flag explicitly for subtler differences. + +Document changes retain the flag. Changing it clears compiled resolvers and +query plans, because their host reads depend on the mode. Select legacy mode +before using an optional runtime feature that the environment lacks. + +## Host reads + +Compilation chooses a read table. Modern selections retain direct DOM reads; +single-node attribute matches guard the method. Legacy resolvers call helpers +through local aliases such as `hTag=s.tagOf`. Only used aliases are declared. +The pseudo-class rewrite preserves selector text inside generated string and +regexp literals; text such as `e.localName` is not itself a property read. + +Legacy collection lookups filter non-elements. Fragment, ID, sibling, and +ancestor walks use node-level traversal when element-level APIs are absent. +Modern traversal keeps its direct reads. `:scope` no longer requires `classList`. + +| Host behavior | Legacy handling | +| --- | --- | +| Attribute names differ from their reflected property names. | Try the markup name, then mappings such as `className`, `htmlFor`, and `colSpan`. | +| A missing attribute returns a property default. | Check the attribute node and its `specified` flag first. | +| URL reads return resolved values. | Probe the document for a raw read through the second argument, attribute node, or ordinary getter. | +| A boolean attribute returns a boolean. | Represent present `true` as an empty string and `false` as absent. The original spelling cannot be recovered. | +| `style` returns an object. | Read `style.cssText`; stringify other non-string values. | +| `hasAttribute` is absent. | Use attribute presence rather than a property default. | +| Wildcard tag collections contain comments. | Keep elements only. | +| Element traversal properties are absent. | Walk `firstChild`, `nextSibling`, `previousSibling`, and `parentNode`, skipping non-elements. | +| `localName` is absent. | Derive it from `nodeName`, removing a namespace prefix and lowercasing HTML names. | +| Class lookup is absent. | Scan descendants and test their class text. | +| `getAttributeNames` is absent. | Read specified names from the attribute collection. | +| `isConnected` is absent. | Walk to the root and check for a document. | +| A form control shadows the form's `id` property. | Read the form's ID attribute. | + +The attribute handling follows David Mark's historical survey, +[A is for Attributes](https://web.archive.org/web/20091217095816/http://www.cinsoft.net/attributes.html). +The distinction remains useful when implementing custom DOM hosts; see +[attributes versus properties](https://jakearchibald.com/2024/attributes-vs-properties/). + +## Limits and pending integration + +This extraction does not add browser features such as constraint validation +or media state. It does not claim compatibility with historical XML engines. +Use `select`, `match`, `first`, and `closest` when the host lacks the prototypes +needed by `install()`. + +The separate `:disabled`/`:enabled` correction in #190 and `:defined` correction +in #193 are not included. Their independent-reference assertions are preserved +as three expected failures. Remove those markers when integrating the fixes, +and adapt their helper reads to the legacy accessors. Other engine changes +from #167 are also outside this extraction. + +### Combining the extracted PRs + +Keep these integration changes when their prerequisites land: + +- In #190's `isDisabled`, use `tagOf`, `upOf`, `firstOf`, and `nextOf` + instead of `localName`, `parentElement`, `firstElementChild`, and + `nextElementSibling`. The disabled-fieldset and first-legend rules stay + unchanged. In #193's `isDefined`, use `tagOf(element)`, + `hasAttrOf(element, 'is')`, and `attrOf(element, 'is')`. +- With #178, clear `matcherDoc` and `matcherRecord` at the start of + `switchContext`, alongside other context-local shortcuts. Do not discard + the document-keyed WeakMap; its records remain reusable. +- With #194, retain one `classOf` implementation and route its fallback through + `attrOf(e, 'class')`. The modern read table should call the `classOf` helper; + the legacy table should call `legacyClassOf`. Expose those helpers under + distinct Snapshot keys so one does not overwrite the other. The legacy + pseudo-class rewrite must select `legacyClassOf` too. Use `read.id('e')` + for the ID comparison so legacy forms retain their attribute fallback. +- With #205, use the captured `sliceCall` in collection adapters. Keep legacy + element filtering and fallback traversal around those calls. The wider-arity + fallback in #187's `argsWith` should also use `sliceCall(args).concat(tail)`. +- With #201, resolve sibling `:has()` search contexts through `upOf(anchor)`, + not `anchor.parentElement`. That read occurs inside the runtime helper, + outside the generated-code rewrite. Cover both `div:has(+ div)` and + `div:has(~ div)` on a legacy host with only node-level traversal. + +These are integration recipes, not claims that the prerequisites are included +in this standalone branch. Validate the combined tree with both the original +legacy assertions and each prerequisite's focused tests before removing the +three expected-failure markers. + +## Validation + +Run with Node.js ≥ 22, using a patch supported by jsdom 30: 22.22.2 or later +in the 22 series, 24.15.0 or later in the 24 series, or 26 or later. + +```sh +pnpm install +pnpm run test:legacy +pnpm run test:node test/legacy-runtime.test.mts +``` + +The Vitest suite needs no browser download, web server, +or WPT checkout. The tests use jsdom 30.0.1 and a proxy host that hides modern +APIs and simulates attribute and collection quirks. These are simulated-host +tests, not executions in old browsers. Both raw-URL strategies are exercised. + +The suite retains the legacy tests from archived #167, including generated-code +inspection, mode changes, scoped queries, missing `WeakMap`, and non-element +collection entries. jsdom 30 supplies a separate selector implementation for +reference comparisons. Modern/legacy parity tests instead compare the same +engine in both modes and do not establish selector-spec correctness. diff --git a/package.json b/package.json index 24e76b6e..e538c035 100644 --- a/package.json +++ b/package.json @@ -117,6 +117,7 @@ "upstream:verify": "node scripts/run.mts scripts/git-partial-submodule.mts verify", "test:attributes": "node scripts/run.mts node_modules/vitest/vitest.mjs run --config .config/vitest.config.mts test/attribute-parse-error.test.mts", "test:attributes:browser": "NWSAPI_BROWSER=1 node scripts/run.mts node_modules/vitest/vitest.mjs run --config .config/vitest.config.mts test/attribute-strings-browser.test.mts", - "test:browser": "NWSAPI_BROWSER=1 node scripts/run.mts node_modules/vitest/vitest.mjs run --config .config/vitest.config.mts" + "test:browser": "NWSAPI_BROWSER=1 node scripts/run.mts node_modules/vitest/vitest.mjs run --config .config/vitest.config.mts", + "test:legacy": "node scripts/run.mts node_modules/vitest/vitest.mjs run --config .config/vitest.config.mts test/node/legacy.spec.mts" } } diff --git a/scripts/lib/tooling-scope.mts b/scripts/lib/tooling-scope.mts index ae3eb0f0..b7cbb48e 100644 --- a/scripts/lib/tooling-scope.mts +++ b/scripts/lib/tooling-scope.mts @@ -7,6 +7,7 @@ export function toolingFiles() { 'src/**/*.mts', 'scripts/**/*.mts', 'test/*.test.mts', + 'test/node/**/*.mts', 'test/jsdom-adapter-package.mts', 'test/upstream/*.mts', '.config/*.mts', diff --git a/src/nwsapi.mts b/src/nwsapi.mts index da9a1bbe..fd7e8ce1 100644 --- a/src/nwsapi.mts +++ b/src/nwsapi.mts @@ -421,6 +421,14 @@ // force a new check for each document change // performed before the next select operation root = doc.documentElement + if (!Config.LEGACY && detectLegacy(doc)) { + Config.LEGACY = true + matchLambdas.clear() + selectLambdas.clear() + matchResolvers.clear() + selectResolvers.clear() + } + useLegacy(Config.LEGACY) HTML_DOCUMENT = isHTML(doc) QUIRKS_MODE = HTML_DOCUMENT && doc.compatMode.indexOf('CSS') < 0 NAMESPACE = root && root.namespaceURI @@ -616,7 +624,23 @@ byIdRaw = function (id, context, from?) { var node = context, nodes = [], - next = from || node.firstElementChild + next + + if (Config.LEGACY) { + next = from || firstOf(node) + while ((node = next)) { + idOf(node) == id && (nodes[nodes.length] = node) + if ((next = firstOf(node) || nextOf(node))) { + continue + } + while (!next && (node = upOf(node)) && node !== context) { + next = nextOf(node) + } + } + return nodes + } + + next = from || node.firstElementChild while ((node = next)) { node.id == id && (nodes[nodes.length] = node) if ((next = node.firstElementChild || node.nextElementSibling)) { @@ -646,12 +670,12 @@ if ('all' in context) { if ((e = context.all[id])) { if (e.nodeType == 1) { - return e.getAttribute('id') != id ? [] : [e] + return attrOf(e, 'id') != id ? [] : [e] } else if (id == 'length') { return (e = context[api](id)) ? [e] : none } for (i = 0, l = e.length, nodes = []; l > i; ++i) { - if (e[i].id == id) { + if (e[i] && e[i].nodeType == 1 && idOf(e[i]) == id) { nodes[nodes.length] = e[i] } } @@ -662,19 +686,20 @@ } } - // Without document.all, every '#id' used to walk the whole subtree, - // which measures 2.4ms against 43ns for getElementById on a - // 6300-element document. getElementById cannot answer on its own, - // because a document may carry the same id more than once and all of - // them match, but it does settle two things in constant time: whether - // the id exists anywhere, and where the first one is, since it returns - // the first in tree order and any duplicate has to follow it. + // Without document.all — jsdom does not implement it — every '#id' + // used to walk the whole subtree, which measures 2.5ms against 43ns + // for getElementById on a 6300-element document. getElementById cannot + // answer on its own, because a document may carry the same id more + // than once and all of them match, but it does settle two things in + // constant time: whether the id exists anywhere, and where the first + // one is, since it returns the first in tree order and any duplicate + // has to follow it. ownerDoc = context.nodeType == 9 ? context : context.ownerDocument if ( ownerDoc && ownerDoc.getElementById && - (context.nodeType == 9 || context.isConnected) + (context.nodeType == 9 || connectedOf(context)) ) { e = ownerDoc.getElementById(id) // nothing in the document carries the id, so nothing under context does @@ -701,7 +726,23 @@ api = method['*'] // DOCUMENT_NODE (9) & ELEMENT_NODE (1) if (api in context) { - return slice.call(context[api](tag)) + nodes = slice.call(context[api](tag)) + return Config.LEGACY ? elementsOf(nodes) : nodes + } else if (Config.LEGACY) { + // DOCUMENT_FRAGMENT_NODE (11) on a host without the element-only + // traversal, so the children are walked by hand + tag = tag.toLowerCase() + nodes = [] + e = firstOf(context) + while (e) { + if (tag == '*' || tagOf(e) == tag) { + nodes[nodes.length] = e + } + if (e[api]) { + concatList(nodes, elementsOf(e[api](tag))) + } + e = nextOf(e) + } } else { tag = tag.toLowerCase() // DOCUMENT_FRAGMENT_NODE (11) @@ -730,12 +771,27 @@ // context agnostic getElementsByClassName byClass = function (cls, context) { var e, + i, + l, nodes, api = method['.'], reCls // DOCUMENT_NODE (9) & ELEMENT_NODE (1) if (api in context) { - return slice.call(context[api](cls)) + nodes = slice.call(context[api](cls)) + return Config.LEGACY ? elementsOf(nodes) : nodes + } else if (Config.LEGACY) { + // A host from before this lookup existed. Every element under the + // context is asked for its class instead, which is what the engine + // would otherwise have the fetch avoid. + reCls = RegExp('(^|\\s)' + cls + '(\\s|$)', QUIRKS_MODE ? 'i' : '') + nodes = [] + e = byTag('*', context) + for (i = 0, l = e.length; l > i; ++i) { + if (reCls.test(classOf(e[i]))) { + nodes[nodes.length] = e[i] + } + } } else { // DOCUMENT_FRAGMENT_NODE (11) if ((e = context.firstElementChild)) { @@ -766,7 +822,7 @@ hasAttributeNS = function (e, name) { var i, l, - attr = e.getAttributeNames() + attr = attrNamesOf(e) name = RegExp(':?' + name + '$', HTML_DOCUMENT ? 'i' : '') for (i = 0, l = attr.length; l > i; ++i) { if (name.test(attr[i])) { @@ -775,6 +831,483 @@ } return false }, + elementsOf = function (nodes) { + var i, + l, + out = [] + for (i = 0, l = nodes.length; l > i; ++i) { + if (nodes[i] && nodes[i].nodeType == 1) { + out[out.length] = nodes[i] + } + } + return out + }, + LEGACY_NAMES = { + accesskey: 'accessKey', + cellpadding: 'cellPadding', + cellspacing: 'cellSpacing', + class: 'className', + colspan: 'colSpan', + contenteditable: 'contentEditable', + for: 'htmlFor', + frameborder: 'frameBorder', + maxlength: 'maxLength', + readonly: 'readOnly', + rowspan: 'rowSpan', + tabindex: 'tabIndex', + usemap: 'useMap', + valign: 'vAlign', + }, + LEGACY_URLS = { + action: 1, + background: 1, + cite: 1, + classid: 1, + codebase: 1, + data: 1, + href: 1, + longdesc: 1, + profile: 1, + src: 1, + usemap: 1, + }, + LEGACY_URL_READ = 'flag', + LEGACY_PROBE = './nwsapi-probe', + probeAttributes = function (document) { + var element, node + + LEGACY_URL_READ = 'flag' + try { + element = document.createElement('a') + element.setAttribute('href', LEGACY_PROBE) + if (element.getAttribute('href', 2) === LEGACY_PROBE) { + return + } + node = + element.attributes && + element.attributes.getNamedItem && + element.attributes.getNamedItem('href') + if ( + node && + (node.value === LEGACY_PROBE || node.nodeValue === LEGACY_PROBE) + ) { + LEGACY_URL_READ = 'node' + return + } + if (element.getAttribute('href') === LEGACY_PROBE) { + LEGACY_URL_READ = 'plain' + } + // nothing answered the markup, so the second argument stays the best + // of the three: it is what the host most likely to resolve took + } catch (e) { + // a host that cannot create an element is not one to probe + } + }, + legacyAttrNode = function (e, lower) { + var attrs = e.attributes, + node + if (!attrs) { + return null + } + node = attrs.getNamedItem ? attrs.getNamedItem(lower) : attrs[lower] + if (!node && LEGACY_NAMES[lower]) { + node = attrs.getNamedItem + ? attrs.getNamedItem(LEGACY_NAMES[lower]) + : attrs[LEGACY_NAMES[lower]] + } + return node || null + }, + legacyAttrOf = function (e, name) { + var lower, node, value + + if (!e || e.nodeType != 1) { + return null + } + lower = name.toLowerCase() + node = legacyAttrNode(e, lower) + + // Presence is the attribute node's to answer, not the property's. A + // property default is not an attribute, and IE 6 and 7 answered + // getAttribute('enctype') with the form default when the markup had set + // nothing at all (Mark, "Known Exceptions"). Where the host keeps an + // attributes collection, that collection decides. + if (e.attributes && (!node || node.specified === false)) { + return null + } + + // A URL attribute, read the way this host answers the markup. + if (LEGACY_URLS[lower] && e.getAttribute) { + if (LEGACY_URL_READ == 'node' && node) { + value = node.value !== undefined ? node.value : node.nodeValue + } else { + value = + LEGACY_URL_READ == 'plain' + ? e.getAttribute(name) + : e.getAttribute(name, 2) + } + if (typeof value == 'string') { + return value + } + } + + if (e.getAttribute) { + value = e.getAttribute(name) + if (value == null && LEGACY_NAMES[lower]) { + value = e.getAttribute(LEGACY_NAMES[lower]) + } + } + if (value == null && node) { + value = node.value !== undefined ? node.value : node.nodeValue + } + if (value == null) { + return null + } + + if (typeof value == 'string') { + return value + } + // a style attribute came back as an object and an event handler as a + // function + if (lower == 'style') { + return e.style ? e.style.cssText : null + } + // A boolean attribute came back as the property's true or false. Read + // as '' when it is present, which is the markup of '' + // and the only answer available: this host cannot say whether the + // markup wrote 'checked' or 'checked="checked"', a loss Mark documents + // under "Booleans" and settles the same way. + if (value === true) { + return '' + } + if (value === false) { + return null + } + return String(value) + }, + legacyHasAttrOf = function (e, name) { + if (!e || e.nodeType != 1) { + return false + } + if (e.hasAttribute) { + return e.hasAttribute(name) + } + return legacyAttrOf(e, name) !== null + }, + legacyTagOf = function (e) { + if (!e) { + return '' + } + if (typeof e.localName == 'string') { + return e.localName + } + // nodeName is upper case for an HTML element and carries the prefix in + // XML, so the part after a colon is the local name + var name = e.nodeName + if (typeof name != 'string') { + return '' + } + name = name.slice(name.indexOf(':') + 1) + return HTML_DOCUMENT ? name.toLowerCase() : name + }, + legacyIdOf = function (e) { + var value = e && e.id + if (typeof value == 'string' && legacyTagOf(e) != 'form') { + return value + } + return legacyAttrOf(e, 'id') || '' + }, + legacyClassOf = function (e) { + var value = e && e.className + if (typeof value == 'string') { + return value + } + if (value && typeof value.baseVal == 'string') { + return value.baseVal + } + return legacyAttrOf(e, 'class') || '' + }, + legacyUpOf = function (e) { + var node = e.parentElement + if (node !== undefined) { + return node + } + node = e.parentNode + return node && node.nodeType == 1 ? node : null + }, + legacyNextOf = function (e) { + var node = e.nextElementSibling + if (node !== undefined) { + return node + } + node = e.nextSibling + while (node && node.nodeType != 1) { + node = node.nextSibling + } + return node || null + }, + legacyPrevOf = function (e) { + var node = e.previousElementSibling + if (node !== undefined) { + return node + } + node = e.previousSibling + while (node && node.nodeType != 1) { + node = node.previousSibling + } + return node || null + }, + legacyFirstOf = function (e) { + var node = e.firstElementChild + if (node !== undefined) { + return node + } + node = e.firstChild + while (node && node.nodeType != 1) { + node = node.nextSibling + } + return node || null + }, + legacyAttrNamesOf = function (e) { + var i, + l, + names = [], + attrs + if (e.getAttributeNames) { + return e.getAttributeNames() + } + attrs = e.attributes + for (i = 0, l = attrs ? attrs.length : 0; l > i; ++i) { + if ( + attrs[i] && + (attrs[i].specified === undefined || attrs[i].specified) + ) { + names[names.length] = + attrs[i].name !== undefined ? attrs[i].name : attrs[i].nodeName + } + } + return names + }, + legacyConnectedOf = function (e) { + var node = e + if (e.isConnected !== undefined) { + return e.isConnected + } + while (node.parentNode) { + node = node.parentNode + } + return node.nodeType == 9 + }, + attrOf = function (e, name) { + return e.getAttribute(name) + }, + hasAttrOf = function (e, name) { + return e.hasAttribute(name) + }, + tagOf = function (e) { + return e.localName + }, + idOf = function (e) { + return e.id + }, + upOf = function (e) { + return e.parentElement + }, + nextOf = function (e) { + return e.nextElementSibling + }, + prevOf = function (e) { + return e.previousElementSibling + }, + firstOf = function (e) { + return e.firstElementChild + }, + attrNamesOf = function (e) { + return e.getAttributeNames() + }, + connectedOf = function (e) { + return e.isConnected + }, + useLegacy = function (on) { + if (on) { + probeAttributes(doc) + } + attrOf = on + ? legacyAttrOf + : function (e, name) { + return e.getAttribute(name) + } + hasAttrOf = on + ? legacyHasAttrOf + : function (e, name) { + return e.hasAttribute(name) + } + tagOf = on + ? legacyTagOf + : function (e) { + return e.localName + } + idOf = on + ? legacyIdOf + : function (e) { + return e.id + } + upOf = on + ? legacyUpOf + : function (e) { + return e.parentElement + } + nextOf = on + ? legacyNextOf + : function (e) { + return e.nextElementSibling + } + prevOf = on + ? legacyPrevOf + : function (e) { + return e.previousElementSibling + } + firstOf = on + ? legacyFirstOf + : function (e) { + return e.firstElementChild + } + attrNamesOf = on + ? legacyAttrNamesOf + : function (e) { + return e.getAttributeNames() + } + connectedOf = on + ? legacyConnectedOf + : function (e) { + return e.isConnected + } + }, + detectLegacy = function (document) { + var root = document && document.documentElement + return ( + !!root && + (!root.hasAttribute || + !document.getElementsByClassName || + root.firstElementChild === undefined || + typeof root.localName != 'string') + ) + }, + classOf = function (e) { + var value = e.className + if (typeof value == 'string') { + return value + } + // an SVGAnimatedString carries the markup in baseVal, which is cheaper + // to read than asking for the attribute again + if (value && typeof value.baseVal == 'string') { + return value.baseVal + } + return attrOf(e, 'class') + }, + H_USED = {}, + helper = function (alias, name) { + H_USED[alias] = name + return alias + }, + readDirect = { + tag: function (v) { + return v + '.localName' + }, + id: function (v) { + return v + '.id' + }, + cls: function (v) { + return v + '.getAttribute("class")' + }, + up: function (v) { + return v + '.parentElement' + }, + next: function (v) { + return v + '.nextElementSibling' + }, + prev: function (v) { + return v + '.previousElementSibling' + }, + attr: function (v, name) { + return v + '.getAttribute("' + name + '")' + }, + has: function (v, name) { + return v + '.hasAttribute("' + name + '")' + }, + }, + readHelped = { + tag: function (v) { + return helper('hTag', 'tagOf') + '(' + v + ')' + }, + id: function (v) { + return helper('hId', 'idOf') + '(' + v + ')' + }, + cls: function (v) { + return helper('hCls', 'classOf') + '(' + v + ')' + }, + up: function (v) { + return helper('hUp', 'upOf') + '(' + v + ')' + }, + next: function (v) { + return helper('hNext', 'nextOf') + '(' + v + ')' + }, + prev: function (v) { + return helper('hPrev', 'prevOf') + '(' + v + ')' + }, + attr: function (v, name) { + return helper('hAttr', 'attrOf') + '(' + v + ',"' + name + '")' + }, + has: function (v, name) { + return helper('hHas', 'hasAttrOf') + '(' + v + ',"' + name + '")' + }, + }, + helpReads = function (code) { + var reads = { + localName: ['hTag', 'tagOf'], + className: ['hCls', 'classOf'], + id: ['hId', 'idOf'], + parentElement: ['hUp', 'upOf'], + nextElementSibling: ['hNext', 'nextOf'], + previousElementSibling: ['hPrev', 'prevOf'], + firstElementChild: ['hFirst', 'firstOf'], + isConnected: ['hConn', 'connectedOf'], + hasAttribute: ['hHas', 'hasAttrOf'], + getAttribute: ['hAttr', 'attrOf'], + } + // Match literals before looking inside them. A nested selector may + // contain text such as "e.localName", which is data, not a host read. + // This recognizes the string and regexp forms emitted by this compiler. + return code.replace( + /("(?:\\[\s\S]|[^"\\])*"|'(?:\\[\s\S]|[^'\\])*'|\/(?:\\[\s\S]|\[(?:\\[\s\S]|[^\]\\])*\]|[^/\\\r\n])+\/[a-z]*)|\b([eno])\.(localName|className|id|parentElement|nextElementSibling|previousElementSibling|firstElementChild|isConnected)\b|\b([eno])\.(hasAttribute|getAttribute)\(("(?:\\[\s\S]|[^"\\])*")\)/g, + function (all, literal, node, prop, namedNode, method, attr) { + if (literal) { + return literal + } + var read = reads[prop || method] + return ( + helper(read[0], read[1]) + + '(' + + (node || namedNode) + + (attr ? ',' + attr : '') + + ')' + ) + }, + ) + }, + readGuarded = { + tag: readDirect.tag, + id: readDirect.id, + cls: readDirect.cls, + up: readDirect.up, + next: readDirect.next, + prev: readDirect.prev, + attr: function (v, name) { + return v + '.getAttribute&&' + v + '.getAttribute("' + name + '")' + }, + has: function (v, name) { + return v + '.hasAttribute&&' + v + '.hasAttribute("' + name + '")' + }, + }, // fast resolver for the :nth-child() and :nth-last-child() pseudo-classes nthElement = (function () { var idx = 0, @@ -795,13 +1328,15 @@ return -1 } var e, i, j, k, l - if (parent === element.parentElement) { + if ( + parent === (Config.LEGACY ? upOf(element) : element.parentElement) + ) { i = set j = idx l = len } else { l = parents.length - parent = element.parentElement + parent = Config.LEGACY ? upOf(element) : element.parentElement for (i = -1, j = 0, k = l - 1; l > j; ++j, --k) { if (parents[j] === parent) { i = j @@ -816,14 +1351,25 @@ parents[(i = l)] = parent l = 0 nodes[i] = Array() - e = (parent && parent.firstElementChild) || element - while (e) { - nodes[i][l] = e - if (e === element) { - j = l + e = parent ? firstOf(parent) || element : element + if (Config.LEGACY) { + while (e) { + nodes[i][l] = e + if (e === element) { + j = l + } + e = nextOf(e) + ++l + } + } else { + while (e) { + nodes[i][l] = e + if (e === element) { + j = l + } + e = e.nextElementSibling + ++l } - e = e.nextElementSibling - ++l } set = i idx = 0 @@ -876,18 +1422,18 @@ j, k, l, - name = element.localName + name = Config.LEGACY ? tagOf(element) : element.localName if ( nodes[set] && nodes[set][name] && - parent === element.parentElement + parent === (Config.LEGACY ? upOf(element) : element.parentElement) ) { i = set j = idx l = len } else { l = parents.length - parent = element.parentElement + parent = Config.LEGACY ? upOf(element) : element.parentElement for (i = -1, j = 0, k = l - 1; l > j; ++j, --k) { if (parents[j] === parent) { i = j @@ -903,16 +1449,29 @@ nodes[i] || (nodes[i] = Object()) l = 0 nodes[i][name] = Array() - e = (parent && parent.firstElementChild) || element - while (e) { - if (e === element) { - j = l + e = parent ? firstOf(parent) || element : element + if (Config.LEGACY) { + while (e) { + if (e === element) { + j = l + } + if (tagOf(e) == name) { + nodes[i][name][l] = e + ++l + } + e = nextOf(e) } - if (e.localName == name) { - nodes[i][name][l] = e - ++l + } else { + while (e) { + if (e === element) { + j = l + } + if (e.localName == name) { + nodes[i][name][l] = e + ++l + } + e = e.nextElementSibling } - e = e.nextElementSibling } set = i idx = j @@ -956,8 +1515,8 @@ // check if node content is editable isContentEditable = function (node) { var attrValue = 'inherit' - if (node.hasAttribute('contenteditable')) { - attrValue = node.getAttribute('contenteditable') + if (hasAttrOf(node, 'contenteditable')) { + attrValue = attrOf(node, 'contenteditable') } switch (attrValue) { case '': @@ -977,7 +1536,7 @@ // or false if node isn't focusable isFocusable = function (node) { var doc = node.ownerDocument - if (node.contentDocument && node.localName == 'iframe') { + if (node.contentDocument && tagOf(node) == 'iframe') { return false } if (doc.hasFocus() && node === doc.activeElement) { @@ -1072,13 +1631,13 @@ // Native matching extends support to host-language states such as pickers. isOpen = function (node) { return ( - (/^(details|dialog)$/i.test(node.localName) && node.open === true) || + (/^(details|dialog)$/i.test(tagOf(node)) && node.open === true) || matchesNative(node, ':open') ) }, isClosed = function (node) { return ( - (/^(details|dialog)$/i.test(node.localName) && node.open === false) || + (/^(details|dialog)$/i.test(tagOf(node)) && node.open === false) || matchesNative(node, ':closed') ) }, @@ -1115,13 +1674,11 @@ // native pseudo-class is therefore required until an explicit state API is // available. :popover is retained as an alias for existing callers. isPopoverOpen = function (node) { - return ( - node.hasAttribute('popover') && matchesNative(node, ':popover-open') - ) + return hasAttrOf(node, 'popover') && matchesNative(node, ':popover-open') }, // ':link', ':any-link' and ':visited' share this test isLink = function (node) { - return reLinkName.test(node.localName) && node.hasAttribute('href') + return reLinkName.test(tagOf(node)) && hasAttrOf(node, 'href') }, // check media resources is playing isPlaying = function (media) { @@ -1160,6 +1717,7 @@ } if (i == 'LEGACY' && Config[i] !== !!option[i]) { matcherDoc = matcherCache = null + clear = true } Config[i] = !!option[i] } @@ -1170,6 +1728,7 @@ matchResolvers.clear() selectResolvers.clear() } + useLegacy(Config.LEGACY) setIdentifierSyntax() return true }, @@ -1407,12 +1966,14 @@ // compile groups or single selector strings into // executable functions for matching or selecting compile = function (selector, mode, callback) { - var factory, + var alias, + factory, head = '', loop = '', macro = '', source = '', vars = '' + H_USED = {} // 'mode' can be boolean or null // true = select / false = match @@ -1447,6 +2008,9 @@ } source = compileSelector(selector, macro, mode, callback) + if (Config.LEGACY) { + source = helpReads(source) + } loop += mode || mode === null ? '{' + source + '}' : source @@ -1462,6 +2026,10 @@ N_VARS.length = 0 } + for (alias in H_USED) { + vars += ',' + alias + '=s.' + H_USED[alias] + } + // oxlint-disable-next-line typescript/no-implied-eval -- Selectors compile to resolver functions. factory = Function( 's', @@ -1494,7 +2062,14 @@ test, type, selector = expression, - vars + vars, + read + + read = Config.LEGACY + ? readHelped + : mode === false + ? readGuarded + : readDirect // isolate selector combinators selector = selector.replace(STD.combinator, '$1') @@ -1519,7 +2094,9 @@ source = 'if((/^' + match[1] + - '$/.test(e.getAttribute("id")))){' + + '$/.test(' + + read.attr('e', 'id') + + '))){' + source + '}' break @@ -1527,7 +2104,7 @@ // class name resolver case '.': match = selector.match(Patterns.className) - compat = (QUIRKS_MODE ? 'i' : '') + '.test(e.getAttribute("class"))' + compat = (QUIRKS_MODE ? 'i' : '') + '.test(' + read.cls('e') + ')' source = 'if((/(^|\\s)' + match[1] + @@ -1541,7 +2118,8 @@ // tag name resolver case /[_a-z]/i.test(symbol) ? symbol : undefined: match = selector.match(Patterns.tagName) - source = 'if((e.localName=="' + match[1] + '")){' + source + '}' + source = + 'if((' + read.tag('e') + '=="' + match[1] + '")){' + source + '}' break // namespace resolver @@ -1599,18 +2177,18 @@ (!match[2] ? NS ? 's.hasAttributeNS(e,"' + name + '")' - : 'e.hasAttribute&&e.hasAttribute("' + name + '")' + : read.has('e', name) : !match[4] && ATTR_STD_OPS[match[2]] && match[2] != '~=' - ? 'e.getAttribute&&e.getAttribute("' + name + '")==""' + ? read.attr('e', name) + '==""' : '(/' + test.p1 + match[4] + test.p2 + '/' + type + - ').test(e.getAttribute&&e.getAttribute("' + - name + - '"))==' + + ').test(' + + read.attr('e', name) + + ')== ' + test.p3) + ')){' + source + @@ -1624,7 +2202,9 @@ source = 'var N' + k + - '=e;while(e&&(e=e.previousElementSibling)){' + + '=e;while(e&&(e=' + + read.prev('e') + + ')){' + source + '}e=N' + k + @@ -1638,7 +2218,9 @@ source = 'var N' + k + - '=e;if(e&&(e=e.previousElementSibling)){' + + '=e;if(e&&(e=' + + read.prev('e') + + ')){' + source + '}e=N' + k + @@ -1653,7 +2235,9 @@ source = 'var N' + k + - '=e;while(e&&(e=e.parentElement)){' + + '=e;while(e&&(e=' + + read.up('e') + + ')){' + source + '}e=N' + k + @@ -1667,7 +2251,9 @@ source = 'var N' + k + - '=e;if(e&&(e=e.parentElement)){' + + '=e;if(e&&(e=' + + read.up('e') + + ')){' + source + '}e=N' + k + @@ -2377,17 +2963,25 @@ // replace :scope context element as a // a reference in the selector string makeref = function (selectors, element) { + var id, name + // replace DOCUMENT with first element (root) if (element.nodeType === 9) { element = element.documentElement } + + id = idOf(element) + // The first token of the class attribute. Read from the text rather + // than through classList, which was the only place this engine needed + // that API and is one more thing an older host does not have. + name = classOf(element) + name = name ? String(name).split(/\s+/)[0] : '' + return selectors.replace( /:scope/i, - element.localName + - (element.id ? '#' + escapeIdentifier(element.id) : '') + - (element.className - ? '.' + escapeIdentifier(element.classList[0]) - : ''), + tagOf(element) + + (id ? '#' + escapeIdentifier(id) : '') + + (name ? '.' + escapeIdentifier(name) : ''), ) }, // equivalent of w3c 'closest' method @@ -2398,7 +2992,7 @@ if (match(selectors, element, callback)) { break } - element = element.parentElement + element = upOf(element) } return element }, @@ -2889,6 +3483,16 @@ selectResolvers = createCache(), // passed to resolvers Snapshot: { + attrOf: typeof legacyAttrOf + hasAttrOf: typeof legacyHasAttrOf + tagOf: typeof legacyTagOf + idOf: typeof legacyIdOf + classOf: typeof legacyClassOf + upOf: typeof legacyUpOf + nextOf: typeof legacyNextOf + prevOf: typeof legacyPrevOf + firstOf: typeof legacyFirstOf + connectedOf: typeof legacyConnectedOf HOVER?: EventTarget doc: Document from: Node @@ -2919,6 +3523,16 @@ root: root, byTag: byTag, + attrOf: legacyAttrOf, + hasAttrOf: legacyHasAttrOf, + tagOf: legacyTagOf, + idOf: legacyIdOf, + classOf: legacyClassOf, + upOf: legacyUpOf, + nextOf: legacyNextOf, + prevOf: legacyPrevOf, + firstOf: legacyFirstOf, + connectedOf: legacyConnectedOf, has: has, first: first, diff --git a/test/node/legacy-host.mts b/test/node/legacy-host.mts new file mode 100644 index 00000000..731e6876 --- /dev/null +++ b/test/node/legacy-host.mts @@ -0,0 +1,226 @@ +/* + * A host shaped like the ones Config.LEGACY exists for. + * + * The legacy handling cannot be tested against the browsers it was written + * for, so this stands in for them: a Proxy over a jsdom document that hides + * the APIs those browsers did not have and answers the ones they did the way + * they did. Everything it does is a behavior that shipped: + * + * - getElementsByTagName('*') and children included comment nodes + * (IE up to 8) + * - no hasAttribute, no getElementsByClassName, no getAttributeNames, + * no isConnected, no classList, no localName + * - no firstElementChild, nextElementSibling, previousElementSibling or + * parentElement, only the node-level traversal + * - getAttribute answered through the DOM property, so 'class' and 'for' + * were only reachable as className and htmlFor, style came back as an + * object and a boolean attribute as true or false + * - a URL attribute came back resolved. IE up to 7 took a second argument, + * 2, to ask for the markup; Opera up to 9.27 resolved a form action with + * no way to ask otherwise, which the 'urls' option selects between + * - a missing attribute could answer the property's default: IE 6 and 7 + * answered getAttribute('enctype') with the form default + * - attributes held every attribute the element could have, with + * specified saying which the markup had set + * + * The catalogue behind those is David Mark's survey, which tested each one + * across the browsers of the day: + * https://web.archive.org/web/20091217095816/http://www.cinsoft.net/attributes.html + * (library at https://github.com/david-mark/My-Library). Also + * https://perfectionkills.com/, jQuery's propFix and attrHooks, and + * https://jakearchibald.com/2024/attributes-vs-properties/ for the split. + */ + +const HIDDEN = new Set([ + 'hasAttribute', + 'getElementsByClassName', + 'getAttributeNames', + 'isConnected', + 'classList', + 'localName', + 'firstElementChild', + 'lastElementChild', + 'nextElementSibling', + 'previousElementSibling', + 'parentElement', + 'children', + 'closest', + 'matches', + 'webkitMatchesSelector', + 'msMatchesSelector', + 'querySelector', + 'querySelectorAll', +]) + +// The names that host answered under a DOM property instead. +const PROPS = { class: 'className', for: 'htmlFor', colspan: 'colSpan' } + +// The attributes it resolved to an absolute URL unless asked for the markup. +const URLS = new Set([ + 'action', + 'cite', + 'data', + 'href', + 'longdesc', + 'src', + 'usemap', +]) + +// Attributes it answered with the property's boolean rather than a string. +const BOOLEANS = new Set([ + 'checked', + 'disabled', + 'selected', + 'readonly', + 'multiple', +]) + +// Property defaults a missing attribute could answer with, which is why +// presence has to come from the attribute node rather than from a value. +const DEFAULTS = { enctype: 'application/x-www-form-urlencoded' } + +export function legacyHost(document, { comments = true, urls = 'flag' } = {}) { + const cache = new WeakMap() + // proxy back to the node it stands for, so a method of the host is called + // with the nodes it expects rather than with the wrappers + const raw = new WeakMap() + const unwrap = value => + value !== null && typeof value === 'object' && raw.has(value) + ? raw.get(value) + : value + + const wrap = value => { + if (value === null || typeof value !== 'object') { + return value + } + if (cache.has(value)) { + return cache.get(value) + } + const nodeType = value.nodeType + const isNode = typeof nodeType === 'number' + const isList = + !isNode && + typeof value.length === 'number' && + typeof value.item === 'function' + if (!isNode && !isList) { + return value + } + const proxy = isList ? asCollection(value) : new Proxy(value, nodeHandler) + cache.set(value, proxy) + raw.set(proxy, value) + return proxy + } + + // A collection this host would have filled differently: it carried comment + // nodes among the elements. Handed back as a plain array-like rather than a + // live one, so its length and its indices always agree. + function asCollection(collection) { + const items = [] + for (let i = 0; i < collection.length; ++i) { + const node = collection.item(i) + items.push(wrap(node)) + if (comments && node.ownerDocument) { + items.push(wrap(node.ownerDocument.createComment('legacy'))) + } + } + const out = { length: items.length, item: index => items[index] ?? null } + for (let i = 0; i < items.length; ++i) { + out[i] = items[i] + } + return out + } + + function attributeOf(element, name, mode) { + const lower = String(name).toLowerCase() + const node = element.attributes.getNamedItem(lower) + if (!node) { + // the property's default, for the handful of attributes that had one, + // and otherwise nothing: the property name was where the value lived + return DEFAULTS[lower] ?? null + } + if (lower === 'style') { + return element.style + } + if (BOOLEANS.has(lower)) { + return true + } + if (URLS.has(lower) && !(urls === 'flag' && mode === 2)) { + // resolved against the document. With urls: 'flag' the second argument + // asks for the markup, the way IE up to 7 answered; with urls: 'plain' + // there is no way to ask, the way Opera answered. + return `http://legacy.example/${node.value.replace(/^[./]+/, '')}` + } + if (PROPS[lower]) { + return null + } + return node.value + } + + const nodeHandler = { + get(target, key) { + if (typeof key === 'string' && HIDDEN.has(key)) { + return undefined + } + + if (key === 'getAttribute') { + return (name, mode) => attributeOf(target, name, mode) + } + + if (key === 'attributes') { + // specified is what separates a set attribute from one the element + // could merely have had + const attrs = target.attributes + return new Proxy(attrs, { + get(list, prop) { + if (prop === 'length') { + return list.length + } + if (prop === 'getNamedItem') { + return name => { + const found = list.getNamedItem(String(name).toLowerCase()) + return found + ? { name: found.name, value: found.value, specified: true } + : null + } + } + if (typeof prop === 'string' && /^\d+$/.test(prop)) { + const found = list[Number(prop)] + return found + ? { name: found.name, value: found.value, specified: true } + : undefined + } + const value = Reflect.get(list, prop) + return typeof value === 'function' ? value.bind(list) : value + }, + }) + } + + // className and htmlFor were the way to reach those two attributes + if (key === 'className') { + return target.getAttribute('class') ?? '' + } + if (key === 'htmlFor') { + return target.getAttribute('for') ?? '' + } + + // nodeName was upper case for an HTML element, and the only name there + if (key === 'nodeName') { + return target.nodeName + } + + const value = Reflect.get(target, key) + if (typeof value === 'function') { + return (...args) => wrap(value.apply(target, args.map(unwrap))) + } + return wrap(value) + }, + has(target, key) { + if (typeof key === 'string' && HIDDEN.has(key)) { + return false + } + return Reflect.has(target, key) + }, + } + + return wrap(document) +} diff --git a/test/node/legacy.spec.mts b/test/node/legacy.spec.mts new file mode 100644 index 00000000..62f0ff6f --- /dev/null +++ b/test/node/legacy.spec.mts @@ -0,0 +1,674 @@ +/* + * Config.LEGACY against a host shaped like the ones it exists for. + * + * The browsers in question cannot be run here, so test/node/legacy-host.mts + * stands in for them: a jsdom document with the modern APIs hidden and the + * old answers put back. Each selector is checked against what jsdom's own + * querySelectorAll says about the same markup, so the expectations come from + * a second implementation rather than from this one. + */ +import { createRequire } from 'node:module' +import { readFileSync } from 'node:fs' +import vm from 'node:vm' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, test } from 'vitest' +import { JSDOM } from 'jsdom' + +import { legacyHost } from './legacy-host.mts' + +const require = createRequire(import.meta.url) +const here = path.dirname(fileURLToPath(import.meta.url)) +const nwsapiPath = path.resolve(here, '..', '..', 'src', 'nwsapi.js') + +const MARKUP = + '' + + '
' + + '

one

' + + '

two

' + + '

three

' + + 'link' + + '' + + '
' + + '
' + + '' + + '
' + + '
' + + '
e
' + + '' + +// Every shape the engine compiles differently, so the legacy reads are all +// exercised: tags, classes, ids, attributes with each operator, the four +// combinators, the structural pseudo-classes, the logical ones and lists. +const SELECTORS = [ + 'div', + '*', + 'p', + 'em', + '.box', + '.a', + '.row', + 'p.a', + 'div.box.wide', + ':not(.box)', + '#d1', + '#p2', + 'div#d2', + '#d1 p', + '[data-k]', + '[data-k="v"]', + '[data-k^="v"]', + '[data-k$="v"]', + '[data-k*="="]', + '[title~="b"]', + '[title|="hello"]', + '[title="A B" i]', + '[for="x"]', + 'a[href]', + 'input[checked]', + 'input[disabled]', + 'div[style]', + 'div p', + 'div > p', + 'p + p', + 'p ~ p', + 'ul li', + 'div ul li', + 'body div p', + 'p:first-child', + 'p:last-child', + 'span:only-child', + 'li:first-child', + 'p:first-of-type', + 'p:last-of-type', + 'em:only-of-type', + 'p:nth-child(1)', + 'p:nth-child(2)', + 'p:nth-child(2n)', + 'p:nth-child(odd)', + 'li:nth-child(3)', + 'li:nth-last-child(1)', + 'p:nth-of-type(2)', + 'p:nth-last-of-type(1)', + 'p:not(.a)', + 'p:not(:first-child)', + 'div:is(.box)', + 'div:where(#d1, #d2)', + 'div:has(p)', + 'div:has(> ul)', + 'span:empty', + ':root', + 'html body', + 'p, span', + 'li, em', + 'div.box, .row', +] + +function build(markup, options) { + const dom = new JSDOM(markup) + const { window } = dom + const host = legacyHost(window.document, options) + delete require.cache[require.resolve(nwsapiPath)] + const NW = require(nwsapiPath)({ + document: host, + DOMException: window.DOMException, + }) + return { window, host, document: window.document, NW } +} + +function buildModern(markup) { + const dom = new JSDOM(markup) + delete require.cache[require.resolve(nwsapiPath)] + const NW = require(nwsapiPath)({ + document: dom.window.document, + DOMException: dom.window.DOMException, + }) + return { window: dom.window, document: dom.window.document, NW } +} + +const ids = nodes => + Array.from(nodes, node => node.id || node.nodeName.toLowerCase()) + +test('LEGACY restores the handling a pre-2015 host needed', () => { + // The generated tests read reflected properties and call the host without + // asking whether it has the method, because every host that can run this + // source returns elements from a tag or class lookup. LEGACY is for one + // that does not: IE up to 8 put comment nodes in a '*' collection. + const { document, NW } = buildModern( + 'x', + ) + const comment = document.body.childNodes[1] + const scope = document.createElement('div') + scope.innerHTML = 'y' + const link = scope.firstChild + // a host handing back something that is not an element + scope.getElementsByTagName = () => [link, document.createComment('c')] + scope.getElementsByClassName = () => [link, document.createComment('c')] + + // by default the fetch is trusted, so a collection like that is an error + // rather than a non-match + expect(() => NW.select('[href]', scope)).toThrow() + expect(() => NW.select('.x.big', scope)).toThrow() + // a tag test reads a property, so it rejects the comment either way + expect(NW.select('a.x', scope).map(node => node.id)).toEqual(['b']) + + try { + NW.configure({ LEGACY: true }) + expect(NW.select('[href]', scope).map(node => node.id)).toEqual(['b']) + expect(NW.select('.x.big', scope).map(node => node.id)).toEqual(['b']) + expect(NW.match('.x', comment)).toBe(false) + expect(NW.match('[href]', comment)).toBe(false) + expect(NW.match('#a', comment)).toBe(false) + + // and the ordinary answers do not change under it + for (const selector of ['a.x', 'a#a', '#a.big', 'a[href]', '.x.big']) { + const mine = NW.select(selector, document).map(node => node.id) + const reference = Array.from( + document.querySelectorAll(selector), + node => node.id, + ) + expect(mine, selector).toEqual(reference) + } + } finally { + NW.configure({ LEGACY: false }) + } + + // flipping the flag has to reach the compiled resolvers, not just the + // next selector nobody has asked for yet + expect(() => NW.select('[href]', scope)).toThrow() +}) + +describe('a host that needs the legacy handling', () => { + test('the host is missing what those browsers were missing', () => { + const { host } = build(MARKUP) + const root = host.documentElement + expect(root.hasAttribute, 'hasAttribute').toBeUndefined() + expect(root.localName, 'localName').toBeUndefined() + expect(root.firstElementChild, 'firstElementChild').toBeUndefined() + expect(root.nextElementSibling, 'nextElementSibling').toBeUndefined() + expect(root.parentElement, 'parentElement').toBeUndefined() + expect(root.classList, 'classList').toBeUndefined() + expect( + host.getElementsByClassName, + 'getElementsByClassName', + ).toBeUndefined() + expect(root.getAttributeNames, 'getAttributeNames').toBeUndefined() + expect(root.isConnected, 'isConnected').toBeUndefined() + // and its tag collection is not all elements + const all = Array.prototype.slice.call(host.getElementsByTagName('*')) + expect( + all.some(node => node.nodeType === 8), + 'comment nodes in the collection', + ).toBe(true) + }) + + test('the engine turns the handling on by itself', () => { + const { NW } = build(MARKUP) + expect(NW.configure().LEGACY).toBe(true) + }) + + test('switching from a modern document invalidates direct-read resolvers', () => { + const modern = buildModern(MARKUP) + const legacy = build(MARKUP) + expect(ids(modern.NW.select('div.box > p.a', modern.document))).toEqual([ + 'p1', + 'p3', + ]) + expect(modern.NW.configure().LEGACY).toBe(false) + expect(ids(modern.NW.select('div.box > p.a', legacy.host))).toEqual([ + 'p1', + 'p3', + ]) + expect(modern.NW.configure().LEGACY).toBe(true) + expect(ids(modern.NW.select('div.box > p.a', modern.document))).toEqual([ + 'p1', + 'p3', + ]) + expect(modern.NW.configure().LEGACY).toBe(true) + }) + + test('URL reads are selected again for each document', () => { + const flag = build(MARKUP) + const node = build(MARKUP, { urls: 'plain' }) + for (const host of [flag.host, node.host, flag.host]) { + expect(ids(flag.NW.select('a[href="./go"]', host))).toEqual(['a1']) + } + }) + + test('every selector shape answers what the reference engine answers', () => { + const { NW, host, document } = build(MARKUP) + for (const selector of SELECTORS) { + const mine = ids(NW.select(selector, host)) + const reference = ids(document.querySelectorAll(selector)) + expect(mine, selector).toEqual(reference) + } + }) + + test('match, first and closest agree as well', () => { + const { NW, host, document } = build(MARKUP) + const byId = id => { + const node = host.getElementById(id) + expect(node, id).toBeTruthy() + return node + } + + expect(NW.match('p.a', byId('p1'))).toBe(true) + expect(NW.match('p.b', byId('p1'))).toBe(false) + expect(NW.match('div > p:first-child', byId('p1'))).toBe(true) + expect(NW.match('[for="x"]', byId('a1'))).toBe(true) + expect(NW.match('input[checked]', byId('i1'))).toBe(true) + + expect(NW.first('div p', host).id).toBe(document.querySelector('div p').id) + expect(NW.first('li.row', host).id).toBe('l2') + expect(NW.first('table', host)).toBeNull() + + expect(NW.closest('div', byId('p1')).id).toBe('d1') + expect(NW.closest('#d2', byId('p1'))).toBeNull() + }) + + test('a query scoped to an element stays inside it', () => { + const { NW, host, document } = build(MARKUP) + const scope = host.getElementById('d2') + for (const selector of ['li', 'ul li', '.row', 'input[checked]', '*']) { + const mine = ids(NW.select(selector, scope)) + const reference = ids( + document.getElementById('d2').querySelectorAll(selector), + ) + expect(mine, selector).toEqual(reference) + } + }) +}) + +describe('pseudo-classes on a host that needs the handling', () => { + // Some of these read properties older than the hosts LEGACY is for, so + // they work there; others read properties that postdate them, so they + // match nothing. Either way none of them may throw, and the ones that can + // work have to agree with the reference engine. + const FORM = + '
' + + '' + + 'l' + + '
' + + '' + + '
' + + const PSEUDOS = [ + ':disabled', + ':enabled', + ':checked', + ':lang(en)', + ':link', + ':any-link', + ':target', + ':required', + ':optional', + ':read-write', + ':read-only', + ':empty', + ':root', + ':placeholder-shown', + ':indeterminate', + ':defined', + ':valid', + ':invalid', + ':default', + ':open', + ':closed', + ':modal', + ] + + test('the legacy path answers what the ordinary path answers', () => { + // Compared against this engine on a modern host rather than against + // jsdom, because the two disagree about a few of these on any host: a + // disabled control is barred from constraint validation, so it does not + // match ':valid' here and does there. What this test is for is whether + // the legacy reads change an answer, and they must not. + const legacy = build(FORM) + const modern = buildModern(FORM) + expect(legacy.NW.configure().LEGACY).toBe(true) + expect(modern.NW.configure().LEGACY).toBe(false) + + for (const selector of PSEUDOS) { + let mine + expect(() => { + mine = ids(legacy.NW.select(selector, legacy.host)) + }, selector).not.toThrow() + expect(mine, selector).toEqual( + ids(modern.NW.select(selector, modern.document)), + ) + } + }) + + // Keep the original aggregate's assertions. These three failures also + // occur in modern mode on master and belong to #190 and #193. + for (const selector of [ + ':disabled', + ':enabled', + ':checked', + ':lang(en)', + ':link', + ':any-link', + ':target', + ':empty', + ':root', + ':defined', + ':optional', + ]) { + test( + selector + ' agrees with the independent reference', + { fails: [':disabled', ':enabled', ':defined'].includes(selector) }, + () => { + const { NW, host, document } = build(FORM) + expect(ids(NW.select(selector, host)), selector).toEqual( + ids(document.querySelectorAll(selector)), + ) + }, + ) + } +}) + +describe('what a legacy resolver is allowed to contain', () => { + // The reads the generated code makes are the whole point of the option, so + // this audits the code itself rather than an answer: with LEGACY on, no + // resolver may read the host directly. It is what caught the twenty + // pseudo-class emissions that were still doing it. + const DIRECT = + /\b[eno]\.(localName|nodeName|className|classList|id|parentElement|firstElementChild|nextElementSibling|previousElementSibling|getAttribute|hasAttribute|isConnected|attributes|children)\b/ + + const SHAPES = [ + 'div', + '.x', + '#d', + '[href]', + '[href="#"]', + '[class~="x"]', + '[title="A" i]', + 'div p', + 'div > p', + 'p + a', + 'p ~ a', + 'div p a', + 'p, span', + ':first-child', + ':last-child', + ':only-child', + ':first-of-type', + ':last-of-type', + ':only-of-type', + ':nth-child(3)', + ':nth-child(2n+1)', + ':nth-of-type(2)', + ':nth-last-child(2)', + ':nth-last-of-type(1)', + ':empty', + ':root', + ':scope', + ':not(.x)', + ':is(.x)', + ':where(p, a)', + ':has(p)', + ':has(> p)', + ':lang(en)', + ':dir(ltr)', + ':link', + ':any-link', + ':visited', + ':target', + ':enabled', + ':disabled', + ':checked', + ':indeterminate', + ':required', + ':optional', + ':valid', + ':invalid', + ':in-range', + ':out-of-range', + ':read-only', + ':read-write', + ':placeholder-shown', + ':default', + ':defined', + ':hover', + ':focus', + ':active', + ':muted', + ':playing', + ':paused', + ':seeking', + ':buffering', + ':stalled', + ':open', + ':closed', + ':modal', + ':fullscreen', + ':picture-in-picture', + ':popover-open', + ':local-link', + ] + + test('no resolver reads the host directly', () => { + const { NW } = build(MARKUP) + expect(NW.configure().LEGACY).toBe(true) + + const offenders = [] + for (const selector of SHAPES) { + for (const mode of [true, false]) { + let code + try { + const factory = NW.compile(selector, mode, null) + // a selector the fetch answers on its own compiles to no resolver + code = factory ? String(factory) : '' + } catch { + // a selector this build rejects is not this test's business + continue + } + const found = code.match(DIRECT) + if (found) { + offenders.push( + `${selector} [${mode ? 'select' : 'match'}] reads ${found[0]}`, + ) + } + } + } + expect(offenders).toEqual([]) + }) + + test('host-read text inside selector strings is data, not generated code', () => { + const { NW, document } = buildModern('
') + const a = document.getElementById('a') + for (const value of [ + 'e.localName', + 'n.parentElement', + 'o.id', + 'e.hasAttribute(x)', + 'e.getAttribute(x)', + 'e/localName', + 'e[localName]', + ]) { + a.setAttribute('data-x', value) + const quoted = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + for (const selector of [ + '[data-x="' + quoted + '"]', + ':not([data-x="' + quoted + '"])', + ':is([data-x="' + quoted + '"])', + ]) { + NW.configure({ LEGACY: false }) + const expected = ids(NW.select(selector, document)) + NW.configure({ LEGACY: true }) + expect(ids(NW.select(selector, document)), selector).toEqual(expected) + } + } + }) + + test('the ordinary path still reads the host directly', () => { + // the other half of the bargain: with the option off, the reads are + // written in place and cost neither a call nor a branch + const { NW } = buildModern(MARKUP) + expect(NW.configure().LEGACY).toBe(false) + expect(String(NW.compile('div p', true, null))).toContain('.localName') + expect(String(NW.compile('[href]', true, null))).toContain('.hasAttribute') + expect(String(NW.compile(':first-child', true, null))).toContain( + '.previousElementSibling', + ) + }) +}) + +describe('the attribute quirks that host had', () => { + // The subject of jQuery's attr/prop split and of David Mark's My-Library: + // getAttribute answered through the DOM property, so what came back was + // not always the markup a selector compares against. + test('a URL attribute compares as markup, not as the resolved URL', () => { + const { NW, host } = build(MARKUP) + const link = host.getElementById('a1') + + // what this host answers without the flag, which is not what the + // selector is asking about + expect(link.getAttribute('href')).toBe('http://legacy.example/go') + expect(link.getAttribute('href', 2)).toBe('./go') + + expect(ids(NW.select('a[href="./go"]', host))).toEqual(['a1']) + expect(ids(NW.select('a[href^="./"]', host))).toEqual(['a1']) + expect(ids(NW.select('a[href="http://legacy.example/go"]', host))).toEqual( + [], + ) + }) + + test('class and for are read through the property that host exposed', () => { + const { NW, host } = build(MARKUP) + const link = host.getElementById('a1') + + // the markup name answered nothing at all + expect(link.getAttribute('for')).toBeNull() + expect(link.htmlFor).toBe('x') + expect(host.getElementById('d1').getAttribute('class')).toBeNull() + expect(host.getElementById('d1').className).toBe('box wide') + + expect(ids(NW.select('.box', host))).toEqual(['d1', 'd2']) + expect(ids(NW.select('[class~="wide"]', host))).toEqual(['d1']) + expect(ids(NW.select('[for="x"]', host))).toEqual(['a1']) + }) + + test('a boolean attribute reads as the markup of the bare form', () => { + // The host answers the property, so '' and + // '' are indistinguishable. Mark settles that + // by reporting the empty string, which is the markup of the bare form, + // and this engine does the same: the presence test works either way and + // the value test agrees with the reference engine on the bare form. + const { NW, host, document } = build(MARKUP) + expect(host.getElementById('i1').getAttribute('checked')).toBe(true) + + expect(ids(NW.select('input[checked]', host))).toEqual(['i1']) + expect(ids(NW.select('input[disabled]', host))).toEqual(['i2']) + + for (const selector of [ + 'input[checked]', + 'input[checked=""]', + 'input[checked="checked"]', + ]) { + expect(ids(NW.select(selector, host)), selector).toEqual( + ids(document.querySelectorAll(selector)), + ) + } + }) + + test('a property default is not an attribute', () => { + // IE 6 and 7 answered getAttribute('enctype') with the form default when + // the markup had set nothing, so a value cannot decide presence. + const { NW, host, document } = build(MARKUP) + const form = host.getElementById('f1') + expect(form.getAttribute('enctype')).toBe( + 'application/x-www-form-urlencoded', + ) + expect(form.attributes.getNamedItem('enctype')).toBeNull() + + expect(ids(NW.select('form[enctype]', host))).toEqual([]) + expect(ids(document.querySelectorAll('form[enctype]'))).toEqual([]) + expect(NW.match('[enctype]', form)).toBe(false) + }) + + test('a host with no way to ask for the markup of a URL', () => { + // Opera up to 9.27 resolved a form action and took no second argument, + // so the read that answers the markup is detected rather than assumed. + const { NW, host, document } = build(MARKUP, { urls: 'plain' }) + const link = host.getElementById('a1') + expect(link.getAttribute('href')).toBe('http://legacy.example/go') + expect(link.getAttribute('href', 2)).toBe('http://legacy.example/go') + + for (const selector of ['a[href="./go"]', 'a[href^="./"]', 'a[href]']) { + expect(ids(NW.select(selector, host)), selector).toEqual( + ids(document.querySelectorAll(selector)), + ) + } + }) + + test('a style attribute is a presence test, not an object stringified', () => { + const { NW, host } = build(MARKUP) + expect(typeof host.getElementById('d3').getAttribute('style')).toBe( + 'object', + ) + expect(ids(NW.select('div[style]', host))).toEqual(['d3']) + }) + + test('an attribute the markup never set is absent', () => { + const { NW, host } = build(MARKUP) + expect(ids(NW.select('[data-missing]', host))).toEqual([]) + expect(ids(NW.select('input[readonly]', host))).toEqual([]) + expect(NW.match('[data-missing]', host.getElementById('d1'))).toBe(false) + }) +}) + +describe('what LEGACY does to a host that does not need it', () => { + test('explicit legacy handling works without WeakMap and survives document changes', () => { + const first = new JSDOM(MARKUP), + second = new JSDOM(MARKUP) + try { + const context = { + module: { exports: {} }, + exports: {}, + WeakMap: undefined, + } + vm.runInNewContext(readFileSync(nwsapiPath, 'utf8'), context) + const NW = context.module.exports({ + document: first.window.document, + DOMException: first.window.DOMException, + }) + expect(NW.configure('LEGACY')).toBe(false) + NW.configure({ LEGACY: true }) + expect(NW.configure('LEGACY')).toBe(true) + for (const dom of [first, second, first]) { + expect(ids(NW.select('div.box > p.a', dom.window.document))).toEqual( + ids(dom.window.document.querySelectorAll('div.box > p.a')), + ) + expect(NW.configure('LEGACY')).toBe(true) + } + } finally { + first.window.close() + second.window.close() + } + }) + + test('the same answers, with the handling forced on', () => { + const dom = new JSDOM(MARKUP) + const { document } = dom.window + delete require.cache[require.resolve(nwsapiPath)] + const NW = require(nwsapiPath)({ + document, + DOMException: dom.window.DOMException, + }) + expect(NW.configure().LEGACY, 'not detected on a modern host').toBe(false) + + const modern = SELECTORS.map(selector => ids(NW.select(selector, document))) + NW.configure({ LEGACY: true }) + expect(NW.configure().LEGACY).toBe(true) + const legacy = SELECTORS.map(selector => ids(NW.select(selector, document))) + NW.configure({ LEGACY: false }) + + for (let i = 0; i < SELECTORS.length; ++i) { + expect(legacy[i], SELECTORS[i]).toEqual(modern[i]) + expect(modern[i], SELECTORS[i]).toEqual( + ids(document.querySelectorAll(SELECTORS[i])), + ) + } + }) +}) From d81095d6b2ceca4ce1233ca1801011b216626164 Mon Sep 17 00:00:00 2001 From: jdalton Date: Mon, 7 Sep 2026 00:09:31 -0400 Subject: [PATCH 2/3] Refresh legacy validation and integration guidance --- docs/legacy.md | 35 ++++++++++++----------------------- 1 file changed, 12 insertions(+), 23 deletions(-) diff --git a/docs/legacy.md b/docs/legacy.md index a5508313..566c643a 100644 --- a/docs/legacy.md +++ b/docs/legacy.md @@ -51,31 +51,21 @@ The attribute handling follows David Mark's historical survey, The distinction remains useful when implementing custom DOM hosts; see [attributes versus properties](https://jakearchibald.com/2024/attributes-vs-properties/). -## Limits and pending integration +## Limits This extraction does not add browser features such as constraint validation or media state. It does not claim compatibility with historical XML engines. Use `select`, `match`, `first`, and `closest` when the host lacks the prototypes needed by `install()`. -The separate `:disabled`/`:enabled` correction in #190 and `:defined` correction -in #193 are not included. Their independent-reference assertions are preserved -as three expected failures. Remove those markers when integrating the fixes, -and adapt their helper reads to the legacy accessors. Other engine changes -from #167 are also outside this extraction. +The `:disabled`, `:enabled`, `:required`, and `:defined` helpers use legacy +accessors when needed. Their reference assertions pass without expected-failure +markers. Other changes from #167 remain outside this extraction. ### Combining the extracted PRs Keep these integration changes when their prerequisites land: -- In #190's `isDisabled`, use `tagOf`, `upOf`, `firstOf`, and `nextOf` - instead of `localName`, `parentElement`, `firstElementChild`, and - `nextElementSibling`. The disabled-fieldset and first-legend rules stay - unchanged. In #193's `isDefined`, use `tagOf(element)`, - `hasAttrOf(element, 'is')`, and `attrOf(element, 'is')`. -- With #178, clear `matcherDoc` and `matcherRecord` at the start of - `switchContext`, alongside other context-local shortcuts. Do not discard - the document-keyed WeakMap; its records remain reusable. - With #194, retain one `classOf` implementation and route its fallback through `attrOf(e, 'class')`. The modern read table should call the `classOf` helper; the legacy table should call `legacyClassOf`. Expose those helpers under @@ -92,24 +82,23 @@ Keep these integration changes when their prerequisites land: These are integration recipes, not claims that the prerequisites are included in this standalone branch. Validate the combined tree with both the original -legacy assertions and each prerequisite's focused tests before removing the -three expected-failure markers. +legacy assertions and each prerequisite's focused tests. ## Validation -Run with Node.js ≥ 22, using a patch supported by jsdom 30: 22.22.2 or later -in the 22 series, 24.15.0 or later in the 24 series, or 26 or later. +Use Node.js 26 and pnpm ≥ 12.3.4. ```sh pnpm install pnpm run test:legacy -pnpm run test:node test/legacy-runtime.test.mts +pnpm run test:node test/repo/unit/legacy-runtime.test.mts +pnpm run test:coverage ``` -The Vitest suite needs no browser download, web server, -or WPT checkout. The tests use jsdom 30.0.1 and a proxy host that hides modern -APIs and simulates attribute and collection quirks. These are simulated-host -tests, not executions in old browsers. Both raw-URL strategies are exercised. +The Node tests use jsdom and a proxy host that hides modern APIs and simulates +attribute and collection quirks. The WPT runner uses the same host fixture in +Chromium to check native answers and DOM mutations. Coverage uses WPT for the +engine and Node for the adapter. These tests do not run historical browsers. The suite retains the legacy tests from archived #167, including generated-code inspection, mode changes, scoped queries, missing `WeakMap`, and non-element From af615f0476e60591ad27dacb7f67453f11699e97 Mon Sep 17 00:00:00 2001 From: jdalton Date: Mon, 7 Sep 2026 00:21:06 -0400 Subject: [PATCH 3/3] Expand legacy host WPT coverage and raise coverage minimums --- .config/coverage.config.mts | 8 +-- assets/repo/coverage.svg | 2 +- .../e2e/upstream/fixtures/legacy-dom.html | 55 +++++++++++++++++++ test/repo/unit/coverage-sources.test.mts | 8 +-- 4 files changed, 64 insertions(+), 9 deletions(-) diff --git a/.config/coverage.config.mts b/.config/coverage.config.mts index f10ce3e6..9d85ace5 100644 --- a/.config/coverage.config.mts +++ b/.config/coverage.config.mts @@ -1,6 +1,6 @@ export const coverageThresholds = { - statements: 73, - branches: 61, - functions: 74, - lines: 70, + statements: 79, + branches: 70, + functions: 80, + lines: 78, } as const diff --git a/assets/repo/coverage.svg b/assets/repo/coverage.svg index dcfaeba1..e6582738 100644 --- a/assets/repo/coverage.svg +++ b/assets/repo/coverage.svg @@ -1 +1 @@ -coverage: 77%coverage77% +coverage: 78%coverage78% diff --git a/test/repo/e2e/upstream/fixtures/legacy-dom.html b/test/repo/e2e/upstream/fixtures/legacy-dom.html index d2fa9aa0..8aed0aa7 100644 --- a/test/repo/e2e/upstream/fixtures/legacy-dom.html +++ b/test/repo/e2e/upstream/fixtures/legacy-dom.html @@ -47,4 +47,59 @@ assert_equals(engine.select('.changed', host).length, 0); }, `Legacy DOM reads preserve native answers and live queries (${urls} URLs)`); } +test(t => { + const engine = NW.Dom; + const doc = document.implementation.createHTMLDocument('scoped'); + doc.body.innerHTML = '

text

'; + const host = legacyHost(doc); + const rawScope = doc.querySelector('section'); + const scope = host.getElementsByTagName('section')[0]; + t.add_cleanup(() => { engine.configure({ LEGACY: false }, true); }); + engine.configure({ LEGACY: true }, true); + for (const selector of ['#duplicate', 'p', '.item', 'p + p', 'p ~ p', ':first-child', ':last-child', ':nth-child(2)', ':nth-last-child(2)']) { + const expected = Array.from(rawScope.querySelectorAll(selector), node => node.outerHTML); + assert_array_equals(engine.select(selector, scope).map(node => node.outerHTML), expected, selector); + for (const node of Array.from(scope.getElementsByTagName('p')).filter(node => node.nodeType === 1)) { + assert_equals(engine.match(selector, node), expected.includes(node.outerHTML), selector); + } + } + const wrapped = host.createDocumentFragment(); + wrapped.appendChild(host.createComment('before')); + const child = host.createElement('div'); + const first = host.createElement('p'); + first.setAttribute('class', 'item'); + child.appendChild(first); + child.appendChild(host.createComment('gap')); + child.appendChild(host.createElement('p')); + wrapped.appendChild(child); + assert_equals(engine.select('p', wrapped).length, 2); + assert_equals(engine.select('.item', wrapped).length, 1); + assert_equals(engine.select('div > p + p', wrapped).length, 1); + assert_array_equals(engine.select('missing', wrapped), []); + assert_equals(engine.first('p', wrapped).nodeName, 'P'); +}, 'Legacy scoped and fragment queries skip comments and respect context boundaries'); + +test(t => { + const engine = NW.Dom; + const doc = document.implementation.createHTMLDocument('attributes'); + doc.body.innerHTML = '
'; + const host = legacyHost(doc, { urls: 'plain' }); + t.add_cleanup(() => { engine.configure({ LEGACY: false }, true); }); + engine.configure({ LEGACY: true }, true); + const link = host.getElementsByTagName('a')[0]; + assert_equals(engine.select('a[href="./go"]', host).length, 1); + assert_true(engine.match('[href="./go"]', link)); + assert_true(engine.match('[style]', link)); + assert_false(engine.match('[enctype]', host.getElementById('form'))); + assert_equals(engine.select('#form', host).length, 1); + const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', '#target'); + doc.body.append(svg); + for (const legacy of [true, false]) { + engine.configure({ LEGACY: legacy }, true); + const context = legacy ? host : doc; + assert_equals(engine.select('[*|href]', context).length, 2); + assert_array_equals(engine.select('[*|missing]', context), []); + } +}, 'Legacy attribute reads preserve URL markup, namespaced presence, and form IDs'); diff --git a/test/repo/unit/coverage-sources.test.mts b/test/repo/unit/coverage-sources.test.mts index 06ea2f9e..b6b27264 100644 --- a/test/repo/unit/coverage-sources.test.mts +++ b/test/repo/unit/coverage-sources.test.mts @@ -51,10 +51,10 @@ test('incomplete coverage cannot silently replace the combined badge', () => { test('aggregate coverage thresholds reject regressions in each metric', () => { const summary = { - statements: { pct: 73 }, - branches: { pct: 61 }, - functions: { pct: 74 }, - lines: { pct: 70 }, + statements: { pct: coverageThresholds.statements }, + branches: { pct: coverageThresholds.branches }, + functions: { pct: coverageThresholds.functions }, + lines: { pct: coverageThresholds.lines }, } expect(() => checkCoverageThresholds(summary)).not.toThrow() for (const metric of Object.keys(coverageThresholds)) {