// 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);