Jelajahi Sumber

Update agi unit test module

Toby Chui 2 minggu lalu
induk
melakukan
64a54065bf

+ 52 - 7
src/mod/agi/agi.go

@@ -3,6 +3,7 @@ package agi
 import (
 	"encoding/json"
 	"errors"
+	"fmt"
 	"io"
 	"log"
 	"net/http"
@@ -283,6 +284,9 @@ w / r : Web request and response writer
 thisuser: userObject
 */
 func (g *Gateway) ExecuteAGIScript(scriptContent string, fsh *filesystem.FileSystemHandler, scriptFile string, scriptScope string, w http.ResponseWriter, r *http.Request, thisuser *user.User) {
+	// Check if developer debug mode is requested via URL query param (set AGI_DEV=true in ao_module)
+	devMode := r.URL.Query().Get("agi_devmode") == "true"
+
 	//Create a new vm for this request
 	vm := otto.New()
 	//Inject standard libs into the vm
@@ -315,8 +319,33 @@ func (g *Gateway) ExecuteAGIScript(scriptContent string, fsh *filesystem.FileSys
 
 	_, err := vm.Run(scriptContent)
 	if err != nil {
-		scriptpath, _ := filepath.Abs(scriptFile)
-		g.RenderErrorTemplate(w, err.Error(), scriptpath)
+		username := ""
+		if thisuser != nil {
+			username = thisuser.Username
+		}
+		log.Printf("[AGI] Script error in %s (user: %s): %s", scriptFile, username, err.Error())
+
+		if devMode {
+			// Return a detailed JSON error payload for developer inspection
+			errMsg := err.Error()
+			stackTrace := errMsg
+			if ottoErr, ok := err.(*otto.Error); ok {
+				stackTrace = ottoErr.String()
+			}
+			errPayload, _ := json.Marshal(map[string]interface{}{
+				"error":      true,
+				"message":    errMsg,
+				"stacktrace": stackTrace,
+				"script":     scriptFile,
+				"user":       username,
+			})
+			w.Header().Set("Content-Type", "application/json")
+			w.WriteHeader(http.StatusInternalServerError)
+			w.Write(errPayload)
+		} else {
+			scriptpath, _ := filepath.Abs(scriptFile)
+			g.RenderErrorTemplate(w, err.Error(), scriptpath)
+		}
 		return
 	}
 
@@ -364,17 +393,32 @@ func (g *Gateway) ExecuteAGIScriptAsUser(fsh *filesystem.FileSystemHandler, scri
 	defer func() {
 		if caught := recover(); caught != nil {
 			if caught == errTimeout {
-				log.Println("[AGI] Execution timeout: " + scriptFile)
+				log.Printf("[AGI] Execution timeout: %s (user: %s)", scriptFile, targetUser.Username)
 				return
 			} else if caught == errExitcall {
 				//Exit gracefully
-
 				return
 			} else {
 				//Something screwed. Return Internal Server Error
-				w.WriteHeader(http.StatusInternalServerError)
-				w.Write([]byte("500 - ECMA VM crashed due to unknown reason"))
-				//panic(caught)
+				log.Printf("[AGI] VM crash in %s (user: %s): %v", scriptFile, targetUser.Username, caught)
+				if w != nil {
+					devMode := r != nil && r.URL.Query().Get("agi_devmode") == "true"
+					if devMode {
+						errPayload, _ := json.Marshal(map[string]interface{}{
+							"error":      true,
+							"message":    fmt.Sprintf("VM crash: %v", caught),
+							"stacktrace": fmt.Sprintf("VM crash: %v", caught),
+							"script":     scriptFile,
+							"user":       targetUser.Username,
+						})
+						w.Header().Set("Content-Type", "application/json")
+						w.WriteHeader(http.StatusInternalServerError)
+						w.Write(errPayload)
+					} else {
+						w.WriteHeader(http.StatusInternalServerError)
+						w.Write([]byte("500 - ECMA VM crashed due to unknown reason"))
+					}
+				}
 			}
 		}
 	}()
@@ -402,6 +446,7 @@ func (g *Gateway) ExecuteAGIScriptAsUser(fsh *filesystem.FileSystemHandler, scri
 
 	_, err = vm.Run(scriptContent)
 	if err != nil {
+		log.Printf("[AGI] Script error in %s (user: %s): %s", scriptFile, targetUser.Username, err.Error())
 		return execID, "", err
 	}
 

+ 3 - 3
src/mod/agi/error.go

@@ -39,13 +39,13 @@ func (g *Gateway) RenderErrorTemplate(w http.ResponseWriter, errmsg string, scri
 		"agi_version":     AgiVersion,
 	}
 
+	// Set status before writing the body so the header is actually sent as 500
+	w.WriteHeader(http.StatusInternalServerError)
+
 	// Execute the template
 	err = tmpl.Execute(w, data)
 	if err != nil {
 		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
 		return
 	}
-
-	// Set the HTTP status code
-	w.WriteHeader(http.StatusInternalServerError)
 }

+ 336 - 50
src/web/UnitTest/backend.html

@@ -4,75 +4,361 @@
         <meta name="apple-mobile-web-app-capable" content="yes" />
         <meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"/>
         <meta charset="UTF-8">
-        <meta name="theme-color" content="#4b75ff">
-        <link rel="stylesheet" href="../script/semantic/semantic.min.css">
+        <meta name="theme-color" content="#0078d4">
         <script src="../script/jquery.min.js"></script>
         <script src="../script/ao_module.js"></script>
-        <script src="../script/semantic/semantic.min.js"></script>
         <title>ao_backend Test</title>
+        <style>
+            *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+            body {
+                font-family: 'Segoe UI Variable', 'Segoe UI', system-ui, -apple-system, sans-serif;
+                font-size: 14px;
+                background: #f3f3f3;
+                color: #201f1e;
+                min-height: 100vh;
+            }
+
+            /* ── Nav ───────────────────────────────────────────────── */
+            .nav-bar { position: sticky; top: 0; z-index: 100; height: 48px; background: rgba(243,243,243,0.9); backdrop-filter: blur(20px) saturate(180%); border-bottom: 1px solid rgba(0,0,0,0.08); display: flex; align-items: center; padding: 0 20px; gap: 4px; }
+            .nav-brand { font-size: 13px; font-weight: 600; color: #201f1e; margin-right: 12px; white-space: nowrap; }
+            .nav-tab { display: inline-flex; align-items: center; height: 48px; padding: 0 12px; font-size: 13px; color: #605e5c; text-decoration: none; border-bottom: 2px solid transparent; transition: color 0.1s, border-color 0.1s; white-space: nowrap; }
+            .nav-tab:hover { color: #201f1e; }
+            .nav-tab.active { color: #0078d4; border-bottom-color: #0078d4; font-weight: 600; }
+
+            /* ── Layout ────────────────────────────────────────────── */
+            .content { max-width: 880px; margin: 24px auto; padding: 0 20px; }
+
+            /* ── Toolbar ───────────────────────────────────────────── */
+            .toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; flex-wrap: wrap; }
+            .toolbar-sep { width: 1px; height: 20px; background: #d1d1d1; margin: 0 4px; }
+            .summary-text { font-size: 12px; color: #797775; }
+
+            .btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 16px; border-radius: 4px; font-family: inherit; font-size: 13px; cursor: pointer; border: 1px solid transparent; transition: background 0.1s; line-height: 1; white-space: nowrap; }
+            .btn-primary { background: #0078d4; color: #fff; border-color: #0078d4; }
+            .btn-primary:hover { background: #106ebe; }
+            .btn-primary:disabled { opacity: 0.55; cursor: default; }
+            .btn-secondary { background: #fff; color: #201f1e; border-color: #d1d1d1; }
+            .btn-secondary:hover { background: #f5f5f5; }
+
+            /* ── Stats pills ───────────────────────────────────────── */
+            .stats-bar { display: none; align-items: center; gap: 8px; margin-bottom: 14px; flex-wrap: wrap; }
+            .stat-pill { display: inline-flex; align-items: center; gap: 5px; padding: 4px 12px; border-radius: 20px; font-size: 12px; font-weight: 600; background: #fff; border: 1px solid #e0e0e0; }
+            .stat-pill.all    { color: #201f1e; }
+            .stat-pill.passed { background: #dff6dd; border-color: #bad7b9; color: #107c10; }
+            .stat-pill.failed { background: #fde7e9; border-color: #f1b8bb; color: #c50f1f; }
+            .stat-pill.pending{ color: #797775; }
+
+            /* ── Test list ─────────────────────────────────────────── */
+            .test-list { background: #fff; border: 1px solid #e0e0e0; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.05); margin-bottom: 20px; }
+
+            .section-header {
+                padding: 8px 16px 8px 16px;
+                font-size: 11px;
+                font-weight: 700;
+                color: #fff;
+                background: #0078d4;
+                letter-spacing: 0.04em;
+                text-transform: uppercase;
+            }
+            .section-header.appdata { background: #8764b8; }
+            .section-header.file    { background: #107c10; }
+            .section-header.http    { background: #ca5010; }
+
+            .test-list-header { padding: 8px 16px; font-size: 11px; font-weight: 600; color: #797775; text-transform: uppercase; letter-spacing: 0.04em; border-bottom: 1px solid #f0f0f0; display: flex; align-items: center; gap: 12px; }
+            .col-name   { flex: 1; }
+            .col-status { width: 82px; text-align: center; flex-shrink: 0; }
+            .col-action { width: 52px; flex-shrink: 0; text-align: right; }
+
+            .test-item { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-bottom: 1px solid #f5f5f5; transition: background 0.1s; }
+            .test-item:last-child { border-bottom: none; }
+            .test-item:hover { background: #fafafa; }
+
+            .test-idx { font-size: 11px; color: #bbb; width: 22px; text-align: right; flex-shrink: 0; font-variant-numeric: tabular-nums; }
+            .test-info { flex: 1; min-width: 0; }
+            .test-name { font-size: 13px; font-weight: 500; }
+            .test-desc { font-size: 11px; color: #a0a0a0; margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: 'Cascadia Code', 'Consolas', monospace; }
+
+            .status-badge { display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 12px; font-size: 11px; font-weight: 600; width: 82px; justify-content: center; flex-shrink: 0; }
+            .status-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
+            .s-pending  { background: #f3f3f3; color: #797775; }
+            .s-pending  .status-dot { background: #c0c0c0; }
+            .s-running  { background: #eff6fc; color: #0078d4; }
+            .s-running  .status-dot { background: #0078d4; animation: pulse 1s ease-in-out infinite; }
+            .s-pass     { background: #dff6dd; color: #107c10; }
+            .s-pass     .status-dot { background: #107c10; }
+            .s-fail     { background: #fde7e9; color: #c50f1f; }
+            .s-fail     .status-dot { background: #c50f1f; }
+            @keyframes pulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:.4;transform:scale(.7)} }
+
+            .btn-run { padding: 4px 10px; font-size: 12px; border-radius: 3px; background: transparent; border: 1px solid #d1d1d1; color: #444; cursor: pointer; font-family: inherit; transition: background 0.1s; }
+            .btn-run:hover { background: #f0f0f0; }
+            .btn-run:disabled { opacity: 0.45; cursor: default; }
+
+            /* ── Result area ───────────────────────────────────────── */
+            .result-area { display: none; padding: 10px 16px 12px 50px; font-family: 'Cascadia Code', 'Consolas', monospace; font-size: 12px; line-height: 1.5; background: #fafafa; border-top: 1px solid #f0f0f0; border-left: 3px solid transparent; word-break: break-all; color: #323130; }
+            .result-area.r-pass { border-left-color: #107c10; }
+            .result-area.r-fail { border-left-color: #c50f1f; }
+        </style>
     </head>
     <body>
-        <br><br>
-        <div class="ui container">
-           <h2>ao_backend Testing Interface</h2>
-           <p>See console for more information</p>
+
+        <nav class="nav-bar">
+            <span class="nav-brand">Unit Tester</span>
+            <a class="nav-tab" href="index.html">AGI Scripts</a>
+            <a class="nav-tab active" href="backend.html">ao_backend</a>
+            <a class="nav-tab" href="wstest.html">WebSocket</a>
+            <a class="nav-tab" href="devmode.html">Dev Mode</a>
+        </nav>
+
+        <div class="content">
+
+            <div class="toolbar">
+                <button class="btn btn-primary" id="btnRunAll" onclick="runAll()">&#9654;&nbsp; Run All</button>
+                <button class="btn btn-secondary" onclick="clearAll()">Clear</button>
+                <div class="toolbar-sep"></div>
+                <span class="summary-text" id="summaryText">12 tests</span>
+            </div>
+
+            <div class="stats-bar" id="statsBar">
+                <span class="stat-pill all"><span id="statTotal">12</span> total</span>
+                <span class="stat-pill passed">&#10003;&nbsp;<span id="statPass">0</span> passed</span>
+                <span class="stat-pill failed">&#10007;&nbsp;<span id="statFail">0</span> failed</span>
+                <span class="stat-pill pending">&middot;&nbsp;<span id="statPending">12</span> pending</span>
+            </div>
+
+            <div id="testContainer"></div>
+
         </div>
-        
-         <!-- <script src="ao_backend.js"></script> -->
-        <script>
-            //Register the backend wrapper path
-            var backendWrapper = ao_module_backend();
-            backendWrapper.start("UnitTest/ao_backend.js");
 
-            //Test appdata
-            backendWrapper.appdata.readFile("UnitTest/appdata.txt", function(content){
-                console.log(content);
-            });
+        <script>
+            /* ── Test definitions ──────────────────────────────────── */
+            const GROUPS = [
+                {
+                    label: "App Data",
+                    cls: "appdata",
+                    tests: [
+                        { name: "appdata.readFile",
+                          desc: 'appdata.readFile("UnitTest/appdata.txt")',
+                          run: function(w, ok, fail) {
+                              w.appdata.readFile("UnitTest/appdata.txt", function(c) { ok(c); });
+                          }
+                        },
+                        { name: "appdata.listDir",
+                          desc: 'appdata.listDir("UnitTest/")',
+                          run: function(w, ok, fail) {
+                              w.appdata.listDir("UnitTest/", function(list) { ok(JSON.stringify(list)); });
+                          }
+                        }
+                    ]
+                },
+                {
+                    label: "File System",
+                    cls: "file",
+                    tests: [
+                        { name: "file.writeFile",
+                          desc: 'file.writeFile("user:/Desktop/hello.txt", "Hello World!")',
+                          run: function(w, ok, fail) {
+                              w.file.writeFile("user:/Desktop/hello.txt", "Hello World!", function(r) { ok(r); });
+                          }
+                        },
+                        { name: "file.readFile",
+                          desc: 'file.readFile("user:/Desktop/hello.txt")',
+                          run: function(w, ok, fail) {
+                              w.file.readFile("user:/Desktop/hello.txt", function(c) { ok(c); });
+                          }
+                        },
+                        { name: "file.readdir",
+                          desc: 'file.readdir("user:/Desktop/")',
+                          run: function(w, ok, fail) {
+                              w.file.readdir("user:/Desktop/", function(files) { ok(JSON.stringify(files)); });
+                          }
+                        },
+                        { name: "file.mtime",
+                          desc: 'file.mtime("user:/Desktop/hello.txt")',
+                          run: function(w, ok, fail) {
+                              w.file.mtime("user:/Desktop/hello.txt", function(t) { ok(t); });
+                          }
+                        },
+                        { name: "file.isDir",
+                          desc: 'file.isDir("user:/Desktop/hello.txt")',
+                          run: function(w, ok, fail) {
+                              w.file.isDir("user:/Desktop/hello.txt", function(r) { ok(String(r)); });
+                          }
+                        },
+                        { name: "file.filesize",
+                          desc: 'file.filesize("user:/Desktop/hello.txt")',
+                          run: function(w, ok, fail) {
+                              w.file.filesize("user:/Desktop/hello.txt", function(s) { ok(s + " bytes"); });
+                          }
+                        },
+                        { name: "file.aglob",
+                          desc: 'file.aglob("user:/Desktop/*.mp3")',
+                          run: function(w, ok, fail) {
+                              w.file.aglob("user:/Desktop/*.mp3", undefined, function(r) { ok(JSON.stringify(r)); });
+                          }
+                        }
+                    ]
+                },
+                {
+                    label: "HTTP",
+                    cls: "http",
+                    tests: [
+                        { name: "http.get",
+                          desc: 'http.get("https://www.google.com/robots.txt")',
+                          run: function(w, ok, fail) {
+                              w.http.get("https://www.google.com/robots.txt", function(r) {
+                                  ok(r ? r.slice(0, 120) + (r.length > 120 ? "…" : "") : "(empty)");
+                              });
+                          }
+                        },
+                        { name: "http.post",
+                          desc: 'http.post("https://www.google.com/robots.txt", undefined)',
+                          run: function(w, ok, fail) {
+                              w.http.post("https://www.google.com/robots.txt", undefined, function(r) {
+                                  ok(r ? r.slice(0, 120) + (r.length > 120 ? "…" : "") : "(empty)");
+                              });
+                          }
+                        }
+                    ]
+                }
+            ];
 
-            backendWrapper.appdata.listDir("UnitTest/", function(list){
-                console.log(list);
+            /* ── Flatten for stats ─────────────────────────────────── */
+            var allTests = [];
+            GROUPS.forEach(function(g) {
+                g.tests.forEach(function(t) {
+                    t.state = "pending";
+                    t.id = allTests.length;
+                    allTests.push(t);
+                });
             });
 
-            //Test File operation
-            backendWrapper.file.readdir("user:/Desktop/", function(files){
-                console.log(files);
-            });
+            /* ── Backend wrapper (init once) ───────────────────────── */
+            var backendWrapper = ao_module_backend();
+            backendWrapper.start("UnitTest/ao_backend.js");
 
-            //Try file read write
-            backendWrapper.file.writeFile("user:/Desktop/hello.txt", "Hello World!",function(data){
-                console.log("Write File: ", data);
-            });
+            /* ── Build UI ──────────────────────────────────────────── */
+            (function buildUI() {
+                let container = document.getElementById("testContainer");
+                GROUPS.forEach(function(g) {
+                    let html = '<div class="test-list">' +
+                        '<div class="section-header ' + g.cls + '">' + g.label + '</div>' +
+                        '<div class="test-list-header">' +
+                            '<span style="width:22px;flex-shrink:0"></span>' +
+                            '<span class="col-name">Test</span>' +
+                            '<span class="col-status">Status</span>' +
+                            '<span class="col-action"></span>' +
+                        '</div>';
+                    g.tests.forEach(function(t) {
+                        html += '<div class="test-item" id="item_' + t.id + '">' +
+                            '<span class="test-idx">' + (t.id + 1) + '</span>' +
+                            '<div class="test-info">' +
+                                '<div class="test-name">' + escHtml(t.name) + '</div>' +
+                                '<div class="test-desc">' + escHtml(t.desc) + '</div>' +
+                            '</div>' +
+                            '<span class="status-badge s-pending" id="badge_' + t.id + '">' +
+                                '<span class="status-dot"></span>Pending' +
+                            '</span>' +
+                            '<div class="col-action">' +
+                                '<button class="btn-run" id="runbtn_' + t.id + '" onclick="runTest(' + t.id + ')">Run</button>' +
+                            '</div>' +
+                        '</div>' +
+                        '<div class="result-area" id="result_' + t.id + '"></div>';
+                    });
+                    html += '</div>';
+                    container.innerHTML += html;
+                });
+                updateStats();
+                document.getElementById("statsBar").style.display = "flex";
+            })();
 
-            backendWrapper.file.readFile("user:/Desktop/hello.txt",function(content){
-                console.log("Read File: " + content);
-            });
+            /* ── Run logic ─────────────────────────────────────────── */
+            function runTest(id) {
+                let t = allTests[id];
+                let btn = document.getElementById("runbtn_" + id);
+                setStatus(id, "running");
+                if (btn) btn.disabled = true;
 
-            backendWrapper.file.mtime("user:/Desktop/hello.txt",function(data){
-                console.log("Test file mtime", data);
-            });
+                let timer = setTimeout(function() {
+                    setStatus(id, "fail");
+                    showResult(id, "r-fail", "Timeout — no response after 10 s");
+                    if (btn) btn.disabled = false;
+                }, 10000);
 
-            backendWrapper.file.isDir("user:/Desktop/hello.txt",function(data){
-                console.log("Test file is Dir", data);
-            });
+                t.run(backendWrapper,
+                    function(result) {           // ok
+                        clearTimeout(timer);
+                        setStatus(id, "pass");
+                        showResult(id, "r-pass", escHtml(String(result || "(empty)")));
+                        if (btn) btn.disabled = false;
+                    },
+                    function(reason) {           // fail
+                        clearTimeout(timer);
+                        setStatus(id, "fail");
+                        showResult(id, "r-fail", escHtml(String(reason || "Unknown error")));
+                        if (btn) btn.disabled = false;
+                    }
+                );
+            }
 
-            backendWrapper.file.filesize("user:/Desktop/hello.txt",function(data){
-                console.log("Test file size", data, " bytes");
-            });
+            function runAll() {
+                let btn = document.getElementById("btnRunAll");
+                btn.disabled = true;
+                btn.textContent = "Running…";
+                allTests.forEach(function(t) { runTest(t.id); });
+                let check = setInterval(function() {
+                    if (!allTests.some(function(t) { return t.state === "running"; })) {
+                        clearInterval(check);
+                        btn.disabled = false;
+                        btn.innerHTML = "&#9654;&nbsp; Run All";
+                    }
+                }, 300);
+            }
 
-            backendWrapper.file.aglob("user:/Desktop/*.mp3",undefined, function(data){
-                console.log("mp3 on desktop: ", data);
-            });
+            function clearAll() {
+                allTests.forEach(function(t) {
+                    t.state = "pending";
+                    setStatus(t.id, "pending");
+                    let r = document.getElementById("result_" + t.id);
+                    if (r) { r.style.display = "none"; r.innerHTML = ""; r.className = "result-area"; }
+                });
+                updateStats();
+            }
 
-            //http test
-            backendWrapper.http.get("https://www.google.com/robots.txt", function(data){
-                console.log("HTTP GET test: ", data);
-            })
+            /* ── Helpers ───────────────────────────────────────────── */
+            function setStatus(id, state) {
+                allTests[id].state = state;
+                let badge = document.getElementById("badge_" + id);
+                if (!badge) return;
+                let labels = { pending:"Pending", running:"Running", pass:"Passed", fail:"Failed" };
+                badge.className = "status-badge s-" + state;
+                badge.innerHTML = '<span class="status-dot"></span>' + labels[state];
+                updateStats();
+            }
 
-            backendWrapper.http.post("https://www.google.com/robots.txt", undefined, function(data){
-                console.log("HTTP POST test: ", data);
-            })
+            function showResult(id, cls, html) {
+                let div = document.getElementById("result_" + id);
+                if (!div) return;
+                div.className = "result-area " + cls;
+                div.innerHTML = html;
+                div.style.display = "block";
+            }
 
+            function updateStats() {
+                let total   = allTests.length;
+                let passed  = allTests.filter(function(t) { return t.state === "pass"; }).length;
+                let failed  = allTests.filter(function(t) { return t.state === "fail"; }).length;
+                let pending = allTests.filter(function(t) { return t.state !== "pass" && t.state !== "fail"; }).length;
+                document.getElementById("statTotal").textContent   = total;
+                document.getElementById("statPass").textContent    = passed;
+                document.getElementById("statFail").textContent    = failed;
+                document.getElementById("statPending").textContent = pending;
+            }
 
+            function escHtml(s) {
+                return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
+            }
         </script>
     </body>
-</html>
+</html>

+ 29 - 0
src/web/UnitTest/backend/filelib.musicglob.js

@@ -0,0 +1,29 @@
+// filelib.musicglob.js
+//
+// For each mounted storage root (from LOADED_STORAGES):
+//   - glob:  direct children of Music/ (shallow, pattern: Music/*)
+//   - aglob: all descendants of Music/ (deep, pattern: Music/**/*)
+//
+// Note: aglob cannot scan bare root dirs; a sub-path is required.
+
+requirelib("filelib");
+
+var results = [];
+
+for (var i = 0; i < LOADED_STORAGES.length; i++) {
+    var storage  = LOADED_STORAGES[i];
+    var basePath = storage.Uuid + ":/Music/";
+
+    var globMatches  = filelib.glob( basePath + "*",    "default");
+    var aglobMatches = filelib.aglob(basePath + "**/*", "default");
+
+    results.push({
+        storage: storage.Name,
+        uuid:    storage.Uuid,
+        path:    storage.Path,
+        glob:    globMatches  || [],
+        aglob:   aglobMatches || []
+    });
+}
+
+sendJSONResp(JSON.stringify(results));

+ 380 - 0
src/web/UnitTest/devmode.html

@@ -0,0 +1,380 @@
+<!DOCTYPE html>
+<html>
+    <head>
+        <meta name="apple-mobile-web-app-capable" content="yes" />
+        <meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"/>
+        <meta charset="UTF-8">
+        <meta name="theme-color" content="#0078d4">
+        <script src="../script/jquery.min.js"></script>
+        <script src="../script/ao_module.js"></script>
+        <title>AGI Dev Mode Test</title>
+        <style>
+            *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+            body {
+                font-family: 'Segoe UI Variable', 'Segoe UI', system-ui, -apple-system, sans-serif;
+                font-size: 14px;
+                background: #f3f3f3;
+                color: #201f1e;
+                min-height: 100vh;
+            }
+
+            /* ── Nav bar ───────────────────────────────────────────── */
+            .nav-bar { position: sticky; top: 0; z-index: 100; height: 48px; background: rgba(243,243,243,0.9); backdrop-filter: blur(20px) saturate(180%); border-bottom: 1px solid rgba(0,0,0,0.08); display: flex; align-items: center; padding: 0 20px; gap: 4px; }
+            .nav-brand { font-size: 13px; font-weight: 600; color: #201f1e; margin-right: 12px; }
+            .nav-tab { display: inline-flex; align-items: center; height: 48px; padding: 0 12px; font-size: 13px; color: #605e5c; text-decoration: none; border-bottom: 2px solid transparent; transition: color 0.1s, border-color 0.1s; white-space: nowrap; }
+            .nav-tab:hover { color: #201f1e; }
+            .nav-tab.active { color: #0078d4; border-bottom-color: #0078d4; font-weight: 600; }
+
+            /* ── Content ───────────────────────────────────────────── */
+            .content { max-width: 880px; margin: 24px auto; padding: 0 20px; }
+
+            .page-title { font-size: 20px; font-weight: 600; margin-bottom: 4px; }
+            .page-subtitle { font-size: 13px; color: #797775; margin-bottom: 20px; line-height: 1.5; }
+            .page-subtitle code { font-family: 'Cascadia Code', 'Consolas', monospace; background: #ebebeb; padding: 1px 5px; border-radius: 3px; font-size: 12px; }
+
+            /* ── Toggle row ────────────────────────────────────────── */
+            .toggle-row {
+                display: flex;
+                align-items: center;
+                gap: 12px;
+                padding: 12px 16px;
+                background: #fff;
+                border: 1px solid #e0e0e0;
+                border-radius: 8px;
+                margin-bottom: 14px;
+            }
+
+            .toggle-switch { position: relative; width: 40px; height: 22px; flex-shrink: 0; }
+            .toggle-switch input { opacity: 0; width: 0; height: 0; }
+            .toggle-track {
+                position: absolute; inset: 0;
+                background: #c0c0c0;
+                border-radius: 11px;
+                cursor: pointer;
+                transition: background 0.2s;
+            }
+            .toggle-track::after {
+                content: '';
+                position: absolute;
+                left: 3px; top: 3px;
+                width: 16px; height: 16px;
+                background: #fff;
+                border-radius: 50%;
+                transition: transform 0.2s;
+            }
+            .toggle-switch input:checked + .toggle-track { background: #0078d4; }
+            .toggle-switch input:checked + .toggle-track::after { transform: translateX(18px); }
+
+            .toggle-label { font-size: 13px; font-weight: 500; }
+            .toggle-hint { font-size: 12px; color: #797775; margin-left: auto; }
+
+            /* ── Toolbar ───────────────────────────────────────────── */
+            .toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
+            .btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 16px; border-radius: 4px; font-family: inherit; font-size: 13px; cursor: pointer; border: 1px solid transparent; transition: background 0.1s; line-height: 1; }
+            .btn-primary { background: #0078d4; color: #fff; border-color: #0078d4; }
+            .btn-primary:hover { background: #106ebe; }
+            .btn-secondary { background: #fff; color: #201f1e; border-color: #d1d1d1; }
+            .btn-secondary:hover { background: #f5f5f5; }
+
+            /* ── Test list card ────────────────────────────────────── */
+            .test-list {
+                background: #fff;
+                border: 1px solid #e0e0e0;
+                border-radius: 8px;
+                overflow: hidden;
+                box-shadow: 0 1px 3px rgba(0,0,0,0.05);
+            }
+
+            .test-list-header {
+                padding: 10px 16px;
+                font-size: 11px;
+                font-weight: 600;
+                color: #797775;
+                text-transform: uppercase;
+                letter-spacing: 0.04em;
+                border-bottom: 1px solid #f0f0f0;
+                display: flex;
+                align-items: center;
+                gap: 12px;
+            }
+            .test-list-header .col-name   { flex: 1; }
+            .test-list-header .col-status { width: 82px; text-align: center; }
+            .test-list-header .col-action { width: 52px; }
+
+            .test-item {
+                display: flex;
+                align-items: center;
+                gap: 12px;
+                padding: 10px 16px;
+                border-bottom: 1px solid #f5f5f5;
+                transition: background 0.1s;
+            }
+            .test-item:hover { background: #fafafa; }
+
+            .test-idx { font-size: 11px; color: #bbb; width: 22px; text-align: right; flex-shrink: 0; }
+
+            .test-info { flex: 1; min-width: 0; }
+            .test-name { font-size: 13px; font-weight: 500; }
+            .test-desc { font-size: 11px; color: #797775; margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+            .test-script { font-size: 11px; color: #bbb; font-family: 'Cascadia Code', 'Consolas', monospace; margin-top: 1px; }
+
+            /* Status badges — same tokens as index.html */
+            .status-badge { display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 12px; font-size: 11px; font-weight: 600; width: 82px; justify-content: center; flex-shrink: 0; }
+            .status-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
+            .s-pending { background: #f3f3f3; color: #797775; }
+            .s-pending .status-dot { background: #c0c0c0; }
+            .s-running { background: #eff6fc; color: #0078d4; }
+            .s-running .status-dot { background: #0078d4; animation: pulse 1s ease-in-out infinite; }
+            .s-pass    { background: #dff6dd; color: #107c10; }
+            .s-pass    .status-dot { background: #107c10; }
+            .s-fail    { background: #fde7e9; color: #c50f1f; }
+            .s-fail    .status-dot { background: #c50f1f; }
+            @keyframes pulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:.4;transform:scale(.7)} }
+
+            .col-action { width: 52px; flex-shrink: 0; text-align: right; }
+            .btn-run { padding: 4px 10px; font-size: 12px; border-radius: 3px; background: transparent; border: 1px solid #d1d1d1; color: #444; cursor: pointer; font-family: inherit; transition: background 0.1s; }
+            .btn-run:hover { background: #f0f0f0; }
+            .btn-run:disabled { opacity: 0.45; cursor: default; }
+
+            /* ── Result area ───────────────────────────────────────── */
+            .result-area {
+                display: none;
+                padding: 10px 16px 12px 50px;
+                background: #fafafa;
+                border-top: 1px solid #f0f0f0;
+                border-left: 3px solid transparent;
+            }
+            .result-area.r-pass { border-left-color: #107c10; }
+            .result-area.r-fail { border-left-color: #c50f1f; }
+            .result-area.r-ok   { border-left-color: #107c10; }
+
+            .result-label { font-size: 11px; font-weight: 600; color: #797775; text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 6px; }
+
+            .result-table { width: 100%; border-collapse: collapse; font-size: 12px; font-family: 'Cascadia Code', 'Consolas', monospace; margin-bottom: 8px; }
+            .result-table td { padding: 2px 0; vertical-align: top; }
+            .result-table td:first-child { color: #797775; font-weight: 600; width: 80px; padding-right: 12px; white-space: nowrap; }
+
+            .stack-block {
+                background: #f0f0f0;
+                border-left: 3px solid #c0c0c0;
+                padding: 7px 10px;
+                font-family: 'Cascadia Code', 'Consolas', monospace;
+                font-size: 11px;
+                white-space: pre-wrap;
+                word-break: break-all;
+                line-height: 1.55;
+                border-radius: 0 3px 3px 0;
+                color: #323130;
+            }
+
+            .raw-block {
+                background: #f0f0f0;
+                border-left: 3px solid #e57373;
+                padding: 7px 10px;
+                font-family: 'Cascadia Code', 'Consolas', monospace;
+                font-size: 11px;
+                white-space: pre-wrap;
+                word-break: break-all;
+                max-height: 180px;
+                overflow-y: auto;
+                line-height: 1.55;
+                border-radius: 0 3px 3px 0;
+            }
+
+            .resp-block {
+                font-family: 'Cascadia Code', 'Consolas', monospace;
+                font-size: 12px;
+                color: #323130;
+                word-break: break-all;
+            }
+        </style>
+    </head>
+    <body>
+
+        <nav class="nav-bar">
+            <span class="nav-brand">Unit Tester</span>
+            <a class="nav-tab" href="index.html">AGI Scripts</a>
+            <a class="nav-tab" href="backend.html">ao_backend</a>
+            <a class="nav-tab" href="wstest.html">WebSocket</a>
+            <a class="nav-tab active" href="devmode.html">Dev Mode</a>
+        </nav>
+
+        <div class="content">
+            <div class="page-title">AGI Dev Mode Debug Test</div>
+            <p class="page-subtitle">
+                Tests the <code>AGI_DEV</code> global flag with <code>ao_module_agirun</code>.
+                When enabled, the backend returns a JSON payload with the full Otto stack trace
+                instead of a generic HTTP&nbsp;500.
+            </p>
+
+            <!-- Toggle -->
+            <div class="toggle-row">
+                <label class="toggle-switch">
+                    <input type="checkbox" id="devmodeCheckbox" checked onchange="onToggle(this)">
+                    <span class="toggle-track"></span>
+                </label>
+                <span class="toggle-label">AGI Dev Mode</span>
+                <span class="toggle-hint" id="toggleHint">ON &mdash; errors return detailed JSON</span>
+            </div>
+
+            <!-- Toolbar -->
+            <div class="toolbar">
+                <button class="btn btn-primary" onclick="runAllTests()">&#9654;&nbsp; Run All</button>
+                <button class="btn btn-secondary" onclick="clearAll()">Clear</button>
+            </div>
+
+            <!-- Test list -->
+            <div class="test-list">
+                <div class="test-list-header">
+                    <span style="width:22px;flex-shrink:0"></span>
+                    <span class="col-name">Test</span>
+                    <span class="col-status">Status</span>
+                    <span class="col-action"></span>
+                </div>
+                <div id="testContainer"></div>
+            </div>
+        </div>
+
+        <script>
+            var AGI_DEV = true;
+
+            const TESTS = [
+                {
+                    id: "runtime_error",
+                    name: "Runtime Reference Error",
+                    script: "UnitTest/devmode/runtime_error.js",
+                    desc: "Calls an undefined variable — triggers ReferenceError in Otto VM",
+                    expectError: true
+                },
+                {
+                    id: "throw_error",
+                    name: "Explicit throw new Error()",
+                    script: "UnitTest/devmode/throw_error.js",
+                    desc: "Throws through a nested call chain — exercises multi-frame stack trace",
+                    expectError: true
+                },
+                {
+                    id: "working_script",
+                    name: "Working Script",
+                    script: "UnitTest/devmode/working_script.js",
+                    desc: "Completes successfully — verifies dev mode does not affect normal execution",
+                    expectError: false
+                }
+            ];
+
+            /* ── Build list ──────────────────────────────────────── */
+            (function build() {
+                let html = TESTS.map(function(t, i) {
+                    return `<div class="test-item" id="item_${t.id}">
+                        <span class="test-idx">${i + 1}</span>
+                        <div class="test-info">
+                            <div class="test-name">${escHtml(t.name)}</div>
+                            <div class="test-desc">${escHtml(t.desc)}</div>
+                            <div class="test-script">${escHtml(t.script)}</div>
+                        </div>
+                        <span class="status-badge s-pending" id="badge_${t.id}">
+                            <span class="status-dot"></span>Pending
+                        </span>
+                        <div class="col-action">
+                            <button class="btn-run" id="runbtn_${t.id}" onclick="runTest('${t.id}')">Run</button>
+                        </div>
+                    </div>
+                    <div class="result-area" id="result_${t.id}"></div>`;
+                }).join("");
+                document.getElementById("testContainer").innerHTML = html;
+            })();
+
+            /* ── Toggle ──────────────────────────────────────────── */
+            function onToggle(cb) {
+                AGI_DEV = cb.checked;
+                document.getElementById("toggleHint").textContent = AGI_DEV
+                    ? "ON — errors return detailed JSON"
+                    : "OFF — errors return generic HTML 500";
+            }
+
+            /* ── Run ─────────────────────────────────────────────── */
+            function runAllTests() {
+                TESTS.forEach(function(t) { runTest(t.id); });
+            }
+
+            function clearAll() {
+                TESTS.forEach(function(t) {
+                    setStatus(t.id, "pending");
+                    let r = document.getElementById("result_" + t.id);
+                    r.style.display = "none"; r.innerHTML = ""; r.className = "result-area";
+                });
+            }
+
+            function runTest(id) {
+                let test = TESTS.find(function(t) { return t.id === id; });
+                let btn = document.getElementById("runbtn_" + id);
+                setStatus(id, "running");
+                if (btn) btn.disabled = true;
+
+                ao_module_agirun(test.script, {}, function(data) {
+                    setStatus(id, "pass");
+                    let body = (typeof data === "string") ? data : JSON.stringify(data);
+                    showResult(id, "r-pass", renderSuccess(body));
+                    if (btn) btn.disabled = false;
+                }, function(xhr) {
+                    setStatus(id, "fail");
+                    let html = AGI_DEV ? renderDevError(xhr) : renderNormalError(xhr);
+                    showResult(id, "r-fail", html);
+                    if (btn) btn.disabled = false;
+                }, 10000);
+            }
+
+            /* ── Renderers ───────────────────────────────────────── */
+            function renderSuccess(body) {
+                return `<div class="result-label">Response (HTTP 200)</div>
+                    <div class="resp-block">${escHtml(body)}</div>`;
+            }
+
+            function renderDevError(xhr) {
+                let e = {};
+                try { e = JSON.parse(xhr.responseText); } catch(_) {}
+                let msg    = escHtml(e.message    || "(no message)");
+                let stack  = escHtml(e.stacktrace || "(no stack trace)");
+                let script = escHtml(e.script     || "(unknown)");
+                let user   = escHtml(e.user       || "(unknown)");
+                let same   = (e.message === e.stacktrace);
+                return `<div class="result-label">Dev Mode Error (HTTP ${xhr.status || "?"})</div>
+                    <table class="result-table">
+                        <tr><td>Message</td><td>${msg}</td></tr>
+                        <tr><td>Script</td><td>${script}</td></tr>
+                        <tr><td>User</td><td>${user}</td></tr>
+                    </table>
+                    <div class="result-label" style="margin-top:8px">Stack Trace</div>
+                    <div class="stack-block">${same ? msg : stack}</div>`;
+            }
+
+            function renderNormalError(xhr) {
+                let raw = escHtml(xhr.responseText || "(empty response body)");
+                return `<div class="result-label">Normal Mode Error (HTTP ${xhr.status || "?"}) &mdash; no debug info</div>
+                    <div class="raw-block">${raw}</div>`;
+            }
+
+            /* ── Helpers ─────────────────────────────────────────── */
+            function setStatus(id, state) {
+                let badge = document.getElementById("badge_" + id);
+                if (!badge) return;
+                let labels = { pending:"Pending", running:"Running", pass:"Passed", fail:"Failed" };
+                badge.className = "status-badge s-" + state;
+                badge.innerHTML = '<span class="status-dot"></span>' + labels[state];
+            }
+
+            function showResult(id, cls, html) {
+                let div = document.getElementById("result_" + id);
+                if (!div) return;
+                div.className = "result-area " + cls;
+                div.innerHTML = html;
+                div.style.display = "block";
+            }
+
+            function escHtml(s) {
+                return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
+            }
+        </script>
+    </body>
+</html>

+ 9 - 0
src/web/UnitTest/devmode/runtime_error.js

@@ -0,0 +1,9 @@
+/*
+    runtime_error.js
+
+    Intentionally triggers a ReferenceError by calling an undefined variable.
+    Used to test the AGI_DEV debug message dump feature.
+*/
+
+var result = undefinedFunction(); // ReferenceError: undefinedFunction is not defined
+sendResp("This line should never be reached");

+ 17 - 0
src/web/UnitTest/devmode/throw_error.js

@@ -0,0 +1,17 @@
+/*
+    throw_error.js
+
+    Intentionally throws an explicit Error object with a custom message.
+    Used to test the AGI_DEV debug message dump feature.
+*/
+
+function inner() {
+    throw new Error("This is a deliberate test error from the AGI dev mode test suite");
+}
+
+function outer() {
+    inner();
+}
+
+outer();
+sendResp("This line should never be reached");

+ 8 - 0
src/web/UnitTest/devmode/working_script.js

@@ -0,0 +1,8 @@
+/*
+    working_script.js
+
+    A script that completes successfully.
+    Used to verify that AGI_DEV mode does not affect normal script execution.
+*/
+
+sendJSONResp(JSON.stringify({status: "ok", message: "Script executed successfully"}));

+ 525 - 62
src/web/UnitTest/index.html

@@ -4,77 +4,540 @@
         <meta name="apple-mobile-web-app-capable" content="yes" />
         <meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"/>
         <meta charset="UTF-8">
-        <meta name="theme-color" content="#4b75ff">
-        <link rel="stylesheet" href="../script/semantic/semantic.min.css">
+        <meta name="theme-color" content="#0078d4">
         <script src="../script/jquery.min.js"></script>
         <script src="../script/ao_module.js"></script>
-        <script src="../script/semantic/semantic.min.js"></script>
-        <title>Unit Test</title>
+        <title>Unit Tester</title>
         <style>
-            body{
-                background-color:white;
+            *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+            body {
+                font-family: 'Segoe UI Variable', 'Segoe UI', system-ui, -apple-system, sans-serif;
+                font-size: 14px;
+                background: #f3f3f3;
+                color: #201f1e;
+                min-height: 100vh;
+            }
+
+            /* ── Nav bar ───────────────────────────────────────────── */
+            .nav-bar {
+                position: sticky;
+                top: 0;
+                z-index: 100;
+                height: 48px;
+                background: rgba(243,243,243,0.9);
+                backdrop-filter: blur(20px) saturate(180%);
+                border-bottom: 1px solid rgba(0,0,0,0.08);
+                display: flex;
+                align-items: center;
+                padding: 0 20px;
+                gap: 4px;
+            }
+
+            .nav-brand {
+                font-size: 13px;
+                font-weight: 600;
+                color: #201f1e;
+                margin-right: 12px;
+                white-space: nowrap;
+            }
+
+            .nav-tab {
+                display: inline-flex;
+                align-items: center;
+                height: 48px;
+                padding: 0 12px;
+                font-size: 13px;
+                color: #605e5c;
+                text-decoration: none;
+                border-bottom: 2px solid transparent;
+                transition: color 0.1s, border-color 0.1s;
+                white-space: nowrap;
+            }
+            .nav-tab:hover { color: #201f1e; }
+            .nav-tab.active { color: #0078d4; border-bottom-color: #0078d4; font-weight: 600; }
+
+            /* ── Content ───────────────────────────────────────────── */
+            .content {
+                max-width: 880px;
+                margin: 24px auto;
+                padding: 0 20px;
+            }
+
+            /* ── Toolbar ───────────────────────────────────────────── */
+            .toolbar {
+                display: flex;
+                align-items: center;
+                gap: 8px;
+                margin-bottom: 14px;
+                flex-wrap: wrap;
+            }
+
+            .toolbar-sep {
+                width: 1px;
+                height: 20px;
+                background: #d1d1d1;
+                margin: 0 4px;
+            }
+
+            .summary-text {
+                font-size: 12px;
+                color: #797775;
+            }
+
+            /* ── Buttons ───────────────────────────────────────────── */
+            .btn {
+                display: inline-flex;
+                align-items: center;
+                gap: 6px;
+                padding: 6px 16px;
+                border-radius: 4px;
+                font-family: inherit;
+                font-size: 13px;
+                font-weight: 400;
+                cursor: pointer;
+                border: 1px solid transparent;
+                transition: background 0.1s, border-color 0.1s;
+                line-height: 1;
+                text-decoration: none;
+                white-space: nowrap;
+            }
+            .btn-primary {
+                background: #0078d4;
+                color: #fff;
+                border-color: #0078d4;
+            }
+            .btn-primary:hover { background: #106ebe; border-color: #106ebe; }
+            .btn-primary:active { background: #005a9e; }
+
+            .btn-secondary {
+                background: #fff;
+                color: #201f1e;
+                border-color: #d1d1d1;
+            }
+            .btn-secondary:hover { background: #f5f5f5; border-color: #c0c0c0; }
+            .btn-secondary:active { background: #ebebeb; }
+
+            .btn-ghost {
+                background: transparent;
+                color: #605e5c;
+                border-color: transparent;
+                padding: 5px 10px;
             }
+            .btn-ghost:hover { background: rgba(0,0,0,0.05); color: #201f1e; }
 
-            .success{
-                color: #20c942;
+            /* ── Stats pills ───────────────────────────────────────── */
+            .stats-bar {
+                display: none;
+                align-items: center;
+                gap: 8px;
+                margin-bottom: 14px;
+                flex-wrap: wrap;
             }
 
-            .failed{
-                color: #eb4034;
+            .stat-pill {
+                display: inline-flex;
+                align-items: center;
+                gap: 5px;
+                padding: 4px 12px;
+                border-radius: 20px;
+                font-size: 12px;
+                font-weight: 600;
+                background: #fff;
+                border: 1px solid #e0e0e0;
+            }
+            .stat-pill.all    { color: #201f1e; }
+            .stat-pill.passed { background: #dff6dd; border-color: #bad7b9; color: #107c10; }
+            .stat-pill.failed { background: #fde7e9; border-color: #f1b8bb; color: #c50f1f; }
+            .stat-pill.pending{ color: #797775; }
+
+            /* ── Test list card ────────────────────────────────────── */
+            .test-list {
+                background: #fff;
+                border: 1px solid #e0e0e0;
+                border-radius: 8px;
+                overflow: hidden;
+                box-shadow: 0 1px 3px rgba(0,0,0,0.05);
+            }
+
+            .test-list-header {
+                padding: 10px 16px;
+                font-size: 11px;
+                font-weight: 600;
+                color: #797775;
+                text-transform: uppercase;
+                letter-spacing: 0.04em;
+                border-bottom: 1px solid #f0f0f0;
+                display: flex;
+                align-items: center;
+                gap: 12px;
+            }
+            .test-list-header .col-name   { flex: 1; }
+            .test-list-header .col-status { width: 82px; text-align: center; }
+            .test-list-header .col-action { width: 52px; }
+
+            .test-item {
+                display: flex;
+                align-items: center;
+                gap: 12px;
+                padding: 10px 16px;
+                border-bottom: 1px solid #f5f5f5;
+                transition: background 0.1s;
+            }
+            .test-item:last-of-type { border-bottom: none; }
+            .test-item:hover { background: #fafafa; }
+
+            .test-idx {
+                font-size: 11px;
+                color: #bbb;
+                font-variant-numeric: tabular-nums;
+                width: 22px;
+                text-align: right;
+                flex-shrink: 0;
+            }
+
+            .test-info { flex: 1; min-width: 0; }
+
+            .test-name {
+                font-size: 13px;
+                font-weight: 500;
+                white-space: nowrap;
+                overflow: hidden;
+                text-overflow: ellipsis;
+            }
+
+            .test-script {
+                font-size: 11px;
+                color: #a0a0a0;
+                font-family: 'Cascadia Code', 'Consolas', monospace;
+                white-space: nowrap;
+                overflow: hidden;
+                text-overflow: ellipsis;
+                margin-top: 1px;
+            }
+
+            /* ── Status badge ──────────────────────────────────────── */
+            .status-badge {
+                display: inline-flex;
+                align-items: center;
+                gap: 5px;
+                padding: 3px 10px;
+                border-radius: 12px;
+                font-size: 11px;
+                font-weight: 600;
+                width: 82px;
+                justify-content: center;
+                flex-shrink: 0;
+            }
+            .status-dot {
+                width: 6px;
+                height: 6px;
+                border-radius: 50%;
+                flex-shrink: 0;
+            }
+            .s-pending { background: #f3f3f3; color: #797775; }
+            .s-pending .status-dot { background: #c0c0c0; }
+
+            .s-running { background: #eff6fc; color: #0078d4; }
+            .s-running .status-dot {
+                background: #0078d4;
+                animation: pulse 1s ease-in-out infinite;
+            }
+
+            .s-pass { background: #dff6dd; color: #107c10; }
+            .s-pass .status-dot { background: #107c10; }
+
+            .s-fail { background: #fde7e9; color: #c50f1f; }
+            .s-fail .status-dot { background: #c50f1f; }
+
+            @keyframes pulse {
+                0%, 100% { opacity: 1; transform: scale(1); }
+                50%       { opacity: 0.4; transform: scale(0.7); }
+            }
+
+            /* Run button column */
+            .col-action { width: 52px; flex-shrink: 0; text-align: right; }
+
+            .btn-run {
+                padding: 4px 10px;
+                font-size: 12px;
+                border-radius: 3px;
+                background: transparent;
+                border: 1px solid #d1d1d1;
+                color: #444;
+                cursor: pointer;
+                font-family: inherit;
+                transition: background 0.1s;
+            }
+            .btn-run:hover { background: #f0f0f0; }
+            .btn-run:active { background: #e5e5e5; }
+            .btn-run:disabled { opacity: 0.45; cursor: default; }
+
+            /* ── Result area ───────────────────────────────────────── */
+            .result-area {
+                display: none;
+                padding: 10px 16px 12px 50px;
+                font-family: 'Cascadia Code', 'Consolas', monospace;
+                font-size: 12px;
+                line-height: 1.5;
+                background: #fafafa;
+                border-top: 1px solid #f0f0f0;
+                border-left: 3px solid transparent;
+                word-break: break-all;
+                color: #323130;
+            }
+            .result-area.r-pass { border-left-color: #107c10; }
+            .result-area.r-fail { border-left-color: #c50f1f; }
+            .result-area a { color: #0078d4; text-decoration: none; font-size: 11px; }
+            .result-area a:hover { text-decoration: underline; }
+
+            .result-label {
+                font-size: 10px;
+                font-weight: 700;
+                color: #a0a0a0;
+                text-transform: uppercase;
+                letter-spacing: 0.05em;
+                margin-bottom: 4px;
+            }
+            .result-label + .result-label { margin-top: 8px; }
+
+            .result-table { border-collapse: collapse; font-size: 12px; margin-bottom: 4px; }
+            .result-table td { padding: 1px 0; vertical-align: top; }
+            .result-table td:first-child { color: #797775; font-weight: 600; width: 70px; padding-right: 10px; white-space: nowrap; }
+
+            .stack-block {
+                background: #f0f0f0;
+                border-left: 3px solid #bbb;
+                padding: 6px 10px;
+                white-space: pre-wrap;
+                word-break: break-all;
+                line-height: 1.55;
+                border-radius: 0 3px 3px 0;
+                font-size: 11px;
+                color: #323130;
+                margin-bottom: 6px;
+            }
+
+            /* ── Empty / loading states ────────────────────────────── */
+            .list-placeholder {
+                padding: 40px 20px;
+                text-align: center;
+                color: #a0a0a0;
+                font-size: 13px;
+            }
+            .list-placeholder .icon {
+                font-size: 28px;
+                margin-bottom: 8px;
             }
         </style>
     </head>
     <body>
-	<br><br>
-		<div class="ui text container">
-			<h3>AGI Unit Testing Module</h3>
-            <button class="ui button" onclick="openGateway();">Open AGI Gateway (No File)</button>
-            <button class="ui button" onclick="runtest();">Run Unit Test</button>
-            <a class="ui button" href="wstest.html">Open WebSocket Test</a>
-            <a class="ui blue button" href="backend.html">Open Backend Unit Test</a>
-            <div class="ui divider"></div>
-            <p>Test Results</p>
-            <table class="ui celled table">
-                <thead>
-                  <tr><th>Script</th>
-                  <th>Results</th>
-                  <th>Status</th>
-                </tr></thead>
-                <tbody id="results">
-                 
-                </tbody>
-              </table>
-		</div>
-        <br><br>
-       <script>
-           function openGateway(){
-                window.open("../system/ajgi/interface");
-           }
-
-			function runtest(){
-				$("#results").html("");
-				//Get a list of test to be run
-				ao_module_agirun("UnitTest/list.agi",{testdata: "Hello World"}, function(data){
-                    data.forEach(test => {
-                        let scriptname = test.split("/").pop();
-                        let thisScriptPath = JSON.parse(JSON.stringify(test));
-                        ao_module_agirun(test, {foo: "Hello", bar: "World"}, function(data){
-                            //Success callback
-                            $("#results").append(`<tr class="positive">
-                                <td data-label="">${scriptname}</td>
-                                <td data-label="">${JSON.stringify(data)}</td>
-                                <td data-label="">Success</td>
-                            </tr>`);
-                        }, function(){
-                            $("#results").append(`<tr class="negative">
-                                <td data-label="">${scriptname}</td>
-                                <td data-label=""><a href="../system/ajgi/interface?script=${thisScriptPath}" target="_blank">Open Debug Interface</a></td>
-                                <td data-label="">Failed</td>
-                            </tr>`);
-                        }, 10000);
-                    });
-				})
-			}
-	   </script>
+
+        <!-- Nav bar -->
+        <nav class="nav-bar">
+            <span class="nav-brand">Unit Tester</span>
+            <a class="nav-tab active" href="index.html">AGI Scripts</a>
+            <a class="nav-tab" href="backend.html">ao_backend</a>
+            <a class="nav-tab" href="wstest.html">WebSocket</a>
+            <a class="nav-tab" href="devmode.html">Dev Mode</a>
+        </nav>
+
+        <!-- Content -->
+        <div class="content">
+
+            <!-- Toolbar -->
+            <div class="toolbar">
+                <button class="btn btn-primary" id="btnRunAll" onclick="runAll()">&#9654;&nbsp; Run All</button>
+                <button class="btn btn-secondary" onclick="clearResults()">Clear</button>
+                <div class="toolbar-sep"></div>
+                <span class="summary-text" id="summaryText">Loading tests&hellip;</span>
+            </div>
+
+            <!-- Stats -->
+            <div class="stats-bar" id="statsBar">
+                <span class="stat-pill all"><span id="statTotal">0</span> total</span>
+                <span class="stat-pill passed">&#10003;&nbsp;<span id="statPass">0</span> passed</span>
+                <span class="stat-pill failed">&#10007;&nbsp;<span id="statFail">0</span> failed</span>
+                <span class="stat-pill pending">&middot;&nbsp;<span id="statPending">0</span> pending</span>
+            </div>
+
+            <!-- Test list -->
+            <div class="test-list" id="testList">
+                <div class="list-placeholder">
+                    <div class="icon">&#9711;</div>
+                    Loading test scripts&hellip;
+                </div>
+            </div>
+
+        </div>
+
+        <script>
+            var AGI_DEV = true;
+            var tests = [];
+
+            /* ── Bootstrap ─────────────────────────────────────── */
+            ao_module_agirun("UnitTest/list.agi", {}, function(data) {
+                tests = data.map(function(script, i) {
+                    return { id: i, script: script, name: script.split("/").pop(), state: "pending" };
+                });
+                renderList();
+                updateStats();
+            }, function() {
+                document.getElementById("testList").innerHTML =
+                    '<div class="list-placeholder"><div class="icon">&#9888;</div>Failed to load test list. Check that the module is registered.</div>';
+                document.getElementById("summaryText").textContent = "Error loading tests";
+            });
+
+            /* ── Render ────────────────────────────────────────── */
+            function renderList() {
+                if (tests.length === 0) {
+                    document.getElementById("testList").innerHTML =
+                        '<div class="list-placeholder"><div class="icon">&#9711;</div>No test scripts found in UnitTest/backend/</div>';
+                    document.getElementById("summaryText").textContent = "0 tests";
+                    return;
+                }
+
+                let header = `<div class="test-list-header">
+                    <span style="width:22px;flex-shrink:0"></span>
+                    <span class="col-name">Script</span>
+                    <span class="col-status">Status</span>
+                    <span class="col-action"></span>
+                </div>`;
+
+                let rows = tests.map(function(t) {
+                    return `<div class="test-item" id="item_${t.id}">
+                        <span class="test-idx">${t.id + 1}</span>
+                        <div class="test-info">
+                            <div class="test-name">${escHtml(t.name)}</div>
+                            <div class="test-script">${escHtml(t.script)}</div>
+                        </div>
+                        <span class="status-badge s-pending" id="badge_${t.id}">
+                            <span class="status-dot"></span>Pending
+                        </span>
+                        <div class="col-action">
+                            <button class="btn-run" id="runbtn_${t.id}" onclick="runTest(${t.id})">Run</button>
+                        </div>
+                    </div>
+                    <div class="result-area" id="result_${t.id}"></div>`;
+                }).join("");
+
+                document.getElementById("testList").innerHTML = header + rows;
+                document.getElementById("summaryText").textContent = tests.length + " tests";
+            }
+
+            /* ── Run logic ─────────────────────────────────────── */
+            function runTest(id) {
+                let t = tests[id];
+                setStatus(id, "running");
+                let btn = document.getElementById("runbtn_" + id);
+                if (btn) { btn.disabled = true; }
+
+                ao_module_agirun(t.script, { foo: "Hello", bar: "World" }, function(data) {
+                    setStatus(id, "pass");
+                    let body = (typeof data === "string") ? escHtml(data) : escHtml(JSON.stringify(data));
+                    showResult(id, "pass", body || "<em style='color:#aaa'>empty response</em>");
+                    if (btn) { btn.disabled = false; }
+                }, function(xhr) {
+                    setStatus(id, "fail");
+                    showResult(id, "fail", renderDevError(t.script, xhr));
+                    if (btn) { btn.disabled = false; }
+                }, 10000);
+            }
+
+            function runAll() {
+                let btn = document.getElementById("btnRunAll");
+                btn.disabled = true;
+                btn.textContent = "Running…";
+                let pending = tests.length;
+                if (pending === 0) { btn.disabled = false; btn.innerHTML = "&#9654;&nbsp; Run All"; return; }
+                tests.forEach(function(t) { runTest(t.id); });
+                // Re-enable after last test finishes (poll state)
+                let checkDone = setInterval(function() {
+                    let still = tests.filter(function(t) { return t.state === "running"; }).length;
+                    if (still === 0) {
+                        clearInterval(checkDone);
+                        btn.disabled = false;
+                        btn.innerHTML = "&#9654;&nbsp; Run All";
+                    }
+                }, 300);
+            }
+
+            function clearResults() {
+                tests.forEach(function(t) {
+                    t.state = "pending";
+                    setStatus(t.id, "pending");
+                    let r = document.getElementById("result_" + t.id);
+                    if (r) { r.style.display = "none"; r.innerHTML = ""; r.className = "result-area"; }
+                });
+                updateStats();
+            }
+
+            /* ── Helpers ───────────────────────────────────────── */
+            function setStatus(id, state) {
+                tests[id].state = state;
+                let badge = document.getElementById("badge_" + id);
+                if (!badge) return;
+                let labels = { pending: "Pending", running: "Running", pass: "Passed", fail: "Failed" };
+                badge.className = "status-badge s-" + state;
+                badge.innerHTML = '<span class="status-dot"></span>' + labels[state];
+                updateStats();
+            }
+
+            function showResult(id, type, content) {
+                let div = document.getElementById("result_" + id);
+                if (!div) return;
+                div.className = "result-area r-" + type;
+                div.innerHTML = content;
+                div.style.display = "block";
+            }
+
+            function updateStats() {
+                let total   = tests.length;
+                let passed  = tests.filter(function(t) { return t.state === "pass"; }).length;
+                let failed  = tests.filter(function(t) { return t.state === "fail"; }).length;
+                let pending = tests.filter(function(t) { return t.state === "pending" || t.state === "running"; }).length;
+                document.getElementById("statTotal").textContent   = total;
+                document.getElementById("statPass").textContent    = passed;
+                document.getElementById("statFail").textContent    = failed;
+                document.getElementById("statPending").textContent = pending;
+                let bar = document.getElementById("statsBar");
+                if (total > 0) bar.style.display = "flex";
+            }
+
+            function renderDevError(scriptPath, xhr) {
+                let e = {};
+                try { e = JSON.parse(xhr.responseText); } catch(_) {}
+                let gatewayLink = '<a href="../system/ajgi/interface?script=' +
+                    encodeURIComponent(scriptPath) + '" target="_blank">Open in AGI Gateway &#8599;</a>';
+                if (!e.error) {
+                    // Non-JSON response — generic fallback
+                    let raw = escHtml(xhr.responseText || "(empty response)");
+                    return '<div class="result-label">Error (HTTP ' + (xhr.status||"?") + ')</div>' +
+                           '<div style="margin-bottom:6px">' + (raw.length < 300 ? raw : raw.slice(0,300) + "…") + '</div>' +
+                           gatewayLink;
+                }
+                let msg   = escHtml(e.message    || "(no message)");
+                let stack = escHtml(e.stacktrace || "(no stack trace)");
+                let scr   = escHtml(e.script     || "(unknown)");
+                let usr   = escHtml(e.user       || "(unknown)");
+                return '<div class="result-label">Error (HTTP ' + (xhr.status||"?") + ')</div>' +
+                       '<table class="result-table">' +
+                       '<tr><td>Message</td><td>' + msg + '</td></tr>' +
+                       '<tr><td>Script</td><td>' + scr + '</td></tr>' +
+                       '<tr><td>User</td><td>' + usr + '</td></tr>' +
+                       '</table>' +
+                       '<div class="result-label">Stack Trace</div>' +
+                       '<div class="stack-block">' + (e.message === e.stacktrace ? msg : stack) + '</div>' +
+                       gatewayLink;
+            }
+
+            function escHtml(s) {
+                return String(s)
+                    .replace(/&/g, "&amp;")
+                    .replace(/</g, "&lt;")
+                    .replace(/>/g, "&gt;")
+                    .replace(/"/g, "&quot;");
+            }
+        </script>
     </body>
-</html>
+</html>

+ 37 - 54
src/web/UnitTest/special/websocket.js

@@ -1,76 +1,59 @@
 /*
     WebSocket Test Script
 
-    This is a special test script and should not be mixed in with normal
-    AGI module test scripts. Please test this seperately
+    Supports an interactive command loop:
+      echo <text>  — sends <text> back to the client
+      stop         — closes the connection gracefully
 
     Author: tobychui
 */
 
-function setup(){
-    //Require the WebSocket Library
-    var succ = requirelib("websocket");
-    if (!succ){
-        console.log("WebSocket Open Failed");
-        return false
+function setup() {
+    if (!requirelib("websocket")) {
+        console.log("WebSocket library load failed");
+        return false;
     }
 
-    //Upgrade the current connection to WebSocket, set timeout to 30 seconds
-    //Timeout value: if after 30 seconds nothing has been send / received, the websocket will be closed
-    //set this value to 0 to display auto socket closing
-    succ = websocket.upgrade(30);
-    if (!succ){
-        console.log("WebSocket Upgrade Failed");
-        return false
+    // Upgrade to WebSocket; 120-second idle timeout
+    if (!websocket.upgrade(120)) {
+        console.log("WebSocket upgrade failed");
+        return false;
     }
 
-    console.log("WebSocket Opened!")
+    console.log("WebSocket opened");
     return true;
 }
 
-function waitForStart(){
-    websocket.send("Send 'start' to start websocket.send test");
-    var recv = "";
-    for (var i = 0; i < 10; i++){
-        //Read the websocket input from Client (Web UI)
-        recv = websocket.read();
-        if (recv == null){
-            console.log("Read Failed!")
-            return
-        }
-        if (recv != "start"){
-            websocket.send(recv + " reveived. Type 'start' to start testing. (Retry count: " + i + "/10)");
-        }else{
-            websocket.send("'start' command received. Starting test");
+function commandLoop() {
+    websocket.send("Connected. Commands: echo <text> | stop");
+
+    while (true) {
+        var msg = websocket.read();
+
+        // null means the connection was closed or timed out
+        if (msg == null) {
+            console.log("WebSocket read returned null — closing");
             break;
         }
-    }
-}
-
-function loop(i){
-    //If the process reach here, that means the WebSocket connection has been opened
-    console.log("Sending: Hello World: " + i);
 
-    //Sebd Hello World {i} to Client (Web UI)
-    websocket.send("Hello World: " + i);
+        msg = msg.trim();
 
-    //Wait for 1 second before next send
-    delay(1000);
+        if (msg === "stop") {
+            websocket.send("Bye!");
+            break;
+        } else if (msg.indexOf("echo ") === 0) {
+            websocket.send(msg.slice(5));
+        } else if (msg === "") {
+            // ignore empty messages
+        } else {
+            websocket.send("Unknown command: '" + msg + "'");
+        }
+    }
 }
 
-function closing(){
-    //Try to close the WebSocket connection
+if (setup()) {
+    commandLoop();
     websocket.close();
+} else {
+    console.log("WebSocket setup failed");
 }
-
-//Start executing the script
-if (setup()){
-    waitForStart();
-    for (var i = 0; i < 10; i++){
-        loop(i);
-    }
-    closing();
-}else{
-    console.log("WebSocket Setup Failed.")
-}
-

+ 281 - 61
src/web/UnitTest/wstest.html

@@ -4,92 +4,312 @@
         <meta name="apple-mobile-web-app-capable" content="yes" />
         <meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"/>
         <meta charset="UTF-8">
-        <meta name="theme-color" content="#4b75ff">
-        <link rel="stylesheet" href="../script/semantic/semantic.min.css">
+        <meta name="theme-color" content="#0078d4">
         <script src="../script/jquery.min.js"></script>
         <script src="../script/ao_module.js"></script>
-        <script src="../script/semantic/semantic.min.js"></script>
         <title>WebSocket Test</title>
+        <style>
+            *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+            body {
+                font-family: 'Segoe UI Variable', 'Segoe UI', system-ui, -apple-system, sans-serif;
+                font-size: 14px;
+                background: #f3f3f3;
+                color: #201f1e;
+                min-height: 100vh;
+            }
+
+            /* ── Nav ───────────────────────────────────────────────── */
+            .nav-bar { position: sticky; top: 0; z-index: 100; height: 48px; background: rgba(243,243,243,0.9); backdrop-filter: blur(20px) saturate(180%); border-bottom: 1px solid rgba(0,0,0,0.08); display: flex; align-items: center; padding: 0 20px; gap: 4px; }
+            .nav-brand { font-size: 13px; font-weight: 600; color: #201f1e; margin-right: 12px; white-space: nowrap; }
+            .nav-tab { display: inline-flex; align-items: center; height: 48px; padding: 0 12px; font-size: 13px; color: #605e5c; text-decoration: none; border-bottom: 2px solid transparent; transition: color 0.1s, border-color 0.1s; white-space: nowrap; }
+            .nav-tab:hover { color: #201f1e; }
+            .nav-tab.active { color: #0078d4; border-bottom-color: #0078d4; font-weight: 600; }
+
+            /* ── Layout ────────────────────────────────────────────── */
+            .content { max-width: 880px; margin: 24px auto; padding: 0 20px; }
+
+            /* ── Buttons ───────────────────────────────────────────── */
+            .btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 16px; border-radius: 4px; font-family: inherit; font-size: 13px; cursor: pointer; border: 1px solid transparent; transition: background 0.1s; line-height: 1; }
+            .btn-primary   { background: #0078d4; color: #fff; border-color: #0078d4; }
+            .btn-primary:hover   { background: #106ebe; }
+            .btn-primary:disabled  { opacity: 0.55; cursor: default; }
+            .btn-secondary { background: #fff; color: #201f1e; border-color: #d1d1d1; }
+            .btn-secondary:hover { background: #f5f5f5; }
+            .btn-danger    { background: #fff; color: #c50f1f; border-color: #f1b8bb; }
+            .btn-danger:hover    { background: #fde7e9; }
+            .btn-danger:disabled { opacity: 0.55; cursor: default; }
+
+            /* ── Connection card ───────────────────────────────────── */
+            .card {
+                background: #fff;
+                border: 1px solid #e0e0e0;
+                border-radius: 8px;
+                overflow: hidden;
+                box-shadow: 0 1px 3px rgba(0,0,0,0.05);
+                margin-bottom: 16px;
+            }
+
+            .card-header {
+                padding: 12px 16px;
+                border-bottom: 1px solid #f0f0f0;
+                display: flex;
+                align-items: center;
+                gap: 10px;
+            }
+
+            .card-title {
+                font-size: 13px;
+                font-weight: 600;
+                flex: 1;
+            }
+
+            .conn-pill {
+                display: inline-flex;
+                align-items: center;
+                gap: 6px;
+                padding: 3px 10px;
+                border-radius: 12px;
+                font-size: 11px;
+                font-weight: 600;
+                background: #f3f3f3;
+                color: #797775;
+                transition: background 0.2s, color 0.2s;
+            }
+            .conn-pill.connected   { background: #dff6dd; color: #107c10; }
+            .conn-pill.error-state { background: #fde7e9; color: #c50f1f; }
+
+            .conn-dot {
+                width: 6px; height: 6px;
+                border-radius: 50%;
+                background: #c0c0c0;
+                flex-shrink: 0;
+                transition: background 0.2s;
+            }
+            .conn-pill.connected   .conn-dot { background: #107c10; }
+            .conn-pill.error-state .conn-dot { background: #c50f1f; }
+
+            /* ── Message log ───────────────────────────────────────── */
+            .msg-log {
+                min-height: 320px;
+                max-height: 480px;
+                overflow-y: auto;
+                padding: 8px 0;
+                background: #fafafa;
+            }
+
+            .msg-log-empty {
+                padding: 60px 20px;
+                text-align: center;
+                color: #bbb;
+                font-size: 13px;
+            }
+
+            .msg-row {
+                display: flex;
+                align-items: baseline;
+                gap: 10px;
+                padding: 4px 16px;
+                border-left: 3px solid transparent;
+                font-family: 'Cascadia Code', 'Consolas', monospace;
+                font-size: 12px;
+                line-height: 1.5;
+            }
+            .msg-row:hover { background: rgba(0,0,0,0.02); }
+
+            .msg-time {
+                font-size: 10px;
+                color: #bbb;
+                white-space: nowrap;
+                flex-shrink: 0;
+                font-variant-numeric: tabular-nums;
+            }
+
+            .msg-icon { flex-shrink: 0; font-size: 11px; }
+
+            .msg-text { flex: 1; word-break: break-all; color: #323130; }
+
+            /* type variants */
+            .msg-system  { border-left-color: #d1d1d1; }
+            .msg-system  .msg-text { color: #797775; font-style: italic; }
+            .msg-sent    { border-left-color: #0078d4; }
+            .msg-sent    .msg-text { color: #004e8c; }
+            .msg-recv    { border-left-color: #107c10; }
+            .msg-recv    .msg-text { color: #0b5a0b; }
+            .msg-error   { border-left-color: #c50f1f; }
+            .msg-error   .msg-text { color: #c50f1f; }
+
+            /* ── Send row ──────────────────────────────────────────── */
+            .send-row {
+                display: flex;
+                gap: 8px;
+                padding: 12px 16px;
+                border-top: 1px solid #f0f0f0;
+                background: #fff;
+            }
+
+            .send-input {
+                flex: 1;
+                padding: 6px 10px;
+                border: 1px solid #d1d1d1;
+                border-radius: 4px;
+                font-family: 'Cascadia Code', 'Consolas', monospace;
+                font-size: 13px;
+                color: #201f1e;
+                background: #fff;
+                outline: none;
+            }
+            .send-input:focus { border-color: #0078d4; box-shadow: 0 0 0 1px #0078d4; }
+            .send-input:disabled { background: #f3f3f3; color: #a0a0a0; }
+
+            /* ── Toolbar ───────────────────────────────────────────── */
+            .toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
+        </style>
     </head>
     <body>
-        <br><br>
-        <div class="ui container">
-            <h3>WebSocket Testing Interface</h3>
-            <div class="ui form">
-                <div class="field">
-                    <label>Recv</label>
-                    <textarea id="incoming"></textarea>
+
+        <nav class="nav-bar">
+            <span class="nav-brand">Unit Tester</span>
+            <a class="nav-tab" href="index.html">AGI Scripts</a>
+            <a class="nav-tab" href="backend.html">ao_backend</a>
+            <a class="nav-tab active" href="wstest.html">WebSocket</a>
+            <a class="nav-tab" href="devmode.html">Dev Mode</a>
+        </nav>
+
+        <div class="content">
+
+            <div class="toolbar">
+                <button class="btn btn-primary"   id="btnConnect"    onclick="openws()">Connect</button>
+                <button class="btn btn-danger"     id="btnDisconnect" onclick="closews()" disabled>Disconnect</button>
+                <button class="btn btn-secondary"  onclick="clearLog()">Clear Log</button>
+            </div>
+
+            <!-- Connection + log card -->
+            <div class="card">
+                <div class="card-header">
+                    <span class="card-title">WebSocket Log</span>
+                    <span class="conn-pill" id="connPill">
+                        <span class="conn-dot"></span>
+                        <span id="connLabel">Not connected</span>
+                    </span>
                 </div>
-                <div class="field">
-                    <label>Send</label>
-                    <input type="text" id="sendMsg">
+
+                <div class="msg-log" id="msgLog">
+                    <div class="msg-log-empty" id="logEmpty">Connect to start the session&hellip;</div>
                 </div>
-                <div class="field">
-                   <button class="ui blue button" onclick="sendws();">Send</button>
+
+                <div class="send-row">
+                    <input class="send-input" id="sendMsg" type="text"
+                           placeholder="Type a message and press Enter or Send"
+                           disabled
+                           onkeydown="if(event.key==='Enter' && !this.disabled) sendws()">
+                    <button class="btn btn-primary" id="btnSend" onclick="sendws()" disabled>Send</button>
                 </div>
             </div>
-            <br>
-            <button class="ui button" onclick="openws();">Open Connection</button>
+
         </div>
 
         <script>
-            var ws;
+            var ws = null;
 
-            $(window).ready(function(){
-                $("#incoming").val("");
-            });
-
-            //Send WebSocket
-            function sendws(){
-                var value = $("#sendMsg").val();
-                ws.send(value);
-                log("✉️ " + value)
-                $("#sendMsg").val("");
-            }
+            /* ── Connection ──────────────────────────────────────── */
+            function openws() {
+                if (ws) ws.close();
+                logMsg("system", "Opening WebSocket…");
+                let endpoint = getWSEndpoint() + "/system/ajgi/interface?script=UnitTest/special/websocket.js";
+                ws = new WebSocket(endpoint);
 
-            //Open WebSocket connection to test script
-            function openws(){
-                log("⏱️ Opening WebSocket...");
-                let socket = new WebSocket(getWSEndpoint() + "/system/ajgi/interface?script=UnitTest/special/websocket.js");
-                ws = socket;
-
-                socket.onopen = function(e) {
-                    log("✔️ Socket Opened");
+                ws.onopen = function() {
+                    setConnState("connected", "Connected");
+                    logMsg("system", "Socket opened");
+                    setSendEnabled(true);
                 };
 
-                socket.onmessage = function(event) {
-                    log(`✔️ Received: ${event.data}`);
+                ws.onmessage = function(e) {
+                    logMsg("recv", e.data);
                 };
 
-                socket.onclose = function(event) {
-                    if (event.wasClean) {
-                        log(`📪 Connection Closed Cleanly code=${event.code} reason=${event.reason}`);
-                    } else {
-                        // e.g. server process killed or network down
-                        // event.code is usually 1006 in this case
-                        log(`❌ Connection Closed Unexpectedly`);
-                    }
+                ws.onclose = function(e) {
+                    setConnState("", "Disconnected");
+                    setSendEnabled(false);
+                    logMsg("system", e.wasClean
+                        ? "Connection closed cleanly (code " + e.code + ")"
+                        : "Connection closed unexpectedly");
+                    ws = null;
                 };
 
-                socket.onerror = function(error) {
-                    log(`❌ ERROR! ${error.message}`);
+                ws.onerror = function() {
+                    setConnState("error-state", "Error");
+                    logMsg("error", "WebSocket error — check the console for details");
                 };
             }
 
-            function log(content){
-                $("#incoming").val($("#incoming").val() + content + "\n");
-                $("#incoming").scrollTop($("#incoming")[0].scrollHeight);
+            function closews() {
+                if (ws) {
+                    ws.close();
+                    logMsg("system", "Closing connection…");
+                }
             }
 
-            function getWSEndpoint(){
-                //Open opeartion in websocket
-                let protocol = "wss://";
-                if (location.protocol !== 'https:') {
-                    protocol = "ws://";
-                }
-                wsControlEndpoint = (protocol + window.location.hostname + ":" + window.location.port);
-                return wsControlEndpoint;
+            function sendws() {
+                let input = document.getElementById("sendMsg");
+                let msg = input.value.trim();
+                if (!msg || !ws || ws.readyState !== WebSocket.OPEN) return;
+                ws.send(msg);
+                logMsg("sent", msg);
+                input.value = "";
+            }
+
+            function clearLog() {
+                document.getElementById("msgLog").innerHTML =
+                    '<div class="msg-log-empty" id="logEmpty">Log cleared.</div>';
+            }
+
+            /* ── UI helpers ──────────────────────────────────────── */
+            function setConnState(cls, label) {
+                let pill = document.getElementById("connPill");
+                pill.className = "conn-pill" + (cls ? " " + cls : "");
+                document.getElementById("connLabel").textContent = label;
+                let connectBtn    = document.getElementById("btnConnect");
+                let disconnectBtn = document.getElementById("btnDisconnect");
+                let connected = (cls === "connected");
+                connectBtn.disabled    = connected;
+                disconnectBtn.disabled = !connected;
+            }
+
+            function setSendEnabled(on) {
+                document.getElementById("sendMsg").disabled  = !on;
+                document.getElementById("btnSend").disabled  = !on;
+                if (on) document.getElementById("sendMsg").focus();
+            }
+
+            function logMsg(type, text) {
+                let log = document.getElementById("msgLog");
+                let empty = document.getElementById("logEmpty");
+                if (empty) empty.remove();
+
+                let icons = { system: "·", sent: "↑", recv: "↓", error: "✕" };
+                let now = new Date();
+                let ts  = now.toTimeString().slice(0,8);
+
+                let row = document.createElement("div");
+                row.className = "msg-row msg-" + type;
+                row.innerHTML =
+                    '<span class="msg-time">' + ts + '</span>' +
+                    '<span class="msg-icon">' + (icons[type] || "·") + '</span>' +
+                    '<span class="msg-text">' + escHtml(text) + '</span>';
+                log.appendChild(row);
+                log.scrollTop = log.scrollHeight;
+            }
+
+            function getWSEndpoint() {
+                let proto = location.protocol === "https:" ? "wss://" : "ws://";
+                return proto + location.hostname + ":" + location.port;
+            }
+
+            function escHtml(s) {
+                return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
             }
         </script>
     </body>
-</html>
+</html>

+ 20 - 3
src/web/script/ao_module.js

@@ -626,8 +626,13 @@ function ao_module_parentCallback(data=""){
 
 
 function ao_module_agirun(scriptpath, data, callback, failedcallback = undefined, timeout=0){
+    let devmode = (typeof AGI_DEV !== 'undefined' && AGI_DEV === true);
+    let url = ao_root + "system/ajgi/interface?script=" + scriptpath;
+    if (devmode) {
+        url += "&agi_devmode=true";
+    }
     $.ajax({
-        url: ao_root + "system/ajgi/interface?script=" + scriptpath,
+        url: url,
         method: "POST",
         data: data,
         success: function(data){
@@ -635,9 +640,21 @@ function ao_module_agirun(scriptpath, data, callback, failedcallback = undefined
                 callback(data);
             }
         },
-        error: function(){
+        error: function(xhr){
+            if (devmode) {
+                try {
+                    let errInfo = JSON.parse(xhr.responseText);
+                    console.error("[AGI Dev] Error in script: " + scriptpath);
+                    console.error("[AGI Dev] Message: " + errInfo.message);
+                    if (errInfo.stacktrace && errInfo.stacktrace !== errInfo.message) {
+                        console.error("[AGI Dev] Stack Trace:\n" + errInfo.stacktrace);
+                    }
+                } catch(e) {
+                    console.error("[AGI Dev] Error in script: " + scriptpath + "\n" + xhr.responseText);
+                }
+            }
             if (typeof(failedcallback) != "undefined"){
-                failedcallback();
+                failedcallback(xhr);
             }
         },
         timeout: timeout