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]>
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
// =====================================================================
|
||||
// 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`);
|
||||
Reference in New Issue
Block a user