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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
},
"scripts": {
"lint": "eslint ./src/nwsapi.js",
"test": "node --test test/repo/integration/maintenance.test.mts",
"test": "node --test test/repo/integration/*.test.mts",
"wpt:serve": "node test/wpt/wpt-launcher.mjs serve",
"wpt:setup": "node test/wpt/wpt-launcher.mjs setup",
"wpt:verify": "node test/wpt/wpt-launcher.mjs verify"
Expand Down
120 changes: 114 additions & 6 deletions src/nwsapi.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@
// emulate firefox error strings
qsNotArgs = 'Not enough arguments',
qsInvalid = ' is not a valid selector',
errors = 0,

// detect structural pseudo-classes in selectors
reNthElem = RegExp('(:nth(?:-last)?-child)', 'i'),
Expand Down Expand Up @@ -489,9 +490,9 @@
},

matchLogical =
function(selector) {
function(selector, prefix) {
var chr, close, escaped, depth = 1, i, l, quote = '',
match = selector.match(REX.LogicalPfx);
match = selector.match(prefix || REX.LogicalPfx);

if (!match) { return null; }

Expand All @@ -516,6 +517,93 @@
];
},

// Validate logical arguments even when the query has no candidates.
validateLogical =
function(argument, relative) {
var previousErrors = errors, selectVars = S_VARS,
matchVars = M_VARS, nodeVars = N_VARS,
list = splitList(argument), parsed, i, j;
S_VARS = [];
M_VARS = [];
N_VARS = [];
try {
for (i = 0; i < list.length; ++i) {
if (!list[i]) {
emit(qsInvalid);
return false;
}
parsed = parse(relative ? '* ' + list[i] : list[i], false);
if (!parsed) {
return false;
}
for (j = 0; j < parsed.length; ++j) {
compileSelector(parsed[j], '', relative, false);
}
}
return errors == previousErrors;
} finally {
S_VARS = selectVars;
M_VARS = matchVars;
N_VARS = nodeVars;
}
},

// Invalid nested branches disappear only inside forgiving logical lists.
prepareForgivingHas =
function(logical) {
var items = splitList(logical[2]), kept = [], item, i;
for (i = 0; i < items.length; ++i) {
item = prepareHas(items[i]);
if (item !== null) {
kept.push(item);
}
}
return ':' + logical[1] + '(' + (kept.join(',') || ':not(*)') + ')';
},

