1 <!DOCTYPE html>2 <html lang="es">3 <head>4 <meta charset="UTF-8">5 <title>Plataforma de Testing Automatizado de APIs</title>6 <!-- Enlaces a Bootstrap CSS y Bootstrap Icons -->7 <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">8 <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css" rel="stylesheet">9 <!-- Estilos personalizados -->10 <style>11 body {12 background-color: var(--bs-body-bg);13 color: var(--bs-body-color);14 transition: background-color 0.3s, color 0.3s;15 }16 .step {17 display: flex;18 align-items: flex-start;19 padding: 8px 12px;20 margin-bottom: 8px;21 position: relative;22 }23 .step:hover {24 background-color: #f8f9fa;25 }26 .step:hover .step-controls {27 display: flex;28 }29 .step-type {30 font-weight: bold;31 color: #0d6efd;32 margin-right: 10px;33 min-width: 60px;34 }35 .step-content {36 flex: 1;37 display: flex;38 flex-direction: column;39 }40 .step-content .main-content {41 display: flex;42 align-items: center;43 flex-wrap: wrap;44 }45 .step-content input,46 .step-content select,47 .step-content textarea {48 margin-right: 8px;49 }50 .step-controls {51 display: none;52 align-items: center;53 margin-left: 5px;54 }55 .step-controls button {56 background: none;57 border: none;58 margin-left: 5px;59 color: #6c757d;60 }61 .step-controls button:hover {62 color: #000;63 }64 .add-step {65 text-align: center;66 margin: 20px 0;67 }68 .theme-toggle {69 cursor: pointer;70 }71 .execute-btn {72 background: none;73 border: none;74 color: #198754;75 font-size: 1.2em;76 }77 .execute-btn:hover {78 color: #145c32;79 }80 .result-panel {81 margin-top: 10px;82 }83 .deferred-label {84 margin-left: 10px;85 font-size: 0.9em;86 color: #6c757d;87 }88 /* Estilo para el borde izquierdo de los detalles */89 .step-content .details {90 border-left: 2px solid #0d6efd;91 padding-left: 10px;92 margin-left: 10px;93 }94 .result-toggle {95 background: none;96 border: none;97 color: #0d6efd;98 padding: 0;99 font-size: 0.9em;100 }101 /* Estilos para los botones de Guardar y Cargar */102 .flow-controls {103 text-align: center;104 margin-bottom: 20px;105 }106 .flow-controls .btn {107 margin-right: 10px;108 }109 /* Ocultar input file */110 #fileInput {111 display: none;112 }113 /* Estilos para perfiles de entorno */114 .environment-controls {115 text-align: center;116 margin-bottom: 20px;117 }118 .environment-controls .btn {119 margin-right: 10px;120 }121 .environment-list {122 margin-bottom: 20px;123 }124 .environment-list .env-item {125 cursor: pointer;126 padding: 5px 10px;127 border: 1px solid #ced4da;128 border-radius: 4px;129 margin-bottom: 5px;130 }131 .environment-list .env-item.active {132 background-color: #0d6efd;133 color: white;134 }135 .modal-header {136 background-color: #0d6efd;137 color: white;138 }139 </style>140 </head>141 <body>142 <!-- Barra de navegación -->143 <nav class="navbar navbar-expand-lg navbar-dark bg-primary">144 <div class="container-fluid">145 <a class="navbar-brand" href="#">146 <!-- Logo -->147 <img src="https://via.placeholder.com/150x40?text=Logo" alt="Logo">148 </a>149 <span class="navbar-text text-white">150 Plataforma de Testing Automatizado de APIs151 </span>152 <!-- Botón de cambio de tema dentro del alcance de Vue -->153 <button class="btn btn-secondary ms-auto theme-toggle" @click="toggleTheme">154 <i class="bi" :class="darkTheme ? 'bi-sun-fill' : 'bi-moon-fill'"></i>155 </button>156 </div>157 </nav>158 159 <!-- Contenido principal -->160 <div id="app" class="container my-4">161 <!-- Botones para Guardar y Cargar Flujos -->162 <div class="flow-controls">163 <button class="btn btn-outline-secondary" @click="saveFlow">164 <i class="bi bi-save"></i> Guardar Flujo165 </button>166 <button class="btn btn-outline-secondary" @click="loadFlow">167 <i class="bi bi-folder2-open"></i> Cargar Flujo168 </button>169 <!-- Input file oculto para cargar archivos -->170 <input type="file" id="fileInput" @change="handleFileUpload" accept=".json">171 </div>172 173 <!-- Controles de Perfiles de Entorno -->174 <div class="environment-controls">175 <button class="btn btn-outline-primary" @click="showEnvironmentModal">176 <i class="bi bi-gear"></i> Configurar Entornos177 </button>178 <span class="ms-3"><strong>Entorno Actual:</strong> {{ currentEnvironment.name }}</span>179 </div>180 181 <!-- Lista de pasos -->182 <div v-for="(step, index) in steps" :key="index" class="step">183 <span class="step-type">{{ getStepTypeLabel(step.type, step.data.method) }}</span>184 185 <div class="step-content">186 <div class="main-content">187 <!-- Contenido según el tipo de paso -->188 <component :is="getStepComponent(step.type)" v-model="step.data" :step="step"></component>189 <!-- Checkbox para "Deferred" -->190 <div v-if="step.type === 'request' || step.type === 'assertion'">191 <input type="checkbox" v-model="step.deferred" :id="'deferred-' + index">192 <label :for="'deferred-' + index" class="deferred-label">Deferred</label>193 </div>194 </div>195 <!-- Resultados dentro del bloque de solicitud -->196 <div v-if="step.type === 'request' && step.result" class="result-panel">197 <button class="result-toggle" @click="step.showResult = !step.showResult">198 <i class="bi" :class="step.showResult ? 'bi-eye-slash' : 'bi-eye'"></i>199 {{ step.showResult ? 'Ocultar' : 'Mostrar' }} Resultado200 </button>201 <div v-if="step.showResult">202 <div class="alert mt-2" :class="{'alert-success': step.result.success, 'alert-danger': !step.result.success}">203 <strong>Resultado:</strong> {{ step.result.message }}204 </div>205 <div v-if="step.result.details">206 <p><strong>Status Code:</strong> {{ step.result.details.status }}</p>207 <p><strong>Headers:</strong></p>208 <pre>{{ step.result.details.headers }}</pre>209 <p><strong>Body:</strong></p>210 <pre style="max-height: 200px; overflow-y: auto;">{{ step.result.details.body }}</pre>211 </div>212 </div>213 </div>214 </div>215 <div class="step-controls">216 <button @click="moveUp(index)" :disabled="index === 0"><i class="bi bi-arrow-up"></i></button>217 <button @click="moveDown(index)" :disabled="index === steps.length - 1"><i class="bi bi-arrow-down"></i></button>218 <button v-if="step.type === 'request'" @click="executeRequestStep(index)" class="execute-btn"><i class="bi bi-play-circle"></i></button>219 <button @click="removeStep(index)"><i class="bi bi-trash"></i></button>220 </div>221 </div>222 223 <!-- Botones para añadir nuevos pasos -->224 <div class="add-step">225 <button class="btn btn-outline-primary me-2" @click="addStep('variable')">226 <i class="bi bi-sliders"></i> Variable227 </button>228 <button class="btn btn-outline-primary me-2" @click="addStep('request')">229 <i class="bi bi-arrow-up-right-circle"></i> Solicitud HTTP230 </button>231 <button class="btn btn-outline-primary me-2" @click="addStep('assertion')">232 <i class="bi bi-check-circle"></i> Aserción233 </button>234 <button class="btn btn-outline-primary me-2" @click="addStep('wait')">235 <i class="bi bi-hourglass-split"></i> Wait236 </button>237 <!-- Agrega más botones si es necesario -->238 </div>239 240 <!-- Botón de Ejecución -->241 <div class="text-center my-4">242 <button class="btn btn-lg btn-success" @click="executeTest" :disabled="!isExecutable">243 <i class="bi bi-play-fill"></i> Ejecutar Prueba Completa244 </button>245 </div>246 247 <!-- Modal para Configurar Entornos -->248 <div class="modal fade" id="environmentModal" tabindex="-1" aria-labelledby="environmentModalLabel" aria-hidden="true">249 <div class="modal-dialog modal-lg">250 <div class="modal-content">251 <div class="modal-header">252 <h5 class="modal-title" id="environmentModalLabel">Perfiles de Entorno</h5>253 <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Cerrar"></button>254 </div>255 <div class="modal-body">256 <div class="row">257 <!-- Lista de entornos -->258 <div class="col-md-4">259 <h6>Entornos Disponibles</h6>260 <div class="environment-list">261 <div v-for="(env, index) in environments" :key="index" class="env-item" :class="{'active': env === currentEnvironment}" @click="selectEnvironment(env)">262 {{ env.name }}263 </div>264 </div>265 <button class="btn btn-sm btn-primary" @click="addEnvironment">266 <i class="bi bi-plus-lg"></i> Añadir Entorno267 </button>268 </div>269 <!-- Detalles del entorno seleccionado -->270 <div class="col-md-8" v-if="selectedEnvironment">271 <h6>Variables del Entorno "{{ selectedEnvironment.name }}"</h6>272 <div class="mt-2">273 <div v-for="(variable, index) in selectedEnvironment.variables" :key="index" class="d-flex align-items-center mb-2">274 <input type="text" class="form-control me-2" v-model="variable.key" placeholder="Nombre de la variable" style="max-width: 150px;">275 <input type="text" class="form-control me-2" v-model="variable.value" placeholder="Valor" style="max-width: 150px;">276 <button class="btn btn-sm btn-outline-danger" @click="removeVariable(index)">277 <i class="bi bi-trash"></i>278 </button>279 </div>280 <button class="btn btn-outline-primary btn-sm" @click="addVariable">281 <i class="bi bi-plus-lg"></i> Agregar Variable282 </button>283 </div>284 </div>285 </div>286 </div>287 <div class="modal-footer">288 <button class="btn btn-secondary" data-bs-dismiss="modal">Cerrar</button>289 <button class="btn btn-primary" @click="saveEnvironments">Guardar Cambios</button>290 </div>291 </div>292 </div>293 </div>294 </div>295 296 <!-- Enlaces a Vue.js, Axios y Bootstrap JS -->297 <script src="https://cdn.jsdelivr.net/npm/vue@3.3.4/dist/vue.global.prod.js"></script>298 <script src="https://cdn.jsdelivr.net/npm/axios@1.4.0/dist/axios.min.js"></script>299 <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>300 301 <!-- Componentes para los pasos -->302 <script>303 const VariableStep = {304 template: `305 <div class="d-flex align-items-center">306 <input type="text" class="form-control me-2" v-model="data.name" placeholder="Nombre" style="max-width: 150px;">307 <span>=</span>308 <input type="text" class="form-control ms-2" v-model="data.value" placeholder="Valor" style="max-width: 150px;">309 </div>310 `,311 props: ['modelValue', 'step'],312 computed: {313 data: {314 get() { return this.modelValue; },315 set(value) { this.$emit('update:modelValue', value); }316 }317 }318 };319 320 const RequestStep = {321 template: `322 <div>323 <div class="d-flex align-items-center flex-wrap">324 <select class="form-select me-2" v-model="data.method" style="max-width: 100px;">325 <option>GET</option>326 <option>POST</option>327 <option>PUT</option>328 <option>DELETE</option>329 </select>330 <input type="text" class="form-control me-2" v-model="data.url" placeholder="URL" style="flex: 1;">331 <!-- Botón para mostrar detalles -->332 <button class="btn btn-sm btn-outline-secondary me-2" @click="toggleDetails">333 <i class="bi" :class="step.expanded ? 'bi-chevron-up' : 'bi-chevron-down'"></i>334 </button>335 </div>336 <!-- Detalles opcionales -->337 <div v-if="step.expanded" class="details mt-2">338 <!-- Headers -->339 <button class="btn btn-sm btn-outline-secondary me-2" @click="toggleHeaders">340 <i class="bi" :class="showHeaders ? 'bi-dash' : 'bi-plus'"></i> Headers341 </button>342 <!-- Body -->343 <button class="btn btn-sm btn-outline-secondary me-2" @click="toggleBody" v-if="data.method !== 'GET'">344 <i class="bi" :class="showBody ? 'bi-dash' : 'bi-plus'"></i> Body345 </button>346 <!-- Repeat Config -->347 <button class="btn btn-sm btn-outline-secondary" @click="toggleRepeat">348 <i class="bi" :class="showRepeat ? 'bi-dash' : 'bi-plus'"></i> Repeat349 </button>350 <!-- Headers -->351 <div v-if="showHeaders" class="mt-2">352 <div v-for="(header, index) in data.headers" :key="index" class="d-flex align-items-center mb-2">353 <input type="text" class="form-control me-2" v-model="header.key" placeholder="Header Key" style="max-width: 150px;">354 <input type="text" class="form-control me-2" v-model="header.value" placeholder="Header Value" style="max-width: 150px;">355 <button class="btn btn-sm btn-outline-danger" @click="removeHeader(index)">356 <i class="bi bi-trash"></i>357 </button>358 </div>359 <button class="btn btn-outline-primary btn-sm" @click="addHeader">360 <i class="bi bi-plus-lg"></i> Agregar Header361 </button>362 </div>363 <!-- Body -->364 <div v-if="showBody" class="mt-2">365 <label class="form-label">Body</label>366 <textarea class="form-control" rows="3" v-model="data.body" placeholder="Cuerpo de la solicitud"></textarea>367 </div>368 <!-- Repeat -->369 <div v-if="showRepeat" class="mt-2">370 <div class="d-flex align-items-center">371 <label class="form-label me-2">Intervalo (s):</label>372 <input type="number" class="form-control me-2" v-model.number="data.repeatInterval" style="max-width: 100px;">373 <label class="form-label me-2">Máximo de Intentos:</label>374 <input type="number" class="form-control me-2" v-model.number="data.repeatMaxAttempts" style="max-width: 100px;">375 </div>376 <div class="mt-2">377 <label class="form-label">Condición para Repetir:</label>378 <input type="text" class="form-control" v-model="data.repeatCondition" placeholder="Ejemplo: response.status == 404">379 </div>380 <div class="mt-2">381 <label class="form-label">Condición de Éxito:</label>382 <input type="text" class="form-control" v-model="data.successCondition" placeholder="Ejemplo: response.status == 200 || response.status == 201">383 </div>384 </div>385 </div>386 </div>387 `,388 props: ['modelValue', 'step'],389 data() {390 return {391 showHeaders: false,392 showBody: false,393 showRepeat: false,394 }395 },396 computed: {397 data: {398 get() { return this.modelValue; },399 set(value) { this.$emit('update:modelValue', value); }400 }401 },402 methods: {403 toggleDetails() {404 this.step.expanded = !this.step.expanded;405 },406 toggleHeaders() {407 this.showHeaders = !this.showHeaders;408 },409 toggleBody() {410 this.showBody = !this.showBody;411 },412 toggleRepeat() {413 this.showRepeat = !this.showRepeat;414 },415 addHeader() {416 this.data.headers.push({ key: '', value: '' });417 },418 removeHeader(index) {419 this.data.headers.splice(index, 1);420 }421 }422 };423 424 const AssertionStep = {425 template: `426 <div class="d-flex align-items-center">427 <input type="text" class="form-control" v-model="data.condition" placeholder="Condición">428 </div>429 `,430 props: ['modelValue', 'step'],431 computed: {432 data: {433 get() { return this.modelValue; },434 set(value) { this.$emit('update:modelValue', value); }435 }436 }437 };438 439 const WaitStep = {440 template: `441 <div class="d-flex align-items-center">442 <label class="form-label me-2">Esperar</label>443 <input type="number" class="form-control me-2" v-model.number="data.seconds" placeholder="Segundos" style="max-width: 100px;">444 </div>445 `,446 props: ['modelValue', 'step'],447 computed: {448 data: {449 get() { return this.modelValue; },450 set(value) { this.$emit('update:modelValue', value); }451 }452 }453 };454 455 // Instancia de Vue456 const { createApp } = Vue;457 458 createApp({459 components: {460 'variable-step': VariableStep,461 'request-step': RequestStep,462 'assertion-step': AssertionStep,463 'wait-step': WaitStep,464 // Agrega más componentes si es necesario465 },466 data() {467 return {468 steps: [],469 darkTheme: false,470 environments: [], // Lista de entornos471 currentEnvironment: null, // Entorno seleccionado472 selectedEnvironment: null, // Para editar en el modal473 }474 },475 computed: {476 isExecutable() {477 // Verifica si hay al menos un paso para ejecutar478 return this.steps.length > 0;479 }480 },481 methods: {482 getStepComponent(type) {483 switch (type) {484 case 'variable':485 return 'variable-step';486 case 'request':487 return 'request-step';488 case 'assertion':489 return 'assertion-step';490 case 'wait':491 return 'wait-step';492 // Otros casos...493 }494 },495 getStepTypeLabel(type, method) {496 switch (type) {497 case 'variable':498 return 'VAR';499 case 'request':500 return method;501 case 'assertion':502 return 'ASSERT';503 case 'wait':504 return 'WAIT';505 // Otros casos...506 }507 },508 moveUp(index) {509 if (index > 0) {510 [this.steps[index - 1], this.steps[index]] = [this.steps[index], this.steps[index - 1]];511 }512 },513 moveDown(index) {514 if (index < this.steps.length - 1) {515 [this.steps[index], this.steps[index + 1]] = [this.steps[index + 1], this.steps[index]];516 }517 },518 removeStep(index) {519 this.steps.splice(index, 1);520 },521 addStep(type) {522 let newStep = {523 type: type,524 data: {},525 deferred: false,526 expanded: false,527 result: null,528 showResult: false,529 };530 switch (type) {531 case 'variable':532 newStep.data = { name: '', value: '' };533 break;534 case 'request':535 newStep.data = {536 method: 'GET',537 url: '',538 headers: [],539 body: '',540 repeatInterval: 0,541 repeatMaxAttempts: 1,542 repeatCondition: '',543 successCondition: '',544 };545 break;546 case 'assertion':547 newStep.data = { condition: '' };548 break;549 case 'wait':550 newStep.data = { seconds: 1 };551 break;552 // Otros casos...553 }554 this.steps.push(newStep);555 },556 async executeTest() {557 // Reiniciar resultados558 this.steps.forEach(step => {559 if (step.type === 'request') {560 step.result = null;561 step.showResult = false;562 }563 });564 // Filtrar los pasos que no son "Deferred"565 const stepsToExecute = this.steps.filter(step => !step.deferred);566 // Ejecutar los pasos secuencialmente567 for (const step of stepsToExecute) {568 const res = await this.executeStep(step);569 if (!res.success) {570 return;571 }572 }573 // Ejecutar los pasos "Deferred"574 const deferredSteps = this.steps.filter(step => step.deferred);575 for (const step of deferredSteps) {576 await this.executeStep(step);577 }578 },579 async executeStep(step) {580 switch (step.type) {581 case 'variable':582 // Aquí podrías almacenar la variable en un contexto si es necesario583 return { success: true };584 case 'request':585 return await this.executeRequest(step);586 case 'assertion':587 // Implementar lógica de aserciones si es necesario588 return { success: true };589 case 'wait':590 await new Promise(resolve => setTimeout(resolve, step.data.seconds * 1000));591 return { success: true };592 // Otros casos...593 }594 },595 async executeRequest(step) {596 const data = step.data;597 try {598 let attempts = 0;599 let maxAttempts = data.repeatMaxAttempts || 1;600 let interval = data.repeatInterval || 0;601 let conditionMet = false;602 let response;603 do {604 attempts++;605 // Configurar la solicitud606 const config = {607 method: data.method.toLowerCase(),608 url: this.replacePlaceholders(data.url),609 headers: {},610 };611 data.headers.forEach(header => {612 config.headers[this.replacePlaceholders(header.key)] = this.replacePlaceholders(header.value);613 });614 if (data.method !== 'GET' && data.body) {615 config.data = this.replacePlaceholders(data.body);616 }617 // Realizar la solicitud618 response = await axios(config);619 // Evaluar condiciones620 if (data.successCondition) {621 conditionMet = eval(data.successCondition);622 } else {623 conditionMet = true;624 }625 if (!conditionMet && attempts < maxAttempts) {626 await new Promise(resolve => setTimeout(resolve, interval * 1000));627 }628 } while (!conditionMet && attempts < maxAttempts);629 // Mostrar resultado dentro del paso630 step.result = {631 success: true,632 message: 'Solicitud ejecutada exitosamente.',633 details: {634 status: response.status,635 headers: JSON.stringify(response.headers, null, 2),636 body: JSON.stringify(response.data, null, 2)637 }638 };639 return { success: true };640 } catch (error) {641 step.result = {642 success: false,643 message: 'Error al ejecutar la solicitud.',644 details: {645 status: error.response ? error.response.status : 'Sin respuesta',646 headers: error.response ? JSON.stringify(error.response.headers, null, 2) : 'No disponible',647 body: error.response ? JSON.stringify(error.response.data, null, 2) : error.message648 }649 };650 return { success: false };651 }652 },653 executeRequestStep(index) {654 const step = this.steps[index];655 // Reiniciar resultado previo656 step.result = null;657 step.showResult = false;658 this.executeRequest(step);659 },660 saveFlow() {661 const dataStr = JSON.stringify(this.steps, null, 2);662 const blob = new Blob([dataStr], { type: "application/json" });663 const url = URL.createObjectURL(blob);664 const a = document.createElement('a');665 a.href = url;666 a.download = 'flujo_de_prueba.json';667 a.click();668 URL.revokeObjectURL(url);669 },670 loadFlow() {671 document.getElementById('fileInput').click();672 },673 handleFileUpload(event) {674 const file = event.target.files[0];675 if (file) {676 const reader = new FileReader();677 reader.onload = (e) => {678 try {679 const data = JSON.parse(e.target.result);680 // Validar que el archivo contiene un array de pasos681 if (Array.isArray(data)) {682 this.steps = data;683 } else {684 alert('El archivo no es válido.');685 }686 } catch (error) {687 alert('Error al cargar el archivo: ' + error.message);688 }689 };690 reader.readAsText(file);691 // Limpiar el valor del input para permitir cargar el mismo archivo nuevamente si es necesario692 event.target.value = '';693 }694 },695 // Métodos para Entornos696 showEnvironmentModal() {697 const modal = new bootstrap.Modal(document.getElementById('environmentModal'));698 modal.show();699 // Clonar el entorno actual para editar700 this.selectedEnvironment = JSON.parse(JSON.stringify(this.currentEnvironment));701 },702 addEnvironment() {703 const envName = prompt('Nombre del nuevo entorno:');704 if (envName) {705 const newEnv = { name: envName, variables: [] };706 this.environments.push(newEnv);707 this.selectedEnvironment = newEnv;708 this.currentEnvironment = newEnv;709 }710 },711 selectEnvironment(env) {712 this.selectedEnvironment = env;713 },714 addVariable() {715 this.selectedEnvironment.variables.push({ key: '', value: '' });716 },717 removeVariable(index) {718 this.selectedEnvironment.variables.splice(index, 1);719 },720 saveEnvironments() {721 // Actualizar el entorno actual722 const index = this.environments.findIndex(env => env.name === this.selectedEnvironment.name);723 if (index !== -1) {724 this.environments.splice(index, 1, this.selectedEnvironment);725 this.currentEnvironment = this.selectedEnvironment;726 }727 // Cerrar el modal728 const modal = bootstrap.Modal.getInstance(document.getElementById('environmentModal'));729 modal.hide();730 },731 replacePlaceholders(text) {732 if (!text || !this.currentEnvironment) return text;733 let result = text;734 this.currentEnvironment.variables.forEach(variable => {735 const regex = new RegExp(`{{${variable.key}}}`, 'g');736 result = result.replace(regex, variable.value);737 });738 return result;739 },740 // Método para cambiar el tema741 toggleTheme() {742 this.darkTheme = !this.darkTheme;743 document.body.classList.toggle('bg-dark');744 document.body.classList.toggle('text-white');745 },746 },747 mounted() {748 // Inicializar el tema según las preferencias del usuario749 const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;750 if (prefersDark) {751 this.toggleTheme();752 }753 // Inicializar entorno por defecto754 this.environments.push({ name: 'Default', variables: [] });755 this.currentEnvironment = this.environments[0];756 }757 }).mount('#app');758 </script>759 </body>760 </html>761
Enlace
El enlace para compartir es: