import { access, readFile } from 'node:fs/promises'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import { JSDOM } from 'jsdom'; import { parse as parseYaml } from 'yaml'; import { launchBrowser, serveDist } from './website-browser-support.mjs'; /** * Structural checks for guide posts: they must carry FAQPage structured data * next to the BlogPosting entry, link to the download hub, and reference only * screenshots that the build actually shipped. */ const distRoot = new URL('../../dist/apps/website/', import.meta.url); const SITE = 'https://4gray.github.io/iptvnator'; const GUIDES = [ { slug: 'player-controls-guide', screenshots: ['blog/feature-guides/screenshots/guide-player-subtitles-dark.png'], }, { slug: 'library-organization-guide', screenshots: ['blog/feature-guides/screenshots/guide-library-watched-dark.png'], }, { slug: 'stable-nightly-updates-guide', screenshots: ['blog/feature-guides/screenshots/guide-update-channel-dark.png'], }, { slug: 'playlist-backup-restore-guide', screenshots: ['blog/feature-guides/screenshots/guide-backup-dark.png'], }, { slug: 'fullscreen-channel-episode-guide', screenshots: ['blog/feature-guides/screenshots/guide-fullscreen-channels-dark.png'], }, { slug: 'stream-info-diagnostics-guide', screenshots: ['blog/feature-guides/screenshots/guide-stream-info-dark.png'], }, { slug: 'm3u-programme-guide', screenshots: ['blog/feature-guides/multi-epg-v0-24.jpg'], }, { slug: 'xtream-codes-setup-guide', screenshots: [ 'blog/guides/screenshots/guide-xtream-add-playlist-dark.png', 'blog/guides/screenshots/guide-xtream-auto-detect-dark.png', 'blog/guides/screenshots/guide-xtream-live-dark.png', ], }, { slug: 'stalker-portal-setup-guide', screenshots: [ 'blog/guides/screenshots/guide-stalker-add-playlist-dark.png', 'blog/guides/screenshots/guide-stalker-live-dark.png', ], }, { slug: 'm3u-playlist-epg-setup-guide', screenshots: [ 'blog/guides/screenshots/guide-m3u-add-playlist-dark.png', 'blog/guides/screenshots/guide-m3u-live-groups-dark.png', 'blog/guides/screenshots/guide-epg-settings-dark.png', ], }, { slug: 'offline-downloads-guide', screenshots: [ 'blog/guides/screenshots/guide-downloads-movie-detail-dark.png', 'blog/guides/screenshots/guide-downloads-manager-dark.png', 'blog/guides/screenshots/guide-downloads-offline-movie-dark.png', ], }, { slug: 'alternative-sources-guide', screenshots: [ 'blog/guides/screenshots/guide-sources-chip-dark.png', 'blog/guides/screenshots/guide-sources-menu-dark.png', ], }, { slug: 'remote-control-guide', screenshots: [ 'blog/guides/screenshots/guide-remote-settings-dark.png', 'blog/guides/screenshots/guide-remote-phone-dark.png', ], }, { slug: 'epg-wrong-program-fix', screenshots: [ 'blog/guides/screenshots/guide-epg-map-menu-dark.png', 'blog/guides/screenshots/guide-epg-map-dialog-dark.png', 'blog/guides/screenshots/guide-epg-offset-dark.png', ], }, { slug: 'tmdb-metadata-guide', screenshots: ['blog/guides/screenshots/guide-tmdb-settings-dark.png'], }, ]; const readDist = (relativePath) => readFile(new URL(relativePath, distRoot), 'utf8'); const FEATURE_GUIDE_SHOTS = { 'stable-nightly-updates-guide': 'guide-update-channel', 'playlist-backup-restore-guide': 'guide-backup', 'library-organization-guide': 'guide-library-watched', 'player-controls-guide': 'guide-player-subtitles', 'stream-info-diagnostics-guide': 'guide-stream-info', 'fullscreen-channel-episode-guide': 'guide-fullscreen-channels', }; if (process.env.PUBLIC_INCLUDE_DRAFTS === 'true') { for (const [slug, shot] of Object.entries(FEATURE_GUIDE_SHOTS)) { if (GUIDES.some((guide) => guide.slug === slug)) continue; GUIDES.push({ slug, screenshots: [`blog/feature-guides/screenshots/${shot}-dark.png`] }); } } test('feature guide drafts reference screenshots that ship with the site', async () => { for (const [slug, shot] of Object.entries(FEATURE_GUIDE_SHOTS)) { const source = await readFile(new URL(`../../apps/website/src/content/blog/${slug}.mdx`, import.meta.url), 'utf8'); const screenshot = `blog/feature-guides/screenshots/${shot}-dark.png`; assert.ok(source.includes(`](/iptvnator/${screenshot})`), `${slug}: inline screenshot`); assert.ok(source.includes(`heroImage: /iptvnator/${screenshot}`), `${slug}: card image`); await access(new URL(screenshot, distRoot)); } }); const HUB_GROUPS = ['getting-started', 'live-tv-epg', 'playback', 'library', 'updates']; const DRAFT_GUIDES = [ 'm3u-programme-guide', 'stable-nightly-updates-guide', 'fullscreen-channel-episode-guide', 'player-controls-guide', 'stream-info-diagnostics-guide', 'playlist-backup-restore-guide', 'library-organization-guide', ]; test('guides hub groups working article links by task and keeps draft visibility consistent', async () => { const html = await readDist('guides/index.html'); const doc = new JSDOM(html).window.document; assert.equal(doc.querySelector('link[rel="canonical"]').href, `${SITE}/guides/`); assert.equal(doc.querySelectorAll('h1').length, 1); for (const id of HUB_GROUPS) { const section = doc.getElementById(id); assert.ok(section?.querySelector('h2'), `${id}: named section`); assert.ok(section.querySelector('[data-guide-article]'), `${id}: not an empty category`); assert.ok(doc.querySelector(`nav[aria-label="Guide topics"] a[href="#${id}"]`)); } const links = [...doc.querySelectorAll('[data-guide-article]')]; assert.equal(new Set(links.map((link) => link.dataset.guideArticle)).size, links.length); for (const link of links) { const slug = link.dataset.guideArticle; assert.equal(link.getAttribute('href'), `/iptvnator/blog/${slug}/`); await access(new URL(`blog/${slug}/index.html`, distRoot)); const source = await readFile(new URL(`../../apps/website/src/content/blog/${slug}.mdx`, import.meta.url), 'utf8'); const meta = parseYaml(source.split('---')[1]); assert.equal(link.querySelector('h3').textContent, meta.title); assert.equal(link.textContent.includes('Draft preview'), meta.draft === true); } for (const slug of DRAFT_GUIDES) { const source = await readFile(new URL(`../../apps/website/src/content/blog/${slug}.mdx`, import.meta.url), 'utf8'); const meta = parseYaml(source.split('---')[1]); const visible = !meta.draft || process.env.PUBLIC_INCLUDE_DRAFTS === 'true'; assert.equal(links.some((link) => link.dataset.guideArticle === slug), visible, `${slug}: draft visibility`); } const schema = extractJsonLd(html); const collection = schema.find((entry) => entry['@type'] === 'CollectionPage'); assert.equal(collection.url, `${SITE}/guides/`); assert.deepEqual(collection.hasPart.map((entry) => entry.url), links.map((link) => `https://4gray.github.io${link.getAttribute('href')}`)); assert.equal(schema.find((entry) => entry['@type'] === 'BreadcrumbList').itemListElement.at(-1).item, `${SITE}/guides/`); assert.match(await readDist('sitemap-0.xml'), /https:\/\/4gray.github.io\/iptvnator\/guides\/<\/loc>/); }); test('guides are discoverable from the header, footer and blog', async () => { const home = new JSDOM(await readDist('index.html')).window.document; assert.ok(home.querySelector('header a[href="/iptvnator/guides/"]')); assert.ok(home.querySelector('#mobile-menu a[href="/iptvnator/guides/"]')); assert.ok(home.querySelector('footer a[href="/iptvnator/guides/"]')); const blog = new JSDOM(await readDist('blog/index.html')).window.document; assert.ok(blog.querySelector('main a[href="/iptvnator/guides/"]')); }); test('guides hub: responsive navigation and task anchors work in Chromium', async (t) => { const browser = await launchBrowser(); if (!browser) return t.skip('Chromium is not installed locally'); const { server, origin } = await serveDist(); try { for (const width of [390, 768, 1024, 1280]) { const page = await browser.newPage({ viewport: { width, height: 900 }, reducedMotion: 'reduce' }); try { await page.goto(`${origin}/iptvnator/guides/`); await page.evaluate(() => document.fonts.ready); assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), `${width}: no horizontal overflow`); if (width < 1024) { const button = page.getByRole('button', { name: 'Toggle menu' }); await button.click(); assert.equal(await button.getAttribute('aria-expanded'), 'true'); await page.locator('#mobile-menu').getByRole('link', { name: 'Guides', exact: true }).click(); await page.waitForURL(`${origin}/iptvnator/guides/`); } else { const bounds = await page.locator('#site-header a:visible').evaluateAll((links) => links.map((link) => { const { left, right } = link.getBoundingClientRect(); return { left, right }; })); for (let index = 1; index < bounds.length; index++) { assert.ok(bounds[index].left >= bounds[index - 1].right, `${width}: header links do not overlap`); } } await page.getByRole('navigation', { name: 'Guide topics' }).getByRole('link', { name: /Updates/ }).click(); await page.waitForFunction(() => { const top = document.getElementById('updates').getBoundingClientRect().top; return top >= 80 && top < 400; }); assert.equal(new URL(page.url()).hash, '#updates'); } finally { await page.close(); } } } finally { await browser.close(); await new Promise((resolve) => server.close(resolve)); } }); function extractJsonLd(html) { const blocks = [...html.matchAll(/