@tokenbuddy/tb-admin 1.0.31 → 1.0.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/src/cli.d.ts.map +1 -1
  2. package/dist/src/cli.js +280 -19
  3. package/dist/src/cli.js.map +1 -1
  4. package/dist/src/client.d.ts +82 -2
  5. package/dist/src/client.d.ts.map +1 -1
  6. package/dist/src/client.js +93 -0
  7. package/dist/src/client.js.map +1 -1
  8. package/dist/src/provider.d.ts +120 -0
  9. package/dist/src/provider.d.ts.map +1 -0
  10. package/dist/src/provider.js +73 -0
  11. package/dist/src/provider.js.map +1 -0
  12. package/dist/src/seller.d.ts +104 -0
  13. package/dist/src/seller.d.ts.map +1 -0
  14. package/dist/src/seller.js +283 -0
  15. package/dist/src/seller.js.map +1 -0
  16. package/dist/src/ui-actions.d.ts +25 -0
  17. package/dist/src/ui-actions.d.ts.map +1 -1
  18. package/dist/src/ui-actions.js +81 -11
  19. package/dist/src/ui-actions.js.map +1 -1
  20. package/dist/src/ui-server.js +9 -0
  21. package/dist/src/ui-server.js.map +1 -1
  22. package/dist/src/ui-state.d.ts +77 -2
  23. package/dist/src/ui-state.d.ts.map +1 -1
  24. package/dist/src/ui-state.js +242 -14
  25. package/dist/src/ui-state.js.map +1 -1
  26. package/dist/src/ui-static.d.ts.map +1 -1
  27. package/dist/src/ui-static.js +95 -17
  28. package/dist/src/ui-static.js.map +1 -1
  29. package/dist/src/vendor-client.d.ts +23 -0
  30. package/dist/src/vendor-client.d.ts.map +1 -0
  31. package/dist/src/vendor-client.js +2 -0
  32. package/dist/src/vendor-client.js.map +1 -0
  33. package/dist/src/vendor-commands.d.ts +35 -0
  34. package/dist/src/vendor-commands.d.ts.map +1 -0
  35. package/dist/src/vendor-commands.js +33 -0
  36. package/dist/src/vendor-commands.js.map +1 -0
  37. package/package.json +1 -1
  38. package/src/cli.ts +305 -31
  39. package/src/client.ts +119 -2
  40. package/src/provider.ts +150 -0
  41. package/src/seller.ts +362 -0
  42. package/src/ui-actions.ts +89 -11
  43. package/src/ui-server.ts +9 -0
  44. package/src/ui-state.ts +293 -15
  45. package/src/ui-static.ts +95 -17
  46. package/src/vendor-client.ts +23 -0
  47. package/src/vendor-commands.ts +65 -0
  48. package/tests/admin.test.ts +20 -1
  49. package/tests/seller.test.ts +307 -0
  50. package/tests/ui-state-fleet.test.ts +257 -0
  51. package/tests/ui-static-row.test.ts +202 -0
  52. package/tests/vendor-cli.test.ts +197 -0
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Step 13 v1.1: admin web UI seller row 渲染测试.
3
+ *
4
+ * 原则: 不引入 jsdom / linkedom. 直接 evaluate ui-static.ts bundle 的
5
+ * inline script 抽出 sellerRow 函数 (regex 匹配 function 声明) + 喂 mock
6
+ * window.__tbFmt, 然后 string match 验证 4 类行 (dataSource=both / fly /
7
+ * registry / 老 1.0.31 缺字段) 渲染出正确的 CSS class + 文案 + 按钮.
8
+ *
9
+ * 覆盖 (跟 docs/processes/seller-fleet-data-sources.md 一致):
10
+ * 1. dataSource="both" → "app-row" (无红/灰 class), 绿点 active, "Both" chip
11
+ * 2. dataSource="fly" → "app-row app-row-fly-only" (灰), "未发布" chip
12
+ * + publish-hint-btn 文案
13
+ * 3. dataSource="registry" → "app-row app-row-registry-only" (整行红边)
14
+ * + "严重事故" + alert-reason + remove-hint-btn
15
+ * 4. dataSource 老 1.0.31 缺失 → 兜底 "both" (默认行, 不报红)
16
+ *
17
+ * 验证 grep 锚点 (跟 spec 一致):
18
+ * - "立即下线 (registry-only)" 必须在 remove-hint-btn 出现
19
+ * - "registry 收录了但 fly app 失踪" 必须在 alert-reason 出现
20
+ * - "未发布 — 可申请发布" 必须在 publish-hint-btn 出现
21
+ */
22
+
23
+ import { adminUiHtml } from "../src/ui-static.js";
24
+
25
+ interface MockFmt {
26
+ UNKNOWN_VALUE: string;
27
+ formatDuration: (v: unknown) => string;
28
+ formatSpeed: (v: unknown) => string;
29
+ formatBalanceAmount: () => string;
30
+ formatSellerCapacity: () => string;
31
+ formatDiscountRatio: (v: unknown) => string;
32
+ formatSellerStatus: (s: unknown) => string;
33
+ sellerStatusTone: (s: unknown) => string;
34
+ normalizeStatusLabel: (s: unknown) => string;
35
+ formatTimeCompact: (s: unknown) => string;
36
+ formatCount: (n: unknown) => string;
37
+ }
38
+
39
+ const MOCK_FMT: MockFmt = {
40
+ UNKNOWN_VALUE: "—",
41
+ formatDuration: () => "123ms",
42
+ formatSpeed: () => "12.3 tok/s",
43
+ formatBalanceAmount: () => "0.00",
44
+ formatSellerCapacity: () => "3/8",
45
+ formatDiscountRatio: () => "1.0x",
46
+ formatSellerStatus: (s) => String(s || "unknown"),
47
+ sellerStatusTone: (s) => {
48
+ const k = String(s || "unknown").toLowerCase();
49
+ if (k === "active" || k === "healthy") return "green";
50
+ if (k === "draining" || k === "degraded") return "amber";
51
+ if (k === "offline" || k === "unhealthy") return "red";
52
+ if (k === "busy_capacity") return "blue";
53
+ return "gray";
54
+ },
55
+ normalizeStatusLabel: (s) => String(s || "—"),
56
+ formatTimeCompact: () => "12:34",
57
+ formatCount: (n) => String(n ?? 0)
58
+ };
59
+
60
+ /**
61
+ * Step 13 v1.1: 不引 jsdom, 用 `new Function` 跑抽出 sellerRow 函数体.
62
+ * ui-static.ts 整个文件是 `<script>...</script>` 模板字符串, 我们用
63
+ * 简单 regex 提取 inline script 内容, 加上 mock window, 跑 sellerRow 拿 HTML.
64
+ *
65
+ * 限制: 不模拟 DOM 树. 验证方式是直接看 sellerRow() 返回的 HTML 字符串
66
+ * 是否含预期的 class + 文案 + 按钮 (跟 spec grep 锚点一一对应).
67
+ */
68
+ function renderRow(row: Record<string, unknown>): string {
69
+ const html = adminUiHtml();
70
+ // 抽 <script>...</script>
71
+ const scriptMatch = html.match(/<script>([\s\S]*?)<\/script>/);
72
+ if (!scriptMatch) {
73
+ throw new Error("adminUiHtml() did not include a <script> tag");
74
+ }
75
+ const scriptBody = scriptMatch[1];
76
+ // mock window + 把 sellerRow 暴露
77
+ const code = `
78
+ var noopArr = () => [];
79
+ var fakeEl = { classList: { toggle: () => undefined, add: () => undefined, remove: () => undefined }, innerHTML: "", textContent: "" };
80
+ var window = { __tbFmt: ${JSON.stringify(MOCK_FMT)}, addEventListener: () => undefined };
81
+ var document = {
82
+ createElement: () => fakeEl,
83
+ addEventListener: () => undefined,
84
+ querySelectorAll: noopArr,
85
+ querySelector: () => fakeEl,
86
+ getElementById: () => fakeEl
87
+ };
88
+ var setTimeout = () => 0;
89
+ var setInterval = () => 0;
90
+ var clearTimeout = () => undefined;
91
+ var clearInterval = () => undefined;
92
+ ${scriptBody}
93
+ return sellerRow(${JSON.stringify(row)});
94
+ `;
95
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
96
+ const fn = new Function(code);
97
+ return fn();
98
+ }
99
+
100
+ describe("admin web seller row 渲染 (Step 13 v1.1 双源 spec)", () => {
101
+ test("dataSource=both → 正常行 (无红/灰 class), 绿点, Both chip", () => {
102
+ const out = renderRow({
103
+ id: "tbs-alpha",
104
+ name: "tbs-alpha",
105
+ app: "tbs-alpha",
106
+ url: "https://tbs-alpha.fly.dev",
107
+ registryStatus: "active",
108
+ nodeStatus: "active",
109
+ upstreamDomain: "openrouter.ai",
110
+ upstreamStatus: "healthy",
111
+ modelsCount: 1,
112
+ dataSource: "both",
113
+ flyApp: { name: "tbs-alpha", status: "running" }
114
+ });
115
+ // 行 class 不带 registry-only / fly-only
116
+ expect(out).toContain('class="app-row"');
117
+ expect(out).not.toContain("app-row-registry-only");
118
+ expect(out).not.toContain("app-row-fly-only");
119
+ // 绿点 active → tone-green
120
+ expect(out).toMatch(/app-dot tone-green/);
121
+ // Both chip
122
+ expect(out).toMatch(/datasource-chip both/);
123
+ expect(out).toContain("Both");
124
+ // 无 alert-reason / remove-hint-btn / publish-hint-btn
125
+ expect(out).not.toContain("alert-reason");
126
+ expect(out).not.toContain("remove-hint-btn");
127
+ expect(out).not.toContain("publish-hint-btn");
128
+ });
129
+
130
+ test("dataSource=fly → 灰底 + 未发布 chip + publish-hint-btn", () => {
131
+ const out = renderRow({
132
+ id: "tbs-flyonly",
133
+ name: "tbs-flyonly",
134
+ app: "tbs-flyonly",
135
+ url: "https://tbs-flyonly.fly.dev",
136
+ registryStatus: "unknown",
137
+ nodeStatus: "unknown",
138
+ upstreamDomain: "tbs-flyonly.fly.dev",
139
+ upstreamStatus: "unknown",
140
+ modelsCount: 0,
141
+ dataSource: "fly",
142
+ publishHint: "未发布 — 可申请发布 (走 vendor-bootstrap stage)",
143
+ flyApp: { name: "tbs-flyonly", status: "running" }
144
+ });
145
+ expect(out).toContain("app-row-fly-only");
146
+ expect(out).not.toContain("app-row-registry-only");
147
+ expect(out).toMatch(/datasource-chip fly/);
148
+ expect(out).toContain("未发布");
149
+ // publish-hint-btn 出现 + 文案含 spec 关键词
150
+ expect(out).toContain("publish-hint-btn");
151
+ expect(out).toContain("未发布 — 可申请发布");
152
+ });
153
+
154
+ test("dataSource=registry → 整行红边 + 严重事故 + alert-reason + 立即下线 (registry-only) 按钮", () => {
155
+ const out = renderRow({
156
+ id: "tbs-ghost",
157
+ name: "tbs-ghost",
158
+ app: "tbs-ghost",
159
+ url: "https://tbs-ghost.fly.dev",
160
+ registryStatus: "active",
161
+ nodeStatus: "unknown",
162
+ upstreamDomain: "tbs-ghost.fly.dev",
163
+ upstreamStatus: "unknown",
164
+ modelsCount: 1,
165
+ dataSource: "registry",
166
+ registryAlert: true,
167
+ alertReason: "registry 收录了但 fly app 失踪 — 严重事故, 立即下线",
168
+ removeHint: "立即下线 (registry-only)"
169
+ });
170
+ expect(out).toContain("app-row-registry-only");
171
+ // 严重事故 文案
172
+ expect(out).toContain("严重事故");
173
+ // alert-reason
174
+ expect(out).toContain("alert-reason");
175
+ expect(out).toContain("registry 收录了但 fly app 失踪");
176
+ // 立即下线 (registry-only) 按钮
177
+ expect(out).toContain("remove-hint-btn");
178
+ expect(out).toContain("立即下线 (registry-only)");
179
+ expect(out).toMatch(/data-action="remove"/);
180
+ expect(out).toMatch(/data-seller-id="tbs-ghost"/);
181
+ });
182
+
183
+ test("dataSource 缺失 (老 1.0.31 行) → 兜底 'both', 不报错不标红", () => {
184
+ const out = renderRow({
185
+ id: "legacy",
186
+ name: "legacy",
187
+ app: "legacy",
188
+ url: "https://legacy.fly.dev",
189
+ registryStatus: "active",
190
+ nodeStatus: "active",
191
+ upstreamDomain: "legacy.fly.dev",
192
+ upstreamStatus: "healthy",
193
+ modelsCount: 1
194
+ // 故意没 dataSource 字段, 模拟 1.0.31 老 row
195
+ });
196
+ expect(out).toContain('class="app-row"');
197
+ expect(out).not.toContain("app-row-registry-only");
198
+ expect(out).not.toContain("app-row-fly-only");
199
+ // 兜底 chip = both
200
+ expect(out).toMatch(/datasource-chip both/);
201
+ });
202
+ });
@@ -0,0 +1,197 @@
1
+ import request from "supertest";
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+ import { buildAdminCli } from "../src/cli.js";
5
+ import { ConfigManager } from "../src/config.js";
6
+ import { execSync } from "child_process";
7
+
8
+ // Use the workspace-linked package. ts-jest will resolve the
9
+ // `.js` to the compiled `.ts` via the root moduleNameMapper.
10
+ import { buildApp, BootstrapConfig } from "@tokenbuddy/wallet-bootstrap";
11
+
12
+ const WALLET_TEST_DIR = path.resolve(__dirname, "../../data-vendor-cli-test");
13
+ const TEMP_REGISTRY_PATH = path.join(WALLET_TEST_DIR, "sellers.json");
14
+ const TEMP_REGISTRY_DB_PATH = path.join(WALLET_TEST_DIR, "bootstrap.sqlite");
15
+ const TEMP_CONFIG_PATH = path.join(WALLET_TEST_DIR, "tb-registry.yaml");
16
+ const TEMP_ADMIN_CONF_PATH = path.join(WALLET_TEST_DIR, "admin-config.json");
17
+ const SUPER_ADMIN_KEY = "platform-super-admin-key-please-rotate-me-1234567890";
18
+ const SESSION_SECRET = "session-secret-32-chars-min-please";
19
+
20
+ const baseRegistry = {
21
+ version: 1,
22
+ defaultSeller: "platform-seed",
23
+ sellers: [
24
+ {
25
+ id: "platform-seed",
26
+ name: "Platform Seed",
27
+ url: "https://platform-seed.example.com",
28
+ status: "active",
29
+ models: ["seed-model"],
30
+ supportedProtocols: ["chat_completions"],
31
+ paymentMethods: ["clawtip"]
32
+ }
33
+ ]
34
+ };
35
+
36
+ const baseConfig: BootstrapConfig = {
37
+ configPath: TEMP_CONFIG_PATH,
38
+ payTo: "pay-to-x",
39
+ sm4KeyBase64: "MDEyMzQ1Njc4OUFCQ0RFRg==",
40
+ skillSlug: "tb-registry",
41
+ skillId: "si-tb-registry",
42
+ description: "Activate",
43
+ resourceUrl: "https://example.com",
44
+ activationFeeFen: 1,
45
+ microsPerFen: 1000000,
46
+ sellerRegistryPath: TEMP_REGISTRY_PATH,
47
+ operatorSecret: "op-secret",
48
+ superAdminKey: SUPER_ADMIN_KEY,
49
+ superAdminLabel: "ops",
50
+ sessionSecret: SESSION_SECRET
51
+ };
52
+
53
+ function buildServer() {
54
+ fs.mkdirSync(WALLET_TEST_DIR, { recursive: true });
55
+ fs.writeFileSync(TEMP_REGISTRY_PATH, JSON.stringify(baseRegistry), "utf8");
56
+ for (const f of [TEMP_REGISTRY_DB_PATH, `${TEMP_REGISTRY_DB_PATH}-wal`, `${TEMP_REGISTRY_DB_PATH}-shm`]) {
57
+ try { fs.rmSync(f, { force: true }); } catch { /* ignore */ }
58
+ }
59
+ return buildApp(baseConfig);
60
+ }
61
+
62
+ async function createVendorToken(app: ReturnType<typeof buildServer>, name: string): Promise<string> {
63
+ const response = await request(app)
64
+ .post("/platform/vendors")
65
+ .set("X-Registry-Admin-Key", SUPER_ADMIN_KEY)
66
+ .send({ name });
67
+ expect(response.status).toBe(200);
68
+ return response.body.token;
69
+ }
70
+
71
+ async function runCli(args: string[], env: NodeJS.ProcessEnv): Promise<{ stdout: string; stderr: string; status: number }> {
72
+ // We invoke the CLI as a child process so the `program` state does
73
+ // not leak between tests. The package's `dist/` may not exist in
74
+ // worktrees, so we run from TypeScript source via `tsx` if
75
+ // available; fall back to requiring the source.
76
+ const cliEntry = path.resolve(__dirname, "../src/cli.ts");
77
+ // Try `npx tsx` first, then `node --import tsx`, then fallback
78
+ // to `node -r ts-node/register`.
79
+ let cmd: string;
80
+ let finalArgs: string[];
81
+ try {
82
+ execSync("which tsx", { stdio: "ignore" });
83
+ cmd = "npx";
84
+ finalArgs = ["tsx", cliEntry, ...args];
85
+ } catch {
86
+ cmd = "node";
87
+ finalArgs = ["--import", "tsx", cliEntry, ...args];
88
+ }
89
+ try {
90
+ const stdout = execSync(`${cmd} ${finalArgs.map((a) => JSON.stringify(a)).join(" ")}`, {
91
+ env: { ...process.env, ...env },
92
+ stdio: ["ignore", "pipe", "pipe"],
93
+ encoding: "utf8"
94
+ });
95
+ return { stdout, stderr: "", status: 0 };
96
+ } catch (err: any) {
97
+ return {
98
+ stdout: err.stdout?.toString() || "",
99
+ stderr: err.stderr?.toString() || err.message,
100
+ status: err.status || 1
101
+ };
102
+ }
103
+ }
104
+
105
+ describe("Vendor CLI integration (Step 5)", () => {
106
+ beforeEach(() => {
107
+ fs.mkdirSync(WALLET_TEST_DIR, { recursive: true });
108
+ for (const f of [TEMP_REGISTRY_PATH, TEMP_REGISTRY_DB_PATH, `${TEMP_REGISTRY_DB_PATH}-wal`, `${TEMP_REGISTRY_DB_PATH}-shm`, TEMP_CONFIG_PATH, TEMP_ADMIN_CONF_PATH]) {
109
+ try { fs.rmSync(f, { force: true }); } catch { /* ignore */ }
110
+ }
111
+ });
112
+
113
+ afterAll(() => {
114
+ fs.rmSync(WALLET_TEST_DIR, { recursive: true, force: true });
115
+ });
116
+
117
+ test("buildAdminCli still constructs without errors after the registry redesign", () => {
118
+ const config = new ConfigManager(TEMP_ADMIN_CONF_PATH);
119
+ const cli = buildAdminCli(config);
120
+ expect(cli.commands.find((c: any) => c.name() === "vendor-bootstrap")).toBeDefined();
121
+ // The legacy `bootstrap` command is still there for backward compat.
122
+ expect(cli.commands.find((c: any) => c.name() === "bootstrap")).toBeDefined();
123
+ });
124
+
125
+ test("RegistryVendorClient can authenticate against a live wallet-bootstrap app and stage a seller", async () => {
126
+ const app = buildServer();
127
+ const token = await createVendorToken(app, "Acme");
128
+ // Direct HTTP call to the vendor API as the CLI would do.
129
+ const stage = await request(app)
130
+ .post("/platform/sellers/stage")
131
+ .set("Authorization", `Bearer ${token}`)
132
+ .send({ seller: {
133
+ id: "acme-seller-1",
134
+ name: "Acme Seller 1",
135
+ url: "https://acme.example.com",
136
+ status: "active",
137
+ models: ["m1"],
138
+ supportedProtocols: ["chat_completions"],
139
+ paymentMethods: ["clawtip"]
140
+ } });
141
+ expect(stage.status).toBe(200);
142
+ expect(stage.body.pendingSeller.id).toBe("acme-seller-1");
143
+ expect(stage.body.pendingSeller.status).toBe("staged");
144
+ });
145
+
146
+ test("RegistryVendorClient listPendingSellers only returns this vendor's rows", async () => {
147
+ const app = buildServer();
148
+ const tokenA = await createVendorToken(app, "VendorA");
149
+ const tokenB = await createVendorToken(app, "VendorB");
150
+ await request(app)
151
+ .post("/platform/sellers/stage")
152
+ .set("Authorization", `Bearer ${tokenA}`)
153
+ .send({ seller: {
154
+ id: "va-1", name: "VA1", url: "https://va1.example.com", status: "active",
155
+ models: ["m1"], supportedProtocols: ["chat_completions"], paymentMethods: ["clawtip"]
156
+ } });
157
+ await request(app)
158
+ .post("/platform/sellers/stage")
159
+ .set("Authorization", `Bearer ${tokenB}`)
160
+ .send({ seller: {
161
+ id: "vb-1", name: "VB1", url: "https://vb1.example.com", status: "active",
162
+ models: ["m1"], supportedProtocols: ["chat_completions"], paymentMethods: ["clawtip"]
163
+ } });
164
+ const aList = await request(app)
165
+ .get("/platform/sellers/pending?status=staged")
166
+ .set("Authorization", `Bearer ${tokenA}`);
167
+ const bList = await request(app)
168
+ .get("/platform/sellers/pending?status=staged")
169
+ .set("Authorization", `Bearer ${tokenB}`);
170
+ expect(aList.body.pendingSellers).toHaveLength(1);
171
+ expect(aList.body.pendingSellers[0].id).toBe("va-1");
172
+ expect(bList.body.pendingSellers).toHaveLength(1);
173
+ expect(bList.body.pendingSellers[0].id).toBe("vb-1");
174
+ });
175
+
176
+ test("RegistryVendorClient submitRelease -> listReleaseRequests round-trips", async () => {
177
+ const app = buildServer();
178
+ const token = await createVendorToken(app, "VendorC");
179
+ await request(app)
180
+ .post("/platform/sellers/stage")
181
+ .set("Authorization", `Bearer ${token}`)
182
+ .send({ seller: {
183
+ id: "vc-1", name: "VC1", url: "https://vc1.example.com", status: "active",
184
+ models: ["m1"], supportedProtocols: ["chat_completions"], paymentMethods: ["clawtip"]
185
+ } });
186
+ const submit = await request(app)
187
+ .post("/platform/release-requests")
188
+ .set("Authorization", `Bearer ${token}`)
189
+ .send({ stagedSellerIds: ["vc-1"], note: "test" });
190
+ expect(submit.status).toBe(200);
191
+ const list = await request(app)
192
+ .get("/platform/release-requests?limit=10")
193
+ .set("Authorization", `Bearer ${token}`);
194
+ expect(list.body.releaseRequests).toHaveLength(1);
195
+ expect(list.body.releaseRequests[0].id).toBe(submit.body.releaseRequest.id);
196
+ });
197
+ });