Download internal/panel/frontend_test.go from a3216/gcli2api: direct link, hf CLI and curl.
- Browser
- Download file 34 kB
-
https://huggingface.co/spaces/a3216/gcli2api/resolve/main/internal/panel/frontend_test.go
- Command line
-
hf download hf://spaces/a3216/gcli2api/internal/panel/frontend_test.go
-
curl -L -o frontend_test.go https://huggingface.co/spaces/a3216/gcli2api/resolve/main/internal/panel/frontend_test.go
34 kB
| package panel | |
| import ( | |
| "bytes" | |
| "net/http/httptest" | |
| "os" | |
| "os/exec" | |
| "path/filepath" | |
| "regexp" | |
| "strconv" | |
| "strings" | |
| "testing" | |
| "time" | |
| ) | |
| // TestAppJSSyntax app.js 必须能通过 JS 解析器语法校验。 | |
| // | |
| // 为什么需要:app.js 是 go:embed 进二进制的静态资源,Go 编译器不检查其内容—— | |
| // 一次对象字面量键名未加引号(Model_chat_GLM5.2 被解析成属性访问 + 数字字面量) | |
| // 就让整个面板白屏,而所有 Go 测试依然全绿。此测试把语法校验前移到 CI。 | |
| // 无 node 环境时跳过(不阻塞无 Node 的构建机)。 | |
| func TestAppJSSyntax(t *testing.T) { | |
| node, err := exec.LookPath("node") | |
| if err != nil { | |
| t.Skip("node not available; skipping JS syntax check") | |
| } | |
| path, err := filepath.Abs("app.js") | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| out, err := exec.Command(node, "--check", path).CombinedOutput() | |
| if err != nil { | |
| t.Fatalf("app.js syntax error:\n%s", out) | |
| } | |
| } | |
| // TestIndexHTMLNoInlineScript index.html 不得含内联 <script> 块: | |
| // 严格 CSP(script-src 'self')会拦截内联脚本,页面将完全不可用。 | |
| // 外链形式 <script src="..."> 允许。 | |
| func TestIndexHTMLNoInlineScript(t *testing.T) { | |
| p := newTestPanel() | |
| rec := httptest.NewRecorder() | |
| p.ServeHTTP(rec, httptest.NewRequest("GET", "/panel/", nil)) | |
| body := rec.Body.String() | |
| rest := body | |
| for { | |
| idx := strings.Index(rest, "<script") | |
| if idx < 0 { | |
| break | |
| } | |
| rest = rest[idx:] | |
| end := strings.Index(rest, ">") | |
| if end < 0 { | |
| break | |
| } | |
| tag := rest[:end+1] | |
| if !strings.Contains(tag, "src=") { | |
| t.Fatalf("index.html contains inline <script> (blocked by CSP): %s", tag) | |
| } | |
| rest = rest[end:] | |
| } | |
| } | |
| // TestAppJSTopLevelSmoke app.js 顶层求值冒烟(v1.11.3/1.11.4 两连炸后补的运行时闸门): | |
| // node + DOM 桩执行 app.js(含按 hash 落到各视图的 go() 顶层调用),抓 TDZ/ | |
| // ReferenceError 类运行时错误——Go 侧 frontend_test 不执行 JS,语法层检查对此全盲。 | |
| // 无 node 的环境跳过(CI/精简机不受影响);harness 与 app.js 同判(app.js 顶层 | |
| // start() 的 setInterval 会让 node 事件循环不退出,故成功路径显式 exit(0))。 | |
| func TestAppJSTopLevelSmoke(t *testing.T) { | |
| node, err := exec.LookPath("node") | |
| if err != nil { | |
| t.Skip("node not installed; JS smoke skipped") | |
| } | |
| harness := `const fs = require('fs'); | |
| const vm = require('vm'); | |
| const src = fs.readFileSync(process.argv[2], 'utf8'); | |
| const inert = new Proxy(function () {}, { | |
| get(t, k) { if (k === Symbol.toPrimitive) return () => ''; return inert; }, | |
| set() { return true; }, | |
| apply() { return inert; }, | |
| construct() { return inert; }, | |
| has() { return true; }, | |
| }); | |
| const sandbox = new Proxy({ | |
| location: { hash: process.env.SMOKE_HASH || '#taskscenter' }, | |
| history: { replaceState() {} }, | |
| localStorage: { getItem: () => null, setItem() {} }, | |
| navigator: { clipboard: { writeText: () => Promise.resolve() } }, | |
| document: { querySelectorAll: () => [], querySelector: () => inert, getElementById: () => inert, addEventListener() {}, documentElement: inert, head: inert, body: inert, createElement: () => inert, cookie: '' }, | |
| fetch: () => new Promise(() => {}), | |
| addEventListener() {}, removeEventListener() {}, | |
| matchMedia: () => ({ matches: false, addEventListener() {} }), | |
| setInterval, clearInterval, setTimeout, clearTimeout, | |
| console, JSON, Math, Date, Number, String, Boolean, Object, Array, Promise, Map, Set, RegExp, Error, TypeError, isNaN, parseInt, parseFloat, encodeURIComponent, decodeURIComponent, URL, Symbol, Proxy, Reflect, | |
| }, { get(t, k) { return t[k]; }, has() { return true; } }); | |
| sandbox.window = sandbox; sandbox.globalThis = sandbox; | |
| vm.createContext(sandbox); | |
| try { | |
| vm.runInContext(src, sandbox, { filename: 'app.js' }); | |
| console.log('SMOKE OK'); | |
| process.exit(0); | |
| } catch (e) { | |
| console.log('SMOKE FAIL:', (e && e.stack ? e.stack : e).toString().split('\n').slice(0, 5).join('\n')); | |
| process.exit(1); | |
| } | |
| ` | |
| hf, err := os.CreateTemp(t.TempDir(), "smoke-*.cjs") | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| if _, err := hf.WriteString(harness); err != nil { | |
| t.Fatal(err) | |
| } | |
| hf.Close() | |
| for _, hash := range []string{"#taskscenter", "#accounts", "#usage", "#models", "#config", "#logs", "#packages"} { | |
| cmd := exec.Command(node, hf.Name(), "app.js") | |
| cmd.Dir = "." // 测试工作目录 = internal/panel | |
| cmd.Env = append(os.Environ(), "SMOKE_HASH="+hash) | |
| out, err := cmd.CombinedOutput() | |
| if err != nil { | |
| t.Fatalf("app.js 顶层求值 %s 崩溃: %v\n%s", hash, err, out) | |
| } | |
| if !bytes.Contains(out, []byte("SMOKE OK")) { | |
| t.Fatalf("app.js smoke %s 未通过:\n%s", hash, out) | |
| } | |
| } | |
| } | |
| // 积分扣除维度的格式必须稳定,且缺样本/缺匹配 Token 时不能伪造比例。 | |
| func TestAppJSCreditDimensionFormatting(t *testing.T) { | |
| node, err := exec.LookPath("node") | |
| if err != nil { | |
| t.Skip("node not installed; credit formatting test skipped") | |
| } | |
| script := `const fs = require('fs'); | |
| const vm = require('vm'); | |
| const src = fs.readFileSync(process.argv[2], 'utf8'); | |
| const start = src.indexOf('function trimFixed'); | |
| const end = src.indexOf('function usStat'); | |
| if (start < 0 || end < 0) throw new Error('credit helpers not found'); | |
| const ctx = { Number, String, RegExp }; | |
| vm.createContext(ctx); | |
| vm.runInContext( | |
| src.slice(start, end) + | |
| '\nthis.fmtCredit=fmtCredit; this.fmtCreditRatio=fmtCreditRatio; this.fmtModelRate=fmtModelRate;', | |
| ctx | |
| ); | |
| process.stdout.write(JSON.stringify({ | |
| credit: ctx.fmtCredit(1.25), | |
| zero: ctx.fmtCredit(0), | |
| hundred: ctx.fmtCredit(100), | |
| ratio: ctx.fmtCreditRatio(12.5, 2, 400), | |
| noSamples: ctx.fmtCreditRatio(12.5, 0, 400), | |
| noTokens: ctx.fmtCreditRatio(12.5, 2, 0), | |
| rate: ctx.fmtModelRate('0.5'), | |
| noRate: ctx.fmtModelRate(''), | |
| }));` | |
| f, err := os.CreateTemp(t.TempDir(), "credit-format-*.cjs") | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| if _, err := f.WriteString(script); err != nil { | |
| t.Fatal(err) | |
| } | |
| f.Close() | |
| out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput() | |
| if err != nil { | |
| t.Fatalf("credit formatting node test failed: %v\n%s", err, out) | |
| } | |
| const want = `{"credit":"1.25","zero":"0","hundred":"100","ratio":"12.5 / 1M","noSamples":"—","noTokens":"—","rate":"x0.5","noRate":"—"}` | |
| if strings.TrimSpace(string(out)) != want { | |
| t.Fatalf("credit formatting=%s want %s", out, want) | |
| } | |
| } | |
| // 模型限流时间必须同时支持上游 reset_at、网关 until 和无重置时间三种形态。 | |
| func TestAppJSRateLimitMeta(t *testing.T) { | |
| node, err := exec.LookPath("node") | |
| if err != nil { | |
| t.Skip("node not installed; rate limit formatting test skipped") | |
| } | |
| script := `const fs = require('fs'); | |
| const vm = require('vm'); | |
| const src = fs.readFileSync(process.argv[2], 'utf8'); | |
| const start = src.indexOf('function dur('); | |
| const end = src.indexOf('function rateLimitRowsHtml'); | |
| if (start < 0 || end < 0) throw new Error('rate limit helpers not found'); | |
| const ctx = { Date, Number, String, Math }; | |
| vm.createContext(ctx); | |
| vm.runInContext(src.slice(start, end) + '\nthis.rateLimitMeta=rateLimitMeta;', ctx); | |
| const now = new Date(2026, 8, 28, 14, 0, 0).getTime(); | |
| const reset = new Date(2026, 8, 28, 16, 0, 0).getTime(); | |
| const until = new Date(2026, 8, 28, 15, 0, 0).getTime(); | |
| const rate = ctx.rateLimitMeta({ model: 'glm-5.3', kind: 'rate_limit', reset_at: new Date(reset).toISOString(), until: new Date(until).toISOString() }, now); | |
| const unavailable = ctx.rateLimitMeta({ model: 'missing', kind: 'model_unavailable', until: new Date(until).toISOString() }, now); | |
| const unknown = ctx.rateLimitMeta({ model: 'glm-5.3', kind: 'rate_limit' }, now); | |
| process.stdout.write(JSON.stringify({ | |
| rate: rate.detail, | |
| unavailable: unavailable.detail, | |
| unknown: unknown.detail, | |
| }));` | |
| f, err := os.CreateTemp(t.TempDir(), "rate-limit-format-*.cjs") | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| if _, err := f.WriteString(script); err != nil { | |
| t.Fatal(err) | |
| } | |
| f.Close() | |
| out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput() | |
| if err != nil { | |
| t.Fatalf("rate-limit formatting node test failed: %v\n%s", err, out) | |
| } | |
| const want = `{"rate":"预计 2026-09-28 16:00 解封(剩余 2时00分) · 网关最快 1时00分 后重试","unavailable":"预计 1时00分 后重试","unknown":"预计解封时间未知"}` | |
| if strings.TrimSpace(string(out)) != want { | |
| t.Fatalf("rate-limit formatting=%s want %s", out, want) | |
| } | |
| } | |
| // 请求记录行必须紧凑、可读,并带上调用来源(IP / UA);来源缺失时以 — 兜底。 | |
| func TestAppJSRequestLogFormatting(t *testing.T) { | |
| node, err := exec.LookPath("node") | |
| if err != nil { | |
| t.Skip("node not installed; request log formatting test skipped") | |
| } | |
| script := `const fs = require('fs'); | |
| const vm = require('vm'); | |
| const src = fs.readFileSync(process.argv[2], 'utf8'); | |
| const escStart = src.indexOf('function esc('); | |
| const escEnd = src.indexOf('function ago('); | |
| const fmtStart = src.indexOf('function fmtTok('); | |
| const fmtEnd = src.indexOf('function usStat('); | |
| const reqStart = src.indexOf('function requestLogText'); | |
| const reqEnd = src.indexOf('function fmtBytes'); | |
| if ([escStart, escEnd, fmtStart, fmtEnd, reqStart, reqEnd].some(v => v < 0)) throw new Error('request log helpers not found'); | |
| const ctx = { Date, Number, String, Math, RegExp, isNaN }; | |
| vm.createContext(ctx); | |
| vm.runInContext( | |
| src.slice(escStart, escEnd) + src.slice(fmtStart, fmtEnd) + src.slice(reqStart, reqEnd) + | |
| '\nthis.requestLogText=requestLogText;', | |
| ctx | |
| ); | |
| const time = new Date(2026, 8, 28, 14, 5, 6).toISOString(); | |
| const good = { time, status: 200, outcome: 'success', model: 'glm-5.3', account: '账号(uid8)', duration_ms: 1250, total_tokens: 2300, credit_known: true, credit: 0.12, request_id: 'req-1', client_ip: '203.0.113.7', user_agent: 'python-requests/2.31.0' }; | |
| const noSource = { ...good, request_id: 'req-3', client_ip: '', user_agent: '' }; | |
| const cached = { ...good, request_id: 'req-2', cache_hit_tokens: 2257, cache_miss_tokens: 43 }; | |
| process.stdout.write(JSON.stringify({ | |
| good: ctx.requestLogText(good), | |
| noSource: ctx.requestLogText(noSource), | |
| cached: ctx.requestLogText(cached), | |
| }));` | |
| f, err := os.CreateTemp(t.TempDir(), "request-log-format-*.cjs") | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| if _, err := f.WriteString(script); err != nil { | |
| t.Fatal(err) | |
| } | |
| f.Close() | |
| out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput() | |
| if err != nil { | |
| t.Fatalf("request log formatting node test failed: %v\n%s", err, out) | |
| } | |
| text := "14:05:06 | 200 成功 | glm-5.3 | 账号(uid8) | 203.0.113.7 | python-requests/2.31.0 | 1.25s | 2.3k tok | 0.12 credit | req-1" | |
| noSource := "14:05:06 | 200 成功 | glm-5.3 | 账号(uid8) | — | — | 1.25s | 2.3k tok | 0.12 credit | req-3" | |
| cached := "14:05:06 | 200 成功 | glm-5.3 | 账号(uid8) | 203.0.113.7 | python-requests/2.31.0 | 1.25s | 2.3k tok | 0.12 credit | 命中 98.1% | req-2" | |
| want := `{"good":` + strconv.Quote(text) + `,"noSource":` + strconv.Quote(noSource) + `,"cached":` + strconv.Quote(cached) + `}` | |
| if strings.TrimSpace(string(out)) != want { | |
| t.Fatalf("request log formatting=%s want %s", out, want) | |
| } | |
| } | |
| // 请求记录筛选:IP / UA / 模型 / 账号 / 请求 ID 的包含匹配(空格分词 AND)+ 结果精确匹配。 | |
| func TestAppJSRequestMatch(t *testing.T) { | |
| node, err := exec.LookPath("node") | |
| if err != nil { | |
| t.Skip("node not installed; request filter test skipped") | |
| } | |
| script := `const fs = require('fs'); | |
| const vm = require('vm'); | |
| const src = fs.readFileSync(process.argv[2], 'utf8'); | |
| const start = src.indexOf('function reqMatch'); | |
| const end = src.indexOf('function reqOutcomeTag'); | |
| if (start < 0 || end < 0) throw new Error('reqMatch not found'); | |
| const ctx = {}; | |
| vm.createContext(ctx); | |
| vm.runInContext(src.slice(start, end) + '\nthis.reqMatch=reqMatch;', ctx); | |
| const base = { outcome: 'success', client_ip: '203.0.113.7', user_agent: 'python-requests/2.31.0', model: 'cn:glm-5.3', account: '示例(uid8)', request_id: 'req-1' }; | |
| const other = { outcome: 'http_error', client_ip: '198.51.100.4', user_agent: 'Mozilla/5.0 Chrome/120', model: 'global:hy3', account: '甲(uid9)', request_id: 'req-2' }; | |
| const rows = [base, other]; | |
| const pick = f => rows.filter(e => ctx.reqMatch(e, f)).map(e => e.request_id); | |
| process.stdout.write(JSON.stringify({ | |
| all: pick({ q: '', outcome: '' }), | |
| byIP: pick({ q: '203.0.113', outcome: '' }), | |
| byUA: pick({ q: 'chrome/120', outcome: '' }), | |
| byModel: pick({ q: 'glm', outcome: '' }), | |
| multiKw: pick({ q: 'glm success', outcome: '' }), | |
| multiMiss: pick({ q: 'glm chrome', outcome: '' }), | |
| byOutcome: pick({ q: '', outcome: 'http_error' }), | |
| combined: pick({ q: '198.51', outcome: 'http_error' }), | |
| }));` | |
| f, err := os.CreateTemp(t.TempDir(), "request-filter-*.cjs") | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| if _, err := f.WriteString(script); err != nil { | |
| t.Fatal(err) | |
| } | |
| f.Close() | |
| out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput() | |
| if err != nil { | |
| t.Fatalf("request filter node test failed: %v\n%s", err, out) | |
| } | |
| // q 对 outcome 不参与匹配(outcome 有独立下拉),multiKw 里的 success 命中不了任何字段。 | |
| const want = `{"all":["req-1","req-2"],"byIP":["req-1"],"byUA":["req-2"],"byModel":["req-1"],"multiKw":[],"multiMiss":[],"byOutcome":["req-2"],"combined":["req-2"]}` | |
| if strings.TrimSpace(string(out)) != want { | |
| t.Fatalf("request filter=%s want %s", out, want) | |
| } | |
| } | |
| // 模型按条件查询:域 / 能力 / 档位 / 价格 / 关键词,以及倍率、上下文、输出排序。 | |
| func TestAppJSModelFilter(t *testing.T) { | |
| node, err := exec.LookPath("node") | |
| if err != nil { | |
| t.Skip("node not installed; model filter test skipped") | |
| } | |
| script := `const fs = require('fs'); | |
| const vm = require('vm'); | |
| const src = fs.readFileSync(process.argv[2], 'utf8'); | |
| const start = src.indexOf('function mdRateValue'); | |
| const end = src.indexOf('function mdRowHtml'); | |
| if (start < 0 || end < 0) throw new Error('model filter helpers not found'); | |
| const ctx = { Number, String, Array, Object, isFinite, parseFloat }; | |
| vm.createContext(ctx); | |
| vm.runInContext(src.slice(start, end) + '\nthis.mdMatch=mdMatch; this.mdSortList=mdSortList; this.mdRateValue=mdRateValue;', ctx); | |
| const models = [ | |
| { id: 'cn:glm-5.2', name: 'GLM-5.2', vendor: 'Zhipu', tags: ['视觉'], supports_tool_call: true, supports_images: true, supports_reasoning: true, can_disable_thinking: true, supported_efforts: ['high', 'xhigh'], default_effort: 'high', is_default: false, credits: '0.79', promo_factor: 0.5, promo_credits: '0.40', promo_label: '夜间折扣', context_length: 1000000, max_output_tokens: 131000 }, | |
| { id: 'cn:hy3', name: 'Hy3', supports_tool_call: true, supports_images: true, supports_reasoning: true, can_disable_thinking: false, supported_efforts: ['low', 'high'], default_effort: 'high', is_default: false, credits: '0', promo_factor: 0, promo_credits: '0', promo_label: '限时免费', context_length: 192000, max_output_tokens: 64000 }, | |
| { id: 'global:hy3', name: 'Hy3 Global', supports_tool_call: false, supports_images: false, supports_reasoning: false, supported_efforts: [], is_default: false, credits: '0.11', context_length: 1000000, max_output_tokens: 393000 }, | |
| { id: 'cn:auto', name: 'Auto', supports_tool_call: true, supports_images: true, supports_reasoning: true, is_default: true, credits: null, context_length: 256000, max_output_tokens: 32000 }, | |
| ]; | |
| const ids = list => list.map(m => m.id); | |
| const filter = f => ids(ctx.mdSortList(models.filter(m => ctx.mdMatch(m, f)), f)); | |
| process.stdout.write(JSON.stringify({ | |
| all: ids(models), | |
| realm: filter({ realm: 'cn' }), | |
| tool: filter({ cap: 'tool' }), | |
| vision: filter({ cap: 'vision' }), | |
| reasoning: filter({ cap: 'reasoning' }), | |
| isDefault: filter({ cap: 'default' }), | |
| effortOff: filter({ effort: 'off' }), | |
| effortLow: filter({ effort: 'low' }), | |
| free: filter({ promo: 'free' }), | |
| promo: filter({ promo: 'promo' }), | |
| discount: filter({ promo: 'discount' }), | |
| q: filter({ q: 'glm zhipu' }), | |
| qMiss: filter({ q: 'glm nosuch' }), | |
| sortRate: filter({ sort: 'rate' }), | |
| sortContext: filter({ sort: 'context' }), | |
| sortOutput: filter({ sort: 'output' }), | |
| sortName: filter({ sort: 'name' }), | |
| rateFree: ctx.mdRateValue(models[1]), | |
| rateMissing: ctx.mdRateValue(models[3]), | |
| }));` | |
| f, err := os.CreateTemp(t.TempDir(), "model-filter-*.cjs") | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| if _, err := f.WriteString(script); err != nil { | |
| t.Fatal(err) | |
| } | |
| f.Close() | |
| out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput() | |
| if err != nil { | |
| t.Fatalf("model filter node test failed: %v\n%s", err, out) | |
| } | |
| const want = `{"all":["cn:glm-5.2","cn:hy3","global:hy3","cn:auto"],` + | |
| `"realm":["cn:glm-5.2","cn:hy3","cn:auto"],` + | |
| `"tool":["cn:glm-5.2","cn:hy3","cn:auto"],` + | |
| `"vision":["cn:glm-5.2","cn:hy3","cn:auto"],` + | |
| `"reasoning":["cn:glm-5.2","cn:hy3","cn:auto"],` + | |
| `"isDefault":["cn:auto"],` + | |
| `"effortOff":["cn:glm-5.2"],` + | |
| `"effortLow":["cn:hy3"],` + | |
| `"free":["cn:hy3"],` + | |
| `"promo":["cn:glm-5.2","cn:hy3"],` + | |
| `"discount":["cn:glm-5.2"],` + | |
| `"q":["cn:glm-5.2"],` + | |
| `"qMiss":[],` + | |
| `"sortRate":["cn:hy3","global:hy3","cn:glm-5.2","cn:auto"],` + | |
| `"sortContext":["cn:glm-5.2","global:hy3","cn:auto","cn:hy3"],` + | |
| `"sortOutput":["global:hy3","cn:glm-5.2","cn:hy3","cn:auto"],` + | |
| `"sortName":["cn:auto","cn:glm-5.2","cn:hy3","global:hy3"],` + | |
| `"rateFree":0,"rateMissing":null}` | |
| if strings.TrimSpace(string(out)) != want { | |
| t.Fatalf("model filter=%s\nwant %s", out, want) | |
| } | |
| } | |
| // 用量时序图的柱体类名不得叫 bar:账号池的积分条是 .bar{height:3px},而 SVG2 里 | |
| // height 是 rect 的 CSS 几何属性——同名类会把每根柱子压成 3px 高,图看起来"没数据"。 | |
| // 这个坑只能在浏览器里看出来,所以在这里钉住类名。 | |
| func TestAppJSUsageChartBarClass(t *testing.T) { | |
| node, err := exec.LookPath("node") | |
| if err != nil { | |
| t.Skip("node not installed; usage chart test skipped") | |
| } | |
| script := `const fs = require('fs'); | |
| const vm = require('vm'); | |
| const src = fs.readFileSync(process.argv[2], 'utf8'); | |
| const start = src.indexOf('function parsePointTime'); | |
| const end = src.indexOf('function fmtTokTip'); | |
| const escStart = src.indexOf('function esc('); | |
| const escEnd = src.indexOf('function ago('); | |
| const fmtStart = src.indexOf('function fmtTok('); | |
| const fmtEnd = src.indexOf('function usStat('); | |
| if ([start, end, escStart, escEnd, fmtStart, fmtEnd].some(v => v < 0)) throw new Error('usage chart helpers not found'); | |
| const host = { innerHTML: '', textContent: '' }; | |
| const ctx = { | |
| Date, Number, String, Math, RegExp, isNaN, Set, Array, Object, Infinity, | |
| document: { getElementById: () => host }, | |
| $: () => host, | |
| }; | |
| vm.createContext(ctx); | |
| vm.runInContext(src.slice(escStart, escEnd) + src.slice(fmtStart, fmtEnd) + src.slice(start, end) + | |
| '\nthis.renderUsageChart=renderUsageChart;', ctx); | |
| const series = [ | |
| { t: '2026-09-30T09', scope: 'hour', prompt_tokens: 35, completion_tokens: 16, total_tokens: 51, requests: 1 }, | |
| { t: '2026-09-30T11', scope: 'hour', prompt_tokens: 978324, completion_tokens: 20621, total_tokens: 998945, requests: 39 }, | |
| { t: '2026-09-30T13', scope: 'hour', prompt_tokens: 27400952, completion_tokens: 104913, total_tokens: 27505865, requests: 200 }, | |
| ]; | |
| ctx.renderUsageChart(series); | |
| const svg = host.innerHTML; | |
| process.stdout.write(JSON.stringify({ | |
| hasUsbar: svg.includes('class="usbar"'), | |
| hasBareBar: /class="bar"/.test(svg), | |
| hasGradient: svg.includes('usGradP') && svg.includes('usGradC'), | |
| barCount: (svg.match(/class="usbar"/g) || []).length, | |
| hasPeak: svg.includes('峰值'), | |
| hasAvg: svg.includes('均值'), | |
| emptyState: (function () { ctx.renderUsageChart([]); return host.innerHTML.includes('us-empty'); })(), | |
| }));` | |
| f, err := os.CreateTemp(t.TempDir(), "usage-chart-*.cjs") | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| if _, err := f.WriteString(script); err != nil { | |
| t.Fatal(err) | |
| } | |
| f.Close() | |
| out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput() | |
| if err != nil { | |
| t.Fatalf("usage chart node test failed: %v\n%s", err, out) | |
| } | |
| const want = `{"hasUsbar":true,"hasBareBar":false,"hasGradient":true,"barCount":6,"hasPeak":true,"hasAvg":true,"emptyState":true}` | |
| if strings.TrimSpace(string(out)) != want { | |
| t.Fatalf("usage chart=%s\nwant %s", out, want) | |
| } | |
| } | |
| // 时间范围控件:预设 → 查询参数的映射。要点: | |
| // - 「今天」必须发浏览器本地时区的 00:00(服务端时区未必一致),且不带 to; | |
| // - 滚动预设 rolling=true 发 hours(服务端整点对齐),rolling=false 折算成 from; | |
| // - 「全部历史」两者都不发;「自定义」发用户挑的 from/to。 | |
| func TestAppJSTimeRangeQuery(t *testing.T) { | |
| node, err := exec.LookPath("node") | |
| if err != nil { | |
| t.Skip("node not installed; time range test skipped") | |
| } | |
| script := `const fs = require('fs'); | |
| const vm = require('vm'); | |
| const src = fs.readFileSync(process.argv[2], 'utf8'); | |
| const start = src.indexOf('const TRANGE_PRESETS'); | |
| const end = src.indexOf('function rateLimitMeta'); | |
| if (start < 0 || end < 0 || end < start) throw new Error('trange helpers not found'); | |
| const host = { innerHTML: '' }; | |
| const ctx = { | |
| Date, Number, String, Math, Map, Array, Object, isNaN, URLSearchParams, | |
| document: { getElementById: () => host }, | |
| $: () => host, | |
| esc: s => String(s == null ? '' : s), | |
| }; | |
| vm.createContext(ctx); | |
| vm.runInContext(src.slice(start, end) + | |
| '\nthis.trangeState=trangeState; this.trangeQuery=trangeQuery; this.trangeLabel=trangeLabel; this.trangeMidnight=trangeMidnight;', ctx); | |
| const q = (preset, rolling) => { | |
| ctx.trangeState('t').preset = preset; | |
| return ctx.trangeQuery('t', rolling).toString(); | |
| }; | |
| const secOf = d => String(Math.floor(d.getTime() / 1000)); | |
| const approx = (qs, wantSec) => { | |
| const m = /(?:^|&)from=(\d+)/.exec(qs); | |
| return m && Math.abs(Number(m[1]) - wantSec) < 120; | |
| }; | |
| const now = Date.now(); | |
| const todayQ = q('today', true); | |
| process.stdout.write(JSON.stringify({ | |
| todayIsMidnight: todayQ === 'from=' + secOf(ctx.trangeMidnight()), | |
| todayNoTo: !/to=/.test(todayQ), | |
| rolling24: q('24', true), | |
| rolling72: q('72', true), | |
| rolling0: q('0', true), | |
| log24From: approx(q('24', false), Math.floor((now - 24 * 3600e3) / 1000)), | |
| log24HasHours: /hours=/.test(q('24', false)), | |
| log7dFrom: approx(q('168', false), Math.floor((now - 168 * 3600e3) / 1000)), | |
| custom: (function () { | |
| const st = ctx.trangeState('t'); | |
| st.preset = 'custom'; | |
| st.from = new Date(2026, 8, 30, 9, 0, 0); | |
| st.to = new Date(2026, 8, 30, 18, 30, 0); | |
| return ctx.trangeQuery('t', true).toString(); | |
| })(), | |
| labelCustom: ctx.trangeLabel('t'), | |
| labelToday: (function () { ctx.trangeState('t').preset = 'today'; return ctx.trangeLabel('t'); })(), | |
| }));` | |
| f, err := os.CreateTemp(t.TempDir(), "trange-*.cjs") | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| if _, err := f.WriteString(script); err != nil { | |
| t.Fatal(err) | |
| } | |
| f.Close() | |
| out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput() | |
| if err != nil { | |
| t.Fatalf("time range node test failed: %v\n%s", err, out) | |
| } | |
| local := func(h, m int) string { | |
| return strconv.FormatInt(time.Date(2026, 9, 30, h, m, 0, 0, time.Local).Unix(), 10) | |
| } | |
| want := `{"todayIsMidnight":true,"todayNoTo":true,` + | |
| `"rolling24":"hours=24","rolling72":"hours=72","rolling0":"",` + | |
| `"log24From":true,"log24HasHours":false,"log7dFrom":true,` + | |
| `"custom":"from=` + local(9, 0) + `&to=` + local(18, 30) + `",` + | |
| `"labelCustom":"9-30 09:00 → 9-30 18:30","labelToday":"今天"}` | |
| if strings.TrimSpace(string(out)) != want { | |
| t.Fatalf("time range=%s\nwant %s", out, want) | |
| } | |
| } | |
| // 配置表单与 CFG_MAP 必须一一对应,且面板声称"可在线改"的热生效键必须真的 | |
| // 出现在表单里。 | |
| // | |
| // 为什么需要:`logging.request_client_info` 曾经在表单里存在过,后来在某次改动中 | |
| // 被连带删掉,而 Go 侧的配置键、livecfg 热生效通路、README 的描述都还在——面板 | |
| // 少了一个开关而 Go 测试全绿,只有人肉点开配置页才会发现。这里把"表单字段 ↔ | |
| // CFG_MAP"与"关键热改键必须在表单里"两条都钉住。 | |
| func TestConfigFormMatchesCFGMap(t *testing.T) { | |
| src, err := os.ReadFile("app.js") | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| js := string(src) | |
| htmlBytes, err := os.ReadFile("index.html") | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| html := string(htmlBytes) | |
| // CFG_MAP 块(下面两条检查共用)。 | |
| mapBlock := js[strings.Index(js, "const CFG_MAP = {"):] | |
| mapBlock = mapBlock[:strings.Index(mapBlock, "\n};")] | |
| // 不能按行首匹配:CFG_MAP 里多个键写在同一行(`a: [...], b: [...]`),只有行首 | |
| // 那个带换行缩进。按「前面是行首或分隔符」判定才不漏。 | |
| inMap := func(name string) bool { | |
| return regexp.MustCompile(`(?:^|[\s,{])` + regexp.QuoteMeta(name) + `:\s*\[`).MatchString(mapBlock) | |
| } | |
| // 1) 表单里的每个 name 都要有 CFG_MAP 条目(否则收集/回填都拿不到它)。 | |
| form := html[strings.Index(html, `<form id="cfgForm">`):] | |
| form = form[:strings.Index(form, "</form>")] | |
| names := map[string]bool{} | |
| for _, m := range regexp.MustCompile(`name="([a-z_0-9]+)"`).FindAllStringSubmatch(form, -1) { | |
| names[m[1]] = true | |
| } | |
| if len(names) == 0 { | |
| t.Fatal("未从配置表单解析出任何 name 字段") | |
| } | |
| for n := range names { | |
| if !inMap(n) { | |
| t.Errorf("表单字段 %q 在 CFG_MAP 里没有条目(保存时会被静默丢弃)", n) | |
| } | |
| } | |
| // 2) CFG_MAP 里的每个键都要在表单里有控件(否则回填/保存是空转)。 | |
| for _, m := range regexp.MustCompile(`(?:^|[\s,{])([a-z_0-9]+):\s*\[`).FindAllStringSubmatch(mapBlock, -1) { | |
| if !names[m[1]] { | |
| t.Errorf("CFG_MAP 键 %q 在配置表单里没有对应控件", m[1]) | |
| } | |
| } | |
| // 3) 明确断言这一个键:后端有配置项、README 说面板可改,UI 不能少。 | |
| if !strings.Contains(js, "request_client_info: ['logging', 'request_client_info']") { | |
| t.Error("CFG_MAP 缺 request_client_info 条目") | |
| } | |
| if !names["request_client_info"] { | |
| t.Error("配置表单缺「记录调用来源」开关(logging.request_client_info)") | |
| } | |
| } | |
| // 同到期时间按面额降序;其余未用完包与零/负余额包分别聚合。 | |
| func TestAppJSDetailGroups(t *testing.T) { | |
| node, err := exec.LookPath("node") | |
| if err != nil { | |
| t.Skip("node not installed; detail groups test skipped") | |
| } | |
| script := `const fs = require('fs'); | |
| const vm = require('vm'); | |
| const src = fs.readFileSync(process.argv[2], 'utf8'); | |
| const start = src.indexOf('const PK_DEFAULT_DETAIL_LIMIT'); | |
| const end = src.indexOf('function renderPackages'); | |
| if (start < 0 || end < 0) throw new Error('detail group functions not found'); | |
| const ctx = { Date, Math, Number, String, Map, Array, Object, isFinite }; | |
| vm.createContext(ctx); | |
| vm.runInContext(src.slice(start, end) + '\nthis.pkDetailGroups = pkDetailGroups; this.pkDetailLimit = pkDetailLimit;', ctx); | |
| const input = [ | |
| { id: 'small-late', size: 100, remain: 1, expires_at: 400 }, | |
| { id: 'zero-early-b', size: 200, remain: 0, expires_at: 200 }, | |
| { id: 'small-early', size: 100, remain: 2, expires_at: 200 }, | |
| { id: 'large-unknown', size: 300, remain: 3, end_time: '' }, | |
| { id: 'zero-early-a', size: 200, remain: -1, expires_at: 200 }, | |
| { id: 'small-unknown', size: 100, remain: 1, end_time: '' }, | |
| { id: 'large-early', size: 300, remain: 4, expires_at: 200 }, | |
| { id: 'zero-late', size: 300, remain: 0, expires_at: 300 }, | |
| ]; | |
| const before = input.map(p => p.id).join(','); | |
| const out = ctx.pkDetailGroups(input, 2); | |
| process.stdout.write(JSON.stringify({ | |
| visible: out.visible.map(p => p.id), | |
| rest: out.rest.map(p => p.id), | |
| used: out.used.map(p => p.id), | |
| restSize: out.restSize, | |
| restRemain: out.restRemain, | |
| usedSize: out.usedSize, | |
| defaultLimit: ctx.pkDetailLimit({}), | |
| configuredLimit: ctx.pkDetailLimit({ panel: { package_detail_limit: 7 } }), | |
| unchanged: input.map(p => p.id).join(',') === before, | |
| }));` | |
| f, err := os.CreateTemp(t.TempDir(), "detail-groups-*.cjs") | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| if _, err := f.WriteString(script); err != nil { | |
| t.Fatal(err) | |
| } | |
| f.Close() | |
| out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput() | |
| if err != nil { | |
| t.Fatalf("detail groups node test failed: %v\n%s", err, out) | |
| } | |
| const want = `{"visible":["large-early","small-early"],"rest":["small-late","large-unknown","small-unknown"],"used":["zero-early-b","zero-early-a","zero-late"],"restSize":500,"restRemain":5,"usedSize":700,"defaultLimit":5,"configuredLimit":7,"unchanged":true}` | |
| if strings.TrimSpace(string(out)) != want { | |
| t.Fatalf("detail groups=%s want %s", out, want) | |
| } | |
| } | |
| // 精确剩余天数聚合、账号内按总余额钳制、无到期批次不进入图表。 | |
| func TestAppJSExpirySummary(t *testing.T) { | |
| node, err := exec.LookPath("node") | |
| if err != nil { | |
| t.Skip("node not installed; expiry summary test skipped") | |
| } | |
| script := `const fs = require('fs'); | |
| const vm = require('vm'); | |
| const src = fs.readFileSync(process.argv[2], 'utf8'); | |
| const start = src.indexOf('const PK_ACCOUNT_COLORS'); | |
| const end = src.indexOf('function renderExpiryDistribution'); | |
| if (start < 0 || end < 0) throw new Error('expiry summary functions not found'); | |
| const ctx = { Date, Math, Number, String, Map, Array, Object, isFinite }; | |
| vm.createContext(ctx); | |
| vm.runInContext(src.slice(start, end) + '\nthis.summarizeCreditDays = summarizeCreditDays; this.pkAccountColorMap = pkAccountColorMap;', ctx); | |
| const day = 86400000, now = 100000; | |
| const out = ctx.summarizeCreditDays([ | |
| { uid: 'a', remain: 100, packages: [ | |
| { name: 'soon-a', remain: 30, expires_at: now + day }, | |
| { name: 'later', remain: 70, expires_at: now + 7 * day }, | |
| ] }, | |
| { uid: 'b', remain: 55, packages: [ | |
| { name: 'soon-b', remain: 20, expires_at: now + day }, | |
| { name: 'unknown', remain: 5, end_time: '' }, | |
| ] }, | |
| { uid: 'err', error: 'offline' }, | |
| ], now); | |
| process.stdout.write(JSON.stringify({ | |
| rows: out.rows.map(row => ({ days: row.days, credits: row.credits })), | |
| accountCount: out.accountCount, | |
| unavailable: out.unavailable, | |
| colorA: ctx.pkAccountColorMap([{ uid: 'b' }, { uid: 'a' }]).get('a'), | |
| colorB: ctx.pkAccountColorMap([{ uid: 'a' }, { uid: 'b' }]).get('b'), | |
| }));` | |
| f, err := os.CreateTemp(t.TempDir(), "expiry-*.cjs") | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| if _, err := f.WriteString(script); err != nil { | |
| t.Fatal(err) | |
| } | |
| f.Close() | |
| out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput() | |
| if err != nil { | |
| t.Fatalf("expiry summary node test failed: %v\n%s", err, out) | |
| } | |
| const want = `{"rows":[{"days":1,"credits":50},{"days":7,"credits":70}],"accountCount":3,"unavailable":1,"colorA":"#4f8cff","colorB":"#25b08b"}` | |
| if strings.TrimSpace(string(out)) != want { | |
| t.Fatalf("expiry summary=%s want %s", out, want) | |
| } | |
| } | |
| // TestAppJSCollectConfigClearable 钉住 collectConfig 的空串语义。 | |
| // | |
| // 覆盖型字段(user_agent / prompt_file)空串必须照发:漏发会让面板显示"已保存" | |
| // 而 config.json 里的值没变(issue #102 附带发现 2)。 | |
| // | |
| // 同时钉住反面:其余文本字段空串仍然不下发。这条同样重要——若哪天为了修上面那个 | |
| // 问题改成"所有空串都发",表单里任何一个没填的框都会变成"请清空",静默抹掉配置。 | |
| func TestAppJSCollectConfigClearable(t *testing.T) { | |
| node, err := exec.LookPath("node") | |
| if err != nil { | |
| t.Skip("node not installed; collectConfig test skipped") | |
| } | |
| script := `const fs = require('fs'); | |
| const vm = require('vm'); | |
| const src = fs.readFileSync(process.argv[2], 'utf8'); | |
| const start = src.indexOf('const CFG_MAP'); | |
| const end = src.indexOf('/* Go 时长字段即时校验'); | |
| if (start < 0 || end < 0 || end < start) throw new Error('collectConfig region not found'); | |
| const mk = v => ({ type: 'text', value: v }); | |
| const cfgForm = { elements: { | |
| listen: mk(''), | |
| api_key: mk('secret'), | |
| user_agent: mk(''), | |
| prompt_file: mk(''), | |
| checkin_hours: mk(''), | |
| }}; | |
| const ctx = { | |
| Date, Number, String, Math, Map, Array, Object, isNaN, URLSearchParams, Set, | |
| document: { getElementById: id => (id === 'cfgForm' ? cfgForm : null) }, | |
| $: id => (id === 'cfgForm' ? cfgForm : null), | |
| }; | |
| vm.createContext(ctx); | |
| vm.runInContext(src.slice(start, end) + '\nthis.collectConfig = collectConfig;', ctx); | |
| const out = ctx.collectConfig(); | |
| const has = (o, k) => Object.prototype.hasOwnProperty.call(o || {}, k); | |
| process.stdout.write(JSON.stringify([ | |
| has(out.upstream, 'user_agent'), (out.upstream || {}).user_agent, | |
| has(out.prompt, 'file'), (out.prompt || {}).file, | |
| has(out, 'listen'), | |
| has(out.schedule, 'checkin_hours'), | |
| out.api_key | |
| ]));` | |
| f, err := os.CreateTemp(t.TempDir(), "cfgc-*.cjs") | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| if _, err := f.WriteString(script); err != nil { | |
| t.Fatal(err) | |
| } | |
| f.Close() | |
| out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput() | |
| if err != nil { | |
| t.Fatalf("collectConfig node test failed: %v\n%s", err, out) | |
| } | |
| // [user_agent 已发, 其值, prompt.file 已发, 其值, listen 未发, checkin_hours 未发, api_key] | |
| const want = `[true,"",true,"",false,false,"secret"]` | |
| if strings.TrimSpace(string(out)) != want { | |
| t.Fatalf("collectConfig=%s want %s", strings.TrimSpace(string(out)), want) | |
| } | |
| } | |