You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
497 lines
19 KiB
497 lines
19 KiB
import { expect, test } from '@playwright/test';
|
|
|
|
test.describe('Navegación', () => {
|
|
test('la portada presenta al artista y lleva a la música', async ({ page }) => {
|
|
await page.goto('/');
|
|
|
|
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Senza Paura');
|
|
await expect(page.getByText('Letrista y creador musical').first()).toBeVisible();
|
|
|
|
// La cabecera destaca un tema y ofrece el catálogo entero.
|
|
await page.getByRole('link', { name: 'Toda la música' }).click();
|
|
await expect(page).toHaveURL('/musica');
|
|
});
|
|
|
|
test('el fondo de la cabecera es propio, no un incrustado', async ({ page }) => {
|
|
// Lo que se comprueba es la decisión, no la técnica con que esté hecha:
|
|
// un iframe de terceros en la primera pantalla pesaría más que el resto de
|
|
// la página y dejaría cookies antes de que nadie pida ver nada.
|
|
await page.goto('/');
|
|
|
|
await expect(page.locator('iframe')).toHaveCount(0);
|
|
// Y el fondo existe: hoy es un shader propio sobre una imagen propia.
|
|
await expect(page.locator('.fondo')).not.toHaveCount(0);
|
|
});
|
|
|
|
test('la cabecera acompaña al desplazamiento', async ({ page }) => {
|
|
await page.goto('/');
|
|
await page.evaluate(() => window.scrollBy(0, 1400));
|
|
|
|
const cabecera = page.getByRole('banner');
|
|
await expect(cabecera).toBeVisible();
|
|
const caja = await cabecera.boundingBox();
|
|
expect(caja?.y).toBeLessThan(1);
|
|
});
|
|
|
|
test('el botón de tema cierra el menú', async ({ page }) => {
|
|
await page.goto('/');
|
|
|
|
const ultimo = page.locator('header .utilidades-derecha > *').last();
|
|
await expect(ultimo).toHaveRole('button');
|
|
await expect(ultimo).toHaveAttribute('aria-label', /tema/);
|
|
});
|
|
|
|
test('las utilidades de la cabecera son iconos agrupados a la derecha', async ({ page }) => {
|
|
await page.goto('/');
|
|
|
|
const utilidades = page.locator('header .utilidades-derecha');
|
|
await expect(utilidades).toBeVisible();
|
|
const buscar = utilidades.getByRole('link', { name: 'Buscar en Senza Paura' });
|
|
const carrito = utilidades.getByRole('link', { name: /^Carrito/ });
|
|
await expect(buscar).toBeVisible();
|
|
await expect(carrito).toBeVisible();
|
|
await expect(utilidades.getByRole('button', { name: /tema/ })).toBeVisible();
|
|
await expect(buscar.locator('svg')).toHaveAttribute('width', '24');
|
|
await expect(carrito.locator('svg')).toHaveAttribute('width', '24');
|
|
});
|
|
|
|
test('mantiene la navegación completa y en una sola fila a mil píxeles', async ({ page }) => {
|
|
await page.setViewportSize({ width: 1000, height: 800 });
|
|
await page.goto('/musica');
|
|
|
|
await expect(page.locator('header .alternar')).toBeHidden();
|
|
await expect(page.getByRole('navigation', { name: 'Principal' })).toBeVisible();
|
|
const filas = await page.locator('header .fila').evaluate((elemento) => {
|
|
const estilo = getComputedStyle(elemento);
|
|
return { flexWrap: estilo.flexWrap, alto: elemento.getBoundingClientRect().height };
|
|
});
|
|
expect(filas.flexWrap).toBe('nowrap');
|
|
expect(filas.alto).toBeLessThan(80);
|
|
});
|
|
|
|
test('el logo de cabecera usa blanco plano en oscuro y azul plano en claro', async ({ page }) => {
|
|
await page.goto('/');
|
|
|
|
const marca = page.locator('header .marca');
|
|
const nombre = page.locator('header .nombre-marca');
|
|
const simbolo = page.locator('header [data-logo-senza-paura]');
|
|
await expect(marca).toHaveCSS('color', 'rgb(255, 255, 255)');
|
|
await expect(nombre).toHaveCSS('background-image', 'none');
|
|
await expect(simbolo).toHaveCSS('fill', 'rgb(255, 255, 255)');
|
|
|
|
await page.getByRole('button', { name: 'Cambiar al tema claro' }).click();
|
|
await expect(page.locator('html')).toHaveAttribute('data-tema', 'claro');
|
|
|
|
const azul = await page
|
|
.locator('body')
|
|
.evaluate((elemento) => getComputedStyle(elemento).color);
|
|
await expect(marca).toHaveCSS('color', azul);
|
|
await expect(nombre).toHaveCSS('background-image', 'none');
|
|
await expect(simbolo).toHaveCSS('fill', azul);
|
|
});
|
|
|
|
test('el símbolo de marca aparece en la cabecera y en el pie', async ({ page }) => {
|
|
await page.goto('/');
|
|
|
|
const logos = page.locator('[data-logo-senza-paura]');
|
|
await expect(logos).toHaveCount(2);
|
|
await expect(logos.first()).toBeVisible();
|
|
await logos.last().scrollIntoViewIfNeeded();
|
|
await expect(logos.last()).toBeVisible();
|
|
});
|
|
|
|
test('la música se presenta por estilos', async ({ page }) => {
|
|
await page.goto('/musica');
|
|
|
|
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Música');
|
|
|
|
// El estilo principal va primero.
|
|
const estilos = page.getByRole('heading', { level: 2 });
|
|
await expect(estilos.first()).toHaveText('Baladas románticas');
|
|
|
|
for (const estilo of ['Clásica', 'Rock', 'Pop melódico']) {
|
|
await expect(page.getByRole('heading', { level: 2, name: estilo })).toBeVisible();
|
|
}
|
|
});
|
|
|
|
test('cada estilo tiene su propia página, con su propio título', async ({ page }) => {
|
|
await page.goto('/musica');
|
|
const enlace = page.getByRole('heading', { level: 2 }).first().getByRole('link');
|
|
const titulo = (await enlace.textContent())?.trim();
|
|
const destino = await enlace.getAttribute('href');
|
|
await enlace.click();
|
|
|
|
expect(titulo).toBeTruthy();
|
|
expect(destino).toBeTruthy();
|
|
await expect(page).toHaveURL(destino!);
|
|
// En su página el estilo es el h1, no un h2 heredado del listado: es de lo
|
|
// que va la página.
|
|
await expect(page.getByRole('heading', { level: 1 })).toHaveText(titulo!);
|
|
await expect(
|
|
page.locator('a[href^="/albumes/"], a[href^="/canciones/"]').first()
|
|
).toBeVisible();
|
|
});
|
|
|
|
test('la página de un estilo lleva al lado todos los demás', async ({ page }) => {
|
|
await page.goto('/musica/clasica');
|
|
|
|
const lateral = page.getByRole('navigation', { name: 'Estilos' });
|
|
await expect(lateral).toBeVisible();
|
|
await expect(lateral.getByRole('link', { name: /Rock/ })).toBeVisible();
|
|
// Y marca en cuál estás.
|
|
await expect(lateral.getByRole('link', { name: /Clásica/ })).toHaveAttribute(
|
|
'aria-current',
|
|
'page'
|
|
);
|
|
});
|
|
|
|
test('desde un estilo se llega al álbum y del álbum al tema', async ({ page }) => {
|
|
await page.goto('/musica');
|
|
await page.getByRole('heading', { level: 2 }).first().getByRole('link').click();
|
|
|
|
const enlaceAlbum = page.locator('a[href^="/albumes/"]').first();
|
|
const album = await enlaceAlbum.getAttribute('href');
|
|
expect(album).toBeTruthy();
|
|
await enlaceAlbum.click();
|
|
await expect(page).toHaveURL(album!);
|
|
await expect(page.getByRole('heading', { level: 2, name: 'Temas' })).toBeVisible();
|
|
|
|
const enlaceCancion = page.locator('a[href^="/canciones/"]').first();
|
|
const cancion = await enlaceCancion.getAttribute('href');
|
|
expect(cancion).toBeTruthy();
|
|
await enlaceCancion.click();
|
|
await expect(page).toHaveURL(cancion!);
|
|
});
|
|
|
|
test('una dirección inexistente muestra la página de error', async ({ page }) => {
|
|
const respuesta = await page.goto('/canciones/no-existe');
|
|
|
|
expect(respuesta?.status()).toBe(404);
|
|
// El titular no repite lo que la lámina ya lleva dibujado.
|
|
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
|
|
'Esta dirección no lleva a ninguna parte'
|
|
);
|
|
// El mensaje que lanzó el `load`, que dice más que la frase de reserva.
|
|
await expect(page.getByText('Ese tema no existe.')).toBeVisible();
|
|
await expect(page).toHaveTitle(/Página no encontrada/);
|
|
});
|
|
|
|
test('los singles sin cubierta propia usan una portada válida', async ({ page }) => {
|
|
const respuesta = page.waitForResponse((r) => r.url().endsWith('/images/singles/portada.svg'));
|
|
|
|
await page.goto('/canciones/dorina');
|
|
|
|
await expect(page.getByRole('img', { name: 'Portada de Dorina' })).toHaveAttribute(
|
|
'src',
|
|
'/images/singles/portada.svg'
|
|
);
|
|
expect((await respuesta).status()).toBe(200);
|
|
});
|
|
});
|
|
|
|
test.describe('La página de error', () => {
|
|
test('lleva su lámina y sale del paso', async ({ page }) => {
|
|
await page.goto('/no-existe-esto');
|
|
|
|
const lamina = page.locator('.lamina img');
|
|
await expect(lamina).toHaveAttribute('src', '/errores/404-vertical.webp');
|
|
// Que exista el archivo, no solo la etiqueta.
|
|
expect(await lamina.evaluate((i: HTMLImageElement) => i.naturalWidth)).toBeGreaterThan(0);
|
|
|
|
await page.getByRole('link', { name: 'Volver al inicio' }).click();
|
|
await expect(page).toHaveURL('/');
|
|
});
|
|
|
|
test('el navegador decodifica las láminas 404 y 500', async ({ page }) => {
|
|
await page.goto('/no-existe-esto');
|
|
|
|
for (const codigo of ['404', '500']) {
|
|
const dimensiones = await page.evaluate(async (nombre) => {
|
|
const imagen = new Image();
|
|
imagen.src = `/errores/${nombre}-vertical.webp`;
|
|
await imagen.decode();
|
|
return { ancho: imagen.naturalWidth, alto: imagen.naturalHeight };
|
|
}, codigo);
|
|
|
|
expect(dimensiones.ancho).toBeGreaterThan(0);
|
|
expect(dimensiones.alto).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
test('aísla el error de la navegación, el pie y el reproductor del sitio', async ({ page }) => {
|
|
await page.goto('/no-existe-esto');
|
|
|
|
await expect(page.locator('header')).toHaveCount(0);
|
|
await expect(page.locator('footer')).toHaveCount(0);
|
|
await expect(page.getByRole('region', { name: 'Reproductor' })).toHaveCount(0);
|
|
await expect(page.locator('main.pagina-error')).toBeVisible();
|
|
await expect(page.getByRole('link', { name: 'Volver al inicio' })).toBeVisible();
|
|
});
|
|
|
|
/*
|
|
* Sin esto, todo el que aterrice en un 404 se traería 686 KB de mp3 que a lo
|
|
* mejor no va a escuchar. Es la razón de ser del `preload="none"`.
|
|
*/
|
|
test('no se trae el mp3 hasta que se pide', async ({ page }) => {
|
|
const pedidos: string[] = [];
|
|
page.on('request', (p) => {
|
|
if (p.url().includes('/errores/')) pedidos.push(p.url());
|
|
});
|
|
|
|
await page.goto('/no-existe-esto');
|
|
await expect(page.getByRole('button', { name: 'Poner música' })).toBeVisible();
|
|
expect(pedidos.some((u) => u.endsWith('.mp3'))).toBe(false);
|
|
|
|
await page.getByRole('button', { name: 'Poner música' }).click();
|
|
await expect(page.getByRole('button', { name: 'Parar' })).toBeVisible();
|
|
|
|
expect(pedidos.some((u) => u.endsWith('/errores/404.mp3'))).toBe(true);
|
|
expect(await page.locator('.musica audio').evaluate((a: HTMLAudioElement) => a.paused)).toBe(
|
|
false
|
|
);
|
|
});
|
|
|
|
test('el botón vuelve a su sitio al parar', async ({ page }) => {
|
|
await page.goto('/no-existe-esto');
|
|
|
|
await page.getByRole('button', { name: 'Poner música' }).click();
|
|
await page.getByRole('button', { name: 'Parar' }).click();
|
|
|
|
await expect(page.getByRole('button', { name: 'Poner música' })).toBeVisible();
|
|
expect(await page.locator('.musica audio').evaluate((a: HTMLAudioElement) => a.paused)).toBe(
|
|
true
|
|
);
|
|
});
|
|
});
|
|
|
|
test.describe('Carrusel de la portada', () => {
|
|
test('tiene tres láminas y solo se ve una', async ({ page }) => {
|
|
await page.goto('/');
|
|
|
|
await expect(page.locator('.lamina')).toHaveCount(3);
|
|
await expect(page.locator('.lamina.visible')).toHaveCount(1);
|
|
});
|
|
|
|
test('los puntos llevan a cada lámina', async ({ page }) => {
|
|
await page.goto('/');
|
|
|
|
await page.getByRole('button', { name: 'Ir a: Aprender a escribir letras' }).click();
|
|
await expect(
|
|
page.getByRole('heading', { name: /Quieres escribir tus propias letras/ })
|
|
).toBeVisible();
|
|
|
|
await page.getByRole('button', { name: 'Ir a: Comprar canciones' }).click();
|
|
await expect(page.getByRole('heading', { name: /canciones por un café/ })).toBeVisible();
|
|
});
|
|
|
|
test('el teclado no entra en las láminas que no se ven', async ({ page }) => {
|
|
// Van con `inert`: sin eso, tabular desde la cabecera cae en botones
|
|
// invisibles y el foco se pierde.
|
|
await page.goto('/');
|
|
|
|
const ocultas = page.locator('.lamina:not(.visible)');
|
|
await expect(ocultas).toHaveCount(2);
|
|
for (const lamina of await ocultas.all()) {
|
|
await expect(lamina).toHaveJSProperty('inert', true);
|
|
}
|
|
});
|
|
|
|
test('cada lámina se lleva su propio fondo', async ({ page }) => {
|
|
/*
|
|
* El fondo a sangre de cada lámina va con `z-index: -1`. Si la lámina no
|
|
* crea su propio contexto de apilamiento, ese fondo se escapa hacia atrás
|
|
* y se pinta detrás del carrusel entero: al pasar de una lámina a otra
|
|
* asomaba la foto de la de al lado, y el fondo de la que entraba daba un
|
|
* salto al terminar el fundido.
|
|
*
|
|
* `opacity: 1` no crea contexto —por eso fallaba justo en la lámina
|
|
* visible—, así que lo que se comprueba es que haya algo que sí lo cree.
|
|
*/
|
|
await page.goto('/');
|
|
|
|
const laminas = await page.locator('.lamina').all();
|
|
expect(laminas.length).toBeGreaterThan(1);
|
|
|
|
for (const lamina of laminas) {
|
|
const propio = await lamina.evaluate((el) => {
|
|
const cs = getComputedStyle(el);
|
|
return (
|
|
cs.isolation === 'isolate' ||
|
|
Number(cs.opacity) < 1 ||
|
|
cs.transform !== 'none' ||
|
|
cs.filter !== 'none' ||
|
|
(cs.position !== 'static' && cs.zIndex !== 'auto')
|
|
);
|
|
});
|
|
expect(propio).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('al pasar de lámina no asoma la foto de la otra', async ({ page }) => {
|
|
await page.goto('/');
|
|
|
|
// A mitad del fundido, que es cuando se veían las dos superpuestas.
|
|
await page.getByRole('button', { name: 'Ir a: Aprender a escribir letras' }).click();
|
|
await page.waitForTimeout(300);
|
|
|
|
// El fondo de la lámina que se va tiene que estar dentro de ella, no
|
|
// suelto por detrás: se comprueba que su opacidad efectiva la acompañe.
|
|
const asoma = await page.evaluate(() => {
|
|
const laminas = [...document.querySelectorAll('.lamina')];
|
|
return laminas.some((l) => {
|
|
const fondo = l.querySelector('.fondo');
|
|
if (!fondo) return false;
|
|
const cs = getComputedStyle(l);
|
|
// Una lámina que se está yendo no puede pintar su fondo a plena vista.
|
|
return Number(cs.opacity) < 0.5 && getComputedStyle(fondo).opacity === '1' && !esSuyo(l);
|
|
});
|
|
|
|
function esSuyo(l: Element) {
|
|
const cs = getComputedStyle(l);
|
|
return (
|
|
cs.isolation === 'isolate' ||
|
|
Number(cs.opacity) < 1 ||
|
|
cs.transform !== 'none' ||
|
|
(cs.position !== 'static' && cs.zIndex !== 'auto')
|
|
);
|
|
}
|
|
});
|
|
expect(asoma).toBe(false);
|
|
});
|
|
|
|
test('se puede parar el paso automático', async ({ page }) => {
|
|
await page.goto('/');
|
|
|
|
const parar = page.getByRole('button', { name: 'Parar el paso automático' });
|
|
await expect(parar).toBeVisible();
|
|
await parar.click();
|
|
await expect(page.getByRole('button', { name: 'Reanudar el paso automático' })).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe('Fondo animado de la portada', () => {
|
|
test('se pinta y arranca', async ({ page }) => {
|
|
await page.goto('/');
|
|
|
|
// La clase solo se pone cuando el programa ha compilado y está dibujando:
|
|
// que exista el canvas no significa que haya salido nada.
|
|
await expect(page.locator('canvas.activo')).toHaveCount(1);
|
|
});
|
|
|
|
test('cambia de paleta con el tema sin perder el contexto WebGL', async ({ page }) => {
|
|
await page.addInitScript(() => {
|
|
type Uniforme = (this: WebGL2RenderingContext, ...argumentos: unknown[]) => void;
|
|
type VentanaConPaletas = Window & { paletasDeFondo: number[][] };
|
|
const ventana = window as VentanaConPaletas;
|
|
const prototipo = WebGL2RenderingContext.prototype as unknown as { uniform3fv: Uniforme };
|
|
const original = prototipo.uniform3fv;
|
|
ventana.paletasDeFondo = [];
|
|
|
|
prototipo.uniform3fv = function (...argumentos) {
|
|
const valores = argumentos[1] as ArrayLike<number>;
|
|
const paleta = Array.from(valores);
|
|
if (paleta.length === 12) ventana.paletasDeFondo.push(paleta);
|
|
original.apply(this, argumentos);
|
|
};
|
|
});
|
|
|
|
await page.goto('/');
|
|
const lienzo = page.locator('canvas');
|
|
await expect(lienzo).toHaveClass(/activo/);
|
|
const primera = await page.evaluate(() =>
|
|
(window as unknown as { paletasDeFondo: number[][] }).paletasDeFondo.at(-1)!
|
|
);
|
|
|
|
await page.getByRole('button', { name: 'Cambiar al tema claro' }).click();
|
|
await expect(page.locator('html')).toHaveAttribute('data-tema', 'claro');
|
|
await expect(lienzo).toHaveClass(/activo/);
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
() => (window as unknown as { paletasDeFondo: number[][] }).paletasDeFondo.length
|
|
)
|
|
)
|
|
.toBeGreaterThan(1);
|
|
|
|
const ultima = await page.evaluate(() =>
|
|
(window as unknown as { paletasDeFondo: number[][] }).paletasDeFondo.at(-1)!
|
|
);
|
|
expect(ultima).not.toEqual(primera);
|
|
expect(
|
|
await lienzo.evaluate((canvas) =>
|
|
(canvas as HTMLCanvasElement).getContext('webgl2')?.isContextLost()
|
|
)
|
|
).toBe(false);
|
|
});
|
|
|
|
test('con movimiento reducido no se monta', async ({ page }) => {
|
|
// Quien pide menos movimiento no quiere una primera pantalla que ondula.
|
|
// En tema oscuro, porque es donde hay imagen debajo que comprobar: en
|
|
// claro el fondo lo pinta el propio shader.
|
|
await page.emulateMedia({ reducedMotion: 'reduce', colorScheme: 'dark' });
|
|
await page.goto('/');
|
|
|
|
await expect(page.locator('canvas.activo')).toHaveCount(0);
|
|
// Y debajo sigue el fondo de siempre, no un hueco.
|
|
await expect(page.locator('.fondo img').first()).toBeAttached();
|
|
});
|
|
|
|
test('deja de dibujar cuando ya no se ve', async ({ page }) => {
|
|
// Un shader a pantalla completa detrás de una página por la que ya has
|
|
// bajado es batería tirada.
|
|
await page.goto('/');
|
|
await expect(page.locator('canvas.activo')).toHaveCount(1);
|
|
|
|
await page.evaluate(() => {
|
|
(window as unknown as { cuadros: number }).cuadros = 0;
|
|
const original = window.requestAnimationFrame;
|
|
window.requestAnimationFrame = (cb) =>
|
|
original(() => {
|
|
(window as unknown as { cuadros: number }).cuadros++;
|
|
cb(performance.now());
|
|
});
|
|
});
|
|
|
|
await page.evaluate(() => window.scrollTo(0, 5000));
|
|
await page.waitForTimeout(600);
|
|
const antes = await page.evaluate(() => (window as unknown as { cuadros: number }).cuadros);
|
|
await page.waitForTimeout(800);
|
|
const despues = await page.evaluate(() => (window as unknown as { cuadros: number }).cuadros);
|
|
|
|
expect(despues - antes).toBe(0);
|
|
});
|
|
|
|
test('solo lo lleva la cabecera, no las tres láminas', async ({ page }) => {
|
|
await page.goto('/');
|
|
|
|
// Tres shaders a pantalla completa pintando lo mismo a la vez no aportan
|
|
// nada y cuestan el triple.
|
|
await expect(page.locator('canvas')).toHaveCount(1);
|
|
});
|
|
});
|
|
|
|
test.describe('El panel no está en la web pública', () => {
|
|
/*
|
|
* Vive en otro dominio y en otro proceso. El servidor público se compila sin
|
|
* sus rutas, así que esto no comprueba que estén escondidas: comprueba que
|
|
* aquí no existen. Es la diferencia entre una puerta cerrada y una pared.
|
|
*/
|
|
test('«/admin» no es una dirección de este sitio', async ({ request }) => {
|
|
const respuesta = await request.get('/admin', { maxRedirects: 0 });
|
|
expect(respuesta.status()).toBe(404);
|
|
});
|
|
|
|
test('ni robots.txt lo nombra', async ({ request }) => {
|
|
// Anunciarlo en robots sería repartir la dirección del panel a quien pase.
|
|
const robots = await request.get('/robots.txt');
|
|
expect(await robots.text()).not.toContain('/admin');
|
|
});
|
|
|
|
test('ni hay un solo enlace a él', async ({ page }) => {
|
|
for (const ruta of ['/', '/musica', '/generos', '/blog', '/cuenta']) {
|
|
await page.goto(ruta);
|
|
await expect(page.locator('a[href*="/admin"]')).toHaveCount(0);
|
|
}
|
|
});
|
|
});
|