@solhun/feedback-kit-core 0.1.0

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,382 @@
1
+ // DP-253 진단 링버퍼 검증.
2
+ //
3
+ // 여기서 지키는 것:
4
+ // - TC3: 링버퍼에 인증정보와 본문이 기록되지 않는다.
5
+ // - TC4: 상한(network 30 / logs 50)을 넘으면 오래된 것부터 밀려난다.
6
+ // - TC5 의 절반: 스냅샷의 배열은 **실제로 모은 결과**다(상수 빈 배열이 아니다).
7
+ // 나머지 절반("수집이 배선 안 된 경우는 null")은 context.test.ts 에 있다.
8
+ //
9
+ // 글로벌(fetch/XMLHttpRequest/console)을 패치하는 모듈이라, 각 테스트는 자기 스텁을
10
+ // 깔고 afterEach 에서 원복한다. 원복이 새면 다른 파일의 테스트가 오염된다.
11
+
12
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
13
+
14
+ import {
15
+ DiagnosticsCollector,
16
+ LOG_BUFFER_LIMIT,
17
+ NETWORK_BUFFER_LIMIT,
18
+ REDACTED,
19
+ sanitizeUrl,
20
+ } from "./diagnostics.js";
21
+
22
+ // ────────────────────────────────────────────────────────────────────────────
23
+ // 테스트 도구
24
+ // ────────────────────────────────────────────────────────────────────────────
25
+
26
+ const START_TIME = 1_700_000_000_000;
27
+ let clockNow = START_TIME;
28
+ const clock = (): number => clockNow;
29
+
30
+ type G = Record<string, unknown>;
31
+ const g = globalThis as unknown as G;
32
+
33
+ type Listener = () => void;
34
+
35
+ /**
36
+ * 최소 XMLHttpRequest 스텁.
37
+ * 응답 완료는 테스트가 `finish(status)` 로 직접 일으킨다(실 네트워크 없음).
38
+ */
39
+ class FakeXhr {
40
+ static instances: FakeXhr[] = [];
41
+
42
+ status = 0;
43
+ opened: { method: string; url: string } | null = null;
44
+ sentBody: unknown = undefined;
45
+
46
+ private listeners = new Map<string, Listener[]>();
47
+
48
+ open(method: string, url: string): void {
49
+ this.opened = { method, url };
50
+ }
51
+
52
+ send(body?: unknown): void {
53
+ this.sentBody = body;
54
+ FakeXhr.instances.push(this);
55
+ }
56
+
57
+ addEventListener(type: string, cb: Listener): void {
58
+ const arr = this.listeners.get(type) ?? [];
59
+ arr.push(cb);
60
+ this.listeners.set(type, arr);
61
+ }
62
+
63
+ finish(status: number): void {
64
+ this.status = status;
65
+ for (const cb of this.listeners.get("loadend") ?? []) cb();
66
+ }
67
+ }
68
+
69
+ let originals: {
70
+ fetch: unknown;
71
+ xhr: unknown;
72
+ warn: unknown;
73
+ error: unknown;
74
+ };
75
+
76
+ let collector: DiagnosticsCollector;
77
+
78
+ beforeEach(() => {
79
+ clockNow = START_TIME;
80
+ FakeXhr.instances = [];
81
+ originals = {
82
+ fetch: g.fetch,
83
+ xhr: g.XMLHttpRequest,
84
+ warn: (g.console as { warn: unknown }).warn,
85
+ error: (g.console as { error: unknown }).error,
86
+ };
87
+ collector = new DiagnosticsCollector({
88
+ now: clock,
89
+ iso: (n: number): string => new Date(n).toISOString(),
90
+ });
91
+ });
92
+
93
+ afterEach(() => {
94
+ collector.uninstall();
95
+ g.fetch = originals.fetch;
96
+ g.XMLHttpRequest = originals.xhr;
97
+ (g.console as G).warn = originals.warn;
98
+ (g.console as G).error = originals.error;
99
+ });
100
+
101
+ /** 응답 status 를 돌려주는 fetch 스텁. 호출될 때마다 시계를 `elapsed` 만큼 민다. */
102
+ function stubFetch(
103
+ status: number,
104
+ elapsed = 12
105
+ ): { calls: Array<{ input: unknown; init: unknown }> } {
106
+ const calls: Array<{ input: unknown; init: unknown }> = [];
107
+ g.fetch = (input: unknown, init: unknown): Promise<unknown> => {
108
+ calls.push({ input, init });
109
+ clockNow += elapsed;
110
+ return Promise.resolve({ status });
111
+ };
112
+ return { calls };
113
+ }
114
+
115
+ // ────────────────────────────────────────────────────────────────────────────
116
+ // TC4 — 상한 초과 시 오래된 것부터 밀려난다
117
+ // ────────────────────────────────────────────────────────────────────────────
118
+
119
+ describe("링버퍼 상한 (TC4)", () => {
120
+ it("요청 35건·로그 55건을 넣으면 network 30 / logs 50 만 남고 최근 것만 남는다", async () => {
121
+ stubFetch(200, 1);
122
+ (g.console as G).warn = (): void => {}; // 테스트 출력이 55줄 밀리지 않게
123
+ collector.install();
124
+
125
+ for (let i = 0; i < 35; i += 1) {
126
+ await (g.fetch as (u: string) => Promise<unknown>)(
127
+ `https://api.example/items/${i}`
128
+ );
129
+ }
130
+ for (let i = 0; i < 55; i += 1) {
131
+ (g.console as { warn: (m: string) => void }).warn(`경고 ${i}`);
132
+ }
133
+
134
+ const snap = collector.snapshot();
135
+
136
+ expect(snap.network).toHaveLength(NETWORK_BUFFER_LIMIT);
137
+ expect(snap.logs).toHaveLength(LOG_BUFFER_LIMIT);
138
+ expect(NETWORK_BUFFER_LIMIT).toBe(30);
139
+ expect(LOG_BUFFER_LIMIT).toBe(50);
140
+
141
+ // 밀려난 건 오래된 쪽(0~4 / 0~4)이고, 남은 건 최근 것이다.
142
+ expect(snap.network[0]?.url).toBe("https://api.example/items/5");
143
+ expect(snap.network.at(-1)?.url).toBe("https://api.example/items/34");
144
+ expect(snap.logs[0]?.message).toBe("경고 5");
145
+ expect(snap.logs.at(-1)?.message).toBe("경고 54");
146
+ });
147
+ });
148
+
149
+ // ────────────────────────────────────────────────────────────────────────────
150
+ // TC3 — 인증정보·본문 미기록
151
+ // ────────────────────────────────────────────────────────────────────────────
152
+
153
+ describe("자격증명·본문 미기록 (TC3)", () => {
154
+ const AUTH = "Bearer super-secret-access-token";
155
+ const COOKIE = "session=abcdef-cookie-value";
156
+ const REQ_BODY = JSON.stringify({ password: "hunter2", card: "4111111111111111" });
157
+ const RES_BODY = { secretAnswer: "top-secret-response-payload" };
158
+
159
+ it("fetch 로 오간 인증 헤더·쿠키·요청 본문·응답 본문이 버퍼에 남지 않는다", async () => {
160
+ g.fetch = (): Promise<unknown> => {
161
+ clockNow += 7;
162
+ // 응답 객체에 본문이 들어 있어도 수집기는 status 만 본다.
163
+ return Promise.resolve({
164
+ status: 201,
165
+ body: RES_BODY,
166
+ json: () => Promise.resolve(RES_BODY),
167
+ });
168
+ };
169
+ collector.install();
170
+
171
+ await (g.fetch as (u: string, i: unknown) => Promise<unknown>)(
172
+ "https://api.example/login?token=super-secret-access-token&api_key=abc123",
173
+ {
174
+ method: "post",
175
+ headers: { Authorization: AUTH, Cookie: COOKIE },
176
+ body: REQ_BODY,
177
+ }
178
+ );
179
+
180
+ const snap = collector.snapshot();
181
+ const dumped = JSON.stringify(snap);
182
+
183
+ for (const secret of [
184
+ "super-secret-access-token",
185
+ "abcdef-cookie-value",
186
+ "hunter2",
187
+ "4111111111111111",
188
+ "top-secret-response-payload",
189
+ "abc123",
190
+ ]) {
191
+ expect(dumped).not.toContain(secret);
192
+ }
193
+
194
+ // 기록되는 건 메서드·URL·상태코드·소요시간까지다.
195
+ expect(snap.network).toHaveLength(1);
196
+ expect(snap.network[0]).toMatchObject({
197
+ method: "POST",
198
+ status: 201,
199
+ durationMs: 7,
200
+ });
201
+ expect(snap.network[0]?.url).toBe(
202
+ `https://api.example/login?token=${REDACTED}&api_key=${REDACTED}`
203
+ );
204
+ });
205
+
206
+ it("XHR 로 보낸 요청 본문과 URL 자격증명도 남지 않는다", () => {
207
+ g.XMLHttpRequest = FakeXhr as unknown as G;
208
+ collector.install();
209
+
210
+ const xhr = new (g.XMLHttpRequest as unknown as typeof FakeXhr)();
211
+ xhr.open("PUT", "https://api.example/profile?access_token=super-secret-access-token");
212
+ xhr.send(REQ_BODY);
213
+ clockNow += 30;
214
+ xhr.finish(204);
215
+
216
+ const snap = collector.snapshot();
217
+ const dumped = JSON.stringify(snap);
218
+
219
+ expect(dumped).not.toContain("super-secret-access-token");
220
+ expect(dumped).not.toContain("hunter2");
221
+ expect(snap.network[0]).toMatchObject({
222
+ method: "PUT",
223
+ url: `https://api.example/profile?access_token=${REDACTED}`,
224
+ status: 204,
225
+ durationMs: 30,
226
+ });
227
+ // 원본 동작은 그대로 — 본문은 손대지 않고 그대로 전달된다.
228
+ expect(xhr.sentBody).toBe(REQ_BODY);
229
+ });
230
+
231
+ it("sanitizeUrl 은 fragment 와 user:pass@ 를 지우고 자격증명 파라미터 값만 가린다", () => {
232
+ expect(sanitizeUrl("https://u:p@api.example/a?x=1")).toBe("https://api.example/a?x=1");
233
+ expect(sanitizeUrl("https://api.example/a#access_token=zzz")).toBe(
234
+ "https://api.example/a"
235
+ );
236
+ expect(sanitizeUrl("https://api.example/a?page=2&refresh_token=zzz")).toBe(
237
+ `https://api.example/a?page=2&refresh_token=${REDACTED}`
238
+ );
239
+ // 진단에 필요한 정보(어떤 파라미터가 있었는지)는 남는다.
240
+ expect(sanitizeUrl("https://api.example/a?q=hello")).toBe(
241
+ "https://api.example/a?q=hello"
242
+ );
243
+ });
244
+ });
245
+
246
+ // ────────────────────────────────────────────────────────────────────────────
247
+ // TC5(절반) — 스냅샷은 실제로 모은 결과다
248
+ // ────────────────────────────────────────────────────────────────────────────
249
+
250
+ describe("스냅샷은 수집 결과다 (TC5)", () => {
251
+ it("아무 일도 없었으면 비어 있고, 요청·로그가 생기면 그 수만큼 늘어난다", async () => {
252
+ stubFetch(200, 3);
253
+ (g.console as G).error = (): void => {};
254
+ collector.install();
255
+
256
+ expect(collector.snapshot()).toEqual({ network: [], logs: [] });
257
+
258
+ await (g.fetch as (u: string) => Promise<unknown>)("https://api.example/a");
259
+ (g.console as { error: (m: string) => void }).error("실패했다");
260
+
261
+ const snap = collector.snapshot();
262
+ expect(snap.network).toHaveLength(1);
263
+ expect(snap.logs).toHaveLength(1);
264
+ expect(snap.logs[0]?.level).toBe("error");
265
+ });
266
+
267
+ it("스냅샷은 사본이라 이후 수집이 이미 만든 페이로드를 바꾸지 않는다", async () => {
268
+ stubFetch(200, 1);
269
+ collector.install();
270
+
271
+ await (g.fetch as (u: string) => Promise<unknown>)("https://api.example/first");
272
+ const snap = collector.snapshot();
273
+ await (g.fetch as (u: string) => Promise<unknown>)("https://api.example/second");
274
+
275
+ expect(snap.network).toHaveLength(1);
276
+ expect(collector.snapshot().network).toHaveLength(2);
277
+ });
278
+ });
279
+
280
+ // ────────────────────────────────────────────────────────────────────────────
281
+ // 수집기가 호스트 동작을 바꾸지 않는다는 보장
282
+ // ────────────────────────────────────────────────────────────────────────────
283
+
284
+ describe("수집기의 비침습성", () => {
285
+ it("실패한 요청도 1건으로 남기고 예외를 그대로 전파한다", async () => {
286
+ const failure = new TypeError("Failed to fetch");
287
+ g.fetch = (): Promise<unknown> => {
288
+ clockNow += 9;
289
+ return Promise.reject(failure);
290
+ };
291
+ collector.install();
292
+
293
+ await expect(
294
+ (g.fetch as (u: string) => Promise<unknown>)("https://api.example/down")
295
+ ).rejects.toBe(failure);
296
+
297
+ // 응답을 못 받았으므로 status 는 null 이다(0 이 아니라).
298
+ expect(collector.snapshot().network[0]).toMatchObject({
299
+ url: "https://api.example/down",
300
+ status: null,
301
+ });
302
+ });
303
+
304
+ it("console.warn/error 는 원본 출력을 그대로 유지한다", () => {
305
+ const seen: string[] = [];
306
+ (g.console as G).warn = (...a: unknown[]): void => {
307
+ seen.push(String(a[0]));
308
+ };
309
+ collector.install();
310
+
311
+ (g.console as { warn: (m: string) => void }).warn("사용자에게 보이던 경고");
312
+
313
+ expect(seen).toEqual(["사용자에게 보이던 경고"]);
314
+ expect(collector.snapshot().logs[0]?.message).toBe("사용자에게 보이던 경고");
315
+ });
316
+
317
+ it("RN 처럼 fetch 가 내부에서 XHR 을 쓰면 1건으로만 센다", async () => {
318
+ g.XMLHttpRequest = FakeXhr as unknown as G;
319
+ // whatwg-fetch 폴리필 흉내: Promise 실행자 안에서 동기적으로 send() 한다.
320
+ g.fetch = (url: string, init?: { method?: string }): Promise<unknown> =>
321
+ new Promise((resolve) => {
322
+ const x = new FakeXhr();
323
+ x.open(init?.method ?? "GET", url);
324
+ x.addEventListener("loadend", () => resolve({ status: x.status }));
325
+ x.send(null);
326
+ });
327
+ collector.install();
328
+
329
+ const pending = (g.fetch as (u: string) => Promise<unknown>)(
330
+ "https://api.example/rn"
331
+ );
332
+ clockNow += 4;
333
+ FakeXhr.instances[0]?.finish(200);
334
+ await pending;
335
+
336
+ expect(collector.snapshot().network).toHaveLength(1);
337
+ });
338
+
339
+ it("excludeMatcher 가 지목한 요청(위젯 자신의 전송)은 기록하지 않는다", async () => {
340
+ stubFetch(200, 1);
341
+ collector.install({
342
+ excludeMatcher: ({ url }) => url.startsWith("https://ingest.example/"),
343
+ });
344
+
345
+ await (g.fetch as (u: string) => Promise<unknown>)(
346
+ "https://ingest.example/api/feedback"
347
+ );
348
+ await (g.fetch as (u: string) => Promise<unknown>)("https://api.example/normal");
349
+
350
+ const snap = collector.snapshot();
351
+ expect(snap.network).toHaveLength(1);
352
+ expect(snap.network[0]?.url).toBe("https://api.example/normal");
353
+ });
354
+
355
+ it("originalConsole 로 찍으면 자기 로그가 버퍼에 되먹임되지 않는다", () => {
356
+ const seen: string[] = [];
357
+ (g.console as G).warn = (...a: unknown[]): void => {
358
+ seen.push(String(a[0]));
359
+ };
360
+ collector.install();
361
+
362
+ collector.originalConsole.warn("feedback-kit: 큐가 가득 찼다");
363
+
364
+ expect(seen).toEqual(["feedback-kit: 큐가 가득 찼다"]);
365
+ expect(collector.snapshot().logs).toHaveLength(0);
366
+ });
367
+
368
+ it("uninstall 하면 글로벌이 원복되고 이후 요청은 기록되지 않는다", async () => {
369
+ const { calls } = stubFetch(200, 1);
370
+ const patchedRef = g.fetch;
371
+ collector.install();
372
+ expect(g.fetch).not.toBe(patchedRef);
373
+
374
+ await (g.fetch as (u: string) => Promise<unknown>)("https://api.example/before");
375
+ collector.uninstall();
376
+ await (g.fetch as (u: string) => Promise<unknown>)("https://api.example/after");
377
+
378
+ expect(g.fetch).toBe(patchedRef);
379
+ expect(calls).toHaveLength(2); // 원본은 두 번 다 호출됐다
380
+ expect(collector.snapshot().network).toHaveLength(1); // 기록은 원복 전 1건뿐
381
+ });
382
+ });