@solhun/feedback-kit-web 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/src/index.ts ADDED
@@ -0,0 +1,97 @@
1
+ // @solhun/feedback-kit-web
2
+ //
3
+ // 웹용 피드백 위젯 엔트리.
4
+ //
5
+ // 화면 지도·리포트 모달·핀 좌표 계산은 전부 코어에 있다. 여기 있는 건 웹에서만 뜻이 있는 것들 —
6
+ // DOM 요소를 제보에 실을 형태로 옮기고(`element-info`), 지목 모드와 마커를 브라우저 저장소에
7
+ // 남기고(`marker-store`), 페이지 클릭을 가로채 주석을 다는 것(`picking`) — 뿐이다.
8
+
9
+ export type {
10
+ FeedbackReport,
11
+ SubmitResult,
12
+ FeedbackContext,
13
+ FeedbackUser,
14
+ FeedbackStorage,
15
+ FeedbackAdapter,
16
+ ElementInfo,
17
+ FeedbackPin,
18
+ } from "@solhun/feedback-kit-core";
19
+
20
+ // 설정은 코어가 정의한다. 웹/앱이 각자 다른 설정 타입을 갖지 않는 게 중요해서
21
+ // (같은 설정을 두 곳에 적으면 갈라진다) 여기서는 다시 내보내기만 한다.
22
+ export type {
23
+ FeedbackConfig,
24
+ ResolvedConfig,
25
+ Visibility,
26
+ VisibilityEnv,
27
+ VisibilityFn,
28
+ WidgetPosition,
29
+ } from "@solhun/feedback-kit-core";
30
+ export { resolveConfig, shouldShowWidget } from "@solhun/feedback-kit-core";
31
+
32
+ // 빌드 플러그인이 심은 소스 경로를 읽는 쪽. 플러그인이 없으면 조용히 null 이다.
33
+ export { parseSourceAttr, SOURCE_ATTR, sourceFromElement } from "@solhun/feedback-kit-core";
34
+
35
+ // 코어의 헤드리스 위젯 계층. 웹 컴포넌트가 이 상태기계를 그대로 그린다.
36
+ export type {
37
+ ModalSubmitStatus,
38
+ ReportModalState,
39
+ ScreenshotStatus,
40
+ WidgetScreen,
41
+ WidgetState,
42
+ } from "@solhun/feedback-kit-core";
43
+ export {
44
+ COMMENT_MAX_CHARS,
45
+ COMMENT_REQUIRED_MESSAGE,
46
+ COMMENT_TOO_LONG_MESSAGE,
47
+ denormalizePin,
48
+ FLOATING_BUTTON_ID,
49
+ MODAL_ACTION_PICK,
50
+ normalizePin,
51
+ ReportModalController,
52
+ SCREENSHOT_FAILED_MESSAGE,
53
+ SUBMIT_DONE_MESSAGE,
54
+ SUBMIT_PENDING_MESSAGE,
55
+ WidgetController,
56
+ } from "@solhun/feedback-kit-core";
57
+
58
+ // ── 웹 전용 ────────────────────────────────────────────────────────────────
59
+
60
+ export {
61
+ cssSelectorPath,
62
+ describeElement,
63
+ ELEMENT_TEXT_MAX_CHARS,
64
+ SELECTOR_MAX_DEPTH,
65
+ visibleText,
66
+ } from "./element-info.js";
67
+
68
+ export type { MarkerStatus, StorageLike, StoredMarker } from "./marker-store.js";
69
+ export {
70
+ MARKER_KEY_PREFIX,
71
+ MarkerStore,
72
+ MAX_MARKERS_PER_PATH,
73
+ PICKING_MODE_KEY,
74
+ } from "./marker-store.js";
75
+
76
+ export type {
77
+ AnnotationPopupState,
78
+ ElementPickingOpts,
79
+ PickingListener,
80
+ PickingQueueLike,
81
+ PickingState,
82
+ } from "./picking.js";
83
+ export { ElementPickingController, OWN_UI_ATTR } from "./picking.js";
84
+
85
+ export { captureWebScreenshot, isOwnUi, reencodeWebScreenshot } from "./screenshot.js";
86
+
87
+ export type { WebWidget, WebWidgetOpts } from "./widget.js";
88
+ export { createWebWidget } from "./widget.js";
89
+
90
+ export type { FeedbackKitProps } from "./feedback-kit.js";
91
+ export { FeedbackKit } from "./feedback-kit.js";
92
+
93
+ // 웹 기본 저장소(localStorage). 앱마다 다시 쓰지 않게 패키지가 제공한다.
94
+ export { createWebStorage } from "./storage.js";
95
+
96
+ // 웹 기본 컨텍스트 제공자(getUrl 등). 붙이는 쪽이 잊으면 제보가 버려진다.
97
+ export { webContextProviders } from "./providers.js";
@@ -0,0 +1,153 @@
1
+ // 요소 지목 모드와 마커를 브라우저 저장소에 남긴다.
2
+ //
3
+ // 왜 저장하나: 지목 모드는 페이지를 옮겨 다니며 쓰는 기능이라 새로고침·이동에 살아남아야 하고,
4
+ // 찍은 마커는 같은 경로로 돌아왔을 때 원래 자리에 다시 보여야 한다.
5
+ //
6
+ // 키 이름은 `feedback-kit:` 로 시작하는 우리 고유 네임스페이스를 쓴다. 남의 위젯과 키가 겹치면
7
+ // 서로의 상태를 지우게 된다.
8
+
9
+ /** 지목 모드 on/off 플래그. */
10
+ export const PICKING_MODE_KEY = "feedback-kit:picking-mode";
11
+
12
+ /** 경로별 마커 목록. 실제 키는 여기에 pathname 을 이어 붙인다. */
13
+ export const MARKER_KEY_PREFIX = "feedback-kit:markers:";
14
+
15
+ /** 한 경로에 남겨 둘 마커 상한. 넘으면 오래된 것부터 버린다. */
16
+ export const MAX_MARKERS_PER_PATH = 50;
17
+
18
+ /** 마커에 보이는 전송 상태. `sending` → `done`, 실패하면 `pending`. */
19
+ export type MarkerStatus = "sending" | "done" | "pending";
20
+
21
+ export interface StoredMarker {
22
+ /** 제보의 `clientSubmissionId` 와 같다 — 전송 상태를 되짚어 갱신하려고 맞춰 둔다. */
23
+ id: string;
24
+ /** 뷰포트 기준 상대 좌표(0~1). 해상도가 달라도 같은 지점을 가리킨다. */
25
+ x: number;
26
+ y: number;
27
+ selector: string | null;
28
+ comment: string;
29
+ status: MarkerStatus;
30
+ /** 생성 시각(ISO). 상한을 넘겼을 때 무엇을 버릴지 정하는 기준. */
31
+ at: string;
32
+ }
33
+
34
+ /** `localStorage` 가 만족하는 최소 계약. 테스트에서 갈아 끼운다. */
35
+ export interface StorageLike {
36
+ getItem(key: string): string | null;
37
+ setItem(key: string, value: string): void;
38
+ removeItem(key: string): void;
39
+ }
40
+
41
+ function defaultStorage(): StorageLike | null {
42
+ try {
43
+ const ls = (globalThis as { localStorage?: StorageLike }).localStorage;
44
+ return ls ?? null;
45
+ } catch {
46
+ // 사파리 프라이빗 모드처럼 접근 자체가 던지는 환경이 있다.
47
+ return null;
48
+ }
49
+ }
50
+
51
+ function markerKey(pathname: string): string {
52
+ return `${MARKER_KEY_PREFIX}${pathname}`;
53
+ }
54
+
55
+ /**
56
+ * 저장소 접근을 한 곳으로 모은다.
57
+ *
58
+ * 저장소가 없거나(SSR) 던지는 환경(프라이빗 모드·용량 초과)에서도 위젯이 죽으면 안 되므로
59
+ * 모든 접근을 try/catch 로 감싸고 실패는 조용히 흘린다. 저장 실패는 "마커가 안 남는다" 정도의
60
+ * 불편이지 제보 자체를 막을 이유가 아니다.
61
+ */
62
+ export class MarkerStore {
63
+ private readonly storage: StorageLike | null;
64
+
65
+ constructor(storage?: StorageLike | null) {
66
+ this.storage = storage === undefined ? defaultStorage() : storage;
67
+ }
68
+
69
+ private read(key: string): string | null {
70
+ try {
71
+ return this.storage?.getItem(key) ?? null;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ private write(key: string, value: string): void {
78
+ try {
79
+ this.storage?.setItem(key, value);
80
+ } catch {
81
+ /* 저장 실패는 흘린다 */
82
+ }
83
+ }
84
+
85
+ private drop(key: string): void {
86
+ try {
87
+ this.storage?.removeItem(key);
88
+ } catch {
89
+ /* 삭제 실패도 흘린다 */
90
+ }
91
+ }
92
+
93
+ // ── 지목 모드 ─────────────────────────────────────────────────────────────
94
+
95
+ isPickingActive(): boolean {
96
+ return this.read(PICKING_MODE_KEY) === "1";
97
+ }
98
+
99
+ setPickingActive(active: boolean): void {
100
+ if (active) this.write(PICKING_MODE_KEY, "1");
101
+ else this.drop(PICKING_MODE_KEY);
102
+ }
103
+
104
+ // ── 마커 ──────────────────────────────────────────────────────────────────
105
+
106
+ list(pathname: string): StoredMarker[] {
107
+ const raw = this.read(markerKey(pathname));
108
+ if (!raw) return [];
109
+ try {
110
+ const parsed: unknown = JSON.parse(raw);
111
+ if (!Array.isArray(parsed)) return [];
112
+ // 남이 쓴 값이나 옛 포맷이 섞여 있어도 위젯이 깨지지 않게 형태를 확인하고 거른다.
113
+ return parsed.filter((item): item is StoredMarker => {
114
+ if (typeof item !== "object" || item === null) return false;
115
+ const m = item as Partial<StoredMarker>;
116
+ return (
117
+ typeof m.id === "string" && typeof m.x === "number" && typeof m.y === "number"
118
+ );
119
+ });
120
+ } catch {
121
+ return [];
122
+ }
123
+ }
124
+
125
+ /** 같은 id 가 있으면 덮어쓰고, 없으면 뒤에 붙인다. */
126
+ upsert(pathname: string, marker: StoredMarker): StoredMarker[] {
127
+ const current = this.list(pathname);
128
+ const index = current.findIndex((m) => m.id === marker.id);
129
+ if (index >= 0) current[index] = marker;
130
+ else current.push(marker);
131
+
132
+ const trimmed =
133
+ current.length > MAX_MARKERS_PER_PATH
134
+ ? current.slice(current.length - MAX_MARKERS_PER_PATH)
135
+ : current;
136
+ this.write(markerKey(pathname), JSON.stringify(trimmed));
137
+ return trimmed;
138
+ }
139
+
140
+ /** 전송 상태만 바꾼다. 없는 id 면 아무것도 하지 않는다. */
141
+ updateStatus(pathname: string, id: string, status: MarkerStatus): StoredMarker[] {
142
+ const current = this.list(pathname);
143
+ const index = current.findIndex((m) => m.id === id);
144
+ if (index < 0) return current;
145
+ current[index] = { ...current[index], status };
146
+ this.write(markerKey(pathname), JSON.stringify(current));
147
+ return current;
148
+ }
149
+
150
+ clear(pathname: string): void {
151
+ this.drop(markerKey(pathname));
152
+ }
153
+ }
@@ -0,0 +1,249 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import {
4
+ MODAL_ACTION_PICK,
5
+ MODAL_ACTION_PIN,
6
+ WidgetController,
7
+ sourceFromElement,
8
+ type FeedbackReport,
9
+ type QueueStatus,
10
+ type ReportParts,
11
+ type SubmitOutcome,
12
+ } from "@solhun/feedback-kit-core";
13
+ import { MarkerStore, type StorageLike } from "./marker-store.js";
14
+ import { ElementPickingController } from "./picking.js";
15
+
16
+ class MemoryStorage implements StorageLike {
17
+ private readonly values = new Map<string, string>();
18
+ getItem(key: string): string | null { return this.values.get(key) ?? null; }
19
+ setItem(key: string, value: string): void { this.values.set(key, value); }
20
+ removeItem(key: string): void { this.values.delete(key); }
21
+ }
22
+
23
+ class TestQueue {
24
+ readonly submitted: FeedbackReport[] = [];
25
+ private readonly listeners = new Set<(status: QueueStatus) => void>();
26
+ private readonly outcomes = new Map<
27
+ string,
28
+ Pick<SubmitOutcome, "delivered" | "id">
29
+ >();
30
+ outcome: SubmitOutcome = { delivered: true, id: "server-1", queued: false };
31
+ pending = 0;
32
+ deferred: Promise<SubmitOutcome> | null = null;
33
+
34
+ async submit(report: FeedbackReport): Promise<SubmitOutcome> {
35
+ this.submitted.push(report);
36
+ const outcome = this.deferred ? await this.deferred : this.outcome;
37
+ if (outcome.queued) {
38
+ this.pending += 1;
39
+ this.emit();
40
+ } else {
41
+ this.outcomes.set(report.clientSubmissionId, outcome);
42
+ }
43
+ return outcome;
44
+ }
45
+
46
+ async retryNow(): Promise<void> {}
47
+ async size(): Promise<number> { return this.pending; }
48
+
49
+ subscribe(listener: (status: QueueStatus) => void): () => void {
50
+ this.listeners.add(listener);
51
+ listener(this.status());
52
+ return () => this.listeners.delete(listener);
53
+ }
54
+
55
+ getOutcome(clientSubmissionId: string): Pick<SubmitOutcome, "delivered" | "id"> | null {
56
+ return this.outcomes.get(clientSubmissionId) ?? null;
57
+ }
58
+
59
+ settle(
60
+ clientSubmissionId: string,
61
+ outcome: Pick<SubmitOutcome, "delivered" | "id">,
62
+ remaining: number,
63
+ ): void {
64
+ this.outcomes.set(clientSubmissionId, outcome);
65
+ this.pending = remaining;
66
+ this.emit();
67
+ }
68
+
69
+ private status(): QueueStatus {
70
+ return { state: this.pending > 0 ? "pending" : "idle", pending: this.pending, lastError: null };
71
+ }
72
+
73
+ private emit(): void {
74
+ const status = this.status();
75
+ for (const listener of this.listeners) listener(status);
76
+ }
77
+ }
78
+
79
+ function reportFactory() {
80
+ let sequence = 0;
81
+ return async (parts: ReportParts): Promise<FeedbackReport> => {
82
+ sequence += 1;
83
+ return {
84
+ clientSubmissionId: `annotation-${sequence}`,
85
+ ...parts,
86
+ context: {
87
+ app: "picking-test",
88
+ screen: "/",
89
+ url: null,
90
+ sessionId: "session",
91
+ user: null,
92
+ source: { screenId: null, sourceFile: null },
93
+ platform: "web",
94
+ timezone: "Asia/Seoul",
95
+ clientTimestamp: "2026-08-07T00:00:00.000Z",
96
+ native: null,
97
+ web: null,
98
+ diagnostics: null,
99
+ extra: {},
100
+ },
101
+ createdAt: `2026-08-07T00:00:0${sequence}.000Z`,
102
+ };
103
+ };
104
+ }
105
+
106
+ function button(label: string, withSource = false): HTMLButtonElement {
107
+ const element = document.createElement("button");
108
+ element.textContent = label;
109
+ element.id = `button-${document.body.childElementCount + 1}`;
110
+ if (withSource) element.setAttribute("data-fk-source", "src/Button.tsx:12:3");
111
+ element.getBoundingClientRect = () =>
112
+ ({ left: 10, top: 20, width: 120, height: 36 }) as DOMRect;
113
+ document.body.append(element);
114
+ return element;
115
+ }
116
+
117
+ function fixture(options: { queue?: TestQueue; path?: { current: string }; storage?: MemoryStorage } = {}) {
118
+ const queue = options.queue ?? new TestQueue();
119
+ const path = options.path ?? { current: "/a" };
120
+ const storage = options.storage ?? new MemoryStorage();
121
+ const store = new MarkerStore(storage);
122
+ const picking = new ElementPickingController({
123
+ queue,
124
+ createReport: reportFactory(),
125
+ store,
126
+ doc: document,
127
+ getPathname: () => path.current,
128
+ getViewport: () => ({ width: 1000, height: 500 }),
129
+ });
130
+ picking.start();
131
+ return { path, picking, queue, storage, store };
132
+ }
133
+
134
+ async function annotate(
135
+ picking: ElementPickingController,
136
+ element: Element,
137
+ comment: string,
138
+ point = { x: 100, y: 50 },
139
+ ): Promise<SubmitOutcome | null> {
140
+ picking.openAnnotation(element, point);
141
+ picking.setAnnotationComment(comment);
142
+ return picking.saveAnnotation();
143
+ }
144
+
145
+ describe("DP-259 요소 지목 모드", () => {
146
+ it("TC1 앱에서는 지목 토글이 노출되지 않는다", async () => {
147
+ const queue = new TestQueue();
148
+ const widget = new WidgetController({
149
+ queue,
150
+ createReport: reportFactory(),
151
+ platform: "native",
152
+ capture: () => ({ base64: "shot", contentType: "image/png" }),
153
+ });
154
+ await widget.openReport();
155
+
156
+ expect(widget.getState().modal.actions).toEqual([MODAL_ACTION_PIN]);
157
+ expect(widget.getState().modal.actions).not.toContain(MODAL_ACTION_PICK);
158
+ expect(widget.startPicking()).toBe(false);
159
+ widget.dispose();
160
+ });
161
+
162
+ it("TC2 마커에 전송 상태가 단계별로 보인다", async () => {
163
+ const queue = new TestQueue();
164
+ let finish!: (outcome: SubmitOutcome) => void;
165
+ queue.deferred = new Promise((resolve) => { finish = resolve; });
166
+ const { picking } = fixture({ queue });
167
+
168
+ const saving = annotate(picking, button("저장"), "상태 확인");
169
+ await vi.waitFor(() => expect(picking.getState().markers[0]?.status).toBe("sending"));
170
+ finish({ delivered: true, id: "server-1", queued: false });
171
+ await saving;
172
+
173
+ expect(picking.getState().markers[0]?.status).toBe("done");
174
+ picking.dispose();
175
+ });
176
+
177
+ it("TC3 같은 경로로 돌아오면 기존 마커가 다시 보인다", async () => {
178
+ const path = { current: "/original" };
179
+ const { picking } = fixture({ path });
180
+ await annotate(picking, button("첫째"), "주석 1");
181
+ await annotate(picking, button("둘째"), "주석 2");
182
+ expect(picking.getState().markers).toHaveLength(2);
183
+
184
+ path.current = "/other";
185
+ await vi.waitFor(() => expect(picking.getState().markers).toHaveLength(0));
186
+ path.current = "/original";
187
+ await vi.waitFor(() => expect(picking.getState().markers).toHaveLength(2));
188
+ picking.dispose();
189
+ });
190
+
191
+ it("TC4 주석에 요소 정보가 함께 담긴다", async () => {
192
+ const { picking, queue } = fixture();
193
+ await annotate(picking, button("결제하기"), "버튼 위치", { x: 250, y: 125 });
194
+
195
+ const report = queue.submitted[0]!;
196
+ expect(report.element?.selector).toContain("#button-");
197
+ expect(report.element?.text).toBe("결제하기");
198
+ expect(report.element?.boundingBox).toEqual({ x: 10, y: 20, width: 120, height: 36 });
199
+ expect(report.pin).toEqual({ x: 0.25, y: 0.25 });
200
+ picking.dispose();
201
+ });
202
+
203
+ it("TC5 소스 경로를 못 얻어도 주석은 정상 저장된다", async () => {
204
+ const { picking, queue } = fixture();
205
+ const outcome = await annotate(picking, button("플러그인 없음"), "정상 저장");
206
+
207
+ expect(outcome?.delivered).toBe(true);
208
+ expect(queue.submitted).toHaveLength(1);
209
+ expect(sourceFromElement(queue.submitted[0]?.element)).toEqual({
210
+ screenId: null,
211
+ sourceFile: null,
212
+ sourceLine: null,
213
+ });
214
+ expect(picking.getState().markers[0]?.status).toBe("done");
215
+ picking.dispose();
216
+ });
217
+
218
+ it("TC6 전송에 실패한 마커는 대기 표시로 남는다", async () => {
219
+ const queue = new TestQueue();
220
+ queue.outcome = { delivered: false, id: null, queued: true };
221
+ const { picking } = fixture({ queue });
222
+ await annotate(picking, button("오프라인"), "나중에 전송");
223
+
224
+ expect(picking.getState().markers).toHaveLength(1);
225
+ expect(picking.getState().markers[0]?.status).toBe("pending");
226
+ expect(await queue.size()).toBe(1);
227
+ picking.dispose();
228
+ });
229
+
230
+ it("다른 경로의 전송 완료를 현재 경로 마커에 잘못 적용하지 않는다", async () => {
231
+ const queue = new TestQueue();
232
+ queue.outcome = { delivered: false, id: null, queued: true };
233
+ const path = { current: "/a" };
234
+ const { picking } = fixture({ queue, path });
235
+ await annotate(picking, button("A"), "A 주석");
236
+ const firstId = queue.submitted[0]!.clientSubmissionId;
237
+
238
+ path.current = "/b";
239
+ picking.syncPath();
240
+ await annotate(picking, button("B"), "B 주석");
241
+ queue.settle(firstId, { delivered: true, id: "server-a" }, 1);
242
+
243
+ expect(picking.getState().markers[0]?.status).toBe("pending");
244
+ path.current = "/a";
245
+ picking.syncPath();
246
+ expect(picking.getState().markers[0]?.status).toBe("done");
247
+ picking.dispose();
248
+ });
249
+ });