From bda2ef07beaf645aec40050a42654aa6c1382cca Mon Sep 17 00:00:00 2001 From: rays Date: Sat, 7 Nov 2026 03:46:49 +0000 Subject: [PATCH] Browser tests for the directory's public pages (#395) The pages anyone can browse at directory.ipxreader.app (site.rs) were at 4% under the browser suite, which only called the directory's API. directory-site.spec.ts opens them as a visitor would: the home page's chart, a category and A to Z, sorted and as many as it says; the podcasts and blogs tabs, which split the feeds between them; search from the header, and a search that finds nothing; a feed's page, its address, and Copy putting it on the clipboard (site.ts, the pages' one script); an unknown feed's 404; and robots.txt and the sitemap, which lists every feed's page. No script error, nothing the security policy refuses, and no failed answer of the directory's own on any page; the fixtures' covers are their publisher's and one is missing on purpose. site.rs is at 87% under the browser suite now, the directory at 69%. Co-Authored-By: Claude Opus 5.5 --- tests/ui/directory-site.spec.ts | 134 ++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 tests/ui/directory-site.spec.ts diff --git a/tests/ui/directory-site.spec.ts b/tests/ui/directory-site.spec.ts new file mode 100644 index 0000000..88a5268 --- /dev/null +++ b/tests/ui/directory-site.spec.ts @@ -0,0 +1,134 @@ +// The directory's public pages (#395), directory.ipxreader.app in production: what anyone can +// browse, served by ipx-directory itself (crates/directory/src/site.rs), with one script of its own +// (site.ts, the Copy button). The suite's directory lists the fixture feeds the suite's ipx shares +// with it. Nothing here signs in: the pages are public, and need no account. +// +// After app.spec.ts, file order being run order, whose tests may have changed a feed's category: +// these follow the categories the pages give, rather than expecting one. + +import type { Page } from '@playwright/test'; +import { DIRECTORY } from './global-setup.ts'; +import { expect, test } from './coverage.ts'; + +const at = (p: string) => DIRECTORY + p; + +/// Script errors, anything the pages' security policy refused, and any answer of the directory's +/// own that failed: none is expected on any page. A cover is the publisher's, loaded from it, and +/// the fixtures' Test Show names one that is not there, so a cover's failure is not counted. +function watch(page: Page) { + const bad: string[] = []; + page.on('pageerror', (e) => bad.push(e.message)); + page.on('console', (m) => { if (m.type() === 'error' && /Refused|Content Security Policy/.test(m.text())) bad.push(m.text()); }); + page.on('response', (r) => { if (r.url().startsWith(DIRECTORY) && r.status() >= 400) bad.push(`${r.status()} ${r.url()}`); }); + return bad; +} + +test.beforeAll(async ({ request }) => { + // The directory reads a feed once a server has shared it: until then a page says nothing is listed. + await expect.poll(async () => (await (await request.get(at('/all'))).text()).includes('Test Show'), + { timeout: 30_000 }).toBe(true); +}); + +test('the home page leads to the most subscribed, a category and every feed A to Z', async ({ page }) => { + const bad = watch(page); + const home = await page.goto(at('/')); + expect(home?.status()).toBe(200); + // Its own script and stylesheet only. + expect(home?.headers()['content-security-policy']).toContain("script-src 'self'"); + await expect(page.locator('h2', { hasText: 'Most subscribed' })).toBeVisible(); + await expect(page.locator('ol.chart li a', { hasText: 'Test Show' })).toBeVisible(); + + // A category: its page is named for it, and lists only what it holds. + const cat = page.locator('.cats a.cat').first(); + const name = (await cat.locator('b').textContent()) ?? ''; + await cat.click(); + await expect(page).toHaveURL(/\/category\//); + await expect(page.locator('h2')).toHaveText(name); + await expect(page.locator('.tiles a.tile').first()).toBeVisible(); + await page.locator('.back a', { hasText: 'Directory' }).click(); + await expect(page).toHaveURL(at('/')); + + // A to Z: as many tiles as the link says, sorted by name. + const more = page.locator('a.more'); + const count = Number((await more.textContent())?.match(/All (\d+) feeds/)?.[1]); + await more.click(); + await expect(page).toHaveURL(at('/all')); + await expect(page.locator('.tiles a.tile')).toHaveCount(count); + const names = await page.locator('.tiles a.tile b').allTextContents(); + expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true }))); + expect(bad).toEqual([]); +}); + +test('podcasts and blogs are told apart', async ({ page }) => { + await page.goto(at('/all')); + const all = await page.locator('.tiles a.tile').count(); + await page.locator('nav.tabs a', { hasText: 'Podcasts' }).click(); + await expect(page).toHaveURL(/kind=podcasts/); + const podcasts = await page.locator('.tiles a.tile').count(); + await page.locator('nav.tabs a', { hasText: 'Blogs' }).click(); + await expect(page).toHaveURL(/kind=blogs/); + const blogs = await page.locator('.tiles a.tile').count(); + // Test Show has episodes; Picture Blog has none, only posts. + expect(podcasts).toBeGreaterThan(0); + expect(blogs).toBeGreaterThan(0); + expect(podcasts + blogs).toBe(all); + await expect(page.locator('.tiles a.tile', { hasText: 'Test Show' })).toHaveCount(0); +}); + +test('a search from the header finds a feed by its name', async ({ page }) => { + await page.goto(at('/')); + const box = page.getByRole('searchbox', { name: 'Find a feed by name' }); + await box.fill('test show'); + await box.press('Enter'); + await expect(page).toHaveURL(/\/search\?q=test\+show/); + await expect(page.locator('.tiles a.tile', { hasText: 'Test Show' })).toBeVisible(); + await box.fill('nothing is called this'); + await box.press('Enter'); + await expect(page.locator('p.hint')).toHaveText('Nothing here.'); +}); + +test('a feed\'s page gives its address, and Copy puts it on the clipboard', async ({ page, context }) => { + const bad = watch(page); + await context.grantPermissions(['clipboard-read', 'clipboard-write'], { origin: DIRECTORY }); + await page.goto(at('/')); + await page.locator('ol.chart li a', { hasText: 'Test Show' }).click(); + await expect(page).toHaveURL(/\/feed\//); + await expect(page.locator('.show .meta h2')).toHaveText('Test Show'); + await expect(page.locator('p.about')).toHaveText('A fixture feed.'); + const address = page.locator('#addr'); + await expect(address).toHaveValue('http://127.0.0.1:8792/show.xml'); + + // site.js, the pages' one script (#215, #392). By its attribute: its name is Copy, then Copied. + const copy = page.locator('button[data-copy]'); + await copy.click(); + await expect(copy).toHaveText('Copied'); + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe('http://127.0.0.1:8792/show.xml'); + await expect(copy).toHaveText('Copy', { timeout: 5_000 }); + expect(bad).toEqual([]); +}); + +test('a feed the directory does not list is a page saying so, answered 404', async ({ page }) => { + const r = await page.goto(at('/feed/no-such-feed')); + expect(r?.status()).toBe(404); + await expect(page.locator('h2')).toHaveText('Not found'); + await expect(page.locator('p.hint')).toHaveText('That feed is not in the directory.'); + await page.locator('.back a').click(); + await expect(page).toHaveURL(at('/')); +}); + +test('crawlers are told every page there is, and to leave search and the API alone', async ({ request }) => { + const robots = await (await request.get(at('/robots.txt'))).text(); + expect(robots).toContain('Disallow: /search'); + expect(robots).toContain('Disallow: /v1/'); + expect(robots).toContain(`Sitemap: ${DIRECTORY}/sitemap.xml`); + + const sitemap = await (await request.get(at('/sitemap.xml'))).text(); + const locs = [...sitemap.matchAll(/([^<]+)<\/loc>/g)].map((m) => m[1]); + expect(locs).toContain(`${DIRECTORY}/`); + expect(locs).toContain(`${DIRECTORY}/all`); + expect(locs.some((l) => l?.startsWith(`${DIRECTORY}/category/`))).toBe(true); + // Every feed on A to Z has its page in it. + const all = await (await request.get(at('/all'))).text(); + for (const id of [...all.matchAll(/href="\/feed\/([^"]+)"/g)].map((m) => m[1])) + expect(locs).toContain(`${DIRECTORY}/feed/${id}`); +});