diff --git a/tests/unit/utils/get-core-content-text.test.mjs b/tests/unit/utils/get-core-content-text.test.mjs new file mode 100644 index 000000000..8fbf44403 --- /dev/null +++ b/tests/unit/utils/get-core-content-text.test.mjs @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { after, afterEach, test } from 'node:test' +import { JSDOM } from 'jsdom' + +const require = createRequire(import.meta.url) +const readability = require('@mozilla/readability') +const originalIsProbablyReaderable = readability.isProbablyReaderable +readability.isProbablyReaderable = () => false +const { getCoreContentText } = await import('../../../src/utils/get-core-content-text.mjs') + +const globalNames = ['document', 'location', 'Node'] +const originalDescriptors = new Map( + globalNames.map((name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)]), +) +let dom + +after(() => { + readability.isProbablyReaderable = originalIsProbablyReaderable +}) + +const setDOM = (html, url = 'https://example.com/') => { + dom = new JSDOM(html, { url }) + + for (const name of globalNames) { + Object.defineProperty(globalThis, name, { + value: dom.window[name], + configurable: true, + }) + } +} + +afterEach(() => { + dom?.window.close() + dom = undefined + + for (const [name, descriptor] of originalDescriptors) { + if (descriptor) { + Object.defineProperty(globalThis, name, descriptor) + } else { + delete globalThis[name] + } + } +}) + +test('getCoreContentText prefers the known-site selector over a generic article', () => { + setDOM( + ` +
Known site content
+
Generic article content
+ `, + 'https://www.google.com/search?q=test', + ) + + assert.equal(getCoreContentText(), 'Known site content') +}) + +test('getCoreContentText uses an article and normalizes its text', () => { + setDOM(` +
First paragraph + + Second,,
+ `) + + assert.equal(getCoreContentText(), 'FirstparagraphSecond') +}) + +test('getCoreContentText falls back to a small document body', (t) => { + t.mock.method(console, 'log', () => {}) + setDOM('Body fallback content') + + assert.equal(getCoreContentText(), 'Body fallback content') +}) + +test('getCoreContentText falls back to the largest body child', (t) => { + t.mock.method(console, 'log', () => {}) + setDOM(` + +
Largest content
+ + + `) + + document.body.getBoundingClientRect = () => ({ width: 100, height: 100 }) + document.querySelector('#content').getBoundingClientRect = () => ({ width: 80, height: 80 }) + document.querySelector('aside').getBoundingClientRect = () => ({ width: 20, height: 20 }) + + assert.equal(getCoreContentText(), 'Largest content') +}) + +test('getCoreContentText uses a major nested content element', (t) => { + t.mock.method(console, 'log', () => {}) + setDOM(` + +
+
Nested content
+ +
+ + `) + + document.body.getBoundingClientRect = () => ({ width: 100, height: 100 }) + document.querySelector('#content').getBoundingClientRect = () => ({ width: 80, height: 80 }) + document.querySelector('#nested-content').getBoundingClientRect = () => ({ + width: 60, + height: 60, + }) + document.querySelector('aside').getBoundingClientRect = () => ({ width: 10, height: 10 }) + + assert.equal(getCoreContentText(), 'Nested content') +}) diff --git a/tests/unit/utils/get-possible-element-by-query-selector.test.mjs b/tests/unit/utils/get-possible-element-by-query-selector.test.mjs new file mode 100644 index 000000000..048e70193 --- /dev/null +++ b/tests/unit/utils/get-possible-element-by-query-selector.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict' +import { afterEach, test } from 'node:test' +import { getPossibleElementByQuerySelector } from '../../../src/utils/get-possible-element-by-query-selector.mjs' + +const originalDocumentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document') + +const restoreDocument = () => { + if (originalDocumentDescriptor) { + Object.defineProperty(globalThis, 'document', originalDocumentDescriptor) + } else { + delete globalThis.document + } +} + +const setDocument = (querySelector) => { + Object.defineProperty(globalThis, 'document', { + value: { querySelector }, + configurable: true, + }) +} + +afterEach(() => { + restoreDocument() +}) + +test('getPossibleElementByQuerySelector returns the first matching selector', () => { + const firstMatch = { id: 'first' } + const calls = [] + setDocument((selector) => { + calls.push(selector) + return selector === '.first' ? firstMatch : { id: 'later' } + }) + + const result = getPossibleElementByQuerySelector(['.first', '.later']) + + assert.equal(result, firstMatch) + assert.deepEqual(calls, ['.first']) +}) + +test('getPossibleElementByQuerySelector falls through missing and invalid selectors', () => { + const fallbackMatch = { id: 'fallback' } + const calls = [] + setDocument((selector) => { + calls.push(selector) + if (selector === '[') throw new DOMException('Invalid selector', 'SyntaxError') + if (selector === '.fallback') return fallbackMatch + return null + }) + + const result = getPossibleElementByQuerySelector(['.missing', '[', '.fallback']) + + assert.equal(result, fallbackMatch) + assert.deepEqual(calls, ['.missing', '[', '.fallback']) +}) + +test('getPossibleElementByQuerySelector returns undefined without usable matches', () => { + const calls = [] + setDocument((selector) => { + calls.push(selector) + return null + }) + + assert.equal(getPossibleElementByQuerySelector(null), undefined) + assert.equal(getPossibleElementByQuerySelector([]), undefined) + assert.equal(getPossibleElementByQuerySelector(['', null, '.missing']), undefined) + assert.deepEqual(calls, ['.missing']) +}) diff --git a/tests/unit/utils/limited-fetch.test.mjs b/tests/unit/utils/limited-fetch.test.mjs new file mode 100644 index 000000000..ec34143fc --- /dev/null +++ b/tests/unit/utils/limited-fetch.test.mjs @@ -0,0 +1,166 @@ +import assert from 'node:assert/strict' +import { afterEach, test } from 'node:test' +import { limitedFetch } from '../../../src/utils/limited-fetch.mjs' + +const originalXMLHttpRequestDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'XMLHttpRequest', +) + +const restoreXMLHttpRequest = () => { + if (originalXMLHttpRequestDescriptor) { + Object.defineProperty(globalThis, 'XMLHttpRequest', originalXMLHttpRequestDescriptor) + } else { + delete globalThis.XMLHttpRequest + } +} + +const installFakeXMLHttpRequest = ({ openError, sendError } = {}) => { + const requests = [] + + class FakeXMLHttpRequest { + constructor() { + this.aborted = false + requests.push(this) + } + + open(method, url) { + if (openError) throw openError + this.method = method + this.url = url + } + + send() { + if (sendError) throw sendError + this.sent = true + } + + abort() { + this.aborted = true + } + } + + Object.defineProperty(globalThis, 'XMLHttpRequest', { + value: FakeXMLHttpRequest, + configurable: true, + }) + + return requests +} + +afterEach(() => { + restoreXMLHttpRequest() +}) + +test('limitedFetch keeps downloading while progress remains below the byte limit', async () => { + const requests = installFakeXMLHttpRequest() + const responsePromise = limitedFetch('https://example.com/data', 5) + let settled = false + const settlementPromise = responsePromise.then( + () => { + settled = true + }, + () => { + settled = true + }, + ) + const [request] = requests + + request.onprogress({ + loaded: 4, + target: { responseText: 'data' }, + }) + await Promise.resolve() + + assert.equal(request.aborted, false) + assert.equal(settled, false) + + request.onload({ + target: { responseText: 'data' }, + }) + + assert.equal(await responsePromise, 'data') + await settlementPromise + assert.equal(request.method, 'GET') + assert.equal(request.url, 'https://example.com/data') + assert.equal(request.sent, true) +}) + +test('limitedFetch truncates and aborts when progress reaches the byte limit', async () => { + const requests = installFakeXMLHttpRequest() + const responsePromise = limitedFetch('https://example.com/data', 5) + const [request] = requests + + request.onprogress({ + loaded: 5, + target: { responseText: '123456789' }, + }) + + assert.equal(await responsePromise, '12345') + assert.equal(request.aborted, true) +}) + +test('limitedFetch truncates a completed response without aborting it', async () => { + const requests = installFakeXMLHttpRequest() + const responsePromise = limitedFetch('https://example.com/data', 4) + const [request] = requests + + request.onload({ + target: { responseText: 'abcdefgh' }, + }) + + assert.equal(await responsePromise, 'abcd') + assert.equal(request.aborted, false) +}) + +test('limitedFetch rejects with the XHR status when the request fails', async () => { + const requests = installFakeXMLHttpRequest() + const responsePromise = limitedFetch('https://example.com/data', 10) + const [request] = requests + + request.onerror({ + target: { status: 503 }, + }) + + await assert.rejects(responsePromise, { + name: 'Error', + message: '503', + }) +}) + +test('limitedFetch rejects when constructing XMLHttpRequest throws', async () => { + const constructorError = new Error('XMLHttpRequest unavailable') + Object.defineProperty(globalThis, 'XMLHttpRequest', { + value: class { + constructor() { + throw constructorError + } + }, + configurable: true, + }) + + await assert.rejects(limitedFetch('https://example.com/data', 10), (error) => { + assert.equal(error, constructorError) + return true + }) +}) + +test('limitedFetch rejects when opening the request throws', async () => { + const openError = new Error('Invalid URL') + installFakeXMLHttpRequest({ openError }) + + await assert.rejects(limitedFetch('not a url', 10), (error) => { + assert.equal(error, openError) + return true + }) +}) + +test('limitedFetch rejects when sending the request throws', async () => { + const sendError = new Error('Request blocked') + installFakeXMLHttpRequest({ sendError }) + + await assert.rejects(limitedFetch('https://example.com/data', 10), (error) => { + assert.equal(error, sendError) + return true + }) +})