diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 251946e7c7..d37c28ef03 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -139,3 +139,74 @@ jobs: - name: Skip notice if: steps.check.outputs.should_test == 'false' run: echo "::notice::Tests skipped - no testable Python changes (only plots/ or non-Python files)" + + test-frontend: + name: Run Frontend Tests + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Check for frontend changes + id: check + run: | + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + BASE_SHA="${{ github.event.pull_request.base.sha }}" + HEAD_SHA="${{ github.event.pull_request.head.sha }}" + CHANGED_FILES=$(git diff --name-only $BASE_SHA $HEAD_SHA) + else + CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD 2>/dev/null || echo "") + fi + + echo "Changed files:" + echo "$CHANGED_FILES" + + # Check for frontend changes + FRONTEND_CHANGES=$(echo "$CHANGED_FILES" | grep '^app/' || true) + + # Force run on workflow_dispatch with force_run=true + if [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.force_run }}" == "true" ]]; then + echo "Manual trigger with force_run=true, will run frontend tests" + echo "should_test=true" >> $GITHUB_OUTPUT + elif [[ -n "$FRONTEND_CHANGES" ]]; then + echo "Found frontend changes, will run tests" + echo "should_test=true" >> $GITHUB_OUTPUT + else + echo "No frontend changes, skipping frontend tests" + echo "should_test=false" >> $GITHUB_OUTPUT + fi + + - name: Set up Node.js + if: steps.check.outputs.should_test == 'true' + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'yarn' + cache-dependency-path: app/yarn.lock + + - name: Install dependencies + if: steps.check.outputs.should_test == 'true' + working-directory: app + run: yarn install --frozen-lockfile + + - name: Run frontend tests with coverage + if: steps.check.outputs.should_test == 'true' + working-directory: app + run: yarn test --coverage + + - name: Upload frontend coverage to Codecov + if: steps.check.outputs.should_test == 'true' + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./app/coverage/coverage-final.json + flags: frontend + fail_ci_if_error: false + + - name: Skip notice + if: steps.check.outputs.should_test == 'false' + run: echo "::notice::Frontend tests skipped - no app/ changes" diff --git a/.gitignore b/.gitignore index dce7411759..6415cd215d 100644 --- a/.gitignore +++ b/.gitignore @@ -216,6 +216,7 @@ dist/ *.tsbuildinfo .yarn/ .pnp.* +app/coverage/ # OS-specific files .DS_Store diff --git a/api/routers/plots.py b/api/routers/plots.py index cf1d2c10cd..eef905ee88 100644 --- a/api/routers/plots.py +++ b/api/routers/plots.py @@ -379,7 +379,7 @@ def _collect_all_images(all_specs: list) -> list[dict]: all_specs: List of Spec objects Returns: - List of image dicts with spec_id, library, quality, url, thumb, and html + List of image dicts with spec_id, library, quality, url, thumb, html, and title """ all_images: list[dict] = [] for spec_obj in all_specs: @@ -395,6 +395,7 @@ def _collect_all_images(all_specs: list) -> list[dict]: "url": impl.preview_url, "thumb": impl.preview_thumb, "html": impl.preview_html, + "title": spec_obj.title, } ) return all_images @@ -483,6 +484,9 @@ async def get_filtered_plots(request: Request, db: AsyncSession = Depends(requir counts = _calculate_contextual_counts(filtered_images, spec_id_to_tags, impl_lookup) or_counts = _calculate_or_counts(filter_groups, all_images, spec_id_to_tags, spec_lookup, impl_lookup) + # Build spec_id -> title mapping for search/tooltips + spec_titles = {spec_id: data["spec"].title for spec_id, data in spec_lookup.items() if data["spec"].title} + # Build and cache response result = FilteredPlotsResponse( total=len(filtered_images), @@ -490,6 +494,7 @@ async def get_filtered_plots(request: Request, db: AsyncSession = Depends(requir counts=counts, globalCounts=global_counts, orCounts=or_counts, + specTitles=spec_titles, ) try: diff --git a/api/schemas.py b/api/schemas.py index 96ea23adde..2380367067 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -97,6 +97,7 @@ class FilteredPlotsResponse(BaseModel): counts: dict globalCounts: dict orCounts: list[dict] + specTitles: dict[str, str] = {} # Mapping spec_id -> title for search/tooltips class LibraryInfo(BaseModel): diff --git a/app/package.json b/app/package.json index 397baa9dc5..507864e04f 100644 --- a/app/package.json +++ b/app/package.json @@ -9,7 +9,9 @@ "build": "tsc && vite build", "preview": "vite preview", "lint": "eslint src --ext ts,tsx", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" }, "engines": { "node": ">=20" @@ -20,6 +22,7 @@ "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.3.7", "@mui/material": "^7.3.7", + "fuse.js": "^7.1.0", "react": "^19.2.3", "react-dom": "^19.2.3", "react-helmet-async": "^2.0.5", @@ -31,7 +34,9 @@ "@types/react-dom": "^19.1.7", "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react-swc": "^4.0.0", + "@vitest/coverage-v8": "^4.0.17", "typescript": "^5.9.2", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^4.0.17" } } diff --git a/app/src/components/FilterBar.tsx b/app/src/components/FilterBar.tsx index 624ed00928..8f64b3b607 100644 --- a/app/src/components/FilterBar.tsx +++ b/app/src/components/FilterBar.tsx @@ -21,12 +21,13 @@ import { useTheme } from '@mui/material/styles'; import type { FilterCategory, ActiveFilters, FilterCounts } from '../types'; import { FILTER_LABELS, FILTER_TOOLTIPS, FILTER_CATEGORIES } from '../types'; import type { ImageSize } from '../constants'; -import { getAvailableValues, getAvailableValuesForGroup, getSearchResults } from '../utils'; +import { getAvailableValues, getAvailableValuesForGroup, getSearchResults, type SearchResult } from '../utils'; interface FilterBarProps { activeFilters: ActiveFilters; filterCounts: FilterCounts | null; // Contextual counts (for AND additions) orCounts: Record[]; // Per-group counts for OR additions + specTitles: Record; // Mapping spec_id -> title for search/tooltips currentTotal: number; // Total number of filtered images displayedCount: number; // Currently displayed images randomAnimation: { index: number; phase: 'out' | 'in'; oldLabel?: string } | null; @@ -44,6 +45,7 @@ export function FilterBar({ activeFilters, filterCounts, orCounts, + specTitles, currentTotal, displayedCount, randomAnimation, @@ -208,8 +210,8 @@ export function FilterBar({ // Memoize search results to avoid recalculating on every render const searchResults = useMemo( - () => getSearchResults(filterCounts, activeFilters, searchQuery, selectedCategory), - [filterCounts, activeFilters, searchQuery, selectedCategory] + () => getSearchResults(filterCounts, activeFilters, searchQuery, selectedCategory, specTitles), + [filterCounts, activeFilters, searchQuery, selectedCategory, specTitles] ); // Track searches with no results (debounced, to discover missing specs) @@ -699,38 +701,83 @@ export function FilterBar({ ] : []), ...(searchResults.length > 0 - ? searchResults.map(({ category, value, count }, idx) => ( - handleValueSelect(category, value)} - selected={idx === highlightedIndex} - sx={{ fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace' }} - > - - - ({count}) - - - )) + ? (() => { + // Split results into exact and fuzzy matches + const exactResults = searchResults.filter((r) => r.matchType === 'exact'); + const fuzzyResults = searchResults.filter((r) => r.matchType === 'fuzzy'); + + const renderMenuItem = (result: SearchResult, idx: number) => { + const { category, value, count } = result; + const specTitle = category === 'spec' ? specTitles[value] : undefined; + const menuItem = ( + handleValueSelect(category, value)} + selected={idx === highlightedIndex} + sx={{ fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace' }} + > + + + ({count}) + + + ); + return specTitle ? ( + + {menuItem} + + ) : ( + menuItem + ); + }; + + const items: React.ReactNode[] = []; + // Add exact matches + exactResults.forEach((result, i) => { + items.push(renderMenuItem(result, i)); + }); + // Add fuzzy label/divider if there are fuzzy results + if (fuzzyResults.length > 0) { + items.push( + + + fuzzy + + + ); + } + // Add fuzzy matches + fuzzyResults.forEach((result, i) => { + items.push(renderMenuItem(result, exactResults.length + i)); + }); + return items; + })() : [ []; + specTitles: Record; allImages: PlotImage[]; displayedImages: PlotImage[]; hasMore: boolean; @@ -55,6 +56,7 @@ export function useFilterFetch({ const [filterCounts, setFilterCounts] = useState(initialState.filterCounts ?? null); const [globalCounts, setGlobalCounts] = useState(initialState.globalCounts ?? null); const [orCounts, setOrCounts] = useState[]>(initialState.orCounts ?? []); + const [specTitles, setSpecTitles] = useState>(initialState.specTitles ?? {}); const [allImages, setAllImages] = useState(initialState.allImages ?? []); const [displayedImages, setDisplayedImages] = useState(initialState.displayedImages ?? []); const [hasMore, setHasMore] = useState(initialState.hasMore ?? false); @@ -97,10 +99,11 @@ export function useFilterFetch({ if (abortController.signal.aborted) return; - // Update filter counts + // Update filter counts and spec titles setFilterCounts(data.counts); setGlobalCounts(data.globalCounts || data.counts); setOrCounts(data.orCounts || []); + setSpecTitles(data.specTitles || {}); // Shuffle images randomly on each load const shuffled = shuffleArray(data.images || []); @@ -128,6 +131,7 @@ export function useFilterFetch({ filterCounts, globalCounts, orCounts, + specTitles, allImages, displayedImages, hasMore, diff --git a/app/src/hooks/useFilterState.ts b/app/src/hooks/useFilterState.ts index 4ffc84bf5e..51ecef4351 100644 --- a/app/src/hooks/useFilterState.ts +++ b/app/src/hooks/useFilterState.ts @@ -31,6 +31,7 @@ interface UseFilterStateReturn { filterCounts: FilterCounts | null; globalCounts: FilterCounts | null; orCounts: Record[]; + specTitles: Record; allImages: PlotImage[]; displayedImages: PlotImage[]; hasMore: boolean; @@ -86,6 +87,7 @@ export function useFilterState({ filterCounts, globalCounts, orCounts, + specTitles, allImages, displayedImages, hasMore, @@ -234,6 +236,7 @@ export function useFilterState({ filterCounts, globalCounts, orCounts, + specTitles, allImages, displayedImages, hasMore, diff --git a/app/src/pages/HomePage.tsx b/app/src/pages/HomePage.tsx index b9b0b73461..21b299eefc 100644 --- a/app/src/pages/HomePage.tsx +++ b/app/src/pages/HomePage.tsx @@ -30,6 +30,7 @@ export function HomePage() { activeFilters, filterCounts, orCounts, + specTitles, allImages, displayedImages, hasMore, @@ -167,6 +168,7 @@ export function HomePage() { activeFilters={activeFilters} filterCounts={filterCounts} orCounts={orCounts} + specTitles={specTitles} currentTotal={allImages.length} displayedCount={displayedImages.length} randomAnimation={randomAnimation} diff --git a/app/src/types/index.ts b/app/src/types/index.ts index a4b7d6238f..121e0d1b8b 100644 --- a/app/src/types/index.ts +++ b/app/src/types/index.ts @@ -7,6 +7,7 @@ export interface PlotImage { html?: string; code?: string; spec_id?: string; + title?: string; } // Filter system types @@ -95,6 +96,7 @@ export interface FilteredPlotsResponse { counts: FilterCounts; // Contextual counts (for AND additions) globalCounts: FilterCounts; // Global counts (for reference) orCounts: Record[]; // Per-group counts for OR additions + specTitles: Record; // Mapping spec_id -> title for search/tooltips } export interface LibraryInfo { diff --git a/app/src/utils/filters.test.ts b/app/src/utils/filters.test.ts new file mode 100644 index 0000000000..9f9afd68d7 --- /dev/null +++ b/app/src/utils/filters.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from 'vitest'; +import { getSearchResults } from './filters'; +import type { FilterCounts, ActiveFilters } from '../types'; + +describe('getSearchResults', () => { + const mockFilterCounts: FilterCounts = { + spec: { + 'scatter-basic': 5, + 'scatter-color-mapped': 3, + 'heatmap-correlation': 4, + 'histogram-kde': 2, + 'bar-grouped': 6, + }, + lib: { + matplotlib: 10, + seaborn: 8, + plotly: 7, + }, + tag: { + basic: 15, + advanced: 10, + }, + }; + + const emptyFilters: ActiveFilters = []; + + it('returns empty array when filterCounts is null', () => { + const results = getSearchResults(null, emptyFilters, 'scatter', null); + expect(results).toEqual([]); + }); + + it('returns empty array when searchQuery is empty', () => { + const results = getSearchResults(mockFilterCounts, emptyFilters, '', null); + expect(results).toEqual([]); + }); + + it('finds exact matches', () => { + const results = getSearchResults(mockFilterCounts, emptyFilters, 'scatter', null); + + expect(results.length).toBeGreaterThan(0); + expect(results.some((r) => r.value === 'scatter-basic')).toBe(true); + }); + + it('finds matches with typos', () => { + const results = getSearchResults(mockFilterCounts, emptyFilters, 'scater', null); + + expect(results.length).toBeGreaterThan(0); + expect(results.some((r) => r.value.includes('scatter'))).toBe(true); + }); + + it('assigns matchType correctly', () => { + const results = getSearchResults(mockFilterCounts, emptyFilters, 'scatter', null); + + expect(results.length).toBeGreaterThan(0); + results.forEach((result) => { + expect(['exact', 'fuzzy']).toContain(result.matchType); + }); + }); + + it('sorts exact matches before fuzzy matches', () => { + const results = getSearchResults(mockFilterCounts, emptyFilters, 'scater', null); + + if (results.length > 1) { + const exactIndices = results + .map((r, i) => (r.matchType === 'exact' ? i : -1)) + .filter((i) => i >= 0); + const fuzzyIndices = results + .map((r, i) => (r.matchType === 'fuzzy' ? i : -1)) + .filter((i) => i >= 0); + + if (exactIndices.length > 0 && fuzzyIndices.length > 0) { + const maxExactIndex = Math.max(...exactIndices); + const minFuzzyIndex = Math.min(...fuzzyIndices); + expect(maxExactIndex).toBeLessThan(minFuzzyIndex); + } + } + }); + + it('filters by selected category', () => { + const results = getSearchResults(mockFilterCounts, emptyFilters, 'scatter', 'spec'); + + results.forEach((result) => { + expect(result.category).toBe('spec'); + }); + }); + + it('excludes already selected values', () => { + const activeFilters: ActiveFilters = [{ category: 'spec', values: ['scatter-basic'] }]; + const results = getSearchResults(mockFilterCounts, activeFilters, 'scatter', null); + + expect(results.some((r) => r.value === 'scatter-basic')).toBe(false); + }); + + it('searches spec titles when provided', () => { + const specTitles = { + 'scatter-basic': 'Basic Scatter Plot', + 'heatmap-correlation': 'Correlation Heatmap Matrix', + }; + const results = getSearchResults( + mockFilterCounts, + emptyFilters, + 'correlation matrix', + 'spec', + specTitles + ); + + expect(results.length).toBeGreaterThan(0); + expect(results.some((r) => r.value === 'heatmap-correlation')).toBe(true); + }); + + it('includes count in results', () => { + const results = getSearchResults(mockFilterCounts, emptyFilters, 'scatter', null); + + expect(results.length).toBeGreaterThan(0); + results.forEach((result) => { + expect(typeof result.count).toBe('number'); + expect(result.count).toBeGreaterThan(0); + }); + }); + + it('supports multi-word queries', () => { + const results = getSearchResults(mockFilterCounts, emptyFilters, 'hi k', 'spec'); + + expect(results.some((r) => r.value === 'histogram-kde')).toBe(true); + }); +}); diff --git a/app/src/utils/filters.ts b/app/src/utils/filters.ts index 4de42dfccc..ef82b012c5 100644 --- a/app/src/utils/filters.ts +++ b/app/src/utils/filters.ts @@ -6,6 +6,7 @@ import type { FilterCategory, ActiveFilters, FilterCounts } from '../types'; import { FILTER_CATEGORIES } from '../types'; +import { createFuzzySearcher, getMatchType, type MatchType } from './fuzzySearch'; /** * Get counts for a specific filter category. @@ -74,25 +75,45 @@ export function getAvailableValuesForGroup( .sort((a, b) => b[1] - a[1]); } +export interface SearchResult { + category: FilterCategory; + value: string; + count: number; + matchType: MatchType; +} + /** - * Search across all filter categories. + * Search across all filter categories using fuzzy matching. + * + * Uses fuse.js for typo-tolerant search. Results are grouped by match quality: + * - 'exact': Very close matches (score < 0.1) + * - 'fuzzy': Looser matches with typo tolerance (score >= 0.1) + * + * For the "spec" category, also searches through spec titles. * * @param filterCounts - Available filter counts * @param activeFilters - Current active filters * @param searchQuery - Search query string * @param selectedCategory - Optional category to limit search to - * @returns Matching results sorted by count + * @param specTitles - Optional mapping of spec_id to title for enhanced spec search + * @returns Matching results sorted by match quality and count + * + * @example + * // Query: "scater" will find "scatter-basic" (typo tolerance) + * // Query: "heatmp" will find "heatmap-correlation" + * // Exact matches appear first, fuzzy matches after a divider */ export function getSearchResults( filterCounts: FilterCounts | null, activeFilters: ActiveFilters, searchQuery: string, - selectedCategory: FilterCategory | null -): { category: FilterCategory; value: string; count: number }[] { - if (!filterCounts) return []; + selectedCategory: FilterCategory | null, + specTitles: Record = {} +): SearchResult[] { + if (!filterCounts || !searchQuery.trim()) return []; const query = searchQuery.toLowerCase().trim(); - const results: { category: FilterCategory; value: string; count: number }[] = []; + const results: Array = []; const categoriesToSearch = selectedCategory ? [selectedCategory] : FILTER_CATEGORIES; @@ -100,12 +121,44 @@ export function getSearchResults( const counts = getCounts(filterCounts, category); const selected = getSelectedValuesForCategory(activeFilters, category); - for (const [value, count] of Object.entries(counts)) { - if (selected.includes(value)) continue; - if (query && !value.toLowerCase().includes(query)) continue; - results.push({ category, value, count }); + // Build searchable items for this category + const items = Object.keys(counts) + .filter((value) => !selected.includes(value)) + .map((value) => ({ + value, + title: category === 'spec' ? specTitles[value] : undefined, + count: counts[value], + })); + + if (items.length === 0) continue; + + // Create fuzzy searcher and search + const searcher = createFuzzySearcher(items); + const matches = searcher.search(query); + + for (const match of matches) { + const score = match.score ?? 0; + results.push({ + category, + value: match.item.value, + count: match.item.count, + score, + matchType: getMatchType(score), + }); } } - return results.sort((a, b) => b.count - a.count); + // Sort: exact first, then by score (lower = better), then by count + return results.sort((a, b) => { + // Exact matches before fuzzy + if (a.matchType !== b.matchType) { + return a.matchType === 'exact' ? -1 : 1; + } + // Within same type, sort by score (lower is better in fuse.js) + if (a.score !== b.score) { + return a.score - b.score; + } + // Then by count (higher is better) + return b.count - a.count; + }); } diff --git a/app/src/utils/fuzzySearch.test.ts b/app/src/utils/fuzzySearch.test.ts new file mode 100644 index 0000000000..6a19a90257 --- /dev/null +++ b/app/src/utils/fuzzySearch.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest'; +import { createFuzzySearcher, getMatchType } from './fuzzySearch'; + +describe('fuzzySearch', () => { + describe('getMatchType', () => { + it('returns "exact" for scores below threshold (0.1)', () => { + expect(getMatchType(0)).toBe('exact'); + expect(getMatchType(0.05)).toBe('exact'); + expect(getMatchType(0.09)).toBe('exact'); + }); + + it('returns "fuzzy" for scores at or above threshold (0.1)', () => { + expect(getMatchType(0.1)).toBe('fuzzy'); + expect(getMatchType(0.2)).toBe('fuzzy'); + expect(getMatchType(0.3)).toBe('fuzzy'); + }); + }); + + describe('createFuzzySearcher', () => { + it('finds exact matches', () => { + const items = [ + { value: 'scatter-basic' }, + { value: 'heatmap-correlation' }, + { value: 'bar-grouped' }, + ]; + const searcher = createFuzzySearcher(items); + const results = searcher.search('scatter'); + + expect(results.length).toBeGreaterThan(0); + expect(results[0].item.value).toBe('scatter-basic'); + }); + + it('finds matches with typos (scater -> scatter)', () => { + const items = [ + { value: 'scatter-basic' }, + { value: 'scatter-color-mapped' }, + { value: 'heatmap-correlation' }, + ]; + const searcher = createFuzzySearcher(items); + const results = searcher.search('scater'); + + expect(results.length).toBeGreaterThan(0); + expect(results[0].item.value).toContain('scatter'); + }); + + it('finds matches with typos (heatmp -> heatmap)', () => { + const items = [ + { value: 'scatter-basic' }, + { value: 'heatmap-correlation' }, + { value: 'heatmap-annotated' }, + ]; + const searcher = createFuzzySearcher(items); + const results = searcher.search('heatmp'); + + expect(results.length).toBeGreaterThan(0); + expect(results[0].item.value).toContain('heatmap'); + }); + + it('searches in title field when provided', () => { + const items = [ + { value: 'chessboard-pieces', title: 'Chess Board Position' }, + { value: 'scatter-basic', title: 'Basic Scatter Plot' }, + ]; + const searcher = createFuzzySearcher(items); + const results = searcher.search('chess'); + + expect(results.length).toBeGreaterThan(0); + expect(results[0].item.value).toBe('chessboard-pieces'); + }); + + it('supports multi-word AND queries', () => { + const items = [ + { value: 'histogram-kde' }, + { value: 'histogram-basic' }, + { value: 'bar-grouped' }, + ]; + const searcher = createFuzzySearcher(items); + const results = searcher.search('hi k'); + + expect(results.length).toBeGreaterThan(0); + expect(results[0].item.value).toBe('histogram-kde'); + }); + + it('returns results with scores', () => { + const items = [{ value: 'scatter-basic' }]; + const searcher = createFuzzySearcher(items); + const results = searcher.search('scatter'); + + expect(results[0].score).toBeDefined(); + expect(typeof results[0].score).toBe('number'); + }); + }); +}); diff --git a/app/src/utils/fuzzySearch.ts b/app/src/utils/fuzzySearch.ts new file mode 100644 index 0000000000..2983047131 --- /dev/null +++ b/app/src/utils/fuzzySearch.ts @@ -0,0 +1,73 @@ +/** + * Fuzzy search utilities using fuse.js. + * + * Provides typo-tolerant search for filter values. + */ + +import Fuse from 'fuse.js'; + +/** Maximum score to count as a match (0 = perfect, 1 = no match) */ +const FUZZY_THRESHOLD = 0.3; + +/** Score below this is considered an "exact" match for UI grouping */ +const EXACT_THRESHOLD = 0.1; + +export type MatchType = 'exact' | 'fuzzy'; + +export interface SearchableItem { + value: string; + title?: string; +} + +export interface FuzzyResult { + item: T; + score: number; + matchType: MatchType; +} + +/** + * Create a fuzzy searcher for filter values. + * + * @param items - Items to search through + * @returns Configured Fuse instance + */ +export function createFuzzySearcher(items: T[]): Fuse { + return new Fuse(items, { + keys: ['value', 'title'], + threshold: FUZZY_THRESHOLD, + distance: 100, + minMatchCharLength: 1, + includeScore: true, + useExtendedSearch: true, + }); +} + +/** + * Determine match type based on fuse.js score. + * + * @param score - Fuse.js score (0 = perfect match, 1 = no match) + * @returns 'exact' for very good matches, 'fuzzy' for looser matches + */ +export function getMatchType(score: number): MatchType { + return score < EXACT_THRESHOLD ? 'exact' : 'fuzzy'; +} + +/** + * Perform fuzzy search and return results with match type. + * + * @param searcher - Fuse instance + * @param query - Search query + * @returns Array of results with score and match type + */ +export function fuzzySearch( + searcher: Fuse, + query: string +): FuzzyResult[] { + const results = searcher.search(query); + + return results.map((result) => ({ + item: result.item, + score: result.score ?? 0, + matchType: getMatchType(result.score ?? 0), + })); +} diff --git a/app/src/utils/index.ts b/app/src/utils/index.ts index c24c1adf29..dc60ec8d41 100644 --- a/app/src/utils/index.ts +++ b/app/src/utils/index.ts @@ -4,3 +4,4 @@ export * from './tooltip'; export * from './filters'; +export * from './fuzzySearch'; diff --git a/app/vitest.config.ts b/app/vitest.config.ts new file mode 100644 index 0000000000..66c44cd170 --- /dev/null +++ b/app/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: ['src/utils/**/*.ts'], + exclude: ['src/**/*.test.ts'], + }, + }, +}); diff --git a/app/yarn.lock b/app/yarn.lock index cd6be9d99e..531d927a73 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -87,6 +87,11 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.28.5" +"@bcoe/v8-coverage@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz#bbe12dca5b4ef983a0d0af4b07b9bc90ea0ababa" + integrity sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA== + "@emotion/babel-plugin@^11.13.5": version "11.13.5" resolved "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz" @@ -337,12 +342,12 @@ resolved "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz" integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== -"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0": +"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0", "@jridgewell/sourcemap-codec@^1.5.5": version "1.5.5" resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz" integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== -"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.28": +"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.28", "@jridgewell/trace-mapping@^0.3.31": version "0.3.31" resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz" integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== @@ -554,6 +559,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.2.tgz#d9ab606437fd072b2cb7df7e54bcdc7f1ccbe8b4" integrity sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA== +"@standard-schema/spec@^1.0.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8" + integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== + "@swc/core-darwin-arm64@1.15.2": version "1.15.2" resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.2.tgz#591fde48757f9c66f050eb82353def199c9967af" @@ -635,7 +645,20 @@ dependencies: "@swc/counter" "^0.1.3" -"@types/estree@1.0.8": +"@types/chai@^5.2.2": + version "5.2.3" + resolved "https://registry.yarnpkg.com/@types/chai/-/chai-5.2.3.tgz#8e9cd9e1c3581fa6b341a5aed5588eb285be0b4a" + integrity sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA== + dependencies: + "@types/deep-eql" "*" + assertion-error "^2.0.1" + +"@types/deep-eql@*": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@types/deep-eql/-/deep-eql-4.0.2.tgz#334311971d3a07121e7eb91b684a605e7eea9cbd" + integrity sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw== + +"@types/estree@1.0.8", "@types/estree@^1.0.0": version "1.0.8" resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz" integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== @@ -704,6 +727,94 @@ "@rolldown/pluginutils" "1.0.0-beta.47" "@swc/core" "^1.13.5" +"@vitest/coverage-v8@^4.0.17": + version "4.0.17" + resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-4.0.17.tgz#3bb100e9a6766de282049fba28e21a010a73509a" + integrity sha512-/6zU2FLGg0jsd+ePZcwHRy3+WpNTBBhDY56P4JTRqUN/Dp6CvOEa9HrikcQ4KfV2b2kAHUFB4dl1SuocWXSFEw== + dependencies: + "@bcoe/v8-coverage" "^1.0.2" + "@vitest/utils" "4.0.17" + ast-v8-to-istanbul "^0.3.10" + istanbul-lib-coverage "^3.2.2" + istanbul-lib-report "^3.0.1" + istanbul-reports "^3.2.0" + magicast "^0.5.1" + obug "^2.1.1" + std-env "^3.10.0" + tinyrainbow "^3.0.3" + +"@vitest/expect@4.0.17": + version "4.0.17" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-4.0.17.tgz#67bb0d4a7d37054590a19dcf831f7936d14a8a02" + integrity sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ== + dependencies: + "@standard-schema/spec" "^1.0.0" + "@types/chai" "^5.2.2" + "@vitest/spy" "4.0.17" + "@vitest/utils" "4.0.17" + chai "^6.2.1" + tinyrainbow "^3.0.3" + +"@vitest/mocker@4.0.17": + version "4.0.17" + resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-4.0.17.tgz#ce559098be1ae18ae5aa441a79939bcd7fc8f42f" + integrity sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ== + dependencies: + "@vitest/spy" "4.0.17" + estree-walker "^3.0.3" + magic-string "^0.30.21" + +"@vitest/pretty-format@4.0.17": + version "4.0.17" + resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-4.0.17.tgz#dde7cb2c01699d0943571137d1b482edff5fc000" + integrity sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw== + dependencies: + tinyrainbow "^3.0.3" + +"@vitest/runner@4.0.17": + version "4.0.17" + resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-4.0.17.tgz#dcc3bb4a4b1077858f3b105e91b2eeb208cee780" + integrity sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ== + dependencies: + "@vitest/utils" "4.0.17" + pathe "^2.0.3" + +"@vitest/snapshot@4.0.17": + version "4.0.17" + resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-4.0.17.tgz#40d71a3dad4ac39812ed96a95fded46a920e1a31" + integrity sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ== + dependencies: + "@vitest/pretty-format" "4.0.17" + magic-string "^0.30.21" + pathe "^2.0.3" + +"@vitest/spy@4.0.17": + version "4.0.17" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-4.0.17.tgz#d0936f8908b4dae091d9b948be3bae8e19d1878d" + integrity sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew== + +"@vitest/utils@4.0.17": + version "4.0.17" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-4.0.17.tgz#48181deab273c87ac4ee20c1c454ffe9c4f453fe" + integrity sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w== + dependencies: + "@vitest/pretty-format" "4.0.17" + tinyrainbow "^3.0.3" + +assertion-error@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" + integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== + +ast-v8-to-istanbul@^0.3.10: + version "0.3.10" + resolved "https://registry.yarnpkg.com/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.10.tgz#ceff0094c8c64b9e04393c2377fd61857429ec04" + integrity sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ== + dependencies: + "@jridgewell/trace-mapping" "^0.3.31" + estree-walker "^3.0.3" + js-tokens "^9.0.1" + babel-plugin-macros@^3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz" @@ -718,6 +829,11 @@ callsites@^3.0.0: resolved "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz" integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== +chai@^6.2.1: + version "6.2.2" + resolved "https://registry.yarnpkg.com/chai/-/chai-6.2.2.tgz#ae41b52c9aca87734505362717f3255facda360e" + integrity sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg== + character-entities-legacy@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz" @@ -798,6 +914,11 @@ error-ex@^1.3.1: dependencies: is-arrayish "^0.2.1" +es-module-lexer@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz#9159601561880a85f2734560a9099b2c31e5372a" + integrity sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA== + esbuild@^0.27.0: version "0.27.1" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.27.1.tgz#56bf43e6a4b4d2004642ec7c091b78de02b0831a" @@ -835,6 +956,18 @@ escape-string-regexp@^4.0.0: resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz" integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== +estree-walker@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" + integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== + dependencies: + "@types/estree" "^1.0.0" + +expect-type@^1.2.2: + version "1.3.0" + resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.3.0.tgz#0d58ed361877a31bbc4dd6cf71bbfef7faf6bd68" + integrity sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA== + fault@^1.0.0: version "1.0.4" resolved "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz" @@ -867,6 +1000,16 @@ function-bind@^1.1.2: resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz" integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== +fuse.js@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-7.1.0.tgz#306228b4befeee11e05b027087c2744158527d09" + integrity sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ== + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + hasown@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz" @@ -909,6 +1052,11 @@ hoist-non-react-statics@^3.3.1: dependencies: react-is "^16.7.0" +html-escaper@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" + integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== + import-fresh@^3.2.1: version "3.3.1" resolved "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz" @@ -959,11 +1107,38 @@ is-hexadecimal@^2.0.0: resolved "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz" integrity sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg== +istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz#2d166c4b0644d43a39f04bf6c2edd1e585f31756" + integrity sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg== + +istanbul-lib-report@^3.0.0, istanbul-lib-report@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz#908305bac9a5bd175ac6a74489eafd0fc2445a7d" + integrity sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw== + dependencies: + istanbul-lib-coverage "^3.0.0" + make-dir "^4.0.0" + supports-color "^7.1.0" + +istanbul-reports@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.2.0.tgz#cb4535162b5784aa623cee21a7252cf2c807ac93" + integrity sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA== + dependencies: + html-escaper "^2.0.0" + istanbul-lib-report "^3.0.0" + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== +js-tokens@^9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-9.0.1.tgz#2ec43964658435296f6761b34e10671c2d9527f4" + integrity sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ== + jsesc@^3.0.2: version "3.1.0" resolved "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz" @@ -994,6 +1169,29 @@ lowlight@^1.17.0: fault "^1.0.0" highlight.js "~10.7.0" +magic-string@^0.30.21: + version "0.30.21" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" + integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.5" + +magicast@^0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/magicast/-/magicast-0.5.1.tgz#518959aea78851cd35d4bb0da92f780db3f606d3" + integrity sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw== + dependencies: + "@babel/parser" "^7.28.5" + "@babel/types" "^7.28.5" + source-map-js "^1.2.1" + +make-dir@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-4.0.0.tgz#c3c2307a771277cd9638305f915c29ae741b614e" + integrity sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw== + dependencies: + semver "^7.5.3" + ms@^2.1.3: version "2.1.3" resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" @@ -1009,6 +1207,11 @@ object-assign@^4.1.1: resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz" integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== +obug@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/obug/-/obug-2.1.1.tgz#2cba74ff241beb77d63055ddf4cd1e9f90b538be" + integrity sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ== + parent-module@^1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz" @@ -1049,6 +1252,11 @@ path-type@^4.0.0: resolved "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== +pathe@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" + integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== + picocolors@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz" @@ -1220,6 +1428,11 @@ scheduler@^0.27.0: resolved "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz" integrity sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q== +semver@^7.5.3: + version "7.7.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" + integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== + set-cookie-parser@^2.6.0: version "2.7.2" resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz#ccd08673a9ae5d2e44ea2a2de25089e67c7edf68" @@ -1230,6 +1443,11 @@ shallowequal@^1.1.0: resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8" integrity sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ== +siginfo@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" + integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g== + source-map-js@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz" @@ -1245,16 +1463,43 @@ space-separated-tokens@^2.0.0: resolved "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz" integrity sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q== +stackback@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" + integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== + +std-env@^3.10.0: + version "3.10.0" + resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.10.0.tgz#d810b27e3a073047b2b5e40034881f5ea6f9c83b" + integrity sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg== + stylis@4.2.0: version "4.2.0" resolved "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz" integrity sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw== +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + supports-preserve-symlinks-flag@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== +tinybench@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b" + integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== + +tinyexec@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.0.2.tgz#bdd2737fe2ba40bd6f918ae26642f264b99ca251" + integrity sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg== + tinyglobby@^0.2.15: version "0.2.15" resolved "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz" @@ -1263,12 +1508,17 @@ tinyglobby@^0.2.15: fdir "^6.5.0" picomatch "^4.0.3" +tinyrainbow@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-3.0.3.tgz#984a5b1c1b25854a9b6bccbe77964d0593d1ea42" + integrity sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q== + typescript@^5.9.2: version "5.9.3" resolved "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz" integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== -vite@^7.3.1: +"vite@^6.0.0 || ^7.0.0", vite@^7.3.1: version "7.3.1" resolved "https://registry.yarnpkg.com/vite/-/vite-7.3.1.tgz#7f6cfe8fb9074138605e822a75d9d30b814d6507" integrity sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA== @@ -1282,6 +1532,40 @@ vite@^7.3.1: optionalDependencies: fsevents "~2.3.3" +vitest@^4.0.17: + version "4.0.17" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-4.0.17.tgz#0e39e67a909a132afe434ee1278bdcf0c17fd063" + integrity sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg== + dependencies: + "@vitest/expect" "4.0.17" + "@vitest/mocker" "4.0.17" + "@vitest/pretty-format" "4.0.17" + "@vitest/runner" "4.0.17" + "@vitest/snapshot" "4.0.17" + "@vitest/spy" "4.0.17" + "@vitest/utils" "4.0.17" + es-module-lexer "^1.7.0" + expect-type "^1.2.2" + magic-string "^0.30.21" + obug "^2.1.1" + pathe "^2.0.3" + picomatch "^4.0.3" + std-env "^3.10.0" + tinybench "^2.9.0" + tinyexec "^1.0.2" + tinyglobby "^0.2.15" + tinyrainbow "^3.0.3" + vite "^6.0.0 || ^7.0.0" + why-is-node-running "^2.3.0" + +why-is-node-running@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz#a3f69a97107f494b3cdc3bdddd883a7d65cebf04" + integrity sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w== + dependencies: + siginfo "^2.0.0" + stackback "0.0.2" + yaml@^1.10.0: version "1.10.2" resolved "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz"