@getrouter/getrouter-cli 0.1.1 โ†’ 0.1.2

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.
@@ -0,0 +1,87 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { fetchAllPages } from "../../../src/core/api/pagination";
3
+
4
+ type PageResponse = {
5
+ items: number[];
6
+ nextPageToken: string | undefined;
7
+ };
8
+
9
+ describe("fetchAllPages", () => {
10
+ it("fetches single page when no next token", async () => {
11
+ const fetchPage = vi.fn().mockResolvedValue({
12
+ items: [1, 2, 3],
13
+ nextPageToken: undefined,
14
+ });
15
+
16
+ const result = await fetchAllPages<PageResponse, number>(
17
+ fetchPage,
18
+ (res) => res.items,
19
+ (res) => res.nextPageToken,
20
+ );
21
+
22
+ expect(result).toEqual([1, 2, 3]);
23
+ expect(fetchPage).toHaveBeenCalledTimes(1);
24
+ expect(fetchPage).toHaveBeenCalledWith(undefined);
25
+ });
26
+
27
+ it("fetches multiple pages", async () => {
28
+ const fetchPage = vi
29
+ .fn()
30
+ .mockResolvedValueOnce({
31
+ items: [1, 2],
32
+ nextPageToken: "page2",
33
+ })
34
+ .mockResolvedValueOnce({
35
+ items: [3, 4],
36
+ nextPageToken: "page3",
37
+ })
38
+ .mockResolvedValueOnce({
39
+ items: [5],
40
+ nextPageToken: undefined,
41
+ });
42
+
43
+ const result = await fetchAllPages<PageResponse, number>(
44
+ fetchPage,
45
+ (res) => res.items,
46
+ (res) => res.nextPageToken,
47
+ );
48
+
49
+ expect(result).toEqual([1, 2, 3, 4, 5]);
50
+ expect(fetchPage).toHaveBeenCalledTimes(3);
51
+ expect(fetchPage).toHaveBeenNthCalledWith(1, undefined);
52
+ expect(fetchPage).toHaveBeenNthCalledWith(2, "page2");
53
+ expect(fetchPage).toHaveBeenNthCalledWith(3, "page3");
54
+ });
55
+
56
+ it("returns empty array when first page is empty", async () => {
57
+ const fetchPage = vi.fn().mockResolvedValue({
58
+ items: [],
59
+ nextPageToken: undefined,
60
+ });
61
+
62
+ const result = await fetchAllPages<PageResponse, number>(
63
+ fetchPage,
64
+ (res) => res.items,
65
+ (res) => res.nextPageToken,
66
+ );
67
+
68
+ expect(result).toEqual([]);
69
+ expect(fetchPage).toHaveBeenCalledTimes(1);
70
+ });
71
+
72
+ it("stops when nextPageToken is empty string", async () => {
73
+ const fetchPage = vi.fn().mockResolvedValueOnce({
74
+ items: [1],
75
+ nextPageToken: "",
76
+ });
77
+
78
+ const result = await fetchAllPages<PageResponse, number>(
79
+ fetchPage,
80
+ (res) => res.items,
81
+ (res) => res.nextPageToken || undefined,
82
+ );
83
+
84
+ expect(result).toEqual([1]);
85
+ expect(fetchPage).toHaveBeenCalledTimes(1);
86
+ });
87
+ });
@@ -79,4 +79,161 @@ describe("requestJson", () => {
79
79
  const headers = (init?.headers ?? {}) as Record<string, string>;
80
80
  expect(headers.Cookie).toBe("router_auth=t2");
81
81
  });
82
+
83
+ it("retries with refreshed token on 401", async () => {
84
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "getrouter-"));
85
+ process.env.GETROUTER_CONFIG_DIR = dir;
86
+ fs.writeFileSync(
87
+ path.join(dir, "auth.json"),
88
+ JSON.stringify({
89
+ accessToken: "expired",
90
+ refreshToken: "refresh-token",
91
+ expiresAt: new Date(Date.now() + 3600000).toISOString(),
92
+ tokenType: "Bearer",
93
+ }),
94
+ );
95
+
96
+ let callCount = 0;
97
+ const fetchSpy = vi.fn(
98
+ async (input: RequestInfo | URL, _init?: RequestInit) => {
99
+ callCount++;
100
+ const url = typeof input === "string" ? input : input.toString();
101
+
102
+ // Refresh token endpoint
103
+ if (url.includes("auth/token")) {
104
+ return {
105
+ ok: true,
106
+ json: async () => ({
107
+ accessToken: "new-access",
108
+ refreshToken: "new-refresh",
109
+ expiresAt: new Date(Date.now() + 3600000).toISOString(),
110
+ }),
111
+ } as Response;
112
+ }
113
+
114
+ // First call returns 401, second succeeds
115
+ if (callCount === 1) {
116
+ return {
117
+ ok: false,
118
+ status: 401,
119
+ statusText: "Unauthorized",
120
+ json: async () => ({ message: "Token expired" }),
121
+ } as Response;
122
+ }
123
+
124
+ return {
125
+ ok: true,
126
+ json: async () => ({ ok: true }),
127
+ } as Response;
128
+ },
129
+ );
130
+
131
+ const res = await requestJson<{ ok: boolean }>({
132
+ path: "/v1/test",
133
+ method: "GET",
134
+ fetchImpl: fetchSpy as unknown as typeof fetch,
135
+ });
136
+
137
+ expect(res.ok).toBe(true);
138
+ expect(fetchSpy).toHaveBeenCalledTimes(3); // initial + refresh + retry
139
+ });
140
+
141
+ it("does not retry when no refresh token", async () => {
142
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "getrouter-"));
143
+ process.env.GETROUTER_CONFIG_DIR = dir;
144
+ fs.writeFileSync(
145
+ path.join(dir, "auth.json"),
146
+ JSON.stringify({ accessToken: "expired", refreshToken: "" }),
147
+ );
148
+
149
+ const fetchSpy = vi.fn(
150
+ async (_input: RequestInfo | URL, _init?: RequestInit) =>
151
+ ({
152
+ ok: false,
153
+ status: 401,
154
+ statusText: "Unauthorized",
155
+ json: async () => ({ message: "Token expired" }),
156
+ }) as Response,
157
+ );
158
+
159
+ await expect(
160
+ requestJson({
161
+ path: "/v1/test",
162
+ method: "GET",
163
+ fetchImpl: fetchSpy as unknown as typeof fetch,
164
+ }),
165
+ ).rejects.toThrow();
166
+
167
+ expect(fetchSpy).toHaveBeenCalledTimes(1); // no retry
168
+ });
169
+
170
+ it("retries on 5xx server errors", async () => {
171
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "getrouter-"));
172
+ process.env.GETROUTER_CONFIG_DIR = dir;
173
+ fs.writeFileSync(
174
+ path.join(dir, "auth.json"),
175
+ JSON.stringify({ accessToken: "token", refreshToken: "" }),
176
+ );
177
+
178
+ let callCount = 0;
179
+ const fetchSpy = vi.fn(
180
+ async (_input: RequestInfo | URL, _init?: RequestInit) => {
181
+ callCount++;
182
+ if (callCount === 1) {
183
+ return {
184
+ ok: false,
185
+ status: 503,
186
+ statusText: "Service Unavailable",
187
+ json: async () => ({ message: "Server overloaded" }),
188
+ } as Response;
189
+ }
190
+ return {
191
+ ok: true,
192
+ json: async () => ({ ok: true }),
193
+ } as Response;
194
+ },
195
+ );
196
+
197
+ const noopSleep = async () => {};
198
+ const res = await requestJson<{ ok: boolean }>({
199
+ path: "/v1/test",
200
+ method: "GET",
201
+ fetchImpl: fetchSpy as unknown as typeof fetch,
202
+ maxRetries: 2,
203
+ _retrySleep: noopSleep,
204
+ });
205
+
206
+ expect(res.ok).toBe(true);
207
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
208
+ });
209
+
210
+ it("does not retry on 4xx client errors", async () => {
211
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "getrouter-"));
212
+ process.env.GETROUTER_CONFIG_DIR = dir;
213
+ fs.writeFileSync(
214
+ path.join(dir, "auth.json"),
215
+ JSON.stringify({ accessToken: "token", refreshToken: "" }),
216
+ );
217
+
218
+ const fetchSpy = vi.fn(
219
+ async (_input: RequestInfo | URL, _init?: RequestInit) =>
220
+ ({
221
+ ok: false,
222
+ status: 404,
223
+ statusText: "Not Found",
224
+ json: async () => ({ message: "Not found" }),
225
+ }) as Response,
226
+ );
227
+
228
+ await expect(
229
+ requestJson({
230
+ path: "/v1/test",
231
+ method: "GET",
232
+ fetchImpl: fetchSpy as unknown as typeof fetch,
233
+ maxRetries: 2,
234
+ }),
235
+ ).rejects.toThrow();
236
+
237
+ expect(fetchSpy).toHaveBeenCalledTimes(1); // no retry on 404
238
+ });
82
239
  });
