@solhun/feedback-kit-web 0.1.0 → 0.2.1
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/dist/index.cjs +122 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +26 -7
- package/dist/index.d.ts +26 -7
- package/dist/index.js +120 -2
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/element-info.ts +0 -138
- package/src/feedback-kit.test.ts +0 -227
- package/src/feedback-kit.tsx +0 -778
- package/src/index.ts +0 -97
- package/src/marker-store.ts +0 -153
- package/src/picking.test.ts +0 -249
- package/src/picking.ts +0 -370
- package/src/providers.ts +0 -43
- package/src/screen-map.test.ts +0 -455
- package/src/screenshot.test.ts +0 -47
- package/src/screenshot.ts +0 -90
- package/src/storage.test.ts +0 -65
- package/src/storage.ts +0 -69
- package/src/widget.ts +0 -70
package/src/element-info.ts
DELETED
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
// 지목한 DOM 요소를 제보에 실을 수 있는 형태로 옮긴다.
|
|
2
|
-
//
|
|
3
|
-
// 목표는 "나중에 이 요소를 다시 찾을 수 있게" 하는 것이다. 그래서 태그·id·클래스만이 아니라
|
|
4
|
-
// 형제 순서까지 담은 CSS 선택자 경로를 함께 만든다. 못 만들어도 제보는 성립해야 하므로
|
|
5
|
-
// 이 파일의 함수들은 절대 던지지 않는다.
|
|
6
|
-
|
|
7
|
-
import type { ElementInfo } from "@solhun/feedback-kit-core";
|
|
8
|
-
|
|
9
|
-
/** 요소 텍스트를 담을 때의 상한. 본문 전체가 실려 페이로드가 붓는 걸 막는다. */
|
|
10
|
-
export const ELEMENT_TEXT_MAX_CHARS = 200;
|
|
11
|
-
|
|
12
|
-
/** 선택자 경로를 몇 단계까지 거슬러 올라갈지. 너무 깊으면 오히려 잘 깨진다. */
|
|
13
|
-
export const SELECTOR_MAX_DEPTH = 8;
|
|
14
|
-
|
|
15
|
-
/** 담아 둘 속성. 전부 담으면 인라인 style 같은 게 페이로드를 채운다. */
|
|
16
|
-
const KEPT_ATTRS = [
|
|
17
|
-
"id",
|
|
18
|
-
"class",
|
|
19
|
-
"name",
|
|
20
|
-
"type",
|
|
21
|
-
"role",
|
|
22
|
-
"href",
|
|
23
|
-
"aria-label",
|
|
24
|
-
"data-testid",
|
|
25
|
-
"data-fk-source",
|
|
26
|
-
];
|
|
27
|
-
|
|
28
|
-
function cssEscape(value: string): string {
|
|
29
|
-
const fn = (globalThis as { CSS?: { escape?: (v: string) => string } }).CSS?.escape;
|
|
30
|
-
if (typeof fn === "function") return fn(value);
|
|
31
|
-
// CSS.escape 가 없는 환경 대비 최소 폴백. 식별자에 쓰이면 곤란한 문자만 막는다.
|
|
32
|
-
return value.replace(/[^a-zA-Z0-9_-]/g, (ch) => `\\${ch}`);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** 요소가 형제들 사이에서 몇 번째 같은 태그인지. 1부터 센다. */
|
|
36
|
-
function nthOfType(el: Element): number {
|
|
37
|
-
const parent = el.parentElement;
|
|
38
|
-
if (!parent) return 1;
|
|
39
|
-
let index = 0;
|
|
40
|
-
for (const child of Array.from(parent.children)) {
|
|
41
|
-
if (child.tagName === el.tagName) {
|
|
42
|
-
index += 1;
|
|
43
|
-
if (child === el) return index;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
return 1;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** 같은 태그 형제가 둘 이상인가. 하나뿐이면 `:nth-of-type` 을 붙일 이유가 없다. */
|
|
50
|
-
function hasTypeSiblings(el: Element): boolean {
|
|
51
|
-
const parent = el.parentElement;
|
|
52
|
-
if (!parent) return false;
|
|
53
|
-
let count = 0;
|
|
54
|
-
for (const child of Array.from(parent.children)) {
|
|
55
|
-
if (child.tagName === el.tagName) {
|
|
56
|
-
count += 1;
|
|
57
|
-
if (count > 1) return true;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
return false;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* 요소까지의 CSS 선택자 경로.
|
|
65
|
-
*
|
|
66
|
-
* id 를 만나면 거기서 멈춘다 — 문서에서 유일하다고 가정할 수 있는 지점이라 그 위는 군더더기다.
|
|
67
|
-
* 클래스는 쓰지 않는다: 유틸리티 클래스(`px-4` 같은 것)가 섞이면 선택자가 길기만 하고
|
|
68
|
-
* 재현성은 오히려 떨어진다.
|
|
69
|
-
*/
|
|
70
|
-
export function cssSelectorPath(el: Element | null | undefined): string | null {
|
|
71
|
-
if (!el || typeof el.tagName !== "string") return null;
|
|
72
|
-
try {
|
|
73
|
-
const parts: string[] = [];
|
|
74
|
-
let node: Element | null = el;
|
|
75
|
-
let depth = 0;
|
|
76
|
-
|
|
77
|
-
while (node && depth < SELECTOR_MAX_DEPTH) {
|
|
78
|
-
const tag = node.tagName.toLowerCase();
|
|
79
|
-
if (tag === "html" || tag === "body") {
|
|
80
|
-
parts.unshift(tag);
|
|
81
|
-
break;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const id = node.getAttribute("id");
|
|
85
|
-
if (id) {
|
|
86
|
-
parts.unshift(`#${cssEscape(id)}`);
|
|
87
|
-
break; // id 위로는 올라갈 필요가 없다.
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
parts.unshift(hasTypeSiblings(node) ? `${tag}:nth-of-type(${nthOfType(node)})` : tag);
|
|
91
|
-
node = node.parentElement;
|
|
92
|
-
depth += 1;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
return parts.length > 0 ? parts.join(" > ") : null;
|
|
96
|
-
} catch {
|
|
97
|
-
return null;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** 눈에 보이는 텍스트를 한 줄로 눌러 담는다. */
|
|
102
|
-
export function visibleText(el: Element): string | null {
|
|
103
|
-
const raw = (el as HTMLElement).innerText ?? el.textContent ?? "";
|
|
104
|
-
const collapsed = raw.replace(/\s+/g, " ").trim();
|
|
105
|
-
if (collapsed === "") return null;
|
|
106
|
-
return collapsed.length > ELEMENT_TEXT_MAX_CHARS
|
|
107
|
-
? collapsed.slice(0, ELEMENT_TEXT_MAX_CHARS)
|
|
108
|
-
: collapsed;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** 지목된 요소를 `ElementInfo` 로 옮긴다. 실패해도 던지지 않는다. */
|
|
112
|
-
export function describeElement(el: Element | null | undefined): ElementInfo | null {
|
|
113
|
-
if (!el || typeof el.tagName !== "string") return null;
|
|
114
|
-
|
|
115
|
-
const attributes: Record<string, string> = {};
|
|
116
|
-
for (const name of KEPT_ATTRS) {
|
|
117
|
-
const value = el.getAttribute(name);
|
|
118
|
-
if (value !== null) attributes[name] = value;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
let boundingBox: ElementInfo["boundingBox"] = null;
|
|
122
|
-
try {
|
|
123
|
-
const rect = el.getBoundingClientRect();
|
|
124
|
-
boundingBox = { x: rect.left, y: rect.top, width: rect.width, height: rect.height };
|
|
125
|
-
} catch {
|
|
126
|
-
boundingBox = null;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
return {
|
|
130
|
-
tag: el.tagName.toLowerCase(),
|
|
131
|
-
id: el.getAttribute("id"),
|
|
132
|
-
className: el.getAttribute("class"),
|
|
133
|
-
text: visibleText(el),
|
|
134
|
-
boundingBox,
|
|
135
|
-
attributes,
|
|
136
|
-
selector: cssSelectorPath(el),
|
|
137
|
-
};
|
|
138
|
-
}
|
package/src/feedback-kit.test.ts
DELETED
|
@@ -1,227 +0,0 @@
|
|
|
1
|
-
import { act, createElement, type ComponentType } from "react";
|
|
2
|
-
import { createRoot, type Root } from "react-dom/client";
|
|
3
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
-
|
|
5
|
-
import type {
|
|
6
|
-
FeedbackReport,
|
|
7
|
-
QueueStatus,
|
|
8
|
-
ReportParts,
|
|
9
|
-
SubmitOutcome,
|
|
10
|
-
} from "@solhun/feedback-kit-core";
|
|
11
|
-
|
|
12
|
-
import { MarkerStore, type StorageLike } from "./marker-store.js";
|
|
13
|
-
import type { WebWidgetOpts } from "./widget.js";
|
|
14
|
-
|
|
15
|
-
// tsconfig.test는 JSX를 끈 채 *.test.ts만 검사한다. 구현은 tsup/패키지 tsconfig가 TSX로
|
|
16
|
-
// 검증하므로, 이 DOM 테스트에서는 런타임 import로 컴포넌트를 불러와 두 경계를 모두 지킨다.
|
|
17
|
-
const feedbackKitModulePath: string = "./feedback-kit.js";
|
|
18
|
-
const { FeedbackKit } = (await import(feedbackKitModulePath)) as {
|
|
19
|
-
FeedbackKit: ComponentType<WebWidgetOpts>;
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
23
|
-
|
|
24
|
-
const SHOT = { base64: "c2NyZWVuc2hvdA==", contentType: "image/png" as const };
|
|
25
|
-
|
|
26
|
-
class MemoryStorage implements StorageLike {
|
|
27
|
-
private readonly values = new Map<string, string>();
|
|
28
|
-
|
|
29
|
-
getItem(key: string): string | null {
|
|
30
|
-
return this.values.get(key) ?? null;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
setItem(key: string, value: string): void {
|
|
34
|
-
this.values.set(key, value);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
removeItem(key: string): void {
|
|
38
|
-
this.values.delete(key);
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
class TestQueue {
|
|
43
|
-
private readonly listeners = new Set<(status: QueueStatus) => void>();
|
|
44
|
-
submitImpl: (report: FeedbackReport) => Promise<SubmitOutcome> = async () => ({
|
|
45
|
-
delivered: true,
|
|
46
|
-
id: "server-1",
|
|
47
|
-
queued: false,
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
submit(report: FeedbackReport): Promise<SubmitOutcome> {
|
|
51
|
-
return this.submitImpl(report);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
async retryNow(): Promise<void> {}
|
|
55
|
-
|
|
56
|
-
async size(): Promise<number> {
|
|
57
|
-
return 0;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
subscribe(listener: (status: QueueStatus) => void): () => void {
|
|
61
|
-
this.listeners.add(listener);
|
|
62
|
-
listener({ state: "idle", pending: 0, lastError: null });
|
|
63
|
-
return () => this.listeners.delete(listener);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
let sequence = 0;
|
|
68
|
-
async function createReport(parts: ReportParts): Promise<FeedbackReport> {
|
|
69
|
-
sequence += 1;
|
|
70
|
-
return {
|
|
71
|
-
clientSubmissionId: `submission-${sequence}`,
|
|
72
|
-
...parts,
|
|
73
|
-
context: {
|
|
74
|
-
app: "renderer-test",
|
|
75
|
-
screen: "/",
|
|
76
|
-
url: "https://example.test/",
|
|
77
|
-
sessionId: "session",
|
|
78
|
-
user: null,
|
|
79
|
-
source: { screenId: null, sourceFile: null },
|
|
80
|
-
platform: "web",
|
|
81
|
-
timezone: "Asia/Seoul",
|
|
82
|
-
clientTimestamp: "2026-08-07T00:00:00.000Z",
|
|
83
|
-
native: null,
|
|
84
|
-
web: null,
|
|
85
|
-
diagnostics: null,
|
|
86
|
-
extra: {},
|
|
87
|
-
},
|
|
88
|
-
createdAt: "2026-08-07T00:00:00.000Z",
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
let mountedRoot: Root | null = null;
|
|
93
|
-
let host: HTMLDivElement | null = null;
|
|
94
|
-
|
|
95
|
-
afterEach(async () => {
|
|
96
|
-
if (mountedRoot) {
|
|
97
|
-
await act(async () => mountedRoot?.unmount());
|
|
98
|
-
}
|
|
99
|
-
mountedRoot = null;
|
|
100
|
-
host?.remove();
|
|
101
|
-
host = null;
|
|
102
|
-
document.body.replaceChildren();
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
async function renderKit(queue = new TestQueue(), capture = async () => SHOT): Promise<void> {
|
|
106
|
-
host = document.createElement("div");
|
|
107
|
-
document.body.append(host);
|
|
108
|
-
mountedRoot = createRoot(host);
|
|
109
|
-
await act(async () => {
|
|
110
|
-
mountedRoot?.render(
|
|
111
|
-
createElement(FeedbackKit, {
|
|
112
|
-
queue,
|
|
113
|
-
createReport,
|
|
114
|
-
capture,
|
|
115
|
-
store: new MarkerStore(new MemoryStorage()),
|
|
116
|
-
doc: document,
|
|
117
|
-
getPathname: () => location.pathname,
|
|
118
|
-
getViewport: () => ({ width: 1000, height: 500 }),
|
|
119
|
-
}),
|
|
120
|
-
);
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function buttonNamed(name: string): HTMLButtonElement {
|
|
125
|
-
const button = Array.from(document.querySelectorAll("button")).find(
|
|
126
|
-
(candidate) => candidate.textContent?.trim() === name,
|
|
127
|
-
);
|
|
128
|
-
if (!button) throw new Error(`버튼을 찾을 수 없음: ${name}`);
|
|
129
|
-
return button;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function click(element: Element): void {
|
|
133
|
-
element.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function changeTextarea(textarea: HTMLTextAreaElement, value: string): void {
|
|
137
|
-
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
|
|
138
|
-
setter?.call(textarea, value);
|
|
139
|
-
textarea.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: value }));
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
async function flushPaint(): Promise<void> {
|
|
143
|
-
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
describe("FeedbackKit React 렌더러", () => {
|
|
147
|
-
it("캡처에서 자체 UI를 숨기고 모달·dirty 확인·포커스 복원을 제공한다", async () => {
|
|
148
|
-
let visibilityDuringCapture = "not-called";
|
|
149
|
-
await renderKit(new TestQueue(), async () => {
|
|
150
|
-
visibilityDuringCapture =
|
|
151
|
-
document.querySelector<HTMLElement>("[data-feedback-kit]")?.style.visibility ?? "missing";
|
|
152
|
-
return SHOT;
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
const floating = buttonNamed("피드백");
|
|
156
|
-
floating.focus();
|
|
157
|
-
await act(async () => {
|
|
158
|
-
click(floating);
|
|
159
|
-
await flushPaint();
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
expect(visibilityDuringCapture).toBe("hidden");
|
|
163
|
-
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
|
|
164
|
-
expect(document.querySelector('img[alt="자동 캡처된 현재 화면 미리보기"]')).not.toBeNull();
|
|
165
|
-
expect(document.querySelector("textarea")).toBe(document.activeElement);
|
|
166
|
-
expect(document.querySelector("select")).not.toBeNull();
|
|
167
|
-
expect(buttonNamed("요소 지목")).not.toBeNull();
|
|
168
|
-
|
|
169
|
-
const comment = document.querySelector("textarea")!;
|
|
170
|
-
await act(async () => changeTextarea(comment, "닫기 전에 확인할 내용"));
|
|
171
|
-
await act(async () => {
|
|
172
|
-
document
|
|
173
|
-
.querySelector('[role="dialog"]')
|
|
174
|
-
?.dispatchEvent(new globalThis.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
expect(document.querySelector('[role="alertdialog"]')).not.toBeNull();
|
|
178
|
-
expect(comment.value).toBe("닫기 전에 확인할 내용");
|
|
179
|
-
|
|
180
|
-
await act(async () => click(buttonNamed("닫기")));
|
|
181
|
-
await act(async () => flushPaint());
|
|
182
|
-
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
|
183
|
-
expect(buttonNamed("피드백")).toBe(document.activeElement);
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
it("지목 오버레이에서 주석 마커의 전송 중·완료 상태를 렌더링한다", async () => {
|
|
187
|
-
const queue = new TestQueue();
|
|
188
|
-
let resolveSubmit!: (outcome: SubmitOutcome) => void;
|
|
189
|
-
queue.submitImpl = () =>
|
|
190
|
-
new Promise((resolve) => {
|
|
191
|
-
resolveSubmit = resolve;
|
|
192
|
-
});
|
|
193
|
-
await renderKit(queue);
|
|
194
|
-
|
|
195
|
-
await act(async () => {
|
|
196
|
-
click(buttonNamed("피드백"));
|
|
197
|
-
await flushPaint();
|
|
198
|
-
});
|
|
199
|
-
await act(async () => click(buttonNamed("요소 지목")));
|
|
200
|
-
expect(buttonNamed("지목 종료")).not.toBeNull();
|
|
201
|
-
expect(buttonNamed("지목 중").disabled).toBe(true);
|
|
202
|
-
|
|
203
|
-
const target = document.createElement("button");
|
|
204
|
-
target.textContent = "페이지 링크";
|
|
205
|
-
target.getBoundingClientRect = () =>
|
|
206
|
-
({ left: 30, top: 40, width: 120, height: 30 }) as DOMRect;
|
|
207
|
-
document.body.append(target);
|
|
208
|
-
|
|
209
|
-
await act(async () => {
|
|
210
|
-
target.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, clientX: 50, clientY: 50 }));
|
|
211
|
-
target.dispatchEvent(
|
|
212
|
-
new MouseEvent("click", { bubbles: true, cancelable: true, clientX: 100, clientY: 100 }),
|
|
213
|
-
);
|
|
214
|
-
});
|
|
215
|
-
const annotation = document.querySelector<HTMLTextAreaElement>("#feedback-kit-annotation")!;
|
|
216
|
-
await act(async () => changeTextarea(annotation, "버튼이 동작하지 않아요"));
|
|
217
|
-
await act(async () => click(buttonNamed("저장")));
|
|
218
|
-
|
|
219
|
-
expect(document.body.textContent).toContain("전송 중");
|
|
220
|
-
await act(async () => {
|
|
221
|
-
resolveSubmit({ delivered: true, id: "server-1", queued: false });
|
|
222
|
-
await Promise.resolve();
|
|
223
|
-
});
|
|
224
|
-
expect(document.body.textContent).toContain("완료");
|
|
225
|
-
expect(buttonNamed("지목 종료")).not.toBeNull();
|
|
226
|
-
});
|
|
227
|
-
});
|