El Módulo 7 terminó con un diagnóstico incómodo: Nómada Tareas tiene seis capas, catorce módulos, dos fuentes de datos y un canal en tiempo real, y la única forma de saber si algo funciona es abrirlo y probarlo a mano. Esta lección ataca la primera mitad del problema —cuando ya sabes que algo falla, ¿cómo averiguas por qué?— y lo hace con un cambio de mentalidad: depurar no es adivinar, es un procedimiento. Vas a aprender el ciclo reproducir → aislar → formular hipótesis → comprobar → corregir → prevenir, la técnica de bisección que reduce a la mitad el terreno sospechoso en cada paso, la consola entera (que es mucho más que console.log), el depurador de las DevTools con sus seis clases de puntos de interrupción, la lectura de una pila de llamadas asíncrona, los source maps, y los paneles de red y almacenamiento. Y terminarás resolviendo tres fallos reales de Nómada Tareas paso a paso, con el método delante.
Contenido
- Por qué depurar por intuición no escala
- El método: seis pasos
- Bisección: partir el problema por la mitad
- La consola más allá de
console.log - El truco de las llaves:
console.log({variable}) - La sentencia
debuggery el primer punto de interrupción - Los seis tipos de punto de interrupción
- Ejecutar paso a paso: step over, into y out
- Los paneles Scope, Watch y Call Stack
- Leer un stack trace y «Pause on exceptions»
- Depurar código asíncrono
- Source maps: por qué producción es ilegible sin ellos
- Depurar la red: el panel Network
- Depurar el almacenamiento: el panel Application
- Depurar en un móvil real
- Caso 1: la tarea que no cambia de estado
- Caso 2: el filtro que pierde tareas al recargar
- Caso 3: el contador de horas descuadrado
- El bug que no se reproduce
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- Por qué depurar por intuición no escala
La forma en que casi todo el mundo empieza a depurar es esta: leer el código donde cree que está el fallo, ver algo raro, cambiarlo, recargar, mirar si sigue fallando. Repetir. A esto se le llama depuración por cambio aleatorio, y tiene tres problemas graves:
- No converge. Cada cambio modifica el sistema, así que el fallo puede desaparecer por una razón distinta de la que creías, y reaparecer una semana después.
- Introduce fallos nuevos. Un
ifañadido "por si acaso" que tapa el síntoma deja el sistema con dos problemas en lugar de uno. - No enseña nada. Cuando el fallo se va, no sabes por qué se fue, y por tanto no puedes evitar el siguiente de la misma familia.
La alternativa es tratar el fallo como lo que es: una diferencia entre lo que el programa hace y lo que crees que hace. Depurar es localizar el punto exacto donde tu modelo mental y la realidad se separan. Y para eso existe un procedimiento.
Una idea que conviene interiorizar desde ya: el 90 % del tiempo de depuración se gasta en localizar el fallo, no en arreglarlo. La corrección suele ser una línea. Por eso todo el método está orientado a localizar rápido, no a escribir rápido.
- El método: seis pasos
flowchart TD
A["1 · Reproducir<br/>pasos exactos y fiables"] --> B["2 · Aislar<br/>reducir al mínimo caso"]
B --> C["3 · Hipótesis<br/>una afirmación falsable"]
C --> D["4 · Comprobar<br/>breakpoint, log o prueba"]
D -->|hipótesis falsa| C
D -->|hipótesis cierta| E["5 · Corregir<br/>la causa, no el síntoma"]
E --> F["6 · Prevenir<br/>una prueba que falle sin el arreglo"]
Los seis pasos, con lo que significa cada uno en la práctica:
| Paso | Qué haces | Cómo sabes que has terminado |
|---|---|---|
| 1 · Reproducir | Escribes la secuencia exacta de acciones que provoca el fallo | Puedes provocarlo a voluntad, tres veces seguidas |
| 2 · Aislar | Quitas todo lo que no sea imprescindible: datos, módulos, pasos | Un caso mínimo que sigue fallando |
| 3 · Hipótesis | Formulas una frase concreta y comprobable: «id llega como cadena» |
La frase se puede demostrar falsa con un dato |
| 4 · Comprobar | Pones un punto de interrupción o un registro que la confirme o la niegue | Tienes un valor observado, no una impresión |
| 5 · Corregir | Arreglas la causa, en la capa correcta | El caso mínimo pasa y entiendes por qué |
| 6 · Prevenir | Escribes una prueba automática que falla sin el arreglo | La prueba pasa a verde al aplicar la corrección |
El paso 6 es el que casi todo el mundo se salta, y es el que convierte una tarde perdida en un activo permanente. Todavía no sabes escribir esas pruebas —eso llega en Pruebas Unitarias con Jest—, pero en esta lección ya vas a dejar escrita la comprobación que debería existir para cada fallo. Cuando llegues a 08-03 tendrás tres pruebas esperándote.
Una regla de oro del paso 3: una hipótesis que no se puede demostrar falsa no es una hipótesis. «Creo que hay algo raro en el filtro» no sirve. «Creo que estado.filtros.responsable vale 'Iván' cuando debería valer null» sí sirve, porque un solo vistazo la resuelve.
- Bisección: partir el problema por la mitad
Cuando el fallo está en algún punto de un recorrido largo —el clic entra por controlador.js, pasa por tablero.js, vuelve por tablero-vista.js y termina en repositorio-local.js—, buscar leyendo es lento. La técnica correcta es la bisección: elegir un punto intermedio del recorrido, comprobar si allí los datos ya están mal, y descartar la mitad correspondiente.
flowchart LR
A["Clic<br/>controlador"] --> B["Tablero<br/>cambiarEstado"]
B --> C["Evento<br/>tarea:cambiada"]
C --> D["Vista<br/>reconciliar"]
D --> E["Repositorio<br/>guardar"]
style C fill:#fde68a,stroke:#b45309
Si el dato ya está mal en el punto medio (C), el fallo está entre A y C. Si está bien, está entre C y E. Cada comprobación divide el terreno entre dos. Con cuatro comprobaciones cubres un recorrido de dieciséis pasos.
La bisección se aplica a tres dimensiones distintas, y conviene conocer las tres:
- En el flujo de datos, como acabas de ver: comprobar en puntos intermedios del camino.
- En el tiempo, con
git bisect: si la aplicación funcionaba hace treinta commits y ahora no, Git puede buscar en binario el commit culpable en cinco pasos.
git bisect start
git bisect bad # el HEAD actual falla
git bisect good a455c72 # este commit funcionaba
# Git te sitúa en un commit intermedio: pruebas y respondes
git bisect good # o: git bisect bad
# ... cinco iteraciones ...
git bisect reset # vuelve a donde estabas- En el código, comentando la mitad: si desactivas
conectarFormularioy el fallo desaparece, ya sabes de qué lado mirar. Es la versión más tosca, pero en una interfaz con muchos oyentes es sorprendentemente eficaz.
- La consola más allá de
console.log
console.logconsole.log es una herramienta legítima —quien diga que un profesional nunca la usa, miente—, pero el objeto console tiene una docena de métodos que resuelven mejor problemas concretos. Estos son los que se usan de verdad:
| Método | Para qué sirve | Ejemplo en Nómada Tareas |
|---|---|---|
console.table(datos) |
Muestra un array de objetos como tabla ordenable | console.table(tablero.tareas.map((t) => t.toJSON())) |
console.dir(obj) |
Muestra el objeto como estructura, no como texto | console.dir(document.querySelector('#lista-tareas')) |
console.group() / groupEnd() |
Agrupa y pliega registros relacionados | Un grupo por cada render |
console.count(etiqueta) |
Cuenta cuántas veces se pasa por un punto | Detectar renders duplicados |
console.time() / timeEnd() |
Mide el tiempo entre dos puntos | console.time('render') … console.timeEnd('render') |
console.assert(cond, msg) |
Registra solo si la condición es falsa | console.assert(r.horasAbiertas === 45, r) |
console.trace() |
Imprime la pila de llamadas sin detener nada | ¿Quién ha llamado a guardar()? |
console.warn / error |
Nivel de gravedad: se pueden filtrar por nivel | Avisos del repositorio |
Cuatro de ellos merecen un ejemplo completo, porque cambian de verdad la forma de trabajar.
console.table es la diferencia entre entender un array de seis objetos de un vistazo y no entenderlo. Acepta un segundo argumento con las columnas que quieres:
// En la consola, con la aplicación abierta
console.table(
tablero.tareas.map((t) => t.toJSON()),
['id', 'titulo', 'responsable', 'estado', 'horasEstimadas']
);┌─────────┬────┬───────────────────────────────┬─────────────┬─────────────┬────────────────┐ │ (index) │ id │ titulo │ responsable │ estado │ horasEstimadas │ ├─────────┼────┼───────────────────────────────┼─────────────┼─────────────┼────────────────┤ │ 0 │ 1 │ 'Rediseñar la sala…' │ 'Iván' │ 'en-curso' │ 12 │ │ 1 │ 2 │ 'Cartelería del taller…' │ 'Marta' │ 'pendiente' │ 6 │ │ 2 │ 3 │ 'Actualizar la web…' │ 'Lucía' │ 'pendiente' │ 14 │ │ 3 │ 4 │ 'Inventario de tintas…' │ 'Marta' │ 'hecha' │ 3 │ │ 4 │ 5 │ 'Guía de encuadernación…' │ 'Iván' │ 'en-curso' │ 8 │ │ 5 │ 6 │ 'Presupuesto de la…' │ 'Iván' │ 'pendiente' │ 5 │ └─────────┴────┴───────────────────────────────┴─────────────┴─────────────┴────────────────┘
Las columnas de la tabla de DevTools se ordenan al pulsar en la cabecera. Ordenar por estado y contar es más rápido que cualquier filter escrito a mano.
console.count responde a la pregunta más frecuente de una interfaz: ¿por qué esto se ejecuta tres veces?
render del tablero: 1 render del tablero: 2 render del tablero: 3 ← un solo clic ha provocado tres renders
Ese resultado ya es un diagnóstico: hay tres oyentes distintos reaccionando al mismo evento, o el evento burbujea y se maneja dos veces. Sin count, ese fallo se manifiesta solo como "va un poco lento".
console.assert es una afirmación que solo habla cuando se incumple. Es perfecta para vigilar invariantes conocidos, como los números canónicos del backlog:
const r = tablero.resumen(HOY);
console.assert(r.horasAbiertas === 45, 'Horas abiertas descuadradas', r);
console.assert(r.esfuerzo === 124, 'Esfuerzo ponderado descuadrado', r);
// Si todo va bien no imprime nada. Si falla:
// Assertion failed: Horas abiertas descuadradas {total: 6, abiertas: 5, horasAbiertas: 48, …}console.group convierte un vertedero de líneas en un árbol plegable:
export function guardarTablero(tablero) {
console.group(`[repositorio] guardar · ${tablero.total} tareas`);
console.log('clave:', 'nomada:tablero:v1');
console.table(tablero.resumen(HOY));
const ok = repositorio.guardar(tablero);
console.log('resultado:', ok ? 'guardado' : 'sin espacio');
console.groupEnd();
}Con console.groupCollapsed() el grupo aparece ya plegado, que es lo que quieres cuando registras algo que ocurre muchas veces.
- El truco de las llaves:
console.log({variable})
console.log({variable})Este pequeño truco ahorra más tiempo del que parece. Compara:
console.log(responsable, estado, visibles.length);
// Iván pendiente 3 ← ¿cuál era cuál?
console.log({ responsable, estado, visibles: visibles.length });
// {responsable: 'Iván', estado: 'pendiente', visibles: 3} ← con nombresAl envolver las variables en llaves usas la abreviatura de propiedades de ES6 (04-01): { responsable } es { responsable: responsable }. El resultado es un objeto donde cada valor viene etiquetado con el nombre de su variable, y además se muestra plegable e inspeccionable en la consola. Adóptalo como norma: no cuesta nada y elimina la clase entera de errores de "estoy leyendo el log de otra variable".
Dos complementos del mismo estilo:
// Marcar el punto exacto, útil cuando hay varios logs iguales
console.log('[controlador:avanzar]', { id, estadoActual: tarea.estado });
// Copiar un valor grande al portapapeles para pegarlo en un fichero
copy(tablero.tareas.map((t) => t.toJSON())); // copy() solo existe en la consolacopy() no es JavaScript estándar: es una de las funciones de utilidad de la consola de DevTools, junto con $0 (el último elemento seleccionado en el inspector), $_ (el último resultado) y $$('selector') (un querySelectorAll que devuelve array de verdad). Solo funcionan escritas a mano en la consola, nunca en tu código.
- La sentencia
debugger y el primer punto de interrupción
debugger y el primer punto de interrupciónconsole.log te dice el valor de lo que se te ocurrió imprimir. El depurador te deja mirarlo todo, en el momento exacto, y además seguir avanzando. La diferencia de potencia es enorme.
La forma más rápida de entrar en el depurador es la palabra clave debugger:
cambiarEstado(id, nuevo) {
const tarea = this.buscarPorId(id);
debugger; // ← el navegador se detiene AQUÍ
if (tarea === null) throw new ErrorDeValidacion(`No existe la tarea ${id}.`, 'id', id);
tarea.cambiarEstado(nuevo);
return this;
}Con las DevTools abiertas, la ejecución se congela en esa línea y puedes inspeccionar id, nuevo, this y todo lo demás. Con las DevTools cerradas, la sentencia no hace nada.
debugger tiene una ventaja (es una línea, funciona siempre, sobrevive a un cambio de fichero) y un peligro serio: si se cuela en producción, congela la aplicación a cualquiera que tenga las herramientas abiertas. En la lección siguiente configurarás una regla de ESLint (no-debugger) que impide exactamente eso.
Lo habitual, sin embargo, es no tocar el código: en el panel Sources (Chrome/Edge) o Depurador (Firefox), abres el fichero y pulsas en el número de línea. Aparece un marcador azul: ese es un punto de interrupción normal.
- Los seis tipos de punto de interrupción
Aquí es donde el depurador deja de ser "un console.log más elegante" y se convierte en otra cosa. Estos son los seis tipos y cuándo usar cada uno:
| Tipo | Cómo se pone | Cuándo es la herramienta correcta |
|---|---|---|
| Normal | Clic en el número de línea | Quieres parar siempre en ese punto |
| Condicional | Clic derecho → Add conditional breakpoint | La línea se ejecuta 200 veces y solo te interesa un caso |
| De registro (logpoint) | Clic derecho → Add logpoint | Quieres el valor sin detener y sin tocar el código |
| Por evento | Event Listener Breakpoints → Mouse → click |
No sabes qué código maneja ese clic |
| Por petición | XHR/fetch Breakpoints → añadir /tareas |
Quieres parar justo antes de una petición concreta |
| Por cambio del DOM | Clic derecho en el nodo → Break on… | Un elemento cambia y no sabes quién lo cambia |
El condicional es el que más tiempo ahorra en este proyecto. La función pintarTarjeta se ejecuta seis veces por render; parar en las seis es inútil. Con la condición tarea.id === 3 paras solo en la que te interesa:
// Condición escrita en el diálogo del breakpoint (no en el código):
tarea.id === 3 && tarea.estado === 'pendiente'La condición es una expresión JavaScript normal evaluada en el ámbito de esa línea. Truco poco conocido: si escribes console.count('paso') como condición, nunca para (porque devuelve undefined, que es falso) pero sí ejecuta el contador. Eso es exactamente lo que hace un logpoint, que es la versión oficial de la idea.
El logpoint merece un párrafo propio porque sustituye al 80 % de los console.log que la gente escribe:
// En el diálogo del logpoint, sobre la línea de cambiarEstado:
'cambiando', {id, tipo: typeof id, nuevo, estadoActual: tarea?.estado}Ventajas frente a escribir el console.log en el fichero: no modificas el código, no hay que recargar, no hay riesgo de olvidarlo en un commit, y funciona igual sobre código de terceros o minificado. Cuando termines, borras el punto y no queda rastro.
Los puntos por evento resuelven la pregunta "¿qué código se ejecuta cuando pulso este botón?" en una aplicación que no conoces. Activas Mouse → click, pulsas el botón, y el depurador te deja dentro del manejador, con la pila de llamadas completa. En Nómada Tareas eso te llevaría directamente al oyente delegado de js/vista/controlador.js, con evento.target ya disponible.
Los puntos por petición (XHR/fetch Breakpoints) paran justo antes de que salga una petición cuya URL contenga el texto que indiques —por ejemplo tareas—. Sirven para inspeccionar el body que estás a punto de enviar antes de que el servidor lo rechace, y para descubrir quién dispara una petición inesperada: la pila de llamadas te lo dice.
Los puntos por cambio del DOM son la solución al clásico "esta clase se quita sola". En el inspector, clic derecho sobre el nodo → Break on → attribute modifications, y el depurador para en la línea de JavaScript que modifica ese atributo. Hay tres variantes:
| Variante | Se dispara cuando |
|---|---|
| subtree modifications | Se añade o elimina un descendiente (útil para reconciliar) |
| attribute modifications | Cambia un atributo: class, data-estado, disabled, hidden |
| node removal | El propio nodo se elimina del árbol |
- Ejecutar paso a paso: step over, into y out
Una vez detenido, controlas la ejecución con cuatro botones. Entenderlos bien es lo que separa usar el depurador de sufrirlo:
| Acción | Atajo habitual | Qué hace |
|---|---|---|
| Resume (continuar) | F8 | Sigue hasta el próximo punto de interrupción |
| Step over (saltar) | F10 | Ejecuta la línea entera, sin entrar en las funciones que llama |
| Step into (entrar) | F11 | Entra dentro de la función que se llama en esta línea |
| Step out (salir) | Mayús + F11 | Termina la función actual y vuelve a quien la llamó |
La regla práctica es sencilla:
- Usa step over por defecto. Vas leyendo el flujo de la función actual sin perderte en las llamadas.
- Usa step into solo cuando sospeches de la función a la que estás llamando.
- Usa step out en cuanto te des cuenta de que has entrado donde no querías (típicamente, dentro de una función de librería).
Con reconciliar, pintarTarjeta y map por medio, es fácil acabar veinte fotogramas dentro de código ajeno. Para evitarlo existe Ignore List (antes blackboxing): marcas un fichero o un patrón como ignorado y el depurador nunca entra en él ni lo muestra en la pila. Marca ahí tus dependencias y la pila se vuelve legible al instante.
- Los paneles Scope, Watch y Call Stack
Cuando la ejecución está detenida, la mitad derecha del panel es donde ocurre la depuración de verdad.
Scope muestra las variables agrupadas por ámbito, y es la comprobación empírica de todo lo que estudiaste en 03-04 y 03-05:
Scope
├─ Local ← las variables de la función actual
│ tarea: Tarea {id: 3, titulo: 'Actualizar la web…'}
│ nuevo: "en-curso"
├─ Closure (conectarTablero) ← ¡el closure, visible!
│ tablero: Tablero {nombre: 'Taller Nómada'}
│ hoy: "2026-09-20"
├─ Module ← lo importado y lo declarado en el módulo
└─ Global ← windowEse bloque Closure es literalmente el entorno capturado del que hablaba 03-04, listado con nombre y contenido. Si alguna vez dudaste de que los closures existen físicamente, aquí están.
Watch es una lista de expresiones que se reevalúan en cada paso. No estás limitado a variables: puedes vigilar cálculos completos.
// Expresiones útiles en Watch mientras se depura Nómada Tareas
tablero.resumen('2026-09-20').horasAbiertas
tablero.tareas.filter((t) => t.abierta).length
estado.filtros
document.querySelectorAll('#lista-tareas > li').lengthVer horasAbiertas cambiar de 45 a 39 en el paso exacto en que ocurre es una información imposible de obtener con logs sueltos.
Call Stack es la pila de llamadas: quién llamó a quién hasta llegar aquí. Se lee de arriba abajo, del más reciente al más antiguo:
pintarTarjeta tarjeta.js:24 ← estás aquí reconciliar dom.js:38 actualizar tablero-vista.js:96 (anonymous) app.js:41 ← el oyente del evento
Pulsando en cualquier fotograma te trasladas a él con su Scope correspondiente, sin perder la detención. Es la manera de responder "¿con qué argumentos me han llamado?" cuando el problema no está aquí sino en quien llamó.
Y una función poco conocida y muy útil: Restart frame. Clic derecho sobre un fotograma → Restart frame vuelve a ejecutar esa función desde el principio, con los mismos argumentos. Si has pasado de largo la línea interesante con un F10 de más, no hace falta recargar la página y repetir los diez clics que te trajeron hasta aquí: reinicias el fotograma y vuelves a pasar. (Con una advertencia: los efectos secundarios que ya ocurrieron —una petición enviada, un setItem hecho— no se deshacen.)
- Leer un stack trace y «Pause on exceptions»
Un error sin capturar imprime algo así:
ErrorDeValidacion: Transición no permitida: "hecha" → "en-curso".
at Tarea.cambiarEstado (tarea.js:71:13)
at Tablero.cambiarEstado (tablero.js:52:11)
at HTMLUListElement.<anonymous> (controlador.js:38:15)Se lee así:
- Primera línea: tipo de error y mensaje. Aquí ya sabes que es una regla R6 incumplida.
- Línea 1 de la pila: dónde se lanzó.
tarea.js:71:13= fichero, línea 71, columna 13. - Líneas siguientes: la cadena de llamadas hacia atrás. La última suele ser el punto de entrada real.
HTMLUListElement.<anonymous>: una función anónima adjunta como oyente a un<ul>. Es el oyente delegado de 06-04.
El error más común al leer una pila es fijarse solo en la primera línea. El lugar donde se lanza casi nunca es el lugar donde está el fallo. Aquí el error se lanza en Tarea, pero la causa está en controlador.js:38: alguien intentó reabrir una tarea ya terminada porque el botón no estaba desactivado. La primera línea dice qué pasó; las de abajo dicen por qué.
«Pause on exceptions» (el icono ⏸ con el rombo, en el panel Sources) hace que el depurador se detenga en el instante en que se lanza el error, con todo el contexto vivo. Tiene dos niveles:
| Opción | Se detiene en | Cuándo activarla |
|---|---|---|
| Pause on uncaught exceptions | Solo errores que nadie captura | Siempre; ruido casi nulo |
| Pause on caught exceptions | También en los que un catch captura |
Cuando algo falla en silencio |
La segunda es la joya escondida. Nómada Tareas captura errores en varios sitios (el try/catch del formulario, el catch del repositorio que descarta datos corruptos, el conReintentos). Si un fallo se está tragando en uno de esos catch, activar «pause on caught exceptions» te lleva directamente al throw original. Eso sí: actívala solo mientras investigas, porque también parará en errores capturados que son perfectamente normales.
- Depurar código asíncrono
Aquí es donde el depurador clásico solía rendirse. Cuando un setTimeout, una promesa o un await retoman la ejecución, la pila de llamadas original ya se ha vaciado —es exactamente el mecanismo del bucle de eventos que estudiaste en 05-07—. Sin ayuda, la pila mostraría solo esto:
Inútil: no dice quién pidió esa petición. Por eso los navegadores modernos mantienen pilas asíncronas, que cosen el fotograma actual con el que programó la tarea:
pedirJson http.js:112 ── Async: await ── ← la costura listarTareas api-tareas.js:47 ── Async: await ── cargarTablero app.js:63 ── Async: promise callback ── (anonymous) controlador.js:52
Ahora sí: la petición nació de un clic en el controlador. Cuatro consejos concretos para depurar asincronía en este proyecto:
- Pon el punto de interrupción después del
await, no antes. Antes solo ves la promesa pendiente; después ves el valor resuelto. - Usa un punto condicional en
conReintentoscon la condiciónintento > 1: paras solo cuando algo ya ha fallado una vez. - En el panel Network activa la columna Initiator. Igual que la pila asíncrona, te dice qué línea disparó cada petición.
- Cuidado con las carreras que el propio depurador provoca. Al detener la ejecución cinco segundos, un timeout de 8 s puede vencer. Si sospechas de una condición de carrera, prefiere logpoints a detenciones.
Un patrón muy útil para asincronía es medir dónde se va el tiempo sin frenar nada:
export async function listarTareasMedido(filtros) {
console.time('listarTareas');
try {
return await listarTareas(filtros);
} finally {
console.timeEnd('listarTareas'); // se ejecuta también si lanza
}
}
// listarTareas: 843.21 msEl finally garantiza que el timeEnd se ejecute aunque la petición falle; si no, un error dejaría el temporizador abierto y el siguiente console.time avisaría de una etiqueta duplicada.
- Source maps: por qué producción es ilegible sin ellos
El código que se despliega no es el que escribes. Un empaquetador lo une, lo minifica y renombra las variables, así que un error en producción se ve así:
t, línea 1, columna 24817. Sin información adicional, esa pista no vale nada.
Un source map es un fichero (app.4f3a1b.js.map) que contiene el diccionario de traducción entre el código generado y el original: qué posición del fichero minificado corresponde a qué fichero, línea, columna y nombre de variable del código fuente. El navegador lo carga si encuentra el comentario final:
Y entonces el mismo error se muestra así:
TypeError: Cannot read properties of null (reading 'dataset')
at manejarClic (js/vista/controlador.js:34:22)Tres decisiones prácticas sobre source maps:
| Escenario | Recomendación | Motivo |
|---|---|---|
| Desarrollo | Siempre activados | El coste de tamaño da igual en local |
| Producción, aplicación pública | Generarlos, pero no publicarlos al alcance de cualquiera | Se suben al servicio de monitorización; el navegador anónimo no los descarga |
| Producción, herramienta interna | Publicarlos sin problema | El código no es secreto y depurar incidencias es más fácil |
Ten presente que un source map reconstruye tu código fuente. Publicarlo equivale a publicar el código sin minificar. No es un fallo de seguridad por sí mismo —la seguridad nunca debe depender de ofuscación—, pero es una decisión consciente que hay que tomar.
En DevTools, si un source map no carga, la pestaña Sources muestra un aviso en la consola (DevTools failed to load source map). Las tres causas habituales: el .map no se desplegó, la ruta del comentario final es incorrecta, o el servidor lo devuelve con un 404 tras una configuración de caché agresiva.
- Depurar la red: el panel Network
El Módulo 7 llenó Nómada Tareas de peticiones. Cuando una falla, el panel Network responde en segundos lo que el código no puede contarte:
- Filtra por
Fetch/XHRpara ver solo tus peticiones, sin imágenes ni CSS. - La columna Status distingue lo que
fetchno distingue: un200con cuerpo vacío, un404, un500, o(failed)para un fallo de transporte. - La pestaña Headers muestra lo que enviaste y lo que llegó, incluida la cabecera
Content-Typeque hace fallar elpedirJsoncuando llega HTML. - La pestaña Payload enseña el cuerpo del
POSTtal cual salió: aquí se descubren losundefinedqueJSON.stringifyelimina en silencio. - La pestaña Timing desglosa el tiempo: Stalled, Waiting (TTFB), Content Download. Si el TTFB es 4 s, no es culpa de tu JavaScript.
Tres funciones del panel que conviene conocer:
Copy as fetch. Clic derecho sobre una petición → Copy → Copy as fetch. Pega el resultado en la consola y reproduces la petición exacta, con sus cabeceras, tantas veces como quieras. Es la mejor forma de aislar si el problema está en el servidor o en tu código: si la petición copiada funciona en la consola y no en la aplicación, el fallo es tuyo.
// Pegado desde "Copy as fetch" y modificado para probar una hipótesis
await fetch('https://api.tallernomada.example/v1/tareas?responsable=Iv%C3%A1n', {
headers: { accept: 'application/json' }
}).then((r) => ({ ok: r.ok, status: r.status, tipo: r.headers.get('content-type') }));Throttling. El selector de red (No throttling / Slow 4G / Offline) simula conexiones lentas. Es imprescindible para probar dos cosas que escribiste en el Módulo 7 y que en local nunca se ven: los estados de "cargando" de 07-03 y el funcionamiento sin conexión del service worker de 07-05. En una red local de 1 ms, el estado de carga aparece y desaparece antes de renderizarse.
Preserve log. Marca esta casilla si la petición que investigas provoca una navegación o una recarga; sin ella, el registro se borra y la petición culpable desaparece.
- Depurar el almacenamiento: el panel Application
Para todo lo de 07-01 y 07-05, el panel Application es la ventana directa al estado persistido:
| Sección | Qué inspeccionas en Nómada Tareas |
|---|---|
| Local Storage | La clave nomada:tablero:v1, su JSON y su tamaño |
| Session Storage | Estado efímero de la pestaña |
| IndexedDB | (No usado aquí, pero es donde miraría una aplicación mayor) |
| Cache Storage | Los recursos precacheados por el service worker |
| Service Workers | Estado del worker: instalado, activo, en espera; Update on reload, Unregister |
| Manifest | Cómo interpreta el navegador tu manifest.json |
El valor de localStorage se puede editar en el sitio: haces doble clic en el valor, cambias el JSON y recargas. Es la forma más rápida de comprobar cómo reacciona tu código a datos corruptos, a un formato de versión antigua, o a un tablero vacío… sin escribir una línea. Y Clear site data te devuelve a un arranque limpio, que es el estado en el que debes reproducir cualquier fallo antes de darlo por bueno.
- Depurar en un móvil real
El emulador de dispositivo de las DevTools cambia el tamaño y simula el táctil, pero no es Safari en un iPhone ni Chrome en un Android de gama baja. Los fallos de verdad —un 100vh que se come la barra de direcciones, un evento táctil que no burbujea, una API que no existe en esa versión— solo se ven en el aparato.
La inspección remota conecta las DevTools de tu ordenador a la página que se ejecuta en el teléfono:
- Android + Chrome: activa las Opciones de desarrollador y la Depuración por USB en el teléfono, conéctalo por cable y abre
chrome://inspect#devicesen el ordenador. La pestaña del teléfono aparece en la lista; pulsas inspect y tienes las DevTools completas, incluidos el depurador y el panel Network. - iOS + Safari: activa Ajustes → Safari → Avanzado → Inspector web en el iPhone y Safari → Ajustes → Avanzado → Mostrar menú Desarrollo en el Mac; el dispositivo aparece bajo el menú Desarrollo.
Y para probar Nómada Tareas en el móvil hace falta que el teléfono llegue a tu servidor de desarrollo. Dos vías:
# Opción A · misma red wifi: sirve en todas las interfaces y usa la IP local
npx serve -l tcp://0.0.0.0:5000
# El móvil abre http://192.168.1.42:5000
# Opción B · túnel público con HTTPS (necesario para service workers reales)
npx localtunnel --port 5000La opción B importa por un detalle de 07-05: los service workers exigen HTTPS salvo en localhost. Y localhost en el móvil es el propio móvil, no tu portátil. Sin túnel HTTPS no podrás depurar la PWA en un dispositivo real.
- Caso 1: la tarea que no cambia de estado
Aplicamos el método completo a un fallo real.
El parte de incidencia. Marta escribe: «He creado la tarea Revisar extintores con el formulario y luego he pulsado Empezar y no hace nada. Las demás sí funcionan.»
Paso 1 · Reproducir. Primero, un arranque limpio (Clear site data). Después, la secuencia exacta:
- Abrir la aplicación con el backlog canónico (6 tareas).
- Pulsar Empezar en la tarea 2 → funciona.
- Crear una tarea nueva con el formulario.
- Pulsar Empezar en la tarea nueva → no pasa nada.
Reproducido, y con una pista de oro: falla solo en las tareas creadas durante la sesión. Las del backlog van bien.
Paso 2 · Aislar. ¿Es del formulario o de la creación? Probamos a crear una tarea desde la consola, sin tocar el formulario:
const t = await crearTarea({ titulo: 'Prueba', responsable: 'Iván', prioridad: 'baja',
horasEstimadas: 2, fechaLimite: '2026-10-30', etiquetas: [] });
tablero.agregar(t);
vista.actualizar();
// Pulsar "Empezar" en esa tarjeta → tampoco funcionaEl formulario queda descartado: el problema está en las tareas que vienen de crearTarea, es decir, de la API.
Paso 3 · Hipótesis. El flujo del clic es: controlador lee li.dataset.id → llama a tablero.cambiarEstado(id, ...) → buscarPorId(id) compara con t.id === id. Y dataset siempre devuelve cadenas. Para las tareas del backlog, id es un número en el modelo; para las de la API… la hipótesis concreta es:
Tarea.desdeJSONestá guardandoidcomo cadena para las tareas que llegan del servidor, ybuscarPorIdusa===, que no convierte tipos (01-07). Por esobuscarPorId('7')devuelvenully no encuentra la tarea.
Espera: si devolviera null, Tablero.cambiarEstado lanzaría ErrorDeValidacion. ¿Por qué no vemos nada? Porque el controlador captura ese error para mostrarlo, y el catch lo escribe en un contenedor que está oculto. Segunda parte de la hipótesis: el error se lanza y se traga.
Paso 4 · Comprobar. Dos comprobaciones, ninguna modifica el código:
- Activar Pause on caught exceptions, pulsar el botón. El depurador se detiene… en el
throwdeTablero.cambiarEstado. Hipótesis B confirmada. - Poner un logpoint en la línea de
buscarPorIdcon{id, tipoId: typeof id, ids: this.tareas.map((t) => [t.id, typeof t.id])}:
Confirmadísimo. El id del modelo es una cadena '7', el dataset también devuelve cadena, pero el controlador hacía Number(li.dataset.id) antes de llamar, así que compara 7 === '7' → false.
Paso 5 · Corregir la causa. Tres arreglos posibles, y solo uno es el correcto:
| Arreglo | Dónde | Veredicto |
|---|---|---|
Cambiar === por == en buscarPorId |
Modelo | ❌ Tapa el síntoma y reintroduce la coerción que 01-07 desaconseja |
| Convertir a número en el controlador | Vista | ❌ El modelo seguiría con tipos mezclados; el fallo reaparecería en otro sitio |
| Normalizar el tipo en la frontera de datos | Tarea.desdeJSON |
✅ Un solo punto, y el invariante «id es number» se cumple siempre |
// js/modelo/tarea.js — la frontera normaliza los tipos
static desdeJSON(datos) {
const plano = typeof datos === 'string' ? JSON.parse(datos) : datos;
return new Tarea({ ...plano, id: Number(plano.id) }); // ← el id SIEMPRE es número
}Y de paso, el catch que se tragaba el error deja de ser silencioso: si el contenedor de errores está oculto, se muestra. Un error que nadie ve es un error que existe dos veces.
Paso 6 · Prevenir. La prueba que debería existir, y que escribirás en 08-03:
// Pendiente para 08-03:
// "Tarea.desdeJSON convierte un id en cadena a número"
// → Tarea.desdeJSON({ ...datos, id: '7' }).id === 7 (y typeof === 'number')
// "Tablero.buscarPorId encuentra una tarea importada del servidor"
- Caso 2: el filtro que pierde tareas al recargar
El parte. Iván: «He filtrado por mi nombre para ver mis tareas, he cerrado el portátil, y al volver solo quedaban tres tareas en todo el tablero. Las de Marta y Lucía han desaparecido.»
Paso 1 · Reproducir. Con el backlog canónico: filtrar por Iván (quedan 3 visibles), recargar (F5) → el tablero tiene 3 tareas y el resumen dice 25 h. Reproducible al 100 %. Y es un fallo de pérdida de datos, la peor categoría: prioridad máxima.
Paso 2 · Aislar. La pregunta clave: ¿se guardó mal, o se lee mal? El panel Application la responde sin tocar el código. Filtramos por Iván (sin recargar) y miramos Local Storage → nomada:tablero:v1:
{ "nombre": "Taller Nómada", "version": 1, "tareas": [
{ "id": 1, "titulo": "Rediseñar la sala polivalente", "responsable": "Iván", … },
{ "id": 5, "titulo": "Guía de encuadernación para residentes", … },
{ "id": 6, "titulo": "Presupuesto de la carpintería", … } ] }Sólo tres tareas ya escritas. El fallo está en la escritura, no en la lectura. Acabamos de descartar la mitad del recorrido con un vistazo.
Paso 3 · Hipótesis. ¿Quién llama a guardar y con qué? Aquí la herramienta es console.trace() colocado como logpoint en RepositorioLocal.guardar, con la expresión:
guardar {total: 3}
console.trace
at HTMLDocument.<anonymous> (app.js:78)
at TableroVista.actualizar (tablero-vista.js:104)La hipótesis se escribe sola:
app.jsestá guardando lo que la vista muestra en lugar del tablero completo. Al filtrar, la vista tiene 3 tareas y eso es lo que se persiste, machacando las otras 3.
Paso 4 · Comprobar. Abrimos app.js:78:
document.addEventListener(EVENTOS.FILTRO_APLICADO, () => {
vista.actualizar({ filtros: leerEstadoDeUrl() });
repositorio.guardar(new Tablero(tablero.nombre, vista.visibles)); // ← aquí
});Alguien, al añadir el enrutador de 07-06, quiso "guardar el estado al filtrar" y construyó un tablero nuevo con las tareas visibles. Hipótesis confirmada con la línea delante.
Paso 5 · Corregir. La causa real es conceptual, y merece enunciarse: se ha persistido un valor derivado de la vista como si fuera estado del modelo. El filtro es presentación (06-06) y su sitio es la URL (07-06); el tablero es el modelo y su sitio es el almacén.
// js/app.js — corregido
document.addEventListener(EVENTOS.FILTRO_APLICADO, (evento) => {
vista.actualizar({ filtros: evento.detail });
escribirEstadoEnUrl(evento.detail); // el filtro vive en la URL…
});
// …y el modelo se guarda solo cuando el MODELO cambia
document.addEventListener(EVENTOS.TAREA_CAMBIADA, () => repositorio.guardar(tablero));
document.addEventListener(EVENTOS.TAREA_CREADA, () => repositorio.guardar(tablero));Paso 6 · Prevenir. Dos comprobaciones para 08-05, donde se prueban modelo y datos juntos:
// Pendiente para 08-05:
// "filtrar la vista no altera lo persistido"
// → filtrar por 'Iván', y repositorio.cargar().total sigue siendo 6
// "guardar tras cambiar de estado conserva las 6 tareas"
- Caso 3: el contador de horas descuadrado
El parte. Lucía: «El resumen dice 48 h abiertas nada más abrir. Deberían ser 45; la tarea del inventario está hecha desde hace una semana.»
Este caso es distinto de los anteriores: no hay que reproducir nada, falla siempre. Y hay algo mucho mejor que un parte de incidencia: hay un oráculo. El backlog canónico tiene números conocidos —48 h totales, 45 h abiertas, esfuerzo 124— y el programa contradice uno de ellos.
Paso 2 · Aislar. Un solo comando en la consola separa el modelo de la vista:
tablero.resumen('2026-09-20');
// { total: 6, abiertas: 5, horasTotales: 48, horasAbiertas: 48, vencidas: 1, esfuerzo: 124 }El modelo ya devuelve 48. La vista está inocente: solo pinta lo que le dan. Y hay un detalle revelador: abiertas: 5 es correcto (5 tareas abiertas de 6), pero horasAbiertas coincide exactamente con horasTotales. Eso no es un error de cálculo, es un error de selección: se están sumando las seis.
Paso 3 · Hipótesis.
horasAbiertasestá reduciendo sobre todas las tareas en lugar de sobre las abiertas.
Paso 4 · Comprobar. Ponemos el punto de interrupción en el getter y usamos Watch con dos expresiones a la vez:
// En Watch:
this.abiertas.length // → 5
this.abiertas.reduce((s, t) => s + t.horasEstimadas, 0) // → 45 ← el valor correctoLa expresión correcta da 45. Miramos el código:
get horasTotales() { return this.#tareas.reduce((s, t) => s + t.horasEstimadas, 0); }
get horasAbiertas() { return this.#tareas.reduce((s, t) => s + t.horasEstimadas, 0); }
// ^^^^^^^^^^^ debería ser this.abiertasUn copiar y pegar. El fallo clásico: dos líneas casi idénticas, una de ellas sin adaptar. Sospecha siempre de las líneas gemelas.
Comprobación adicional con git, para saber cuándo entró y descartar que hubiera más daños en el mismo cambio:
git log -L :horasAbiertas:js/modelo/tablero.js
# muestra la historia completa de ESA función, con el commit que la rompióPaso 5 · Corregir.
Paso 6 · Prevenir. Este caso es el argumento perfecto para el módulo entero. Un fallo así:
- No lanza ningún error.
- No rompe ninguna pantalla.
- Es invisible salvo que alguien conozca el número correcto.
- Y sale de una capa —el modelo— que se puede comprobar sin navegador, sin DOM y sin red, con una función que recibe datos y devuelve datos.
// Pendiente para 08-03 (será literalmente la primera prueba que escribas):
// "el resumen del backlog canónico da 45 h abiertas de 48 y esfuerzo 124"Mientras tanto, una red de seguridad de treinta segundos que puedes dejar puesta ya, activada por una bandera:
// js/app.js — comprobación de invariantes, solo en desarrollo
if (localStorage.getItem('nomada:diag') === '1') {
const r = tablero.resumen(HOY);
console.assert(r.horasTotales === r.horasAbiertas + 3, 'horasAbiertas descuadrado', r);
console.assert(r.total === r.abiertas + 1, 'recuento de abiertas descuadrado', r);
}
- El bug que no se reproduce
Queda la categoría más difícil: «a veces, al salir del ascensor, se me duplican las tareas». No hay pasos, no hay pantalla, no hay pila. Cuatro herramientas, en orden de esfuerzo creciente:
1 · Registro estructurado. Sustituye los console.log sueltos por un registrador único que emita objetos con contexto. Los objetos se pueden filtrar, contar y enviar; el texto libre no.
// js/util/registro.js
const NIVELES = { debug: 10, info: 20, warn: 30, error: 40 };
let umbral = NIVELES.warn; // en producción, solo warn y error
export function ajustarNivel(nombre) { umbral = NIVELES[nombre] ?? NIVELES.warn; }
export function registrar(nivel, evento, datos = {}) {
if (NIVELES[nivel] < umbral) return;
const linea = {
ts: new Date().toISOString(),
nivel,
evento, // 'tarea:cambiada', 'api:error', 'sw:activado'
sesion: sesionId(), // el mismo id durante toda la visita
...datos
};
console[nivel === 'debug' ? 'log' : nivel](linea);
historial.push(linea); // buffer circular en memoria
if (historial.length > 200) historial.shift();
}
const historial = [];
export const volcarHistorial = () => [...historial]; // para adjuntar a un informeEl buffer circular es la clave: cuando el fallo raro por fin ocurre, tienes las 200 líneas anteriores, no solo el momento del desastre. Ese es exactamente el contexto que falta en los incidentes que no se reproducen.
2 · Banderas de diagnóstico. No puedes pedirle a Marta que abra las DevTools. Pero sí puedes darle un interruptor:
// Activable con ?diag=1 en la URL, y persistente hasta que se desactive
const params = new URLSearchParams(location.search);
if (params.has('diag')) localStorage.setItem('nomada:diag', params.get('diag'));
if (localStorage.getItem('nomada:diag') === '1') ajustarNivel('debug');Y un botón «Copiar informe de diagnóstico» que ponga en el portapapeles el historial, el resumen del tablero, la versión de la aplicación y el navigator.userAgent. Un informe así convierte «a veces falla» en un caso reproducible.
3 · Reproducir las condiciones, no los pasos. Los fallos intermitentes casi siempre vienen de cuatro sitios: la red (usa Slow 4G y Offline), el tiempo (una tarea que vence hoy y no ayer), la concurrencia (dos pestañas abiertas con el evento storage de 07-01) y el estado previo (un localStorage de una versión antigua). Provoca esas condiciones deliberadamente y muchos "irreproducibles" se vuelven fiables.
4 · Monitorización de errores. Para lo que ocurre en el ordenador de otra persona, la solución de la industria es un servicio de monitorización (Sentry, Rollbar, Bugsnag y similares). El mecanismo esencial cabe en diez líneas, y entenderlo importa más que el proveedor concreto:
// Captura global: errores síncronos y promesas rechazadas sin manejar
window.addEventListener('error', (e) => {
enviarIncidencia({ tipo: 'error', mensaje: e.message, fichero: e.filename,
linea: e.lineno, pila: e.error?.stack });
});
window.addEventListener('unhandledrejection', (e) => { // ← el de 05-06
enviarIncidencia({ tipo: 'promesa', mensaje: String(e.reason), pila: e.reason?.stack });
});Lo que un servicio añade sobre esto: agrupa incidencias idénticas, aplica los source maps del apartado 12 para mostrar tu código original, guarda la traza de acciones previas del usuario, y avisa cuando aparece un fallo nuevo tras un despliegue. Dos advertencias imprescindibles: no envíes datos personales en el contexto (ni títulos de tarea, si pueden contener información sensible), y respeta la normativa de protección de datos aplicable.
Errores Comunes y Consejos
- Cambiar el código antes de entender el fallo. Si no puedes explicar por qué falla, tu arreglo es una apuesta. Primero la hipótesis comprobada, después la edición.
- Depurar en un estado sucio. Un
localStoragecon datos de tres experimentos anteriores produce fallos fantasma. Reproduce siempre desdeClear site data, y en una ventana de incógnito si sospechas de una extensión. - Confundir dónde se lanza el error con dónde está el fallo. Lee la pila entera, de abajo arriba. El culpable suele estar dos fotogramas más abajo que el
throw. - Olvidar que
datasetdevuelve cadenas. Es la fuente del caso 1 y de la mitad de los fallos de identidad en aplicaciones con DOM. Normaliza los tipos en la frontera, siempre. console.logde un objeto y creer que ves su valor de entonces. La consola muestra objetos en vivo: al desplegarlo puedes estar viendo su estado actual, no el del momento del log. Si necesitas la foto, registrastructuredClone(obj)(04-08) oJSON.stringify(obj).- Poner el punto de interrupción antes del
await. Ponlo después: antes solo verás una promesa pendiente. - Dejar
debuggero logs de depuración en un commit. Se resuelve solo en la lección siguiente, conno-debuggeryno-consoleen ESLint más un hook de Git. - Tapar errores con
try { … } catch {}vacío. Uncatchvacío convierte un fallo ruidoso en uno silencioso, que es infinitamente peor. Si de verdad quieres ignorar algo, escribe por qué en un comentario. - Consejo: escribe la hipótesis en una nota antes de comprobarla. Obliga a concretar y evita la deriva de ir mirando cosas al azar. Al terminar tendrás además el material del post mortem.
- Consejo: si llevas más de una hora sin avanzar, explícaselo a alguien. El rubber duck debugging funciona porque verbalizar obliga a hacer explícitas las suposiciones. La mitad de las veces la solución aparece a mitad de la explicación.
- Consejo:
Ctrl/Cmd + Pen el panel Sources abre cualquier fichero por nombre, yCtrl/Cmd + Mayús + Pes la paleta de comandos de las DevTools. Con esos dos atajos dejarás de buscar ficheros en el árbol.
Ejercicios
Ejercicio 1 — Diagnóstico con el método.
Un compañero reporta: «Al pulsar dos veces rápido sobre Empezar en la tarea 1, la tarjeta se queda en en-curso pero el resumen dice que hay 4 tareas abiertas en lugar de 5, y en la consola aparece un ErrorDeValidacion: Transición no permitida: "en-curso" → "en-curso"». Redacta el diagnóstico completo siguiendo los seis pasos: pasos de reproducción, caso mínimo aislado, hipótesis falsable, qué instrumento de DevTools usarías para comprobarla (indica el tipo exacto de punto de interrupción y su condición), en qué capa corregirías, y qué prueba escribirías para prevenirlo.
Ejercicio 2 — Un módulo de registro con niveles y buffer.
Escribe js/util/registro.js completo con: cuatro niveles (debug, info, warn, error), un umbral configurable por bandera (?diag=1 en la URL, persistido en localStorage), un identificador de sesión estable durante toda la visita, un buffer circular de las últimas 200 líneas, y volcarInforme() que devuelva un objeto con el historial, el resumen del tablero, la versión de la aplicación y el userAgent, listo para copiar al portapapeles. Debe salir por console.debug/info/warn/error según el nivel para que el filtro de la consola funcione.
Ejercicio 3 — Instrumentar el flujo de un clic.
Sin modificar el código de la aplicación, describe la configuración de DevTools que te permitiría seguir el recorrido completo de un clic en Empezar de la tarea 3 —desde el oyente delegado hasta la escritura en localStorage— registrando en cada punto los valores relevantes y sin detener nunca la ejecución. Indica el fichero, la línea aproximada y la expresión exacta de cada logpoint, y qué esperarías ver en la consola si todo funciona bien.
Soluciones
Solución 1
1 · REPRODUCIR
- Clear site data. Cargar con el backlog canónico.
- Doble clic rápido (< 100 ms) sobre "Empezar" de la tarea 1.
- Se reproduce 3 de cada 3 veces si el segundo clic llega antes del render.
2 · AISLAR
- Caso mínimo: dos llamadas seguidas a controlador.avanzar(1) sin render intermedio.
tablero.cambiarEstado(1, 'en-curso'); tablero.cambiarEstado(1, 'en-curso');
- Sin DOM: el segundo lanza el mismo ErrorDeValidacion. El fallo NO es del navegador
ni del doble clic: es que el estado siguiente se calcula desde el DOM, no desde el modelo.
3 · HIPÓTESIS (falsable)
El controlador calcula el estado destino a partir de `li.dataset.estado`, que solo se
actualiza en el render. Entre el primer clic y su render, el dataset todavía dice
'pendiente', así que el segundo clic vuelve a pedir 'en-curso' → transición inválida (R6).
Y el resumen se recalcula en el catch con un contador incrementado a mano, que ya se
había sumado antes de lanzar: de ahí el 4 en lugar de 5.
4 · COMPROBAR
- Punto de interrupción CONDICIONAL en controlador.js, línea del cálculo del destino:
condición: id === 1
- Y un logpoint en la misma línea:
'avanzar', {id, datasetEstado: li.dataset.estado, modeloEstado: tablero.buscarPorId(id).estado}
Si la hipótesis es cierta, en el segundo clic se verá:
{id: 1, datasetEstado: 'pendiente', modeloEstado: 'en-curso'} ← divergen
- Activar "Pause on caught exceptions" para confirmar que el error se traga en el catch.
5 · CORREGIR (capa: vista/controlador)
- El estado destino se calcula SIEMPRE desde el modelo, nunca desde el DOM:
const tarea = tablero.buscarPorId(id);
const destino = SIGUIENTE[tarea.estado];
if (destino === null) return;
- El DOM es una proyección del estado, no su fuente (ciclo de 06-06).
- Además, el resumen se recalcula con tablero.resumen(HOY), sin contadores manuales.
6 · PREVENIR (para 08-03)
- "dos cambiarEstado consecutivos al mismo destino lanzan ErrorDeValidacion" (modelo).
- "dos clics seguidos en avanzar dejan la tarea en 'en-curso' y el resumen en 5 abiertas"
(integración con jsdom, 08-05).Solución 2
// js/util/registro.js
const NIVELES = Object.freeze({ debug: 10, info: 20, warn: 30, error: 40 });
const SALIDA = Object.freeze({ debug: 'debug', info: 'info', warn: 'warn', error: 'error' });
const MAXIMO = 200;
const historial = [];
let umbral = NIVELES.warn;
/** Bandera de diagnóstico: ?diag=1 la activa y queda persistida hasta ?diag=0. */
function leerBandera() {
const p = new URLSearchParams(location.search);
if (p.has('diag')) localStorage.setItem('nomada:diag', p.get('diag'));
return localStorage.getItem('nomada:diag') === '1';
}
/** Un id por visita: permite agrupar todas las líneas de una misma sesión. */
function sesionId() {
let id = sessionStorage.getItem('nomada:sesion');
if (id === null) {
id = crypto.randomUUID();
sessionStorage.setItem('nomada:sesion', id);
}
return id;
}
export function ajustarNivel(nombre) {
umbral = NIVELES[nombre] ?? NIVELES.warn;
}
export function registrar(nivel, evento, datos = {}) {
const linea = { ts: new Date().toISOString(), nivel, evento, sesion: sesionId(), ...datos };
historial.push(linea); // el buffer guarda TODO, aunque no se imprima
if (historial.length > MAXIMO) historial.shift();
if (NIVELES[nivel] >= umbral) console[SALIDA[nivel]](`[nomada] ${evento}`, linea);
return linea;
}
export const debug = (evento, datos) => registrar('debug', evento, datos);
export const info = (evento, datos) => registrar('info', evento, datos);
export const aviso = (evento, datos) => registrar('warn', evento, datos);
export const fallo = (evento, datos) => registrar('error', evento, datos);
/** Informe completo, listo para copiar y adjuntar a una incidencia. */
export function volcarInforme({ tablero, hoy, version = '1.0.0' } = {}) {
return {
version,
generado: new Date().toISOString(),
sesion: sesionId(),
userAgent: navigator.userAgent,
diagnostico: leerBandera(),
resumen: tablero ? tablero.resumen(hoy) : null,
historial: structuredClone(historial) // copia profunda: la foto, no el objeto vivo
};
}
export async function copiarInforme(contexto) {
await navigator.clipboard.writeText(JSON.stringify(volcarInforme(contexto), null, 2));
}
// Arranque
if (leerBandera()) ajustarNivel('debug');Solución 3
Configuración de DevTools (ninguna línea de código modificada):
A · Ignore List
Añadir cualquier dependencia externa para que "step into" no se pierda.
B · Cuatro logpoints (clic derecho en el número de línea → Add logpoint)
1) js/vista/controlador.js — dentro del oyente delegado, tras el closest():
'A·clic', {accion: boton?.dataset.accion, id: boton?.closest('[data-id]')?.dataset.id}
→ esperado: {accion: 'avanzar', id: '3'} (¡cadena! ojo al caso 1)
2) js/modelo/tablero.js — primera línea de cambiarEstado(id, nuevo):
'B·modelo', {id, tipo: typeof id, nuevo, actual: this.buscarPorId(id)?.estado}
→ esperado: {id: 3, tipo: 'number', nuevo: 'en-curso', actual: 'pendiente'}
3) js/vista/tablero-vista.js — primera línea de actualizar():
'C·render', {visibles: this.visibles?.length, resumen: this.estado?.tablero.resumen('2026-09-20')}
→ esperado: {visibles: 6, resumen: {…, horasAbiertas: 45, …}}
4) js/datos/repositorio-local.js — primera línea de guardar(tablero):
'D·persistir', {total: tablero.total, bytes: JSON.stringify(tablero).length}
→ esperado: {total: 6, bytes: ~1200}
C · Comprobación cruzada
- console.count activado en el logpoint C detectaría renders duplicados.
- Si aparecen A y B pero no C, el evento 'tarea:cambiada' no se está emitiendo.
- Si aparecen A, B y C pero no D, falta el oyente de persistencia (caso 2).
- Si D dice total: 3 mientras C dice visibles: 3, se está persistiendo la vista (caso 2).
D · Extra sin detener nada
Un breakpoint por cambio del DOM (attribute modifications) sobre el <li data-id="3">
señalaría exactamente qué línea escribe data-estado, útil si C se ejecuta pero la
tarjeta no cambia.Conclusión
Has cambiado la forma de enfrentarte a un fallo. Ya no se trata de mirar el código hasta que algo parezca sospechoso, sino de recorrer seis pasos: reproducir de forma fiable, aislar hasta el caso mínimo, formular una hipótesis falsable, comprobarla con un instrumento, corregir la causa en la capa correcta y prevenir con una prueba. Y sabes que cuando el terreno es grande, la bisección —en el flujo de datos, en la historia con git bisect o en el propio código— convierte una búsqueda lineal en una logarítmica.
Conoces la consola entera y no solo console.log: table para ver seis tareas de un vistazo, count para descubrir los renders duplicados, assert para vigilar invariantes como las 45 h abiertas, group para no ahogarte en líneas, trace para saber quién llamó, y el truco de las llaves console.log({variable}) que etiqueta cada valor con su nombre. Y dominas el depurador de verdad: la sentencia debugger y sus riesgos, los seis tipos de punto de interrupción —normal, condicional, de registro, por evento, por petición y por cambio del DOM—, la navegación con step over, into y out, los paneles Scope (donde los closures de 03-04 son por fin visibles), Watch con expresiones calculadas y Call Stack con su Restart frame. Sabes leer un stack trace de abajo arriba, activar «Pause on caught exceptions» para cazar los fallos que se tragan los catch, seguir una pila asíncrona cosida a través de los await de 05-07, y por qué sin source maps un error de producción no dice nada. Y tienes el panel Network con su Copy as fetch y su throttling, el panel Application para el localStorage y el service worker del Módulo 7, y la inspección remota para el móvil de verdad.
Sobre todo, has resuelto tres fallos reales con el método delante: un id que llegaba como cadena desde la API y hacía fallar el === de 01-07 —corregido en la frontera de datos, no en el modelo ni en la vista—; un filtro que persistía las tareas visibles en lugar del tablero completo, confundiendo un valor derivado de la vista con el estado del modelo; y un horasAbiertas que sumaba sobre todas las tareas y devolvía 48 en lugar de 45, un fallo silencioso que ninguna pantalla delataba. Y has visto qué hacer cuando el fallo no se reproduce: registro estructurado con buffer circular, banderas de diagnóstico activables por URL, reproducción de condiciones en lugar de pasos, y una nota sobre los servicios de monitorización que aplican tus source maps a los errores de otras personas.
Los tres casos comparten un final incómodo: los tres se han cerrado con una prueba «pendiente de escribir». Y los tres, además, tenían una advertencia previa que nadie vio. El id mezclando tipos, el catch vacío que se tragaba el error, las dos líneas gemelas donde una no se adaptó: son exactamente el tipo de problema que una máquina puede señalar antes de ejecutar nada, leyendo el código. Antes de automatizar las comprobaciones de comportamiento conviene automatizar las de forma, porque son más baratas y atrapan una familia entera de fallos por sí solas. Eso es lo siguiente: Calidad de Código: ESLint, Prettier y Convenciones, donde configurarás un analizador estático sobre Nómada Tareas, arreglarás los avisos que aparezcan —incluido, con toda seguridad, algún debugger olvidado de esta lección— y pondrás un guardián en Git para que nada de esto vuelva a llegar al repositorio.
Curso de JavaScript: De Principiante a Avanzado
Módulo 1: Introducción a JavaScript
- ¿Qué es JavaScript?
- Configuración de tu Entorno de Desarrollo
- Tu Primer Programa en JavaScript
- Sintaxis y Conceptos Básicos de JavaScript
- Variables y Tipos de Datos
- Operadores Básicos
- Conversión de Tipos y Comparaciones
- El Proyecto del Curso: Nómada Tareas
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for, while, do-while
- Sentencias Switch
- Control del Flujo: break, continue y Bucles Anidados
- Manejo de Errores con try-catch
Módulo 3: Funciones
- Definición y Llamada de Funciones
- Expresiones de Función y Funciones Flecha
- Parámetros y Valores de Retorno
- Ámbito y Closures
- Hoisting y el Contexto de Ejecución
- Funciones de Orden Superior
- Recursividad
Módulo 4: Objetos y Arrays
- Introducción a los Objetos
- Métodos de Objeto y la Palabra Clave
this - Arrays: Conceptos Básicos y Métodos
- Iteración sobre Arrays
- Buscar, Ordenar y Agregar Datos: find, sort y reduce
- Desestructuración de Arrays
- Desestructuración de Objetos, Spread y Rest
- JSON y Copias de Objetos
Módulo 5: Objetos y Funciones Avanzadas
- Prototipos y Herencia
- Clases y Programación Orientada a Objetos
- Encapsulación: Getters, Setters y Campos Privados
- Módulos e Importación/Exportación
- JavaScript Asíncrono: Callbacks
- Promesas y Async/Await
- El Bucle de Eventos y la Cola de Microtareas
- Iteradores y Generadores
Módulo 6: El Modelo de Objetos del Documento (DOM)
- Introducción al DOM
- Selección y Manipulación de Elementos del DOM
- Manejo de Eventos
- Propagación, Delegación y Eventos Personalizados
- Creación y Eliminación de Elementos del DOM
- Renderizado de Listas y Plantillas HTML
- Manejo y Validación de Formularios
Módulo 7: APIs del Navegador y Temas Avanzados
- Almacenamiento Local y de Sesión
- Fetch API y AJAX
- Peticiones Robustas: Errores, Timeouts y AbortController
- WebSockets
- Service Workers y Aplicaciones Web Progresivas (PWAs)
- APIs del Navegador Esenciales
- Introducción a WebAssembly
Módulo 8: Pruebas y Depuración
- Depuración de JavaScript
- Calidad de Código: ESLint, Prettier y Convenciones
- Pruebas Unitarias con Jest
- Dobles de Prueba: Mocks, Stubs y Spies
- Pruebas de Integración
- Pruebas de Extremo a Extremo con Cypress
Módulo 9: Rendimiento y Optimización
- Medir Antes de Optimizar: DevTools y Web Vitals
- Optimización del Rendimiento de JavaScript
- Gestión de Memoria
- Manipulación Eficiente del DOM
- Carga Perezosa y División de Código
Módulo 10: Frameworks y Librerías de JavaScript
- Por Qué Existen los Frameworks
- Introducción a React
- Gestión de Estado con Redux
- Conceptos Básicos de Vue.js
- Conceptos Básicos de Angular
- Elegir el Framework Adecuado
