464 lines
16 KiB
TypeScript
464 lines
16 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { parseDOM } from 'htmlparser2';
|
|
import { type Cheerio } from './index.js';
|
|
import { cheerio, fruits, food, noscript } from './__fixtures__/fixtures.js';
|
|
import type { AnyNode, Element } from 'domhandler';
|
|
|
|
declare module './index.js' {
|
|
interface Cheerio<T> {
|
|
myPlugin(...args: unknown[]): {
|
|
context: Cheerio<T>;
|
|
args: unknown[];
|
|
};
|
|
foo(this: void): void;
|
|
}
|
|
}
|
|
|
|
function testAppleSelect($apple: ArrayLike<Element>) {
|
|
expect($apple).toHaveLength(1);
|
|
const apple = $apple[0];
|
|
expect(apple.parentNode).toHaveProperty('tagName', 'ul');
|
|
expect(apple.prev).toBe(null);
|
|
expect((apple.next as Element).attribs).toHaveProperty('class', 'orange');
|
|
expect(apple.childNodes).toHaveLength(1);
|
|
expect(apple.childNodes[0]).toHaveProperty('data', 'Apple');
|
|
}
|
|
|
|
describe('cheerio', () => {
|
|
it('cheerio(null) should be empty', () => {
|
|
expect(cheerio(null as never)).toHaveLength(0);
|
|
});
|
|
|
|
it('cheerio(undefined) should be empty', () => {
|
|
expect(cheerio(undefined)).toHaveLength(0);
|
|
});
|
|
|
|
it("cheerio('') should be empty", () => {
|
|
expect(cheerio('')).toHaveLength(0);
|
|
});
|
|
|
|
it('cheerio(selector) with no context or root should be empty', () => {
|
|
expect(cheerio('.h2')).toHaveLength(0);
|
|
expect(cheerio('#fruits')).toHaveLength(0);
|
|
});
|
|
|
|
it('cheerio(node) : should override previously-loaded nodes', () => {
|
|
const $ = cheerio.load('<div><span></span></div>');
|
|
const spanNode = $('span')[0];
|
|
const $span = $(spanNode);
|
|
expect($span[0]).toBe(spanNode);
|
|
});
|
|
|
|
it('should be able to create html without a root or context', () => {
|
|
const $h2 = cheerio('<h2>');
|
|
expect($h2).not.toHaveLength(0);
|
|
expect($h2).toHaveLength(1);
|
|
expect($h2[0]).toHaveProperty('tagName', 'h2');
|
|
});
|
|
|
|
it('should be able to create complicated html', () => {
|
|
const $script = cheerio(
|
|
'<script src="script.js" type="text/javascript"></script>',
|
|
) as Cheerio<Element>;
|
|
expect($script).not.toHaveLength(0);
|
|
expect($script).toHaveLength(1);
|
|
expect($script[0].attribs).toHaveProperty('src', 'script.js');
|
|
expect($script[0].attribs).toHaveProperty('type', 'text/javascript');
|
|
expect($script[0].childNodes).toHaveLength(0);
|
|
});
|
|
|
|
// eslint-disable-next-line vitest/expect-expect
|
|
it('should be able to select .apple with only a context', () => {
|
|
const $apple = cheerio('.apple', fruits);
|
|
testAppleSelect($apple);
|
|
});
|
|
|
|
// eslint-disable-next-line vitest/expect-expect
|
|
it('should be able to select .apple with a node as context', () => {
|
|
const $apple = cheerio('.apple', cheerio(fruits)[0]);
|
|
testAppleSelect($apple);
|
|
});
|
|
|
|
// eslint-disable-next-line vitest/expect-expect
|
|
it('should be able to select .apple with only a root', () => {
|
|
const $apple = cheerio('.apple', null, fruits);
|
|
testAppleSelect($apple);
|
|
});
|
|
|
|
it('should be able to select an id', () => {
|
|
const $fruits = cheerio('#fruits', null, fruits);
|
|
expect($fruits).toHaveLength(1);
|
|
expect($fruits[0].attribs).toHaveProperty('id', 'fruits');
|
|
});
|
|
|
|
it('should be able to select a tag', () => {
|
|
const $ul = cheerio('ul', fruits);
|
|
expect($ul).toHaveLength(1);
|
|
expect($ul[0].tagName).toBe('ul');
|
|
});
|
|
|
|
it('should accept a node reference as a context', () => {
|
|
const $elems = cheerio('<div><span></span></div>');
|
|
expect(cheerio('span', $elems[0])).toHaveLength(1);
|
|
});
|
|
|
|
it('should accept an array of node references as a context', () => {
|
|
const $elems = cheerio('<div><span></span></div>');
|
|
expect(cheerio('span', $elems.toArray())).toHaveLength(1);
|
|
});
|
|
|
|
it('should select only elements inside given context (Issue #193)', () => {
|
|
const $ = cheerio.load(food);
|
|
const $fruits = $('#fruits');
|
|
const fruitElements = $('li', $fruits);
|
|
|
|
expect(fruitElements).toHaveLength(3);
|
|
});
|
|
|
|
it('should be able to select multiple tags', () => {
|
|
const $fruits = cheerio('li', null, fruits);
|
|
expect($fruits).toHaveLength(3);
|
|
const classes = ['apple', 'orange', 'pear'];
|
|
$fruits.each((idx, $fruit) => {
|
|
expect($fruit.attribs).toHaveProperty('class', classes[idx]);
|
|
});
|
|
});
|
|
|
|
// eslint-disable-next-line vitest/expect-expect
|
|
it('should be able to do: cheerio("#fruits .apple")', () => {
|
|
const $apple = cheerio('#fruits .apple', fruits);
|
|
testAppleSelect($apple);
|
|
});
|
|
|
|
// eslint-disable-next-line vitest/expect-expect
|
|
it('should be able to do: cheerio("li.apple")', () => {
|
|
const $apple = cheerio('li.apple', fruits);
|
|
testAppleSelect($apple);
|
|
});
|
|
|
|
// eslint-disable-next-line vitest/expect-expect
|
|
it('should be able to select by attributes', () => {
|
|
const $apple = cheerio('li[class=apple]', fruits);
|
|
testAppleSelect($apple);
|
|
});
|
|
|
|
it('should be able to select multiple classes: cheerio(".btn.primary")', () => {
|
|
const $a = cheerio(
|
|
'.btn.primary',
|
|
'<p><a class="btn primary" href="#">Save</a></p>',
|
|
);
|
|
expect($a).toHaveLength(1);
|
|
expect($a[0].childNodes[0]).toHaveProperty('data', 'Save');
|
|
});
|
|
|
|
it('should not create a top-level node', () => {
|
|
const $elem = cheerio('* div', '<div>');
|
|
expect($elem).toHaveLength(0);
|
|
});
|
|
|
|
it('should be able to select multiple elements: cheerio(".apple, #fruits")', () => {
|
|
const $elems = cheerio('.apple, #fruits', fruits);
|
|
expect($elems).toHaveLength(2);
|
|
|
|
const $apple = $elems
|
|
.toArray()
|
|
.filter((elem) => elem.attribs['class'] === 'apple');
|
|
const $fruit = $elems
|
|
.toArray()
|
|
.find((elem) => elem.attribs['id'] === 'fruits');
|
|
testAppleSelect($apple);
|
|
expect($fruit?.attribs).toHaveProperty('id', 'fruits');
|
|
});
|
|
|
|
it('should select first element cheerio(:first)', () => {
|
|
const $elem = cheerio('li:first', fruits);
|
|
expect($elem.attr('class')).toBe('apple');
|
|
|
|
const $filtered = cheerio('li', fruits).filter(':even');
|
|
expect($filtered).toHaveLength(2);
|
|
});
|
|
|
|
it('should be able to select immediate children: cheerio("#fruits > .pear")', () => {
|
|
const $food = cheerio(food);
|
|
cheerio('.pear', $food).append('<li class="pear">Another Pear!</li>');
|
|
expect(cheerio('#fruits .pear', $food)).toHaveLength(2);
|
|
const $elem = cheerio('#fruits > .pear', $food);
|
|
expect($elem).toHaveLength(1);
|
|
expect($elem.attr('class')).toBe('pear');
|
|
});
|
|
|
|
it('should be able to select immediate children: cheerio(".apple + .pear")', () => {
|
|
expect(cheerio('.apple + li', fruits)).toHaveLength(1);
|
|
expect(cheerio('.apple + .pear', fruits)).toHaveLength(0);
|
|
const $elem = cheerio('.apple + .orange', fruits);
|
|
expect($elem).toHaveLength(1);
|
|
expect($elem.attr('class')).toBe('orange');
|
|
});
|
|
|
|
it('should be able to select immediate children: cheerio(".apple ~ .pear")', () => {
|
|
expect(cheerio('.apple ~ li', fruits)).toHaveLength(2);
|
|
expect(cheerio('.apple ~ .pear', fruits).attr('class')).toBe('pear');
|
|
});
|
|
|
|
it('should handle wildcards on attributes: cheerio("li[class*=r]")', () => {
|
|
const $elem = cheerio('li[class*=r]', fruits);
|
|
expect($elem).toHaveLength(2);
|
|
expect($elem.eq(0).attr('class')).toBe('orange');
|
|
expect($elem.eq(1).attr('class')).toBe('pear');
|
|
});
|
|
|
|
it('should handle beginning of attr selectors: cheerio("li[class^=o]")', () => {
|
|
const $elem = cheerio('li[class^=o]', fruits);
|
|
expect($elem).toHaveLength(1);
|
|
expect($elem.eq(0).attr('class')).toBe('orange');
|
|
});
|
|
|
|
it('should handle beginning of attr selectors: cheerio("li[class$=e]")', () => {
|
|
const $elem = cheerio('li[class$=e]', fruits);
|
|
expect($elem).toHaveLength(2);
|
|
expect($elem.eq(0).attr('class')).toBe('apple');
|
|
expect($elem.eq(1).attr('class')).toBe('orange');
|
|
});
|
|
|
|
it('(extended Array) should not interfere with prototype methods (issue #119)', () => {
|
|
const extended: AnyNode[] = [];
|
|
// @ts-expect-error - Ignore for testing
|
|
extended.find =
|
|
// @ts-expect-error - Ignore for testing
|
|
extended.children =
|
|
// @ts-expect-error - Ignore for testing
|
|
extended.each =
|
|
function () {
|
|
/* Ignore */
|
|
};
|
|
const $empty = cheerio(extended);
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
expect($empty.find).toBe(cheerio.prototype.find);
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
expect($empty.children).toBe(cheerio.prototype.children);
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
expect($empty.each).toBe(cheerio.prototype.each);
|
|
});
|
|
|
|
it('cheerio.html(null) should return a "" string', () => {
|
|
expect(cheerio.html(null as never)).toBe('');
|
|
});
|
|
|
|
it('should set html(number) as a string', () => {
|
|
const $elem = cheerio('<div>');
|
|
$elem.html(123 as never);
|
|
expect(typeof $elem.text()).toBe('string');
|
|
});
|
|
|
|
it('should set text(number) as a string', () => {
|
|
const $elem = cheerio('<div>');
|
|
$elem.text(123 as never);
|
|
expect(typeof $elem.text()).toBe('string');
|
|
});
|
|
|
|
describe('.load', () => {
|
|
it('should generate selections as proper instances', () => {
|
|
const $ = cheerio.load(fruits);
|
|
|
|
expect($('.apple')).toBeInstanceOf($);
|
|
});
|
|
|
|
// Issue #1092
|
|
it('should handle a character `)` in `:contains` selector', () => {
|
|
const result = cheerio.load('<p>)aaa</p>')(
|
|
String.raw`:contains('\)aaa')`,
|
|
);
|
|
expect(result).toHaveLength(3);
|
|
expect(result.first().prop('tagName')).toBe('HTML');
|
|
expect(result.eq(1).prop('tagName')).toBe('BODY');
|
|
expect(result.last().prop('tagName')).toBe('P');
|
|
});
|
|
|
|
it('should be able to filter down using the context', () => {
|
|
const $ = cheerio.load(fruits);
|
|
const apple = $('.apple', 'ul');
|
|
const lis = $('li', 'ul');
|
|
|
|
expect(apple).toHaveLength(1);
|
|
expect(lis).toHaveLength(3);
|
|
});
|
|
|
|
it('should preserve root content', () => {
|
|
const $ = cheerio.load(fruits);
|
|
// Root should not be overwritten
|
|
const el = $('<div></div>');
|
|
expect(Object.is(el, el._root)).toBe(false);
|
|
// Query has to have results
|
|
expect($('li', 'ul')).toHaveLength(3);
|
|
});
|
|
|
|
it('should allow loading a pre-parsed DOM', () => {
|
|
const dom = parseDOM(food);
|
|
const $ = cheerio.load(dom);
|
|
|
|
expect($('ul')).toHaveLength(3);
|
|
});
|
|
|
|
it('should allow loading a single element', () => {
|
|
const el = parseDOM(food)[0];
|
|
const $ = cheerio.load(el);
|
|
|
|
expect($('ul')).toHaveLength(3);
|
|
});
|
|
|
|
it('should render xml in html() when options.xml = true', () => {
|
|
const str = '<MixedCaseTag UPPERCASEATTRIBUTE=""></MixedCaseTag>';
|
|
const expected = '<MixedCaseTag UPPERCASEATTRIBUTE=""/>';
|
|
const $ = cheerio.load(str, { xml: true });
|
|
|
|
expect($('MixedCaseTag').get(0)).toHaveProperty(
|
|
'tagName',
|
|
'MixedCaseTag',
|
|
);
|
|
expect($.html()).toBe(expected);
|
|
});
|
|
|
|
it('should render xml in html() when options.xml = true passed to html()', () => {
|
|
const str = '<MixedCaseTag UPPERCASEATTRIBUTE=""></MixedCaseTag>';
|
|
// Since parsing done without xml flag, all tags converted to lowercase
|
|
const expectedXml =
|
|
'<html><head/><body><mixedcasetag uppercaseattribute=""/></body></html>';
|
|
const expectedNoXml =
|
|
'<html><head></head><body><mixedcasetag uppercaseattribute=""></mixedcasetag></body></html>';
|
|
const $ = cheerio.load(str);
|
|
|
|
expect($('MixedCaseTag').get(0)).toHaveProperty(
|
|
'tagName',
|
|
'mixedcasetag',
|
|
);
|
|
expect($.html()).toBe(expectedNoXml);
|
|
expect($.html({ xml: true })).toBe(expectedXml);
|
|
});
|
|
|
|
it('should respect options on the element level', () => {
|
|
const str =
|
|
'<!doctype html><html><head><title>Some test</title></head><body><footer><p>Copyright © 2003-2014</p></footer></body></html>';
|
|
const expectedHtml = '<p>Copyright © 2003-2014</p>';
|
|
const expectedXml = '<p>Copyright © 2003-2014</p>';
|
|
const domNotEncoded = cheerio.load(str, {
|
|
xml: { decodeEntities: false },
|
|
});
|
|
const domEncoded = cheerio.load(str);
|
|
|
|
expect(domNotEncoded('footer').html()).toBe(expectedHtml);
|
|
expect(domEncoded('footer').html()).toBe(expectedXml);
|
|
});
|
|
|
|
it('should use htmlparser2 if xml option is used', () => {
|
|
const str = '<div></div>';
|
|
const dom = cheerio.load(str, null, false);
|
|
expect(dom.html()).toBe(str);
|
|
});
|
|
|
|
it('should return a fully-qualified Function', () => {
|
|
const $ = cheerio.load('<div>');
|
|
|
|
expect($).toBeInstanceOf(Function);
|
|
});
|
|
|
|
describe('prototype extensions', () => {
|
|
it('should honor extensions defined on `prototype` property', () => {
|
|
const $ = cheerio.load('<div>');
|
|
|
|
($.prototype as Cheerio<AnyNode>).myPlugin = function (
|
|
...args: unknown[]
|
|
) {
|
|
return {
|
|
context: this,
|
|
args,
|
|
};
|
|
};
|
|
|
|
const $div = $('div');
|
|
|
|
expect(typeof $div.myPlugin).toBe('function');
|
|
expect($div.myPlugin().context).toBe($div);
|
|
expect($div.myPlugin(1, 2, 3).args).toStrictEqual([1, 2, 3]);
|
|
});
|
|
|
|
it('should honor extensions defined on `fn` property', () => {
|
|
const $ = cheerio.load('<div>');
|
|
$.fn.myPlugin = function (...args: unknown[]) {
|
|
return {
|
|
context: this,
|
|
args,
|
|
};
|
|
};
|
|
|
|
const $div = $('div');
|
|
|
|
expect(typeof $div.myPlugin).toBe('function');
|
|
expect($div.myPlugin().context).toBe($div);
|
|
expect($div.myPlugin(1, 2, 3).args).toStrictEqual([1, 2, 3]);
|
|
});
|
|
|
|
it('should isolate extensions between loaded functions', () => {
|
|
const $a = cheerio.load('<div>');
|
|
const $b = cheerio.load('<div>');
|
|
|
|
($a.prototype as Cheerio<AnyNode>).foo = function () {
|
|
/* Ignore */
|
|
};
|
|
|
|
expect($b('div').foo).toBeUndefined();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('parse5 options', () => {
|
|
// Should parse noscript tags only with false option value
|
|
it('{scriptingEnabled: ???}', () => {
|
|
// [default] `scriptingEnabled: true` - tag contains one text element
|
|
const withScripts = cheerio.load(noscript)('noscript');
|
|
expect(withScripts).toHaveLength(1);
|
|
expect(withScripts[0].children).toHaveLength(1);
|
|
expect(withScripts[0].children[0].type).toBe('text');
|
|
|
|
// `scriptingEnabled: false` - content of noscript will parsed
|
|
const noScripts = cheerio.load(noscript, { scriptingEnabled: false })(
|
|
'noscript',
|
|
);
|
|
expect(noScripts).toHaveLength(1);
|
|
expect(noScripts[0].children).toHaveLength(2);
|
|
expect(noScripts[0].children[0].type).toBe('comment');
|
|
expect(noScripts[0].children[1].type).toBe('tag');
|
|
expect(noScripts[0].children[1]).toHaveProperty('name', 'a');
|
|
|
|
// `scriptingEnabled: ???` - should acts as true
|
|
for (const val of [undefined, null, 0, '']) {
|
|
const options = { scriptingEnabled: val as never };
|
|
const result = cheerio.load(noscript, options)('noscript');
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].children).toHaveLength(1);
|
|
expect(result[0].children[0].type).toBe('text');
|
|
}
|
|
});
|
|
|
|
// Should contain location data only with truthful option value
|
|
it('{sourceCodeLocationInfo: ???}', () => {
|
|
// Location data should not be present
|
|
for (const val of [undefined, null, 0, false, '']) {
|
|
const options = { sourceCodeLocationInfo: val as never };
|
|
const result = cheerio.load(noscript, options)('noscript');
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0]).not.toHaveProperty('sourceCodeLocation');
|
|
}
|
|
|
|
// Location data should be present
|
|
for (const val of [true, 1, 'test']) {
|
|
const options = { sourceCodeLocationInfo: val as never };
|
|
const result = cheerio.load(noscript, options)('noscript');
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0]).toHaveProperty('sourceCodeLocation');
|
|
expect(typeof result[0].sourceCodeLocation).toBe('object');
|
|
}
|
|
});
|
|
});
|
|
});
|