Skip to content

Commit 413959d

Browse files
committed
chore: validate package existence before adding to compare
1 parent 5d279f7 commit 413959d

2 files changed

Lines changed: 88 additions & 15 deletions

File tree

app/components/Compare/PackageSelector.vue

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script setup lang="ts">
22
import { NO_DEPENDENCY_ID } from '~/composables/usePackageComparison'
3+
import { checkPackageExists } from '~/utils/package-name'
34
45
const packages = defineModel<string[]>({ required: true })
56
@@ -13,6 +14,12 @@ const maxPackages = computed(() => props.max ?? 4)
1314
// Input state
1415
const inputValue = shallowRef('')
1516
const isInputFocused = shallowRef(false)
17+
const isCheckingPackage = shallowRef(false)
18+
const packageError = shallowRef('')
19+
20+
watch(inputValue, () => {
21+
packageError.value = ''
22+
})
1623
1724
// Use the shared npm search composable
1825
const { data: searchData, status } = useNpmSearch(inputValue, { size: 15 })
@@ -76,19 +83,42 @@ function removePackage(name: string) {
7683
packages.value = packages.value.filter(p => p !== name)
7784
}
7885
79-
function handleKeydown(e: KeyboardEvent) {
80-
const inputValueTrim = inputValue.value.trim()
81-
const hasMatchInPackages = filteredResults.value.find(result => {
82-
return result.name === inputValueTrim
83-
})
84-
85-
if (e.key === 'Enter' && inputValueTrim) {
86-
e.preventDefault()
87-
if (showNoDependencyOption.value) {
88-
addPackage(NO_DEPENDENCY_ID)
89-
} else if (hasMatchInPackages) {
90-
addPackage(inputValueTrim)
86+
async function handleKeydown(e: KeyboardEvent) {
87+
if (e.key !== 'Enter' || !inputValue.value.trim() || isCheckingPackage.value) return
88+
e.preventDefault()
89+
90+
const name = inputValue.value.trim()
91+
if (packages.value.length >= maxPackages.value) return
92+
if (packages.value.includes(name)) return
93+
94+
// Easter egg: "no dependency" option
95+
if (showNoDependencyOption.value) {
96+
addPackage(NO_DEPENDENCY_ID)
97+
return
98+
}
99+
100+
// If it matches a dropdown result, add immediately (already confirmed to exist)
101+
const exactMatch = filteredResults.value.find(r => r.name === name)
102+
if (exactMatch) {
103+
addPackage(exactMatch.name)
104+
return
105+
}
106+
107+
// Otherwise, verify it exists on npm
108+
isCheckingPackage.value = true
109+
packageError.value = ''
110+
try {
111+
const exists = await checkPackageExists(name)
112+
if (name !== inputValue.value.trim()) return // stale guard
113+
if (exists) {
114+
addPackage(name)
115+
} else {
116+
packageError.value = `Package "${name}" was not found on npm.`
91117
}
118+
} catch {
119+
packageError.value = 'Could not verify package. Please try again.'
120+
} finally {
121+
isCheckingPackage.value = false
92122
}
93123
}
94124
@@ -147,7 +177,8 @@ function handleBlur() {
147177
class="absolute inset-y-0 start-3 flex items-center text-fg-subtle pointer-events-none group-focus-within:text-accent"
148178
aria-hidden="true"
149179
>
150-
<span class="i-carbon:search w-4 h-4" />
180+
<span v-if="isCheckingPackage" class="i-carbon:renew w-4 h-4 animate-spin" />
181+
<span v-else class="i-carbon:search w-4 h-4" />
151182
</span>
152183
<input
153184
id="package-search"
@@ -158,7 +189,8 @@ function handleBlur() {
158189
? $t('compare.selector.search_first')
159190
: $t('compare.selector.search_add')
160191
"
161-
class="w-full bg-bg-subtle border border-border rounded-lg ps-10 pe-4 py-2.5 font-mono text-sm text-fg placeholder:text-fg-subtle motion-reduce:transition-none duration-200 focus:border-accent focus-visible:(outline-2 outline-accent/70)"
192+
:disabled="isCheckingPackage"
193+
class="w-full bg-bg-subtle border border-border rounded-lg ps-10 pe-4 py-2.5 font-mono text-sm text-fg placeholder:text-fg-subtle motion-reduce:transition-none duration-200 focus:border-accent focus-visible:(outline-2 outline-accent/70) disabled:opacity-60 disabled:cursor-wait"
162194
aria-autocomplete="list"
163195
@focus="isInputFocused = true"
164196
@blur="handleBlur"
@@ -214,6 +246,11 @@ function handleBlur() {
214246
</button>
215247
</div>
216248
</Transition>
249+
250+
<!-- Package not found error -->
251+
<p v-if="packageError" class="text-xs text-red-400 mt-1" role="alert">
252+
{{ packageError }}
253+
</p>
217254
</div>
218255

219256
<!-- Hint -->

test/nuxt/components/compare/PackageSelector.spec.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,25 @@
11
import { beforeEach, describe, expect, it, vi } from 'vitest'
22
import { ref } from 'vue'
3+
import { flushPromises } from '@vue/test-utils'
34
import { mountSuspended } from '@nuxt/test-utils/runtime'
45
import PackageSelector from '~/components/Compare/PackageSelector.vue'
56

7+
// Mock checkPackageExists
8+
vi.mock('~/utils/package-name', () => ({
9+
checkPackageExists: vi.fn(),
10+
}))
11+
12+
import { checkPackageExists } from '~/utils/package-name'
13+
const mockCheckPackageExists = vi.mocked(checkPackageExists)
14+
615
// Mock $fetch for useNpmSearch
716
const mockFetch = vi.fn()
817
vi.stubGlobal('$fetch', mockFetch)
918

1019
describe('PackageSelector', () => {
1120
beforeEach(() => {
1221
mockFetch.mockReset()
22+
mockCheckPackageExists.mockReset()
1323
mockFetch.mockResolvedValue({
1424
objects: [
1525
{ package: { name: 'lodash', description: 'Lodash modular utilities' } },
@@ -18,6 +28,7 @@ describe('PackageSelector', () => {
1828
total: 2,
1929
time: new Date().toISOString(),
2030
})
31+
mockCheckPackageExists.mockResolvedValue(true)
2132
})
2233

2334
describe('selected packages display', () => {
@@ -132,7 +143,9 @@ describe('PackageSelector', () => {
132143
})
133144

134145
describe('adding packages', () => {
135-
it('adds package on Enter key', async () => {
146+
it('adds package on Enter key when package exists', async () => {
147+
mockCheckPackageExists.mockResolvedValue(true)
148+
136149
const component = await mountSuspended(PackageSelector, {
137150
props: {
138151
modelValue: [],
@@ -142,6 +155,7 @@ describe('PackageSelector', () => {
142155
const input = component.find('input')
143156
await input.setValue('lodash')
144157
await input.trigger('keydown', { key: 'Enter' })
158+
await flushPromises()
145159

146160
const emitted = component.emitted('update:modelValue')
147161
expect(emitted).toBeTruthy()
@@ -165,6 +179,8 @@ describe('PackageSelector', () => {
165179
})
166180

167181
it('clears input after adding package', async () => {
182+
mockCheckPackageExists.mockResolvedValue(true)
183+
168184
const component = await mountSuspended(PackageSelector, {
169185
props: {
170186
modelValue: [],
@@ -174,11 +190,31 @@ describe('PackageSelector', () => {
174190
const input = component.find('input')
175191
await input.setValue('lodash')
176192
await input.trigger('keydown', { key: 'Enter' })
193+
await flushPromises()
177194

178195
// Input should be cleared
179196
expect((input.element as HTMLInputElement).value).toBe('')
180197
})
181198

199+
it('does not add non-existent packages', async () => {
200+
mockCheckPackageExists.mockResolvedValue(false)
201+
202+
const component = await mountSuspended(PackageSelector, {
203+
props: {
204+
modelValue: [],
205+
},
206+
})
207+
208+
const input = component.find('input')
209+
await input.setValue('nonexistent-pkg')
210+
await input.trigger('keydown', { key: 'Enter' })
211+
await flushPromises()
212+
213+
const emitted = component.emitted('update:modelValue')
214+
expect(emitted).toBeFalsy()
215+
expect(component.find('[role="alert"]').exists()).toBe(true)
216+
})
217+
182218
it('does not add duplicate packages', async () => {
183219
const component = await mountSuspended(PackageSelector, {
184220
props: {

0 commit comments

Comments
 (0)