Files
itziarZG a06316dade
ci / build-and-test (push) Canceled after 0s
feat(seo): custom news sitemap with per-aviso news blocks
Replace the @astrojs/sitemap integration with two handwritten endpoints
under src/pages/ so we can attach a Google News <news:news> block to each
aviso detail URL. The integration already emitted the news namespace
but never the per-URL blocks, so the project was not eligible for
Google News indexing.

- src/pages/sitemap-index.xml.ts: single-child sitemapindex pointing at
  /sitemap-0.xml.
- src/pages/sitemap-0.xml.ts: home + huella + one entry per aviso from
  getAvisosAmbientales. Each aviso carries <news:news> with
  publication name (fuente), language ca, publication_date (ISO),
  title and keywords (temas joined).
- astro.config.mjs: drop the @astrojs/sitemap integration; remove the
  dependency from package.json and pnpm-lock.yaml.
- e2e: add a test asserting the news namespace and at least one
  <news:news> block with publication_date and language. 22/22 e2e
  pass; check and eslint clean.
2026-07-26 22:27:32 +02:00

153 lines
7.0 KiB
TypeScript

import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
const dist = (name: string) => join(process.cwd(), 'dist', name);
test.describe('site smoke', () => {
test('home renders and reports no axe violations @ desktop', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/Avisos ambientals d'Eivissa i Formentera/);
await expect(page.locator('h1#page-title')).toBeVisible();
await expect(page.locator('main#main-content')).toBeVisible();
await expect(page.locator('text=Resum d\'avisos per municipi')).toBeVisible();
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.disableRules(['region']) // decorative map region is intentional
.analyze();
expect(results.violations, JSON.stringify(results.violations, null, 2)).toEqual([]);
});
test('skip-link is the first interactive element and jumps to main', async ({ page }) => {
await page.goto('/');
await page.keyboard.press('Tab');
const focused = await page.evaluate(() => document.activeElement?.textContent?.trim());
expect(focused).toBe('Saltar al contingut');
await page.keyboard.press('Enter');
await expect(page.locator('main#main-content')).toBeFocused();
});
test('filter pills toggle aria-pressed and update the live counter', async ({ page }) => {
await page.goto('/');
const municipioGroup = page.locator('[data-filter-type="municipio"]');
const allPills = municipioGroup.locator('.pill');
const firstPill = allPills.first();
const targetValue = await municipioGroup
.locator('.pill:not(:first-child)')
.first()
.getAttribute('data-value');
expect(targetValue).toBeTruthy();
const targetPill = municipioGroup.locator(`.pill[data-value="${targetValue}"]`);
await expect(firstPill).toHaveAttribute('aria-pressed', 'true');
await targetPill.click();
await expect(firstPill).toHaveAttribute('aria-pressed', 'false');
await expect(targetPill).toHaveAttribute('aria-pressed', 'true');
const counter = page.locator('#filters-count');
await expect(counter).toContainText(/avís/i);
});
test('404 page is noindex and reachable', async ({ page }) => {
const response = await page.goto('/no-existeix-aqui');
expect(response?.status()).toBe(404);
const robots = await page.locator('meta[name="robots"]').getAttribute('content');
expect(robots).toContain('noindex');
await expect(page.locator('h1#not-found-title')).toBeVisible();
});
test('JSON-LD on the home parses and exposes Organization + ItemList', async ({ page }) => {
await page.goto('/');
const payload = await page.locator('head script[type="application/ld+json"]').first().textContent();
expect(payload).toBeTruthy();
const data = JSON.parse(payload ?? '{}');
const types = (data['@graph'] as Array<{ '@type': string }>).map((n) => n['@type']);
expect(types).toEqual(expect.arrayContaining(['Organization', 'WebSite', 'ItemList']));
});
test('robots.txt built from Astro.site and points at the sitemap', async ({ request }) => {
const res = await request.get('/robots.txt');
expect(res.status()).toBe(200);
const body = await res.text();
expect(body).toMatch(/User-agent: \*/);
expect(body).toMatch(/Sitemap: https:\/\/www\.eivissaenvironmentalwatch\.es\/sitemap-index\.xml/);
});
test('sitemap lists the two real pages and not the 404', async ({ request }) => {
const res = await request.get('/sitemap-index.xml');
expect(res.status()).toBe(200);
const index = await res.text();
const sub = await request.get(/<loc>(.*?)<\/loc>/.exec(index)?.[1] ?? '/sitemap-0.xml');
const sitemap = await sub.text();
expect(sitemap).toContain('https://www.eivissaenvironmentalwatch.es/');
expect(sitemap).toContain('https://www.eivissaenvironmentalwatch.es/huella/');
expect(sitemap).not.toContain('/404/');
});
test('aviso detail page renders and exposes NewsArticle JSON-LD', async ({ request, page }) => {
const index = await request.get('/sitemap-0.xml');
const sitemap = await index.text();
const avisPath = /\/avis\/[a-z0-9-]+\//.exec(sitemap)?.[0];
expect(avisPath, 'sitemap should list at least one aviso detail page').toBeTruthy();
const response = await request.get(avisPath!);
expect(response.status()).toBe(200);
await page.goto(avisPath!);
await expect(page.locator('h1#aviso-title')).toBeVisible();
await expect(page.locator('aside.aviso__source a[rel="noopener noreferrer"][target="_blank"]')).toBeVisible();
const payload = await page.locator('head script[type="application/ld+json"]').first().textContent();
expect(payload).toBeTruthy();
const data = JSON.parse(payload ?? '{}');
const graph = data['@graph'] as Array<Record<string, unknown>>;
const types = graph.map((n) => n['@type']);
expect(types).toEqual(expect.arrayContaining(['Organization', 'WebSite', 'NewsArticle']));
const article = graph.find((n) => n['@type'] === 'NewsArticle');
expect(article, 'expected a NewsArticle node in JSON-LD').toBeTruthy();
expect(article?.['isAccessibleForFree']).toBe(true);
expect(article?.['inLanguage']).toBe('ca-ES');
expect(article?.['mainEntityOfPage']).toBeTruthy();
const ogImage = await page.locator('meta[property="og:image"]').first().getAttribute('content');
expect(ogImage).toMatch(/\/og\/avis\/[a-z0-9-]+\.png$/);
const ogUrl = ogImage!.replace('https://www.eivissaenvironmentalwatch.es', '');
const ogRes = await request.get(ogUrl);
expect(ogRes.status(), ogUrl).toBe(200);
});
test('sitemap includes every aviso detail page', async ({ request }) => {
const res = await request.get('/sitemap-0.xml');
const sitemap = await res.text();
const avisPaths = sitemap.match(/\/avis\/[a-z0-9-]+\//g) ?? [];
expect(avisPaths.length).toBeGreaterThan(0);
for (const p of avisPaths) {
const r = await request.get(p);
expect(r.status(), p).toBe(200);
}
});
test('news sitemap declares Google News namespace and per-aviso news block', async ({ request }) => {
const res = await request.get('/sitemap-0.xml');
const sitemap = await res.text();
expect(sitemap).toContain('xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"');
const newsBlocks = sitemap.match(/<news:news>/g) ?? [];
expect(newsBlocks.length).toBeGreaterThan(0);
expect(sitemap).toMatch(/<news:publication_date>[^<]+<\/news:publication_date>/);
expect(sitemap).toMatch(/<news:language>ca<\/news:language>/);
});
});
test.describe('build artifacts', () => {
test('og PNG exists with the right dimensions', async () => {
const buf = await readFile(dist('og-default.png'));
expect(buf.subarray(0, 8)).toEqual(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
const width = buf.readUInt32BE(16);
const height = buf.readUInt32BE(20);
expect({ width, height }).toEqual({ width: 1200, height: 630 });
});
});