Skip to content

Commit 84e3592

Browse files
committed
fix: prevent adding non-existent packages on compare page
1 parent 13ef534 commit 84e3592

2 files changed

Lines changed: 83 additions & 7 deletions

File tree

app/components/Compare/PackageSelector.vue

Lines changed: 46 additions & 6 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,10 +83,36 @@ function removePackage(name: string) {
7683
packages.value = packages.value.filter(p => p !== name)
7784
}
7885
79-
function handleKeydown(e: KeyboardEvent) {
80-
if (e.key === 'Enter' && inputValue.value.trim()) {
81-
e.preventDefault()
82-
addPackage(inputValue.value.trim())
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+
// If it matches a dropdown result, add immediately (already confirmed to exist)
95+
const exactMatch = filteredResults.value.find(r => r.name === name)
96+
if (exactMatch) {
97+
addPackage(exactMatch.name)
98+
return
99+
}
100+
101+
// Otherwise, verify it exists on npm
102+
isCheckingPackage.value = true
103+
packageError.value = ''
104+
try {
105+
const exists = await checkPackageExists(name)
106+
if (name !== inputValue.value.trim()) return // stale guard
107+
if (exists) {
108+
addPackage(name)
109+
} else {
110+
packageError.value = `Package "${name}" was not found on npm.`
111+
}
112+
} catch {
113+
packageError.value = 'Could not verify package. Please try again.'
114+
} finally {
115+
isCheckingPackage.value = false
83116
}
84117
}
85118
@@ -138,7 +171,8 @@ function handleBlur() {
138171
class="absolute inset-y-0 start-3 flex items-center text-fg-subtle pointer-events-none group-focus-within:text-accent"
139172
aria-hidden="true"
140173
>
141-
<span class="i-carbon:search w-4 h-4" />
174+
<span v-if="isCheckingPackage" class="i-carbon:renew w-4 h-4 animate-spin" />
175+
<span v-else class="i-carbon:search w-4 h-4" />
142176
</span>
143177
<input
144178
id="package-search"
@@ -149,7 +183,8 @@ function handleBlur() {
149183
? $t('compare.selector.search_first')
150184
: $t('compare.selector.search_add')
151185
"
152-
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)"
186+
:disabled="isCheckingPackage"
187+
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"
153188
aria-autocomplete="list"
154189
@focus="isInputFocused = true"
155190
@blur="handleBlur"
@@ -205,6 +240,11 @@ function handleBlur() {
205240
</button>
206241
</div>
207242
</Transition>
243+
244+
<!-- Package not found error -->
245+
<p v-if="packageError" class="text-xs text-red-400 mt-1" role="alert">
246+
{{ packageError }}
247+
</p>
208248
</div>
209249

210250
<!-- 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,13 +155,16 @@ describe('PackageSelector', () => {
142155
const input = component.find('input')
143156
await input.setValue('my-package')
144157
await input.trigger('keydown', { key: 'Enter' })
158+
await flushPromises()
145159

146160
const emitted = component.emitted('update:modelValue')
147161
expect(emitted).toBeTruthy()
148162
expect(emitted![0]![0]).toEqual(['my-package'])
149163
})
150164

151165
it('clears input after adding package', async () => {
166+
mockCheckPackageExists.mockResolvedValue(true)
167+
152168
const component = await mountSuspended(PackageSelector, {
153169
props: {
154170
modelValue: [],
@@ -158,11 +174,31 @@ describe('PackageSelector', () => {
158174
const input = component.find('input')
159175
await input.setValue('my-package')
160176
await input.trigger('keydown', { key: 'Enter' })
177+
await flushPromises()
161178

162179
// Input should be cleared
163180
expect((input.element as HTMLInputElement).value).toBe('')
164181
})
165182

183+
it('does not add non-existent packages', async () => {
184+
mockCheckPackageExists.mockResolvedValue(false)
185+
186+
const component = await mountSuspended(PackageSelector, {
187+
props: {
188+
modelValue: [],
189+
},
190+
})
191+
192+
const input = component.find('input')
193+
await input.setValue('nonexistent-pkg')
194+
await input.trigger('keydown', { key: 'Enter' })
195+
await flushPromises()
196+
197+
const emitted = component.emitted('update:modelValue')
198+
expect(emitted).toBeFalsy()
199+
expect(component.find('[role="alert"]').exists()).toBe(true)
200+
})
201+
166202
it('does not add duplicate packages', async () => {
167203
const component = await mountSuspended(PackageSelector, {
168204
props: {

0 commit comments

Comments
 (0)