owned this note
owned this note
Published
Linked with GitHub
# Guía de desarrollo en LimeApp
Bienvenida, esta es la guía para desarrolladoras de la LimeApp...
📌 **Indice**
[TOC]
## 1 Qué se necesita saber para colaborar en la LimeApp?
La LimeApp está escrita en Javascript, HTML y css.
Tener un conocimiento básico de estos lenguajes te ayudará para colaborar
en el desarrollo. Puedes seguir estos recursos para aprender de estos lenguajes:
- Tutorial de javascript: https://javascript.info/
- Te recomendamos en particular leer sobre [Promises](https://es.javascript.info/promise-basics)
También puedes aprender haciendo, siguiendo esta guía y animándote a probar hacer cambios o agregar nuevas pantallas.
## 2 La Arquitectura
<figure>
<img src="https://i.imgur.com/GAna29D.png">
<figcaption>Arquitectura de la LimeApp</figcaption>
</figure>
La LimeApp es una aplicación web hecha en el framework [Preact](https://preactjs.com/) un framework muy parecido a React, con la misma API, pero que elegimos porque ocupa menos espacio (3kB).
El bundle de la aplicación, que incluye todo el código de la aplicación ejecutable por un navegadow web, está alojado en `/www/app` y es servido
por el webserver uHTTPd al visitar la dirección IP del router o el dominio asociado a la misma, típicamente: thisnode.info.
uHTTPd tiene un plugin para [ubus](https://openwrt.org/docs/techref/ubus) que nos permite hacer llamadas ubus a los distintos servicios/módulos de libremesh a través de reques HTTP POST utilizando el protocolo de mensajes JSON RPC (Remote Procedure Call).
Esta es la interfaz utilizada en la LimeApp para hacer llamadas al `backend` (el router).
No importa si no estás familiarizada con estas tecnologías ahora mismo, las irás descubriendo con la práctica.
## 3 Los elementos de la LimeApp
Una funcionalidad típica de la LimeApp implementa una pantalla compuesta por uno o más `Componentes` de Preact.
Los `Componentes` definen lo que se renderiza en pantalla, especificando el aspecto visual de la pantalla (HTML + css) y el lógico: qué datos se muestran, cuándo y qué acciones realiza cada botón.
En las pantallas de la LimeApp comunmente queremos mostrar información que está disponible en el router, (ej: hace cuánto tiempo está prendido), y realizar acciones que modifican la configuración del router (ej: cambiar la contraseña de administración). A esto nos referimos con llamadas al `backend`.
Para conectar la interfaz con llamadas al `backend` los `Componentes` utilizan `Queries` y `Mutations` de la librería [React Query](react-query.tanstack.com/).
Esta librería permite indicar de forma declarativa qué datos (`Queries`) utiliza y qué acciones (`Mutations`) realiza cada componente. Y además nos abstrae de la complejidad de: re-renderizar cada componente de la pantalla que dependa de un dato que se actualizó en el `backend`, de-duplicar las llamadas repetidas, y manejar la caché de estas llamadas para no pedir dos veces el mismo dato innecesariamente.
Las `Queries` y `Mutations` llaman a funciones asíncronas que definen los endpoints y el cuerpo de los requests al backend para realizar el pedido necesario, a estas funciones les llamamos API endpoints, ya que matchean uno a uno con endpoints del `backend`.
Todas ellas, utilizan una interfaz común, el `uHTTPd client`, un Singleton que nos abstrae del manejo del id de ubus session, la dirección url base del webserver y los detalles del protocolo JSON RPC.
## 4 Ejemplo
Para entender mejor como todas estas piezas dialogan entre si, veamos un ejemplo:
Acceso Remoto. El Acceso Remoto permite abrir una session de terminal en el router para que sea accedida de manera remota por otra persona para ayudar a diagnosticar problemas en la red. En la LimeApp implementamos esta funcionalidad basándonos en [tmate](tmate.io). La pantalla permite ver el token de la sesión actual, cerrar la sesión actual o abrir una nueva sesión.
Empecemos el recorrido!
### 4.1 Tests
La LimeApp tiene una batería de test creciente. Esta batería provee un setup de testing y ejemplos de tests que nos permiten desarrollar nueva funcionalidad utilizando [Test Driven Development](https://en.wikipedia.org/wiki/Test-driven_development).
Para implementar los tests utilizamos el Framework de Testing [Jest](https://jestjs.io/) junto a la librería [Testing Library](https://testing-library.com/).
Esta librería permite escribir tests que verifiquen el funcionamiento de nuestros componentes desde la perspectiva de la persona usuaria, y son resistentes a cambios de implementación.
La estrategia de testeo es la siguiente:
Testeamos dos cosas por separado:
- 1) El componente que renderiza la funcionalidad que estamos testeando, mockeando los API endpoints que llaman al `backend`.
- 2) los API endpoints que llaman al `backend`.
De esta forma, si los tests para los API endpoints pasan, los mocks de los endpoints en los test del componente se corresponden con la implementación testeada y los tests del componente también pasan, nos garantizamos que el componente funciona como esperamos, desde la interfaz hasta la llamadas al backend.
La razón para hacer esta división en dos pasos es que se ajusta muy bien a las capacidades de mocking de Jest.
#### 4.1.2 Tests del componente
El consejo principal para escribir los tests del componente, es pensar en los requisitos que tiene que cumplir desde la perspectiva de la persona usuaria. Suelen ser del tipo:
"Si hago click en el botón A, me muestra el texto B"
"Si hago submit del formulario B, y el backend informa un error, me muestra el error"
También testeamos que se llame a los endpoints con los parámetros correctos.
Para poder mockear los API endpoints, primero tenemos que definirlos. Podemos dejar la implementación real para después.
```javascript
// File at: lime-plugin-remotesupport/src/remoteSupportApi.js
export function getSession() {}
export function openSession() {}
export function closeSession() {}
```
Estas son las funciones que utilizarán nuestras `Queries` y `Mutations` más tarde, por el momento no hacen nada, solo nos sirven para mockear.
Ahora estamos listos para definir los tests del componente. Se explica cada parte en los comentarios.
```javascript
// File at: lime-plugin-remotesupport/remoteSupport.spec.js
// Importar pragma de Preact, necesario para renderizar componentes con jsx.
import { h } from 'preact';
// Importamos las utils de la Testing Library
import { fireEvent, cleanup, act, screen } from '@testing-library/preact';
import '@testing-library/jest-dom';
// Importar la util render, esta util renderiza el componente en el mismo
// contexto en el que se renderizará en la App.
import { render } from 'utils/test_utils';
// Importamos el componente de la pantalla de remoteSupport,
// que aún no implementamos.
import RemoteSupportPage from './src/remoteSupportPage';
// Importamos y mockeamos los API endpoints recién definidos.
import { getSession, openSession, closeSession } from './src/remoteSupportApi';
jest.mock('./src/remoteSupportApi');
describe('Remote Support Page', () => {
// Antes de cada test mockeamos la implementación de las llamadas a la API.
beforeEach(() => {
getSession.mockImplementation(async () =>
({ rw_ssh: 'ssh -p2222 test_rw_token@test_host', ro_ssh: 'ssh -p2222 test_ro_token@test_host'})
);
openSession.mockImplementation(async () => null);
closeSession.mockImplementation(async () => null);
});
// Luego de cada test limpiamos el entorno de testing.
afterEach(() => {
cleanup();
act(() => queryCache.clear());
});
// Testeamos que se rendericen los elementos esperados en cada escenario.
it('shows a button to create session when there is no session', async () => {
getSession.mockImplementation(async () => null);
render(<RemoteSupportPage />);
expect(await screen.findByRole('button', {name: /create session/i })).toBeEnabled();
});
it('shows rw session token when there is an open session', async () => {
render(<RemoteSupportPage />);
expect(await screen.findByText('ssh -p2222 test_rw_token@test_host')).toBeInTheDocument();
})
it('shows a button to close session when there is an open session', async () => {
render(<RemoteSupportPage />);
expect(await screen.findByRole('button', {name: /close session/i})).toBeEnabled();
})
it('show an error when open session fails', async() => {
getSession.mockImplementation(async() => null);
openSession.mockImplementation(async() => { throw new Error() })
render(<RemoteSupportPage />)
fireEvent.click(
await screen.findByRole('button', {name: /create session/i })
);
expect(await screen.findByText(/Cannot connect to the remote support server/i)).toBeVisible();
});
});
```
Con los tests ya definidos podemos pasar a la implementación de `RemoteSupportPage`.
> Consejo: Utilizando `it.skip(...)` podemos marcar los tests para que no se ejecuten, e ir desmarcando del más simple al más complejo, a medida que avanzamos en la implementación.
Para correr los tests:
```
npm run test plugins/lime-plugin-remotesupport/remoteSupport.spec.js
```
#### 4.1.3 Tests de los API endpoints
Luego de haber creado los tests del componente, seguramente ya tenemos más confianza en qué datos y qué acciones debiera realizar cada uno de los API endpoints. Podemos discutir la API con quien desarrollo el `backend` y dejarlo asentado en los tests.
```javascript
// File Path: lime-plugin-remotesupport/src/remoteSupportApi.spec.js
// Importamos y mockeamos el uHTTPd client
import api from 'utils/uhttpd.service';
jest.mock('utils/uhttpd.service')
// Importamos los API endpoints a testear
import { getSession, openSession, closeSession } from './remoteSupportApi';
// Antes de cada tests reseteamos el mock del uHTTPd client
beforeEach(() => {
api.call.mockClear();
api.call.mockImplementation(async () => ({ status: 'ok' }));
})
describe('getSession', () => {
it('calls the expected endpoint', async () => {
await getSession();
expect(api.call).toBeCalledWith('tmate', 'get_session', {});
})
it('resolves to session when there is a connected session', async () => {
const sessionData = {
rw_ssh: 'ssh -p2222 pL2qpxKQvPP9f9GPWjG2WkfrM@ny1.tmate.io',
ro_ssh: 'ssh -p2222 pL2qpxKQvPP9f9GPWjG2WkfrM@ny1.tmate.io'
};
api.call.mockImplementation(async () => (
{
status: 'ok',
session: sessionData,
}));
let session = await getSession();
expect(session).toEqual(sessionData);
});
it('resolves to null when there is a non established session', async () => {
const sessionData = {
rw_ssh: "", ro_ssh: ""
};
api.call.mockImplementation(async () => (
{
status: 'ok',
session: sessionData,
}));
let session = await getSession();
expect(session).toBeNull();
});
});
describe('closeSession', () => {
it('calls the expected endpoint', async () => {
await closeSession();
expect(api.call).toBeCalledWith('tmate', 'close_session', {})
})
});
describe('openSession', () => {
it('calls the expected endpoint', async () => {
await openSession();
expect(api.call).toBeCalledWith('tmate', 'open_session', {})
})
it('resolves to api response on success', async () => {
const result = await openSession();
expect(result).toEqual({ status: 'ok'})
})
it('rejects to api call error on error', async () => {
api.call.mockImplementationOnce(() => Promise.reject('timeout'));
api.call.mockImplementationOnce(async () => ({'status': 'ok'}));
expect.assertions(1);
try {
await openSession()
} catch (e) {
expect(e).toEqual('timeout')
}
})
it('calls close session when rejected ', async () => {
api.call.mockImplementationOnce(() => Promise.reject('timeout'));
api.call.mockImplementationOnce(async () => ({'status': 'ok'}));
expect.assertions(1);
try {
await openSession()
} catch (e) {
expect(api.call).toBeCalledWith('tmate', 'close_session', {})
}
})
});
```
Con estos tests listos, estamos cubiertos para empezar la implementación.
### 4.2 Queries y Mutations
Las `Queries` y `Mutations` conectarán nuestro componente con los endpoints.
Nos abstraen de la complejidad de re-renderizar cada componente de la pantalla que dependa de un dato que se actualizó en el `backend`. Es decir que si hay más de un componente en pantalla que depende de la misma `Query` y uno de los dos realiza una acción que actualiza los datos del `backend`, ambos componentes se re-renderizarán para mostrar la información actualizada.
También nos permite de-duplicar las llamadas repetidas, y manejar la caché de estas llamadas, con lo cual podemos utilizar la misma `Query` en muchos componentes sin que eso signifique múltiples e innecesarias llamadas repetidas al `backend`. Todo eso se articula en una instancia global la `QueryCache`.
Las `Queries` y `Mutations` son (Hooks)[https://preactjs.com/guide/v10/hooks/] de Preact.
Sin más, vamos a implementarlas, se explica cada parte en los comentarios.
```javascript
// Path: lime-plugin-remotesupport/src/remoteSupportQueries.js
// Importar los hooks de react-query
import { useQuery, useMutation } from 'react-query';
// Importar de los utils de la LimeApp la QueryCache.
import queryCache from 'utils/queryCache';
import { getSession, openSession, closeSession } from './remoteSupportApi'
export function useSession() {
// Cada Query define un id único en el scope de la aplicación
// para identificarla en la QueryCache. En este caso: ["tmate", "get_session"]
// El segundo parámetro es una función asíncrona, el API endpoint.
return useQuery(["tmate", "get_session"], getSession);
}
export function useOpenSession() {
return useMutation(openSession, {
// Luego de abrir con éxito una nueva sesión, invalidamos la query de
// useSession. Esto hará que todos los componentes que la estén
// renderizando se re-rendericen para mostrar la información actualizada.
onSuccess: () => queryCache.invalidateQueries(["tmate", "get_session"]),
// También podemos especificar directamente la nueva data asociada a la
// query. En este caso que openSession falló le asignamos null.
onError: () => queryCache.setQueryData(["tmate", "get_session"], null)
})
}
export function useCloseSession() {
return useMutation(closeSession, {
onSuccess: () => queryCache.invalidateQueries(["tmate", "get_session"])
})
}
```
Ya tenemos las `Queries` listas :) ahora podemos utilizarlas en el `Componente`.
### 4.3 Componente
El `Componente` principal tiene que renderizar la pantalla de Acceso Remoto. Cumpliendo con los tests que definimos en la sección "4.1.2 Tests del componente".
Una estrategia que funciona bien en la práctica es ir iterando la implementación de manera que cumpla con los tests más simples primero, y luego con los más complejos. De esa forma podemos mantener acotado el espacio para introducir bugs, y debuggear se hace más fácil.
Para definir lo que se renderiza, utilizamos [JSX](https://reactjs.org/docs/introducing-jsx.html), que es una extensión a JavaScript que nos permite
definir la estructura del documento declarativamente, y por detrás Babel lo compila a llamadas imperatimos del DOM del tipo document.createElement().
Veamos como queda implementado nuestro componente que pasa los tests.
```javascript
// File Path: lime-plugin-remotesupport/src/remoteSupportPage.js
import { h } from 'preact';
import { useSession, useOpenSession, useCloseSession } from './remoteSupportQueries';
import Loading from 'components/loading';
// Utilizamos i18n-js para internacionalizacion
import I18n from 'i18n-js';
const RemoteSupportPage = () => {
// Las `Queries` nos facilitan el estado de ejecución de la query
// (i.e, isLoading).
const {data: session, isLoading: loadingSession} = useSession();
const [openSession, openStatus] = useOpenSession();
const [closeSession, closeStatus] = useCloseSession();
if (loadingSession) {
return <div class="container container-center"><Loading /></div>
}
return(
<div>
<h4>{I18n.t("Ask for remote support")}</h4>
{!session &&
<div>
<p>{I18n.t("There's no open session for remote support. Click at Create Session to begin one")}</p>
<button onClick={openSession}>{I18n.t("Create Session")}</button>
</div>
}
{openStatus.isError &&
<div>
<b>{I18n.t("Cannot connect to the remote support server")}</b><br />
{I18n.t("Please verify your internet connection")}
</div>
}
{session &&
<div>
<p>{I18n.t("There's an active remote support session")}</p>
<p>{I18n.t("Share the following command with whoever you want to give them access to your node")}</p>
<div><pre>{session.rw_ssh}</pre></div>
<div>
<h5>{I18n.t("Close Session")}</h5>
<p>{I18n.t("Click at Close Session to end the remote support session. No one will be able to access your node with this token again")}</p>
<button onClick={closeSession}>{I18n.t("Close Session")}</button>
</div>
</div>
}
{openStatus.isLoading || closeStatus.isLoading &&
<Loading />
}
</div>
);
export default RemoteSupportPage;
```
#### 4.3.1 Estilo y Visualización
##### Storybook
Una vez que nuestro componente pasa los tests, podemos agregarle estilo con clases
de css para lograr la UI que buscamos.
Para poder visualizar nuestro componente mientras le agregamos estilo utilizamos [StoryBook](https://storybook.js.org/). Storybook te permite definir los distintos estados de tu pantalla como distintas user `Stories` y renderizar tu componente en ese contexto de forma aislada. Una vez escritas las stories, luego es fácil volver a ellas para probar cambios en la UI.
Definamos las stories para nuestro componente.
```javascript
// File path: plugins/lime-plugin-remotesupport/remotesupport.stories.js
import { RemoteSupportPage } from './src/remoteSupportPage';
// En default definimos el título que agrupará las stories en el StoryBook
export default {
title: 'Containers/RemoteSupport'
};
// Cada export adicional es un Story que visualiza un caso en particular.
export const noSession = () => <RemoteSupportPage/>;
noSession.args = {
queries: [
[['tmate', 'get_session'], null]
]
};
export const openedSession = () => <RemoteSupportPage />;
openedSession.args = {
queries: [
[['tmate', 'get_session'], {
rw_ssh: 'ssh -p2222 pL2qpxKQvPP9f9GPWjG2WkfrM@remotesupportlibrerouter.org',
}
]
]
};
```
En el ejemplo de arriba utilizamos una QueryCache mock, para darle contexto a nuestras stories. Los detalles de este setup están en la configuración de StoryBook.
Una alternativa es separar la implementación de nuestro componente en un componente `HOC` (High Order Component) y un componente presentacional, y que el HOC le pase al componente presentacional toda la información necesaria para renderizar mediante `properties`. Lo cual nos permitiría testear visualmente más casos, ya que no hay estado interno en el presentacional.
Ambos approach tienen sus pros y sus contras. Depende en cada caso cuál es más cómodo.
Una vez que definimos las stories, podemos correr Storybook con:
```
npm run storybook
```
##### CSS
Para agregar estilo específico de este componente utilizamos hojas de estilo locales al componente:
```javascript
// File Path: lime-plugin-remotesupport/src/remoteSupportPage.js
// (...)
import style from './style.less'
// (...)
return (
//(...)
<div class={style.someClass} />
// (...)
)
```
```less
// File Path: lime-plugin-remotesupport/src/style.less
.someClass {
backgroundColor: #f2f2f2f2;
}
```
Y para reglas de estilo generales que utilizamos en toda la aplicación
usamos un index.less global. Que sigue los nombres de clases bootstrap.
Ejemplo:
```less
// File Path: index.less
.d-flex {
display: flex;
}
```
```javascript
// File Path: lime-plugin-remotesupport/src/remoteSupportPage.js
// (...)
return (
//(...)
<div class="d-flex" />
// (...)
)
```
### 4.4 API endpoints
Lo único que nos falta es implementar los API endpoints, cumpliendo con los tests que escribimos en "4.1.3 - Tests de los API endpoints".
```javascript
import api from 'utils/uhttpd.service';
export function getSession() {
return api.call("tmate", "get_session", {})
.then(result => result.session || null);
}
export function openSession() {
return api.call("tmate", "open_session", {})
.catch((error) => {
closeSession();
throw error;
})
}
export function closeSession() {
return api.call("tmate", "close_session", {})
}
```
### 4.5 Agregar la pantalla al menú.
Para agregar la pantalla al menú, definimos el index.js de remoteSupport:
```javascript
// Path: lime-plugin-remotesupport/index.js
import { h } from 'preact';
import I18n from 'i18n-js';
import RemoteSupportPage from './src/remoteSupportPage';
export default {
name: 'remotesupport',
page: RemoteSupportPage,
// menu define el elemento a mostrar en el Menu.
menu: () => <a href={'#/remotesupport'}>{I18n.t('Remote Support')}</a>,
}
```
Y lo agregamos a la lista de plugins.
```javascript
// (...)
import RemoteSupport from '../plugins/lime-plugin-remotesupport';
export const plugins = [
// (...)
// El órden de esta lista es el órden de los items en el Menú
RemoteSupport,
];
```
### 4.6 Conclusión.
Finalmente concluímos todos los pasos necesarios para agregar una nueva pantalla
a la LimeApp, completamente testeada funcional y visualmente.
## 5 Traduciones
Seguir las instrucciones en [CONTRIBUTING.md](CONTRIBUTING.md#contributing-with-translations)
## 6 Herramientas de Desarrollo
### 6.1 Qemu Virtual Machine
Para desarrollar en la LimeApp es muy útil probar la integración de la app con el router. A falta de un router podemos levantar una máquina virtual de QEMU, como se explica en [TESTING.md](https://github.com/libremesh/lime-packages/blob/master/TESTING.md#development-with-qemu-virtual-machine) de lime-packages.
Para utilizar la virtual como backend, podemos modificar el uHTTPd client, para que apunte a la dirección IP de la virtual.
Típicamente
```javascript
(...)
export class UhttpdService {
constructor() {
this.url = 'http://10.13.0.1/ubus';
(...)
```
Y luego levantar la app en modo desarrollo:
```
env WEB_PATH="/" npm run dev
```
### 6.2 Script "create-plugin"
Esta dev tool nos permite bootstrapear la estructura de directorio de un nuevo plugin, indicando solo el nombre. Para correrla:
```
npm run create-plugin <newPluginName>
```
## 7 Avanzados
### 7.1 Legacy code
Antes de usar la librería ReactQuery, el código de la LimeApp utilizaba Redux + Rx-Js para guardar y actualizar el estado del `backend`. Los archivos y conceptos "epics", "actions", "reducers" son legacy code aún no actualizado para que usen ReactQuery.
La razón por la que abandonamos esas librerías es que agregaban más indirección al código del provecho que sacábamos de ellas. ReactQuery tiene una API más directa, que hace más fácil seguir el código. Poco a poco iremos migrando el legacy code a ReactQuery.
### 7.2 Ruteo de urls
TODO
### 7.3 Rutas protegidas con contraseña.
TODO
### 7.4 package-lock.json
TODO
### 8 Otras lecturas recomendadas
- [React as a UI Runtime](https://overreacted.io/react-as-a-ui-runtime/) de Dan Abramov. Útil para entender el modelo de programación de React / Preact, en profundidad.