@@ -0,0 +1,152 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { isServerError, withRetry } from "../../src/core/http/retry";
3
+
4
+ describe("isServerError", () => {
5
+ it("returns true for 5xx errors", () => {
6
+ expect(isServerError(500)).toBe(true);
7
+ expect(isServerError(502)).toBe(true);
8
+ expect(isServerError(503)).toBe(true);
9
+ expect(isServerError(599)).toBe(true);
10
+ });
11
+
12
+ it("returns true for 408 and 429", () => {
13
+ expect(isServerError(408)).toBe(true);
14
+ expect(isServerError(429)).toBe(true);
15
+ });
16
+
17
+ it("returns false for 4xx client errors", () => {
18
+ expect(isServerError(400)).toBe(false);
19
+ expect(isServerError(401)).toBe(false);
20
+ expect(isServerError(403)).toBe(false);
21
+ expect(isServerError(404)).toBe(false);
22
+ });
23
+
24
+ it("returns false for 2xx success", () => {
25
+ expect(isServerError(200)).toBe(false);
26
+ expect(isServerError(201)).toBe(false);
27
+ });
28
+ });
29
+
30
+ describe("withRetry", () => {
31
+ it("returns result on first success", async () => {
32
+ const fn = vi.fn().mockResolvedValue("success");
33
+ const result = await withRetry(fn);
34
+ expect(result).toBe("success");
35
+ expect(fn).toHaveBeenCalledTimes(1);
36
+ });
37
+
38
+ it("retries on failure and succeeds", async () => {
39
+ const fn = vi
40
+ .fn()
41
+ .mockRejectedValueOnce(new TypeError("network error"))
42
+ .mockResolvedValueOnce("success");
43
+
44
+ const sleep = vi.fn().mockResolvedValue(undefined);
45
+ const result = await withRetry(fn, { sleep });
46
+
47
+ expect(result).toBe("success");
48
+ expect(fn).toHaveBeenCalledTimes(2);
49
+ expect(sleep).toHaveBeenCalledTimes(1);
50
+ });
51
+
52
+ it("respects maxRetries", async () => {
53
+ const fn = vi.fn().mockRejectedValue(new TypeError("network error"));
54
+ const sleep = vi.fn().mockResolvedValue(undefined);
55
+
56
+ await expect(withRetry(fn, { maxRetries: 2, sleep })).rejects.toThrow(
57
+ "network error",
58
+ );
59
+ expect(fn).toHaveBeenCalledTimes(3); // initial + 2 retries
60
+ });
61
+
62
+ it("calls onRetry callback", async () => {
63
+ const fn = vi
64
+ .fn()
65
+ .mockRejectedValueOnce(new TypeError("fail"))
66
+ .mockResolvedValueOnce("ok");
67
+
68
+ const onRetry = vi.fn();
69
+ const sleep = vi.fn().mockResolvedValue(undefined);
70
+
71
+ await withRetry(fn, { onRetry, sleep });
72
+
73
+ expect(onRetry).toHaveBeenCalledWith(expect.any(TypeError), 1, 1000);
74
+ });
75
+
76
+ it("uses exponential backoff", async () => {
77
+ const fn = vi
78
+ .fn()
79
+ .mockRejectedValueOnce(new TypeError("fail1"))
80
+ .mockRejectedValueOnce(new TypeError("fail2"))
81
+ .mockResolvedValueOnce("ok");
82
+
83
+ const onRetry = vi.fn();
84
+ const sleep = vi.fn().mockResolvedValue(undefined);
85
+
86
+ await withRetry(fn, {
87
+ onRetry,
88
+ sleep,
89
+ initialDelayMs: 100,
90
+ maxDelayMs: 500,
91
+ });
92
+
93
+ expect(onRetry).toHaveBeenCalledTimes(2);
94
+ expect(onRetry).toHaveBeenNthCalledWith(1, expect.any(TypeError), 1, 100);
95
+ expect(onRetry).toHaveBeenNthCalledWith(2, expect.any(TypeError), 2, 200);
96
+ });
97
+
98
+ it("respects maxDelayMs", async () => {
99
+ const fn = vi
100
+ .fn()
101
+ .mockRejectedValueOnce(new TypeError("1"))
102
+ .mockRejectedValueOnce(new TypeError("2"))
103
+ .mockRejectedValueOnce(new TypeError("3"))
104
+ .mockResolvedValueOnce("ok");
105
+
106
+ const onRetry = vi.fn();
107
+ const sleep = vi.fn().mockResolvedValue(undefined);
108
+
109
+ await withRetry(fn, {
110
+ onRetry,
111
+ sleep,
112
+ initialDelayMs: 100,
113
+ maxDelayMs: 150,
114
+ maxRetries: 5,
115
+ });
116
+
117
+ expect(onRetry).toHaveBeenNthCalledWith(3, expect.any(TypeError), 3, 150);
118
+ });
119
+
120
+ it("does not retry when shouldRetry returns false", async () => {
121
+ const error = new Error("non-retryable");
122
+ const fn = vi.fn().mockRejectedValue(error);
123
+ const shouldRetry = vi.fn().mockReturnValue(false);
124
+
125
+ await expect(withRetry(fn, { shouldRetry })).rejects.toThrow(
126
+ "non-retryable",
127
+ );
128
+ expect(fn).toHaveBeenCalledTimes(1);
129
+ });
130
+
131
+ it("retries on 500 status errors", async () => {
132
+ const error = { status: 500, message: "server error" };
133
+ const fn = vi.fn().mockRejectedValueOnce(error).mockResolvedValueOnce("ok");
134
+
135
+ const sleep = vi.fn().mockResolvedValue(undefined);
136
+ const result = await withRetry(fn, { sleep });
137
+
138
+ expect(result).toBe("ok");
139
+ expect(fn).toHaveBeenCalledTimes(2);
140
+ });
141
+
142
+ it("retries on 429 rate limit", async () => {
143
+ const error = { status: 429, message: "rate limited" };
144
+ const fn = vi.fn().mockRejectedValueOnce(error).mockResolvedValueOnce("ok");
145
+
146
+ const sleep = vi.fn().mockResolvedValue(undefined);
147
+ const result = await withRetry(fn, { sleep });
148
+
149
+ expect(result).toBe("ok");
150
+ expect(fn).toHaveBeenCalledTimes(2);
151
+ });
152
+ });
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
2
2
  import { renderUsageChart } from "../../src/core/output/usages";
