From 3907fcb858ccd85ada9255d0eabefb5f41f9da50 Mon Sep 17 00:00:00 2001 From: Raúl Benencia Date: Thu, 4 Jun 2026 11:47:22 -0300 Subject: test: add frontend smoke js test --- .../expected-results/configs-static-default.txt | 2 + test/integ-test/expected-results/static.html | 2 + test/integ-test/frontend_smoke.js | 372 +++++++++++++++++++++ test/integ-test/integ_test.py | 22 +- 4 files changed, 396 insertions(+), 2 deletions(-) create mode 100644 test/integ-test/frontend_smoke.js diff --git a/test/integ-test/expected-results/configs-static-default.txt b/test/integ-test/expected-results/configs-static-default.txt index 0aa8329..a55a15c 100644 --- a/test/integ-test/expected-results/configs-static-default.txt +++ b/test/integ-test/expected-results/configs-static-default.txt @@ -1,3 +1,5 @@ + +
 bootstrap.sh
 rc.local-bootstrap
diff --git a/test/integ-test/expected-results/static.html b/test/integ-test/expected-results/static.html
index eff16d6..7cc2f18 100644
--- a/test/integ-test/expected-results/static.html
+++ b/test/integ-test/expected-results/static.html
@@ -1,3 +1,5 @@
+
+
 
 css/
 fonts/
