From 7267ef55e8e0c38dc90922ef8b28026c3d3d9842 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Fri, 30 Jan 2026 06:49:02 +0000 Subject: [PATCH 1/5] fix: prevent flicker of package manager tabs on hydration --- app/app.vue | 4 +- app/components/ExecuteCommandTerminal.vue | 105 ++++++ app/components/InstallCommandTerminal.vue | 273 +++++++++++++++ app/components/PackageManagerTabs.vue | 61 ++++ app/components/PackageSkeleton.vue | 2 +- app/composables/useSelectedPackageManager.ts | 32 +- app/composables/useSettings.ts | 25 -- app/pages/[...package].vue | 349 +------------------ app/utils/prehydrate.ts | 60 ++++ test/nuxt/components/DateTime.spec.ts | 1 - 10 files changed, 545 insertions(+), 367 deletions(-) create mode 100644 app/components/ExecuteCommandTerminal.vue create mode 100644 app/components/InstallCommandTerminal.vue create mode 100644 app/components/PackageManagerTabs.vue create mode 100644 app/utils/prehydrate.ts diff --git a/app/app.vue b/app/app.vue index c695e88cb2..2e9d67ea24 100644 --- a/app/app.vue +++ b/app/app.vue @@ -6,8 +6,8 @@ const route = useRoute() const router = useRouter() const { locale, locales } = useI18n() -// Initialize accent color before hydration to prevent flash -initAccentOnPrehydrate() +// Initialize user preferences (accent color, package manager) before hydration to prevent flash/CLS +initPreferencesOnPrehydrate() const isHomepage = computed(() => route.name === 'index') diff --git a/app/components/ExecuteCommandTerminal.vue b/app/components/ExecuteCommandTerminal.vue new file mode 100644 index 0000000000..e21b82f467 --- /dev/null +++ b/app/components/ExecuteCommandTerminal.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/app/components/InstallCommandTerminal.vue b/app/components/InstallCommandTerminal.vue new file mode 100644 index 0000000000..37bd3441f4 --- /dev/null +++ b/app/components/InstallCommandTerminal.vue @@ -0,0 +1,273 @@ + + + + + diff --git a/app/components/PackageManagerTabs.vue b/app/components/PackageManagerTabs.vue new file mode 100644 index 0000000000..5b66ab99f9 --- /dev/null +++ b/app/components/PackageManagerTabs.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/app/components/PackageSkeleton.vue b/app/components/PackageSkeleton.vue index c21bf63c25..d297bb3f02 100644 --- a/app/components/PackageSkeleton.vue +++ b/app/components/PackageSkeleton.vue @@ -106,7 +106,7 @@ id="install-heading-skeleton" class="text-xs text-fg-subtle uppercase tracking-wider mb-3" > - {{ $t('package.skeleton.install') }} + {{ $t('package.skeleton.get_started') }}
diff --git a/app/composables/useSelectedPackageManager.ts b/app/composables/useSelectedPackageManager.ts index 0780ca31d4..63b2efd63c 100644 --- a/app/composables/useSelectedPackageManager.ts +++ b/app/composables/useSelectedPackageManager.ts @@ -1,4 +1,28 @@ -/** @public */ -export function useSelectedPackageManager() { - return useLocalStorage('npmx-pm', 'npm') -} +/** + * Composable for managing the selected package manager preference. + * + * This composable syncs the selected PM to both localStorage and the + * `data-pm` attribute on ``. The attribute enables CSS-based + * visibility of PM-specific content without JavaScript. + * + * @public + */ +export const useSelectedPackageManager = createSharedComposable( + function useSelectedPackageManager() { + const pm = useLocalStorage('npmx-pm', 'npm') + + // Sync to data-pm attribute on the client + if (import.meta.client) { + // Watch for changes and update the attribute + watch( + pm, + newPM => { + document.documentElement.dataset.pm = newPM + }, + { immediate: true }, + ) + } + + return pm + }, +) diff --git a/app/composables/useSettings.ts b/app/composables/useSettings.ts index 8b5fdf177c..1bf4a98188 100644 --- a/app/composables/useSettings.ts +++ b/app/composables/useSettings.ts @@ -84,28 +84,3 @@ export function useAccentColor() { setAccentColor, } } - -/** - * Applies accent color before hydration to prevent flash of default color. - * Call this from app.vue to ensure accent color is applied on every page. - * @public - */ -export function initAccentOnPrehydrate() { - // Callback is stringified by Nuxt - external variables won't be available. - // Colors must be hardcoded since ACCENT_COLORS can't be referenced. - onPrehydrate(() => { - const colors: Record = { - rose: 'oklch(0.797 0.084 11.056)', - amber: 'oklch(0.828 0.165 84.429)', - emerald: 'oklch(0.792 0.153 166.95)', - sky: 'oklch(0.787 0.128 230.318)', - violet: 'oklch(0.714 0.148 286.067)', - coral: 'oklch(0.704 0.177 14.75)', - } - const settings = JSON.parse(localStorage.getItem('npmx-settings') || '{}') - const color = settings.accentColorId ? colors[settings.accentColorId as AccentColorId] : null - if (color) { - document.documentElement.style.setProperty('--accent-color', color) - } - }) -} diff --git a/app/pages/[...package].vue b/app/pages/[...package].vue index b32e1bbafe..3c5e5165bb 100644 --- a/app/pages/[...package].vue +++ b/app/pages/[...package].vue @@ -13,8 +13,6 @@ definePageMeta({ const router = useRouter() -const isMounted = useMounted() - const { packageName, requestedVersion, orgName } = usePackageRoute() if (import.meta.server) { @@ -273,15 +271,6 @@ const typesPackageName = computed(() => { return packageAnalysis.value.types.packageName }) -const { - selectedPM, - installCommandParts, - typesInstallCommandParts, - showTypesInInstall, - copied, - copyInstallCommand, -} = useInstallCommand(packageName, requestedVersion, jsrInfo, typesPackageName) - // Executable detection for run command const executableInfo = computed(() => { if (!displayVersion.value || !pkg.value) return null @@ -306,46 +295,6 @@ const isCreatePkg = computed(() => { return isCreatePackage(pkg.value.name) }) -// Run command parts for a specific command (local execute after install) -function getRunParts(command?: string) { - if (!pkg.value) return [] - return getRunCommandParts({ - packageName: pkg.value.name, - packageManager: selectedPM.value, - jsrInfo: jsrInfo.value, - command, - isBinaryOnly: false, // Local execute - }) -} - -// Execute command parts for binary-only packages (remote execute) -const executeCommandParts = computed(() => { - if (!pkg.value) return [] - return getExecuteCommandParts({ - packageName: pkg.value.name, - packageManager: selectedPM.value, - jsrInfo: jsrInfo.value, - isBinaryOnly: true, - isCreatePackage: isCreatePkg.value, - }) -}) - -// Full execute command string for copying -const executeCommand = computed(() => { - if (!pkg.value) return '' - return getExecuteCommand({ - packageName: pkg.value.name, - packageManager: selectedPM.value, - jsrInfo: jsrInfo.value, - isBinaryOnly: true, - isCreatePackage: isCreatePkg.value, - }) -}) - -// Copy execute command (for binary-only packages) -const { copied: executeCopied, copy: copyExecute } = useClipboard({ copiedDuring: 2000 }) -const copyExecuteCommand = () => copyExecute(executeCommand.value) - // Get associated create-* package info (e.g., vite -> create-vite) const createPackageInfo = computed(() => { if (!packageAnalysis.value?.createPackage) return null @@ -354,60 +303,6 @@ const createPackageInfo = computed(() => { return packageAnalysis.value.createPackage }) -// Create command parts for associated create-* package -const createCommandParts = computed(() => { - if (!createPackageInfo.value) return [] - const pm = packageManagers.find(p => p.id === selectedPM.value) - if (!pm) return [] - - // Extract short name: create-vite -> vite - const createPkgName = createPackageInfo.value.packageName - let shortName: string - if (createPkgName.startsWith('@')) { - // @scope/create-foo -> foo - const slashIndex = createPkgName.indexOf('/') - const name = createPkgName.slice(slashIndex + 1) - shortName = name.startsWith('create-') ? name.slice('create-'.length) : name - } else { - // create-vite -> vite - shortName = createPkgName.startsWith('create-') - ? createPkgName.slice('create-'.length) - : createPkgName - } - - return [...pm.create.split(' '), shortName] -}) - -// Full create command string for copying -const createCommand = computed(() => { - return createCommandParts.value.join(' ') -}) - -// Copy create command -const { copied: createCopied, copy: copyCreate } = useClipboard({ copiedDuring: 2000 }) -const copyCreateCommand = () => copyCreate(createCommand.value) - -// Primary run command parts -const runCommandParts = computed(() => { - if (!executableInfo.value?.hasExecutable) return [] - return getRunParts(executableInfo.value.primaryCommand) -}) - -// Full run command string for copying -function getFullRunCommand(command?: string) { - if (!pkg.value) return '' - return getRunCommand({ - packageName: pkg.value.name, - packageManager: selectedPM.value, - jsrInfo: jsrInfo.value, - command, - }) -} - -// Copy run command -const { copied: runCopied, copy: copyRun } = useClipboard({ copiedDuring: 2000 }) -const copyRunCommand = (command?: string) => copyRun(getFullRunCommand(command)) - // Expandable description const descriptionExpanded = ref(false) const descriptionRef = useTemplateRef('descriptionRef') @@ -943,65 +838,13 @@ function handleClick(event: MouseEvent) { {{ $t('package.run.title') }} -
- -
-
-
- -
-
- - - -
-
- -
- $ - {{ i > 0 ? ' ' : '' }}{{ part }} - -
-
-
+
+ @@ -1023,178 +866,16 @@ function handleClick(event: MouseEvent) { -
- -
- -
- -
-
- - - -
-
- -
- $ - {{ i > 0 ? ' ' : '' }}{{ part }} - -
- - -
- $ - {{ i > 0 ? ' ' : '' }}{{ part }} - - -
- - - - - - -
-
+
+
diff --git a/app/utils/prehydrate.ts b/app/utils/prehydrate.ts new file mode 100644 index 0000000000..c101b649f0 --- /dev/null +++ b/app/utils/prehydrate.ts @@ -0,0 +1,60 @@ +import type { ACCENT_COLORS } from '#shared/utils/constants' + +type AccentColorId = keyof typeof ACCENT_COLORS + +/** + * Initialize user preferences before hydration to prevent flash/layout shift. + * This sets CSS custom properties and data attributes that CSS can use + * to show the correct content before Vue hydration occurs. + * + * Call this in app.vue or any page that needs early access to user preferences. + * @public + */ +export function initPreferencesOnPrehydrate() { + // Callback is stringified by Nuxt - external variables won't be available. + // All constants must be hardcoded inside the callback. + onPrehydrate(() => { + // Accent colors - hardcoded since ACCENT_COLORS can't be referenced + const colors: Record = { + rose: 'oklch(0.797 0.084 11.056)', + amber: 'oklch(0.828 0.165 84.429)', + emerald: 'oklch(0.792 0.153 166.95)', + sky: 'oklch(0.787 0.128 230.318)', + violet: 'oklch(0.714 0.148 286.067)', + coral: 'oklch(0.704 0.177 14.75)', + } + + // Valid package manager IDs + const validPMs = new Set(['npm', 'pnpm', 'yarn', 'bun', 'deno', 'vlt']) + + // Read settings from localStorage + const settings = JSON.parse(localStorage.getItem('npmx-settings') || '{}') + + // Apply accent color + const color = settings.accentColorId ? colors[settings.accentColorId as AccentColorId] : null + if (color) { + document.documentElement.style.setProperty('--accent-color', color) + } + + // Read and apply package manager preference + const storedPM = localStorage.getItem('npmx-pm') + // Parse the stored value (it's stored as a JSON string by useLocalStorage) + let pm = 'npm' + if (storedPM) { + try { + const parsed = JSON.parse(storedPM) + if (validPMs.has(parsed)) { + pm = parsed + } + } catch { + // If parsing fails, check if it's a plain string (legacy format) + if (validPMs.has(storedPM)) { + pm = storedPM + } + } + } + + // Set data attribute for CSS-based visibility + document.documentElement.dataset.pm = pm + }) +} diff --git a/test/nuxt/components/DateTime.spec.ts b/test/nuxt/components/DateTime.spec.ts index 612bf5d483..dd5973662a 100644 --- a/test/nuxt/components/DateTime.spec.ts +++ b/test/nuxt/components/DateTime.spec.ts @@ -10,7 +10,6 @@ vi.mock('~/composables/useSettings', () => ({ settings: ref({ relativeDates: mockRelativeDates.value }), }), useAccentColor: () => ({}), - initAccentOnPrehydrate: () => {}, })) describe('DateTime', () => { From f45016d9dd4354e28690b3708656040313b8e886 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Fri, 30 Jan 2026 06:53:23 +0000 Subject: [PATCH 2/5] test: update tests --- .../composables/use-install-command.spec.ts | 5 +- tests/create-command.spec.ts | 141 +++++++++++------- 2 files changed, 93 insertions(+), 53 deletions(-) diff --git a/test/nuxt/composables/use-install-command.spec.ts b/test/nuxt/composables/use-install-command.spec.ts index 0b2e81b58e..20a827a3fe 100644 --- a/test/nuxt/composables/use-install-command.spec.ts +++ b/test/nuxt/composables/use-install-command.spec.ts @@ -3,8 +3,11 @@ import type { JsrPackageInfo } from '#shared/types/jsr' describe('useInstallCommand', () => { beforeEach(() => { - // Reset localStorage before each test + // Reset localStorage and package manager state before each test localStorage.clear() + // Reset the shared composable state to default 'npm' + const pm = useSelectedPackageManager() + pm.value = 'npm' }) afterEach(() => { diff --git a/tests/create-command.spec.ts b/tests/create-command.spec.ts index 001dfa03f1..735c5e05a1 100644 --- a/tests/create-command.spec.ts +++ b/tests/create-command.spec.ts @@ -3,13 +3,17 @@ import { expect, test } from '@nuxt/test-utils/playwright' test.describe('Create Command', () => { test.describe('Visibility', () => { test('/vite - should show create command (same maintainers)', async ({ page, goto }) => { - await goto('/vite', { waitUntil: 'domcontentloaded' }) + await goto('/vite', { waitUntil: 'hydration' }) + + await expect(page.locator('h1')).toContainText('vite', { timeout: 15000 }) - // Create command section should be visible (SSR) - // Use specific container to avoid matching README code blocks - const createCommandSection = page.locator('.group\\/createcmd') - await expect(createCommandSection).toBeVisible() - await expect(createCommandSection.locator('code')).toContainText(/create vite/i) + // Create command is loaded via API (packageAnalysis), wait for it + // All PM variants are rendered; npm is visible by default + const createCommandRow = page + .locator('[data-pm-cmd="npm"]') + .filter({ hasText: /create vite/i }) + await expect(createCommandRow).toBeVisible() + await expect(createCommandRow.locator('code')).toContainText(/create vite/i) // Link to create-vite should be present (uses sr-only text, so check attachment not visibility) await expect(page.locator('a[href="/create-vite"]')).toBeAttached() @@ -19,15 +23,19 @@ test.describe('Create Command', () => { page, goto, }) => { - await goto('/next', { waitUntil: 'domcontentloaded' }) + await goto('/next', { waitUntil: 'hydration' }) + + // Wait for package page to load + await expect(page.locator('h1')).toContainText('next', { timeout: 15000 }) - // Create command section should be visible (SSR) - // Use specific container to avoid matching README code blocks - const createCommandSection = page.locator('.group\\/createcmd') - await expect(createCommandSection).toBeVisible() - await expect(createCommandSection.locator('code')).toContainText(/create next-app/i) + // Create command is loaded via API + const createCommandRow = page + .locator('[data-pm-cmd="npm"]') + .filter({ hasText: /create next-app/i }) + await expect(createCommandRow).toBeVisible({ timeout: 15000 }) + await expect(createCommandRow.locator('code')).toContainText(/create next-app/i) - // Link to create-next-app should be present (uses sr-only text, so check attachment not visibility) + // Link to create-next-app should be present await expect(page.locator('a[href="/create-next-app"]')).toBeAttached() }) @@ -35,42 +43,61 @@ test.describe('Create Command', () => { page, goto, }) => { - await goto('/nuxt', { waitUntil: 'domcontentloaded' }) - - // Create command section should be visible (SSR) - // nuxt has create-nuxt package, so command is "npm create nuxt" - // Use specific container to avoid matching README code blocks - const createCommandSection = page.locator('.group\\/createcmd') - await expect(createCommandSection).toBeVisible() - await expect(createCommandSection.locator('code')).toContainText(/create nuxt/i) + await goto('/nuxt', { waitUntil: 'hydration' }) + + // Wait for package page to load (longer timeout for network flakiness) + await expect(page.locator('h1')).toContainText('nuxt', { timeout: 15000 }) + + // Create command is loaded via API, wait for it + const createCommandRow = page + .locator('[data-pm-cmd="npm"]') + .filter({ hasText: /create nuxt/i }) + await expect(createCommandRow).toBeVisible({ timeout: 15000 }) + await expect(createCommandRow.locator('code')).toContainText(/create nuxt/i) }) test('/color - should NOT show create command (different maintainers)', async ({ page, goto, }) => { - await goto('/color', { waitUntil: 'domcontentloaded' }) + await goto('/color', { waitUntil: 'hydration' }) - // Wait for package to load + // Wait for package page to load await expect(page.locator('h1').filter({ hasText: 'color' })).toBeVisible() - // Create command section should NOT be visible (different maintainers) - const createCommandSection = page.locator('.group\\/createcmd') - await expect(createCommandSection).not.toBeVisible() + // Wait for API to complete (install command should be visible) + await expect(page.locator('[data-pm-cmd="npm"]').first()).toBeVisible() + + // Give time for any create command to appear, then verify it doesn't + await page.waitForTimeout(1000) + + // Create command should NOT exist (different maintainers) + const createCommandRow = page + .locator('[data-pm-cmd="npm"]') + .filter({ hasText: /\bcreate color\b/i }) + await expect(createCommandRow).toHaveCount(0) }) test('/lodash - should NOT show create command (no create-lodash exists)', async ({ page, goto, }) => { - await goto('/lodash', { waitUntil: 'domcontentloaded' }) + await goto('/lodash', { waitUntil: 'hydration' }) - // Wait for package to load + // Wait for package page to load await expect(page.locator('h1').filter({ hasText: 'lodash' })).toBeVisible() - // Create command section should NOT be visible (no create-lodash exists) - const createCommandSection = page.locator('.group\\/createcmd') - await expect(createCommandSection).not.toBeVisible() + // Wait for API to complete (install command should be visible) + await expect(page.locator('[data-pm-cmd="npm"]').first()).toBeVisible() + + // Give time for any create command to appear, then verify it doesn't + await page.waitForTimeout(1000) + + // Create command should NOT exist (no create-lodash exists) + const createCommandRow = page + .locator('[data-pm-cmd="npm"]') + .filter({ hasText: /\bcreate lodash\b/i }) + await expect(createCommandRow).toHaveCount(0) }) }) @@ -79,19 +106,22 @@ test.describe('Create Command', () => { await goto('/vite', { waitUntil: 'hydration' }) // Wait for package analysis API to load (create command requires this) - // First ensure the package page has loaded - await expect(page.locator('h1')).toContainText('vite') + // First ensure the package page has loaded (longer timeout for network flakiness) + await expect(page.locator('h1')).toContainText('vite', { timeout: 15000 }) - // Find the create command container (wait longer for API response) - const createCommandContainer = page.locator('.group\\/createcmd') - await expect(createCommandContainer).toBeVisible({ timeout: 15000 }) + // Find the create command row (npm variant) - it contains "create vite" in code + // The component renders all PM variants; npm is visible by default + const createCommandRow = page + .locator('[data-pm-cmd="npm"]') + .filter({ hasText: /create vite/i }) + await expect(createCommandRow).toBeVisible({ timeout: 15000 }) // Copy button should initially be hidden (opacity-0) - const copyButton = createCommandContainer.locator('button') + const copyButton = createCommandRow.locator('button') await expect(copyButton).toHaveCSS('opacity', '0') // Hover over the container - await createCommandContainer.hover() + await createCommandRow.hover() // Copy button should become visible await expect(copyButton).toHaveCSS('opacity', '1') @@ -107,12 +137,18 @@ test.describe('Create Command', () => { await goto('/vite', { waitUntil: 'hydration' }) - // Find and hover over the create command container - const createCommandContainer = page.locator('.group\\/createcmd') - await createCommandContainer.hover() + // Wait for h1 to confirm page loaded (longer timeout for network flakiness) + await expect(page.locator('h1')).toContainText('vite', { timeout: 15000 }) + + // Find and hover over the create command row (npm variant), wait for API + const createCommandRow = page + .locator('[data-pm-cmd="npm"]') + .filter({ hasText: /create vite/i }) + await expect(createCommandRow).toBeVisible({ timeout: 15000 }) + await createCommandRow.hover() // Click the copy button - const copyButton = createCommandContainer.locator('button') + const copyButton = createCommandRow.locator('button') await copyButton.click() // Button text should change to "copied!" @@ -131,16 +167,17 @@ test.describe('Create Command', () => { test('hovering install command shows copy button', async ({ page, goto }) => { await goto('/lodash', { waitUntil: 'hydration' }) - // Find the install command container - const installCommandContainer = page.locator('.group\\/installcmd') - await expect(installCommandContainer).toBeVisible() + // Find the npm install command row (npm is the default, so it's visible) + // The component uses group/cmd class for each command row + const installCommandRow = page.locator('[data-pm-cmd="npm"]').first() + await expect(installCommandRow).toBeVisible() - // Copy button should initially be hidden - const copyButton = installCommandContainer.locator('button') + // Copy button should initially be hidden (opacity-0) + const copyButton = installCommandRow.locator('button') await expect(copyButton).toHaveCSS('opacity', '0') // Hover over the container - await installCommandContainer.hover() + await installCommandRow.hover() // Copy button should become visible await expect(copyButton).toHaveCSS('opacity', '1') @@ -156,12 +193,12 @@ test.describe('Create Command', () => { await goto('/lodash', { waitUntil: 'hydration' }) - // Find and hover over the install command container - const installCommandContainer = page.locator('.group\\/installcmd') - await installCommandContainer.hover() + // Find and hover over the npm install command row (npm is the default) + const installCommandRow = page.locator('[data-pm-cmd="npm"]').first() + await installCommandRow.hover() // Click the copy button - const copyButton = installCommandContainer.locator('button') + const copyButton = installCommandRow.locator('button') await copyButton.click() // Button text should change to "copied!" From 508c796e69b54ff720575f57702f5dc7787482b6 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Fri, 30 Jan 2026 07:03:23 +0000 Subject: [PATCH 3/5] chore: revert create-command test --- tests/create-command.spec.ts | 141 +++++++++++++---------------------- 1 file changed, 52 insertions(+), 89 deletions(-) diff --git a/tests/create-command.spec.ts b/tests/create-command.spec.ts index 735c5e05a1..001dfa03f1 100644 --- a/tests/create-command.spec.ts +++ b/tests/create-command.spec.ts @@ -3,17 +3,13 @@ import { expect, test } from '@nuxt/test-utils/playwright' test.describe('Create Command', () => { test.describe('Visibility', () => { test('/vite - should show create command (same maintainers)', async ({ page, goto }) => { - await goto('/vite', { waitUntil: 'hydration' }) - - await expect(page.locator('h1')).toContainText('vite', { timeout: 15000 }) + await goto('/vite', { waitUntil: 'domcontentloaded' }) - // Create command is loaded via API (packageAnalysis), wait for it - // All PM variants are rendered; npm is visible by default - const createCommandRow = page - .locator('[data-pm-cmd="npm"]') - .filter({ hasText: /create vite/i }) - await expect(createCommandRow).toBeVisible() - await expect(createCommandRow.locator('code')).toContainText(/create vite/i) + // Create command section should be visible (SSR) + // Use specific container to avoid matching README code blocks + const createCommandSection = page.locator('.group\\/createcmd') + await expect(createCommandSection).toBeVisible() + await expect(createCommandSection.locator('code')).toContainText(/create vite/i) // Link to create-vite should be present (uses sr-only text, so check attachment not visibility) await expect(page.locator('a[href="/create-vite"]')).toBeAttached() @@ -23,19 +19,15 @@ test.describe('Create Command', () => { page, goto, }) => { - await goto('/next', { waitUntil: 'hydration' }) - - // Wait for package page to load - await expect(page.locator('h1')).toContainText('next', { timeout: 15000 }) + await goto('/next', { waitUntil: 'domcontentloaded' }) - // Create command is loaded via API - const createCommandRow = page - .locator('[data-pm-cmd="npm"]') - .filter({ hasText: /create next-app/i }) - await expect(createCommandRow).toBeVisible({ timeout: 15000 }) - await expect(createCommandRow.locator('code')).toContainText(/create next-app/i) + // Create command section should be visible (SSR) + // Use specific container to avoid matching README code blocks + const createCommandSection = page.locator('.group\\/createcmd') + await expect(createCommandSection).toBeVisible() + await expect(createCommandSection.locator('code')).toContainText(/create next-app/i) - // Link to create-next-app should be present + // Link to create-next-app should be present (uses sr-only text, so check attachment not visibility) await expect(page.locator('a[href="/create-next-app"]')).toBeAttached() }) @@ -43,61 +35,42 @@ test.describe('Create Command', () => { page, goto, }) => { - await goto('/nuxt', { waitUntil: 'hydration' }) - - // Wait for package page to load (longer timeout for network flakiness) - await expect(page.locator('h1')).toContainText('nuxt', { timeout: 15000 }) - - // Create command is loaded via API, wait for it - const createCommandRow = page - .locator('[data-pm-cmd="npm"]') - .filter({ hasText: /create nuxt/i }) - await expect(createCommandRow).toBeVisible({ timeout: 15000 }) - await expect(createCommandRow.locator('code')).toContainText(/create nuxt/i) + await goto('/nuxt', { waitUntil: 'domcontentloaded' }) + + // Create command section should be visible (SSR) + // nuxt has create-nuxt package, so command is "npm create nuxt" + // Use specific container to avoid matching README code blocks + const createCommandSection = page.locator('.group\\/createcmd') + await expect(createCommandSection).toBeVisible() + await expect(createCommandSection.locator('code')).toContainText(/create nuxt/i) }) test('/color - should NOT show create command (different maintainers)', async ({ page, goto, }) => { - await goto('/color', { waitUntil: 'hydration' }) + await goto('/color', { waitUntil: 'domcontentloaded' }) - // Wait for package page to load + // Wait for package to load await expect(page.locator('h1').filter({ hasText: 'color' })).toBeVisible() - // Wait for API to complete (install command should be visible) - await expect(page.locator('[data-pm-cmd="npm"]').first()).toBeVisible() - - // Give time for any create command to appear, then verify it doesn't - await page.waitForTimeout(1000) - - // Create command should NOT exist (different maintainers) - const createCommandRow = page - .locator('[data-pm-cmd="npm"]') - .filter({ hasText: /\bcreate color\b/i }) - await expect(createCommandRow).toHaveCount(0) + // Create command section should NOT be visible (different maintainers) + const createCommandSection = page.locator('.group\\/createcmd') + await expect(createCommandSection).not.toBeVisible() }) test('/lodash - should NOT show create command (no create-lodash exists)', async ({ page, goto, }) => { - await goto('/lodash', { waitUntil: 'hydration' }) + await goto('/lodash', { waitUntil: 'domcontentloaded' }) - // Wait for package page to load + // Wait for package to load await expect(page.locator('h1').filter({ hasText: 'lodash' })).toBeVisible() - // Wait for API to complete (install command should be visible) - await expect(page.locator('[data-pm-cmd="npm"]').first()).toBeVisible() - - // Give time for any create command to appear, then verify it doesn't - await page.waitForTimeout(1000) - - // Create command should NOT exist (no create-lodash exists) - const createCommandRow = page - .locator('[data-pm-cmd="npm"]') - .filter({ hasText: /\bcreate lodash\b/i }) - await expect(createCommandRow).toHaveCount(0) + // Create command section should NOT be visible (no create-lodash exists) + const createCommandSection = page.locator('.group\\/createcmd') + await expect(createCommandSection).not.toBeVisible() }) }) @@ -106,22 +79,19 @@ test.describe('Create Command', () => { await goto('/vite', { waitUntil: 'hydration' }) // Wait for package analysis API to load (create command requires this) - // First ensure the package page has loaded (longer timeout for network flakiness) - await expect(page.locator('h1')).toContainText('vite', { timeout: 15000 }) + // First ensure the package page has loaded + await expect(page.locator('h1')).toContainText('vite') - // Find the create command row (npm variant) - it contains "create vite" in code - // The component renders all PM variants; npm is visible by default - const createCommandRow = page - .locator('[data-pm-cmd="npm"]') - .filter({ hasText: /create vite/i }) - await expect(createCommandRow).toBeVisible({ timeout: 15000 }) + // Find the create command container (wait longer for API response) + const createCommandContainer = page.locator('.group\\/createcmd') + await expect(createCommandContainer).toBeVisible({ timeout: 15000 }) // Copy button should initially be hidden (opacity-0) - const copyButton = createCommandRow.locator('button') + const copyButton = createCommandContainer.locator('button') await expect(copyButton).toHaveCSS('opacity', '0') // Hover over the container - await createCommandRow.hover() + await createCommandContainer.hover() // Copy button should become visible await expect(copyButton).toHaveCSS('opacity', '1') @@ -137,18 +107,12 @@ test.describe('Create Command', () => { await goto('/vite', { waitUntil: 'hydration' }) - // Wait for h1 to confirm page loaded (longer timeout for network flakiness) - await expect(page.locator('h1')).toContainText('vite', { timeout: 15000 }) - - // Find and hover over the create command row (npm variant), wait for API - const createCommandRow = page - .locator('[data-pm-cmd="npm"]') - .filter({ hasText: /create vite/i }) - await expect(createCommandRow).toBeVisible({ timeout: 15000 }) - await createCommandRow.hover() + // Find and hover over the create command container + const createCommandContainer = page.locator('.group\\/createcmd') + await createCommandContainer.hover() // Click the copy button - const copyButton = createCommandRow.locator('button') + const copyButton = createCommandContainer.locator('button') await copyButton.click() // Button text should change to "copied!" @@ -167,17 +131,16 @@ test.describe('Create Command', () => { test('hovering install command shows copy button', async ({ page, goto }) => { await goto('/lodash', { waitUntil: 'hydration' }) - // Find the npm install command row (npm is the default, so it's visible) - // The component uses group/cmd class for each command row - const installCommandRow = page.locator('[data-pm-cmd="npm"]').first() - await expect(installCommandRow).toBeVisible() + // Find the install command container + const installCommandContainer = page.locator('.group\\/installcmd') + await expect(installCommandContainer).toBeVisible() - // Copy button should initially be hidden (opacity-0) - const copyButton = installCommandRow.locator('button') + // Copy button should initially be hidden + const copyButton = installCommandContainer.locator('button') await expect(copyButton).toHaveCSS('opacity', '0') // Hover over the container - await installCommandRow.hover() + await installCommandContainer.hover() // Copy button should become visible await expect(copyButton).toHaveCSS('opacity', '1') @@ -193,12 +156,12 @@ test.describe('Create Command', () => { await goto('/lodash', { waitUntil: 'hydration' }) - // Find and hover over the npm install command row (npm is the default) - const installCommandRow = page.locator('[data-pm-cmd="npm"]').first() - await installCommandRow.hover() + // Find and hover over the install command container + const installCommandContainer = page.locator('.group\\/installcmd') + await installCommandContainer.hover() // Click the copy button - const copyButton = installCommandRow.locator('button') + const copyButton = installCommandContainer.locator('button') await copyButton.click() // Button text should change to "copied!" From 831ef743640e51a6aaf829ba69871d5c03bbff4c Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Fri, 30 Jan 2026 07:51:57 +0000 Subject: [PATCH 4/5] test: get first cmd --- tests/create-command.spec.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/tests/create-command.spec.ts b/tests/create-command.spec.ts index 001dfa03f1..9edcc27760 100644 --- a/tests/create-command.spec.ts +++ b/tests/create-command.spec.ts @@ -7,7 +7,7 @@ test.describe('Create Command', () => { // Create command section should be visible (SSR) // Use specific container to avoid matching README code blocks - const createCommandSection = page.locator('.group\\/createcmd') + const createCommandSection = page.locator('.group\\/createcmd').first() await expect(createCommandSection).toBeVisible() await expect(createCommandSection.locator('code')).toContainText(/create vite/i) @@ -23,7 +23,7 @@ test.describe('Create Command', () => { // Create command section should be visible (SSR) // Use specific container to avoid matching README code blocks - const createCommandSection = page.locator('.group\\/createcmd') + const createCommandSection = page.locator('.group\\/createcmd').first() await expect(createCommandSection).toBeVisible() await expect(createCommandSection.locator('code')).toContainText(/create next-app/i) @@ -40,7 +40,7 @@ test.describe('Create Command', () => { // Create command section should be visible (SSR) // nuxt has create-nuxt package, so command is "npm create nuxt" // Use specific container to avoid matching README code blocks - const createCommandSection = page.locator('.group\\/createcmd') + const createCommandSection = page.locator('.group\\/createcmd').first() await expect(createCommandSection).toBeVisible() await expect(createCommandSection.locator('code')).toContainText(/create nuxt/i) }) @@ -55,7 +55,8 @@ test.describe('Create Command', () => { await expect(page.locator('h1').filter({ hasText: 'color' })).toBeVisible() // Create command section should NOT be visible (different maintainers) - const createCommandSection = page.locator('.group\\/createcmd') + // Use .first() for consistency, though none should exist + const createCommandSection = page.locator('.group\\/createcmd').first() await expect(createCommandSection).not.toBeVisible() }) @@ -69,7 +70,8 @@ test.describe('Create Command', () => { await expect(page.locator('h1').filter({ hasText: 'lodash' })).toBeVisible() // Create command section should NOT be visible (no create-lodash exists) - const createCommandSection = page.locator('.group\\/createcmd') + // Use .first() for consistency, though none should exist + const createCommandSection = page.locator('.group\\/createcmd').first() await expect(createCommandSection).not.toBeVisible() }) }) @@ -83,7 +85,7 @@ test.describe('Create Command', () => { await expect(page.locator('h1')).toContainText('vite') // Find the create command container (wait longer for API response) - const createCommandContainer = page.locator('.group\\/createcmd') + const createCommandContainer = page.locator('.group\\/createcmd').first() await expect(createCommandContainer).toBeVisible({ timeout: 15000 }) // Copy button should initially be hidden (opacity-0) @@ -108,7 +110,7 @@ test.describe('Create Command', () => { await goto('/vite', { waitUntil: 'hydration' }) // Find and hover over the create command container - const createCommandContainer = page.locator('.group\\/createcmd') + const createCommandContainer = page.locator('.group\\/createcmd').first() await createCommandContainer.hover() // Click the copy button @@ -132,7 +134,7 @@ test.describe('Create Command', () => { await goto('/lodash', { waitUntil: 'hydration' }) // Find the install command container - const installCommandContainer = page.locator('.group\\/installcmd') + const installCommandContainer = page.locator('.group\\/installcmd').first() await expect(installCommandContainer).toBeVisible() // Copy button should initially be hidden @@ -157,7 +159,7 @@ test.describe('Create Command', () => { await goto('/lodash', { waitUntil: 'hydration' }) // Find and hover over the install command container - const installCommandContainer = page.locator('.group\\/installcmd') + const installCommandContainer = page.locator('.group\\/installcmd').first() await installCommandContainer.hover() // Click the copy button From 936dd94a896a6b4705802c737a5c3209c9cba485 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Fri, 30 Jan 2026 08:13:01 +0000 Subject: [PATCH 5/5] test: fix other selectors --- tests/create-command.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/create-command.spec.ts b/tests/create-command.spec.ts index 9edcc27760..bac1e54b77 100644 --- a/tests/create-command.spec.ts +++ b/tests/create-command.spec.ts @@ -12,7 +12,7 @@ test.describe('Create Command', () => { await expect(createCommandSection.locator('code')).toContainText(/create vite/i) // Link to create-vite should be present (uses sr-only text, so check attachment not visibility) - await expect(page.locator('a[href="/create-vite"]')).toBeAttached() + await expect(page.locator('a[href="/create-vite"]').first()).toBeAttached() }) test('/next - should show create command (shared maintainer, same repo)', async ({ @@ -28,7 +28,7 @@ test.describe('Create Command', () => { await expect(createCommandSection.locator('code')).toContainText(/create next-app/i) // Link to create-next-app should be present (uses sr-only text, so check attachment not visibility) - await expect(page.locator('a[href="/create-next-app"]')).toBeAttached() + await expect(page.locator('a[href="/create-next-app"]').first()).toBeAttached() }) test('/nuxt - should show create command (same maintainer, same org)', async ({