From 9033e09ac123aaef06d75d0d25b63993b48e9665 Mon Sep 17 00:00:00 2001 From: Anton Voylenko Date: Mon, 13 Jul 2026 19:53:15 +0300 Subject: [PATCH] feat: add web dashboard --- .env.example | 2 + Dockerfile | 1 + README.md | 4 + public/dashboard/app.js | 397 ++++++++++++++++++++++++++++++++++++ public/dashboard/index.html | 103 ++++++++++ public/dashboard/style.css | 396 +++++++++++++++++++++++++++++++++++ src/config.js | 2 + src/routes.js | 21 +- tests/dashboard.test.js | 41 ++++ 9 files changed, 966 insertions(+), 1 deletion(-) create mode 100644 public/dashboard/app.js create mode 100644 public/dashboard/index.html create mode 100644 public/dashboard/style.css create mode 100644 tests/dashboard.test.js diff --git a/.env.example b/.env.example index 2d3b82e..b2f32e3 100644 --- a/.env.example +++ b/.env.example @@ -46,6 +46,8 @@ AUTO_START_SESSIONS=TRUE SESSIONS_PATH=./sessions # OPTIONAL, ENABLE SWAGGER ENDPOINT FOR API DOCUMENTATION ENABLE_SWAGGER_ENDPOINT=TRUE +# OPTIONAL, ENABLE SIMPLE WEB UI FOR SESSION MANAGEMENT AT /dashboard (FALSE BY DEFAULT) +ENABLE_WEB_UI=FALSE ## Reverse Proxy / Load Balancer ## # OPTIONAL, BASE PATH FOR MOUNTING ROUTES (e.g., /api/v1/whatsapp) diff --git a/Dockerfile b/Dockerfile index ef2d74a..7bfdc66 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,6 +44,7 @@ COPY server.js ./ COPY LICENSE ./ COPY swagger.json ./ COPY src/ ./src/ +COPY public/ ./public/ EXPOSE 3000 diff --git a/README.md b/README.md index dfa1e3e..a197785 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,10 @@ By setting the `DISABLED_CALLBACKS` environment variable you can specify what ev By setting the `ENABLE_WEBHOOK` environment to `FALSE` you can disable webhook dispatching. This will help you if you want to switch to websocket method(see below). +### Web dashboard + +The service ships with a simple web UI for session management. It is disabled by default — set the `ENABLE_WEB_UI` environment variable to `TRUE`, restart the service and go to the `/dashboard` endpoint. The dashboard lets you start, stop, restart and terminate sessions, scan the pairing QR code (or request a pairing code for a phone number) and inspect connected session details. If the `API_KEY` environment variable is set, enter the key in the dashboard header — it is stored in your browser's localStorage and sent as the `x-api-key` header with every request. + ### Scanning QR code In order to validate a new WhatsApp Web instance you need to scan the QR code using your mobile phone. Official documentation can be found at (https://faq.whatsapp.com/1079327266110265/?cms_platform=android) page. The service itself delivers the QR code content as a webhook event or you can use the REST endpoints (`/session/qr/:sessionId` or `/session/qr/:sessionId/image` to get the QR code as a png image). diff --git a/public/dashboard/app.js b/public/dashboard/app.js new file mode 100644 index 0000000..af70b22 --- /dev/null +++ b/public/dashboard/app.js @@ -0,0 +1,397 @@ +(function () { + const API_BASE = window.location.pathname.slice(0, window.location.pathname.lastIndexOf('/dashboard')) + const STORAGE_KEY = 'wwebjsApiKey' + // must match sessionNameValidation in src/middleware.js + const SESSION_ID_PATTERN = /^[\w-]+$/ + const POLL_INTERVAL_MS = 5000 + const QR_POLL_INTERVAL_MS = 3000 + + const $ = (id) => document.getElementById(id) + + let pollTimer = null + let qrTimer = null + let qrSessionId = null + let qrObjectUrl = null + let authFailed = false + + async function apiFetch (path, options = {}) { + const headers = Object.assign({}, options.headers) + const apiKey = window.localStorage.getItem(STORAGE_KEY) + if (apiKey) { + headers['x-api-key'] = apiKey + } + const response = await window.fetch(API_BASE + path, Object.assign({}, options, { headers, cache: 'no-store' })) + if (response.status === 403) { + authFailed = true + $('apiKeyBanner').classList.remove('hidden') + throw new Error('Invalid or missing API key') + } + return response + } + + async function apiJson (path, options) { + const response = await apiFetch(path, options) + return response.json() + } + + function showToast (message, isError) { + const toast = document.createElement('div') + toast.className = 'toast' + (isError ? ' toast-error' : '') + toast.textContent = message + $('toasts').appendChild(toast) + setTimeout(() => toast.remove(), 4000) + } + + /* + * Sessions list + */ + + async function refreshSessions () { + const body = await apiJson('/session/getSessions') + if (!body.success) { + throw new Error(body.error || 'Failed to fetch sessions') + } + const ids = body.result.sort() + const statuses = await Promise.all( + ids.map((id) => apiJson('/session/status/' + encodeURIComponent(id)).catch(() => null)) + ) + renderSessions(ids, statuses) + } + + function renderSessions (ids, statuses) { + const tbody = $('sessionsBody') + tbody.textContent = '' + $('emptyState').classList.toggle('hidden', ids.length > 0) + ids.forEach((id, index) => { + const status = statuses[index] + const connected = Boolean(status && status.success && status.state === 'CONNECTED') + const row = document.createElement('tr') + + const idCell = document.createElement('td') + idCell.className = 'session-id' + idCell.textContent = id + row.appendChild(idCell) + + const statusCell = document.createElement('td') + const badge = document.createElement('span') + badge.className = 'badge ' + (connected ? 'badge-connected' : 'badge-pending') + badge.textContent = (status && status.state) || 'STARTING' + statusCell.appendChild(badge) + row.appendChild(statusCell) + + const actionsCell = document.createElement('td') + actionsCell.className = 'actions-cell' + const actions = document.createElement('div') + actions.className = 'actions' + if (connected) { + actions.appendChild(actionButton('Details', () => openDetailsModal(id))) + } else { + actions.appendChild(actionButton('Show QR', () => openQrModal(id))) + } + actions.appendChild(actionButton('Restart', () => sessionAction(id, 'restart'))) + actions.appendChild(actionButton('Stop', () => sessionAction(id, 'stop'))) + actions.appendChild(actionButton('Terminate', () => sessionAction(id, 'terminate', 'Terminate session "' + id + '"? This logs out the linked device.'), 'danger')) + actionsCell.appendChild(actions) + row.appendChild(actionsCell) + + tbody.appendChild(row) + }) + } + + function actionButton (label, onClick, extraClass) { + const button = document.createElement('button') + button.type = 'button' + button.className = 'secondary' + (extraClass ? ' ' + extraClass : '') + button.textContent = label + button.addEventListener('click', onClick) + return button + } + + async function sessionAction (id, action, confirmMessage) { + if (confirmMessage && !window.confirm(confirmMessage)) { + return + } + try { + const body = await apiJson('/session/' + action + '/' + encodeURIComponent(id)) + if (body.success) { + showToast(id + ': ' + (body.message || action + ' completed')) + } else { + showToast(id + ': ' + (body.error || body.message || action + ' failed'), true) + } + } catch (error) { + showToast(error.message, true) + } + refreshNow() + } + + async function globalAction (path, confirmMessage) { + if (confirmMessage && !window.confirm(confirmMessage)) { + return + } + try { + const body = await apiJson(path) + showToast(body.message || body.error || 'Done', !body.success) + } catch (error) { + showToast(error.message, true) + } + refreshNow() + } + + /* + * Polling + */ + + function refreshNow () { + clearTimeout(pollTimer) + pollLoop() + } + + async function pollLoop () { + if (!document.hidden && !authFailed) { + try { + await refreshSessions() + } catch { + // errors surface through the API key banner or action toasts + } + } + pollTimer = setTimeout(pollLoop, POLL_INTERVAL_MS) + } + + /* + * Add session + */ + + function openStartModal () { + $('newSessionId').value = '' + $('addSessionError').classList.add('hidden') + $('startModal').classList.remove('hidden') + $('newSessionId').focus() + } + + function closeStartModal () { + $('startModal').classList.add('hidden') + } + + async function startNewSession (event) { + event.preventDefault() + const input = $('newSessionId') + const errorEl = $('addSessionError') + const id = input.value.trim() + if (!SESSION_ID_PATTERN.test(id)) { + errorEl.textContent = 'Session id should be alphanumerical or -' + errorEl.classList.remove('hidden') + return + } + errorEl.classList.add('hidden') + const button = $('startSubmitBtn') + button.disabled = true + button.textContent = 'Starting…' + try { + const body = await apiJson('/session/start/' + encodeURIComponent(id)) + if (body.success) { + showToast(id + ': ' + body.message) + closeStartModal() + openQrModal(id) + } else { + showToast(id + ': ' + (body.error || body.message), true) + } + } catch (error) { + showToast(error.message, true) + } + button.disabled = false + button.textContent = 'Start' + refreshNow() + } + + /* + * QR modal + */ + + function openQrModal (id) { + clearTimeout(qrTimer) + qrSessionId = id + $('qrSessionName').textContent = id + $('qrImage').classList.add('hidden') + $('qrConnected').classList.add('hidden') + $('qrWaiting').textContent = 'Waiting for QR code…' + $('qrWaiting').classList.remove('hidden') + $('pairingCode').classList.add('hidden') + $('pairingPhone').value = '' + $('qrModal').classList.remove('hidden') + qrTick() + } + + async function qrTick () { + try { + const status = await apiJson('/session/status/' + encodeURIComponent(qrSessionId)) + if (status.message === 'session_not_found') { + $('qrImage').classList.add('hidden') + $('qrWaiting').classList.remove('hidden') + $('qrWaiting').textContent = 'Session no longer exists' + refreshNow() + return + } + if (status.success && status.state === 'CONNECTED') { + $('qrWaiting').classList.add('hidden') + $('qrImage').classList.add('hidden') + $('qrConnected').classList.remove('hidden') + refreshNow() + return + } + const response = await apiFetch('/session/qr/' + encodeURIComponent(qrSessionId) + '/image') + const contentType = response.headers.get('content-type') || '' + // the endpoint responds 200 with a JSON body when the QR is not ready + if (contentType.startsWith('image/png')) { + const blob = await response.blob() + if (qrObjectUrl) { + URL.revokeObjectURL(qrObjectUrl) + } + qrObjectUrl = URL.createObjectURL(blob) + const image = $('qrImage') + image.src = qrObjectUrl + image.classList.remove('hidden') + $('qrWaiting').classList.add('hidden') + } + } catch { + // keep polling; auth errors surface through the API key banner + } + qrTimer = setTimeout(qrTick, QR_POLL_INTERVAL_MS) + } + + function closeQrModal () { + clearTimeout(qrTimer) + if (qrObjectUrl) { + URL.revokeObjectURL(qrObjectUrl) + qrObjectUrl = null + } + $('qrModal').classList.add('hidden') + refreshNow() + } + + async function requestPairingCode () { + const phoneNumber = $('pairingPhone').value.replace(/\D/g, '') + if (!phoneNumber) { + showToast('Enter the phone number in international format, digits only', true) + return + } + const button = $('pairingBtn') + button.disabled = true + try { + const body = await apiJson('/session/requestPairingCode/' + encodeURIComponent(qrSessionId), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phoneNumber, showNotification: true }) + }) + if (body.success) { + const codeEl = $('pairingCode') + codeEl.textContent = body.result + codeEl.classList.remove('hidden') + } else { + showToast(body.error || body.message || 'Failed to request pairing code', true) + } + } catch (error) { + showToast(error.message, true) + } + button.disabled = false + } + + /* + * Details modal + */ + + async function openDetailsModal (id) { + $('detailsSessionName').textContent = id + const list = $('detailsList') + list.textContent = '' + $('detailsModal').classList.remove('hidden') + try { + const status = await apiJson('/session/status/' + encodeURIComponent(id)) + addDetail(list, 'State', status.state || status.message) + if (status.success && status.state === 'CONNECTED') { + const body = await apiJson('/client/getClassInfo/' + encodeURIComponent(id)) + const info = body.sessionInfo || {} + addDetail(list, 'Phone number', info.wid && info.wid.user) + addDetail(list, 'Push name', info.pushname) + addDetail(list, 'Platform', info.platform) + } + } catch (error) { + showToast(error.message, true) + } + } + + function addDetail (list, label, value) { + const dt = document.createElement('dt') + dt.textContent = label + const dd = document.createElement('dd') + dd.textContent = value || '—' + list.appendChild(dt) + list.appendChild(dd) + } + + /* + * API key + */ + + function saveApiKey () { + const value = $('apiKeyInput').value.trim() + if (value) { + window.localStorage.setItem(STORAGE_KEY, value) + } else { + window.localStorage.removeItem(STORAGE_KEY) + } + authFailed = false + $('apiKeyBanner').classList.add('hidden') + showToast('API key saved') + refreshNow() + } + + // the swagger endpoint is optional (ENABLE_SWAGGER_ENDPOINT); only show the link when it responds + async function checkApiDocs () { + try { + const response = await window.fetch(API_BASE + '/api-docs', { method: 'HEAD', cache: 'no-store' }) + if (response.ok) { + $('apiDocsLink').classList.remove('hidden') + } + } catch { + // endpoint unavailable; keep the link hidden + } + } + + function init () { + $('apiKeyInput').value = window.localStorage.getItem(STORAGE_KEY) || '' + checkApiDocs() + $('apiKeySaveBtn').addEventListener('click', saveApiKey) + $('addSessionBtn').addEventListener('click', openStartModal) + $('addSessionForm').addEventListener('submit', startNewSession) + $('startCloseBtn').addEventListener('click', closeStartModal) + $('refreshBtn').addEventListener('click', () => refreshNow()) + $('terminateInactiveBtn').addEventListener('click', () => globalAction('/session/terminateInactive', 'Terminate all inactive sessions?')) + $('terminateAllBtn').addEventListener('click', () => globalAction('/session/terminateAll', 'Terminate ALL sessions? This logs out every linked device.')) + $('qrCloseBtn').addEventListener('click', closeQrModal) + $('pairingBtn').addEventListener('click', requestPairingCode) + $('detailsCloseBtn').addEventListener('click', () => $('detailsModal').classList.add('hidden')) + $('startModal').addEventListener('click', (event) => { + if (event.target === $('startModal')) { + closeStartModal() + } + }) + $('qrModal').addEventListener('click', (event) => { + if (event.target === $('qrModal')) { + closeQrModal() + } + }) + $('detailsModal').addEventListener('click', (event) => { + if (event.target === $('detailsModal')) { + $('detailsModal').classList.add('hidden') + } + }) + document.addEventListener('visibilitychange', () => { + if (!document.hidden) { + refreshNow() + } + }) + pollLoop() + } + + init() +})() diff --git a/public/dashboard/index.html b/public/dashboard/index.html new file mode 100644 index 0000000..3326a6f --- /dev/null +++ b/public/dashboard/index.html @@ -0,0 +1,103 @@ + + + + + + WWebJS API Dashboard + + + +
+

