Files
Uriel JarethandClaude Opus 5 0b9c9c1dc2 Portal interno INSTA: base de conocimiento con Magic Login y permisos por rol
Base de conocimiento para el personal del Instituto Santiago Tapia: oferta
educativa (en tarjetas y en tabla), requisitos y titulacion, becas y fechas
de inscripcion, links de clases de Google Meet, preguntas frecuentes,
mensajes listos y datos del instituto.

Acceso con Magic Login: enlace de un solo uso, 15 minutos de vigencia, y en
la base solo se guarda el SHA-256 del token. El canje es atomico, asi que dos
canjes simultaneos del mismo enlace no pueden crear dos sesiones.

Tres roles con 19 permisos. El catalogo lo edita direccion; los links de
clases, direccion y administracion; el resto del equipo consulta. El menu se
pinta con lo que el servidor autoriza y cada endpoint exige la misma clave,
asi que no hay pantallas alcanzables escribiendo la direccion a mano.

Toda edicion pasa por una lista blanca de campos validados y queda en la
bitacora. Un precio vacio no se guarda como cero, un link que no es una
direccion se rechaza, y no se puede marcar una validez oficial como
confirmada sin numero de acuerdo.

Pensado para leerse comodo: texto de 17 px, contraste AA, areas de toque de
44 px, tema claro y bloques plegados en telefono.

La oferta, los costos, las becas, los requisitos y el contacto son los reales
del instituto, tomados del CSV de oferta educativa y de la base de
conocimiento. Los usuarios de acceso y los links de Meet son de demostracion.

Verificado: 103 comprobaciones de API y 51 pruebas en WebKit escritorio y
iPhone 13.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-29 23:20:34 -06:00

151 lines
6.2 KiB
JavaScript

// =====================================================================
// Capturas de referencia en WebKit.
// node tests/capturas.mjs
// Deja los PNG en tests/capturas/ y una hoja de contactos HTML para
// revisar el portal completo de un vistazo.
// =====================================================================
import { webkit } from '@playwright/test';
import { mkdir, writeFile, rm } from 'node:fs/promises';
import path from 'node:path';
const BASE = process.env.BASE ?? 'http://localhost:4180';
const SALIDA = path.resolve('tests/capturas');
const SECCIONES = ['inicio', 'oferta', 'requisitos', 'becas', 'clases', 'preguntas', 'mensajes', 'contacto', 'equipo', 'bitacora'];
const VISTAS = [
{ id: 'escritorio', ancho: 1440, alto: 900 },
{ id: 'tableta', ancho: 820, alto: 1100 },
{ id: 'movil', ancho: 390, alto: 844 },
];
const CUENTAS = {
director: '[email protected]',
administrativo: '[email protected]',
empleado: '[email protected]',
};
await rm(SALIDA, { recursive: true, force: true });
await mkdir(SALIDA, { recursive: true });
const navegador = await webkit.launch();
const hechas = [];
async function capturar(ctx, nombre, url) {
const page = await ctx.newPage();
await page.goto(url, { waitUntil: 'networkidle' });
await page.waitForTimeout(450); // deja terminar la entrada en escena
const archivo = `${nombre}.png`;
const destino = path.join(SALIDA, archivo);
const alto = await page.evaluate(() => document.documentElement.scrollHeight);
// WebKit no puede capturar más de 32767 px de alto. Si una sección se pasa,
// es en sí mismo un aviso: se corta la captura y se anota.
if (alto > 30000) {
console.log(` ⚠ ${nombre} mide ${alto} px de alto: se captura solo la primera pantalla`);
await page.screenshot({ path: destino });
hechas.push({ archivo, nombre: `${nombre} (recortada · ${alto} px)` });
} else {
await page.screenshot({ path: destino, fullPage: true });
hechas.push({ archivo, nombre: `${nombre} · ${alto} px` });
}
await page.close();
}
async function abrirSesion(ctx, email) {
const r = await ctx.request.post(`${BASE}/api/auth/enlace`, { data: { email } });
const { token } = await r.json();
const p = await ctx.newPage();
await p.goto(`${BASE}/acceso/${token}`);
await p.waitForSelector('.barra__nav .nav-item');
await p.close();
}
for (const v of VISTAS) {
const ctx = await navegador.newContext({
viewport: { width: v.ancho, height: v.alto },
colorScheme: 'light',
locale: 'es-MX',
deviceScaleFactor: 1,
});
await capturar(ctx, `${v.id}-00-entrada`, `${BASE}/`);
await abrirSesion(ctx, CUENTAS.director);
let i = 1;
for (const s of SECCIONES) {
await capturar(ctx, `${v.id}-${String(i++).padStart(2, '0')}-${s}`, `${BASE}/portal#/${s}`);
}
await ctx.close();
console.log(` ✔ ${v.id} (${v.ancho} px)`);
}
// La oferta en vista de tabla, que es la alterna
{
const ctx = await navegador.newContext({ viewport: { width: 1440, height: 900 }, locale: 'es-MX' });
await abrirSesion(ctx, CUENTAS.director);
const p = await ctx.newPage();
await p.goto(`${BASE}/portal#/oferta`, { waitUntil: 'networkidle' });
await p.locator('[data-vista="tabla"]').click();
await p.waitForTimeout(400);
await p.screenshot({ path: path.join(SALIDA, 'escritorio-oferta-tabla.png'), fullPage: true });
hechas.push({ archivo: 'escritorio-oferta-tabla.png', nombre: 'oferta en tabla' });
// La ventana de edición de un programa
await p.locator('[data-vista="tarjetas"]').click();
await p.locator('[data-editar-programa]').first().click();
await p.waitForTimeout(350);
await p.screenshot({ path: path.join(SALIDA, 'escritorio-editor-programa.png') });
hechas.push({ archivo: 'escritorio-editor-programa.png', nombre: 'editor de programa' });
await p.close();
await ctx.close();
}
// La ventana de edición de una clase, en teléfono
{
const ctx = await navegador.newContext({ viewport: { width: 390, height: 844 }, locale: 'es-MX' });
await abrirSesion(ctx, CUENTAS.administrativo);
const p = await ctx.newPage();
await p.goto(`${BASE}/portal#/clases`, { waitUntil: 'networkidle' });
await p.locator('.cabecera-seccion__acciones [data-agregar-clase]').click();
await p.waitForTimeout(350);
await p.screenshot({ path: path.join(SALIDA, 'movil-editor-clase.png') });
hechas.push({ archivo: 'movil-editor-clase.png', nombre: 'editor de clase en móvil' });
await p.close();
await ctx.close();
}
// El menú de cada rol, para ver la diferencia de permisos de un vistazo
for (const [rol, email] of Object.entries(CUENTAS)) {
const ctx = await navegador.newContext({ viewport: { width: 1180, height: 1000 }, locale: 'es-MX' });
await abrirSesion(ctx, email);
const p = await ctx.newPage();
await p.goto(`${BASE}/portal#/inicio`, { waitUntil: 'networkidle' });
await p.waitForTimeout(450);
await p.screenshot({ path: path.join(SALIDA, `rol-${rol}.png`) });
hechas.push({ archivo: `rol-${rol}.png`, nombre: `menú de ${rol}` });
await p.close();
await ctx.close();
}
console.log(' ✔ menús por rol y ventanas de edición');
await navegador.close();
const hoja = `<!doctype html><meta charset="utf-8"><title>Capturas · Portal INSTA</title>
<style>
body{font:16px/1.6 system-ui;margin:0;padding:28px;background:#f3f7fc;color:#16283f}
h1{font-family:Palatino,Georgia,serif;margin:0 0 6px}
p{margin:0 0 24px;color:#45535f}
.g{display:grid;gap:18px;grid-template-columns:repeat(auto-fill,minmax(340px,1fr))}
figure{margin:0;background:#fff;border:1px solid #cfdce9;border-radius:20px 4px 20px 4px;overflow:hidden}
figcaption{padding:10px 14px;font-size:13px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#66737f;border-bottom:1px solid #e5edf5}
img{display:block;width:100%;height:auto}
</style>
<h1>Portal interno INSTA · capturas en WebKit</h1>
<p>${hechas.length} capturas · escritorio 1440, tableta 820, teléfono 390, las ventanas de edición y los tres menús por rol.</p>
<div class="g">
${hechas.map((h) => `<figure><figcaption>${h.nombre}</figcaption><img src="${h.archivo}" alt="${h.nombre}" loading="lazy"></figure>`).join('\n')}
</div>`;
await writeFile(path.join(SALIDA, 'index.html'), hoja, 'utf8');
console.log(`\n ${hechas.length} capturas en tests/capturas/ · abre tests/capturas/index.html\n`);