145 lines
5.8 KiB
JavaScript
145 lines
5.8 KiB
JavaScript
// Smoke test for the Empty Link Fixer content script.
|
|
//
|
|
// Runs the actual config.js + content.js against jsdom pages and verifies that
|
|
// empty anchors are filled — statically, on dynamically added content, emptied
|
|
// text, late href attributes and inside (open) shadow DOM — while links that
|
|
// have text or media content are left alone.
|
|
//
|
|
// Usage: node tests/smoke.mjs (needs `npm install --no-save jsdom`)
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import assert from 'node:assert/strict';
|
|
import { JSDOM } from 'jsdom';
|
|
|
|
const dir = fileURLToPath(new URL('..', import.meta.url));
|
|
const configJs = readFileSync(`${dir}config.js`, 'utf8');
|
|
const contentJs = readFileSync(`${dir}content.js`, 'utf8');
|
|
|
|
const tick = (ms = 40) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
function boot(html, { url = 'https://example.com/', hosts = ['example.com'] } = {}) {
|
|
const dom = new JSDOM(html, {
|
|
url,
|
|
runScripts: 'outside-only',
|
|
pretendToBeVisual: true,
|
|
});
|
|
const { window } = dom;
|
|
window.eval(configJs);
|
|
window.eval(`globalThis.LINK_FIX_CONFIG = ${JSON.stringify({
|
|
hosts,
|
|
includeSubdomains: true,
|
|
rescanIntervalMs: 0, // disable the safety-net timer during tests
|
|
debug: false,
|
|
})};`);
|
|
window.eval(contentJs);
|
|
return dom;
|
|
}
|
|
|
|
const $ = (dom, sel) => dom.window.document.querySelector(sel);
|
|
|
|
async function run() {
|
|
// ---------------------------------------------------------------- static
|
|
{
|
|
const dom = boot(`
|
|
<a id="e1" href="https://example.com/a"></a>
|
|
<a id="e2" href="/docs">Already has text</a>
|
|
<a id="e3" href="/icon"><img src="x.png" alt="icon"></a>
|
|
<a id="e4" href="/svg"><svg viewBox="0 0 10 10"><circle cx="5" cy="5" r="5"/></svg></a>
|
|
<a id="e5" href="/ws"> </a>
|
|
<a id="e6" href="#"></a>
|
|
<a id="e7" href="javascript:void(0)"></a>
|
|
<a id="e8" href="/hidden" style="display:none"></a>
|
|
`);
|
|
await tick();
|
|
assert.equal($(dom, '#e1').textContent, 'https://example.com/a', 'empty anchor filled with href');
|
|
assert.equal($(dom, '#e2').textContent.trim(), 'Already has text', 'text anchor untouched');
|
|
assert.equal($(dom, '#e3').textContent, '', 'image anchor untouched');
|
|
assert.ok($(dom, '#e3').querySelector('img'), 'image preserved inside anchor');
|
|
assert.equal($(dom, '#e4').textContent, '', 'svg anchor untouched');
|
|
assert.ok($(dom, '#e4').querySelector('svg'), 'svg preserved inside anchor');
|
|
assert.equal($(dom, '#e5').textContent.trim(), '/ws', 'whitespace-only anchor filled');
|
|
assert.equal($(dom, '#e6').textContent, '', 'placeholder href="#" not filled');
|
|
assert.equal($(dom, '#e7').textContent, '', 'javascript: href not filled');
|
|
assert.equal($(dom, '#e8').textContent.trim(), '/hidden', 'empty anchor filled regardless of visibility');
|
|
dom.window.close();
|
|
}
|
|
|
|
// ------------------------------------------------- dynamically inserted
|
|
{
|
|
const dom = boot('<div id="root"></div>');
|
|
await tick();
|
|
dom.window.document.querySelector('#root').innerHTML =
|
|
'<a href="/dyn"></a><a href="/kept">keep me</a>';
|
|
await tick();
|
|
assert.equal($(dom, 'a[href="/dyn"]').textContent.trim(), '/dyn', 'dynamically added empty anchor filled');
|
|
assert.equal($(dom, 'a[href="/kept"]').textContent.trim(), 'keep me', 'dynamically added text anchor untouched');
|
|
dom.window.close();
|
|
}
|
|
|
|
// ------------------------------------------------- text emptied in place
|
|
{
|
|
const dom = boot('<a id="t1" href="/t1">content here</a>');
|
|
await tick();
|
|
// Simulate a framework emptying the text node in place (characterData).
|
|
dom.window.document.querySelector('#t1').firstChild.nodeValue = '';
|
|
await tick();
|
|
assert.equal($(dom, '#t1').textContent.trim(), '/t1', 'anchor re-filled after its text node was emptied');
|
|
dom.window.close();
|
|
}
|
|
|
|
// ------------------------------------------------- children replaced
|
|
{
|
|
const dom = boot('<a id="t2" href="/t2">content here</a>');
|
|
await tick();
|
|
// Simulate a re-render that wipes the inner content entirely.
|
|
dom.window.document.querySelector('#t2').textContent = '';
|
|
await tick();
|
|
assert.equal($(dom, '#t2').textContent.trim(), '/t2', 'anchor re-filled after children were removed');
|
|
dom.window.close();
|
|
}
|
|
|
|
// ------------------------------------------------- href added later
|
|
{
|
|
const dom = boot('<a id="t3"></a>');
|
|
await tick();
|
|
dom.window.document.querySelector('#t3').setAttribute('href', '/later');
|
|
await tick();
|
|
assert.equal($(dom, '#t3').textContent.trim(), '/later', 'anchor filled when href appears dynamically');
|
|
dom.window.close();
|
|
}
|
|
|
|
// ------------------------------------------------- open shadow DOM
|
|
{
|
|
const dom = boot('<div id="host"></div>');
|
|
const { document } = dom.window;
|
|
await tick();
|
|
const host = document.querySelector('#host');
|
|
const root = host.attachShadow({ mode: 'open' });
|
|
root.innerHTML = '<a href="/shadow"></a><a href="/shadow2">shadow text</a>';
|
|
document.body.appendChild(host); // host enters the DOM after init
|
|
await tick();
|
|
assert.equal(root.querySelector('a[href="/shadow"]').textContent.trim(), '/shadow', 'anchor inside open shadow root filled');
|
|
assert.equal(root.querySelector('a[href="/shadow2"]').textContent.trim(), 'shadow text', 'text anchor in shadow root untouched');
|
|
dom.window.close();
|
|
}
|
|
|
|
// ------------------------------------------------- different site
|
|
{
|
|
const dom = boot('<a id="off" href="https://other.com/x"></a>', {
|
|
url: 'https://somewhere-else.com/',
|
|
hosts: ['example.com'],
|
|
});
|
|
await tick();
|
|
assert.equal($(dom, '#off').textContent, '', 'nothing happens on a non-configured site');
|
|
dom.window.close();
|
|
}
|
|
|
|
console.log('All smoke tests passed ✔');
|
|
}
|
|
|
|
run().catch((err) => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|