From d8cc78f0b9ce1d5a42b496c4a6ecc619c2a230fd Mon Sep 17 00:00:00 2001 From: argszero Date: Thu, 6 Aug 2026 07:23:25 +0800 Subject: [PATCH 1/2] =?UTF-8?q?emrg:=20GUI=20renderer=20=E5=86=92=E7=83=9F?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=20=E2=80=94=205=20=E7=94=A8=E4=BE=8B?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E5=8A=A0=E8=BD=BD/boot/=E6=B5=81=E5=BC=8F/?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E8=A1=8C/=E5=A4=9A=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=EF=BC=88P6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- emrg/gui/test/renderer.smoke.test.js | 236 +++++++++++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 emrg/gui/test/renderer.smoke.test.js diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js new file mode 100644 index 00000000..1693fd8c --- /dev/null +++ b/emrg/gui/test/renderer.smoke.test.js @@ -0,0 +1,236 @@ +"use strict"; +/** + * renderer.smoke.test.js — renderer 层冒烟测试(node:test,零依赖)。 + * 覆盖:7 个 JS 模块按 index.html 顺序加载无崩溃、boot 两条路径(config 缺失→首启 / config 就绪→会话)、 + * 流式 delta 追加、工具行 running→done 状态流转、多模型管理加载/保存。 + * 背景:renderer 是 GUI 重设计(#417)核心,CI 此前仅 node --check 语法检查,逻辑零覆盖。 + * 方法:vm.createContext 模拟浏览器全局(DOM mock + window.emrg),逐模块 runInContext。 + */ + +const { test } = require("node:test"); +const assert = require("node:assert"); +const fs = require("node:fs"); +const path = require("node:path"); +const vm = require("node:vm"); + +const RENDERER_JS = path.join(__dirname, "..", "renderer", "js"); + +// ── DOM mock(最小但真实:classList 操作同步到 className) ── +function makeEl(id) { + const node = { + id, + children: [], + dataset: {}, + style: {}, + attributes: {}, + _cls: new Set(), + classList: { + add(c) { node._cls.add(c); node._update(); }, + remove(c) { node._cls.delete(c); node._update(); }, + toggle(c) { node._cls.has(c) ? node._cls.delete(c) : node._cls.add(c); node._update(); }, + contains(c) { return node._cls.has(c); }, + }, + _update() { node.className = [...node._cls].join(" "); }, + className: "", + textContent: "", + innerHTML: "", + value: "", + disabled: false, + title: "", + checked: false, + selectedIndex: -1, + scrollTop: 0, + scrollHeight: 0, + clientHeight: 100, + open: false, + appendChild(c) { this.children.push(c); return c; }, + addEventListener() {}, + querySelector() { return null; }, + querySelectorAll() { return []; }, + closest() { return null; }, + showModal() { this.open = true; }, + close() { this.open = false; }, + setAttribute(k, v) { this.attributes[k] = v; if (k === "value") this.value = v; }, + removeAttribute(k) { delete this.attributes[k]; }, + insertBefore(c) { this.children.unshift(c); return c; }, + remove() {}, + focus() {}, + }; + return node; +} + +const ELEMENT_IDS = [ + "chat-view", "input", "send-btn", "stop-btn", "conv-list", "status-dot", "settings-btn", + "conn-banner", "empty-state", "model-switcher", "model-switcher-label", "brand-star", "new-chat-btn", + "settings-dialog", "settings-cancel", "settings-save", "set-api-key", "set-base-url", "set-project-dir", + "set-model", "pick-dir-btn", "theme-options", "welcome-dialog", "welcome-api-key", "welcome-base-url", + "welcome-model", "welcome-project-dir", "welcome-pick-btn", "welcome-save", "confirm-dialog", + "confirm-title", "confirm-message", "confirm-cancel", "confirm-ok", "main", + "model-list", "add-model-btn", "model-form", "model-form-name", "model-form-id", + "model-form-vision", "model-form-save", "model-form-cancel", +]; + +/** 构造浏览器沙箱(win 即全局对象) */ +function makeSandbox(overrides = {}) { + const els = {}; + for (const id of ELEMENT_IDS) els[id] = makeEl(id); + const document = { + getElementById: (id) => els[id] || makeEl(id), + createElement: (tag) => makeEl(tag), + createTextNode: (t) => ({ text: t }), + querySelector: () => null, + querySelectorAll: () => [], + addEventListener() {}, + documentElement: { setAttribute() {}, removeAttribute() {} }, + body: { classList: { add() {}, remove() {}, toggle() {} } }, + }; + const win = { + console, + setTimeout, + clearTimeout, + Date, + Math, + JSON, + String, + Number, + Object, + Array, + RegExp, + Map, + Set, + Promise, + requestAnimationFrame: (cb) => cb(), + requestIdleCallback: (cb) => cb(), + crypto: { randomUUID: () => "mock-uuid" }, + DOMPurify: { sanitize: (x) => x }, + marked: null, + hljs: null, + emrg: { + init: async () => ({ config_exists: false, api_key_configured: false, project_dir: "", project_dir_valid: true, server_id: "", model: "", evolution_count: 0, sessions: [] }), + onEvent() {}, + sendMessage: async () => ({}), + cancel: async () => ({}), + getSettings: async () => ({ apiKey: "", baseUrl: "", model: "", projectDir: "", models: [], modelDetails: [], theme: "system" }), + saveSettings: async () => ({}), + pickProjectDir: async () => null, + listSessions: async () => [], + switchSession: async () => ({}), + newSession: async () => ({ session_id: "s2" }), + deleteSession: async () => ({}), + setModel: async () => ({}), + ...overrides, + }, + }; + win.window = win; + win.document = document; + const ctx = vm.createContext(win); + for (const f of ["utils", "markdown", "copywriting", "chat", "sidebar", "dialogs", "app"]) { + const code = fs.readFileSync(path.join(RENDERER_JS, f + ".js"), "utf8"); + vm.runInContext(code, ctx, { filename: "renderer/js/" + f + ".js" }); + } + return { ctx, win, els, document }; +} + +/** 等 microtask 完成 */ +const tick = () => new Promise((r) => setTimeout(r, 20)); + +test("7 模块按序加载且全局符号解析", () => { + const { ctx } = makeSandbox(); + const out = vm.runInContext( + "(function(){ return { App: typeof App, Chat: typeof EMRG_Chat, Copy: typeof EMRG_Copy, Sidebar: typeof EMRG_Sidebar, Dialogs: typeof EMRG_Dialogs, utils: typeof $ }; })()", + ctx + ); + // ⚠️ vm 上下文对象原型不同 Realm → 不用 deepStrictEqual,逐个字段断言 + assert.strictEqual(out.App, "object"); + assert.strictEqual(out.Chat, "object"); + assert.strictEqual(out.Copy, "object"); + assert.strictEqual(out.Sidebar, "object"); + assert.strictEqual(out.Dialogs, "object"); + assert.strictEqual(out.utils, "function"); +}); + +test("boot:config 缺失 → 首启引导(不拉起 daemon)", async () => { + const { ctx, els } = makeSandbox(); + await tick(); + // 首启路径:welcome-dialog 应 open(mock showModal 置 open=true) + assert.ok(els["welcome-dialog"].open, "welcome-dialog 应打开"); +}); + +test("boot:config 就绪 → 加载会话列表", async () => { + const { ctx, els } = makeSandbox({ + init: async () => ({ + config_exists: true, + api_key_configured: true, + project_dir: "/tmp", + project_dir_valid: true, + server_id: "srv-1", + model: "deepseek-chat", + evolution_count: 42, + sessions: [{ session_id: "s1", title: "测试对话", updated_at: "2026-08-05T10:00:00Z" }], + }), + switchSession: async () => ({}), + }); + await tick(); + // conv-list 应有分组标签 + 会话项 + const items = vm.runInContext('document.getElementById("conv-list").children.length', ctx); + assert.ok(items >= 2, `conv-list 应有分组标签+会话项,实际 ${items}`); +}); + +test("流式 delta 追加 + 工具行 running→done 状态流转", async () => { + const { ctx, els } = makeSandbox(); + await tick(); + const r = vm.runInContext(`(function() { + App.state.sessionId = "s1"; + App.state.ownStreamRequestId = "rid-1"; + EMRG_Chat.handleDelta([{ request_id: "rid-1", content: "你好" }]); + EMRG_Chat.handleDelta([{ request_id: "rid-1", content: ",世界" }]); + EMRG_Chat.handleToolStart({ request_id: "rid-1", tool_call_id: "t1", tool_name: "read" }); + EMRG_Chat.handleToolEnd({ tool_call_id: "t1", tool_name: "read", content: "file contents", elapsed: 0.3 }); + EMRG_Chat.handleDone({ request_id: "rid-1" }); + return { + chatChildren: $("chat-view").children.length, + toolRowClass: $("chat-view").children[1] ? $("chat-view").children[1].className : "none", + }; + })()`, ctx); + assert.strictEqual(r.chatChildren, 2, "用户流 + 工具行 2 个节点"); + assert.ok(r.toolRowClass.includes("done"), `工具行应 done,实际 ${r.toolRowClass}`); +}); + +test("多模型管理:modelDetails 加载渲染 + saveSettings 传 models 数组", async () => { + let saved = null; + const { ctx, els } = makeSandbox({ + getSettings: async () => ({ + apiKey: "sk-x", + baseUrl: "", + model: "deepseek-chat", + projectDir: "/tmp", + theme: "system", + models: ["deepseek-chat", "gpt-4o"], + modelDetails: [ + { name: "deepseek-chat", vision: false }, + { name: "gpt-4o", model: "gpt-4o", vision: true }, + ], + }), + saveSettings: async (cfg) => { + saved = cfg; + return { ok: true }; + }, + }); + await tick(); + await vm.runInContext("EMRG_Dialogs.showSettings()", ctx); + const rows = vm.runInContext('document.getElementById("model-list").children.length', ctx); + assert.ok(rows >= 2, `模型列表应 ≥2 行(默认 + gpt-4o),实际 ${rows}`); + + vm.runInContext( + 'document.getElementById("set-api-key").value = "sk-x"; document.getElementById("set-base-url").value = ""; document.getElementById("set-project-dir").value = "/tmp"', + ctx + ); + await vm.runInContext("EMRG_Dialogs.saveSettings()", ctx); + assert.ok(saved, "saveSettings 应被调用"); + assert.strictEqual(saved.model, "deepseek-chat"); + assert.strictEqual(saved.theme, "system"); + assert.ok(Array.isArray(saved.models)); + assert.strictEqual(saved.models.length, 2, "models 数组含默认 + gpt-4o"); + const gpt = saved.models.find((m) => m.name === "gpt-4o"); + assert.strictEqual(gpt.vision, true); +}); From b466da75a0d83d76c74f10519555137017ace01a Mon Sep 17 00:00:00 2001 From: argszero Date: Thu, 6 Aug 2026 07:26:54 +0800 Subject: [PATCH 2/2] =?UTF-8?q?emrg:=20GUI=20=E5=9B=9E=E5=88=B0=E5=BA=95?= =?UTF-8?q?=E9=83=A8=E6=82=AC=E6=B5=AE=E6=8C=89=E9=92=AE=20=E2=80=94=20?= =?UTF-8?q?=E4=B8=8A=E6=BB=91=E9=98=85=E8=AF=BB=E5=87=BA=E7=8E=B0=E2=86=93?= =?UTF-8?q?=EF=BC=8C=E7=82=B9=E5=87=BB=E5=9B=9E=E5=BA=95=EF=BC=88rant=20?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E8=8A=82=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- emrg/gui/renderer/css/components.css | 29 ++++++++++++++++++++++++++++ emrg/gui/renderer/index.html | 2 ++ emrg/gui/renderer/js/app.js | 15 +++++++++++--- emrg/gui/test/renderer.smoke.test.js | 2 +- 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/emrg/gui/renderer/css/components.css b/emrg/gui/renderer/css/components.css index d4bed6d6..9bc21362 100644 --- a/emrg/gui/renderer/css/components.css +++ b/emrg/gui/renderer/css/components.css @@ -431,6 +431,35 @@ cursor: pointer; } +/* 回到底部悬浮按钮(上滑阅读时出现,不打扰) */ +.back-to-bottom { + position: absolute; + bottom: 96px; + left: 50%; + transform: translateX(-50%); + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--bg-panel); + border: 1px solid var(--border); + box-shadow: var(--shadow-md); + color: var(--text-2); + font-size: 18px; + display: flex; + align-items: center; + justify-content: center; + z-index: 15; + transition: + opacity var(--dur-med) var(--ease), + background-color var(--dur-fast) var(--ease), + box-shadow var(--dur-fast) var(--ease); +} +.back-to-bottom:hover { + background: var(--accent); + color: #fff; + box-shadow: var(--shadow-lg); +} + /* ── 连接状态圆点 ─────────────────────── */ .status-dot { width: 9px; diff --git a/emrg/gui/renderer/index.html b/emrg/gui/renderer/index.html index 86eca2ae..fb1bfbc9 100644 --- a/emrg/gui/renderer/index.html +++ b/emrg/gui/renderer/index.html @@ -34,6 +34,8 @@
+ +