后端:ModelConfig.is_default + migration;get_default_model 优先 is_default 否则回落最早 active(零回归); adminpanel providers CRUD(api_key write-only 不回传)+ models CRUD(?provider/capability 筛)+ set-default(同 capability 清旧); IsPlatformAdmin + 审计。 前端:adminApi providers/models/setDefault;Admin 模型供应商页(供应商表+模型表+启停+定价+设默认+供应商/模型弹窗)。 测试:adminpanel 51 单测过(api_key 隐藏+入库/CRUD/set-default 改 get_default_model); apps.ai 仍仅 3 个进场前既有失败(零回归);无头 e2e _admin-p8.mjs 5 断言过 + 0 console error (用一次性禁用 provider+model capability=export,无副作用,跑完删);tsc+build 绿。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
93 lines
4.8 KiB
JavaScript
93 lines
4.8 KiB
JavaScript
// Phase 8 e2e:模型供应商 —— 列表渲染 + UI 设默认 / 编辑定价 / 启停。
|
|
// ⚠ 该页驱动真实生成路由:用一次性「禁用」供应商 + 模型(capability=export,不碰 image/text/video 默认),
|
|
// 全程不动真模型,跑完 API 删除。set-default 作用在禁用模型上(get_default_model 只取 active,无副作用)。
|
|
import { chromium } from "playwright";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
|
|
const BASE = process.env.BASE || "http://127.0.0.1:5188";
|
|
const API = process.env.API || "http://127.0.0.1:8010";
|
|
const OUT = path.resolve("output/admin");
|
|
fs.mkdirSync(OUT, { recursive: true });
|
|
const stamp = Date.now();
|
|
const PNAME = `e2eP${stamp}`;
|
|
const MNAME = `e2eM${stamp}`;
|
|
|
|
async function apiLogin(u, p) {
|
|
const res = await fetch(`${API}/api/auth/login/`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: u, password: p }) });
|
|
return res.json();
|
|
}
|
|
const admin = await apiLogin("admin", "admin123");
|
|
const authH = { "Content-Type": "application/json", Authorization: `Token ${admin.token}` };
|
|
// 一次性禁用供应商 + 模型
|
|
const prov = await (await fetch(`${API}/api/admin/providers/`, { method: "POST", headers: authH, body: JSON.stringify({ name: PNAME, display_name: PNAME, status: "disabled" }) })).json();
|
|
const model = await (await fetch(`${API}/api/admin/models/`, { method: "POST", headers: authH, body: JSON.stringify({ provider: prov.id, name: MNAME, display_name: MNAME, capability: "export", unit_price: "1", status: "disabled" }) })).json();
|
|
const modelId = model.id;
|
|
const getModel = async () => (await (await fetch(`${API}/api/admin/models/`, { headers: authH })).json()).find((m) => m.id === modelId);
|
|
|
|
const r = { page: {}, setDefault: {}, edit: {}, toggle: {}, consoleErrors: [], pass: false };
|
|
const browser = await chromium.launch({ headless: true });
|
|
const hook = (p, tag) => {
|
|
p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push(`${tag}:${m.text()}`); });
|
|
p.on("pageerror", (e) => r.consoleErrors.push(`${tag}:PAGEERR:${e.message}`));
|
|
};
|
|
|
|
try {
|
|
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
|
await ctx.addInitScript((tk) => localStorage.setItem("airshelf_token", tk), admin.token);
|
|
const p = await ctx.newPage(); hook(p, "models");
|
|
await p.goto(BASE + "/admin/providers", { waitUntil: "load" });
|
|
await p.waitForSelector(".admin-app", { timeout: 12000 });
|
|
await p.waitForSelector(".admin-table", { timeout: 12000 }).catch(() => {});
|
|
await p.waitForTimeout(500);
|
|
r.page.title = (await p.locator(".content h1").first().innerText().catch(() => "")).trim();
|
|
r.page.provRow = (await p.locator(`.admin-table tbody tr:has-text("${PNAME}")`).count()) >= 1;
|
|
r.page.modelRow = (await p.locator(`.admin-table tbody tr:has-text("${MNAME}")`).count()) >= 1;
|
|
await p.screenshot({ path: path.join(OUT, "p8-models.png"), fullPage: true });
|
|
|
|
const modelRow = () => p.locator(`.admin-table tbody tr:has-text("${MNAME}")`).first();
|
|
|
|
// UI 设默认(作用在禁用模型,安全)
|
|
await modelRow().locator('button:has-text("设默认")').click();
|
|
await p.waitForTimeout(900);
|
|
r.setDefault.ok = (await getModel()).is_default === true;
|
|
|
|
// UI 编辑定价 → 9
|
|
await modelRow().locator('button:has-text("编辑")').click();
|
|
await p.waitForSelector(".modal", { timeout: 6000 });
|
|
await p.locator('.modal input').nth(2).fill("9"); // 单价(供应商下拉/模型名/能力select 之后的输入… 用占位更稳)
|
|
// 更稳:用单价占位定位
|
|
await p.locator('.modal input[placeholder="0"]').fill("9");
|
|
await p.locator(".modal-f .btn-primary").click();
|
|
await p.waitForTimeout(900);
|
|
r.edit.price = (await getModel()).unit_price;
|
|
|
|
// UI 启停:禁用 → 点「启用」→ active
|
|
await modelRow().locator('button:has-text("启用")').click();
|
|
await p.waitForTimeout(900);
|
|
r.toggle.active = (await getModel()).status === "active";
|
|
// 再停用,保持收尾安全(禁用)
|
|
await modelRow().locator('button:has-text("停用")').click();
|
|
await p.waitForTimeout(700);
|
|
|
|
await ctx.close();
|
|
} finally {
|
|
await browser.close();
|
|
// 清理一次性模型 + 供应商
|
|
await fetch(`${API}/api/admin/models/${modelId}/`, { method: "DELETE", headers: authH });
|
|
await fetch(`${API}/api/admin/providers/${prov.id}/`, { method: "DELETE", headers: authH });
|
|
}
|
|
|
|
const checks = {
|
|
pageLoads: r.page.title === "模型供应商" && r.page.provRow === true && r.page.modelRow === true,
|
|
setDefault: r.setDefault.ok === true,
|
|
editPrice: r.edit.price === "9.0000",
|
|
toggle: r.toggle.active === true,
|
|
zeroConsoleErrors: r.consoleErrors.length === 0,
|
|
};
|
|
r.checks = checks;
|
|
r.pass = Object.values(checks).every(Boolean);
|
|
console.log(JSON.stringify(r, null, 2));
|
|
fs.writeFileSync(path.join(OUT, "p8-summary.json"), JSON.stringify(r, null, 2));
|
|
process.exit(r.pass ? 0 : 1);
|