3
3
 
4
4
  describe("renderUsageChart", () => {
5
- it("renders stacked bars", () => {
5
+ it("renders bars with total tokens", () => {
6
6
  const output = renderUsageChart([
7
7
  {
8
8
  day: "2026-01-03",
@@ -29,16 +29,14 @@ describe("renderUsageChart", () => {
29
29
  expect(output.startsWith("๐Ÿ“Š Usage (last 7 days)")).toBe(true);
30
30
  expect(output).toContain("Tokens");
31
31
  expect(output).toContain("โ–ˆ");
32
- expect(output).toContain("โ–’");
33
- expect(output).toContain("I:1K");
34
- expect(output).toContain("O:1K");
32
+ expect(output).toContain("2K");
35
33
  expect(output).toContain("๐Ÿ“Š Usage (last 7 days) ยท Tokens\n\n2026-01-03");
36
- expect(output).toContain("O:1K\n\nLegend: โ–ˆ input โ–’ output");
37
- expect(output).toContain("Legend: โ–ˆ input โ–’ output");
38
- expect(output).not.toContain("O:0179");
34
+ expect(output).not.toContain("Legend");
35
+ expect(output).not.toContain("I:");
36
+ expect(output).not.toContain("O:");
39
37
  });
40
38
 
41
- it("prints input and output totals separately", () => {
39
+ it("prints total tokens only", () => {
42
40
  const output = renderUsageChart([
43
41
  {
44
42
  day: "2026-01-03",
@@ -48,9 +46,9 @@ describe("renderUsageChart", () => {
48
46
  requests: 2,
49
47
  },
50
48
  ]);
51
- expect(output).toContain("I:1.2K");
52
- expect(output).toContain("O:3.4K");
53
- expect(output).not.toContain("4.6K");
49
+ expect(output).toContain("4.6K");
50
+ expect(output).not.toContain("I:");
51
+ expect(output).not.toContain("O:");
54
52
  });
55
53
 
56
54
  it("handles numeric strings without skewing bars", () => {
@@ -66,6 +64,7 @@ describe("renderUsageChart", () => {
66
64
  ],
67
65
  10,
68
66
  );
69
- expect(output).toContain("โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–’โ–’โ–’โ–’โ–’");
67
+ expect(output).toContain("โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ");
68
+ expect(output).toContain("2K");
70
69
  });
71
70
  });
package/tsconfig.json CHANGED
@@ -7,7 +7,8 @@
7
7
  "strict": true,
8
8
  "esModuleInterop": true,
9
9
  "moduleResolution": "node",
10
- "skipLibCheck": true
10
+ "skipLibCheck": true,
11
+ "resolveJsonModule": true
11
12
  },
12
13
  "include": ["src", "tests"]
13
14
  }