Polish customer update flow and prepare ZIP installation
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user