import assert from "node:assert/strict"; import fs from "node:fs"; import vm from "node:vm"; const sourcePath = new URL("../web/js/o1keyUpdateButton.js", import.meta.url); const source = fs.readFileSync(sourcePath, "utf8").replace(/^import .*;\s*$/gm, ""); class Element { constructor(tagName) { this.tagName = tagName; this.children = []; this.listeners = new Map(); this.attributes = new Map(); this.style = {}; this.dataset = {}; this.className = ""; this.textContent = ""; this.disabled = false; this.open = false; this.parentElement = null; this.classList = { add: (...names) => this.updateClasses(names, []), remove: (...names) => this.updateClasses([], names), }; } updateClasses(add, remove) { const names = new Set(this.className.split(/\s+/).filter(Boolean)); for (const name of remove) names.delete(name); for (const name of add) names.add(name); this.className = [...names].join(" "); } append(...elements) { for (const element of elements) { if (element.parentElement) { const siblings = element.parentElement.children; siblings.splice(siblings.indexOf(element), 1); } element.parentElement = this; this.children.push(element); } } appendChild(element) { this.append(element); } after(element) { if (element.parentElement) { const siblings = element.parentElement.children; siblings.splice(siblings.indexOf(element), 1); } const siblings = this.parentElement.children; siblings.splice(siblings.indexOf(this) + 1, 0, element); element.parentElement = this.parentElement; } get nextElementSibling() { const siblings = this.parentElement?.children ?? []; return siblings[siblings.indexOf(this) + 1] ?? null; } cloneNode(deep) { const clone = new Element(this.tagName); clone.id = this.id; clone.type = this.type; clone.className = this.className; clone.textContent = this.textContent; clone.attributes = new Map(this.attributes); if (deep) clone.append(...this.children.map((child) => child.cloneNode(true))); return clone; } querySelector(selector) { const test = (element) => selector.startsWith("#") ? element.id === selector.slice(1) : element.className.split(/\s+/).includes(selector.slice(1)); for (const child of this.children) { if (test(child)) return child; const nested = child.querySelector(selector); if (nested) return nested; } return null; } setAttribute(name, value) { this.attributes.set(name, value); } removeAttribute(name) { this.attributes.delete(name); } addEventListener(name, listener) { this.listeners.set(name, listener); } showModal() { this.open = true; } close() { this.open = false; } } const body = new Element("body"); const head = new Element("head"); const toolbar = new Element("div"); body.append(toolbar); const document = { body, head, createElement: (tag) => new Element(tag), querySelector: (selector) => body.querySelector(selector) || head.querySelector(selector), }; let extension; let observer; const requests = []; const responses = []; let fetchFailure = false; let dismissNotice; const reply = (body, status = 200) => ({ ok: status < 400, status, async json() { return body; } }); const app = { registerExtension(value) { extension = value; } }; const api = { async fetchApi(path, options) { requests.push({ path, options }); if (fetchFailure) throw new Error("offline"); return responses.shift(); }, }; class MutationObserver { constructor(callback) { this.callback = callback; observer = this; } observe() {} disconnect() { this.disconnected = true; } } const window = { setTimeout(callback) { dismissNotice = callback; return 1; }, clearTimeout() { dismissNotice = null; }, }; vm.runInNewContext(source, { app, api, document, window, MutationObserver }); extension.setup(); assert.ok(observer); const token = new Element("button"); token.id = "o1key-token-manager-button"; token.className = "side-bar-button o1key-token-manager-button"; const tokenIcon = new Element("span"); tokenIcon.className = "pi pi-key side-bar-button-icon"; const tokenLabel = new Element("span"); tokenLabel.className = "side-bar-button-label"; tokenLabel.textContent = "令牌管理"; token.append(tokenIcon, tokenLabel); const restart = new Element("button"); restart.id = "o1key-restart-button"; toolbar.append(token, restart); observer.callback(); const update = document.querySelector("#o1key-update-button"); const detect = document.querySelector("#o1key-detect-button"); assert.equal(toolbar.children[0], token); assert.equal(toolbar.children[1], update); assert.equal(toolbar.children[2], detect); assert.equal(toolbar.children[3], restart); assert.equal(update.querySelector(".side-bar-button-label").textContent, "更新"); assert.match(update.querySelector(".side-bar-button-icon").className, /pi-download/); assert.equal(detect.querySelector(".side-bar-button-label").textContent, "检测"); assert.match(detect.querySelector(".side-bar-button-icon").className, /pi-check-circle/); assert.ok(observer.disconnected); extension.setup(); assert.equal(toolbar.children.length, 4); detect.listeners.get("click")(); const notice = document.querySelector("#o1key-detect-notice"); assert.equal(notice.children[1].textContent, "检测按钮已加载"); assert.equal(notice.hidden, false); assert.equal(requests.length, 0); dismissNotice(); assert.equal(notice.hidden, true); detect.listeners.get("click")(); assert.equal(notice.hidden, false); assert.equal(document.body.children.filter((child) => child.id === "o1key-detect-notice").length, 1); responses.push(reply({ update_available: false })); update.listeners.get("click")(); const checkingDialog = document.querySelector("#o1key-update-dialog"); const checkingIcon = checkingDialog.children[0].children[1].children[1].children[0]; assert.equal(checkingDialog.dataset.state, "checking"); assert.match(checkingIcon.className, /is-loading/); assert.doesNotMatch(checkingIcon.className, /pi-spin/); assert.match(head.children[0].textContent, /flex: 0 0 18px/); assert.match(head.children[0].textContent, /animation: o1key-update-spin/); assert.match(head.children[0].textContent, /--p-primary-500/); await new Promise(setImmediate); const dialog = document.querySelector("#o1key-update-dialog"); assert.equal(dialog.open, true); const [header, content, actions] = dialog.children[0].children; const [description, resultCard] = content.children; const [status, suggestion] = resultCard.children[1].children; const [secondary, primary] = actions.children; assert.equal(header.children[1].children[1].textContent, "更新 O1Key 节点包"); assert.equal(description.textContent, "检查是否有新版本可用。"); assert.doesNotMatch(source, /git\.o1key\.com|发布仓库|main 分支|requirements\.txt/); assert.doesNotMatch(source, /window\.confirm/); assert.equal(requests.length, 1); assert.equal(requests[0].path, "/o1key/update/check"); assert.equal(requests[0].options.cache, "no-store"); assert.match(status.textContent, /已是最新版本/); assert.equal(dialog.dataset.state, "current"); assert.equal(resultCard.dataset.kind, "success"); assert.equal(primary.textContent, "重新检查"); assert.equal(head.children.length, 1); responses.push(reply({ update_available: false })); await primary.listeners.get("click")(); assert.equal(requests[1].path, "/o1key/update/check"); assert.match(status.textContent, /已是最新版本/); responses.push(reply({ update_available: true })); await primary.listeners.get("click")(); assert.equal(requests.length, 3); assert.match(status.textContent, /发现新版本/); assert.equal(dialog.dataset.state, "available"); assert.equal(primary.textContent, "立即更新"); assert.equal(secondary.textContent, "稍后再说"); assert.equal(resultCard.dataset.kind, "available"); const requestsBeforeDecline = requests.length; secondary.listeners.get("click")(); assert.equal(dialog.open, false); assert.equal(requests.length, requestsBeforeDecline); responses.push(reply({ update_available: true })); update.listeners.get("click")(); await new Promise(setImmediate); assert.equal(dialog.open, true); assert.equal(dialog.dataset.state, "available"); responses.push(reply({ updated: true, version: "abc1234", requirements_changed: false })); await primary.listeners.get("click")(); assert.equal(requests.at(-1).path, "/o1key/update"); assert.equal(requests.at(-1).options.method, "POST"); assert.equal(requests.at(-1).options.headers["X-O1Key-Update"], "1"); assert.match(status.textContent, /更新完成/); assert.match(suggestion.textContent, /重启 ComfyUI/); assert.equal(primary.textContent, "完成"); assert.equal(dialog.dataset.state, "success"); responses.push(reply({ update_available: true })); await dialog.checkForUpdate(); responses.push(reply({ updated: true, requirements_changed: true })); await primary.listeners.get("click")(); assert.match(suggestion.textContent, /联系技术支持/); assert.doesNotMatch(suggestion.textContent, /requirements\.txt/); const requestsBeforeError = requests.length; responses.push(reply({ code: "local_changes", error: "插件目录有未提交的代码修改。", suggestion: "请先提交" }, 409)); await dialog.checkForUpdate(); assert.equal(requests.length, requestsBeforeError + 1); assert.match(status.textContent, /自定义修改/); assert.match(suggestion.textContent, /联系技术支持/); assert.doesNotMatch(status.textContent + suggestion.textContent, /插件目录|提交|Git/); assert.equal(dialog.dataset.state, "error"); assert.equal(resultCard.dataset.kind, "error"); responses.push({ ok: false, status: 404 }); await primary.listeners.get("click")(); assert.match(status.textContent, /尚未就绪/); responses.push({ ok: false, status: 500, async json() { throw new Error("invalid JSON"); } }); await primary.listeners.get("click")(); assert.match(status.textContent, /无法读取检查结果/); fetchFailure = true; await primary.listeners.get("click")(); assert.match(status.textContent, /暂时无法完成操作/); assert.equal(primary.disabled, false); assert.equal(head.children.length, 1); header.children[2].listeners.get("click")(); assert.equal(dialog.open, false);