diff --git a/test/integ-test/frontend_smoke.js b/test/integ-test/frontend_smoke.js
new file mode 100644
index 0000000..3810166
--- /dev/null
+++ b/test/integ-test/frontend_smoke.js
@@ -0,0 +1,372 @@
+#!/usr/bin/env node
+
+/*
+  Copyright 2018 ThousandEyes Inc.
+
+  Licensed under the Apache License, Version 2.0 (the "License");
+  you may not use this file except in compliance with the License.
+  You may obtain a copy of the License at
+
+  http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+*/
+
+'use strict';
+
+const fs = require('fs');
+const http = require('http');
+const os = require('os');
+const path = require('path');
+const { spawn } = require('child_process');
+
+const apiURL = process.argv[2] || 'http://localhost:18888';
+const chromiumBin = process.env.CHROMIUM_BIN || 'chromium';
+const smokeMac = '02:00:00:00:00:42';
+const smokeMacPath = smokeMac.replace(/:/g, '-');
+
+class CDPClient {
+    constructor(webSocketURL) {
+        this.nextID = 1;
+        this.pending = new Map();
+        this.handlers = new Map();
+        this.ws = new WebSocket(webSocketURL);
+
+        this.opened = new Promise((resolve, reject) => {
+            this.ws.addEventListener('open', resolve, { once: true });
+            this.ws.addEventListener('error', reject, { once: true });
+        });
+
+        this.ws.addEventListener('message', (event) => {
+            const message = JSON.parse(event.data);
+            if (message.id && this.pending.has(message.id)) {
+                const pending = this.pending.get(message.id);
+                this.pending.delete(message.id);
+                if (message.error) {
+                    pending.reject(new Error(message.error.message));
+                } else {
+                    pending.resolve(message.result || {});
+                }
+                return;
+            }
+
+            const handlers = this.handlers.get(message.method) || [];
+            handlers.forEach((handler) => handler(message));
+        });
+    }
+
+    async send(method, params, sessionID) {
+        await this.opened;
+
+        const id = this.nextID++;
+        const message = {
+            id: id,
+            method: method,
+            params: params || {},
+        };
+
+        if (sessionID) {
+            message.sessionId = sessionID;
+        }
+
+        return new Promise((resolve, reject) => {
+            this.pending.set(id, { resolve: resolve, reject: reject });
+            this.ws.send(JSON.stringify(message));
+        });
+    }
+
+    on(method, handler) {
+        if (!this.handlers.has(method)) {
+            this.handlers.set(method, []);
+        }
+        this.handlers.get(method).push(handler);
+    }
+
+    close() {
+        this.ws.close();
+    }
+}
+
+function requestURL(url) {
+    return new Promise((resolve, reject) => {
+        http.get(url, (response) => {
+            let body = '';
+            response.setEncoding('utf8');
+            response.on('data', (chunk) => {
+                body += chunk;
+            });
+            response.on('end', () => {
+                if (response.statusCode < 200 || response.statusCode >= 300) {
+                    reject(new Error('GET ' + url + ' failed with ' + response.statusCode));
+                    return;
+                }
+                resolve(body);
+            });
+        }).on('error', reject);
+    });
+}
+
+async function requestJSON(url) {
+    return JSON.parse(await requestURL(url));
+}
+
+async function waitFor(description, check, timeoutMS = 5000) {
+    const deadline = Date.now() + timeoutMS;
+
+    while (Date.now() < deadline) {
+        if (await check()) {
+            return;
+        }
+        await new Promise((resolve) => setTimeout(resolve, 100));
+    }
+
+    throw new Error('Timed out waiting for ' + description);
+}
+
+async function waitForDevToolsURL(chromium) {
+    let stderr = '';
+
+    return new Promise((resolve, reject) => {
+        const timeout = setTimeout(() => {
+            reject(new Error('Timed out waiting for Chromium DevTools URL. stderr: ' + stderr));
+        }, 10000);
+
+        chromium.stderr.on('data', (chunk) => {
+            stderr += chunk.toString();
+            const match = stderr.match(/DevTools listening on (ws:\/\/[^\s]+)/);
+            if (match) {
+                clearTimeout(timeout);
+                resolve(match[1]);
+            }
+        });
+
+        chromium.once('exit', (code) => {
+            clearTimeout(timeout);
+            reject(new Error('Chromium exited before DevTools was ready with code ' + code + '. stderr: ' + stderr));
+        });
+    });
+}
+
+function stopProcess(child) {
+    return new Promise((resolve) => {
+        if (child.exitCode !== null || child.signalCode !== null) {
+            resolve();
+            return;
+        }
+
+        const timeout = setTimeout(() => {
+            child.kill('SIGKILL');
+        }, 5000);
+
+        child.once('exit', () => {
+            clearTimeout(timeout);
+            resolve();
+        });
+
+        child.kill('SIGTERM');
+    });
+}
+
+function formatExceptionDetails(details) {
+    const parts = [details.text || 'Unhandled browser exception'];
+
+    if (details.exception) {
+        parts.push(details.exception.description || details.exception.value || '');
+    }
+
+    if (details.stackTrace && details.stackTrace.callFrames) {
+        details.stackTrace.callFrames.forEach((frame) => {
+            parts.push(frame.url + ':' + frame.lineNumber + ':' + frame.columnNumber);
+        });
+    }
+
+    return parts.filter(Boolean).join(' ');
+}
+
+async function evaluate(cdp, sessionID, expression) {
+    const result = await cdp.send('Runtime.evaluate', {
+        expression: expression,
+        awaitPromise: true,
+        returnByValue: true,
+    }, sessionID);
+
+    if (result.exceptionDetails) {
+        const exception = result.exceptionDetails.exception;
+        throw new Error(
+            (exception && (exception.description || exception.value)) ||
+            result.exceptionDetails.text ||
+            'Runtime evaluation failed'
+        );
+    }
+
+    return result.result.value;
+}
+
+async function waitForPage(cdp, sessionID, expression, description) {
+    await waitFor(description, async () => {
+        return Boolean(await evaluate(cdp, sessionID, expression));
+    });
+}
+
+async function run() {
+    const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shoelaces-chromium-'));
+    const chromium = spawn(chromiumBin, [
+        '--headless=new',
+        '--disable-gpu',
+        '--disable-dev-shm-usage',
+        '--disable-breakpad',
+        '--disable-component-extensions-with-background-pages',
+        '--disable-crash-reporter',
+        '--disable-crashpad',
+        '--disable-extensions',
+        '--no-first-run',
+        '--no-default-browser-check',
+        '--no-crash-upload',
+        '--no-sandbox',
+        '--remote-debugging-port=0',
+        '--user-data-dir=' + userDataDir,
+        'about:blank',
+    ]);
+
+    let cdp;
+
+    try {
+        const browserWS = await waitForDevToolsURL(chromium);
+        cdp = new CDPClient(browserWS);
+        await cdp.opened;
+
+        const target = await cdp.send('Target.createTarget', { url: 'about:blank' });
+        const attached = await cdp.send('Target.attachToTarget', {
+            targetId: target.targetId,
+            flatten: true,
+        });
+        const sessionID = attached.sessionId;
+        const pageErrors = [];
+
+        cdp.on('Runtime.exceptionThrown', (message) => {
+            if (message.sessionId === sessionID) {
+                pageErrors.push(formatExceptionDetails(message.params.exceptionDetails));
+            }
+        });
+        cdp.on('Runtime.consoleAPICalled', (message) => {
+            if (message.sessionId === sessionID && message.params.type === 'error') {
+                pageErrors.push(message.params.args.map((arg) => arg.value || arg.description).join(' '));
+            }
+        });
+
+        await cdp.send('Runtime.enable', {}, sessionID);
+        await cdp.send('Page.enable', {}, sessionID);
+
+        await requestURL(apiURL + '/poll/1/' + smokeMacPath + '?host=frontend-smoke');
+        await cdp.send('Page.navigate', { url: apiURL + '/' }, sessionID);
+
+        await waitForPage(cdp, sessionID, 'document.readyState === "complete"', 'home page load');
+        await waitForPage(
+            cdp,
+            sessionID,
+            'Boolean(document.querySelector("#mac option[value=\\"' + smokeMac + '\\"]"))',
+            'unknown server option'
+        );
+
+        const homeState = await evaluate(cdp, sessionID, `(() => {
+            const systems = document.getElementById('systems');
+            const loading = document.getElementById('loading');
+            return {
+                systemsDisplay: systems ? getComputedStyle(systems).display : null,
+                loadingDisplay: loading ? getComputedStyle(loading).display : null,
+                macText: document.querySelector('#mac option[value="${smokeMac}"]')?.textContent || '',
+            };
+        })()`);
+
+        if (homeState.systemsDisplay === 'none') {
+            throw new Error('Server form did not become visible');
+        }
+        if (homeState.loadingDisplay !== 'none') {
+            throw new Error('Loading panel did not become hidden');
+        }
+        if (!homeState.macText.includes('frontend-smoke')) {
+            throw new Error('Unknown server option did not include hostname: ' + homeState.macText);
+        }
+
+        await evaluate(cdp, sessionID, `(() => {
+            const mac = document.getElementById('mac');
+            const target = document.getElementById('target');
+            mac.value = '${smokeMac}';
+            target.value = 'coreos.ipxe';
+            target.dispatchEvent(new Event('change', { bubbles: true }));
+            return true;
+        })()`);
+
+        await waitForPage(
+            cdp,
+            sessionID,
+            'document.querySelectorAll(".params-container input[type=text]").length >= 2',
+            'template parameter inputs'
+        );
+
+        const paramNames = await evaluate(cdp, sessionID, `Array.from(
+            document.querySelectorAll('.params-container input[type=text]'),
+            (input) => input.name
+        ).sort()`);
+
+        if (JSON.stringify(paramNames) !== JSON.stringify(['cloudconfig', 'version'])) {
+            throw new Error('Unexpected parameter inputs: ' + JSON.stringify(paramNames));
+        }
+
+        const submitState = await evaluate(cdp, sessionID, `(async () => {
+            const form = document.getElementById('systems');
+            document.querySelector('input[name="cloudconfig"]').value = 'virtual';
+            document.querySelector('input[name="version"]').value = '777.0';
+            const response = await fetch(form.action, {
+                method: form.method,
+                body: new URLSearchParams(new FormData(form)),
+            });
+            return {
+                ok: response.ok,
+                status: response.status,
+            };
+        })()`);
+
+        if (!submitState.ok) {
+            throw new Error('Manual selection form POST failed with ' + submitState.status);
+        }
+
+        await waitFor('manual selection to leave ajax server list', async () => {
+            const servers = await requestJSON(apiURL + '/ajax/servers');
+            return !servers.some((server) => server.Mac === smokeMac);
+        });
+
+        await cdp.send('Page.navigate', { url: apiURL + '/events' }, sessionID);
+        await waitForPage(cdp, sessionID, 'document.readyState === "complete"', 'events page load');
+        await waitForPage(
+            cdp,
+            sessionID,
+            'document.querySelector(".event-log .card") && document.body.textContent.includes("' + smokeMac + '")',
+            'event history card'
+        );
+
+        const eventText = await evaluate(cdp, sessionID, 'document.querySelector(".event-log").textContent');
+        if (!eventText.includes('A user selected coreos.ipxe')) {
+            throw new Error('Event history did not render user selection: ' + eventText);
+        }
+
+        if (pageErrors.length > 0) {
+            throw new Error('Browser console errors: ' + pageErrors.join('; '));
+        }
+    } finally {
+        if (cdp) {
+            cdp.close();
+        }
+        await stopProcess(chromium);
+        fs.rmSync(userDataDir, { recursive: true, force: true });
+    }
+}
+
+run().catch((error) => {
+    console.error(error.stack || error.message);
+    process.exit(1);
+});
diff --git a/test/integ-test/integ_test.py b/test/integ-test/integ_test.py
index fe3899e..ca1c90e 100755
--- a/test/integ-test/integ_test.py
+++ b/test/integ-test/integ_test.py
@@ -18,6 +18,7 @@
 
 import os
 import signal
