@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,730 @@
1
+ // DP-255 어댑터 TC 검증.
2
+ //
3
+ // import 를 어댑터 파일이 아니라 **코어 공개 진입점(`../index.js`)** 에서 하는 이유:
4
+ // 여기서 확인하려는 건 "파일 안의 함수가 맞게 도는가"보다 "호스트가 실제로 손에 쥐는
5
+ // 표면이 맞는가"다. 배럴 배선이 빠지면 파일 단위 테스트는 통과하면서 소비자만 깨진다.
6
+
7
+ import { readdirSync, readFileSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
11
+
12
+ import {
13
+ buildLassoEnvelope,
14
+ buildLinearIssue,
15
+ buildNotionPage,
16
+ buildTitle,
17
+ createLassoAdapter,
18
+ FeedbackQueue,
19
+ LASSO_DEFAULT_ENDPOINT,
20
+ renderReportBody,
21
+ TITLE_MAX_CHARS,
22
+ } from "../index.js";
23
+ import type {
24
+ FeedbackAdapter,
25
+ FeedbackReport,
26
+ FeedbackStorage,
27
+ LassoFetch,
28
+ LassoRequestInit,
29
+ LassoResponseLike,
30
+ QueueStatus,
31
+ SubmitOutcome,
32
+ SubmitResult,
33
+ } from "../index.js";
34
+
35
+ // ────────────────────────────────────────────────────────────────────────────
36
+ // 공용 도구
37
+ // ────────────────────────────────────────────────────────────────────────────
38
+
39
+ const START_TIME = 1_700_000_000_000;
40
+ let clockNow = START_TIME;
41
+ const clock = (): number => clockNow;
42
+ const advance = (ms: number): void => {
43
+ clockNow += ms;
44
+ };
45
+
46
+ const ENDPOINT = "https://ingest.example/api/feedback";
47
+
48
+ let seq = 0;
49
+
50
+ function makeReport(over: Partial<FeedbackReport> = {}): FeedbackReport {
51
+ seq += 1;
52
+ return {
53
+ clientSubmissionId: `sub-${seq}`,
54
+ kind: "report",
55
+ comment: `테스트 제보 ${seq}`,
56
+ priority: "unset",
57
+ screenshot: null,
58
+ pin: null,
59
+ element: null,
60
+ context: {
61
+ app: "test-app",
62
+ screen: "/home",
63
+ url: null,
64
+ sessionId: "session-1",
65
+ user: { id: "guest_1", email: null, isGuest: true },
66
+ source: { screenId: null, sourceFile: null },
67
+ platform: "unknown",
68
+ timezone: "Asia/Seoul",
69
+ clientTimestamp: new Date(clockNow).toISOString(),
70
+ native: null,
71
+ web: null,
72
+ diagnostics: null,
73
+ extra: {},
74
+ },
75
+ createdAt: new Date(clockNow).toISOString(),
76
+ ...over,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * 담을 수 있는 값을 전부 채운 제보.
82
+ *
83
+ * TC3·TC4·TC5 가 전부 "대상이 못 받는 값이 어디로 가는가"를 묻기 때문에,
84
+ * 빈 컨텍스트로는 아무것도 증명되지 않는다(접을 게 없으면 접힘 규칙도 안 깨진다).
85
+ */
86
+ function makeRichReport(over: Partial<FeedbackReport> = {}): FeedbackReport {
87
+ const base = makeReport({
88
+ kind: "annotation",
89
+ comment: "저장 버튼을 눌러도 아무 일이 없다",
90
+ priority: "high",
91
+ screenshot: { base64: "AAAA".repeat(64), contentType: "image/png" },
92
+ pin: { x: 0.5, y: 0.25 },
93
+ element: {
94
+ tag: "button",
95
+ id: "save",
96
+ className: "btn primary",
97
+ text: "저장",
98
+ boundingBox: { x: 10, y: 20, width: 80, height: 32 },
99
+ attributes: { "data-testid": "save-button" },
100
+ },
101
+ });
102
+ return {
103
+ ...base,
104
+ context: {
105
+ ...base.context,
106
+ url: "https://app.example/orders/1?token=abc",
107
+ platform: "native",
108
+ source: { screenId: "OrderDetail", sourceFile: "src/screens/Order.tsx", sourceLine: 42 },
109
+ native: {
110
+ screenPath: "src/screens/Order.tsx",
111
+ navPath: ["Root", "Orders", "OrderDetail"],
112
+ routeParams: { orderId: 1 },
113
+ appInfo: { version: "1.2.3", channel: "preview", updateId: "u-1", runtimeVersion: "1.0.0" },
114
+ device: { model: "iPhone 15", osName: "iOS", osVersion: "17.4", deviceType: "phone" },
115
+ display: { width: 393, height: 852, pixelRatio: 3, fontScale: 1 },
116
+ },
117
+ web: {
118
+ viewport: { width: 1280, height: 800, devicePixelRatio: 2 },
119
+ userAgent: "Mozilla/5.0 (test)",
120
+ },
121
+ diagnostics: {
122
+ network: [
123
+ {
124
+ method: "POST",
125
+ url: "https://api.example/orders?token=%5BREDACTED%5D",
126
+ status: 500,
127
+ durationMs: 812,
128
+ at: "2026-02-01T09:00:00.000Z",
129
+ },
130
+ {
131
+ method: "GET",
132
+ url: "https://api.example/me",
133
+ status: null,
134
+ durationMs: 30_000,
135
+ at: "2026-02-01T09:00:01.000Z",
136
+ },
137
+ ],
138
+ logs: [
139
+ { level: "error", message: "저장 실패: 500", at: "2026-02-01T09:00:02.000Z" },
140
+ ],
141
+ },
142
+ extra: { build: "ci-42" },
143
+ },
144
+ ...over,
145
+ };
146
+ }
147
+
148
+ function memoryStorage(): FeedbackStorage {
149
+ const map = new Map<string, string>();
150
+ return {
151
+ async get(key) {
152
+ return map.get(key) ?? null;
153
+ },
154
+ async set(key, value) {
155
+ map.set(key, value);
156
+ },
157
+ async remove(key) {
158
+ map.delete(key);
159
+ },
160
+ };
161
+ }
162
+
163
+ interface FetchStep {
164
+ status?: number;
165
+ /** 응답 본문 원문. JSON 이 아닐 수도 있다(프록시가 끼어든 상황 재현). */
166
+ body?: string;
167
+ headers?: Record<string, string>;
168
+ /** 값이 있으면 fetch 자체가 그 값을 던진다(네트워크 오류). */
169
+ throws?: unknown;
170
+ /** true 면 본문 읽기에서 실패한다(끊긴 스트림). */
171
+ textThrows?: boolean;
172
+ }
173
+
174
+ function fakeFetch(steps: FetchStep[] = []) {
175
+ const calls: Array<{ url: string; init: LassoRequestInit }> = [];
176
+ const plan = [...steps];
177
+ const impl: LassoFetch = async (url, init) => {
178
+ calls.push({ url, init });
179
+ const step = plan.shift() ?? {};
180
+ if (step.throws !== undefined) throw step.throws;
181
+ const status = step.status ?? 200;
182
+ const body = step.body ?? JSON.stringify({ ok: true, id: `srv-${calls.length}` });
183
+ const headers = new Map(Object.entries(step.headers ?? {}));
184
+ const res: LassoResponseLike = {
185
+ status,
186
+ headers: { get: (name) => headers.get(name) ?? null },
187
+ text: async () => {
188
+ if (step.textThrows === true) throw new Error("stream closed");
189
+ return body;
190
+ },
191
+ };
192
+ return res;
193
+ };
194
+ return { calls, impl };
195
+ }
196
+
197
+ /** 경고를 모으되 원문을 그대로 보관한다(누설 검사가 이 문자열을 훑는다). */
198
+ function warnSink() {
199
+ const lines: string[] = [];
200
+ const warn = (message: string, ...rest: unknown[]): void => {
201
+ lines.push([message, ...rest].map((v) => String(v)).join(" "));
202
+ };
203
+ return { lines, warn };
204
+ }
205
+
206
+ beforeEach(() => {
207
+ clockNow = START_TIME;
208
+ seq = 0;
209
+ });
210
+
211
+ afterEach(() => {
212
+ vi.unstubAllGlobals();
213
+ });
214
+
215
+ // ────────────────────────────────────────────────────────────────────────────
216
+ // TC1 — 어댑터를 바꿔도 코어 동작이 같다
217
+ // ────────────────────────────────────────────────────────────────────────────
218
+
219
+ describe("TC1 어댑터를 바꿔도 코어 동작이 같다", () => {
220
+ /** 시나리오에서 어댑터가 낼 결과 순서. */
221
+ type Step = "ok" | "retryable" | "dead";
222
+
223
+ const OK_ID = "srv-ok-1";
224
+
225
+ interface Probe {
226
+ adapter: FeedbackAdapter;
227
+ /** 각 시도에 실린 멱등 키. 재시도가 같은 키를 쓰는지 본다. */
228
+ keys: () => string[];
229
+ }
230
+
231
+ /** 실제 라쏘런 어댑터 + 가짜 전송. 계획을 HTTP 응답으로 번역한다. */
232
+ function lassoProbe(plan: Step[]): Probe {
233
+ const steps: FetchStep[] = plan.map((step) => {
234
+ if (step === "ok") return { status: 200, body: JSON.stringify({ ok: true, id: OK_ID }) };
235
+ if (step === "retryable") return { status: 503 };
236
+ return { status: 400 };
237
+ });
238
+ const { calls, impl } = fakeFetch(steps);
239
+ const adapter = createLassoAdapter({
240
+ endpoint: ENDPOINT,
241
+ fetch: impl,
242
+ token: "fk_test_token",
243
+ warn: () => {},
244
+ });
245
+ return { adapter, keys: () => calls.map((c) => c.init.headers["Idempotency-Key"]) };
246
+ }
247
+
248
+ /** 코어가 요구하는 최소 계약만 만족하는 다른 어댑터(전송 없음). */
249
+ function inlineProbe(plan: Step[]): Probe {
250
+ const seen: string[] = [];
251
+ const rest = [...plan];
252
+ const adapter: FeedbackAdapter = {
253
+ async submit(report) {
254
+ seen.push(report.clientSubmissionId);
255
+ const step = rest.shift() ?? "ok";
256
+ if (step === "ok") return { ok: true, id: OK_ID, retryable: false, retryAfterMs: null };
257
+ return { ok: false, id: null, retryable: step === "retryable", retryAfterMs: null };
258
+ },
259
+ };
260
+ return { adapter, keys: () => seen };
261
+ }
262
+
263
+ interface Observed {
264
+ outcome: SubmitOutcome;
265
+ sizeAfterSubmit: number;
266
+ sizeAfterRetry: number;
267
+ status: QueueStatus;
268
+ keys: string[];
269
+ }
270
+
271
+ async function observe(probeFor: (plan: Step[]) => Probe, plan: Step[]): Promise<Observed> {
272
+ clockNow = START_TIME;
273
+ const { adapter, keys } = probeFor(plan);
274
+ const queue = new FeedbackQueue({
275
+ storage: memoryStorage(),
276
+ adapter,
277
+ now: clock,
278
+ flushIntervalMs: 0,
279
+ });
280
+ const outcome = await queue.submit(makeReport({ clientSubmissionId: "sub-fixed" }));
281
+ const sizeAfterSubmit = await queue.size();
282
+ advance(10 * 60 * 1000); // 백오프를 확실히 넘긴다
283
+ await queue.flush();
284
+ const sizeAfterRetry = await queue.size();
285
+ return { outcome, sizeAfterSubmit, sizeAfterRetry, status: queue.getStatus(), keys: keys() };
286
+ }
287
+
288
+ const PLANS: Array<[string, Step[]]> = [
289
+ ["첫 시도에 성공", ["ok"]],
290
+ ["한 번 실패 후 재시도 성공", ["retryable", "ok"]],
291
+ ["재시도 불가 실패", ["dead"]],
292
+ ];
293
+
294
+ it.each(PLANS)("%s — 라쏘런 어댑터와 최소 어댑터의 관측값이 같다", async (_name, plan) => {
295
+ const viaLasso = await observe(lassoProbe, plan);
296
+ const viaInline = await observe(inlineProbe, plan);
297
+ expect(viaLasso).toEqual(viaInline);
298
+ });
299
+
300
+ it("성공하면 큐에서 사라지고 서버 id 가 그대로 올라온다", async () => {
301
+ for (const probe of [lassoProbe, inlineProbe]) {
302
+ const seenResult = await observe(probe, ["ok"]);
303
+ expect(seenResult.outcome).toEqual({ delivered: true, id: OK_ID, queued: false });
304
+ expect(seenResult.sizeAfterSubmit).toBe(0);
305
+ }
306
+ });
307
+
308
+ it("재시도는 같은 멱등 키를 다시 쓴다(중복 적재 방지)", async () => {
309
+ for (const probe of [lassoProbe, inlineProbe]) {
310
+ const seenResult = await observe(probe, ["retryable", "ok"]);
311
+ expect(seenResult.outcome.queued).toBe(true);
312
+ expect(seenResult.sizeAfterSubmit).toBe(1);
313
+ expect(seenResult.sizeAfterRetry).toBe(0);
314
+ expect(seenResult.keys).toEqual(["sub-fixed", "sub-fixed"]);
315
+ }
316
+ });
317
+
318
+ it("재시도 불가 실패는 두 어댑터 모두 큐에 쌓지 않는다", async () => {
319
+ for (const probe of [lassoProbe, inlineProbe]) {
320
+ const seenResult = await observe(probe, ["dead"]);
321
+ expect(seenResult.outcome).toEqual({ delivered: false, id: null, queued: false });
322
+ expect(seenResult.sizeAfterSubmit).toBe(0);
323
+ expect(seenResult.status.lastError).toBe("non-retryable");
324
+ }
325
+ });
326
+ });
327
+
328
+ // ────────────────────────────────────────────────────────────────────────────
329
+ // TC2 — 어댑터가 실패를 결과로 돌려준다
330
+ // ────────────────────────────────────────────────────────────────────────────
331
+
332
+ describe("TC2 어댑터가 실패를 결과로 돌려준다", () => {
333
+ function adapterWith(steps: FetchStep[], over: { token?: string | null } = {}) {
334
+ const { calls, impl } = fakeFetch(steps);
335
+ const sink = warnSink();
336
+ const adapter = createLassoAdapter({
337
+ endpoint: ENDPOINT,
338
+ fetch: impl,
339
+ token: over.token ?? null,
340
+ warn: sink.warn,
341
+ });
342
+ return { adapter, calls, warned: sink.lines };
343
+ }
344
+
345
+ const FAILURES: Array<[string, FetchStep, Partial<SubmitResult>]> = [
346
+ ["네트워크 예외", { throws: new TypeError("Failed to fetch") }, { retryable: true, retryAfterMs: null }],
347
+ [
348
+ "429 는 대기 시간을 함께 돌려준다",
349
+ { status: 429, headers: { "Retry-After": "30" } },
350
+ { retryable: true, retryAfterMs: 30_000, rateLimited: true },
351
+ ],
352
+ ["408", { status: 408 }, { retryable: true }],
353
+ ["425", { status: 425 }, { retryable: true }],
354
+ ["500", { status: 500 }, { retryable: true }],
355
+ ["502", { status: 502 }, { retryable: true }],
356
+ ["503", { status: 503 }, { retryable: true }],
357
+ ["504", { status: 504 }, { retryable: true }],
358
+ ["400", { status: 400 }, { retryable: false }],
359
+ ["401", { status: 401 }, { retryable: false }],
360
+ ["403", { status: 403 }, { retryable: false }],
361
+ ["413", { status: 413 }, { retryable: false }],
362
+ ["2xx 이지만 ok:false", { status: 200, body: '{"ok":false,"error":"invalid"}' }, { retryable: false }],
363
+ ["2xx 인데 본문이 JSON 이 아님", { status: 200, body: "<!doctype html><title>502</title>" }, { retryable: true }],
364
+ ["2xx 인데 본문을 못 읽음", { status: 200, textThrows: true }, { retryable: true }],
365
+ ];
366
+
367
+ it.each(FAILURES)("%s — 던지지 않고 실패를 결과로 돌려준다", async (_name, step, expected) => {
368
+ const { adapter } = adapterWith([step]);
369
+ const result = await adapter.submit(makeReport());
370
+ expect(result).toMatchObject({ ok: false, id: null, ...expected });
371
+ });
372
+
373
+ it("2xx + ok:true 면 성공과 서버 id 를 돌려준다", async () => {
374
+ const { adapter } = adapterWith([{ status: 200, body: '{"ok":true,"id":"srv-9"}' }]);
375
+ await expect(adapter.submit(makeReport())).resolves.toEqual({
376
+ ok: true,
377
+ id: "srv-9",
378
+ retryable: false,
379
+ retryAfterMs: null,
380
+ });
381
+ });
382
+
383
+ it("id 가 data 안에 있어도 찾아낸다", async () => {
384
+ const { adapter } = adapterWith([{ status: 200, body: '{"ok":true,"data":{"id":"nested-1"}}' }]);
385
+ const result = await adapter.submit(makeReport());
386
+ expect(result).toMatchObject({ ok: true, id: "nested-1" });
387
+ });
388
+
389
+ it("id 가 없어도 성공은 성공이다", async () => {
390
+ const { adapter } = adapterWith([{ status: 200, body: '{"ok":true}' }]);
391
+ expect(await adapter.submit(makeReport())).toMatchObject({ ok: true, id: null });
392
+ });
393
+
394
+ it("Retry-After 가 숫자가 아니면 무시한다(문자열을 그대로 믿지 않는다)", async () => {
395
+ const { adapter } = adapterWith([
396
+ { status: 429, headers: { "Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT" } },
397
+ ]);
398
+ const result = await adapter.submit(makeReport());
399
+ expect(result).toMatchObject({ ok: false, retryable: true, rateLimited: true, retryAfterMs: null });
400
+ });
401
+
402
+ it("Error 가 아닌 값을 던져도 결과로 바꾼다", async () => {
403
+ const { adapter } = adapterWith([{ throws: "boom" }]);
404
+ expect(await adapter.submit(makeReport())).toMatchObject({ ok: false, retryable: true });
405
+ });
406
+
407
+ it("전송 수단이 아예 없으면 재시도 가능한 실패다(끊긴 게 아니라 아직 못 보낸 것)", async () => {
408
+ vi.stubGlobal("fetch", undefined);
409
+ const sink = warnSink();
410
+ const adapter = createLassoAdapter({ endpoint: ENDPOINT, warn: sink.warn });
411
+ expect(await adapter.submit(makeReport())).toMatchObject({ ok: false, retryable: true });
412
+ expect(sink.lines.length).toBeGreaterThan(0);
413
+ });
414
+
415
+ it("상대 endpoint 인데 오리진이 없으면 재시도하지 않는다(재시도해도 같은 결과다)", async () => {
416
+ const { impl, calls } = fakeFetch([]);
417
+ const sink = warnSink();
418
+ const adapter = createLassoAdapter({ fetch: impl, warn: sink.warn });
419
+ expect(await adapter.submit(makeReport())).toMatchObject({ ok: false, retryable: false });
420
+ expect(calls).toHaveLength(0); // 보내보지도 않는다
421
+ expect(sink.lines.join("\n")).toContain(LASSO_DEFAULT_ENDPOINT);
422
+ });
423
+
424
+ it("오리진이 있으면 상대 endpoint 를 절대 URL 로 만든다", async () => {
425
+ vi.stubGlobal("location", { origin: "https://app.example" });
426
+ const { impl, calls } = fakeFetch([{ status: 200, body: '{"ok":true,"id":"srv-1"}' }]);
427
+ const adapter = createLassoAdapter({ fetch: impl, warn: () => {} });
428
+ await adapter.submit(makeReport());
429
+ expect(calls[0]?.url).toBe(`https://app.example${LASSO_DEFAULT_ENDPOINT}`);
430
+ });
431
+
432
+ it("토큰이 있으면 Authorization 을, 없으면 아예 헤더를 안 붙인다", async () => {
433
+ const withToken = adapterWith([{ status: 200 }], { token: "fk_live_1" });
434
+ await withToken.adapter.submit(makeReport());
435
+ expect(withToken.calls[0]?.init.headers.Authorization).toBe("Bearer fk_live_1");
436
+ expect(withToken.calls[0]?.init.headers["Idempotency-Key"]).toBe("sub-1");
437
+
438
+ const withoutToken = adapterWith([{ status: 200 }]);
439
+ await withoutToken.adapter.submit(makeReport());
440
+ expect(withoutToken.calls[0]?.init.headers.Authorization).toBeUndefined();
441
+ expect(withoutToken.calls[0]?.init.headers["Idempotency-Key"]).toBe("sub-2");
442
+ });
443
+
444
+ // 이 검사가 TC2 의 진짜 목적이다. 실패를 "결과로" 돌려준다는 건 실패 사실을 어딘가에
445
+ // 뱉지 않는다는 뜻이기도 하다 — 진단 로그로 토큰·본문이 새면 실패 처리 자체가 사고다.
446
+ it("경고 어디에도 토큰·요청 본문·헤더 이름이 실리지 않는다", async () => {
447
+ const token = "fk_live_SECRET_TOKEN";
448
+ const secret = "비밀번호가 1234 라서 안 된다";
449
+ const warned: string[] = [];
450
+ for (const [, step] of FAILURES) {
451
+ const { adapter, warned: lines } = adapterWith([step], { token });
452
+ await adapter.submit(makeReport({ comment: secret }));
453
+ warned.push(...lines);
454
+ }
455
+ const joined = warned.join("\n");
456
+ expect(warned.length).toBeGreaterThan(0); // 아무 경고도 없으면 검사가 공허하다
457
+ expect(joined).not.toContain(token);
458
+ expect(joined).not.toContain(secret);
459
+ expect(joined).not.toContain("Authorization");
460
+ expect(joined).not.toContain("Bearer");
461
+ });
462
+
463
+ it("warn 이 던져도 전송 결과에는 영향이 없다", async () => {
464
+ const { impl } = fakeFetch([{ status: 500 }]);
465
+ const adapter = createLassoAdapter({
466
+ endpoint: ENDPOINT,
467
+ fetch: impl,
468
+ warn: () => {
469
+ throw new Error("콘솔이 막혔다");
470
+ },
471
+ });
472
+ expect(await adapter.submit(makeReport())).toMatchObject({ ok: false, retryable: true });
473
+ });
474
+ });
475
+
476
+ // ────────────────────────────────────────────────────────────────────────────
477
+ // TC3 — 대상이 못 받는 필드는 본문에 접어 넣는다
478
+ // ────────────────────────────────────────────────────────────────────────────
479
+
480
+ describe("TC3 대상이 못 받는 필드는 본문에 접어 넣는다", () => {
481
+ const count = (haystack: string, needle: string): number => haystack.split(needle).length - 1;
482
+
483
+ it("제보 하나당 접힌 블록은 정확히 하나다", () => {
484
+ const body = renderReportBody(makeRichReport());
485
+ expect(count(body, "<details>")).toBe(1);
486
+ expect(count(body, "</details>")).toBe(1);
487
+ expect(count(body, "<summary>")).toBe(1);
488
+ });
489
+
490
+ it("사용자 코멘트는 블록 밖, 진단 값들은 블록 안에 있다", () => {
491
+ const report = makeRichReport();
492
+ const body = renderReportBody(report);
493
+ const open = body.indexOf("<details>");
494
+ const close = body.indexOf("</details>");
495
+
496
+ expect(body.indexOf(report.comment)).toBeLessThan(open);
497
+ for (const label of ["- 소스:", "- 요소:", "- 기기:", "- 진단 네트워크", "- 진단 로그", "- 핀:"]) {
498
+ const at = body.indexOf(label);
499
+ expect(at).toBeGreaterThan(open);
500
+ expect(at).toBeLessThan(close);
501
+ }
502
+ });
503
+
504
+ it("로그 본문에 </details> 가 들어 있어도 블록이 거기서 닫히지 않는다", () => {
505
+ const report = makeRichReport();
506
+ report.context.diagnostics = {
507
+ network: [],
508
+ logs: [
509
+ { level: "warn", message: "툴팁이 </details> 때문에 깨진다", at: "2026-02-01T09:00:00.000Z" },
510
+ ],
511
+ };
512
+ const body = renderReportBody(report);
513
+ expect(count(body, "</details>")).toBe(1);
514
+ expect(body).toContain("&lt;/details>");
515
+ });
516
+
517
+ it("여러 줄 로그가 접힌 블록의 줄 구조를 흐트러뜨리지 않는다", () => {
518
+ const report = makeRichReport();
519
+ report.context.diagnostics = {
520
+ network: [],
521
+ logs: [{ level: "error", message: "1행\n2행\n3행", at: "2026-02-01T09:00:00.000Z" }],
522
+ };
523
+ const body = renderReportBody(report);
524
+ expect(body).toContain("[error] 1행 2행 3행");
525
+ expect(body).not.toContain("1행\n2행");
526
+ });
527
+
528
+ it("'수집 안 함'과 '수집했는데 비어 있음'을 본문에서 구분한다", () => {
529
+ const notCollected = makeRichReport();
530
+ notCollected.context.diagnostics = null;
531
+ expect(renderReportBody(notCollected)).toContain("- 진단: 수집하지 않음");
532
+
533
+ const empty = makeRichReport();
534
+ empty.context.diagnostics = { network: [], logs: [] };
535
+ const body = renderReportBody(empty);
536
+ expect(body).toContain("- 진단 네트워크 (0건)");
537
+ expect(body).toContain("- 진단 로그 (0건)");
538
+ expect(body).not.toContain("수집하지 않음");
539
+ });
540
+
541
+ // 라쏘런은 headline 필드가 없고 comment 하나로 받는다(실호출로 확정한 계약).
542
+ // 지목 제보면 annotation.comment, 리포트 제출이면 report.comment 에 들어간다.
543
+ function lassoBody(envelope: ReturnType<typeof buildLassoEnvelope>): string {
544
+ return envelope.report?.comment ?? envelope.annotation?.comment ?? "";
545
+ }
546
+
547
+ it("라쏘런 봉투는 구조화 필드로 못 받는 값을 본문 안에 담아 보낸다", () => {
548
+ const report = makeRichReport();
549
+ const envelope = buildLassoEnvelope(report);
550
+ // 봉투에 진단·요소·기기 전용 필드는 없다. 없다고 값이 사라지면 안 된다.
551
+ for (const key of Object.keys(envelope)) {
552
+ expect(key).not.toMatch(/diagnostic|element|device|display|native/i);
553
+ }
554
+ for (const needle of ["- 진단 네트워크", "- 요소:", "- 기기:", "- 내비 스택:"]) {
555
+ expect(lassoBody(envelope)).toContain(needle);
556
+ }
557
+ expect(count(lassoBody(envelope), "</details>")).toBe(1);
558
+ });
559
+ });
560
+
561
+ // ────────────────────────────────────────────────────────────────────────────
562
+ // TC4 — 어댑터가 달라도 사람이 읽는 내용은 같다
563
+ // ────────────────────────────────────────────────────────────────────────────
564
+
565
+ describe("TC4 어댑터가 달라도 사람이 읽는 내용은 같다", () => {
566
+ const VARIANTS: Array<[string, () => FeedbackReport]> = [
567
+ ["최소 제보", () => makeReport()],
568
+ ["전부 채운 제보", () => makeRichReport()],
569
+ ["코멘트가 빈 제보", () => makeRichReport({ comment: " " })],
570
+ ["코멘트가 아주 긴 제보", () => makeRichReport({ comment: "가".repeat(300) })],
571
+ [
572
+ "진단을 수집하지 않은 제보",
573
+ () => {
574
+ const r = makeRichReport();
575
+ r.context.diagnostics = null;
576
+ return r;
577
+ },
578
+ ],
579
+ ];
580
+
581
+ it.each(VARIANTS)("%s — 세 대상의 본문과 제목이 글자 단위로 같다", (_name, build) => {
582
+ const report = build();
583
+ const lasso = buildLassoEnvelope(report);
584
+ const linear = buildLinearIssue(report);
585
+ const notion = buildNotionPage(report);
586
+
587
+ const lassoText = lasso.report?.comment ?? lasso.annotation?.comment ?? "";
588
+ expect(new Set([lassoText, linear.description, notion.content]).size).toBe(1);
589
+ // 라쏘런은 제목 필드가 없다(comment 첫 줄이 목록에 뜬다) → 제목 비교 대상에서 뺀다.
590
+ expect(new Set([linear.title, notion.title]).size).toBe(1);
591
+ // 렌더러가 바뀌어도 세 대상이 함께 움직인다는 것까지 고정한다.
592
+ expect(linear.description).toBe(renderReportBody(report));
593
+ expect(notion.title).toBe(buildTitle(report));
594
+ });
595
+
596
+ it("제목은 코멘트 첫 줄에서 오고, 비면 화면 이름으로 대체된다", () => {
597
+ const report = makeRichReport({ comment: "첫 줄\n둘째 줄" });
598
+ expect(buildTitle(report)).toBe("첫 줄");
599
+
600
+ const blank = makeRichReport({ comment: " " });
601
+ expect(buildTitle(blank)).toBe("요소 주석 — /home");
602
+ });
603
+
604
+ it("긴 제목은 잘리되 세 대상에서 똑같이 잘린다", () => {
605
+ const report = makeRichReport({ comment: "가".repeat(300) });
606
+ const title = buildTitle(report);
607
+ expect(title).toHaveLength(TITLE_MAX_CHARS);
608
+ expect(title.endsWith("…")).toBe(true);
609
+ expect(buildLinearIssue(report).title).toBe(title);
610
+ expect(buildNotionPage(report).title).toBe(title);
611
+ });
612
+ });
613
+
614
+ // ────────────────────────────────────────────────────────────────────────────
615
+ // TC5 — 코어가 특정 어댑터의 payload 형태를 참조하지 않는다
616
+ // ────────────────────────────────────────────────────────────────────────────
617
+
618
+ describe("TC5 코어가 특정 어댑터의 payload 형태를 참조하지 않는다", () => {
619
+ // 검사 대상은 `packages/core/src` **바로 아래**의 비테스트 .ts 파일이다.
620
+ // `adapters/` 하위는 일부러 뺀다 — 거기는 대상별 모양을 아는 게 일이다.
621
+ // 확인하는 건 "코어 본체가 특정 대상의 필드명을 알고 있는가"다.
622
+ const CORE_DIR = dirname(dirname(fileURLToPath(import.meta.url)));
623
+
624
+ function coreFiles(): string[] {
625
+ return readdirSync(CORE_DIR, { withFileTypes: true })
626
+ .filter((e) => e.isFile() && e.name.endsWith(".ts") && !e.name.endsWith(".test.ts"))
627
+ .map((e) => e.name);
628
+ }
629
+
630
+ function read(name: string): string {
631
+ return readFileSync(join(CORE_DIR, name), "utf8");
632
+ }
633
+
634
+ /** 중첩까지 훑어 객체의 키 이름을 모은다. */
635
+ function collectKeys(value: unknown, into = new Set<string>()): Set<string> {
636
+ if (Array.isArray(value)) {
637
+ for (const item of value) collectKeys(item, into);
638
+ return into;
639
+ }
640
+ if (value !== null && typeof value === "object") {
641
+ for (const [key, child] of Object.entries(value)) {
642
+ into.add(key);
643
+ collectKeys(child, into);
644
+ }
645
+ }
646
+ return into;
647
+ }
648
+
649
+ /** 실제로 전선에 실린 payload 를 붙잡는다(빌더 반환값이 아니라 직렬화된 본문). */
650
+ async function capturePayload(report: FeedbackReport): Promise<Record<string, unknown>> {
651
+ const { calls, impl } = fakeFetch([{ status: 200 }]);
652
+ const adapter = createLassoAdapter({ endpoint: ENDPOINT, fetch: impl, warn: () => {} });
653
+ await adapter.submit(report);
654
+ const raw = calls[0]?.init.body ?? "{}";
655
+ return JSON.parse(raw) as Record<string, unknown>;
656
+ }
657
+
658
+ it("검사기가 볼 파일을 실제로 찾았다", () => {
659
+ // 파일이 옮겨지면 아래 검사들이 '아무것도 못 찾아서' 조용히 통과할 수 있다.
660
+ const files = coreFiles();
661
+ expect(files.length).toBeGreaterThanOrEqual(10);
662
+ for (const must of ["queue.ts", "types.ts", "config.ts", "report.ts", "context.ts"]) {
663
+ expect(files).toContain(must);
664
+ }
665
+ });
666
+
667
+ /**
668
+ * 코어가 자기 도메인 이름으로 정당하게 쓰는 단어들(types.ts 의 필드명).
669
+ * 실제 라쏘런 계약은 url·timestamp·logs 처럼 **일반적인** 이름을 쓰므로,
670
+ * 이걸 빼지 않으면 "코어가 대상 필드명을 안다"가 아니라 "같은 단어를 쓴다"를
671
+ * 잡게 된다. 검사 대상은 대상에만 있는 이름이어야 한다.
672
+ */
673
+ function coreVocabulary(): Set<string> {
674
+ const names = new Set<string>();
675
+ const types = read("types.ts");
676
+ // 필드명 (`comment: string`)
677
+ for (const match of types.matchAll(/^\s*(\w+)\??:/gm)) names.add(match[1]);
678
+ // 유니온 리터럴 (`"report" | "annotation"`) — 이것도 코어가 아는 자기 어휘다.
679
+ for (const match of types.matchAll(/"(\w+)"/g)) names.add(match[1]);
680
+ return names;
681
+ }
682
+
683
+ it("라쏘런 봉투에만 있는 필드명이 코어 본체 어디에도 없다", async () => {
684
+ const report = makeRichReport();
685
+ const payload = await capturePayload(report);
686
+
687
+ // 금지어 목록을 손으로 적지 않는다 — payload 에서 제보 자체의 키와 코어 어휘를
688
+ // 뺀 나머지가 정의상 "라쏘런에만 있는 이름"이다. 봉투가 바뀌면 목록도 따라 바뀐다.
689
+ const known = new Set([...collectKeys(report), ...coreVocabulary()]);
690
+ const lassoOnly = [...collectKeys(payload)].filter((key) => !known.has(key));
691
+ expect(lassoOnly.length).toBeGreaterThan(2); // 목록이 비면 검사가 공허하다
692
+
693
+ const lassoSource = read(join("adapters", "lasso.ts"));
694
+ const coreSources = coreFiles().map((name) => [name, read(name)] as const);
695
+
696
+ for (const key of lassoOnly) {
697
+ const word = new RegExp(`\\b${key}\\b`);
698
+ expect(lassoSource, `${key} 는 어댑터에 있어야 한다`).toMatch(word);
699
+ for (const [name, text] of coreSources) {
700
+ expect(text, `${name} 이 라쏘런 필드명 ${key} 를 알고 있다`).not.toMatch(word);
701
+ }
702
+ }
703
+ });
704
+
705
+ it("멱등 키 헤더 이름도 어댑터에만 있다", () => {
706
+ expect(read(join("adapters", "lasso.ts"))).toContain("Idempotency-Key");
707
+ for (const name of coreFiles()) {
708
+ expect(read(name), `${name}`).not.toContain("Idempotency-Key");
709
+ }
710
+ });
711
+
712
+ it("코어 본체는 어댑터를 import 하지 않는다(공개 진입점의 재수출만 예외)", () => {
713
+ for (const name of coreFiles()) {
714
+ if (name === "index.ts") continue; // 배럴은 재수출이라 값을 쓰지 않는다
715
+ expect(read(name), `${name}`).not.toContain("adapters/");
716
+ }
717
+ // 예외인 index.ts 도 "재수출"이지 "사용"이 아니어야 한다.
718
+ const barrel = read("index.ts");
719
+ for (const line of barrel.split("\n")) {
720
+ if (line.includes("adapters/")) expect(line.startsWith("}") || line.includes("export")).toBe(true);
721
+ }
722
+ });
723
+
724
+ it("코어가 아는 것은 어댑터의 '이름'까지다(모양이 아니라)", () => {
725
+ // config.ts 는 adapter: "lasso" 같은 설정값을 다룬다 — 이건 알아도 된다.
726
+ // 알면 안 되는 건 그 대상이 payload 를 어떤 모양으로 받는지다.
727
+ expect(read("config.ts")).toContain("lasso");
728
+ expect(read("config.ts")).not.toContain("bodyMarkdown");
729
+ });
730
+ });