Cierra una redirección abierta, quita código muerto y afina el responsive
El campo `volverA` de los formularios se comprobaba en tres sitios con tres
copias del mismo filtro, y las tres dejaban pasar `/\otro-dominio.com`: el
navegador convierte la barra invertida en barra normal al resolver, así que eso
es `//otro-dominio.com` con otra ropa. En la pantalla de entrar eso es phishing
servido: enlace a nuestro propio acceso, y quien escribe su correo acaba fuera.
Ahora hay un solo `destinoSeguro`, que no compara prefijos sino que resuelve la
URL y solo acepta lo que sigue en el origen. Con pruebas de cada disfraz.
Fuera siete funciones exportadas que no llamaba nadie. Las escribí "por si
acaso" y una API que no se usa no es una API: es código que leer y mantener sin
que nada dependa de ella.
Del repaso responsive salen dos fallos reales. Los puntos del carrusel medían
8×8 píxeles, la tercera parte del mínimo para acertarles con el dedo; ahora el
punto se ve igual y el botón mide 24×24. Y el hueco reservado bajo el contenido
para la barra de reproducción estaba escrito a mano en 88 píxeles cuando la
barra mide 97 en escritorio y 145 apilada en un móvil: se comía el pie. Ahora la
barra se mide sola y publica su alto.
Todo ello fijado en `responsive.e2e.ts`, que recorre diez páginas a cuatro
anchos.
Sobre rendimiento, nada que hacer: parecía que las páginas tardaban 215 ms, pero
eran de la resolución de `localhost` por IPv6 en la medición. Por IPv4 se
renderizan en 5-9 ms, así que memorizar el catálogo habría sido optimizar un
problema inexistente.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 month ago
|
|
|
import { expect, test } from '@playwright/test';
|
|
|
|
|
|
|
|
|
|
/** Anchos que representan un móvil pequeño, uno normal, una tableta y un panel. */
|
|
|
|
|
const ANCHOS = [320, 375, 768, 1440];
|
|
|
|
|
|
|
|
|
|
const RUTAS = [
|
|
|
|
|
'/',
|
|
|
|
|
'/musica',
|
|
|
|
|
'/musica/baladas-romanticas',
|
|
|
|
|
'/albumes/como-si-nada',
|
|
|
|
|
'/canciones/dorina',
|
|
|
|
|
'/taller/bachata',
|
|
|
|
|
'/blog',
|
|
|
|
|
'/etiquetas',
|
|
|
|
|
'/carrito',
|
|
|
|
|
'/entrar'
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
test.describe('Responsive', () => {
|
|
|
|
|
for (const ancho of ANCHOS) {
|
|
|
|
|
test(`ninguna página desborda a lo ancho a ${ancho}px`, async ({ page }) => {
|
|
|
|
|
// Es el fallo clásico: un elemento se pasa de ancho, aparece una barra
|
|
|
|
|
// horizontal y toda la página se puede arrastrar de lado.
|
|
|
|
|
await page.setViewportSize({ width: ancho, height: 800 });
|
|
|
|
|
|
|
|
|
|
for (const ruta of RUTAS) {
|
|
|
|
|
await page.goto(ruta);
|
|
|
|
|
const desborde = await page.evaluate(
|
|
|
|
|
() => document.documentElement.scrollWidth - window.innerWidth
|
|
|
|
|
);
|
|
|
|
|
expect(desborde, `${ruta} a ${ancho}px`).toBeLessThanOrEqual(1);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test('los controles se pueden pulsar con el dedo', async ({ page }) => {
|
|
|
|
|
// 24x24 es el mínimo de la norma. Los enlaces dentro de un texto quedan
|
|
|
|
|
// fuera —los limita la línea—, pero un botón suelto no tiene excusa.
|
|
|
|
|
await page.setViewportSize({ width: 375, height: 800 });
|
|
|
|
|
|
|
|
|
|
for (const ruta of ['/', '/musica', '/carrito', '/canciones/dorina']) {
|
|
|
|
|
await page.goto(ruta);
|
|
|
|
|
const pequenos = await page.evaluate(() => {
|
|
|
|
|
const fuera: string[] = [];
|
|
|
|
|
for (const e of document.querySelectorAll('button, input, select, [role="button"]')) {
|
|
|
|
|
const c = e.getBoundingClientRect();
|
|
|
|
|
if (!c.width || !c.height) continue;
|
|
|
|
|
if (c.width < 24 || c.height < 24) {
|
|
|
|
|
fuera.push(`${e.getAttribute('aria-label') ?? e.textContent?.trim().slice(0, 20)}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return [...new Set(fuera)];
|
|
|
|
|
});
|
|
|
|
|
expect(pequenos, `controles pequeños en ${ruta}`).toEqual([]);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('la barra de reproducción no tapa el final de la página', async ({ page }) => {
|
|
|
|
|
// La barra se apila en un móvil y crece de 97 a 145 píxeles. El hueco que
|
|
|
|
|
// se le reserva sale de medirla, no de un número escrito a mano: con uno
|
|
|
|
|
// fijo, en móvil se comía el pie.
|
|
|
|
|
await page.setViewportSize({ width: 375, height: 800 });
|
|
|
|
|
await page.goto('/albumes/como-si-nada');
|
|
|
|
|
await page.getByRole('button', { name: 'Reproducir', exact: true }).first().click();
|
|
|
|
|
await expect(page.getByRole('region', { name: 'Reproductor' })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
|
|
|
|
|
|
|
|
|
const solapa = await page.evaluate(() => {
|
|
|
|
|
const barra = document.querySelector('[aria-label="Reproductor"]')!.getBoundingClientRect();
|
|
|
|
|
const pie = document.querySelector('footer')!.getBoundingClientRect();
|
|
|
|
|
return Math.round(pie.bottom - barra.top);
|
|
|
|
|
});
|
|
|
|
|
expect(solapa).toBeLessThanOrEqual(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('en móvil la navegación se despliega y sigue completa', async ({ page }) => {
|
|
|
|
|
await page.setViewportSize({ width: 375, height: 800 });
|
|
|
|
|
await page.goto('/');
|
|
|
|
|
|
|
|
|
|
const nav = page.getByRole('navigation', { name: 'Principal' });
|
|
|
|
|
await expect(nav).toBeHidden();
|
|
|
|
|
|
|
|
|
|
await page.getByRole('button', { name: 'Menú' }).click();
|
|
|
|
|
await expect(nav).toBeVisible();
|
|
|
|
|
await expect(nav.getByRole('link')).toHaveCount(5);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('las barras laterales bajan en vez de desaparecer', async ({ page }) => {
|
|
|
|
|
// Son la navegación de su sección: esconderlas deja la página sin salidas.
|
|
|
|
|
await page.setViewportSize({ width: 375, height: 800 });
|
|
|
|
|
|
|
|
|
|
await page.goto('/musica/baladas-romanticas');
|
|
|
|
|
await expect(page.getByRole('navigation', { name: 'Estilos' })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
await page.goto('/taller/bachata');
|
|
|
|
|
await expect(page.getByRole('navigation', { name: 'Contenido del taller' })).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('la tabla se desplaza dentro de su marco, no arrastra la página', async ({ page }) => {
|
|
|
|
|
await page.setViewportSize({ width: 375, height: 800 });
|
|
|
|
|
await page.goto('/musica');
|
|
|
|
|
await page.getByRole('button', { name: 'Tabla' }).click();
|
|
|
|
|
|
|
|
|
|
const dentro = await page.evaluate(() => {
|
|
|
|
|
const marco = document.querySelector('.marco')!;
|
|
|
|
|
return marco.scrollWidth > marco.clientWidth;
|
|
|
|
|
});
|
|
|
|
|
expect(dentro).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test.describe('Respuesta al puntero', () => {
|
|
|
|
|
// Un control que no acusa el puntero parece deshabilitado. Se comprueba en
|
|
|
|
|
// pruebas porque no se ve en una captura en reposo, que es justo como se
|
|
|
|
|
// coló el botón dorado quedándose transparente al pasar por encima.
|
|
|
|
|
const CASOS: [string, 'link' | 'button', string][] = [
|
|
|
|
|
['/', 'link', 'Ver el disco'],
|
|
|
|
|
['/musica', 'button', 'Tabla'],
|
|
|
|
|
['/blog', 'link', 'Seguirlo por RSS'],
|
|
|
|
|
['/canciones/como-si-nada', 'link', 'Desamor'],
|
|
|
|
|
['/albumes/como-si-nada', 'button', 'Añadir']
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
for (const [ruta, rol, nombre] of CASOS) {
|
|
|
|
|
test(`«${nombre}» acusa el puntero`, async ({ page }) => {
|
|
|
|
|
await page.goto(ruta);
|
|
|
|
|
const el = page.getByRole(rol, { name: nombre }).first();
|
|
|
|
|
|
|
|
|
|
const estilo = () =>
|
|
|
|
|
el.evaluate((e) => {
|
|
|
|
|
const s = getComputedStyle(e);
|
|
|
|
|
return [s.backgroundColor, s.color, s.borderColor, s.textDecorationLine].join('|');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const antes = await estilo();
|
|
|
|
|
await el.hover();
|
|
|
|
|
await expect.poll(estilo).not.toBe(antes);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test('el botón dorado sigue siendo dorado al pasar por encima', async ({ page }) => {
|
|
|
|
|
// Pasó: una regla anterior usaba el atajo `background`, que borra
|
|
|
|
|
// `background-image`, y el botón se quedaba en un rectángulo oscuro con el
|
|
|
|
|
// borde de oro.
|
|
|
|
|
await page.goto('/');
|
|
|
|
|
const boton = page.getByRole('button', { name: 'Poner el disco' });
|
|
|
|
|
|
|
|
|
|
await boton.hover();
|
|
|
|
|
const fondo = await boton.evaluate((e) => getComputedStyle(e).backgroundImage);
|
|
|
|
|
expect(fondo).toContain('linear-gradient');
|
|
|
|
|
});
|
|
|
|
|
});
|
Arregla el contraste del botón dorado y unifica la letra de los botones
El texto del botón dorado se aclaraba al pasar el puntero y quedaba en 1,67 de
contraste sobre el oro. La causa no era el color del botón: `.btn:hover` pone
`color: inherit` —para que la regla general de los enlaces no tiña de dorado los
secundarios— y le gana por especificidad. Ahora el dorado repite su color ahí.
De paso, el dorado se parte en dos tokens porque hacía dos trabajos
incompatibles: de relleno tiene que ser claro para que encima se lea el marino
—7,6 de contraste en el tema claro— y escrito tiene que ser hondo para leerse
sobre el fondo —5,3—. Con un solo valor, el tema claro se quedaba en 4,4, por
debajo del mínimo. Y encima del oro va marino, no blanco, en los dos temas.
Los botones tenían tres tamaños distintos: el dorado a 16px con relleno 8/16 y
el de al lado a 14px con 12/24. Dos botones juntos con distinta letra se ven
como un descuido. Ahora hay una sola escala, y los 15px son los del menú.
El botón de ver el carrito solo aparece cuando hay algo dentro, con la cuenta:
uno que lleva a una página vacía gasta el sitio de una llamada a la acción para
decir que no hay nada.
Tres pruebas nuevas lo fijan, incluida la del contraste con el puntero encima,
que es donde estaba el fallo y donde una captura en reposo no lo enseña.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 month ago
|
|
|
|
|
|
|
|
test.describe('Contraste del dorado', () => {
|
|
|
|
|
// El dorado hace dos trabajos y no puede ser el mismo valor en los dos: de
|
|
|
|
|
// relleno tiene que ser claro para que encima se lea el marino, y escrito
|
|
|
|
|
// tiene que ser hondo para leerse sobre el fondo. Esto lo fija.
|
|
|
|
|
const MINIMO = 4.5;
|
|
|
|
|
|
|
|
|
|
async function contraste(page: import('@playwright/test').Page, selector: string) {
|
|
|
|
|
return page
|
|
|
|
|
.locator(selector)
|
|
|
|
|
.first()
|
|
|
|
|
.evaluate((e) => {
|
|
|
|
|
const aRgb = (c: string) => {
|
|
|
|
|
const x = document.createElement('canvas').getContext('2d')!;
|
|
|
|
|
x.fillStyle = c;
|
|
|
|
|
x.fillRect(0, 0, 1, 1);
|
|
|
|
|
const d = x.getImageData(0, 0, 1, 1).data;
|
|
|
|
|
return [d[0], d[1], d[2]];
|
|
|
|
|
};
|
|
|
|
|
const lum = ([r, g, b]: number[]) => {
|
|
|
|
|
const f = (v: number) => {
|
|
|
|
|
v /= 255;
|
|
|
|
|
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
|
|
|
|
};
|
|
|
|
|
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
|
|
|
|
|
};
|
|
|
|
|
const s = getComputedStyle(e);
|
|
|
|
|
const [a, b] = [lum(aRgb(s.color)), lum(aRgb(s.backgroundColor))].sort((m, n) => n - m);
|
|
|
|
|
return (a + 0.05) / (b + 0.05);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const esquema of ['dark', 'light'] as const) {
|
|
|
|
|
test(`el botón dorado se lee en tema ${esquema}`, async ({ page }) => {
|
|
|
|
|
await page.emulateMedia({ colorScheme: esquema });
|
|
|
|
|
await page.goto('/');
|
|
|
|
|
|
|
|
|
|
expect(await contraste(page, '.btn--primary')).toBeGreaterThan(MINIMO);
|
|
|
|
|
|
|
|
|
|
// Y también con el puntero encima, que es cuando la banda de luz se
|
|
|
|
|
// mueve por debajo del texto.
|
|
|
|
|
await page.locator('.btn--primary').first().hover();
|
|
|
|
|
expect(await contraste(page, '.btn--primary')).toBeGreaterThan(MINIMO);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test('todos los botones llevan la misma letra', async ({ page }) => {
|
|
|
|
|
// Dos botones juntos con distinto cuerpo se ven como un descuido, y así
|
|
|
|
|
// estaban: el dorado a 16px y el de al lado a 14.
|
|
|
|
|
await page.goto('/');
|
|
|
|
|
|
|
|
|
|
const tamanos = await page
|
|
|
|
|
.locator('a.btn, button.solido')
|
|
|
|
|
.evaluateAll((es) => [
|
|
|
|
|
...new Set(es.map((e) => `${getComputedStyle(e).fontSize} ${getComputedStyle(e).padding}`))
|
|
|
|
|
]);
|
|
|
|
|
expect(tamanos).toHaveLength(1);
|
|
|
|
|
});
|
|
|
|
|
});
|