+import shutil
 import subprocess
 import sys
 import time
@@ -79,8 +80,8 @@ def shoelaces_instance(config_file):
     sys.stderr.write("\nDone\n")
 
 
-def test_shoelaces_startup(shoelaces_instance):
-    """ Test API liveness """
+def wait_for_shoelaces():
+    """Wait until the integration server is accepting requests."""
     attempts = 0
     while True:
         try:
@@ -96,12 +97,29 @@ def test_shoelaces_startup(shoelaces_instance):
             time.sleep(1)
 
 
+def test_shoelaces_startup(shoelaces_instance):
+    """ Test API liveness """
+    wait_for_shoelaces()
+
+
 @pytest.mark.parametrize(("path"), [("/"), ("/events"), ("/mappings")])
 def test_response_success(shoelaces_instance, path):
     r = requests.get("{}{}".format(API_URL, path))
     r.raise_for_status()
 
 
+def test_frontend_browser_smoke(shoelaces_instance):
+    """Exercise local.js in a real browser against the integration server."""
+    if shutil.which("node") is None:
+        pytest.skip("node is required for the frontend smoke test")
+    if shutil.which("chromium") is None:
+        pytest.skip("chromium is required for the frontend smoke test")
+
+    wait_for_shoelaces()
+    script = os.path.join(TEST_DIR, "frontend_smoke.js")
+    subprocess.check_call(["node", script, API_URL], cwd=BASE_DIR)
+
+
 REQUEST_RESPONSE_PAIRS = [("/static/", "static.html"),
                           ("/configs/static/", "configs-static-default.txt"),
                           ("/configs/static/rc.local-bootstrap",
-- 
cgit v1.2.3