@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.
- package/LICENSE +21 -0
- package/dist/index.cjs +2366 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1234 -0
- package/dist/index.d.ts +1234 -0
- package/dist/index.js +2268 -0
- package/dist/index.js.map +1 -0
- package/package.json +35 -0
- package/src/adapters/adapters.test.ts +730 -0
- package/src/adapters/body.ts +280 -0
- package/src/adapters/index.ts +19 -0
- package/src/adapters/lasso.live.test.ts +156 -0
- package/src/adapters/lasso.ts +316 -0
- package/src/adapters/linear.ts +39 -0
- package/src/adapters/notion.ts +30 -0
- package/src/config.test.ts +284 -0
- package/src/config.ts +396 -0
- package/src/context.test.ts +427 -0
- package/src/context.ts +326 -0
- package/src/diagnostics.test.ts +382 -0
- package/src/diagnostics.ts +502 -0
- package/src/guest-id.ts +36 -0
- package/src/index.ts +179 -0
- package/src/keepalive.test.ts +72 -0
- package/src/keepalive.ts +37 -0
- package/src/queue.test.ts +848 -0
- package/src/queue.ts +546 -0
- package/src/report.ts +58 -0
- package/src/ring-buffer.test.ts +62 -0
- package/src/ring-buffer.ts +42 -0
- package/src/source-attr.test.ts +77 -0
- package/src/source-attr.ts +86 -0
- package/src/types.ts +320 -0
- package/src/uuid.test.ts +116 -0
- package/src/uuid.ts +54 -0
- package/src/widget/controller.ts +224 -0
- package/src/widget/focus.ts +66 -0
- package/src/widget/index.ts +59 -0
- package/src/widget/modal.test.ts +472 -0
- package/src/widget/modal.ts +526 -0
- package/src/widget/pin.ts +110 -0
- package/src/widget/screenshot.ts +109 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// 라쏘런(doc-platform) 어댑터 — 기본 전송 대상.
|
|
2
|
+
//
|
|
3
|
+
// 이 파일이 지는 책임과 지지 않는 책임:
|
|
4
|
+
// - 진다: 제보를 라쏘런이 받는 모양으로 **바꿔서** 보내고, 결과를 SubmitResult 로 **돌려준다.**
|
|
5
|
+
// - 안 진다: 재시도·백오프·큐 적재. 그건 코어(queue.ts)가 한다. 여기서 재시도하면
|
|
6
|
+
// 같은 제보가 두 층에서 각자 재시도돼 서버가 중복을 받는다.
|
|
7
|
+
//
|
|
8
|
+
// 절대 규칙 두 가지:
|
|
9
|
+
// 1. **던지지 않는다.** 네트워크 오류든 파싱 실패든 SubmitResult 로 바꿔 돌려준다.
|
|
10
|
+
// (큐가 예외를 흡수하긴 하지만, 그때는 전부 "일시 실패"로 뭉뚱그려져 4xx 가 영원히 재시도된다.)
|
|
11
|
+
// 2. **본문·헤더를 로그에 남기지 않는다.** 경고에는 상태 코드와 사유만 적는다.
|
|
12
|
+
// `Authorization` 헤더와 응답 본문은 어떤 경로로도 콘솔에 닿지 않는다.
|
|
13
|
+
|
|
14
|
+
import { byteLengthOf, canUseKeepalive } from "../keepalive.js";
|
|
15
|
+
import type { FeedbackAdapter, FeedbackReport, SubmitResult } from "../types.js";
|
|
16
|
+
// 라쏘런은 headline 필드가 없다(comment 하나로 받는다) → buildTitle 을 쓰지 않는다.
|
|
17
|
+
import { renderReportBody } from "./body.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 엔드포인트 기본값. 코어가 아니라 **어댑터**가 갖는다(config.ts 의 ENDPOINT_FROM_ADAPTER).
|
|
21
|
+
*
|
|
22
|
+
* 상대 경로인 이유: 웹은 같은 오리진의 수집 라우트로 보내는 게 기본이고, 그래야
|
|
23
|
+
* Origin 허용 목록이라는 실제 방어선이 그대로 작동한다. 앱(RN)에는 오리진이 없으므로
|
|
24
|
+
* `endpoint` 를 절대 URL 로 지정해야 한다 — 지정 없이 앱에서 쓰면 아래에서 설정 오류로 끊는다.
|
|
25
|
+
*/
|
|
26
|
+
export const LASSO_DEFAULT_ENDPOINT = "/api/feedback";
|
|
27
|
+
|
|
28
|
+
/** 최소한의 응답 형태. 코어는 DOM 타입(lib)에 의존하지 않으므로 구조적으로 정의한다. */
|
|
29
|
+
export interface LassoResponseLike {
|
|
30
|
+
status: number;
|
|
31
|
+
headers?: { get(name: string): string | null } | undefined;
|
|
32
|
+
text(): Promise<string>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 최소한의 요청 형태. 표준 fetch 의 부분집합이라 그대로 꽂힌다. */
|
|
36
|
+
export interface LassoRequestInit {
|
|
37
|
+
method: string;
|
|
38
|
+
headers: Record<string, string>;
|
|
39
|
+
body: string;
|
|
40
|
+
keepalive?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type LassoFetch = (url: string, init: LassoRequestInit) => Promise<LassoResponseLike>;
|
|
44
|
+
|
|
45
|
+
export interface LassoAdapterOpts {
|
|
46
|
+
/** 수집 토큰. 없으면 `Authorization` 헤더 자체를 붙이지 않는다(미귀속 전송). */
|
|
47
|
+
token?: string | null;
|
|
48
|
+
/** 수집 엔드포인트. 없으면 LASSO_DEFAULT_ENDPOINT. */
|
|
49
|
+
endpoint?: string | null;
|
|
50
|
+
/** 전송 함수 주입(테스트용). 없으면 globalThis.fetch. */
|
|
51
|
+
fetch?: LassoFetch | null;
|
|
52
|
+
/** 경고 출력 주입. 위젯은 캡처를 우회하는 원본 console 을 넘긴다. */
|
|
53
|
+
warn?: ((message: string, ...rest: unknown[]) => void) | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 라쏘런으로 보내는 봉투.
|
|
58
|
+
*
|
|
59
|
+
* 필드명이 코어의 FeedbackReport 와 일부러 다르다. 코어는 이 이름들을 **모른다** —
|
|
60
|
+
* 대상마다 받는 모양이 다르다는 게 어댑터를 두는 이유 자체다(DP-255 TC5).
|
|
61
|
+
*/
|
|
62
|
+
/**
|
|
63
|
+
* 라쏘런 `dp-agentation-ingest` 가 실제로 요구하는 봉투.
|
|
64
|
+
*
|
|
65
|
+
* 이 모양은 **상상하지 않고 실호출로 확정했다.** 이전 버전은 필드명을 지어내서
|
|
66
|
+
* (submissionKey/bodyMarkdown/severity …) 실 엔드포인트에 100% 400 으로 거절당했고,
|
|
67
|
+
* 400 은 재시도 불가라 모든 제보가 조용히 데드레터로 사라졌다.
|
|
68
|
+
* 계약을 바꿀 때는 반드시 실호출로 다시 확인한다(adapters.live.test.ts).
|
|
69
|
+
*/
|
|
70
|
+
export interface LassoEnvelope {
|
|
71
|
+
/** UUID. **Idempotency-Key 헤더와 반드시 같은 값**이어야 서버가 받는다. */
|
|
72
|
+
clientSubmissionId: string;
|
|
73
|
+
/**
|
|
74
|
+
* 수집 소스의 종류와 같아야 한다(서버가 대조한다). 코어의 platform 을 옮긴 값이다:
|
|
75
|
+
* web → "web", native → "app". 앱은 report.create 만 보낼 수 있다(요소 지목은 DOM 이 필요).
|
|
76
|
+
*/
|
|
77
|
+
clientType: "web" | "app";
|
|
78
|
+
/** 지목 주석은 annotation.add, 리포트 모달 제출은 report.create. */
|
|
79
|
+
event: "annotation.add" | "report.create";
|
|
80
|
+
/** epoch ms 정수. ISO 문자열을 보내면 거절된다. */
|
|
81
|
+
timestamp: number;
|
|
82
|
+
/** 절대 HTTP(S) URL. 소스의 host_patterns 에 등록된 origin 이어야 한다. */
|
|
83
|
+
url: string;
|
|
84
|
+
priority: "unset" | "normal" | "high" | "urgent";
|
|
85
|
+
annotation?: {
|
|
86
|
+
id: string;
|
|
87
|
+
comment: string;
|
|
88
|
+
x: number | null;
|
|
89
|
+
y: number | null;
|
|
90
|
+
element: string | null;
|
|
91
|
+
elementPath: string | null;
|
|
92
|
+
selectedText: string | null;
|
|
93
|
+
boundingBox: { x: number; y: number; width: number; height: number } | null;
|
|
94
|
+
timestamp: number;
|
|
95
|
+
};
|
|
96
|
+
report?: {
|
|
97
|
+
comment: string;
|
|
98
|
+
pin: { x: number; y: number } | null;
|
|
99
|
+
sourceFile: string | null;
|
|
100
|
+
screenshotBase64: string | null;
|
|
101
|
+
screenshotContentType: "image/jpeg" | "image/png" | null;
|
|
102
|
+
network: unknown[];
|
|
103
|
+
logs: unknown[];
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
109
|
+
|
|
110
|
+
function resolveFetch(injected: LassoFetch | null | undefined): LassoFetch | null {
|
|
111
|
+
if (injected) return injected;
|
|
112
|
+
const g = globalThis as { fetch?: unknown };
|
|
113
|
+
return typeof g.fetch === "function" ? (g.fetch as LassoFetch) : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 상대 경로를 절대 URL 로 만든다. 오리진을 못 찾으면 null(= 설정 오류).
|
|
118
|
+
*
|
|
119
|
+
* 왜 굳이 절대 URL 로 바꾸나: 진단 수집기가 "위젯 자신의 요청"을 제외할 때 URL 로 매칭하는데,
|
|
120
|
+
* 상대/절대가 섞이면 그 매칭이 헐거워진다. 어느 층에서 보든 같은 문자열이 되게 맞춘다.
|
|
121
|
+
*/
|
|
122
|
+
function absoluteEndpoint(endpoint: string): string | null {
|
|
123
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(endpoint)) return endpoint;
|
|
124
|
+
const origin = (globalThis as { location?: { origin?: string } }).location?.origin;
|
|
125
|
+
if (typeof origin !== "string" || origin.length === 0) return null;
|
|
126
|
+
return endpoint.startsWith("/") ? `${origin}${endpoint}` : `${origin}/${endpoint}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** `Retry-After: 30`(초)만 해석한다. HTTP-date 형식은 코어의 기본 백오프에 맡긴다. */
|
|
130
|
+
function parseRetryAfterMs(res: LassoResponseLike): number | null {
|
|
131
|
+
const raw = res.headers?.get("Retry-After") ?? null;
|
|
132
|
+
if (raw === null) return null;
|
|
133
|
+
const seconds = Number(raw.trim());
|
|
134
|
+
return Number.isFinite(seconds) && seconds >= 0 ? Math.round(seconds * 1000) : null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function fail(retryable: boolean, extra?: Partial<SubmitResult>): SubmitResult {
|
|
138
|
+
return { ok: false, id: null, retryable, retryAfterMs: null, ...extra };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 응답 본문에서 서버가 준 id 를 꺼낸다. 없으면 null(성공 판정과는 무관하다). */
|
|
142
|
+
function idFrom(parsed: Record<string, unknown>): string | null {
|
|
143
|
+
// 라쏘런은 `annotationId` 로 준다(실호출로 확인). `id`/`data.id` 는 다른 대상 대비 폴백.
|
|
144
|
+
const annotationId = parsed.annotationId;
|
|
145
|
+
if (typeof annotationId === "string" && annotationId.length > 0) return annotationId;
|
|
146
|
+
const direct = parsed.id;
|
|
147
|
+
if (typeof direct === "string" && direct.length > 0) return direct;
|
|
148
|
+
const data = parsed.data;
|
|
149
|
+
if (data && typeof data === "object") {
|
|
150
|
+
const nested = (data as Record<string, unknown>).id;
|
|
151
|
+
if (typeof nested === "string" && nested.length > 0) return nested;
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 제보를 라쏘런 봉투로 바꾼다. 순수 함수라 테스트가 전송 없이 모양만 볼 수 있다. */
|
|
157
|
+
export function buildLassoEnvelope(report: FeedbackReport): LassoEnvelope {
|
|
158
|
+
const c = report.context;
|
|
159
|
+
const clientType: "web" | "app" = c.platform === "native" ? "app" : "web";
|
|
160
|
+
// 서버가 epoch ms 정수만 받는다. createdAt(ISO)이 깨져 있어도 전송을 막지 않는다.
|
|
161
|
+
const parsed = Date.parse(report.createdAt);
|
|
162
|
+
const timestamp = Number.isFinite(parsed) ? parsed : 0;
|
|
163
|
+
const el = report.element;
|
|
164
|
+
|
|
165
|
+
// 요소를 지목한 제보는 annotation.add 로, 모달 제출은 report.create 로 간다.
|
|
166
|
+
// 대상이 둘을 같은 라우트에서 event 로 가른다.
|
|
167
|
+
if (report.kind === "annotation") {
|
|
168
|
+
return {
|
|
169
|
+
clientSubmissionId: report.clientSubmissionId,
|
|
170
|
+
clientType,
|
|
171
|
+
event: "annotation.add",
|
|
172
|
+
timestamp,
|
|
173
|
+
url: c.url ?? "",
|
|
174
|
+
priority: report.priority,
|
|
175
|
+
annotation: {
|
|
176
|
+
id: report.clientSubmissionId,
|
|
177
|
+
comment: renderReportBody(report),
|
|
178
|
+
x: report.pin?.x ?? null,
|
|
179
|
+
y: report.pin?.y ?? null,
|
|
180
|
+
element: el ? el.tag : null,
|
|
181
|
+
elementPath: el?.attributes["data-feedback-source"] ?? null,
|
|
182
|
+
selectedText: el?.text ?? null,
|
|
183
|
+
boundingBox: el?.boundingBox ?? null,
|
|
184
|
+
timestamp,
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
clientSubmissionId: report.clientSubmissionId,
|
|
191
|
+
clientType,
|
|
192
|
+
event: "report.create",
|
|
193
|
+
timestamp,
|
|
194
|
+
// 앱에는 URL 이 없다 — 화면 이름을 app:// 경로로 싣는다(서버가 그대로 저장한다).
|
|
195
|
+
url: c.url ?? (clientType === "app" ? `app://${c.screen ?? "unknown"}` : ""),
|
|
196
|
+
priority: report.priority,
|
|
197
|
+
report: {
|
|
198
|
+
comment: renderReportBody(report),
|
|
199
|
+
pin: report.pin ? { x: report.pin.x, y: report.pin.y } : null,
|
|
200
|
+
sourceFile: el?.attributes["data-feedback-source"] ?? null,
|
|
201
|
+
screenshotBase64: report.screenshot?.base64 ?? null,
|
|
202
|
+
screenshotContentType: report.screenshot?.contentType ?? null,
|
|
203
|
+
// 서버가 없으면 빈 배열로 취급하지만, 미연동과 "없었음"을 구분해 보낸다.
|
|
204
|
+
network: report.context.diagnostics?.network ?? [],
|
|
205
|
+
logs: report.context.diagnostics?.logs ?? [],
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 라쏘런 어댑터를 만든다.
|
|
212
|
+
*
|
|
213
|
+
* 실패 분류(코어가 이 값만 보고 재시도 여부를 정한다):
|
|
214
|
+
* - 429 → 재시도 가능 + rateLimited. 코어가 최소 10분을 띄운다.
|
|
215
|
+
* - 408/425/5xx/네트워크 오류 → 재시도 가능(일시적).
|
|
216
|
+
* - 그 밖의 4xx → **재시도 불가.** 400/401/413 은 다시 보내도 같은 답이라 큐만 막는다.
|
|
217
|
+
* - 2xx 인데 본문의 `ok` 가 true 가 아님 → 재시도 불가. 스키마 거절은 저절로 낫지 않는다.
|
|
218
|
+
* - 2xx 인데 본문을 못 읽음 → **재시도 가능.** 프록시·CDN 이 HTML 을 끼워 넣는 일이 실제로 있고,
|
|
219
|
+
* 멱등 키가 있어 다시 보내도 중복이 생기지 않는다.
|
|
220
|
+
*/
|
|
221
|
+
export function createLassoAdapter(opts: LassoAdapterOpts = {}): FeedbackAdapter {
|
|
222
|
+
const token = opts.token ?? null;
|
|
223
|
+
const endpoint = opts.endpoint && opts.endpoint.trim().length > 0
|
|
224
|
+
? opts.endpoint.trim()
|
|
225
|
+
: LASSO_DEFAULT_ENDPOINT;
|
|
226
|
+
const warn = opts.warn ?? null;
|
|
227
|
+
|
|
228
|
+
const emit = (message: string): void => {
|
|
229
|
+
if (!warn) return;
|
|
230
|
+
try {
|
|
231
|
+
warn(message);
|
|
232
|
+
} catch {
|
|
233
|
+
// 로깅 실패가 전송 실패로 번지면 안 된다.
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
async submit(report: FeedbackReport): Promise<SubmitResult> {
|
|
239
|
+
const url = absoluteEndpoint(endpoint);
|
|
240
|
+
if (url === null) {
|
|
241
|
+
// 앱에서 상대 경로를 그대로 둔 설정 오류. 재시도해도 절대 낫지 않으므로
|
|
242
|
+
// 재시도 불가로 끊는다(큐가 데드레터로 보내고 뒤 제보를 막지 않는다).
|
|
243
|
+
emit(
|
|
244
|
+
`feedback-kit: endpoint "${endpoint}" 를 절대 URL 로 만들 수 없다(오리진 없음). ` +
|
|
245
|
+
"앱에서는 endpoint 를 절대 URL 로 지정해야 한다."
|
|
246
|
+
);
|
|
247
|
+
return fail(false);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const sender = resolveFetch(opts.fetch);
|
|
251
|
+
if (sender === null) {
|
|
252
|
+
// fetch 가 없는 런타임. 폴리필이 늦게 들어올 수 있으니 일시 실패로 둔다.
|
|
253
|
+
emit("feedback-kit: fetch 를 찾지 못했다(전송을 미룬다).");
|
|
254
|
+
return fail(true);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const body = JSON.stringify(buildLassoEnvelope(report));
|
|
258
|
+
const headers: Record<string, string> = {
|
|
259
|
+
"Content-Type": "application/json",
|
|
260
|
+
// 재시도가 중복 제보가 되지 않게. 코어가 같은 항목을 다시 보내도 키는 그대로다.
|
|
261
|
+
"Idempotency-Key": report.clientSubmissionId,
|
|
262
|
+
};
|
|
263
|
+
if (token !== null) headers.Authorization = `Bearer ${token}`;
|
|
264
|
+
|
|
265
|
+
let res: LassoResponseLike;
|
|
266
|
+
try {
|
|
267
|
+
res = await sender(url, {
|
|
268
|
+
method: "POST",
|
|
269
|
+
headers,
|
|
270
|
+
body,
|
|
271
|
+
// 페이지를 즉시 이탈해도 전송이 끝나게. 본문이 한도를 넘으면 브라우저가
|
|
272
|
+
// 요청 자체를 거절하므로 크기를 보고 켠다.
|
|
273
|
+
keepalive: canUseKeepalive(byteLengthOf(body)),
|
|
274
|
+
});
|
|
275
|
+
} catch {
|
|
276
|
+
// 오프라인·DNS·CORS 등. 예외 객체에 요청 본문이 들어있을 수 있어 로그에 싣지 않는다.
|
|
277
|
+
emit("feedback-kit: 전송에 실패했다(네트워크). 큐에 남겨 다시 시도한다.");
|
|
278
|
+
return fail(true);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const status = res.status;
|
|
282
|
+
|
|
283
|
+
if (status < 200 || status >= 300) {
|
|
284
|
+
if (status === 429) {
|
|
285
|
+
emit("feedback-kit: 수집 한도에 걸렸다(429). 잠시 뒤 다시 시도한다.");
|
|
286
|
+
return fail(true, { retryAfterMs: parseRetryAfterMs(res), rateLimited: true });
|
|
287
|
+
}
|
|
288
|
+
const retryable = RETRYABLE_STATUS.has(status);
|
|
289
|
+
emit(
|
|
290
|
+
`feedback-kit: 수집 응답 ${status}${retryable ? " (다시 시도한다)" : " (재시도하지 않는다)"}`
|
|
291
|
+
);
|
|
292
|
+
return fail(retryable, { retryAfterMs: retryable ? parseRetryAfterMs(res) : null });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
let parsed: Record<string, unknown>;
|
|
296
|
+
try {
|
|
297
|
+
const text = await res.text();
|
|
298
|
+
const value: unknown = JSON.parse(text);
|
|
299
|
+
if (value === null || typeof value !== "object") throw new Error("객체가 아님");
|
|
300
|
+
parsed = value as Record<string, unknown>;
|
|
301
|
+
} catch {
|
|
302
|
+
emit(`feedback-kit: 수집 응답 ${status} 의 본문을 읽지 못했다. 다시 시도한다.`);
|
|
303
|
+
return fail(true);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// 2xx 만으로 성공을 단정하지 않는다. 서버가 200 + ok:false 로 거절할 수 있고,
|
|
307
|
+
// 그걸 성공으로 읽으면 제보가 큐에서 지워진 채 어디에도 남지 않는다.
|
|
308
|
+
if (parsed.ok !== true) {
|
|
309
|
+
emit(`feedback-kit: 수집 서버가 제보를 거절했다(${status}). 재시도하지 않는다.`);
|
|
310
|
+
return fail(false);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return { ok: true, id: idFrom(parsed), retryable: false, retryAfterMs: null };
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Linear 어댑터 — **인터페이스만 열어둔다. 전송은 구현하지 않는다.**
|
|
2
|
+
//
|
|
3
|
+
// 왜 껍데기를 미리 두는가:
|
|
4
|
+
// 어댑터 교체가 실제로 가능한지는 두 번째 대상을 만들어봐야 드러난다. 여기서 확인하는 건
|
|
5
|
+
// "라쏘런에만 있는 모양을 코어가 몰래 전제하고 있지 않은가"이고, 그건 전송 없이
|
|
6
|
+
// **payload 를 조립하는 것만으로** 확인된다(DP-255 TC4·TC5).
|
|
7
|
+
//
|
|
8
|
+
// 전송을 안 쓰는 이유는 게을러서가 아니다. Linear 로 실제로 보내려면 팀 ID·상태 ID·
|
|
9
|
+
// API 키 같은 워크스페이스별 값이 필요한데, 그건 이 wave 에 명세가 없다. 없는 명세를
|
|
10
|
+
// 상상해서 만들어두면 나중에 전부 지워야 한다. 그래서 **모양만** 맞춰둔다.
|
|
11
|
+
|
|
12
|
+
import type { FeedbackReport } from "../types.js";
|
|
13
|
+
import { buildTitle, renderReportBody } from "./body.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Linear 이슈 생성 입력의 최소 형태.
|
|
17
|
+
*
|
|
18
|
+
* 필드명이 라쏘런 봉투와 다르다는 게 핵심이다 — 같은 제보라도 대상마다 이름이 다르고,
|
|
19
|
+
* 코어는 그 어느 쪽도 모른다.
|
|
20
|
+
*/
|
|
21
|
+
export interface LinearIssueInput {
|
|
22
|
+
/** 이슈 제목. 목록에서 한 줄로 보인다. */
|
|
23
|
+
title: string;
|
|
24
|
+
/** 이슈 본문(마크다운). 대상이 못 받는 값은 전부 여기 접힌 블록으로 들어간다. */
|
|
25
|
+
description: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 제보를 Linear 이슈 입력으로 바꾼다.
|
|
30
|
+
*
|
|
31
|
+
* 본문은 공용 렌더러가 만든 것을 **그대로** 쓴다. 여기서 다시 조립하면
|
|
32
|
+
* "Linear 로 간 제보에는 소스 경로가 없다" 같은 차이가 조용히 생긴다.
|
|
33
|
+
*/
|
|
34
|
+
export function buildLinearIssue(report: FeedbackReport): LinearIssueInput {
|
|
35
|
+
return {
|
|
36
|
+
title: buildTitle(report),
|
|
37
|
+
description: renderReportBody(report),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Notion 어댑터 — **인터페이스만 열어둔다. 전송은 구현하지 않는다.**
|
|
2
|
+
//
|
|
3
|
+
// linear.ts 와 같은 이유로 payload 조립까지만 둔다. Notion 은 본문을 블록 배열로 받는
|
|
4
|
+
// 대상이라 "마크다운 문자열 하나"와 모양이 가장 많이 다른데, 그래서 오히려 좋은 대조군이다
|
|
5
|
+
// — 이 대상까지 같은 렌더러로 채워진다면 본문 구성이 특정 대상에 묶여 있지 않다는 뜻이다.
|
|
6
|
+
|
|
7
|
+
import type { FeedbackReport } from "../types.js";
|
|
8
|
+
import { buildTitle, renderReportBody } from "./body.js";
|
|
9
|
+
|
|
10
|
+
/** Notion 페이지 생성 입력의 최소 형태. */
|
|
11
|
+
export interface NotionPageInput {
|
|
12
|
+
/** 페이지 제목 속성. */
|
|
13
|
+
title: string;
|
|
14
|
+
/**
|
|
15
|
+
* 페이지 본문(마크다운 원문).
|
|
16
|
+
*
|
|
17
|
+
* 블록 배열로 쪼개지 않는 이유: 쪼개는 규칙(어디서 문단을 나눌지, 접힌 블록을
|
|
18
|
+
* toggle 로 옮길지)은 실제 Notion API 계약이 정해진 뒤에야 의미가 있다.
|
|
19
|
+
* 지금 추측으로 쪼개두면 본문 내용이 대상마다 갈라지고, 그게 이 wave 가 막으려는 것이다.
|
|
20
|
+
*/
|
|
21
|
+
content: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 제보를 Notion 페이지 입력으로 바꾼다. 본문은 공용 렌더러 결과 그대로다. */
|
|
25
|
+
export function buildNotionPage(report: FeedbackReport): NotionPageInput {
|
|
26
|
+
return {
|
|
27
|
+
title: buildTitle(report),
|
|
28
|
+
content: renderReportBody(report),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_ADAPTER,
|
|
5
|
+
DEFAULT_POSITION,
|
|
6
|
+
detectDevBuild,
|
|
7
|
+
diagnosticsProviderFor,
|
|
8
|
+
isInternalUser,
|
|
9
|
+
resolveConfig,
|
|
10
|
+
shouldShowWidget,
|
|
11
|
+
type ConfigWarningCode,
|
|
12
|
+
type DiagnosticsSource,
|
|
13
|
+
} from "./config.js";
|
|
14
|
+
import { buildContext } from "./context.js";
|
|
15
|
+
import type { DiagnosticsPayload, FeedbackStorage } from "./types.js";
|
|
16
|
+
|
|
17
|
+
function codes(warnings: { code: ConfigWarningCode }[]): ConfigWarningCode[] {
|
|
18
|
+
return warnings.map((w) => w.code);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function memoryStorage(): FeedbackStorage {
|
|
22
|
+
const map = new Map<string, string>();
|
|
23
|
+
return {
|
|
24
|
+
async get(key) {
|
|
25
|
+
return map.get(key) ?? null;
|
|
26
|
+
},
|
|
27
|
+
async set(key, value) {
|
|
28
|
+
map.set(key, value);
|
|
29
|
+
},
|
|
30
|
+
async remove(key) {
|
|
31
|
+
map.delete(key);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("resolveConfig", () => {
|
|
37
|
+
it("아무것도 안 주면 기본값으로 채운다", () => {
|
|
38
|
+
const r = resolveConfig();
|
|
39
|
+
expect(r.adapter).toBe(DEFAULT_ADAPTER);
|
|
40
|
+
expect(r.visibility).toBe("all");
|
|
41
|
+
expect(r.captureScreenshot).toBe(true);
|
|
42
|
+
expect(r.captureDiagnostics).toBe(true);
|
|
43
|
+
expect(r.position).toEqual(DEFAULT_POSITION);
|
|
44
|
+
expect(r.endpoint).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("토큰이 없으면 경고만 남기고 던지지 않는다", () => {
|
|
48
|
+
const r = resolveConfig({ token: " " });
|
|
49
|
+
expect(r.token).toBeNull();
|
|
50
|
+
expect(codes(r.warnings)).toContain("missing-token");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// 빌드 타임 인라인이 실패하면 토큰 자리에 이런 문자열이 그대로 박힌다.
|
|
54
|
+
// 서버가 401 을 주기 전에 여기서 먼저 알려주는 게 목적.
|
|
55
|
+
it.each([
|
|
56
|
+
"undefined",
|
|
57
|
+
"null",
|
|
58
|
+
"process.env.FEEDBACK_TOKEN",
|
|
59
|
+
"${VITE_FEEDBACK_TOKEN}",
|
|
60
|
+
"%NEXT_PUBLIC_FEEDBACK_TOKEN%",
|
|
61
|
+
])("치환되지 않은 토큰 %s 을 잡아낸다", (token) => {
|
|
62
|
+
const r = resolveConfig({ token });
|
|
63
|
+
expect(codes(r.warnings)).toContain("unsubstituted-token");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("정상 토큰에는 경고가 없다", () => {
|
|
67
|
+
const r = resolveConfig({ token: "fk_live_abc123" });
|
|
68
|
+
expect(r.token).toBe("fk_live_abc123");
|
|
69
|
+
expect(r.warnings).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("모르는 값이 와도 던지지 않고 기본값으로 되돌린다", () => {
|
|
73
|
+
const r = resolveConfig({
|
|
74
|
+
adapter: "지라" as never,
|
|
75
|
+
visibility: "직원만" as never,
|
|
76
|
+
position: "가운데" as never,
|
|
77
|
+
});
|
|
78
|
+
expect(r.adapter).toBe(DEFAULT_ADAPTER);
|
|
79
|
+
expect(r.visibility).toBe("all");
|
|
80
|
+
expect(r.position).toEqual(DEFAULT_POSITION);
|
|
81
|
+
expect(codes(r.warnings)).toEqual(
|
|
82
|
+
expect.arrayContaining(["unknown-adapter", "unknown-visibility", "unknown-position"])
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("코너만 줘도 되고 오프셋만 덮어써도 된다", () => {
|
|
87
|
+
expect(resolveConfig({ position: "top-left" }).position).toEqual({
|
|
88
|
+
corner: "top-left",
|
|
89
|
+
offsetX: DEFAULT_POSITION.offsetX,
|
|
90
|
+
offsetY: DEFAULT_POSITION.offsetY,
|
|
91
|
+
});
|
|
92
|
+
expect(resolveConfig({ position: { offsetY: 80 } }).position).toEqual({
|
|
93
|
+
corner: DEFAULT_POSITION.corner,
|
|
94
|
+
offsetX: DEFAULT_POSITION.offsetX,
|
|
95
|
+
offsetY: 80,
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("onWarn 이 던져도 설정 해석은 계속된다", () => {
|
|
100
|
+
const onWarn = vi.fn(() => {
|
|
101
|
+
throw new Error("로깅 실패");
|
|
102
|
+
});
|
|
103
|
+
const r = resolveConfig({ onWarn });
|
|
104
|
+
expect(onWarn).toHaveBeenCalled();
|
|
105
|
+
expect(r.adapter).toBe(DEFAULT_ADAPTER);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("어댑터 객체를 직접 주면 그대로 쓴다", () => {
|
|
109
|
+
const adapter = {
|
|
110
|
+
submit: async () => ({ ok: true, id: "1", retryable: false, retryAfterMs: null }),
|
|
111
|
+
};
|
|
112
|
+
expect(resolveConfig({ adapter }).adapter).toBe(adapter);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("detectDevBuild", () => {
|
|
117
|
+
it("__DEV__ 가 있으면 그걸 따른다", () => {
|
|
118
|
+
const g = globalThis as Record<string, unknown>;
|
|
119
|
+
g.__DEV__ = true;
|
|
120
|
+
try {
|
|
121
|
+
expect(detectDevBuild()).toBe(true);
|
|
122
|
+
g.__DEV__ = false;
|
|
123
|
+
expect(detectDevBuild()).toBe(false);
|
|
124
|
+
} finally {
|
|
125
|
+
delete g.__DEV__;
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("shouldShowWidget", () => {
|
|
131
|
+
it("visibility=all 이면 로그인하지 않은 사용자에게도 보인다", () => {
|
|
132
|
+
const r = resolveConfig({ visibility: "all" });
|
|
133
|
+
expect(shouldShowWidget(r, { user: null })).toBe(true);
|
|
134
|
+
expect(shouldShowWidget(r, { user: { id: "u1", isGuest: true } })).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("visibility=internal 이면 일반 사용자에게는 숨긴다", () => {
|
|
138
|
+
const r = resolveConfig({ visibility: "internal" });
|
|
139
|
+
expect(shouldShowWidget(r, { user: { id: "u1", role: "user" } })).toBe(false);
|
|
140
|
+
expect(shouldShowWidget(r, { user: null })).toBe(false);
|
|
141
|
+
expect(shouldShowWidget(r, { user: { id: "u2", role: "admin" } })).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("게스트는 role 이 붙어 있어도 내부 사용자가 아니다", () => {
|
|
145
|
+
const r = resolveConfig({ visibility: "internal" });
|
|
146
|
+
expect(shouldShowWidget(r, { user: { id: "g", role: "admin", isGuest: true } })).toBe(
|
|
147
|
+
false
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("visibility=dev-only 는 개발 빌드에서만 보인다", () => {
|
|
152
|
+
const r = resolveConfig({ visibility: "dev-only", isDev: false });
|
|
153
|
+
expect(shouldShowWidget(r)).toBe(false);
|
|
154
|
+
expect(shouldShowWidget(r, { isDev: true })).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// TC4: visibility 에 false 를 반환하는 함수를 주면 버튼이 렌더되지 않는다.
|
|
158
|
+
it("visibility 함수의 판정을 그대로 따른다", () => {
|
|
159
|
+
const never = resolveConfig({ visibility: () => false });
|
|
160
|
+
expect(shouldShowWidget(never, { user: { id: "u1", role: "admin" } })).toBe(false);
|
|
161
|
+
|
|
162
|
+
const seen: unknown[] = [];
|
|
163
|
+
const always = resolveConfig({
|
|
164
|
+
visibility: (env) => {
|
|
165
|
+
seen.push(env.user);
|
|
166
|
+
return true;
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
expect(shouldShowWidget(always, { user: { id: "u1" }, platform: "web" })).toBe(true);
|
|
170
|
+
expect(seen).toEqual([{ id: "u1" }]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("판정 함수가 던지면 숨기고 경고를 남긴다", () => {
|
|
174
|
+
const r = resolveConfig({
|
|
175
|
+
visibility: () => {
|
|
176
|
+
throw new Error("세션 조회 실패");
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
expect(shouldShowWidget(r)).toBe(false);
|
|
180
|
+
expect(codes(r.warnings)).toContain("visibility-threw");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("internal 판정 함수가 던져도 숨긴다", () => {
|
|
184
|
+
const r = resolveConfig({
|
|
185
|
+
visibility: "internal",
|
|
186
|
+
isInternal: () => {
|
|
187
|
+
throw new Error("권한 조회 실패");
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
expect(shouldShowWidget(r, { user: { id: "u1" } })).toBe(false);
|
|
191
|
+
expect(codes(r.warnings)).toContain("visibility-threw");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("internalRoles 로 내부 판정 기준을 바꿀 수 있다", () => {
|
|
195
|
+
const r = resolveConfig({ visibility: "internal", internalRoles: ["QA"] });
|
|
196
|
+
expect(isInternalUser(r, { user: { id: "u1", role: "qa" }, isDev: false, platform: "web" })).toBe(
|
|
197
|
+
true
|
|
198
|
+
);
|
|
199
|
+
expect(
|
|
200
|
+
isInternalUser(r, { user: { id: "u2", role: "admin" }, isDev: false, platform: "web" })
|
|
201
|
+
).toBe(false);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe("diagnosticsProviderFor", () => {
|
|
206
|
+
function fakeCollector(): DiagnosticsSource & { installed: number } {
|
|
207
|
+
const payload: DiagnosticsPayload = {
|
|
208
|
+
network: [
|
|
209
|
+
{
|
|
210
|
+
method: "GET",
|
|
211
|
+
url: "https://api.example.com/x",
|
|
212
|
+
status: 500,
|
|
213
|
+
durationMs: 12,
|
|
214
|
+
at: "2026-01-01T00:00:00.000Z",
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
logs: [{ level: "error", message: "boom", at: "2026-01-01T00:00:00.000Z" }],
|
|
218
|
+
};
|
|
219
|
+
return {
|
|
220
|
+
installed: 0,
|
|
221
|
+
install() {
|
|
222
|
+
this.installed += 1;
|
|
223
|
+
},
|
|
224
|
+
snapshot() {
|
|
225
|
+
return payload;
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
it("켜져 있으면 수집기를 설치하고 공급자를 돌려준다", () => {
|
|
231
|
+
const collector = fakeCollector();
|
|
232
|
+
const provider = diagnosticsProviderFor({ captureDiagnostics: true }, collector);
|
|
233
|
+
expect(collector.installed).toBe(1);
|
|
234
|
+
expect(provider?.().network).toHaveLength(1);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// TC5: captureDiagnostics=false 면 수집 자체를 하지 않는다.
|
|
238
|
+
// 빈 배열을 돌려주면 "수집했는데 없었다"와 구분되지 않으므로 공급자를 아예 만들지 않는다.
|
|
239
|
+
it("꺼져 있으면 수집기를 설치하지 않고 공급자도 없다", () => {
|
|
240
|
+
const collector = fakeCollector();
|
|
241
|
+
expect(diagnosticsProviderFor({ captureDiagnostics: false }, collector)).toBeUndefined();
|
|
242
|
+
expect(collector.installed).toBe(0);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("꺼진 설정으로 만든 컨텍스트는 diagnostics 가 null 이고 나머지는 정상이다", async () => {
|
|
246
|
+
const resolved = resolveConfig({ captureDiagnostics: false, token: "fk_t" });
|
|
247
|
+
const collector = fakeCollector();
|
|
248
|
+
const ctx = await buildContext({
|
|
249
|
+
app: "demo",
|
|
250
|
+
sessionId: "s-1",
|
|
251
|
+
storage: memoryStorage(),
|
|
252
|
+
platform: "web",
|
|
253
|
+
getUser: () => ({ id: "u1", email: "a@b.c" }),
|
|
254
|
+
getCurrentScreen: () => "홈",
|
|
255
|
+
getUrl: () => "https://app.example.com/home",
|
|
256
|
+
getWebContext: () => ({ userAgent: "vitest" }),
|
|
257
|
+
getDiagnostics: diagnosticsProviderFor(resolved, collector),
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
expect(ctx.diagnostics).toBeNull();
|
|
261
|
+
expect(collector.installed).toBe(0);
|
|
262
|
+
// 진단만 빠지고 나머지 컨텍스트는 그대로 채워진다.
|
|
263
|
+
expect(ctx.user?.id).toBe("u1");
|
|
264
|
+
expect(ctx.screen).toBe("홈");
|
|
265
|
+
expect(ctx.url).toBe("https://app.example.com/home");
|
|
266
|
+
expect(ctx.web?.userAgent).toBe("vitest");
|
|
267
|
+
expect(ctx.clientTimestamp).toEqual(expect.any(String));
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("켜진 설정으로 만든 컨텍스트에는 진단이 채워진다", async () => {
|
|
271
|
+
const resolved = resolveConfig({ captureDiagnostics: true });
|
|
272
|
+
const collector = fakeCollector();
|
|
273
|
+
const ctx = await buildContext({
|
|
274
|
+
app: "demo",
|
|
275
|
+
sessionId: "s-1",
|
|
276
|
+
storage: memoryStorage(),
|
|
277
|
+
platform: "web",
|
|
278
|
+
getUrl: () => "https://app.example.com/home",
|
|
279
|
+
getDiagnostics: diagnosticsProviderFor(resolved, collector),
|
|
280
|
+
});
|
|
281
|
+
expect(ctx.diagnostics?.network).toHaveLength(1);
|
|
282
|
+
expect(ctx.diagnostics?.logs).toHaveLength(1);
|
|
283
|
+
});
|
|
284
|
+
});
|