@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.
@@ -0,0 +1,455 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import {
4
+ MODAL_ACTION_PICK,
5
+ MODAL_ACTION_PIN,
6
+ MODAL_FIELD_COMMENT,
7
+ FeedbackQueue,
8
+ type FeedbackAdapter,
9
+ type FeedbackReport,
10
+ type FeedbackStorage,
11
+ type QueueStatus,
12
+ type ReportParts,
13
+ type SubmitResult,
14
+ type SubmitOutcome,
15
+ } from "@solhun/feedback-kit-core";
16
+ import { createNativeWidget } from "../../native/src/widget.js";
17
+ import { MarkerStore, type StorageLike } from "./marker-store.js";
18
+ import { createWebWidget } from "./widget.js";
19
+
20
+ const SHOT = { base64: "c2NyZWVuc2hvdA==", contentType: "image/png" as const };
21
+
22
+ class MemoryStorage implements StorageLike {
23
+ private readonly values = new Map<string, string>();
24
+
25
+ getItem(key: string): string | null {
26
+ return this.values.get(key) ?? null;
27
+ }
28
+
29
+ setItem(key: string, value: string): void {
30
+ this.values.set(key, value);
31
+ }
32
+
33
+ removeItem(key: string): void {
34
+ this.values.delete(key);
35
+ }
36
+ }
37
+
38
+ class AsyncMemoryStorage implements FeedbackStorage {
39
+ private readonly values = new Map<string, string>();
40
+
41
+ async get(key: string): Promise<string | null> {
42
+ return this.values.get(key) ?? null;
43
+ }
44
+
45
+ async set(key: string, value: string): Promise<void> {
46
+ this.values.set(key, value);
47
+ }
48
+
49
+ async remove(key: string): Promise<void> {
50
+ this.values.delete(key);
51
+ }
52
+ }
53
+
54
+ class TestQueue {
55
+ readonly submitted: FeedbackReport[] = [];
56
+ private readonly listeners = new Set<(status: QueueStatus) => void>();
57
+ private nextOutcome: SubmitOutcome = { delivered: true, id: "server-1", queued: false };
58
+ private deferred: Promise<SubmitOutcome> | null = null;
59
+ private pendingCount = 0;
60
+
61
+ setOutcome(outcome: SubmitOutcome): void {
62
+ this.nextOutcome = outcome;
63
+ }
64
+
65
+ defer(outcome: Promise<SubmitOutcome>): void {
66
+ this.deferred = outcome;
67
+ }
68
+
69
+ async submit(report: FeedbackReport): Promise<SubmitOutcome> {
70
+ this.submitted.push(report);
71
+ const outcome = this.deferred ? await this.deferred : this.nextOutcome;
72
+ this.deferred = null;
73
+ if (!outcome.delivered && outcome.queued) {
74
+ this.pendingCount += 1;
75
+ this.emit("pending");
76
+ }
77
+ return outcome;
78
+ }
79
+
80
+ async retryNow(): Promise<void> {
81
+ this.recover();
82
+ }
83
+
84
+ async size(): Promise<number> {
85
+ return this.pendingCount;
86
+ }
87
+
88
+ subscribe(listener: (status: QueueStatus) => void): () => void {
89
+ this.listeners.add(listener);
90
+ listener(this.status());
91
+ return () => this.listeners.delete(listener);
92
+ }
93
+
94
+ recover(): void {
95
+ this.pendingCount = 0;
96
+ this.emit("idle");
97
+ }
98
+
99
+ private status(state: QueueStatus["state"] = this.pendingCount > 0 ? "pending" : "idle"):
100
+ QueueStatus {
101
+ return { state, pending: this.pendingCount, lastError: null };
102
+ }
103
+
104
+ private emit(state: QueueStatus["state"]): void {
105
+ const status = this.status(state);
106
+ for (const listener of this.listeners) listener(status);
107
+ }
108
+ }
109
+
110
+ function reportFactory() {
111
+ let sequence = 0;
112
+ return async (parts: ReportParts): Promise<FeedbackReport> => {
113
+ sequence += 1;
114
+ return {
115
+ clientSubmissionId: `submission-${sequence}`,
116
+ ...parts,
117
+ context: {
118
+ app: "test",
119
+ screen: "/screen",
120
+ url: "https://example.test/screen",
121
+ sessionId: "session",
122
+ user: null,
123
+ source: { screenId: null, sourceFile: null },
124
+ platform: "web",
125
+ timezone: "Asia/Seoul",
126
+ clientTimestamp: "2026-08-07T00:00:00.000Z",
127
+ native: null,
128
+ web: null,
129
+ diagnostics: null,
130
+ extra: {},
131
+ },
132
+ createdAt: `2026-08-07T00:00:0${sequence}.000Z`,
133
+ };
134
+ };
135
+ }
136
+
137
+ function webFixture(options: {
138
+ queue?: TestQueue;
139
+ storage?: MemoryStorage;
140
+ path?: { current: string };
141
+ } = {}) {
142
+ const queue = options.queue ?? new TestQueue();
143
+ const storage = options.storage ?? new MemoryStorage();
144
+ const path = options.path ?? { current: "/first" };
145
+ const store = new MarkerStore(storage);
146
+ const kit = createWebWidget({
147
+ queue,
148
+ createReport: reportFactory(),
149
+ capture: async () => SHOT,
150
+ store,
151
+ doc: document,
152
+ getPathname: () => path.current,
153
+ getViewport: () => ({ width: 1000, height: 500 }),
154
+ });
155
+ return { ...kit, queue, storage, path };
156
+ }
157
+
158
+ function targetButton(): HTMLButtonElement {
159
+ const button = document.createElement("button");
160
+ button.textContent = "저장";
161
+ button.id = `target-${document.body.childElementCount}`;
162
+ button.getBoundingClientRect = () =>
163
+ ({ left: 10, top: 20, width: 100, height: 40 }) as DOMRect;
164
+ document.body.append(button);
165
+ return button;
166
+ }
167
+
168
+ describe("DP-257 화면 지도와 제보 단위", () => {
169
+ it("TC1 플로팅 버튼을 누르면 리포트 모달이 열린다", async () => {
170
+ const { widget, dispose } = webFixture();
171
+
172
+ expect(widget.getState().screen).toBe("button");
173
+ await widget.openReport();
174
+
175
+ const state = widget.getState();
176
+ expect(state.screen).toBe("modal");
177
+ expect(state.modal.open).toBe(true);
178
+ expect(state.modal.focusOrder).toContain(MODAL_FIELD_COMMENT);
179
+ expect(state.modal.screenshotStatus).toBe("ready");
180
+ dispose();
181
+ });
182
+
183
+ it("TC2 주석을 저장하면 마커가 생기고 지목 모드가 유지된다", async () => {
184
+ const { widget, picking, queue, dispose } = webFixture();
185
+ widget.startPicking();
186
+ picking.openAnnotation(targetButton(), { x: 100, y: 100 });
187
+ picking.setAnnotationComment("첫 주석");
188
+
189
+ await picking.saveAnnotation();
190
+
191
+ expect(queue.submitted).toHaveLength(1);
192
+ expect(picking.getState().markers).toHaveLength(1);
193
+ expect(picking.getState().markers[0]?.status).toBe("done");
194
+ expect(widget.getState().screen).toBe("picking");
195
+ expect(picking.isActive).toBe(true);
196
+ dispose();
197
+ });
198
+
199
+ it("TC3 모달을 취소하면 플로팅 버튼 화면으로 돌아온다", async () => {
200
+ const { widget, queue, dispose } = webFixture();
201
+ await widget.openReport();
202
+
203
+ expect(widget.closeReport()).toBe("closed");
204
+
205
+ expect(widget.getState().screen).toBe("button");
206
+ expect(queue.submitted).toHaveLength(0);
207
+ dispose();
208
+ });
209
+
210
+ it("TC4 주석 팝업을 취소하면 아무것도 남지 않는다", () => {
211
+ const { widget, picking, queue, dispose } = webFixture();
212
+ widget.startPicking();
213
+ picking.openAnnotation(targetButton(), { x: 10, y: 20 });
214
+ picking.setAnnotationComment("취소할 주석");
215
+
216
+ picking.cancelAnnotation();
217
+
218
+ expect(picking.getState().popup).toBeNull();
219
+ expect(picking.getState().markers).toHaveLength(0);
220
+ expect(queue.submitted).toHaveLength(0);
221
+ expect(picking.isActive).toBe(true);
222
+ dispose();
223
+ });
224
+
225
+ it("TC5 코멘트를 넣고 보내면 전송 상태가 보인다", async () => {
226
+ const queue = new TestQueue();
227
+ let finish!: (outcome: SubmitOutcome) => void;
228
+ queue.defer(new Promise((resolve) => { finish = resolve; }));
229
+ const { widget, dispose } = webFixture({ queue });
230
+ await widget.openReport();
231
+ widget.modal.setComment("전송 상태 확인");
232
+
233
+ const submitting = widget.submitReport();
234
+ expect(widget.getState().modal.submitStatus).toBe("sending");
235
+ finish({ delivered: true, id: "server-1", queued: false });
236
+ await submitting;
237
+ dispose();
238
+ });
239
+
240
+ it("TC6 페이지를 이동하거나 새로고침해도 지목 모드가 유지된다", () => {
241
+ const storage = new MemoryStorage();
242
+ const path = { current: "/first" };
243
+ const first = webFixture({ storage, path });
244
+ first.widget.startPicking();
245
+ path.current = "/second";
246
+ first.picking.syncPath();
247
+ expect(first.picking.isActive).toBe(true);
248
+ first.dispose();
249
+
250
+ const restored = webFixture({ storage, path });
251
+ const target = targetButton();
252
+ target.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
253
+ expect(restored.widget.getState().screen).toBe("picking");
254
+ expect(restored.picking.isActive).toBe(true);
255
+ expect(restored.picking.getState().hovered?.selector).not.toBeNull();
256
+ restored.dispose();
257
+ });
258
+
259
+ it("TC7 전송에 성공하면 완료를 알리고 모달이 닫힌다", async () => {
260
+ const { widget, queue, dispose } = webFixture();
261
+ await widget.openReport();
262
+ widget.modal.setComment("성공 제보");
263
+
264
+ await widget.submitReport();
265
+
266
+ expect(widget.getState().modal.submitMessage).toBe("보냈습니다");
267
+ expect(widget.getState().modal.open).toBe(false);
268
+ expect(widget.getState().screen).toBe("button");
269
+ expect(await queue.size()).toBe(0);
270
+ dispose();
271
+ });
272
+
273
+ it("TC8 지목 종료를 누르면 모드가 꺼진다", () => {
274
+ const storage = new MemoryStorage();
275
+ const first = webFixture({ storage });
276
+ first.widget.startPicking();
277
+ first.widget.stopPicking();
278
+ expect(first.widget.getState().screen).toBe("button");
279
+ first.dispose();
280
+
281
+ const restored = webFixture({ storage });
282
+ expect(restored.picking.isActive).toBe(false);
283
+ expect(restored.widget.getState().screen).toBe("button");
284
+ restored.dispose();
285
+ });
286
+
287
+ it("TC9 전송에 실패하면 입력을 보존한 채 대기 큐에 남는다", async () => {
288
+ const queue = new TestQueue();
289
+ queue.setOutcome({ delivered: false, id: null, queued: true });
290
+ const { widget, dispose } = webFixture({ queue });
291
+ await widget.openReport();
292
+ widget.modal.setComment("오프라인 제보");
293
+ widget.modal.setPriority("urgent");
294
+
295
+ await widget.submitReport();
296
+
297
+ const state = widget.getState().modal;
298
+ expect(state.submitMessage).toBe("대기 중");
299
+ expect(state.showRetry).toBe(true);
300
+ expect(state.pending).toBe(1);
301
+ expect(state.comment).toBe("오프라인 제보");
302
+ expect(state.priority).toBe("urgent");
303
+ expect(state.screenshot).toEqual(SHOT);
304
+ dispose();
305
+ });
306
+
307
+ it("TC10 앱에서 핀 찍기로 좌표를 지정한다", async () => {
308
+ const queue = new TestQueue();
309
+ const widget = createNativeWidget({
310
+ queue,
311
+ createReport: reportFactory(),
312
+ capture: () => SHOT,
313
+ });
314
+ await widget.openReport();
315
+
316
+ expect(widget.getState().modal.actions).toContain(MODAL_ACTION_PIN);
317
+ expect(widget.openPinScreen()).toBe(true);
318
+ widget.placePin({ x: 25, y: 75 }, { width: 100, height: 100 });
319
+ expect(widget.getState().screen).toBe("pin");
320
+ expect(widget.getState().pinDraft).toEqual({ x: 0.25, y: 0.75 });
321
+ widget.dispose();
322
+ });
323
+
324
+ it("TC11 연결이 복구되면 대기 큐가 같은 id로 자동으로 비워진다", async () => {
325
+ let online = false;
326
+ const requests: string[] = [];
327
+ const stored = new Set<string>();
328
+ const adapter: FeedbackAdapter = {
329
+ async submit(report): Promise<SubmitResult> {
330
+ requests.push(report.clientSubmissionId);
331
+ if (!online) {
332
+ return { ok: false, id: null, retryable: true, retryAfterMs: null };
333
+ }
334
+ stored.add(report.clientSubmissionId);
335
+ return { ok: true, id: "server-1", retryable: false, retryAfterMs: null };
336
+ },
337
+ };
338
+ const queue = new FeedbackQueue({
339
+ adapter,
340
+ storage: new AsyncMemoryStorage(),
341
+ flushIntervalMs: 0,
342
+ });
343
+ const storage = new MemoryStorage();
344
+ const store = new MarkerStore(storage);
345
+ const kit = createWebWidget({
346
+ queue,
347
+ createReport: reportFactory(),
348
+ capture: () => SHOT,
349
+ store,
350
+ doc: document,
351
+ });
352
+ const { widget, dispose } = kit;
353
+ await widget.openReport();
354
+ widget.modal.setComment("복구할 제보");
355
+ await widget.submitReport();
356
+ const firstId = widget.modal.getLastReport()?.clientSubmissionId;
357
+
358
+ online = true;
359
+ // 브라우저의 online listener가 내부적으로 호출하는 동일 경로를 직접 실행한다.
360
+ await queue.retryNow();
361
+ await vi.waitFor(async () => {
362
+ expect(await queue.size()).toBe(0);
363
+ });
364
+
365
+ expect(requests).toEqual([firstId, firstId]);
366
+ expect(stored).toEqual(new Set([firstId!]));
367
+ expect(widget.modal.getLastReport()?.clientSubmissionId).toBe(firstId);
368
+ expect(widget.getState().modal.pending).toBe(0);
369
+ expect(widget.getState().screen).toBe("button");
370
+ dispose();
371
+ });
372
+
373
+ it("TC12 핀을 지정하면 모달로 돌아와 좌표가 유지된다", async () => {
374
+ const queue = new TestQueue();
375
+ const widget = createNativeWidget({
376
+ queue,
377
+ createReport: reportFactory(),
378
+ capture: () => SHOT,
379
+ });
380
+ await widget.openReport();
381
+ widget.openPinScreen();
382
+ widget.placePin({ x: 20, y: 30 }, { width: 100, height: 100 });
383
+
384
+ widget.confirmPin();
385
+ widget.modal.setComment("핀 포함");
386
+ await widget.submitReport();
387
+
388
+ expect(queue.submitted[0]?.pin).toEqual({ x: 0.2, y: 0.3 });
389
+ widget.dispose();
390
+ });
391
+
392
+ it("TC13 모달에서 요소 지목 모드로 전환된다", async () => {
393
+ const { widget, picking, dispose } = webFixture();
394
+ await widget.openReport();
395
+ expect(widget.getState().modal.actions).toContain(MODAL_ACTION_PICK);
396
+
397
+ expect(widget.startPicking()).toBe(true);
398
+
399
+ expect(widget.getState().modal.open).toBe(false);
400
+ expect(widget.getState().screen).toBe("picking");
401
+ expect(picking.isActive).toBe(true);
402
+ dispose();
403
+ });
404
+
405
+ it("TC14 리포트 모달 제출 1회는 제보 1건만 만든다", async () => {
406
+ const { widget, queue, dispose } = webFixture();
407
+ await widget.openReport();
408
+ widget.modal.setComment("한 번만");
409
+
410
+ await widget.submitReport();
411
+
412
+ expect(queue.submitted).toHaveLength(1);
413
+ expect(queue.submitted[0]?.kind).toBe("report");
414
+ dispose();
415
+ });
416
+
417
+ it("TC15 요소를 클릭하면 주석 팝업이 열리고 페이지 클릭은 막힌다", () => {
418
+ const { widget, picking, dispose } = webFixture();
419
+ const link = document.createElement("a");
420
+ link.href = "https://example.test/next";
421
+ link.textContent = "이동";
422
+ document.body.append(link);
423
+ const pageHandler = vi.fn();
424
+ link.addEventListener("click", pageHandler);
425
+ widget.startPicking();
426
+
427
+ const allowed = link.dispatchEvent(new MouseEvent("click", {
428
+ bubbles: true,
429
+ cancelable: true,
430
+ clientX: 100,
431
+ clientY: 50,
432
+ }));
433
+
434
+ expect(allowed).toBe(false);
435
+ expect(pageHandler).not.toHaveBeenCalled();
436
+ expect(picking.getState().popup?.element?.tag).toBe("a");
437
+ dispose();
438
+ });
439
+
440
+ it("TC16 주석 3개를 찍으면 제보 3건이 생긴다", async () => {
441
+ const { widget, picking, queue, dispose } = webFixture();
442
+ widget.startPicking();
443
+
444
+ for (let index = 0; index < 3; index += 1) {
445
+ picking.openAnnotation(targetButton(), { x: 100 + index, y: 50 });
446
+ picking.setAnnotationComment(`주석 ${index + 1}`);
447
+ await picking.saveAnnotation();
448
+ }
449
+
450
+ expect(queue.submitted).toHaveLength(3);
451
+ expect(new Set(queue.submitted.map((report) => report.clientSubmissionId)).size).toBe(3);
452
+ expect(queue.submitted.every((report) => report.kind === "annotation")).toBe(true);
453
+ dispose();
454
+ });
455
+ });
@@ -0,0 +1,47 @@
1
+ // 웹 스크린샷 캡처.
2
+ //
3
+ // 라이브러리(html2canvas-pro)가 실제 래스터화를 맡으므로 여기서 검증하는 것은
4
+ // **우리가 책임지는 계약**이다: 위젯 자기 UI 제외 / 실패해도 던지지 않고 null /
5
+ // 비브라우저에서 안전.
6
+ //
7
+ // 왜 직접 만든 SVG foreignObject 방식을 버렸나: 그 방식은 캔버스를 tainted 시켜
8
+ // `toDataURL` 이 SecurityError 를 던진다 → 캡처가 **항상** 실패한다. 실측 확인:
9
+ // 같은 브라우저에서 일반 SVG 는 오염되지 않고 foreignObject 만 오염된다.
10
+
11
+ import { describe, expect, it, vi } from "vitest";
12
+
13
+ import { captureWebScreenshot, isOwnUi } from "./screenshot.js";
14
+ import { OWN_UI_ATTR } from "./picking.js";
15
+
16
+ describe("자기 UI 제외", () => {
17
+ it("위젯 루트와 그 자손은 캡처에서 뺀다", () => {
18
+ document.body.innerHTML = `
19
+ <div id="page"><p id="text">본문</p></div>
20
+ <div id="widget" ${OWN_UI_ATTR}><button id="btn">보내기</button></div>`;
21
+
22
+ expect(isOwnUi(document.getElementById("widget")!)).toBe(true);
23
+ // 자손도 빠져야 한다 — 모달 안 버튼이 결과에 찍히면 안 된다.
24
+ expect(isOwnUi(document.getElementById("btn")!)).toBe(true);
25
+ expect(isOwnUi(document.getElementById("page")!)).toBe(false);
26
+ expect(isOwnUi(document.getElementById("text")!)).toBe(false);
27
+ });
28
+ });
29
+
30
+ describe("captureWebScreenshot", () => {
31
+ it("라이브러리가 던져도 null 로 물러난다(제보 흐름을 막지 않는다)", async () => {
32
+ // jsdom 에는 캔버스가 없어 라이브러리가 실패한다 — 그 경로가 곧 이 테스트다.
33
+ // 여기서 예외가 새면 모달이 통째로 죽는다.
34
+ await expect(captureWebScreenshot()).resolves.toBeNull();
35
+ });
36
+
37
+ it("document 가 없는 환경에서도 던지지 않는다", async () => {
38
+ const original = Object.getOwnPropertyDescriptor(globalThis, "document");
39
+ Object.defineProperty(globalThis, "document", { value: undefined, configurable: true });
40
+ try {
41
+ await expect(captureWebScreenshot()).resolves.toBeNull();
42
+ } finally {
43
+ if (original) Object.defineProperty(globalThis, "document", original);
44
+ vi.restoreAllMocks();
45
+ }
46
+ });
47
+ });
@@ -0,0 +1,90 @@
1
+ import type { ScreenshotCapture, ScreenshotReencode } from "@solhun/feedback-kit-core";
2
+ import html2canvas from "html2canvas-pro";
3
+
4
+ import { OWN_UI_ATTR } from "./picking.js";
5
+
6
+ /**
7
+ * 왜 라이브러리를 쓰나 (직접 만든 SVG foreignObject 방식에서 갈아탄 이유):
8
+ *
9
+ * `<foreignObject>` 로 DOM 을 감싼 SVG 를 캔버스에 그리면 **캔버스가 tainted 되어**
10
+ * `toDataURL()` 이 `SecurityError` 를 던진다. 즉 캡처가 항상 null 이 되고, 사용자는
11
+ * 매번 "캡처 실패 — 파일로 첨부할 수 있습니다"만 본다. 실측으로 확인했다:
12
+ * 같은 브라우저에서 일반 SVG(rect)는 오염되지 않고 `foreignObject` 만 오염된다.
13
+ *
14
+ * html2canvas-pro(MIT)는 foreignObject 를 쓰지 않는다. DOM 을 순회하며 캔버스에
15
+ * 직접 그리므로 오염 자체가 생기지 않는다. 명세의 "DOM 을 이미지로 변환하는 MIT
16
+ * 라이브러리를 쓴다"도 원래 이 뜻이었다.
17
+ */
18
+
19
+ function payload(dataUrl: string): string | null {
20
+ const comma = dataUrl.indexOf(",");
21
+ return comma >= 0 ? dataUrl.slice(comma + 1) : null;
22
+ }
23
+
24
+ /** 캡처에서 빼야 할 노드인가(위젯 자신의 UI). 결과에 자기 모달이 찍히면 안 된다. */
25
+ export function isOwnUi(node: Element): boolean {
26
+ return node.hasAttribute?.(OWN_UI_ATTR) === true
27
+ || node.closest?.(`[${OWN_UI_ATTR}]`) !== null;
28
+ }
29
+
30
+ /**
31
+ * 웹 기본 캡처 — 현재 viewport 를 JPEG 로.
32
+ *
33
+ * 실패하면 **null 을 돌려준다**(던지지 않는다). 코어가 "캡처 실패" 상태로 바꾸고
34
+ * 사용자는 파일로 첨부할 수 있다 — 그림 때문에 제보 자체를 잃지 않는다.
35
+ */
36
+ export const captureWebScreenshot: ScreenshotCapture = async () => {
37
+ const doc = globalThis.document;
38
+ const view = doc?.defaultView;
39
+ if (!doc?.documentElement || !view) return null;
40
+
41
+ try {
42
+ const canvas = await html2canvas(doc.documentElement, {
43
+ // 현재 보이는 화면만. 문서 전체를 그리면 제보와 무관한 영역까지 커진다.
44
+ width: view.innerWidth,
45
+ height: view.innerHeight,
46
+ x: view.scrollX,
47
+ y: view.scrollY,
48
+ windowWidth: view.innerWidth,
49
+ windowHeight: view.innerHeight,
50
+ // 배율은 2배까지만. 그 위는 용량만 커지고 8 MiB 한도에 먼저 걸린다.
51
+ scale: Math.min(2, Math.max(1, view.devicePixelRatio || 1)),
52
+ // 위젯 자신의 UI 는 결과에서 뺀다.
53
+ ignoreElements: isOwnUi,
54
+ // 외부 이미지가 CORS 를 안 열어두면 그것 때문에 전체가 실패할 수 있다.
55
+ // 못 가져오는 리소스는 건너뛰고 나머지를 그린다.
56
+ useCORS: true,
57
+ allowTaint: false,
58
+ logging: false,
59
+ backgroundColor: null,
60
+ });
61
+ return { base64: payload(canvas.toDataURL("image/jpeg", 0.9)) ?? "", contentType: "image/jpeg" };
62
+ } catch {
63
+ return null;
64
+ }
65
+ };
66
+
67
+ /** 8 MiB 정책이 요구할 때 기존 이미지를 JPEG 품질 단계로 다시 인코딩한다. */
68
+ export const reencodeWebScreenshot: ScreenshotReencode = async (shot, quality) => {
69
+ const doc = globalThis.document;
70
+ if (!doc) return null;
71
+ try {
72
+ const image = await new Promise<HTMLImageElement | null>((resolve) => {
73
+ const img = new Image();
74
+ img.onload = () => resolve(img);
75
+ img.onerror = () => resolve(null);
76
+ img.src = `data:${shot.contentType};base64,${shot.base64}`;
77
+ });
78
+ if (!image) return null;
79
+ const canvas = doc.createElement("canvas");
80
+ canvas.width = image.naturalWidth || image.width;
81
+ canvas.height = image.naturalHeight || image.height;
82
+ const context = canvas.getContext("2d");
83
+ if (!context) return null;
84
+ context.drawImage(image, 0, 0);
85
+ const base64 = payload(canvas.toDataURL("image/jpeg", quality));
86
+ return base64 ? { base64, contentType: "image/jpeg" } : null;
87
+ } catch {
88
+ return null;
89
+ }
90
+ };
@@ -0,0 +1,65 @@
1
+ // 웹 기본 저장소 — localStorage 계약과 폴백.
2
+
3
+ import { describe, expect, it, vi } from "vitest";
4
+
5
+ import { createWebStorage } from "./storage.js";
6
+
7
+ function fake() {
8
+ const map = new Map<string, string>();
9
+ return {
10
+ map,
11
+ getItem: (k: string) => map.get(k) ?? null,
12
+ setItem: (k: string, v: string) => void map.set(k, v),
13
+ removeItem: (k: string) => void map.delete(k),
14
+ };
15
+ }
16
+
17
+ describe("createWebStorage", () => {
18
+ it("get/set/remove 왕복이 된다", async () => {
19
+ const s = createWebStorage(fake());
20
+ await s.set("k", "v");
21
+ expect(await s.get("k")).toBe("v");
22
+ await s.remove("k");
23
+ expect(await s.get("k")).toBeNull();
24
+ });
25
+
26
+ it("없는 키는 null 이다", async () => {
27
+ expect(await createWebStorage(fake()).get("nope")).toBeNull();
28
+ });
29
+
30
+ it("쿼터 초과는 그대로 던진다(큐가 스크린샷을 떼어내야 한다)", async () => {
31
+ const backing = fake();
32
+ backing.setItem = () => {
33
+ throw new Error("QuotaExceededError");
34
+ };
35
+ // 여기서 삼키면 큐가 축소 경로를 못 타고 제보를 통째로 잃는다.
36
+ await expect(createWebStorage(backing).set("k", "v")).rejects.toThrow(/Quota/);
37
+ });
38
+
39
+ it("읽기가 던지면 null 로 물러난다(제보 흐름을 막지 않는다)", async () => {
40
+ const backing = fake();
41
+ backing.getItem = () => {
42
+ throw new Error("SecurityError");
43
+ };
44
+ expect(await createWebStorage(backing).get("k")).toBeNull();
45
+ });
46
+
47
+ it("localStorage 를 아예 못 쓰면 메모리로 물러난다", async () => {
48
+ const original = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
49
+ Object.defineProperty(globalThis, "localStorage", {
50
+ configurable: true,
51
+ get() {
52
+ throw new Error("사파리 프라이빗 모드");
53
+ },
54
+ });
55
+ try {
56
+ const s = createWebStorage(); // 인자 없이 = 전역을 고르는 경로
57
+ await s.set("k", "v");
58
+ expect(await s.get("k")).toBe("v"); // 메모리라도 동작은 같다
59
+ } finally {
60
+ if (original) Object.defineProperty(globalThis, "localStorage", original);
61
+ else delete (globalThis as Record<string, unknown>).localStorage;
62
+ vi.restoreAllMocks();
63
+ }
64
+ });
65
+ });