WWebJS API Dashboard

+
+ +
+ + +
+
+
+ + + +
+
+ +
+ + + +
+
+ +
+ + + + + + + + + +
SessionStatusActions
+ +
+
+ + + + + + + +
+ + + + diff --git a/public/dashboard/style.css b/public/dashboard/style.css new file mode 100644 index 0000000..b6c9f47 --- /dev/null +++ b/public/dashboard/style.css @@ -0,0 +1,396 @@ +:root { + --accent: #00a884; + --accent-dark: #008f72; + --danger: #dc3545; + --bg: #f0f2f5; + --surface: #ffffff; + --text: #111b21; + --muted: #667781; + --border: #e9edef; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + background: var(--bg); + color: var(--text); +} + +header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + padding: 14px 24px; + background: var(--accent); + color: #fff; +} + +header h1 { + font-size: 18px; + margin: 0; +} + +.header-actions { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.header-link { + color: #fff; + border: 1px solid rgba(255, 255, 255, 0.6); + border-radius: 6px; + padding: 7px 14px; + font-size: 14px; + text-decoration: none; +} + +.header-link:hover { + background: rgba(255, 255, 255, 0.12); +} + +.api-key-controls { + display: flex; + gap: 8px; +} + +.api-key-controls input { + border: none; + border-radius: 6px; + padding: 8px 10px; + min-width: 260px; +} + +.api-key-controls button { + background: #fff; + color: var(--accent-dark); + font-weight: 600; +} + +.banner { + background: #fff3cd; + color: #8a6d1a; + padding: 10px 24px; + font-size: 14px; +} + +main { + max-width: 960px; + margin: 24px auto; + padding: 0 16px; + display: grid; + gap: 16px; +} + +.toolbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.start-form { + display: flex; + gap: 8px; + width: 100%; +} + +.start-form input { + flex: 1; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 14px; +} + +.global-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.field-error { + color: var(--danger); + font-size: 13px; + margin: 0; +} + +button { + border: none; + border-radius: 6px; + padding: 8px 14px; + background: var(--accent); + color: #fff; + font-size: 14px; + cursor: pointer; +} + +button:hover { + background: var(--accent-dark); +} + +button:disabled { + opacity: 0.6; + cursor: default; +} + +button.secondary { + background: var(--surface); + color: var(--text); + border: 1px solid var(--border); +} + +button.secondary:hover { + background: var(--bg); +} + +button.secondary.danger { + color: var(--danger); +} + +table { + width: 100%; + border-collapse: collapse; + background: var(--surface); + border-radius: 10px; + overflow: hidden; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); +} + +th, +td { + text-align: left; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + font-size: 14px; +} + +thead th { + color: var(--muted); + text-transform: uppercase; + font-size: 12px; + letter-spacing: 0.04em; +} + +tbody tr:last-child td { + border-bottom: none; +} + +.session-id { + font-weight: 600; + overflow-wrap: anywhere; +} + +.actions-col, +td.actions-cell { + text-align: right; +} + +.actions { + display: flex; + gap: 6px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.badge { + display: inline-block; + padding: 3px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; +} + +.badge-connected { + background: #d9fdd3; + color: #0b6b48; +} + +.badge-pending { + background: #fff3cd; + color: #8a6d1a; +} + +.empty { + color: var(--muted); + text-align: center; + padding: 24px; + background: var(--surface); + border-radius: 10px; + margin: 0; +} + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(17, 27, 33, 0.55); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + z-index: 100; +} + +.modal { + background: var(--surface); + border-radius: 12px; + max-width: 420px; + width: 100%; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 14px 18px; + border-bottom: 1px solid var(--border); +} + +.modal-header h2 { + font-size: 16px; + margin: 0; + overflow-wrap: anywhere; +} + +.close-btn { + background: none; + color: var(--muted); + font-size: 22px; + line-height: 1; + padding: 0 4px; +} + +.close-btn:hover { + background: none; + color: var(--text); +} + +.modal-body { + padding: 18px; + display: grid; + gap: 12px; + justify-items: center; +} + +#qrImage { + width: 264px; + height: 264px; + image-rendering: pixelated; +} + +.qr-placeholder { + width: 264px; + height: 264px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg); + border-radius: 8px; + color: var(--muted); + font-size: 14px; +} + +.qr-success { + font-size: 20px; + color: #0b6b48; + font-weight: 700; + padding: 40px 0; +} + +.hint { + font-size: 12px; + color: var(--muted); + margin: 0; + text-align: center; +} + +details { + width: 100%; + font-size: 14px; +} + +summary { + cursor: pointer; + color: var(--accent-dark); +} + +.pairing-form { + display: flex; + gap: 8px; + margin-top: 10px; +} + +.pairing-form input { + flex: 1; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 14px; +} + +.pairing-code { + font-size: 26px; + font-weight: 700; + letter-spacing: 0.2em; + text-align: center; + margin-top: 12px; +} + +#detailsList { + width: 100%; + margin: 0; + display: grid; + grid-template-columns: auto 1fr; + gap: 6px 16px; + font-size: 14px; +} + +#detailsList dt { + color: var(--muted); +} + +#detailsList dd { + margin: 0; + font-weight: 600; + overflow-wrap: anywhere; +} + +#toasts { + position: fixed; + top: 16px; + left: 50%; + transform: translateX(-50%); + display: grid; + gap: 8px; + justify-items: center; + z-index: 200; +} + +.toast { + background: #111b21; + color: #fff; + padding: 10px 16px; + border-radius: 8px; + font-size: 14px; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25); + max-width: 340px; + overflow-wrap: anywhere; +} + +.toast-error { + background: var(--danger); +} + +.hidden { + display: none !important; +} diff --git a/src/config.js b/src/config.js index 625dc6c..2d0e73f 100644 --- a/src/config.js +++ b/src/config.js @@ -11,6 +11,7 @@ const maxAttachmentSize = parseInt(process.env.MAX_ATTACHMENT_SIZE) || 10000000 const setMessagesAsSeen = (process.env.SET_MESSAGES_AS_SEEN || '').toLowerCase() === 'true' const disabledCallbacks = process.env.DISABLED_CALLBACKS ? process.env.DISABLED_CALLBACKS.split('|') : [] const enableSwaggerEndpoint = (process.env.ENABLE_SWAGGER_ENDPOINT || '').toLowerCase() === 'true' +const enableWebUI = (process.env.ENABLE_WEB_UI || '').toLowerCase() === 'true' const webVersion = process.env.WEB_VERSION const webVersionCacheType = process.env.WEB_VERSION_CACHE_TYPE || 'none' const rateLimitMax = parseInt(process.env.RATE_LIMIT_MAX) || 1000 @@ -39,6 +40,7 @@ module.exports = { setMessagesAsSeen, disabledCallbacks, enableSwaggerEndpoint, + enableWebUI, webVersion, webVersionCacheType, rateLimitMax, diff --git a/src/routes.js b/src/routes.js index a0953d1..3438c63 100644 --- a/src/routes.js +++ b/src/routes.js @@ -1,8 +1,9 @@ const express = require('express') +const path = require('path') const routes = express.Router() const swaggerUi = require('swagger-ui-express') const swaggerDocument = require('../swagger.json') -const { enableLocalCallbackExample, enableSwaggerEndpoint } = require('./config') +const { enableLocalCallbackExample, enableSwaggerEndpoint, enableWebUI } = require('./config') const middleware = require('./middleware') const healthController = require('./controllers/healthController') @@ -262,4 +263,22 @@ if (enableSwaggerEndpoint) { routes.get('/api-docs', swaggerUi.setup(swaggerDocument) /* #swagger.ignore = true */) } +/** + * ================ + * WEB UI ENDPOINTS + * ================ + */ +if (enableWebUI) { + routes.get('/dashboard', (req, res, next) => { + // #swagger.ignore = true + // redirect to the trailing-slash URL so relative asset paths resolve correctly + const urlPath = req.originalUrl.split('?')[0] + if (!urlPath.endsWith('/')) { + return res.redirect(301, urlPath + '/') + } + next() + }) + routes.use('/dashboard', express.static(path.join(__dirname, '../public/dashboard'))) +} + module.exports = { routes } diff --git a/tests/dashboard.test.js b/tests/dashboard.test.js new file mode 100644 index 0000000..ae7c874 --- /dev/null +++ b/tests/dashboard.test.js @@ -0,0 +1,41 @@ +const request = require('supertest') + +// Mock your application's environment variables +process.env.API_KEY = 'test_api_key' +process.env.SESSIONS_PATH = './sessions_test' +process.env.ENABLE_WEB_UI = 'TRUE' + +const app = require('../src/app') +jest.mock('qrcode-terminal') + +describe('Web UI dashboard', () => { + it('should serve the dashboard page', async () => { + const response = await request(app).get('/dashboard/') + expect(response.status).toBe(200) + expect(response.headers['content-type']).toMatch(/html/) + expect(response.text).toContain('WWebJS API Dashboard') + }) + + it('should redirect /dashboard to /dashboard/', async () => { + const response = await request(app).get('/dashboard') + expect(response.status).toBe(301) + expect(response.headers.location).toBe('/dashboard/') + }) + + it('should serve the dashboard assets', async () => { + const response = await request(app).get('/dashboard/app.js') + expect(response.status).toBe(200) + expect(response.headers['content-type']).toMatch(/javascript/) + }) + + it('should not serve the dashboard when disabled', async () => { + process.env.ENABLE_WEB_UI = 'FALSE' + let disabledApp + jest.isolateModules(() => { + disabledApp = require('../src/app') + }) + const response = await request(disabledApp).get('/dashboard/') + expect(response.status).toBe(404) + process.env.ENABLE_WEB_UI = 'TRUE' + }) +})