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 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 03:46:49 +00:00
parent 194a9250e7
commit bda2ef07be

View File

@@ -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>([^<]+)<\/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}`);
});