diff --git a/apps/web/src/app/board-utils.test.ts b/apps/web/src/app/board-utils.test.ts new file mode 100644 index 0000000..7e90530 --- /dev/null +++ b/apps/web/src/app/board-utils.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect } from 'vitest'; +import { computeElementDiff } from './board-utils'; + +describe('computeElementDiff', () => { + it('returns empty ops for identical arrays', () => { + const arr = [{ id: 'a', text: 'hello' }]; + const result = computeElementDiff(arr, [...arr]); + expect(result.removes).toEqual([]); + expect(result.sets).toEqual([]); + expect(result.inserts).toEqual([]); + }); + + it('returns empty ops for empty arrays', () => { + const result = computeElementDiff([], []); + expect(result.removes).toEqual([]); + expect(result.sets).toEqual([]); + expect(result.inserts).toEqual([]); + }); + + it('detects element removal with back-to-front indices', () => { + const current = [{ id: 'a' }, { id: 'b' }, { id: 'c' }]; + const next = [{ id: 'b' }]; + const result = computeElementDiff(current, next); + expect(result.removes).toEqual([2, 0]); + expect(result.sets).toEqual([]); + expect(result.inserts).toEqual([]); + }); + + it('detects property changes', () => { + const current = [{ id: 'a', text: 'old', color: 'red' }]; + const next = [{ id: 'a', text: 'new', color: 'red' }]; + const result = computeElementDiff(current, next); + expect(result.removes).toEqual([]); + expect(result.sets).toEqual([ + { + index: 0, + properties: { text: 'old' }, + newProperties: { text: 'new' }, + }, + ]); + expect(result.inserts).toEqual([]); + }); + + it('detects property deletion as null in newProperties', () => { + const current = [{ id: 'a', text: 'hello', extra: 'remove-me' }]; + const next = [{ id: 'a', text: 'hello' }]; + const result = computeElementDiff(current, next); + expect(result.sets).toHaveLength(1); + expect(result.sets[0].newProperties.extra).toBeNull(); + }); + + it('detects property addition', () => { + const current = [{ id: 'a', text: 'hello' }]; + const next = [{ id: 'a', text: 'hello', color: 'blue' }]; + const result = computeElementDiff(current, next); + expect(result.sets).toHaveLength(1); + expect(result.sets[0].newProperties).toEqual({ color: 'blue' }); + expect(result.sets[0].properties).toEqual({}); + }); + + it('detects element insertion', () => { + const current = [{ id: 'a' }]; + const next = [{ id: 'a' }, { id: 'b', text: 'new' }]; + const result = computeElementDiff(current, next); + expect(result.removes).toEqual([]); + expect(result.sets).toEqual([]); + expect(result.inserts).toEqual([ + { index: 1, element: { id: 'b', text: 'new' } }, + ]); + }); + + it('handles all inserts from empty', () => { + const next = [{ id: 'a' }, { id: 'b' }, { id: 'c' }]; + const result = computeElementDiff([], next); + expect(result.removes).toEqual([]); + expect(result.sets).toEqual([]); + expect(result.inserts).toHaveLength(3); + expect(result.inserts.map((i) => i.element.id)).toEqual(['a', 'b', 'c']); + }); + + it('handles all removes to empty', () => { + const current = [{ id: 'a' }, { id: 'b' }, { id: 'c' }]; + const result = computeElementDiff(current, []); + expect(result.removes).toEqual([2, 1, 0]); + expect(result.sets).toEqual([]); + expect(result.inserts).toEqual([]); + }); + + it('handles complex mixed changes (remove + modify + insert)', () => { + const current = [ + { id: 'a', text: 'A' }, + { id: 'b', text: 'B' }, + { id: 'c', text: 'C' }, + ]; + const next = [ + { id: 'a', text: 'A-modified' }, + { id: 'd', text: 'D' }, + { id: 'c', text: 'C' }, + ]; + const result = computeElementDiff(current, next); + // b removed (index 1 in original) + expect(result.removes).toEqual([1]); + // a modified (index 0 in post-remove [a, c]) + expect(result.sets).toHaveLength(1); + expect(result.sets[0].index).toBe(0); + expect(result.sets[0].newProperties.text).toBe('A-modified'); + // d inserted at index 1 + expect(result.inserts).toEqual([ + { index: 1, element: { id: 'd', text: 'D' } }, + ]); + }); + + it('handles deep object property changes', () => { + const current = [{ id: 'a', children: [{ text: 'old' }] }]; + const next = [{ id: 'a', children: [{ text: 'new' }] }]; + const result = computeElementDiff(current, next); + expect(result.sets).toHaveLength(1); + expect(result.sets[0].newProperties.children).toEqual([{ text: 'new' }]); + }); + + it('preserves insert order for multiple new elements', () => { + const current = [{ id: 'a' }]; + const next = [{ id: 'x' }, { id: 'a' }, { id: 'y' }]; + const result = computeElementDiff(current, next); + expect(result.inserts).toHaveLength(2); + expect(result.inserts[0]).toEqual({ index: 0, element: { id: 'x' } }); + expect(result.inserts[1]).toEqual({ index: 2, element: { id: 'y' } }); + }); +}); diff --git a/apps/web/src/app/board-utils.ts b/apps/web/src/app/board-utils.ts new file mode 100644 index 0000000..283d49f --- /dev/null +++ b/apps/web/src/app/board-utils.ts @@ -0,0 +1,72 @@ +export interface DiffOps { + removes: number[]; + sets: { + index: number; + properties: Record; + newProperties: Record; + }[]; + inserts: { + index: number; + element: { id: string; [key: string]: unknown }; + }[]; +} + +type Element = { id: string; [key: string]: unknown }; + +/** + * Compute the diff operations needed to transform `current` into `next`. + * Returns removes (back-to-front indices), sets (property changes), and inserts (new elements). + */ +export function computeElementDiff( + current: Element[], + next: Element[] +): DiffOps { + const newById = new Map(next.map((el) => [el.id, el])); + const newIds = new Set(next.map((el) => el.id)); + + // Phase 1: removes (back-to-front) + const removes: number[] = []; + for (let i = current.length - 1; i >= 0; i--) { + if (!newIds.has(current[i].id)) { + removes.push(i); + } + } + + // Simulate removal to get post-remove state + const afterRemove = current.filter((el) => newIds.has(el.id)); + + // Phase 2: sets (property changes) + const sets: DiffOps['sets'] = []; + for (let i = 0; i < afterRemove.length; i++) { + const cur = afterRemove[i]; + const target = newById.get(cur.id); + if (!target) continue; + const properties: Record = {}; + const newProperties: Record = {}; + const allKeys = new Set([...Object.keys(cur), ...Object.keys(target)]); + for (const key of allKeys) { + if (key === 'id') continue; + const curVal = cur[key]; + const newVal = target[key]; + if (JSON.stringify(curVal) !== JSON.stringify(newVal)) { + if (curVal !== undefined) properties[key] = curVal; + newProperties[key] = newVal !== undefined ? newVal : null; + } + } + if (Object.keys(newProperties).length > 0) { + sets.push({ index: i, properties, newProperties }); + } + } + + // Phase 3: inserts + const currentIds = new Set(afterRemove.map((el) => el.id)); + const inserts: DiffOps['inserts'] = []; + for (let i = 0; i < next.length; i++) { + if (!currentIds.has(next[i].id)) { + inserts.push({ index: i, element: next[i] }); + currentIds.add(next[i].id); + } + } + + return { removes, sets, inserts }; +} diff --git a/apps/web/src/app/file-board.tsx b/apps/web/src/app/file-board.tsx index 878b7ec..3d26c8a 100644 --- a/apps/web/src/app/file-board.tsx +++ b/apps/web/src/app/file-board.tsx @@ -10,6 +10,7 @@ import { } from '@plait/core'; import type { VyncCanvasFile, VyncViewport, WsMessage } from '@vync/shared'; import localforage from 'localforage'; +import { computeElementDiff } from './board-utils'; function toPlaitViewport(v: VyncViewport): Viewport { return { zoom: v.zoom, origination: [v.x, v.y] } as Viewport; @@ -53,55 +54,31 @@ function applyExternalChanges( board: PlaitBoard, newChildren: PlaitElement[] ): void { - const newById = new Map(newChildren.map((el) => [el.id, el])); - const newIds = new Set(newChildren.map((el) => el.id)); + const diff = computeElementDiff( + board.children as { id: string; [key: string]: unknown }[], + newChildren as { id: string; [key: string]: unknown }[] + ); // Phase 1: Remove deleted nodes (back-to-front to preserve indices) - for (let i = board.children.length - 1; i >= 0; i--) { - if (!newIds.has(board.children[i].id)) { - Transforms.removeNode(board, [i]); - } + for (const idx of diff.removes) { + Transforms.removeNode(board, [idx]); } // Phase 2: Update modified nodes via board.apply() directly. // Transforms.setNode strips null from newProperties, so property deletions // would be silently lost. Using board.apply() preserves null → delete semantics. - for (let i = 0; i < board.children.length; i++) { - const current = board.children[i]; - const target = newById.get(current.id); - if (!target) continue; - const properties: Record = {}; - const newProperties: Record = {}; - const allKeys = new Set([...Object.keys(current), ...Object.keys(target)]); - for (const key of allKeys) { - if (key === 'id') continue; - const curVal = (current as Record)[key]; - const newVal = (target as Record)[key]; - if (JSON.stringify(curVal) !== JSON.stringify(newVal)) { - if (curVal !== undefined) properties[key] = curVal; - newProperties[key] = newVal !== undefined ? newVal : null; - } - } - if (Object.keys(newProperties).length > 0) { - board.apply({ - type: 'set_node', - path: [i], - properties: properties as Partial, - newProperties: newProperties as Partial, - }); - } + for (const set of diff.sets) { + board.apply({ + type: 'set_node', + path: [set.index], + properties: set.properties as Partial, + newProperties: set.newProperties as Partial, + }); } // Phase 3: Insert new nodes so final order matches newChildren exactly. - // Walk newChildren in order; for each new ID, insert at position i. - // board.children grows with each insert, but index i stays correct because - // we process newChildren sequentially and all prior positions are settled. - const currentIds = new Set(board.children.map((el) => el.id)); - for (let i = 0; i < newChildren.length; i++) { - if (!currentIds.has(newChildren[i].id)) { - Transforms.insertNode(board, newChildren[i], [i]); - currentIds.add(newChildren[i].id); - } + for (const ins of diff.inserts) { + Transforms.insertNode(board, ins.element as PlaitElement, [ins.index]); } } diff --git a/apps/web/src/app/tab-bar.test.tsx b/apps/web/src/app/tab-bar.test.tsx new file mode 100644 index 0000000..ab714cc --- /dev/null +++ b/apps/web/src/app/tab-bar.test.tsx @@ -0,0 +1,113 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { TabBar } from './tab-bar'; +import type { TabInfo } from './tab-utils'; + +afterEach(cleanup); + +function renderTabBar(overrides: Partial[0]> = {}) { + const props = { + tabs: [] as TabInfo[], + activeFilePath: null as string | null, + registeredFiles: [] as string[], + discoveredFiles: [] as string[], + onTabClick: vi.fn(), + onTabClose: vi.fn(), + onAddFile: vi.fn(), + onDiscoverFile: vi.fn(), + onDropdownOpen: vi.fn(), + ...overrides, + }; + return { ...render(), props }; +} + +describe('TabBar', () => { + it('renders tabs from props', () => { + const tabs: TabInfo[] = [ + { filePath: '/a/plan.vync', label: 'plan.vync' }, + { filePath: '/b/notes.vync', label: 'notes.vync' }, + ]; + renderTabBar({ tabs }); + expect(screen.getByText('plan.vync')).toBeTruthy(); + expect(screen.getByText('notes.vync')).toBeTruthy(); + }); + + it('marks active tab with active class', () => { + const tabs: TabInfo[] = [ + { filePath: '/a/plan.vync', label: 'plan.vync' }, + { filePath: '/b/notes.vync', label: 'notes.vync' }, + ]; + renderTabBar({ tabs, activeFilePath: '/b/notes.vync' }); + const activeTab = screen.getByText('notes.vync').closest('.vync-tab'); + expect(activeTab?.classList.contains('vync-tab--active')).toBe(true); + const inactiveTab = screen.getByText('plan.vync').closest('.vync-tab'); + expect(inactiveTab?.classList.contains('vync-tab--active')).toBe(false); + }); + + it('calls onTabClick when tab is clicked', () => { + const onTabClick = vi.fn(); + const tabs: TabInfo[] = [{ filePath: '/a/plan.vync', label: 'plan.vync' }]; + renderTabBar({ tabs, onTabClick }); + fireEvent.click(screen.getByText('plan.vync')); + expect(onTabClick).toHaveBeenCalledWith('/a/plan.vync'); + }); + + it('calls onTabClose and stops propagation on close button click', () => { + const onTabClick = vi.fn(); + const onTabClose = vi.fn(); + const tabs: TabInfo[] = [{ filePath: '/a/plan.vync', label: 'plan.vync' }]; + renderTabBar({ tabs, onTabClick, onTabClose }); + fireEvent.click(screen.getByLabelText('Close plan.vync')); + expect(onTabClose).toHaveBeenCalledWith('/a/plan.vync'); + expect(onTabClick).not.toHaveBeenCalled(); + }); + + it('toggles dropdown on + button click', () => { + const onDropdownOpen = vi.fn(); + renderTabBar({ + registeredFiles: ['/a/plan.vync'], + discoveredFiles: [], + onDropdownOpen, + }); + const addBtn = screen.getByLabelText('Open file'); + expect(addBtn.getAttribute('aria-expanded')).toBe('false'); + + fireEvent.click(addBtn); + expect(addBtn.getAttribute('aria-expanded')).toBe('true'); + expect(onDropdownOpen).toHaveBeenCalled(); + + fireEvent.click(addBtn); + expect(addBtn.getAttribute('aria-expanded')).toBe('false'); + }); + + it('shows reopenable files in dropdown', () => { + const tabs: TabInfo[] = [{ filePath: '/a/plan.vync', label: 'plan.vync' }]; + renderTabBar({ + tabs, + registeredFiles: ['/a/plan.vync', '/b/notes.vync'], + discoveredFiles: [], + }); + fireEvent.click(screen.getByLabelText('Open file')); + expect(screen.getByText('Reopen')).toBeTruthy(); + expect(screen.getByText('notes.vync')).toBeTruthy(); + }); + + it('shows discovered files in dropdown', () => { + renderTabBar({ + discoveredFiles: ['/c/ideas.vync'], + }); + fireEvent.click(screen.getByLabelText('Open file')); + expect(screen.getByText('Open')).toBeTruthy(); + expect(screen.getByText('ideas.vync')).toBeTruthy(); + }); + + it('shows empty state when no files available', () => { + renderTabBar({ + registeredFiles: [], + discoveredFiles: [], + }); + fireEvent.click(screen.getByLabelText('Open file')); + expect(screen.getByText(/No files found/)).toBeTruthy(); + }); +}); diff --git a/apps/web/src/app/tab-utils.test.ts b/apps/web/src/app/tab-utils.test.ts new file mode 100644 index 0000000..4d0c7fb --- /dev/null +++ b/apps/web/src/app/tab-utils.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { computeLabels } from './tab-utils'; + +describe('computeLabels', () => { + it('returns empty array for empty input', () => { + expect(computeLabels([])).toEqual([]); + }); + + it('returns basename for a single file', () => { + const result = computeLabels(['/home/user/project/plan.vync']); + expect(result).toEqual([ + { filePath: '/home/user/project/plan.vync', label: 'plan.vync' }, + ]); + }); + + it('returns basename when all basenames are unique', () => { + const result = computeLabels(['/a/foo.vync', '/b/bar.vync']); + expect(result).toEqual([ + { filePath: '/a/foo.vync', label: 'foo.vync' }, + { filePath: '/b/bar.vync', label: 'bar.vync' }, + ]); + }); + + it('uses parent/basename for duplicate basenames', () => { + const result = computeLabels([ + '/projects/alpha/plan.vync', + '/projects/beta/plan.vync', + ]); + expect(result).toEqual([ + { filePath: '/projects/alpha/plan.vync', label: 'alpha/plan.vync' }, + { filePath: '/projects/beta/plan.vync', label: 'beta/plan.vync' }, + ]); + }); + + it('handles filename-only paths (no directory)', () => { + const result = computeLabels(['plan.vync']); + expect(result).toEqual([{ filePath: 'plan.vync', label: 'plan.vync' }]); + }); + + it('disambiguates only duplicates, leaves uniques as basename', () => { + const result = computeLabels([ + '/a/plan.vync', + '/b/plan.vync', + '/c/notes.vync', + ]); + expect(result).toEqual([ + { filePath: '/a/plan.vync', label: 'a/plan.vync' }, + { filePath: '/b/plan.vync', label: 'b/plan.vync' }, + { filePath: '/c/notes.vync', label: 'notes.vync' }, + ]); + }); +}); diff --git a/docs/ISSUES.md b/docs/ISSUES.md index ec069e6..a9ccb20 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -15,7 +15,7 @@ | I-004 | [probePort()가 mode를 항상 'daemon'으로 덮어씀](#i-004) | minor | resolved | CLI (open.ts) | 2026-03-14 | | I-005 | [Semantic Sync 확신 과대평가 — 단일축 판단 + 무검증 수용](#i-005) | major | open | vync-translator, vync.md | 2026-03-14 | | I-006 | [Diff 엔진 시각적 변동 미감지 — 위치/크기 변경 무시](#i-006) | minor | open | diff.ts | 2026-03-14 | -| I-007 | [React 컴포넌트 테스트 커버리지 부재](#i-007) | minor | open | apps/web, react-board, react-text | 2026-04-04 | +| I-007 | [React 컴포넌트 테스트 커버리지 부재](#i-007) | minor | resolved | apps/web, react-board, react-text | 2026-04-04 | | I-008 | [file-board.tsx 디버그 console.log 잔존](#i-008) | minor | resolved | apps/web | 2026-04-04 | --- @@ -217,16 +217,18 @@ Semantic Sync 회귀 테스트(3라운드)에서 translator의 확신 레벨이 **React 컴포넌트 테스트 커버리지 부재** -심각도: `minor` · 상태: `open` · 발견일: 2026-04-04 +심각도: `minor` · 상태: `resolved` · 발견일: 2026-04-04 · 해결일: 2026-04-04 컴포넌트: `apps/web/src/app/`, `packages/react-board/`, `packages/react-text/` **현상**: graph-view mappers를 제외하면 React 컴포넌트에 대한 테스트가 전혀 없음. `FileBoard`, `TabBar`, `App`, board hooks 모두 미테스트. -**위험 영역**: -- `file-board.tsx`의 reconnect-recovery 경로 (404 → re-register → retry) -- `file-board.tsx`의 `applyExternalChanges` diff 로직 -- `tab-bar.tsx`의 파일 열기/닫기/재열기 플로우 +**해결**: +하이브리드 접근법 (순수 함수 추출 + RTL 렌더링 테스트): +- `computeElementDiff()` → `board-utils.ts`로 추출, 12 유닛 테스트 (remove/set/insert 3단계 각각 검증) +- `computeLabels()` → 6 유닛 테스트 (중복 basename 분기 등) +- `TabBar` → 8 RTL 렌더링 테스트 (탭 클릭, 닫기, 드롭다운, 빈 상태) +- 설계 spec: `docs/superpowers/specs/2026-04-04-react-test-coverage-design.md` --- diff --git a/docs/PLAN.md b/docs/PLAN.md index 4b404db..fae4648 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -35,7 +35,10 @@ - 3팀(canvas/codex/sync) 병렬 코드 리뷰: 데드코드/타입/a11y 수정 - I-001, I-008 resolved. I-006 데드코드 정리. I-007 설계 완료 (구현 대기) - 114개 테스트 PASS -- I-007 React 테스트 커버리지: 설계 완료 (spec: `docs/superpowers/specs/2026-04-04-react-test-coverage-design.md`), 구현 대기 +- I-007 React 테스트 커버리지: 구현 완료 (2026-04-04, `feat/i-007-react-tests`) + - `computeElementDiff()` 순수 함수 추출 → `board-utils.ts` + - 테스트 3파일 26케이스: board-utils(12), tab-utils(6), tab-bar RTL(8) + - 140개 테스트 PASS (26 신규) --- @@ -66,6 +69,7 @@ | — | Graph View PoC (F-008) | 2026-03-16 | develop | React Flow v12 + ELK.js, 11/11 시나리오 PASS | | — | Graph View 구현 (F-008) | 2026-04-04 | develop | VyncFile union, type routing, useGraphSync, 114 tests | | — | 프로젝트 통합 리뷰 | 2026-04-04 | develop | 빌드 안정화, 3팀 코드 리뷰, I-001/I-008 resolved | +| — | I-007 React 테스트 커버리지 | 2026-04-04 | feat/i-007-react-tests | computeElementDiff 추출, 26 테스트 (board-utils/tab-utils/tab-bar) | --- diff --git a/package-lock.json b/package-lock.json index ce5885d..613e3ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,6 +66,7 @@ "@swc/cli": "^0.6.0", "@swc/core": "~1.5.7", "@swc/helpers": "~0.5.11", + "@testing-library/dom": "^10.4.1", "@testing-library/react": "16.3.0", "@types/express": "^5.0.6", "@types/is-hotkey": "^0.1.10", @@ -6631,6 +6632,26 @@ "node": ">=14.16" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@testing-library/react": { "version": "16.3.0", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", @@ -6724,6 +6745,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -11488,6 +11516,13 @@ "node": ">=6.0.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -16568,6 +16603,16 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", @@ -18697,6 +18742,34 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/proc-log": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-3.0.0.tgz", @@ -18976,6 +19049,13 @@ "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==" }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", diff --git a/package.json b/package.json index 96ee3dc..f19e092 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "@swc/cli": "^0.6.0", "@swc/core": "~1.5.7", "@swc/helpers": "~0.5.11", + "@testing-library/dom": "^10.4.1", "@testing-library/react": "16.3.0", "@types/express": "^5.0.6", "@types/is-hotkey": "^0.1.10",