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:
134
tests/ui/directory-site.spec.ts
Normal file
134
tests/ui/directory-site.spec.ts
Normal 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}`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user