diff --git a/app/components/OgBrand.vue b/app/components/OgBrand.vue index 4b98b976a6..5714c898c6 100644 --- a/app/components/OgBrand.vue +++ b/app/components/OgBrand.vue @@ -1,4 +1,6 @@ + + diff --git a/app/pages/~[username]/index.vue b/app/pages/~[username]/index.vue index e56d7efc4e..8125ad078f 100644 --- a/app/pages/~[username]/index.vue +++ b/app/pages/~[username]/index.vue @@ -129,10 +129,9 @@ useSeoMeta({ }) defineOgImage( - 'Page.takumi', + 'UserProfile.takumi', { - title: () => `~${username.value}`, - description: () => (results.value ? `${results.value.total} packages` : 'npm user profile'), + username: () => username.value, }, { alt: () => `~${username.value} npm user profile on npmx` }, ) diff --git a/test/nuxt/components/OgImageUserProfile.spec.ts b/test/nuxt/components/OgImageUserProfile.spec.ts new file mode 100644 index 0000000000..2228049b90 --- /dev/null +++ b/test/nuxt/components/OgImageUserProfile.spec.ts @@ -0,0 +1,106 @@ +import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { shallowRef } from 'vue' + +const { mockUseUserPackages, mockRefresh } = vi.hoisted(() => ({ + mockRefresh: vi.fn(), + mockUseUserPackages: vi.fn(), +})) + +mockNuxtImport('useUserPackages', () => mockUseUserPackages) + +import OgImageUserProfile from '~/components/OgImage/UserProfile.takumi.vue' + +/** + * Wire up the `useUserPackages` mock so the total only becomes available after + * `refresh()` is awaited — mirroring the real `useLazyAsyncData` behaviour where + * the fetcher does not resolve during the island's synchronous setup. This makes + * the tests a regression guard: a component that reads `data` without awaiting + * `refresh()` sees `null` and renders "0 packages". + */ +function mockPackages(total: number | null) { + const data = shallowRef<{ total: number } | null>(null) + mockRefresh.mockImplementation(async () => { + data.value = total === null ? null : { total } + }) + mockUseUserPackages.mockReturnValue({ data, refresh: mockRefresh }) +} + +describe('OgImageUserProfile', () => { + beforeEach(() => { + vi.restoreAllMocks() + mockUseUserPackages.mockReset() + mockRefresh.mockReset() + }) + + it('renders the username with a tilde prefix', async () => { + mockPackages(9) + + const component = await mountSuspended(OgImageUserProfile, { + props: { username: 'houtan-rocky' }, + }) + + expect(component.text()).toContain('~houtan-rocky') + }) + + it('shows the package count from useUserPackages', async () => { + mockPackages(9) + + const component = await mountSuspended(OgImageUserProfile, { + props: { username: 'houtan-rocky' }, + }) + + expect(component.text()).toContain('9 packages') + expect(mockUseUserPackages).toHaveBeenCalledWith('houtan-rocky') + // The fetch must be forced — `useLazyAsyncData` does not resolve on its own + // during the island's synchronous setup. + expect(mockRefresh).toHaveBeenCalled() + }) + + it('uses the singular noun for a single package', async () => { + mockPackages(1) + + const component = await mountSuspended(OgImageUserProfile, { + props: { username: 'solo' }, + }) + + expect(component.text()).toContain('1 package') + expect(component.text()).not.toContain('1 packages') + }) + + it('shows zero packages when the user has none', async () => { + mockPackages(0) + + const component = await mountSuspended(OgImageUserProfile, { + props: { username: 'nobody' }, + }) + + expect(component.text()).toContain('0 packages') + }) + + it('degrades to zero packages when no data resolves', async () => { + mockPackages(null) + + const component = await mountSuspended(OgImageUserProfile, { + props: { username: 'flaky' }, + }) + + expect(component.text()).toContain('0 packages') + }) + + it('skips the lookup for an invalid username', async () => { + const component = await mountSuspended(OgImageUserProfile, { + props: { username: 'not a valid name!' }, + }) + + expect(mockUseUserPackages).not.toHaveBeenCalled() + expect(component.text()).toContain('0 packages') + }) + + it('renders a generic description and skips fetching when no username is given', async () => { + const component = await mountSuspended(OgImageUserProfile, { props: {} }) + + expect(component.text()).toContain('npm user profile') + expect(mockUseUserPackages).not.toHaveBeenCalled() + }) +}) diff --git a/test/unit/a11y-component-coverage.spec.ts b/test/unit/a11y-component-coverage.spec.ts index 86f8371613..d6f82a4d62 100644 --- a/test/unit/a11y-component-coverage.spec.ts +++ b/test/unit/a11y-component-coverage.spec.ts @@ -31,6 +31,8 @@ const SKIPPED_COMPONENTS: Record = { 'OgImage/Page.takumi.vue': 'OG Image component - server-rendered image, not interactive UI', 'OgImage/Profile.takumi.vue': 'OG Image component - server-rendered image, not interactive UI', 'OgImage/Splash.takumi.vue': 'OG Image component - server-rendered image, not interactive UI', + 'OgImage/UserProfile.takumi.vue': + 'OG Image component - server-rendered image, not interactive UI', // Client-only components with complex dependencies 'Header/AuthModal.client.vue': 'Complex auth modal with navigation - requires full app context',