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 <button class="btn btn-secondary ms-auto theme-toggle" id="themeToggle">153 <i class="bi bi-moon-fill"></i>154 </button>155 </div>156 </nav>157 158 <!-- Contenido principal -->159 <div id="app" class="container my-4">160 <!-- Botones para Guardar y Cargar Flujos -->161 <div class="flow-controls">162 <button class="btn btn-outline-secondary" @click="saveFlow">163 <i class="bi bi-save"></i> Guardar Flujo164 </button>165 <button class="btn btn-outline-secondary" @click="loadFlow">166 <i class="bi bi-folder2-open"></i> Cargar Flujo167 </button>168 <!-- Input file oculto para cargar archivos -->169 <input type="file" id="fileInput" @change="handleFileUpload" accept=".json">170 </div>171 172 <!-- Controles de Perfiles de Entorno -->173 <div class="environment-controls">174 <button class="btn btn-outline-primary" @click="showEnvironmentModal">175 <i class="bi bi-gear"></i> Configurar Entornos176 </button>177 <span class="ms-3" v-if="currentEnvironment"><strong>Entorno Actual:</strong> {{ currentEnvironment.name }}</span>178 </div>179 180 <!-- Lista de pasos -->181 <div v-for="(step, index) in steps" :key="index" class="step">182 <span class="step-type">{{ getStepTypeLabel(step.type, step.data.method) }}</span>183 184 <div class="step-content">185 <div class="main-content">186 <!-- Contenido según el tipo de paso -->187 <component :is="getStepComponent(step.type)" v-model="step.data" :step="step"></component>188 <!-- Checkbox para "Deferred" -->189 <div v-if="step.type === 'request' || step.type === 'assertion'">190 <input type="checkbox" v-model="step.deferred" :id="'deferred-' + index">191 <label :for="'deferred-' + index" class="deferred-label">Deferred</label>192 </div>193 </div>194 <!-- Resultados dentro del bloque de solicitud -->195 <div v-if="step.type === 'request' && step.result" class="result-panel">196 <button class="result-toggle" @click="step.showResult = !step.showResult">197 <i class="bi" :class="step.showResult ? 'bi-eye-slash' : 'bi-eye'"></i>198 {{ step.showResult ? 'Ocultar' : 'Mostrar' }} Resultado199 </button>200 <div v-if="step.showResult">201 <div class="alert mt-2" :class="{'alert-success': step.result.success, 'alert-danger': !step.result.success}">202 <strong>Resultado:</strong> {{ step.result.message }}203 </div>204 <div v-if="step.result.details">205 <p><strong>Status Code:</strong> {{ step.result.details.status }}</p>206 <p><strong>Headers:</strong></p>207 <pre>{{ step.result.details.headers }}</pre>208 <p><strong>Body:</strong></p>209 <pre style="max-height: 200px; overflow-y: auto;">{{ step.result.details.body }}</pre>210 </div>211 </div>212 </div>213 </div>214 <div class="step-controls">215 <button @click="moveUp(index)" :disabled="index === 0"><i class="bi bi-arrow-up"></i></button>216 <button @click="moveDown(index)" :disabled="index === steps.length - 1"><i class="bi bi-arrow-down"></i></button>217 <button v-if="step.type === 'request'" @click="executeRequestStep(index)" class="execute-btn"><i class="bi bi-play-circle"></i></button>218 <button @click="removeStep(index)"><i class="bi bi-trash"></i></button>219 </div>220 </div>221 222 <!-- Botones para añadir nuevos pasos -->223 <div class="add-step">224 <button class="btn btn-outline-primary me-2" @click="addStep('variable')">225 <i class="bi bi-sliders"></i> Variable226 </button>227 <button class="btn btn-outline-primary me-2" @click="addStep('request')">228 <i class="bi bi-arrow-up-right-circle"></i> Solicitud HTTP229 </button>230 <button class="btn btn-outline-primary me-2" @click="addStep('assertion')">231 <i class="bi bi-check-circle"></i> Aserción232 </button>233 <button class="btn btn-outline-primary me-2" @click="addStep('wait')">234 <i class="bi bi-hourglass-split"></i> Wait235 </button>236 <!-- Agrega más botones si es necesario -->237 </div>238 239 <!-- Botón de Ejecución -->240 <div class="text-center my-4">241 <button class="btn btn-lg btn-success" @click="executeTest" :disabled="!isExecutable">242 <i class="bi bi-play-fill"></i> Ejecutar Prueba Completa243 </button>244 </div>245 246 <!-- Modal para Configurar Entornos -->247 <div class="modal fade" id="environmentModal" tabindex="-1" aria-labelledby="environmentModalLabel" aria-hidden="true">248 <div class="modal-dialog modal-lg">249 <div class="modal-content">250 <div class="modal-header">251 <h5 class="modal-title" id="environmentModalLabel">Perfiles de Entorno</h5>252 <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Cerrar"></button>253 </div>254 <div class="modal-body">255 <div class="row">256 <!-- Lista de entornos -->257 <div class="col-md-4">258 <h6>Entornos Disponibles</h6>259 <div class="environment-list">260 <div v-for="(env, index) in environments" :key="index" class="env-item" :class="{'active': env === selectedEnvironment}" @click="selectEnvironment(env)">261 {{ env.name }}262 </div>263 </div>264 <button class="btn btn-sm btn-primary" @click="addEnvironment">265 <i class="bi bi-plus-lg"></i> Añadir Entorno266 </button>267 </div>268 <!-- Detalles del entorno seleccionado -->269 <div class="col-md-8" v-if="selectedEnvironment">270 <h6>Variables del Entorno "{{ selectedEnvironment.name }}"</h6>271 <div class="mt-2">272 <div v-for="(variable, index) in selectedEnvironment.variables" :key="index" class="d-flex align-items-center mb-2">273 <input type="text" class="form-control me-2" v-model="variable.key" placeholder="Nombre de la variable" style="max-width: 150px;">274 <input type="text" class="form-control me-2" v-model="variable.value" placeholder="Valor" style="max-width: 150px;">275 <button class="btn btn-sm btn-outline-danger" @click="removeVariable(index)">276 <i class="bi bi-trash"></i>277 </button>278 </div>279 <button class="btn btn-outline-primary btn-sm" @click="addVariable">280 <i class="bi bi-plus-lg"></i> Agregar Variable281 </button>282 </div>283 </div>284 </div>285 </div>286 <div class="modal-footer">287 <button class="btn btn-secondary" data-bs-dismiss="modal">Cerrar</button>288 <button class="btn btn-primary" @click="saveEnvironments">Guardar Cambios</button>289 </div>290 </div>291 </div>292 </div>293 </div>294 295 <!-- Enlaces a Vue.js, Axios y Bootstrap JS -->296 <script src="https://cdn.jsdelivr.net/npm/vue@3.3.4/dist/vue.global.prod.js"></script>297 <script src="https://cdn.jsdelivr.net/npm/axios@1.4.0/dist/axios.min.js"></script>298 <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>299 300 <!-- Componentes para los pasos -->301 <script>302 const VariableStep = {303 template: `304 <div class="d-flex align-items-center">305 <input type="text" class="form-control me-2" v-model="data.name" placeholder="Nombre" style="max-width: 150px;">306 <span>=</span>307 <input type="text" class="form-control ms-2" v-model="data.value" placeholder="Valor" style="max-width: 150px;">308 </div>309 `,310 props: ['modelValue', 'step'],311 computed: {312 data: {313 get() { return this.modelValue; },314 set(value) { this.$emit('update:modelValue', value); }315 }316 }317 };318 319 const RequestStep = {320 template: `321 <div>322 <div class="d-flex align-items-center flex-wrap">323 <select class="form-select me-2" v-model="data.method" style="max-width: 100px;">324 <option>GET</option>325 <option>POST</option>326 <option>PUT</option>327 <option>DELETE</option>328 </select>329 <input type="text" class="form-control me-2" v-model="data.url" placeholder="URL" style="flex: 1;">330 <!-- Botón para mostrar detalles -->331 <button class="btn btn-sm btn-outline-secondary me-2" @click="toggleDetails">332 <i class="bi" :class="step.expanded ? 'bi-chevron-up' : 'bi-chevron-down'"></i>333 </button>334 </div>335 <!-- Detalles opcionales -->336 <div v-if="step.expanded" class="details mt-2">337 <!-- Headers -->338 <button class="btn btn-sm btn-outline-secondary me-2" @click="toggleHeaders">339 <i class="bi" :class="showHeaders ? 'bi-dash' : 'bi-plus'"></i> Headers340 </button>341 <!-- Body -->342 <button class="btn btn-sm btn-outline-secondary me-2" @click="toggleBody" v-if="data.method !== 'GET'">343 <i class="bi" :class="showBody ? 'bi-dash' : 'bi-plus'"></i> Body344 </button>345 <!-- Repeat Config -->346 <button class="btn btn-sm btn-outline-secondary" @click="toggleRepeat">347 <i class="bi" :class="showRepeat ? 'bi-dash' : 'bi-plus'"></i> Repeat348 </button>349 <!-- Headers -->350 <div v-if="showHeaders" class="mt-2">351 <div v-for="(header, index) in data.headers" :key="index" class="d-flex align-items-center mb-2">352 <input type="text" class="form-control me-2" v-model="header.key" placeholder="Header Key" style="max-width: 150px;">353 <input type="text" class="form-control me-2" v-model="header.value" placeholder="Header Value" style="max-width: 150px;">354 <button class="btn btn-sm btn-outline-danger" @click="removeHeader(index)">355 <i class="bi bi-trash"></i>356 </button>357 </div>358 <button class="btn btn-outline-primary btn-sm" @click="addHeader">359 <i class="bi bi-plus-lg"></i> Agregar Header360 </button>361 </div>362 <!-- Body -->363 <div v-if="showBody" class="mt-2">364 <label class="form-label">Body</label>365 <textarea class="form-control" rows="3" v-model="data.body" placeholder="Cuerpo de la solicitud"></textarea>366 </div>367 <!-- Repeat -->368 <div v-if="showRepeat" class="mt-2">369 <div class="d-flex align-items-center">370 <label class="form-label me-2">Intervalo (s):</label>371 <input type="number" class="form-control me-2" v-model.number="data.repeatInterval" style="max-width: 100px;">372 <label class="form-label me-2">Máximo de Intentos:</label>373 <input type="number" class="form-control me-2" v-model.number="data.repeatMaxAttempts" style="max-width: 100px;">374 </div>375 <div class="mt-2">376 <label class="form-label">Condición para Repetir:</label>377 <input type="text" class="form-control" v-model="data.repeatCondition" placeholder="Ejemplo: response.status == 404">378 </div>379 <div class="mt-2">380 <label class="form-label">Condición de Éxito:</label>381 <input type="text" class="form-control" v-model="data.successCondition" placeholder="Ejemplo: response.status == 200 || response.status == 201">382 </div>383 </div>384 </div>385 </div>386 `,387 props: ['modelValue', 'step'],388 data() {389 return {390 showHeaders: false,391 showBody: false,392 showRepeat: false,393 }394 },395 computed: {396 data: {397 get() { return this.modelValue; },398 set(value) { this.$emit('update:modelValue', value); }399 }400 },401 methods: {402 toggleDetails() {403 this.step.expanded = !this.step.expanded;404 },405 toggleHeaders() {406 this.showHeaders = !this.showHeaders;407 },408 toggleBody() {409 this.showBody = !this.showBody;410 },411 toggleRepeat() {412 this.showRepeat = !this.showRepeat;413 },414 addHeader() {415 this.data.headers.push({ key: '', value: '' });416 },417 removeHeader(index) {418 this.data.headers.splice(index, 1);419 }420 }421 };422 423 const AssertionStep = {424 template: `425 <div class="d-flex align-items-center">426 <input type="text" class="form-control" v-model="data.condition" placeholder="Condición">427 </div>428 `,429 props: ['modelValue', 'step'],430 computed: {431 data: {432 get() { return this.modelValue; },433 set(value) { this.$emit('update:modelValue', value); }434 }435 }436 };437 438 const WaitStep = {439 template: `440 <div class="d-flex align-items-center">441 <label class="form-label me-2">Esperar</label>442 <input type="number" class="form-control me-2" v-model.number="data.seconds" placeholder="Segundos" style="max-width: 100px;">443 </div>444 `,445 props: ['modelValue', 'step'],446 computed: {447 data: {448 get() { return this.modelValue; },449 set(value) { this.$emit('update:modelValue', value); }450 }451 }452 };453 454 // Instancia de Vue455 const { createApp } = Vue;456 457 createApp({458 components: {459 'variable-step': VariableStep,460 'request-step': RequestStep,461 'assertion-step': AssertionStep,462 'wait-step': WaitStep,463 // Agrega más componentes si es necesario464 },465 data() {466 return {467 steps: [],468 darkTheme: false,469 environments: [], // Lista de entornos470 currentEnvironment: null, // Entorno seleccionado471 selectedEnvironment: null, // Para editar en el modal472 }473 },474 computed: {475 isExecutable() {476 // Verifica si hay al menos un paso para ejecutar477 return this.steps.length > 0;478 }479 },480 methods: {481 getStepComponent(type) {482 switch (type) {483 case 'variable':484 return 'variable-step';485 case 'request':486 return 'request-step';487 case 'assertion':488 return 'assertion-step';489 case 'wait':490 return 'wait-step';491 // Otros casos...492 }493 },494 getStepTypeLabel(type, method) {495 switch (type) {496 case 'variable':497 return 'VAR';498 case 'request':499 return method;500 case 'assertion':501 return 'ASSERT';502 case 'wait':503 return 'WAIT';504 // Otros casos...505 }506 },507 moveUp(index) {508 if (index > 0) {509 [this.steps[index - 1], this.steps[index]] = [this.steps[index], this.steps[index - 1]];510 }511 },512 moveDown(index) {513 if (index < this.steps.length - 1) {514 [this.steps[index], this.steps[index + 1]] = [this.steps[index + 1], this.steps[index]];515 }516 },517 removeStep(index) {518 this.steps.splice(index, 1);519 },520 addStep(type) {521 let newStep = {522 type: type,523 data: {},524 deferred: false,525 expanded: false,526 result: null,527 showResult: false,528 };529 switch (type) {530 case 'variable':531 newStep.data = { name: '', value: '' };532 break;533 case 'request':534 newStep.data = {535 method: 'GET',536 url: '',537 headers: [],538 body: '',539 repeatInterval: 0,540 repeatMaxAttempts: 1,541 repeatCondition: '',542 successCondition: '',543 };544 break;545 case 'assertion':546 newStep.data = { condition: '' };547 break;548 case 'wait':549 newStep.data = { seconds: 1 };550 break;551 // Otros casos...552 }553 this.steps.push(newStep);554 },555 async executeTest() {556 // Reiniciar resultados557 this.steps.forEach(step => {558 if (step.type === 'request') {559 step.result = null;560 step.showResult = false;561 }562 });563 // Filtrar los pasos que no son "Deferred"564 const stepsToExecute = this.steps.filter(step => !step.deferred);565 // Ejecutar los pasos secuencialmente566 for (const step of stepsToExecute) {567 const res = await this.executeStep(step);568 if (!res.success) {569 return;570 }571 }572 // Ejecutar los pasos "Deferred"573 const deferredSteps = this.steps.filter(step => step.deferred);574 for (const step of deferredSteps) {575 await this.executeStep(step);576 }577 },578 async executeStep(step) {579 switch (step.type) {580 case 'variable':581 // Aquí podrías almacenar la variable en un contexto si es necesario582 return { success: true };583 case 'request':584 return await this.executeRequest(step);585 case 'assertion':586 // Implementar lógica de aserciones si es necesario587 return { success: true };588 case 'wait':589 await new Promise(resolve => setTimeout(resolve, step.data.seconds * 1000));590 return { success: true };591 // Otros casos...592 }593 },594 async executeRequest(step) {595 const data = step.data;596 try {597 let attempts = 0;598 let maxAttempts = data.repeatMaxAttempts || 1;599 let interval = data.repeatInterval || 0;600 let conditionMet = false;601 let response;602 do {603 attempts++;604 // Configurar la solicitud605 const config = {606 method: data.method.toLowerCase(),607 url: this.replacePlaceholders(data.url),608 headers: {},609 };610 data.headers.forEach(header => {611 config.headers[this.replacePlaceholders(header.key)] = this.replacePlaceholders(header.value);612 });613 if (data.method !== 'GET' && data.body) {614 config.data = this.replacePlaceholders(data.body);615 }616 // Realizar la solicitud617 response = await axios(config);618 // Evaluar condiciones619 if (data.successCondition) {620 conditionMet = eval(data.successCondition);621 } else {622 conditionMet = true;623 }624 if (!conditionMet && attempts < maxAttempts) {625 await new Promise(resolve => setTimeout(resolve, interval * 1000));626 }627 } while (!conditionMet && attempts < maxAttempts);628 // Mostrar resultado dentro del paso629 step.result = {630 success: true,631 message: 'Solicitud ejecutada exitosamente.',632 details: {633 status: response.status,634 headers: JSON.stringify(response.headers, null, 2),635 body: JSON.stringify(response.data, null, 2)636 }637 };638 return { success: true };639 } catch (error) {640 step.result = {641 success: false,642 message: 'Error al ejecutar la solicitud.',643 details: {644 status: error.response ? error.response.status : 'Sin respuesta',645 headers: error.response ? JSON.stringify(error.response.headers, null, 2) : 'No disponible',646 body: error.response ? JSON.stringify(error.response.data, null, 2) : error.message647 }648 };649 return { success: false };650 }651 },652 executeRequestStep(index) {653 const step = this.steps[index];654 // Reiniciar resultado previo655 step.result = null;656 step.showResult = false;657 this.executeRequest(step);658 },659 saveFlow() {660 const dataStr = JSON.stringify(this.steps, null, 2);661 const blob = new Blob([dataStr], { type: "application/json" });662 const url = URL.createObjectURL(blob);663 const a = document.createElement('a');664 a.href = url;665 a.download = 'flujo_de_prueba.json';666 a.click();667 URL.revokeObjectURL(url);668 },669 loadFlow() {670 document.getElementById('fileInput').click();671 },672 handleFileUpload(event) {673 const file = event.target.files[0];674 if (file) {675 const reader = new FileReader();676 reader.onload = (e) => {677 try {678 const data = JSON.parse(e.target.result);679 // Validar que el archivo contiene un array de pasos680 if (Array.isArray(data)) {681 this.steps = data;682 } else {683 alert('El archivo no es válido.');684 }685 } catch (error) {686 alert('Error al cargar el archivo: ' + error.message);687 }688 };689 reader.readAsText(file);690 // Limpiar el valor del input para permitir cargar el mismo archivo nuevamente si es necesario691 event.target.value = '';692 }693 },694 // Métodos para Entornos695 showEnvironmentModal() {696 const modal = new bootstrap.Modal(document.getElementById('environmentModal'));697 modal.show();698 // Clonar el entorno seleccionado para editar699 this.selectedEnvironment = JSON.parse(JSON.stringify(this.currentEnvironment));700 },701 addEnvironment() {702 const envName = prompt('Nombre del nuevo entorno:');703 if (envName) {704 const newEnv = { name: envName, variables: [] };705 this.environments.push(newEnv);706 this.selectedEnvironment = newEnv;707 this.currentEnvironment = newEnv;708 }709 },710 selectEnvironment(env) {711 this.selectedEnvironment = env;712 },713 addVariable() {714 this.selectedEnvironment.variables.push({ key: '', value: '' });715 },716 removeVariable(index) {717 this.selectedEnvironment.variables.splice(index, 1);718 },719 saveEnvironments() {720 // Actualizar el entorno actual721 const index = this.environments.findIndex(env => env.name === this.selectedEnvironment.name);722 if (index !== -1) {723 this.environments.splice(index, 1, this.selectedEnvironment);724 this.currentEnvironment = this.selectedEnvironment;725 }726 // Cerrar el modal727 const modal = bootstrap.Modal.getInstance(document.getElementById('environmentModal'));728 modal.hide();729 },730 replacePlaceholders(text) {731 if (!text || !this.currentEnvironment) return text;732 let result = text;733 this.currentEnvironment.variables.forEach(variable => {734 const regex = new RegExp(`{{${variable.key}}}`, 'g');735 result = result.replace(regex, variable.value);736 });737 return result;738 },739 // Añadimos el método toggleTheme740 toggleTheme() {741 document.body.classList.toggle('bg-dark');742 document.body.classList.toggle('text-white');743 const icon = document.getElementById('themeToggle').querySelector('i');744 icon.classList.toggle('bi-moon-fill');745 icon.classList.toggle('bi-sun-fill');746 },747 },748 mounted() {749 // Inicializar el tema según las preferencias del usuario750 const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;751 if (prefersDark) {752 this.toggleTheme();753 }754 // Inicializar entorno por defecto755 const defaultEnv = { name: 'Default', variables: [] };756 this.environments.push(defaultEnv);757 this.currentEnvironment = defaultEnv;758 this.selectedEnvironment = defaultEnv;759 }760 }).mount('#app');761 762 // Manejo del botón de cambio de tema fuera de Vue763 document.getElementById('themeToggle').addEventListener('click', () => {764 const app = document.getElementById('app').__vue_app__.config.globalProperties;765 app.toggleTheme();766 });767 </script>768 </body>769 </html>770
Enlace
El enlace para compartir es: