chatccc 0.2.178 → 0.2.180

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.
@@ -1,129 +1,219 @@
1
- import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
-
5
- import { afterEach, describe, expect, it, vi } from "vitest";
6
-
7
- async function loadFeishuApiWithHome(homeDir: string, userDataDir: string) {
8
- vi.resetModules();
9
- vi.doMock("node:os", () => ({ homedir: () => homeDir }));
10
- vi.doMock("../config.ts", () => ({
11
- APP_ID: "app_id",
12
- APP_SECRET: "app_secret",
13
- BASE_URL: "https://open.feishu.test",
14
- CHAT_LOGS_DIR: join(userDataDir, "logs"),
15
- PROJECT_ROOT: process.cwd(),
16
- USER_DATA_DIR: userDataDir,
17
- CLAUDE_SESSION_PREFIX: "Claude Code Session:",
18
- CURSOR_SESSION_PREFIX: "Cursor Session:",
19
- CODEX_SESSION_PREFIX: "Codex Session:",
20
- ts: () => "test-ts",
21
- resolveDefaultAgentTool: () => "claude",
22
- toolDisplayName: (tool: string) => tool,
23
- }));
24
- return import("../feishu-api.ts");
25
- }
26
-
27
- async function writeCodexAuth(homeDir: string): Promise<void> {
28
- const codexDir = join(homeDir, ".codex");
29
- await mkdir(codexDir, { recursive: true });
30
- await writeFile(
31
- join(codexDir, "auth.json"),
32
- JSON.stringify({ tokens: { access_token: "codex-access-token" } }),
33
- "utf-8",
34
- );
35
- }
36
-
37
- function mockAvatarFetch(uploadedNames: string[], usageResponse: Response): void {
38
- vi.stubGlobal("fetch", vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
39
- const urlText = String(url);
40
- if (urlText === "https://chatgpt.com/backend-api/wham/usage") {
41
- return usageResponse.clone();
42
- }
43
- if (urlText === "https://open.feishu.test/im/v1/images") {
44
- const form = init?.body as FormData;
45
- const image = form.get("image") as File;
46
- uploadedNames.push(image.name);
47
- return new Response(JSON.stringify({ code: 0, data: { image_key: "img_test" } }), { status: 200 });
48
- }
49
- if (urlText === "https://open.feishu.test/im/v1/chats/chat_1") {
50
- return new Response(JSON.stringify({ code: 0 }), { status: 200 });
51
- }
52
- throw new Error(`unexpected fetch: ${urlText}`);
53
- }));
54
- }
55
-
56
- describe("Codex avatar usage battery", () => {
57
- afterEach(() => {
58
- vi.unstubAllGlobals();
59
- vi.doUnmock("node:os");
60
- vi.doUnmock("../config.ts");
61
- vi.restoreAllMocks();
62
- });
63
-
64
- it("adds weekly battery and 5h ring percentages to Codex avatar uploads when usage lookup succeeds", async () => {
65
- const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
66
- const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
67
- const uploadedNames: string[] = [];
68
- await writeCodexAuth(homeDir);
69
- mockAvatarFetch(uploadedNames, new Response(JSON.stringify({
70
- rate_limit: {
71
- primary_window: { used_percent: 37 },
72
- secondary_window: { used_percent: 12 },
73
- },
74
- }), { status: 200 }));
75
-
76
- try {
77
- const { setChatAvatar } = await loadFeishuApiWithHome(homeDir, userDataDir);
78
- await setChatAvatar("tenant-token", "chat_1", "codex", "busy");
79
-
80
- expect(uploadedNames).toEqual(["avatar_codex_busy_week_88_5h_63.jpg"]);
81
- } finally {
82
- await rm(homeDir, { recursive: true, force: true });
83
- await rm(userDataDir, { recursive: true, force: true });
84
- }
85
- });
86
-
87
- it("returns both 5h and weekly Codex usage windows", async () => {
88
- const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
89
- const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
90
- const uploadedNames: string[] = [];
91
- await writeCodexAuth(homeDir);
92
- mockAvatarFetch(uploadedNames, new Response(JSON.stringify({
93
- rate_limit: {
94
- primary_window: { used_percent: 37, reset_after_seconds: 10349, reset_at: 1781528212 },
95
- secondary_window: { used_percent: 12, reset_after_seconds: 325063, reset_at: 1781842926 },
96
- },
97
- }), { status: 200 }));
98
-
99
- try {
100
- const { getCodexUsageSummary } = await loadFeishuApiWithHome(homeDir, userDataDir);
101
- await expect(getCodexUsageSummary()).resolves.toEqual({
102
- fiveHour: { usedPercent: 37, remainingPercent: 63, resetAfterSeconds: 10349, resetAtEpochSeconds: 1781528212 },
103
- weekly: { usedPercent: 12, remainingPercent: 88, resetAfterSeconds: 325063, resetAtEpochSeconds: 1781842926 },
104
- });
105
- } finally {
106
- await rm(homeDir, { recursive: true, force: true });
107
- await rm(userDataDir, { recursive: true, force: true });
108
- }
109
- });
110
-
111
- it("falls back to the pre-combined Codex avatar when usage lookup fails", async () => {
112
- const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
113
- const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
114
- const uploadedNames: string[] = [];
115
- await writeCodexAuth(homeDir);
116
- vi.spyOn(console, "warn").mockImplementation(() => {});
117
- mockAvatarFetch(uploadedNames, new Response("fail", { status: 500 }));
118
-
119
- try {
120
- const { setChatAvatar } = await loadFeishuApiWithHome(homeDir, userDataDir);
121
- await setChatAvatar("tenant-token", "chat_1", "codex", "idle");
122
-
123
- expect(uploadedNames).toEqual(["avatar_codex_idle.jpg"]);
124
- } finally {
125
- await rm(homeDir, { recursive: true, force: true });
126
- await rm(userDataDir, { recursive: true, force: true });
127
- }
128
- });
129
- });
1
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+
7
+ const mockConfig = {
8
+ cursor: {
9
+ avatarBatteryMode: "apiPercent",
10
+ onDemandMonthlyBudget: 1000,
11
+ },
12
+ };
13
+ const getCursorUsageSummaryMock = vi.fn();
14
+
15
+ async function loadFeishuApiWithHome(homeDir: string, userDataDir: string) {
16
+ vi.resetModules();
17
+ vi.doMock("node:os", () => ({ homedir: () => homeDir }));
18
+ vi.doMock("../config.ts", () => ({
19
+ APP_ID: "app_id",
20
+ APP_SECRET: "app_secret",
21
+ BASE_URL: "https://open.feishu.test",
22
+ CHAT_LOGS_DIR: join(userDataDir, "logs"),
23
+ PROJECT_ROOT: process.cwd(),
24
+ USER_DATA_DIR: userDataDir,
25
+ CLAUDE_SESSION_PREFIX: "Claude Code Session:",
26
+ CURSOR_SESSION_PREFIX: "Cursor Session:",
27
+ CODEX_SESSION_PREFIX: "Codex Session:",
28
+ ts: () => "test-ts",
29
+ resolveDefaultAgentTool: () => "claude",
30
+ toolDisplayName: (tool: string) => tool,
31
+ config: mockConfig,
32
+ }));
33
+ vi.doMock("../cursor-usage.ts", () => ({
34
+ getCursorUsageSummary: getCursorUsageSummaryMock,
35
+ }));
36
+ return import("../feishu-api.ts");
37
+ }
38
+
39
+ async function writeCodexAuth(homeDir: string): Promise<void> {
40
+ const codexDir = join(homeDir, ".codex");
41
+ await mkdir(codexDir, { recursive: true });
42
+ await writeFile(
43
+ join(codexDir, "auth.json"),
44
+ JSON.stringify({ tokens: { access_token: "codex-access-token" } }),
45
+ "utf-8",
46
+ );
47
+ }
48
+
49
+ function mockAvatarFetch(uploadedNames: string[], usageResponse: Response): void {
50
+ vi.stubGlobal("fetch", vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
51
+ const urlText = String(url);
52
+ if (urlText === "https://chatgpt.com/backend-api/wham/usage") {
53
+ return usageResponse.clone();
54
+ }
55
+ if (urlText === "https://open.feishu.test/im/v1/images") {
56
+ const form = init?.body as FormData;
57
+ const image = form.get("image") as File;
58
+ uploadedNames.push(image.name);
59
+ return new Response(JSON.stringify({ code: 0, data: { image_key: "img_test" } }), { status: 200 });
60
+ }
61
+ if (urlText === "https://open.feishu.test/im/v1/chats/chat_1") {
62
+ return new Response(JSON.stringify({ code: 0 }), { status: 200 });
63
+ }
64
+ throw new Error(`unexpected fetch: ${urlText}`);
65
+ }));
66
+ }
67
+
68
+ describe("Codex avatar usage battery", () => {
69
+ afterEach(() => {
70
+ vi.unstubAllGlobals();
71
+ vi.doUnmock("node:os");
72
+ vi.doUnmock("../config.ts");
73
+ vi.doUnmock("../cursor-usage.ts");
74
+ vi.restoreAllMocks();
75
+ getCursorUsageSummaryMock.mockReset();
76
+ mockConfig.cursor.avatarBatteryMode = "apiPercent";
77
+ mockConfig.cursor.onDemandMonthlyBudget = 1000;
78
+ });
79
+
80
+ it("adds weekly battery and 5h ring percentages to Codex avatar uploads when usage lookup succeeds", async () => {
81
+ const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
82
+ const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
83
+ const uploadedNames: string[] = [];
84
+ await writeCodexAuth(homeDir);
85
+ mockAvatarFetch(uploadedNames, new Response(JSON.stringify({
86
+ rate_limit: {
87
+ primary_window: { used_percent: 37 },
88
+ secondary_window: { used_percent: 12 },
89
+ },
90
+ }), { status: 200 }));
91
+
92
+ try {
93
+ const { setChatAvatar } = await loadFeishuApiWithHome(homeDir, userDataDir);
94
+ await setChatAvatar("tenant-token", "chat_1", "codex", "busy");
95
+
96
+ expect(uploadedNames).toEqual(["avatar_codex_busy_week_88_5h_63.jpg"]);
97
+ } finally {
98
+ await rm(homeDir, { recursive: true, force: true });
99
+ await rm(userDataDir, { recursive: true, force: true });
100
+ }
101
+ });
102
+
103
+ it("returns both 5h and weekly Codex usage windows", async () => {
104
+ const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
105
+ const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
106
+ const uploadedNames: string[] = [];
107
+ await writeCodexAuth(homeDir);
108
+ mockAvatarFetch(uploadedNames, new Response(JSON.stringify({
109
+ rate_limit: {
110
+ primary_window: { used_percent: 37, reset_after_seconds: 10349, reset_at: 1781528212 },
111
+ secondary_window: { used_percent: 12, reset_after_seconds: 325063, reset_at: 1781842926 },
112
+ },
113
+ }), { status: 200 }));
114
+
115
+ try {
116
+ const { getCodexUsageSummary } = await loadFeishuApiWithHome(homeDir, userDataDir);
117
+ await expect(getCodexUsageSummary()).resolves.toEqual({
118
+ fiveHour: { usedPercent: 37, remainingPercent: 63, resetAfterSeconds: 10349, resetAtEpochSeconds: 1781528212 },
119
+ weekly: { usedPercent: 12, remainingPercent: 88, resetAfterSeconds: 325063, resetAtEpochSeconds: 1781842926 },
120
+ });
121
+ } finally {
122
+ await rm(homeDir, { recursive: true, force: true });
123
+ await rm(userDataDir, { recursive: true, force: true });
124
+ }
125
+ });
126
+
127
+ it("falls back to the pre-combined Codex avatar when usage lookup fails", async () => {
128
+ const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
129
+ const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
130
+ const uploadedNames: string[] = [];
131
+ await writeCodexAuth(homeDir);
132
+ vi.spyOn(console, "warn").mockImplementation(() => {});
133
+ mockAvatarFetch(uploadedNames, new Response("fail", { status: 500 }));
134
+
135
+ try {
136
+ const { setChatAvatar } = await loadFeishuApiWithHome(homeDir, userDataDir);
137
+ await setChatAvatar("tenant-token", "chat_1", "codex", "idle");
138
+
139
+ expect(uploadedNames).toEqual(["avatar_codex_idle.jpg"]);
140
+ } finally {
141
+ await rm(homeDir, { recursive: true, force: true });
142
+ await rm(userDataDir, { recursive: true, force: true });
143
+ }
144
+ });
145
+ });
146
+
147
+ describe("Cursor avatar usage battery", () => {
148
+ afterEach(() => {
149
+ vi.unstubAllGlobals();
150
+ vi.doUnmock("node:os");
151
+ vi.doUnmock("../config.ts");
152
+ vi.doUnmock("../cursor-usage.ts");
153
+ vi.restoreAllMocks();
154
+ getCursorUsageSummaryMock.mockReset();
155
+ mockConfig.cursor.avatarBatteryMode = "apiPercent";
156
+ mockConfig.cursor.onDemandMonthlyBudget = 1000;
157
+ });
158
+
159
+ it("uses remaining API percentage for Cursor avatar battery", async () => {
160
+ const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
161
+ const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
162
+ const uploadedNames: string[] = [];
163
+ getCursorUsageSummaryMock.mockResolvedValue({
164
+ planUsage: { apiPercentUsed: 40 },
165
+ });
166
+ mockAvatarFetch(uploadedNames, new Response("unused", { status: 500 }));
167
+
168
+ try {
169
+ const { setChatAvatar } = await loadFeishuApiWithHome(homeDir, userDataDir);
170
+ await setChatAvatar("tenant-token", "chat_1", "cursor", "busy");
171
+
172
+ expect(uploadedNames).toEqual(["avatar_cursor_busy_battery_60.jpg"]);
173
+ } finally {
174
+ await rm(homeDir, { recursive: true, force: true });
175
+ await rm(userDataDir, { recursive: true, force: true });
176
+ }
177
+ });
178
+
179
+ it("uses On-Demand budget for Cursor avatar battery when configured", async () => {
180
+ const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
181
+ const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
182
+ const uploadedNames: string[] = [];
183
+ mockConfig.cursor.avatarBatteryMode = "onDemandUse";
184
+ mockConfig.cursor.onDemandMonthlyBudget = 1000;
185
+ getCursorUsageSummaryMock.mockResolvedValue({
186
+ spendLimitUsage: { individualUsed: 25000 },
187
+ });
188
+ mockAvatarFetch(uploadedNames, new Response("unused", { status: 500 }));
189
+
190
+ try {
191
+ const { setChatAvatar } = await loadFeishuApiWithHome(homeDir, userDataDir);
192
+ await setChatAvatar("tenant-token", "chat_1", "cursor", "idle");
193
+
194
+ expect(uploadedNames).toEqual(["avatar_cursor_idle_battery_75.jpg"]);
195
+ } finally {
196
+ await rm(homeDir, { recursive: true, force: true });
197
+ await rm(userDataDir, { recursive: true, force: true });
198
+ }
199
+ });
200
+
201
+ it("falls back to the pre-combined Cursor avatar when usage lookup fails", async () => {
202
+ const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
203
+ const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
204
+ const uploadedNames: string[] = [];
205
+ vi.spyOn(console, "warn").mockImplementation(() => {});
206
+ getCursorUsageSummaryMock.mockRejectedValue(new Error("cursor unavailable"));
207
+ mockAvatarFetch(uploadedNames, new Response("unused", { status: 500 }));
208
+
209
+ try {
210
+ const { setChatAvatar } = await loadFeishuApiWithHome(homeDir, userDataDir);
211
+ await setChatAvatar("tenant-token", "chat_1", "cursor", "busy");
212
+
213
+ expect(uploadedNames).toEqual(["avatar_cursor_busy.jpg"]);
214
+ } finally {
215
+ await rm(homeDir, { recursive: true, force: true });
216
+ await rm(userDataDir, { recursive: true, force: true });
217
+ }
218
+ });
219
+ });