Files
iptvnator/tools/testing/website-compare-pages.test.mjs
T
4grayandClaude Opus 5 f833e16cc4 feat(website): add the computer vs TV box comparison
Third page in the set, and the one that answers the question people ask
before installing anything: where should IPTV actually run. It compares the
two places rather than two products — everything that needs a keyboard
(adding a provider, attaching a guide, mapping a channel, working out why a
stream fails) against everything that needs a couch — and states plainly
that IPTVnator has no TV build, with the phone remote as the answer for a
laptop wired to the television.

No third-party software is named, so the page carries no ThirdPartyNote: the
rows describe the ordinary experience of each kind of device and say so in
the caption.

The compare hub's lead no longer claims every page is a choice inside the
app, and the three newest entries get distinct icons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 21:34:13 +02:00

95 lines
4.1 KiB
JavaScript

import { readFile } from 'node:fs/promises';
import { test } from 'node:test';
import assert from 'node:assert/strict';
/**
* Structural checks for the comparison pages: canonical URLs, the
* WebPage + FAQPage + BreadcrumbList structured data, a short verdict before
* the detail, at least one comparison table, cross-links and sitemap entries.
*/
const distRoot = new URL('../../dist/apps/website/', import.meta.url);
const SITE = 'https://4gray.github.io/iptvnator';
const COMPARISONS = [
'm3u-vs-xtream-vs-stalker',
'playback-engines',
'desktop-vs-browser',
'iptvnator-vs-vlc',
'iptvnator-vs-kodi',
'computer-vs-tv-box',
];
/** Pages that name other software must say they are not affiliated with it, and date their claims. */
const NAMES_THIRD_PARTY_SOFTWARE = new Set(['iptvnator-vs-vlc', 'iptvnator-vs-kodi']);
const readDist = (relativePath) => readFile(new URL(relativePath, distRoot), 'utf8');
function extractJsonLd(html) {
const blocks = [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)];
assert.ok(blocks.length > 0, 'Expected at least one JSON-LD script block.');
return blocks.flatMap((match) => JSON.parse(match[1]));
}
for (const slug of COMPARISONS) {
test(`comparison page ${slug}: canonical, schema and verdict`, async () => {
const html = await readDist(`compare/${slug}/index.html`);
assert.match(html, new RegExp(`<link rel="canonical" href="${SITE}/compare/${slug}/"`));
assert.match(html, /<meta name="robots" content="index, follow/);
assert.match(html, /Short answer/, 'Every comparison must state its verdict up front.');
assert.match(html, /<table/, 'Every comparison must carry a comparison table.');
const schema = extractJsonLd(html);
const page = schema.find((entry) => entry['@type'] === 'WebPage');
assert.ok(page, 'Expected a WebPage entry.');
assert.equal(page.url, `${SITE}/compare/${slug}/`);
const faq = schema.find((entry) => entry['@type'] === 'FAQPage');
assert.ok(faq && faq.mainEntity.length >= 5, 'Expected a FAQPage with at least five questions.');
const crumbs = schema.find((entry) => entry['@type'] === 'BreadcrumbList');
assert.equal(crumbs.itemListElement.at(-1).item, `${SITE}/compare/${slug}/`);
assert.ok(
!schema.some((entry) => entry['@type'] === 'SoftwareApplication'),
'A comparison page is guidance, not a product listing.'
);
if (NAMES_THIRD_PARTY_SOFTWARE.has(slug)) {
assert.match(html, /Not affiliated\./, 'A page naming other software must carry the disclaimer.');
assert.match(
html,
/Claims on this page were checked in \w+ \d{4}/,
'A page naming other software must date its claims.'
);
}
});
test(`comparison page ${slug}: links to the hub and the other comparisons`, async () => {
const html = await readDist(`compare/${slug}/index.html`);
assert.match(html, /href="\/iptvnator\/compare\/"/);
for (const other of COMPARISONS.filter((candidate) => candidate !== slug)) {
assert.match(html, new RegExp(`href="/iptvnator/compare/${other}/"`));
}
assert.match(html, /href="\/iptvnator\/blog\/[a-z0-9-]+\/"/, 'Expected a link into the guides.');
});
}
test('compare hub links to every comparison and to the feature pages', async () => {
const html = await readDist('compare/index.html');
assert.match(html, new RegExp(`<link rel="canonical" href="${SITE}/compare/"`));
for (const slug of COMPARISONS) {
assert.match(html, new RegExp(`href="/iptvnator/compare/${slug}/"`));
}
assert.match(html, /href="\/iptvnator\/features\/[a-z0-9-]+\/"/);
});
test('the header and the features hub link into the comparisons', async () => {
const home = await readDist('index.html');
assert.match(home, /href="\/iptvnator\/compare\/"/);
const features = await readDist('features/index.html');
assert.match(features, /href="\/iptvnator\/compare\/"/);
});
test('sitemap lists the comparison pages', async () => {
const sitemap = await readDist('sitemap-0.xml');
for (const path of ['compare/', ...COMPARISONS.map((slug) => `compare/${slug}/`)]) {
assert.match(sitemap, new RegExp(`<loc>${SITE}/${path}</loc>`));
}
});