@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,42 @@
|
|
|
1
|
+
// 고정 용량 링버퍼.
|
|
2
|
+
//
|
|
3
|
+
// 진단 수집은 "최근 것"만 있으면 된다. 상한이 없으면 오래 켜둔 화면에서 메모리가
|
|
4
|
+
// 계속 늘고, 상한을 넘겼을 때 새 항목을 버리면 정작 사고 직전의 기록이 안 남는다.
|
|
5
|
+
// 그래서 상한 초과 시 **가장 오래된 것부터** 밀어낸다.
|
|
6
|
+
|
|
7
|
+
export class RingBuffer<T> {
|
|
8
|
+
/** 보관 상한. 0 이하/비정상 값이면 0(아무것도 담지 않음)으로 떨어진다. */
|
|
9
|
+
readonly capacity: number;
|
|
10
|
+
|
|
11
|
+
private items: T[] = [];
|
|
12
|
+
|
|
13
|
+
constructor(capacity: number) {
|
|
14
|
+
this.capacity =
|
|
15
|
+
Number.isFinite(capacity) && capacity > 0 ? Math.floor(capacity) : 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
push(item: T): void {
|
|
19
|
+
if (this.capacity === 0) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
this.items.push(item);
|
|
23
|
+
const overflow = this.items.length - this.capacity;
|
|
24
|
+
if (overflow > 0) {
|
|
25
|
+
// 한 번에 여러 개가 넘칠 일은 없지만(push 마다 검사), 방어적으로 잘라낸다.
|
|
26
|
+
this.items.splice(0, overflow);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 현재 내용의 복사본. 호출자가 들고 있어도 이후 push 에 영향받지 않는다. */
|
|
31
|
+
toArray(): T[] {
|
|
32
|
+
return this.items.slice();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get size(): number {
|
|
36
|
+
return this.items.length;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
clear(): void {
|
|
40
|
+
this.items = [];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { parseSourceAttr, SOURCE_ATTR, sourceFromElement } from "./source-attr.js";
|
|
4
|
+
|
|
5
|
+
describe("parseSourceAttr", () => {
|
|
6
|
+
it("파일:줄:칼럼 을 해석한다", () => {
|
|
7
|
+
expect(parseSourceAttr("src/pages/Home.tsx:42:7")).toEqual({
|
|
8
|
+
file: "src/pages/Home.tsx",
|
|
9
|
+
line: 42,
|
|
10
|
+
column: 7,
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("칼럼이 없어도 된다", () => {
|
|
15
|
+
expect(parseSourceAttr("src/App.tsx:3")).toEqual({
|
|
16
|
+
file: "src/App.tsx",
|
|
17
|
+
line: 3,
|
|
18
|
+
column: null,
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// 윈도우 경로에는 콜론이 들어 있다. 앞에서부터 자르면 드라이브 문자에서 깨진다.
|
|
23
|
+
it("경로에 콜론이 있어도 뒤에서부터 잘라 안전하다", () => {
|
|
24
|
+
expect(parseSourceAttr("C:/repo/src/App.tsx:10:2")).toEqual({
|
|
25
|
+
file: "C:/repo/src/App.tsx",
|
|
26
|
+
line: 10,
|
|
27
|
+
column: 2,
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("모양이 아니면 null 이다", () => {
|
|
32
|
+
expect(parseSourceAttr(undefined)).toBeNull();
|
|
33
|
+
expect(parseSourceAttr("")).toBeNull();
|
|
34
|
+
expect(parseSourceAttr("src/App.tsx")).toBeNull();
|
|
35
|
+
expect(parseSourceAttr("src/App.tsx:0")).toBeNull();
|
|
36
|
+
expect(parseSourceAttr(":12:3")).toBeNull();
|
|
37
|
+
expect(parseSourceAttr(42)).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("sourceFromElement", () => {
|
|
42
|
+
const base = { screenId: "home", sourceFile: "src/screens/Home.tsx" };
|
|
43
|
+
|
|
44
|
+
// TC6: 프로덕션에서도 번들 경로가 아니라 원본 파일 경로와 줄 번호가 남는다.
|
|
45
|
+
it("요소에 심긴 경로가 화면 단위 매핑보다 구체적이라 우선한다", () => {
|
|
46
|
+
const el = {
|
|
47
|
+
attributes: { [SOURCE_ATTR]: "src/components/SubmitButton.tsx:17:5" },
|
|
48
|
+
};
|
|
49
|
+
expect(sourceFromElement(el, base)).toEqual({
|
|
50
|
+
screenId: "home",
|
|
51
|
+
sourceFile: "src/components/SubmitButton.tsx",
|
|
52
|
+
sourceLine: 17,
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// TC7: 빌드 플러그인을 안 붙였을 때. 조용히 화면 단위 매핑으로 떨어진다.
|
|
57
|
+
it("속성이 없으면 화면 단위 매핑을 그대로 쓴다", () => {
|
|
58
|
+
expect(sourceFromElement({ attributes: {} }, base)).toEqual({
|
|
59
|
+
screenId: "home",
|
|
60
|
+
sourceFile: "src/screens/Home.tsx",
|
|
61
|
+
sourceLine: null,
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("요소도 매핑도 없으면 전부 null 이다(던지지 않는다)", () => {
|
|
66
|
+
expect(sourceFromElement(null)).toEqual({
|
|
67
|
+
screenId: null,
|
|
68
|
+
sourceFile: null,
|
|
69
|
+
sourceLine: null,
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("속성 값이 망가져 있어도 화면 단위 매핑으로 떨어진다", () => {
|
|
74
|
+
const el = { attributes: { [SOURCE_ATTR]: "쓰레기값" } };
|
|
75
|
+
expect(sourceFromElement(el, base).sourceFile).toBe("src/screens/Home.tsx");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// 소스 경로 읽기 — 빌드 플러그인이 요소에 심어둔 속성을 해석한다.
|
|
2
|
+
//
|
|
3
|
+
// 왜 속성인가: 런타임에 React 내부(`_debugSource` 등)에서 얻는 소스 정보는 **개발
|
|
4
|
+
// 빌드에서만** 채워진다. 프로덕션 번들에는 없다. 반면 빌드 시점에 심은 문자열 속성은
|
|
5
|
+
// 미니파이에 훼손되지 않고 React 내부 구조에도 의존하지 않아서, 프로덕션에서도 원본
|
|
6
|
+
// 파일 경로와 줄 번호가 그대로 남는다.
|
|
7
|
+
//
|
|
8
|
+
// 이 모듈은 **읽기만** 한다. 심는 쪽은 `@solhun/feedback-kit-build` 이고, 그 패키지는
|
|
9
|
+
// 선택 사항이다. 속성이 없으면 여기서는 조용히 null 을 돌려주고 위젯은 그대로 동작한다
|
|
10
|
+
// (소스 경로만 빈다).
|
|
11
|
+
|
|
12
|
+
import type { ElementInfo, SourceMapping } from "./types.js";
|
|
13
|
+
|
|
14
|
+
/** 빌드 플러그인이 심는 속성 이름. `data-` 라서 DOM 이 그대로 통과시킨다. */
|
|
15
|
+
export const SOURCE_ATTR = "data-fk-source";
|
|
16
|
+
|
|
17
|
+
/** 해석된 소스 위치. */
|
|
18
|
+
export interface SourceLocation {
|
|
19
|
+
/** 원본 파일 경로(번들 경로가 아니다). */
|
|
20
|
+
file: string;
|
|
21
|
+
/** 1부터 시작하는 줄 번호. */
|
|
22
|
+
line: number;
|
|
23
|
+
/** 1부터 시작하는 칼럼. 없으면 null. */
|
|
24
|
+
column: number | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `"src/pages/Home.tsx:42:7"` 을 해석한다.
|
|
29
|
+
*
|
|
30
|
+
* 뒤에서부터 숫자 꼬리를 떼어낸다 — 파일 경로 자체에 콜론이 들어갈 수 있어서
|
|
31
|
+
* (윈도우 드라이브 문자 `C:\`) 앞에서부터 자르면 깨진다.
|
|
32
|
+
*/
|
|
33
|
+
export function parseSourceAttr(value: unknown): SourceLocation | null {
|
|
34
|
+
if (typeof value !== "string") return null;
|
|
35
|
+
const raw = value.trim();
|
|
36
|
+
if (raw === "") return null;
|
|
37
|
+
|
|
38
|
+
let rest = raw;
|
|
39
|
+
let line: number | null = null;
|
|
40
|
+
let column: number | null = null;
|
|
41
|
+
|
|
42
|
+
for (let i = 0; i < 2; i += 1) {
|
|
43
|
+
const at = rest.lastIndexOf(":");
|
|
44
|
+
if (at <= 0) break;
|
|
45
|
+
const tail = rest.slice(at + 1);
|
|
46
|
+
if (!/^\d+$/.test(tail)) break;
|
|
47
|
+
const n = Number.parseInt(tail, 10);
|
|
48
|
+
if (!Number.isFinite(n) || n <= 0) break;
|
|
49
|
+
rest = rest.slice(0, at);
|
|
50
|
+
// 뒤에서부터 읽으므로 첫 번째로 떼어낸 숫자가 칼럼, 두 번째가 줄이다.
|
|
51
|
+
if (line === null) line = n;
|
|
52
|
+
else {
|
|
53
|
+
column = line;
|
|
54
|
+
line = n;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// `rest` 가 콜론으로 시작하면(`":12:3"`) 파일 자리가 비어 있다는 뜻이라 경로가 아니다.
|
|
59
|
+
if (line === null || rest === "" || rest.startsWith(":")) return null;
|
|
60
|
+
return { file: rest, line, column };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 지목된 요소에서 소스 매핑을 뽑는다.
|
|
65
|
+
*
|
|
66
|
+
* `base` 는 화면 단위 매핑(빌드 플러그인의 screen-map)이다. 요소에서 얻은 파일 경로가
|
|
67
|
+
* 더 구체적이므로 그걸 우선하고, 화면 식별자는 base 것을 유지한다.
|
|
68
|
+
*/
|
|
69
|
+
export function sourceFromElement(
|
|
70
|
+
element: Pick<ElementInfo, "attributes"> | null | undefined,
|
|
71
|
+
base?: SourceMapping | null
|
|
72
|
+
): SourceMapping {
|
|
73
|
+
const fallback: SourceMapping = {
|
|
74
|
+
screenId: base?.screenId ?? null,
|
|
75
|
+
sourceFile: base?.sourceFile ?? null,
|
|
76
|
+
sourceLine: base?.sourceLine ?? null,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const attrs = element?.attributes;
|
|
80
|
+
if (!attrs) return fallback;
|
|
81
|
+
|
|
82
|
+
const loc = parseSourceAttr(attrs[SOURCE_ATTR]);
|
|
83
|
+
if (!loc) return fallback;
|
|
84
|
+
|
|
85
|
+
return { screenId: fallback.screenId, sourceFile: loc.file, sourceLine: loc.line };
|
|
86
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// @solhun/feedback-kit-core — 통합 계약(공개 타입)
|
|
2
|
+
//
|
|
3
|
+
// 이 파일에 정의된 타입들은 호스트(웹/앱)와 수집 백엔드가 모두 합의한 "제보 계약"이다.
|
|
4
|
+
// 어댑터(FeedbackAdapter)와 위젯은 이 타입들만 안다. 내부 구현(큐/UUID/진단 버퍼)은
|
|
5
|
+
// 비공개 모듈로 숨긴다.
|
|
6
|
+
//
|
|
7
|
+
// 주의: 코어는 플랫폼 무관(순수 TS)이어야 한다. document/window/react-native를 직접
|
|
8
|
+
// 참조하지 않는다. 글로벌(fetch/XMLHttpRequest/console/crypto)은 globalThis로만 접근한다.
|
|
9
|
+
|
|
10
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
11
|
+
// 종류 / 우선순위
|
|
12
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
/** 제보 종류. `report`=일반 의견, `annotation`=화면 위 핀 찍힌 주석. */
|
|
15
|
+
export type FeedbackKind = "report" | "annotation";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 우선순위.
|
|
19
|
+
* - unset: 사용자가 고르지 않음(기본)
|
|
20
|
+
* - normal / high / urgent: 사용자가 명시적으로 선택
|
|
21
|
+
*/
|
|
22
|
+
export type FeedbackPriority = "unset" | "normal" | "high" | "urgent";
|
|
23
|
+
|
|
24
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
25
|
+
// 제보 본문
|
|
26
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/** 스크린샷 캡처 결과. base64 인코딩된 이미지 데이터 + MIME. 없으면 null. */
|
|
29
|
+
export interface FeedbackScreenshot {
|
|
30
|
+
base64: string;
|
|
31
|
+
contentType: "image/png" | "image/jpeg";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 핀 좌표. 스크린샷 이미지 기준 0~1 정규화 실수.
|
|
36
|
+
* (0,0)=좌상단, (1,1)=우하단. 핀이 없으면 null.
|
|
37
|
+
*/
|
|
38
|
+
export interface FeedbackPin {
|
|
39
|
+
x: number;
|
|
40
|
+
y: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 핀이 찍힌 DOM/RN 요소 정보(선택). 호스트가 채워 넣는다. */
|
|
44
|
+
export interface ElementInfo {
|
|
45
|
+
tag: string;
|
|
46
|
+
id: string | null;
|
|
47
|
+
className: string | null;
|
|
48
|
+
/** 잘린 가시 텍스트(너무 길면 자른다). */
|
|
49
|
+
text: string | null;
|
|
50
|
+
/** 뷰포트/스크린샷 기준 bounding box. 알 수 없으면 null. */
|
|
51
|
+
boundingBox: { x: number; y: number; width: number; height: number } | null;
|
|
52
|
+
/** 기타 속성. 값은 문자열로 직렬화한다. */
|
|
53
|
+
attributes: Record<string, string>;
|
|
54
|
+
/**
|
|
55
|
+
* 요소를 다시 찾기 위한 CSS 선택자 경로(웹 전용). 앱처럼 DOM 이 없는 곳은 생략한다.
|
|
56
|
+
* 선택 필드로 둔 이유: 이 값을 못 구해도 제보 자체는 성립해야 하기 때문이다.
|
|
57
|
+
*/
|
|
58
|
+
selector?: string | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
62
|
+
// 사용자 / 화면 컨텍스트
|
|
63
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 호스트가 `getUser` 로 넘기는 제보자 신원.
|
|
67
|
+
* id는 필수(게스트면 영구 게스트 식별자). 나머지는 선택.
|
|
68
|
+
*/
|
|
69
|
+
export interface FeedbackUser {
|
|
70
|
+
id: string;
|
|
71
|
+
name?: string;
|
|
72
|
+
email?: string;
|
|
73
|
+
role?: string;
|
|
74
|
+
/** 로그인 사용자가 아니라 게스트면 true. 생략하면 로그인 사용자로 본다. */
|
|
75
|
+
isGuest?: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 제보에 실제로 실리는, 해석이 끝난 사용자.
|
|
80
|
+
*
|
|
81
|
+
* **게스트도 객체를 채운다** — `isGuest: true` + 기기에 영구 저장된 게스트 식별자.
|
|
82
|
+
* 전체가 `null`인 경우는 게스트 식별자조차 아직 없는 최초 실행 순간뿐이다
|
|
83
|
+
* (저장소 접근이 실패해 id를 만들지도 읽지도 못한 상태).
|
|
84
|
+
*/
|
|
85
|
+
export interface ContextUser {
|
|
86
|
+
id: string | null;
|
|
87
|
+
email: string | null;
|
|
88
|
+
isGuest: boolean;
|
|
89
|
+
name?: string;
|
|
90
|
+
role?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 빌드 플러그인이 심는 화면↔소스파일 매핑. 후속 wave에서 채워진다.
|
|
95
|
+
* 코어는 통과만 한다(값을 해석하지 않는다).
|
|
96
|
+
*/
|
|
97
|
+
export interface SourceMapping {
|
|
98
|
+
screenId: string | null;
|
|
99
|
+
sourceFile: string | null;
|
|
100
|
+
/**
|
|
101
|
+
* 소스 파일 안의 줄 번호(선택).
|
|
102
|
+
*
|
|
103
|
+
* 요소 단위 매핑(빌드 플러그인이 심는 `data-fk-source`)에서만 채워진다.
|
|
104
|
+
* 화면 단위 매핑에는 줄 번호가 없으므로 생략되거나 null 이다.
|
|
105
|
+
*/
|
|
106
|
+
sourceLine?: number | null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
110
|
+
// 진단 링버퍼 항목
|
|
111
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 네트워크 기록 1건.
|
|
115
|
+
*
|
|
116
|
+
* 기록 항목은 **메서드·URL·상태코드·소요시간까지**다.
|
|
117
|
+
* `Authorization` 헤더·쿠키·요청 본문·응답 본문은 어떤 경우에도 담기지 않는다
|
|
118
|
+
* (수집 코드가 애초에 읽지 않는다 — 필드가 없는 게 아니라 접근 자체를 안 한다).
|
|
119
|
+
*/
|
|
120
|
+
export interface DiagNetworkEntry {
|
|
121
|
+
method: string;
|
|
122
|
+
/** 쿼리스트링의 자격증명형 파라미터는 값이 가려진 상태. */
|
|
123
|
+
url: string;
|
|
124
|
+
/** 응답을 못 받았으면(네트워크 실패·중단) null. */
|
|
125
|
+
status: number | null;
|
|
126
|
+
durationMs: number;
|
|
127
|
+
/** 기록 시각(ISO 8601). */
|
|
128
|
+
at: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 콘솔 기록 1건. `console.warn` / `console.error` 만 대상. */
|
|
132
|
+
export interface DiagLogEntry {
|
|
133
|
+
level: "warn" | "error";
|
|
134
|
+
message: string;
|
|
135
|
+
at: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 제보에 실리는 진단 스냅샷.
|
|
140
|
+
*
|
|
141
|
+
* 두 배열은 **수집기가 실제로 모은 결과**다. 제출 시점에 비어 있는 것은 정상이고
|
|
142
|
+
* (그 세션에 요청·경고가 없었다는 뜻), 배선이 안 됐을 때는 배열이 아니라
|
|
143
|
+
* 컨텍스트의 `diagnostics` 자체가 `null`이 된다. 그래서 "빈 배열"과
|
|
144
|
+
* "수집 안 함"이 페이로드에서 구분된다.
|
|
145
|
+
*/
|
|
146
|
+
export interface DiagnosticsPayload {
|
|
147
|
+
network: DiagNetworkEntry[];
|
|
148
|
+
logs: DiagLogEntry[];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
152
|
+
// 앱/웹 전용 컨텍스트
|
|
153
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
/** 앱 빌드 정보. 어느 빌드에서 났는지 특정하는 데 쓴다. */
|
|
156
|
+
export interface AppInfo {
|
|
157
|
+
version: string | null;
|
|
158
|
+
channel: string | null;
|
|
159
|
+
updateId: string | null;
|
|
160
|
+
runtimeVersion: string | null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 기기 정보. **화면 크기는 여기가 아니라 `display` 다.** */
|
|
164
|
+
export interface DeviceInfo {
|
|
165
|
+
model: string | null;
|
|
166
|
+
osName: string | null;
|
|
167
|
+
osVersion: string | null;
|
|
168
|
+
deviceType: string | null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 화면 크기/배율. */
|
|
172
|
+
export interface DisplayInfo {
|
|
173
|
+
width: number | null;
|
|
174
|
+
height: number | null;
|
|
175
|
+
pixelRatio: number | null;
|
|
176
|
+
fontScale: number | null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 앱(React Native) 전용 컨텍스트. */
|
|
180
|
+
export interface NativeContext {
|
|
181
|
+
/** 화면↔파일 매핑이 있을 때의 소스 파일 경로. 매핑이 없으면 null. */
|
|
182
|
+
screenPath: string | null;
|
|
183
|
+
/** 전체 라우트 스택. 바깥(루트)부터 안쪽(현재 화면) 순서. */
|
|
184
|
+
navPath: string[];
|
|
185
|
+
/** 현재 화면의 파라미터. JSON 왕복으로 함수·순환참조를 제거한 값. */
|
|
186
|
+
routeParams: Record<string, unknown> | null;
|
|
187
|
+
appInfo: AppInfo;
|
|
188
|
+
device: DeviceInfo;
|
|
189
|
+
display: DisplayInfo;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** 웹 전용 컨텍스트. */
|
|
193
|
+
export interface WebContext {
|
|
194
|
+
viewport: {
|
|
195
|
+
width: number | null;
|
|
196
|
+
height: number | null;
|
|
197
|
+
devicePixelRatio: number | null;
|
|
198
|
+
};
|
|
199
|
+
/** userAgent 원문(파싱하지 않고 그대로 싣는다). */
|
|
200
|
+
userAgent: string | null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* 제보가 만들어진 환경 컨텍스트. 사용자 신원(user)도 여기에 포함된다.
|
|
205
|
+
* (FeedbackReport의 8개 스펙 필드를 건드리지 않고 신원을 전달하기 위해 context 안에 둔다.)
|
|
206
|
+
*
|
|
207
|
+
* 이 타입은 **수집까지**를 정의한다. 여기 담긴 값을 어느 필드에 실어 보낼지는
|
|
208
|
+
* 어댑터가 정한다(코어는 특정 어댑터의 필드명을 모른다).
|
|
209
|
+
*/
|
|
210
|
+
export interface FeedbackContext {
|
|
211
|
+
/** 앱/제품 식별자. config.app에서 온다. */
|
|
212
|
+
app: string;
|
|
213
|
+
/** 현재 화면 이름/경로(navigationRef에서 채움). 없으면 null. */
|
|
214
|
+
screen: string | null;
|
|
215
|
+
/** 현재 URL(웹) 또는 딥링크(앱). 없으면 null. */
|
|
216
|
+
url: string | null;
|
|
217
|
+
/** 세션 식별자(UUID). 위젯 인스턴스 단위로 하나. */
|
|
218
|
+
sessionId: string;
|
|
219
|
+
/** 제출 시점에 결정된 사용자. 게스트도 객체를 채운다. */
|
|
220
|
+
user: ContextUser | null;
|
|
221
|
+
/** 빌드 플러그인 매핑(선택). 없으면 screenId/sourceFile 이 null. */
|
|
222
|
+
source: SourceMapping;
|
|
223
|
+
/** 실행 플랫폼. */
|
|
224
|
+
platform: "web" | "native" | "unknown";
|
|
225
|
+
/** IANA 타임존 이름(예: `Asia/Seoul`). 알 수 없으면 null. */
|
|
226
|
+
timezone: string | null;
|
|
227
|
+
/** 클라이언트 기준 제출 시각(ISO 8601). */
|
|
228
|
+
clientTimestamp: string;
|
|
229
|
+
/** 앱 전용 컨텍스트. 웹이거나 공급자가 없으면 null. */
|
|
230
|
+
native: NativeContext | null;
|
|
231
|
+
/** 웹 전용 컨텍스트. 앱이거나 공급자가 없으면 null. */
|
|
232
|
+
web: WebContext | null;
|
|
233
|
+
/** 진단 링버퍼 스냅샷. 수집이 배선되지 않았으면 null. */
|
|
234
|
+
diagnostics: DiagnosticsPayload | null;
|
|
235
|
+
/** 자유 형식 추가 메타. 직렬화 가능한 값만. */
|
|
236
|
+
extra: Record<string, unknown>;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
240
|
+
// 제보 / 제출 결과
|
|
241
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* 하나의 피드백 제보. 통합 계약의 핵심.
|
|
245
|
+
* clientSubmissionId / context / createdAt 은 코어가 채운다.
|
|
246
|
+
* 나머지는 호스트가 submit() 호출 시 제공한다.
|
|
247
|
+
*/
|
|
248
|
+
export interface FeedbackReport {
|
|
249
|
+
/** 클라이언트에서 만든 UUID v4. 멱등 키(중복 제출 식별용). */
|
|
250
|
+
clientSubmissionId: string;
|
|
251
|
+
kind: FeedbackKind;
|
|
252
|
+
comment: string;
|
|
253
|
+
priority: FeedbackPriority;
|
|
254
|
+
screenshot: FeedbackScreenshot | null;
|
|
255
|
+
pin: FeedbackPin | null;
|
|
256
|
+
element: ElementInfo | null;
|
|
257
|
+
context: FeedbackContext;
|
|
258
|
+
/** 제출 시각(ISO 8601). */
|
|
259
|
+
createdAt: string;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* 어댑터 제출 결과.
|
|
264
|
+
* - ok: 백엔드가 정상 수락. **HTTP 2xx 만으로는 부족하다** — 응답 본문의 `ok=true`까지
|
|
265
|
+
* 확인한 뒤에만 true 여야 한다(2xx + ok=false 는 실패로 취급).
|
|
266
|
+
* - id: 백엔드가 부여한 제보 id(수락 안 됐으면 null). 표시용이며 로컬 키로 쓰지 않는다.
|
|
267
|
+
* - retryable: 일시적 실패면 true(코어가 재시도).
|
|
268
|
+
* - retryAfterMs: 백엔드가 권장한 대기 시간. 없으면 null(코어가 백오프로 계산).
|
|
269
|
+
* - rateLimited: 429(요청 한도 초과)면 true. 재시도 간격의 일반 상한(5분)을 넘겨
|
|
270
|
+
* 최소 10분 뒤에 다시 시도해야 한다는 신호. 정책 판단은 코어가 한다
|
|
271
|
+
* (어댑터는 "429였다"는 사실만 전달한다).
|
|
272
|
+
*/
|
|
273
|
+
export interface SubmitResult {
|
|
274
|
+
ok: boolean;
|
|
275
|
+
id: string | null;
|
|
276
|
+
retryable: boolean;
|
|
277
|
+
retryAfterMs: number | null;
|
|
278
|
+
rateLimited?: boolean;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
282
|
+
// 저장소 / 어댑터 인터페이스 (호스트가 구현)
|
|
283
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* 영속 저장소 인터페이스. 코어는 인터페이스만 안다.
|
|
287
|
+
* - web: localStorage 구현 제공
|
|
288
|
+
* - native: AsyncStorage 구현 제공
|
|
289
|
+
* 큐/게스트 식별자가 이 저장소에 의존한다 → 저장소를 바꿔도 동작이 동일(TC4).
|
|
290
|
+
*/
|
|
291
|
+
export interface FeedbackStorage {
|
|
292
|
+
get(key: string): Promise<string | null>;
|
|
293
|
+
set(key: string, value: string): Promise<void>;
|
|
294
|
+
remove(key: string): Promise<void>;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** 제보를 수집 백엔드로 보내는 어댑터. 호스트(또는 SDK)가 구현. */
|
|
298
|
+
export interface FeedbackAdapter {
|
|
299
|
+
submit(report: FeedbackReport): Promise<SubmitResult>;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
303
|
+
// 설정 / 콜백 타입
|
|
304
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
/** 제출 시점에 호출돼 사용자를 반환. 동기/비동기 모두 허용. */
|
|
307
|
+
export type GetUserFn = () => FeedbackUser | Promise<FeedbackUser>;
|
|
308
|
+
|
|
309
|
+
/** 현재 화면(라우트/스크린 이름)을 반환. 없으면 null. */
|
|
310
|
+
export type GetCurrentScreenFn = () => string | null;
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* 진단 버퍼가 요청을 캡처할지 결정하는 매처.
|
|
314
|
+
* true 반환 = 이 요청은 캡처에서 제외(예: 위젯 자신의 전송 요청).
|
|
315
|
+
*/
|
|
316
|
+
export interface DiagRequestRef {
|
|
317
|
+
url: string;
|
|
318
|
+
method: string;
|
|
319
|
+
}
|
|
320
|
+
export type DiagnosticsExcludeMatcher = (req: DiagRequestRef) => boolean;
|
package/src/uuid.test.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// clientSubmissionId 생성기 검증.
|
|
2
|
+
//
|
|
3
|
+
// 담당 TC: "clientSubmissionId 가 표준 API 없이도 생성된다"(w1 패키지 구조).
|
|
4
|
+
//
|
|
5
|
+
// 이 값은 서버가 쓰는 **멱등 키**다. 재시도할 때마다 값이 흔들리면 같은 제보가 서버에
|
|
6
|
+
// 여러 건 쌓인다. 그래서 검증 대상은 "그럴듯한 문자열이 나온다"가 아니라
|
|
7
|
+
// (1) 형식이 RFC 4122 v4 이고 (2) crypto 가 아예 없는 런타임에서도 나오고
|
|
8
|
+
// (3) 매번 다른 값이라는 세 가지다.
|
|
9
|
+
//
|
|
10
|
+
// crypto 를 지웠다 되돌리는 테스트라 globalThis 를 직접 만진다. 각 케이스가 끝나면
|
|
11
|
+
// 원래 서술자를 복원한다(다른 파일이 crypto 를 쓰기 때문에 누수되면 안 된다).
|
|
12
|
+
|
|
13
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
14
|
+
|
|
15
|
+
import { uuidv4 } from "./uuid.js";
|
|
16
|
+
|
|
17
|
+
const UUID_V4 =
|
|
18
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
19
|
+
|
|
20
|
+
const g = globalThis as Record<string, unknown>;
|
|
21
|
+
const originalCrypto = Object.getOwnPropertyDescriptor(globalThis, "crypto");
|
|
22
|
+
|
|
23
|
+
function restoreCrypto(): void {
|
|
24
|
+
if (originalCrypto) {
|
|
25
|
+
Object.defineProperty(globalThis, "crypto", originalCrypto);
|
|
26
|
+
} else {
|
|
27
|
+
delete g.crypto;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 전역 crypto 를 통째로 교체한다(undefined 면 없는 런타임을 흉내낸다). */
|
|
32
|
+
function setCrypto(value: unknown): void {
|
|
33
|
+
Object.defineProperty(globalThis, "crypto", {
|
|
34
|
+
value,
|
|
35
|
+
configurable: true,
|
|
36
|
+
writable: true,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
restoreCrypto();
|
|
42
|
+
vi.restoreAllMocks();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("uuidv4", () => {
|
|
46
|
+
it("RFC 4122 v4 형식을 지킨다(버전 4 · 변형 10xx)", () => {
|
|
47
|
+
for (let i = 0; i < 50; i++) {
|
|
48
|
+
expect(uuidv4()).toMatch(UUID_V4);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("crypto 가 아예 없어도 유효한 값을 만든다", () => {
|
|
53
|
+
setCrypto(undefined);
|
|
54
|
+
|
|
55
|
+
const id = uuidv4();
|
|
56
|
+
|
|
57
|
+
expect(id).toMatch(UUID_V4);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("crypto 는 있는데 getRandomValues 가 없어도 유효한 값을 만든다", () => {
|
|
61
|
+
// 보안 컨텍스트가 아닌 웹뷰처럼 crypto 객체는 있지만 알맹이가 빠진 경우.
|
|
62
|
+
setCrypto({});
|
|
63
|
+
|
|
64
|
+
const id = uuidv4();
|
|
65
|
+
|
|
66
|
+
expect(id).toMatch(UUID_V4);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("getRandomValues 가 있으면 그걸 쓴다", () => {
|
|
70
|
+
const getRandomValues = vi.fn((arr: Uint8Array) => {
|
|
71
|
+
arr.fill(0xab);
|
|
72
|
+
return arr;
|
|
73
|
+
});
|
|
74
|
+
setCrypto({ getRandomValues });
|
|
75
|
+
|
|
76
|
+
const id = uuidv4();
|
|
77
|
+
|
|
78
|
+
expect(getRandomValues).toHaveBeenCalledTimes(1);
|
|
79
|
+
// 0xab 로 채운 뒤 버전·변형 비트만 덮이므로 나머지 자리는 전부 ab 다.
|
|
80
|
+
expect(id).toBe("abababab-abab-4bab-abab-abababababab");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("getRandomValues 가 this 를 요구해도 깨지지 않는다", () => {
|
|
84
|
+
// 실제 브라우저 구현은 crypto 에 바인딩되지 않은 채 호출하면 예외를 던진다.
|
|
85
|
+
const cryptoObj = {
|
|
86
|
+
marker: "real-crypto",
|
|
87
|
+
getRandomValues(this: { marker?: string }, arr: Uint8Array) {
|
|
88
|
+
if (this?.marker !== "real-crypto") {
|
|
89
|
+
throw new TypeError("Illegal invocation");
|
|
90
|
+
}
|
|
91
|
+
arr.fill(0x11);
|
|
92
|
+
return arr;
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
setCrypto(cryptoObj);
|
|
96
|
+
|
|
97
|
+
expect(() => uuidv4()).not.toThrow();
|
|
98
|
+
expect(uuidv4()).toMatch(UUID_V4);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("매번 다른 값이다(멱등 키가 겹치면 제보가 서로를 덮는다)", () => {
|
|
102
|
+
const ids = new Set<string>();
|
|
103
|
+
for (let i = 0; i < 500; i++) ids.add(uuidv4());
|
|
104
|
+
|
|
105
|
+
expect(ids.size).toBe(500);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("crypto 가 없는 폴백 경로에서도 값이 겹치지 않는다", () => {
|
|
109
|
+
setCrypto(undefined);
|
|
110
|
+
|
|
111
|
+
const ids = new Set<string>();
|
|
112
|
+
for (let i = 0; i < 500; i++) ids.add(uuidv4());
|
|
113
|
+
|
|
114
|
+
expect(ids.size).toBe(500);
|
|
115
|
+
});
|
|
116
|
+
});
|