@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,280 @@
|
|
|
1
|
+
// 어댑터 공용 본문 렌더러.
|
|
2
|
+
//
|
|
3
|
+
// 왜 어댑터마다 따로 안 쓰고 여기 한 곳에 모으는가:
|
|
4
|
+
// 제보를 어느 대상(라쏘런·Linear·Notion)으로 보내든 **사람이 읽는 내용은 같아야 한다.**
|
|
5
|
+
// 어댑터마다 본문을 따로 조립하면 "Linear 로 보낸 제보에는 소스 경로가 없다" 같은
|
|
6
|
+
// 차이가 조용히 생긴다. 그래서 어댑터는 이 함수가 만든 문자열을 자기 필드명에 실어
|
|
7
|
+
// 나르기만 하고, 내용 구성에는 관여하지 않는다(DP-255 TC3·TC4).
|
|
8
|
+
//
|
|
9
|
+
// 접는 규칙도 여기 하나뿐이다:
|
|
10
|
+
// - 대상이 받아주지 못하는 구조화 필드(진단·기기·요소 등)는 **본문 하단의 접힌 블록**에 넣는다.
|
|
11
|
+
// - 접힌 블록은 제보 하나당 **정확히 하나**다. 어댑터가 추가로 접지 않는다.
|
|
12
|
+
// - 사용자가 쓴 코멘트는 접지 않는다 — 읽는 사람이 제일 먼저 봐야 하는 내용이다.
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
AppInfo,
|
|
16
|
+
ContextUser,
|
|
17
|
+
DeviceInfo,
|
|
18
|
+
DisplayInfo,
|
|
19
|
+
ElementInfo,
|
|
20
|
+
FeedbackKind,
|
|
21
|
+
FeedbackPriority,
|
|
22
|
+
FeedbackReport,
|
|
23
|
+
SourceMapping,
|
|
24
|
+
WebContext,
|
|
25
|
+
} from "../types.js";
|
|
26
|
+
import { sourceFromElement } from "../source-attr.js";
|
|
27
|
+
|
|
28
|
+
/** 접힌 블록의 여는 태그. 테스트와 어댑터가 같은 문자열을 보게 상수로 둔다. */
|
|
29
|
+
export const DETAILS_OPEN = "<details>";
|
|
30
|
+
|
|
31
|
+
/** 접힌 블록의 요약줄. 대상에서 이 문구만 보이고 나머지는 접힌다. */
|
|
32
|
+
export const DETAILS_SUMMARY = "<summary>진단 정보</summary>";
|
|
33
|
+
|
|
34
|
+
/** 접힌 블록의 닫는 태그. */
|
|
35
|
+
export const DETAILS_CLOSE = "</details>";
|
|
36
|
+
|
|
37
|
+
/** 제목 최대 길이. 대상마다 제한이 다르지만, 한 줄로 읽히는 길이가 실질 상한이다. */
|
|
38
|
+
export const TITLE_MAX_CHARS = 80;
|
|
39
|
+
|
|
40
|
+
const KIND_LABEL: Record<FeedbackKind, string> = {
|
|
41
|
+
report: "제보",
|
|
42
|
+
annotation: "요소 주석",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const PRIORITY_LABEL: Record<FeedbackPriority, string> = {
|
|
46
|
+
unset: "미지정",
|
|
47
|
+
normal: "보통",
|
|
48
|
+
high: "높음",
|
|
49
|
+
urgent: "긴급",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 접힌 블록 **안에** 들어가는 자유 텍스트를 안전하게 만든다.
|
|
54
|
+
*
|
|
55
|
+
* `<` 만 막는 이유: 로그 메시지나 요소 텍스트에 `</details>` 가 들어 있으면 그 지점에서
|
|
56
|
+
* 블록이 닫혀 뒤쪽 진단이 통째로 펼쳐진다(제보 하나 = 접힌 블록 하나 규칙이 깨진다).
|
|
57
|
+
* `&` 까지 이스케이프하면 URL 쿼리(`?a=1&b=2`)가 읽기 어려워지는데, `&` 만으로는
|
|
58
|
+
* 블록이 깨지지 않으므로 그대로 둔다.
|
|
59
|
+
*
|
|
60
|
+
* 줄바꿈은 공백으로 접는다. 목록 한 줄이 여러 줄로 쪼개지면 표가 무너진다.
|
|
61
|
+
*/
|
|
62
|
+
function inline(value: string): string {
|
|
63
|
+
return value.replace(/</g, "<").replace(/[\r\n\t]+/g, " ").trim();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** `- 라벨: 값` 한 줄. 값이 비면 줄 자체를 만들지 않는다(빈 항목으로 지면을 채우지 않는다). */
|
|
67
|
+
function kv(label: string, value: string | null | undefined): string | null {
|
|
68
|
+
if (value === null || value === undefined) return null;
|
|
69
|
+
const text = inline(value);
|
|
70
|
+
return text.length > 0 ? `- ${label}: ${text}` : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 객체를 한 줄 JSON 으로. 접힌 블록 안이라 이스케이프까지 거친다. */
|
|
74
|
+
function json(value: unknown): string | null {
|
|
75
|
+
try {
|
|
76
|
+
const text = JSON.stringify(value);
|
|
77
|
+
return text === undefined || text === "{}" ? null : text;
|
|
78
|
+
} catch {
|
|
79
|
+
// 순환 참조 등. 제보 전체를 실패시킬 이유는 아니다.
|
|
80
|
+
return "(직렬화 불가)";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** base64 길이에서 원본 바이트 수를 되돌린다(패딩 제외). */
|
|
85
|
+
function approxBytesOfBase64(b64: string): number {
|
|
86
|
+
const padding = b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0;
|
|
87
|
+
return Math.max(0, Math.floor((b64.length * 3) / 4) - padding);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function humanBytes(n: number): string {
|
|
91
|
+
return n < 1024 ? `${n} B` : `${(n / 1024).toFixed(1)} KB`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `src/pages/Home.tsx:42` 형태. SourceMapping 에 칼럼이 없으므로 파일:줄까지다. */
|
|
95
|
+
function formatSource(s: SourceMapping): string | null {
|
|
96
|
+
const file = s.sourceFile;
|
|
97
|
+
const line = typeof s.sourceLine === "number" ? s.sourceLine : null;
|
|
98
|
+
const filePart = file ? (line === null ? file : `${file}:${line}`) : null;
|
|
99
|
+
if (filePart && s.screenId) return `${filePart} (화면 ${s.screenId})`;
|
|
100
|
+
if (filePart) return filePart;
|
|
101
|
+
return s.screenId ? `화면 ${s.screenId}` : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function formatUser(u: ContextUser | null): string | null {
|
|
105
|
+
if (!u) return null;
|
|
106
|
+
const bits: string[] = [u.id ?? "(미상)"];
|
|
107
|
+
if (u.name) bits.push(u.name);
|
|
108
|
+
if (u.email) bits.push(u.email);
|
|
109
|
+
if (u.role) bits.push(`역할 ${u.role}`);
|
|
110
|
+
if (u.isGuest) bits.push("게스트");
|
|
111
|
+
return bits.join(" · ");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** `button#save.primary` 형태의 셀렉터. 읽는 사람이 요소를 특정할 수 있으면 충분하다. */
|
|
115
|
+
function elementSelector(el: ElementInfo): string {
|
|
116
|
+
if (el.selector) return el.selector;
|
|
117
|
+
let out = el.tag;
|
|
118
|
+
if (el.id) out += `#${el.id}`;
|
|
119
|
+
if (el.className) {
|
|
120
|
+
for (const cls of el.className.split(/\s+/)) {
|
|
121
|
+
if (cls) out += `.${cls}`;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function formatAppInfo(a: AppInfo): string | null {
|
|
128
|
+
const bits: string[] = [];
|
|
129
|
+
if (a.version) bits.push(a.version);
|
|
130
|
+
if (a.channel) bits.push(`채널 ${a.channel}`);
|
|
131
|
+
if (a.runtimeVersion) bits.push(`런타임 ${a.runtimeVersion}`);
|
|
132
|
+
if (a.updateId) bits.push(`업데이트 ${a.updateId}`);
|
|
133
|
+
return bits.length > 0 ? bits.join(" · ") : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function formatDevice(d: DeviceInfo): string | null {
|
|
137
|
+
const bits: string[] = [];
|
|
138
|
+
if (d.model) bits.push(d.model);
|
|
139
|
+
if (d.osName || d.osVersion) bits.push([d.osName, d.osVersion].filter(Boolean).join(" "));
|
|
140
|
+
if (d.deviceType) bits.push(d.deviceType);
|
|
141
|
+
return bits.length > 0 ? bits.join(" · ") : null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function formatDisplay(d: DisplayInfo): string | null {
|
|
145
|
+
const bits: string[] = [];
|
|
146
|
+
if (d.width !== null && d.height !== null) bits.push(`${d.width}×${d.height}`);
|
|
147
|
+
if (d.pixelRatio !== null) bits.push(`@${d.pixelRatio}x`);
|
|
148
|
+
if (d.fontScale !== null) bits.push(`글꼴 배율 ${d.fontScale}`);
|
|
149
|
+
return bits.length > 0 ? bits.join(" · ") : null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function formatViewport(v: WebContext["viewport"]): string | null {
|
|
153
|
+
const bits: string[] = [];
|
|
154
|
+
if (v.width !== null && v.height !== null) bits.push(`${v.width}×${v.height}`);
|
|
155
|
+
if (v.devicePixelRatio !== null) bits.push(`@${v.devicePixelRatio}x`);
|
|
156
|
+
return bits.length > 0 ? bits.join(" · ") : null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 접힌 블록 안의 목록. 값이 없는 항목은 줄이 아예 없다. */
|
|
160
|
+
function detailLines(report: FeedbackReport): string[] {
|
|
161
|
+
const c = report.context;
|
|
162
|
+
const out: (string | null)[] = [];
|
|
163
|
+
|
|
164
|
+
out.push(kv("종류", KIND_LABEL[report.kind]));
|
|
165
|
+
out.push(kv("우선순위", PRIORITY_LABEL[report.priority]));
|
|
166
|
+
out.push(kv("제출 ID", report.clientSubmissionId));
|
|
167
|
+
out.push(kv("작성 시각", report.createdAt));
|
|
168
|
+
|
|
169
|
+
out.push(kv("앱", c.app));
|
|
170
|
+
out.push(kv("플랫폼", c.platform));
|
|
171
|
+
out.push(kv("화면", c.screen));
|
|
172
|
+
out.push(kv("URL", c.url));
|
|
173
|
+
out.push(kv("소스", formatSource(c.source)));
|
|
174
|
+
out.push(kv("사용자", formatUser(c.user)));
|
|
175
|
+
out.push(kv("세션", c.sessionId));
|
|
176
|
+
out.push(
|
|
177
|
+
kv("수집 시각", c.timezone ? `${c.clientTimestamp} (${c.timezone})` : c.clientTimestamp)
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
if (report.element) {
|
|
181
|
+
const el = report.element;
|
|
182
|
+
out.push(kv("요소", elementSelector(el)));
|
|
183
|
+
out.push(kv("요소 CSS 경로", el.selector ?? null));
|
|
184
|
+
out.push(kv("요소 소스", formatSource(sourceFromElement(el))));
|
|
185
|
+
out.push(kv("요소 텍스트", el.text));
|
|
186
|
+
if (el.boundingBox) {
|
|
187
|
+
const b = el.boundingBox;
|
|
188
|
+
out.push(
|
|
189
|
+
kv(
|
|
190
|
+
"요소 위치",
|
|
191
|
+
`x ${Math.round(b.x)} · y ${Math.round(b.y)} · ${Math.round(b.width)}×${Math.round(
|
|
192
|
+
b.height
|
|
193
|
+
)}`
|
|
194
|
+
)
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
out.push(kv("요소 속성", json(el.attributes)));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (report.pin) {
|
|
201
|
+
// 정규화 좌표(0~1)라 그대로 쓰면 0.123456789 같은 값이 나온다. 세 자리면 충분하다.
|
|
202
|
+
out.push(kv("핀", `x ${report.pin.x.toFixed(3)} · y ${report.pin.y.toFixed(3)} (0~1 정규화)`));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (report.screenshot) {
|
|
206
|
+
const bytes = approxBytesOfBase64(report.screenshot.base64);
|
|
207
|
+
out.push(kv("스크린샷", `${report.screenshot.contentType} · 약 ${humanBytes(bytes)}`));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (c.native) {
|
|
211
|
+
const n = c.native;
|
|
212
|
+
out.push(kv("화면 경로", n.screenPath));
|
|
213
|
+
out.push(kv("내비 스택", n.navPath.length > 0 ? n.navPath.join(" › ") : null));
|
|
214
|
+
out.push(kv("라우트 파라미터", n.routeParams ? json(n.routeParams) : null));
|
|
215
|
+
out.push(kv("앱 버전", formatAppInfo(n.appInfo)));
|
|
216
|
+
out.push(kv("기기", formatDevice(n.device)));
|
|
217
|
+
out.push(kv("디스플레이", formatDisplay(n.display)));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (c.web) {
|
|
221
|
+
out.push(kv("뷰포트", formatViewport(c.web.viewport)));
|
|
222
|
+
out.push(kv("UA", c.web.userAgent));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const extra = json(c.extra);
|
|
226
|
+
out.push(kv("추가", extra));
|
|
227
|
+
|
|
228
|
+
// 진단은 "수집 안 함(null)"과 "수집했는데 0건([])"을 구분해서 적는다.
|
|
229
|
+
// 둘을 똑같이 적으면 읽는 사람이 버그인지 설정인지 판단할 수 없다.
|
|
230
|
+
if (c.diagnostics === null) {
|
|
231
|
+
out.push("- 진단: 수집하지 않음");
|
|
232
|
+
} else {
|
|
233
|
+
const { network, logs } = c.diagnostics;
|
|
234
|
+
out.push(`- 진단 네트워크 (${network.length}건)`);
|
|
235
|
+
for (const e of network) {
|
|
236
|
+
const status = e.status === null ? "실패" : String(e.status);
|
|
237
|
+
out.push(` - ${inline(`${e.method} ${e.url}`)} → ${status} · ${e.durationMs}ms · ${e.at}`);
|
|
238
|
+
}
|
|
239
|
+
out.push(`- 진단 로그 (${logs.length}건)`);
|
|
240
|
+
for (const e of logs) {
|
|
241
|
+
out.push(` - [${e.level}] ${inline(e.message)} · ${e.at}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return out.filter((line): line is string => line !== null);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* 제보 하나를 사람이 읽는 본문으로 만든다.
|
|
250
|
+
*
|
|
251
|
+
* 형태: `사용자 코멘트` + 빈 줄 + `접힌 진단 블록 하나`.
|
|
252
|
+
* 코멘트는 **이스케이프하지 않는다** — 접힌 블록보다 앞에 있어서 우리 블록을 깨뜨릴 수 없고,
|
|
253
|
+
* 사람이 쓴 문장을 우리가 변형하는 쪽이 더 나쁘다.
|
|
254
|
+
*/
|
|
255
|
+
export function renderReportBody(report: FeedbackReport): string {
|
|
256
|
+
const comment = report.comment.trim().length > 0 ? report.comment.trimEnd() : "(코멘트 없음)";
|
|
257
|
+
return [
|
|
258
|
+
comment,
|
|
259
|
+
"",
|
|
260
|
+
DETAILS_OPEN,
|
|
261
|
+
DETAILS_SUMMARY,
|
|
262
|
+
"",
|
|
263
|
+
...detailLines(report),
|
|
264
|
+
"",
|
|
265
|
+
DETAILS_CLOSE,
|
|
266
|
+
].join("\n");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* 제목. 코멘트 첫 줄을 쓰되, 비어 있으면 종류와 화면으로 대신한다.
|
|
271
|
+
* 제목이 비면 대상에서 목록이 읽히지 않으므로 항상 비어 있지 않은 문자열을 돌려준다.
|
|
272
|
+
*/
|
|
273
|
+
export function buildTitle(report: FeedbackReport): string {
|
|
274
|
+
const firstLine = report.comment.split("\n").find((l) => l.trim().length > 0);
|
|
275
|
+
const fallback = `${KIND_LABEL[report.kind]} — ${
|
|
276
|
+
report.context.screen ?? report.context.url ?? report.context.app
|
|
277
|
+
}`;
|
|
278
|
+
const base = inline(firstLine ?? fallback) || fallback;
|
|
279
|
+
return base.length > TITLE_MAX_CHARS ? `${base.slice(0, TITLE_MAX_CHARS - 1)}…` : base;
|
|
280
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// 어댑터 모음.
|
|
2
|
+
//
|
|
3
|
+
// 코어의 다른 모듈은 이 디렉터리를 **import 하지 않는다.** 방향은 한쪽뿐이다:
|
|
4
|
+
// 어댑터가 코어 타입을 읽고, 코어는 `FeedbackAdapter` 인터페이스만 안다.
|
|
5
|
+
// (코어가 어댑터를 되짚어 import 하는 순간 "어댑터 교체 가능"이 거짓이 된다.)
|
|
6
|
+
|
|
7
|
+
export { buildTitle, renderReportBody, TITLE_MAX_CHARS } from "./body.js";
|
|
8
|
+
export { buildLinearIssue } from "./linear.js";
|
|
9
|
+
export type { LinearIssueInput } from "./linear.js";
|
|
10
|
+
export { buildNotionPage } from "./notion.js";
|
|
11
|
+
export type { NotionPageInput } from "./notion.js";
|
|
12
|
+
export { buildLassoEnvelope, createLassoAdapter, LASSO_DEFAULT_ENDPOINT } from "./lasso.js";
|
|
13
|
+
export type {
|
|
14
|
+
LassoAdapterOpts,
|
|
15
|
+
LassoEnvelope,
|
|
16
|
+
LassoFetch,
|
|
17
|
+
LassoRequestInit,
|
|
18
|
+
LassoResponseLike,
|
|
19
|
+
} from "./lasso.js";
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// 라쏘런 어댑터 — **실 엔드포인트 왕복** 테스트.
|
|
2
|
+
//
|
|
3
|
+
// 왜 따로 두나:
|
|
4
|
+
// mock 은 "우리 쪽 조립"만 증명하고 **저쪽이 받아주는지는 증명하지 않는다.**
|
|
5
|
+
// 실제로 이 어댑터의 첫 버전은 필드명을 지어내서(submissionKey/bodyMarkdown/severity…)
|
|
6
|
+
// 실 엔드포인트에 100% 400 으로 거절당했는데, 유닛 테스트 50건은 전부 초록불이었다.
|
|
7
|
+
// 가짜 수집 서버가 같은 잘못된 계약으로 짜여 있었기 때문이다.
|
|
8
|
+
// 400 은 재시도 불가라, 그대로 나갔다면 모든 제보가 조용히 사라졌다.
|
|
9
|
+
//
|
|
10
|
+
// 실행 방법(자격증명이 없으면 skip 된다 — CI 를 막지 않는다):
|
|
11
|
+
// FEEDBACK_KIT_LIVE_ENDPOINT=https://<...>/dp-agentation-ingest/<sourceId> \
|
|
12
|
+
// FEEDBACK_KIT_LIVE_TOKEN=dp_ingest_... \
|
|
13
|
+
// FEEDBACK_KIT_LIVE_ORIGIN=http://localhost:3000 \
|
|
14
|
+
// pnpm vitest run packages/core/src/adapters/lasso.live.test.ts
|
|
15
|
+
//
|
|
16
|
+
// 배포 전후 1회 돌린다. CI 상시 실행 대상이 아니다(외부 상태에 의존한다).
|
|
17
|
+
|
|
18
|
+
import { describe, expect, it } from "vitest";
|
|
19
|
+
|
|
20
|
+
import { buildLassoEnvelope, createLassoAdapter } from "./lasso.js";
|
|
21
|
+
import { uuidv4 } from "../uuid.js";
|
|
22
|
+
import type { FeedbackReport } from "../types.js";
|
|
23
|
+
|
|
24
|
+
const endpoint = process.env.FEEDBACK_KIT_LIVE_ENDPOINT ?? "";
|
|
25
|
+
const token = process.env.FEEDBACK_KIT_LIVE_TOKEN ?? "";
|
|
26
|
+
const origin = process.env.FEEDBACK_KIT_LIVE_ORIGIN ?? "";
|
|
27
|
+
const live = endpoint !== "" && token !== "" && origin !== "";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 브라우저 흉내내기. `Origin` 은 브라우저가 자동으로 붙이고 스크립트가 못 바꾸는
|
|
31
|
+
* 헤더라 어댑터가 설정하지 않는다. Node 에서는 아무도 안 붙여서 서버가 403
|
|
32
|
+
* (origin_not_allowed)을 준다 — 어댑터 잘못이 아니라 실행 환경 차이다.
|
|
33
|
+
*/
|
|
34
|
+
const browserFetch = ((url: string, init: Record<string, unknown>) =>
|
|
35
|
+
fetch(url, {
|
|
36
|
+
...init,
|
|
37
|
+
headers: { ...(init.headers as Record<string, string>), Origin: origin },
|
|
38
|
+
} as RequestInit)) as never;
|
|
39
|
+
|
|
40
|
+
function report(over: Partial<FeedbackReport> = {}): FeedbackReport {
|
|
41
|
+
return {
|
|
42
|
+
clientSubmissionId: uuidv4(),
|
|
43
|
+
kind: "report",
|
|
44
|
+
comment: "[feedback-kit live] 실 왕복 테스트 — 확인 후 삭제",
|
|
45
|
+
priority: "normal",
|
|
46
|
+
screenshot: null,
|
|
47
|
+
pin: null,
|
|
48
|
+
element: null,
|
|
49
|
+
createdAt: new Date().toISOString(),
|
|
50
|
+
context: {
|
|
51
|
+
app: "feedback-kit-live",
|
|
52
|
+
screen: "/live",
|
|
53
|
+
url: `${origin}/live`,
|
|
54
|
+
sessionId: "live-session",
|
|
55
|
+
clientTimestamp: new Date().toISOString(),
|
|
56
|
+
user: null,
|
|
57
|
+
// 빌드 플러그인 없이 붙인 앱을 흉내낸다(값은 전부 null 이지만 객체는 있다).
|
|
58
|
+
source: { screenId: null, sourceFile: null, sourceLine: null },
|
|
59
|
+
native: null,
|
|
60
|
+
web: null,
|
|
61
|
+
diagnostics: null,
|
|
62
|
+
extra: {},
|
|
63
|
+
},
|
|
64
|
+
...over,
|
|
65
|
+
} as FeedbackReport;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 앱 소스 왕복(별도 소스·별도 키). 웹과 계약이 다르다: clientType="app",
|
|
69
|
+
// url 은 app:// 경로, Origin 헤더 없음(브라우저가 아니다).
|
|
70
|
+
const appEndpoint = process.env.FEEDBACK_KIT_LIVE_APP_ENDPOINT ?? "";
|
|
71
|
+
const appToken = process.env.FEEDBACK_KIT_LIVE_APP_TOKEN ?? "";
|
|
72
|
+
const appLive = appEndpoint !== "" && appToken !== "";
|
|
73
|
+
|
|
74
|
+
describe.skipIf(!appLive)("라쏘런 실 왕복 — 앱", () => {
|
|
75
|
+
function appReport(): FeedbackReport {
|
|
76
|
+
const r = report();
|
|
77
|
+
r.context.platform = "native";
|
|
78
|
+
r.context.url = null;
|
|
79
|
+
r.context.screen = "홈";
|
|
80
|
+
return r;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
it("앱 제보가 같은 라우트로 받아들여진다", async () => {
|
|
84
|
+
const adapter = createLassoAdapter({ endpoint: appEndpoint, token: appToken, warn: () => {} });
|
|
85
|
+
|
|
86
|
+
const result = await adapter.submit(appReport());
|
|
87
|
+
|
|
88
|
+
expect(result.ok, `서버가 거절했다: retryable=${result.retryable}`).toBe(true);
|
|
89
|
+
expect(result.id).toBeTypeOf("string");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("앱은 url 자리에 화면 경로를 싣는다", () => {
|
|
93
|
+
const envelope = buildLassoEnvelope(appReport());
|
|
94
|
+
|
|
95
|
+
expect(envelope.clientType).toBe("app");
|
|
96
|
+
// URL 이 없는 플랫폼이라 화면 이름이 그 자리를 대신한다.
|
|
97
|
+
expect(envelope.url).toBe("app://홈");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe.skipIf(!live)("라쏘런 실 왕복", () => {
|
|
102
|
+
it("리포트 제출이 실제로 받아들여진다(ok:true)", async () => {
|
|
103
|
+
const adapter = createLassoAdapter({ endpoint, token, fetch: browserFetch, warn: () => {} });
|
|
104
|
+
|
|
105
|
+
const result = await adapter.submit(report());
|
|
106
|
+
|
|
107
|
+
// 여기서 실패하면 필드명 계약이 어긋난 것이다. 메시지를 그대로 읽어라 —
|
|
108
|
+
// 서버가 어느 필드를 기대하는지 알려준다.
|
|
109
|
+
expect(result.ok, `서버가 거절했다: retryable=${result.retryable}`).toBe(true);
|
|
110
|
+
expect(result.id).toBeTypeOf("string");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("지목 주석도 같은 라우트로 받아들여진다", async () => {
|
|
114
|
+
const adapter = createLassoAdapter({ endpoint, token, fetch: browserFetch, warn: () => {} });
|
|
115
|
+
|
|
116
|
+
const result = await adapter.submit(
|
|
117
|
+
report({
|
|
118
|
+
kind: "annotation",
|
|
119
|
+
pin: { x: 0.5, y: 0.5 },
|
|
120
|
+
element: {
|
|
121
|
+
tag: "button",
|
|
122
|
+
id: "submit",
|
|
123
|
+
className: "btn",
|
|
124
|
+
text: "저장",
|
|
125
|
+
boundingBox: { x: 10, y: 20, width: 80, height: 32 },
|
|
126
|
+
attributes: {},
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
expect(result.ok, `서버가 거절했다: retryable=${result.retryable}`).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("같은 제출 id 를 다시 보내도 서버가 중복으로 접는다", async () => {
|
|
135
|
+
const adapter = createLassoAdapter({ endpoint, token, fetch: browserFetch, warn: () => {} });
|
|
136
|
+
const same = report();
|
|
137
|
+
|
|
138
|
+
const first = await adapter.submit(same);
|
|
139
|
+
const second = await adapter.submit(same);
|
|
140
|
+
|
|
141
|
+
// 재시도가 제보를 복제하지 않는다는 것 — 큐의 "전송 보장"이 기대는 전제다.
|
|
142
|
+
expect(first.ok).toBe(true);
|
|
143
|
+
expect(second.ok).toBe(true);
|
|
144
|
+
expect(second.id).toBe(first.id);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("잘못된 토큰은 재시도 불가로 끊는다(큐를 막지 않는다)", async () => {
|
|
148
|
+
const adapter = createLassoAdapter({ endpoint, token: "dp_ingest_wrong", fetch: browserFetch, warn: () => {} });
|
|
149
|
+
|
|
150
|
+
const result = await adapter.submit(report());
|
|
151
|
+
|
|
152
|
+
expect(result.ok).toBe(false);
|
|
153
|
+
// 401 을 재시도하면 큐가 영영 안 비워진다.
|
|
154
|
+
expect(result.retryable).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
});
|