237 lines
9.5 KiB
JavaScript
237 lines
9.5 KiB
JavaScript
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;
|
|
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; }
|
|
}
|
|
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");
|
|
assert.equal(toolbar.children[0], token);
|
|
assert.equal(toolbar.children[1], update);
|
|
assert.equal(toolbar.children[2], restart);
|
|
assert.equal(update.querySelector(".side-bar-button-label").textContent, "更新");
|
|
assert.match(update.querySelector(".side-bar-button-icon").className, /pi-download/);
|
|
assert.ok(observer.disconnected);
|
|
extension.setup();
|
|
assert.equal(toolbar.children.length, 3);
|
|
|
|
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);
|