Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 43 additions & 8 deletions src/nwsapi.mts
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,28 @@
// argument left unclosed is closed by EOF, as the CSS Syntax parser does
// with any open construct. Returns a match-like array so that callers can
// pop() the remainder the same way they do with a RegExp match.
splitList =
function(text) {
var chr, depth = 0, escaped, i = 0, l = text.length,
quote = '', start = 0, list = [ ];

for (; l > i; ++i) {
chr = text.charAt(i);
if (escaped) { escaped = false; continue; }
if (chr == '\\') { escaped = true; }
else if (quote) { if (chr == quote) { quote = ''; } }
else if (chr == '\x22' || chr == '\x27') { quote = chr; }
else if (chr == '\x28' || chr == '\x5b') { ++depth; }
else if (chr == '\x29' || chr == '\x5d') { --depth; }
else if (chr == ',' && depth === 0) {
list[list.length] = text.slice(start, i).replace(REX.TrimSpaces, '');
start = i + 1;
}
}
list[list.length] = text.slice(start).replace(REX.TrimSpaces, '');
return list;
},

matchLogical =
function(selector) {
var chr, close, escaped, depth = 1, i, l, quote = '',
Expand Down Expand Up @@ -868,6 +890,8 @@
if (typeof option == 'string') { return !!Config[option]; }
if (typeof option != 'object') { return Config; }
for (var i in option) {
// Compiled logical selectors capture the forgiving mode.
if (i == 'FORGIVING' && Config[i] !== !!option[i]) { clear = true; }
Config[i] = !!option[i];
}
// clear lambda cache
Expand Down Expand Up @@ -1363,10 +1387,8 @@
case 'is':
case 'where':
if (Config.FORGIVING) {
source =
'try{' +
'if(s.match("' + expr + '",e)){' + source + '}' +
'}catch(E){}';
source = 'if(s.matchForgiving(' +
JSON.stringify(splitList(match[2])) + ',e)){' + source + '}';
} else {
source = 'if(s.match("' + expr + '",e)){' + source + '}';
}
Expand Down Expand Up @@ -1908,7 +1930,7 @@

// parse, validate and split possible compound selectors
if ((selectors = parsed.match(reValidator)) && selectors.join('') == parsed) {
selectors = parsed.match(REX.SplitGroup);
selectors = splitList(parsed);
if (parsed[parsed.length - 1] == ',') {
emit(qsInvalid);
return Config.VERBOSITY ? undefined : (type ? none : false);
Expand All @@ -1929,7 +1951,7 @@
// the fragments compiled each of them as a selector of its own,
// which made 'div:not(:is(svg|div))' match every element in the
// document rather than the divs.
selectors = parsed.match(REX.SplitGroup) || [ parsed ];
selectors = splitList(parsed);
}
}

Expand All @@ -1938,7 +1960,7 @@

// equivalent of w3c 'matches' method
match =
function _matches(selectors, element, callback) {
function _matches(selectors, element, callback?: (element: Element) => unknown) {

var resolver;

Expand All @@ -1952,6 +1974,17 @@
return match_assert(resolver.factory, element, callback);
},

// Invalid items do not discard the remaining forgiving selectors.
matchForgiving =
function(list, element) {
for (var i = 0, l = list.length; l > i; ++i) {
try {
if (match(list[i], element)) { return true; }
} catch (e) { }
}
return false;
},

// true if element matches the selector
has =
function(selector, context, callback) {
Expand Down Expand Up @@ -2243,7 +2276,8 @@
HOVER?: EventTarget;
doc: Document; from: Node; root: Element;
byTag: typeof byTag; has: typeof has; first: typeof first;
match: typeof match; select: typeof select; ancestor: typeof ancestor;
match: typeof match; matchForgiving: typeof matchForgiving;
select: typeof select; ancestor: typeof ancestor;
nthOfType: typeof nthOfType; nthElement: typeof nthElement;
isOpen: typeof isOpen; isClosed: typeof isClosed; isModal: typeof isModal;
isFullscreen: typeof isFullscreen; isPictureInPicture: typeof isPictureInPicture;
Expand All @@ -2261,6 +2295,7 @@
has: has,
first: first,
match: match,
matchForgiving: matchForgiving,
select: select,

ancestor: ancestor,
Expand Down
119 changes: 119 additions & 0 deletions test/forgiving-list-items.test.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const assert = require('node:assert/strict')
import { test } from 'vitest'
const { JSDOM } = require('jsdom')
const factory = require('../src/nwsapi.js')

const markup =
'<!doctype html><body><div id="d" data-v="a,b"><p id="p" class="a,b">x</p></div><p id="q"></p>'
const cases: [string, string[]][] = [
['p:is(svg|p, p)', ['p', 'q']],
['div:is(svg|div, #d)', ['d']],
[':where(svg|p, p)', ['p', 'q']],
[':is(svg|p)', []],
['p:is(:unknown, #p)', ['p']],
['p:is(#p, :unknown)', ['p']],
['p:is(:unknown, :is(#p, #q))', ['p', 'q']],
['p:where(:unknown, :not(#q))', ['p']],
['div:is(:unknown, [data-v="a,b"])', ['d']],
["div:where(:unknown, [data-v='a,b'])", ['d']],
['p:is(:unknown, .a\\,b)', ['p']],
['div:not(:is(svg|div))', ['d']],
['div[data-v="a,b"], p:is(:unknown, #q)', ['d', 'q']],
['p:is(:unknown, #p), p:where(:unknown, #q)', ['p', 'q']],
['p:is(, #p,)', ['p']],
['p:is(:unknown)', []],
]

function fixture(t) {
const { window } = new JSDOM(markup)
t.onTestFinished(() => window.close())
return { document: window.document, nw: factory(window) }
}

for (const [selector, expected] of cases) {
test(selector, t => {
const { document, nw } = fixture(t)
for (let repeat = 0; repeat < 2; repeat++) {
assert.deepEqual(
nw.select(selector, document).map(e => e.id),
expected,
)
assert.equal(nw.first(selector, document)?.id, expected[0])
for (const id of ['d', 'p', 'q']) {
assert.equal(
nw.match(selector, document.getElementById(id)),
expected.includes(id),
id,
)
}
}
})
}

test('changing FORGIVING invalidates cached match and select resolvers', t => {
const { document, nw } = fixture(t)
const selector = 'p:is(svg|p, #p)'
for (let repeat = 0; repeat < 2; repeat++) {
nw.configure({ FORGIVING: true })
assert.deepEqual(
nw.select(selector, document).map(e => e.id),
['p'],
)
assert.equal(nw.match(selector, document.getElementById('p')), true)
nw.configure({ FORGIVING: false })
assert.throws(() => nw.select(selector, document), { name: 'SyntaxError' })
assert.throws(() => nw.match(selector, document.getElementById('p')), {
name: 'SyntaxError',
})
}
})

test('unforgiving and top-level invalid lists still throw', t => {
const { document, nw } = fixture(t)
for (const selector of ['svg|p', 'p,', 'p:not(svg|p, p)']) {
assert.throws(() => nw.select(selector, document), { name: 'SyntaxError' })
}
})

test(
'hexadecimal class escapes',
{
fails: true /* 'Existing class escape limitation, independent of list splitting' */,
},
t => {
const { document, nw } = fixture(t)
assert.equal(nw.match('p.a\\2c b', document.getElementById('p')), true)
},
)

test(
'Chromium agrees on every selector',
{ skip: !process.env.NWSAPI_BROWSER },
async () => {
const { chromium } = require('@playwright/test')
const { readFileSync } = require('node:fs')
const browser = await chromium.launch({ headless: true })
try {
const page = await browser.newPage()
await page.setContent(markup)
await page.addScriptTag({
content: readFileSync(require.resolve('../src/nwsapi.js'), 'utf8'),
})
for (const [selector, expected] of cases) {
const result = await page.evaluate(
selector => ({
native: Array.from(document.querySelectorAll(selector), e => e.id),
nwsapi: NW.Dom.select(selector, document).map(e => e.id),
}),
selector,
)
assert.deepEqual(result.native, expected, selector)
assert.deepEqual(result.nwsapi, expected, selector)
}
} finally {
await browser.close()
}
},
)
24 changes: 24 additions & 0 deletions test/forgiving-list-testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Forgiving selector lists

Run with Node.js ≥ 22:

```sh
pnpm install
pnpm run test:node test/forgiving-list-items.test.mts
```

For native Chromium comparisons:

```sh
pnpm install
pnpm exec playwright install chromium
NWSAPI_BROWSER=1 pnpm run test:node test/forgiving-list-items.test.mts
```

The tests preserve the per-item regression from #167. They cover invalid
namespaces, unknown pseudos, nested lists, quoted and escaped commas,
top-level lists, and repeated configuration changes. An expected-failure test records the
existing hexadecimal class-escape limitation in `match()`.

The [forgiving selector-list algorithm](https://drafts.csswg.org/selectors/#forgiving-selector)
discards invalid items without discarding their valid neighbors.