Polish customer update flow and prepare ZIP installation

This commit is contained in:
Jony
2026-09-24 21:52:01 +08:00
parent ba920f2b66
commit 0cf99740c0
8 changed files with 740 additions and 165 deletions
+201 -57
View File
@@ -2,7 +2,6 @@ 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, "");
@@ -11,82 +10,227 @@ class Element {
this.tagName = tagName;
this.children = [];
this.listeners = new Map();
this.attributes = new Map();
this.style = {};
this.disabled = false;
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),
};
}
append(...children) { this.children.push(...children); }
replaceChildren(...children) { this.children = [...children]; }
setAttribute(name, value) { this[name] = value; }
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; }
}
let extension;
let tab;
let requests = 0;
let fetchFailure = false;
let response = { ok: true, async json() { return { updated: true, version: "abc1234", requirements_changed: false }; } };
const app = {
registerExtension(value) { extension = value; },
extensionManager: {
getSidebarTabs() { return tab ? [tab] : []; },
registerSidebarTab(value) { tab = value; },
},
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++;
assert.equal(path, "/o1key/update");
assert.equal(options.method, "POST");
assert.equal(options.headers["X-O1Key-Update"], "1");
requests.push({ path, options });
if (fetchFailure) throw new Error("offline");
return response;
return responses.shift();
},
};
vm.runInNewContext(source, { app, api, document: { createElement: (tag) => new Element(tag) }, window: { confirm: () => true } });
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.equal(tab.id, "o1key-update");
assert.equal(tab.title, "更新");
const firstTab = tab;
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(tab, firstTab);
assert.equal(toolbar.children.length, 3);
const container = new Element("div");
tab.render(container);
const [, description, sourceLabel, button, status, suggestion] = container.children[0].children;
assert.match(description.textContent, /安全快进/);
assert.match(sourceLabel.textContent, /git\.o1key\.com\/publisher\/comfyui_o1key/);
await button.listeners.get("click")();
assert.equal(requests, 1);
assert.match(status.textContent, /abc1234/);
assert.match(suggestion.textContent, /重启 ComfyUI/);
response = { ok: true, async json() { return { updated: true, version: "def5678", requirements_changed: true }; } };
await button.listeners.get("click")();
assert.equal(requests, 2);
assert.match(suggestion.textContent, /安装 requirements.txt/);
response = { ok: true, async json() { return { updated: false, version: "def5678" }; } };
await button.listeners.get("click")();
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.match(suggestion.textContent, /无需重启/);
assert.equal(dialog.dataset.state, "current");
assert.equal(resultCard.dataset.kind, "success");
assert.equal(primary.textContent, "重新检查");
assert.equal(head.children.length, 1);
response = { ok: false, status: 409, async json() { return { code: "local_changes", error: "插件目录有本地修改", suggestion: "请先保存修改" }; } };
await button.listeners.get("click")();
assert.match(status.textContent, /本地修改/);
assert.match(suggestion.textContent, /请先保存修改/);
responses.push(reply({ update_available: false }));
await primary.listeners.get("click")();
assert.equal(requests[1].path, "/o1key/update/check");
assert.match(status.textContent, /已是最新版本/);
response = { ok: false, status: 404 };
await button.listeners.get("click")();
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");
response = { ok: false, status: 500, async json() { throw new Error("invalid JSON"); } };
await button.listeners.get("click")();
assert.match(status.textContent, /无法读取/);
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 button.listeners.get("click")();
assert.match(status.textContent, /无法连接本地 ComfyUI/);
assert.equal(button.disabled, false);
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);
+10
View File
@@ -48,11 +48,14 @@ class UpdaterTests(unittest.TestCase):
self.git(self.author, "push", "origin", "main")
def test_fast_forward_and_requirements_change(self):
self.assertFalse(updater.check_for_update()["update_available"])
self.assertFalse(updater.update_package()["updated"])
# The release URL, not the user's origin, determines the update source.
self.git(self.install, "remote", "set-url", "origin", str(self.install / "unused-origin"))
(self.author / "requirements.txt").write_text("requests>=3\n", encoding="utf-8")
self.commit_and_push()
self.assertTrue(updater.check_for_update()["update_available"])
self.assertEqual((self.install / "requirements.txt").read_text(encoding="utf-8"), "requests>=2\n")
result = updater.update_package()
self.assertTrue(result["updated"])
self.assertTrue(result["requirements_changed"])
@@ -60,6 +63,13 @@ class UpdaterTests(unittest.TestCase):
def test_local_modification_is_preserved(self):
(self.install / "version.txt").write_text("local work\n", encoding="utf-8")
self.assertFalse(updater.check_for_update()["update_available"])
self.assertFalse(updater.update_package()["updated"])
(self.author / "version.txt").write_text("new release\n", encoding="utf-8")
self.commit_and_push()
with self.assertRaises(updater.UpdateError) as caught:
updater.check_for_update()
self.assertEqual(caught.exception.code, "local_changes")
with self.assertRaises(updater.UpdateError) as caught:
updater.update_package()
self.assertEqual(caught.exception.code, "local_changes")