prepareHas =
function(text) {
var i = 0, quote = '', bracket = 0, chr, logical,
output = '', start = 0;
for (; i < text.length; ++i) {
chr = text.charAt(i);
if (chr == '\\') {
++i;
continue;
}
if (quote) {
if (chr == quote) {
quote = '';
}
continue;
}
if (chr == '"' || chr == "'") {
quote = chr;
continue;
}
if (chr == '[') {
++bracket;
continue;
}
if (chr == ']') {
--bracket;
continue;
}
if (bracket || chr != ':') {
continue;
}
if (/^:(?:has\(|:|(?:before|after|first-line|first-letter)(?![-\w]))/i.test(text.slice(i))) {
return null;
}
if (Config.FORGIVING && (logical = matchLogical(text.slice(i), /^:(is|where)\(/i))) {
output += text.slice(start, i) + prepareForgivingHas(logical);
i += logical[0].length - 1;
start = i + 1;
}
}
return output + text.slice(start);
},

method = {
'#': 'getElementById',
'*': 'getElementsByTagName',
Expand Down Expand Up @@ -934,8 +1022,10 @@
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; }
// Compiled selectors capture forgiving and error-reporting behavior.
if ((i == 'FORGIVING' || i == 'VERBOSITY') && Config[i] !== !!option[i]) {
clear = true;
}
Config[i] = !!option[i];
}
// clear lambda cache
Expand All @@ -953,6 +1043,7 @@
emit =
function(message, proto) {
var err;
++errors;
if (Config.VERBOSITY) {
if (proto) {
err = new proto(message);
Expand Down Expand Up @@ -1037,7 +1128,7 @@
'(?:' + pseudoparms + '?)?|' +
// universal * &
// namespace *|*
'(?:\\*|\\*\\|)|' +
'(?:\\*\\||\\*)|' +
'(?:' +
'(?::' + pseudonames +
'(?:\\x28' + pseudoparms + '?(?:\\x29|$))?|' +
Expand All @@ -1059,7 +1150,7 @@
'(?:' +
// universal * &
// namespace *|*
'(?:\\*|\\*\\|)|' +
'(?:\\*\\||\\*)|' +
'(?:[.#]?' + identifier + ')+|' +
'(?:' + attributes + ')+|' +
'(?:::?' + pseudonames + pseudoclass + ')|' +
Expand Down Expand Up @@ -1437,16 +1528,33 @@
source = 'if(s.matchForgiving(' +
JSON.stringify(splitList(match[2])) + ',e)){' + source + '}';
} else {
if (!validateLogical(match[2], false)) {
return '';
}
source = 'if(s.match("' + expr + '",e)){' + source + '}';
}
break;
case 'matches':
if (!validateLogical(match[2], false)) {
return '';
}
source = 'if(s.match("' + expr + '",e)){' + source + '}';
break;
case 'not':
if (!validateLogical(match[2], false)) {
return '';
}
source = 'if(!s.match("' + expr + '",e)){' + source + '}';
break;
case 'has':
match[2] = prepareHas(match[2]);
if (match[2] === null) {
emit('\'' + expression + '\'' + qsInvalid);
return '';
}
if (!validateLogical(match[2], true)) {
return '';
}
source = 'if(s.has(' + JSON.stringify(splitList(match[2])) + ',e)){' + source + '}';
break;
default:
Expand Down
169 changes: 169 additions & 0 deletions test/repo/integration/has-validation.test.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { test } from 'node:test'
import type { TestContext } from 'node:test'

const require = createRequire(import.meta.url)
const { JSDOM } = require('jsdom')
const source = readFileSync(
process.env['NWSAPI_TEST_SOURCE'] ||
new URL('../../../src/nwsapi.js', import.meta.url),
'utf8',
)

function fixture(t: TestContext) {
const { window } = new JSDOM(
'<!doctype html><div id="parent"><span id="child"></span></div>',
{
runScripts: 'outside-only',
},
)
t.after(() => window.close())
window.eval(source)
const engine = window.NW.Dom
engine.install()
return { window, document: window.document, engine }
}

for (const selector of [
':has()',
':has(123)',
':has(span, 123)',
':has(123, span)',
':has(, span)',
':has(span,)',
':has(:has(*))',
':has(span:has(*))',
':has(:not(:has(*)))',
':not(:has(123))',
':has(::before)',
':has(:before)',
':has(:unknown)',
]) {
test(`${selector} throws before visiting candidates in every selector API`, t => {
const { window, document, engine } = fixture(t)
const empty = document.createElement('section')
const parent = document.getElementById('parent')
const contexts = [
document,
document.implementation.createHTMLDocument(''),
parent,
empty,
document.createDocumentFragment(),
]
for (let repeat = 0; repeat < 2; repeat += 1) {
for (const context of contexts) {
for (const query of [
() => context.querySelector(selector),
() => context.querySelectorAll(selector),
() => engine.select(selector, context),
() => engine.first(selector, context),
]) {
assert.throws(
query,
error =>
error instanceof window.DOMException &&
error.name === 'SyntaxError',
)
}
}
for (const element of [parent, empty]) {
assert.throws(() => element.matches(selector), { name: 'SyntaxError' })
assert.throws(() => element.closest(selector), { name: 'SyntaxError' })
assert.throws(() => engine.match(selector, element), {
name: 'SyntaxError',
})
}
}
assert.equal(parent.matches(':has(> span)'), true)
})
}

for (const pseudo of ['is', 'where']) {
test(`:has() discards nested branches only inside forgiving :${pseudo}()`, t => {
const { document, engine } = fixture(t)
const parent = document.getElementById('parent')
for (const invalid of [':has(*)', ':not(:has(*))', '::before']) {
for (let repeat = 0; repeat < 2; repeat += 1) {
assert.equal(parent.matches(`:has(:${pseudo}(${invalid}))`), false)
const selector = `div:has(:${pseudo}(${invalid}, span))`
assert.equal(parent.matches(selector), true)
assert.deepEqual(Array.from(document.querySelectorAll(selector)), [
parent,
])
}
}
assert.equal(
parent.matches(`:has(:${pseudo}(:where(:has(*)), span))`),
true,
)
const selector = `div:has(:${pseudo}(:has(*), span))`
engine.configure({ FORGIVING: false })
assert.throws(() => document.createElement('div').querySelector(selector), {
name: 'SyntaxError',
})
assert.throws(() => parent.matches(selector), { name: 'SyntaxError' })
engine.configure({ FORGIVING: true })
assert.equal(parent.matches(selector), true)
})
}

test('wildcard namespaces work on either side of :has()', t => {
const { document } = fixture(t)
const parent = document.getElementById('parent')
for (const selector of [
'*|*:has(> span)',
'div:has(*|*)',
'div:has(> *|span)',
'*|div:has(> *|*)',
]) {
for (let repeat = 0; repeat < 2; repeat += 1) {
assert.deepEqual(Array.from(document.querySelectorAll(selector)), [
parent,
])
assert.equal(parent.matches(selector), true)
}
}
})

test('quoted and escaped pseudo text stays literal inside :has()', t => {
const { document } = fixture(t)
const parent = document.getElementById('parent')
const child = document.getElementById('child')
child.setAttribute('data-value', ':has(*), ::before')
child.className = ':has(*)'
for (const selector of [
'div:has(> [data-value=":has(*), ::before"])',
"div:has(> [data-value=':has(*), ::before'])",
String.raw`div:has(> .\:has\(\*\))`,
'div:has(> :is(:has(*), [data-value=":has(*), ::before"]))',
]) {
assert.deepEqual(Array.from(document.querySelectorAll(selector)), [parent])
assert.equal(parent.matches(selector), true)
}
})

test('quiet validation rejects the entire argument list and preserves verbose retries', t => {
const { document, engine } = fixture(t)
const parent = document.getElementById('parent')
for (const selector of [
'div:has()',
'div:has(span, 123)',
'div:has(123, span)',
'div:has(:has(*), span)',
'div:not(:has(123))',
]) {
for (let repeat = 0; repeat < 2; repeat += 1) {
engine.configure({ VERBOSITY: false, LOGERRORS: false })
assert.deepEqual(Array.from(engine.select(selector)), [])
assert.equal(engine.match(selector, parent), false)
engine.configure({ VERBOSITY: true })
assert.throws(() => engine.select(selector), { name: 'SyntaxError' })
assert.throws(() => engine.match(selector, parent), {
name: 'SyntaxError',
})
}
}
assert.equal(parent.matches(':has(> span)'), true)
})
Loading