From 25e76119be2b498fc7504f664bbf86f1ed1ce995 Mon Sep 17 00:00:00 2001 From: Carlos Sardo Date: Tue, 31 Mar 2026 11:12:55 +0200 Subject: [PATCH 1/2] fix(docs): re-initialize search after Astro View Transitions The Search.astro component captured DOM references once at script execution time. After client-side navigation via Astro View Transitions, those references pointed to detached DOM nodes, making search non-functional until a hard refresh. Wrapped all DOM lookups and event listener attachments in an initSearch() function driven by the astro:page-load event, which fires on initial load and after each View Transition. Added 2 Playwright regression tests verifying search works after View Transition navigation. Closes bradygaster/squad#712 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/src/components/Search.astro | 116 +++++---- docs/tests/search.spec.mjs | 393 ++++++++++++++++++------------- 2 files changed, 302 insertions(+), 207 deletions(-) diff --git a/docs/src/components/Search.astro b/docs/src/components/Search.astro index cac07914b..66f9a72d1 100644 --- a/docs/src/components/Search.astro +++ b/docs/src/components/Search.astro @@ -48,15 +48,9 @@ const base = import.meta.env.BASE_URL; diff --git a/docs/tests/search.spec.mjs b/docs/tests/search.spec.mjs index 93109efa7..bc30911bb 100644 --- a/docs/tests/search.spec.mjs +++ b/docs/tests/search.spec.mjs @@ -1,164 +1,229 @@ -/** - * Playwright e2e tests for Phase 1 search improvements. - * Tests run against the live preview at http://localhost:4321/squad/ - * Run with: npx playwright test - */ -import { test, expect } from '@playwright/test'; - -const BASE = '/squad/'; - -// Helper: open search modal via button click -async function openSearchViaButton(page) { - await page.goto(BASE); - const searchBtn = page.locator('#search-btn'); - await expect(searchBtn).toBeVisible(); - await searchBtn.click(); - const modal = page.locator('#search-modal'); - await expect(modal).not.toHaveClass(/hidden/); - return modal; -} - -test.describe('Search modal interactions', () => { - - test('search modal opens when clicking the search button', async ({ page }) => { - const modal = await openSearchViaButton(page); - await expect(modal).toBeVisible(); - const input = page.locator('#search-input'); - await expect(input).toBeFocused(); - }); - - test('search modal opens on Ctrl+K keyboard shortcut', async ({ page }) => { - await page.goto(BASE); - const modal = page.locator('#search-modal'); - await expect(modal).toHaveClass(/hidden/); - - await page.keyboard.press('Control+k'); - await expect(modal).not.toHaveClass(/hidden/); - await expect(page.locator('#search-input')).toBeFocused(); - }); - - test('pressing Escape closes the search modal', async ({ page }) => { - await openSearchViaButton(page); - const modal = page.locator('#search-modal'); - await expect(modal).not.toHaveClass(/hidden/); - - await page.keyboard.press('Escape'); - await expect(modal).toHaveClass(/hidden/); - }); - - test('clicking backdrop closes the search modal', async ({ page }) => { - await openSearchViaButton(page); - const modal = page.locator('#search-modal'); - await expect(modal).not.toHaveClass(/hidden/); - - // The dialog container intercepts pointer events over the backdrop center, - // so we dispatch a click event directly on the backdrop element - await page.evaluate(() => document.getElementById('search-backdrop').click()); - await expect(modal).toHaveClass(/hidden/); - }); -}); - -test.describe('Search results', () => { - - test('typing a query shows results', async ({ page }) => { - await openSearchViaButton(page); - const input = page.locator('#search-input'); - await input.fill('agent'); - - // Wait for results to appear (debounce is 200ms + pagefind load) - const results = page.locator('#search-results a'); - await expect(results.first()).toBeVisible({ timeout: 10_000 }); - const count = await results.count(); - expect(count).toBeGreaterThan(0); - }); - - test('results display section badges', async ({ page }) => { - await openSearchViaButton(page); - await page.locator('#search-input').fill('agent'); - - // Wait for results - const resultLinks = page.locator('#search-results a'); - await expect(resultLinks.first()).toBeVisible({ timeout: 10_000 }); - - // Look for section badge spans within results - const badges = page.locator('#search-results a span.inline-flex'); - const badgeCount = await badges.count(); - expect(badgeCount).toBeGreaterThan(0); - - // Verify badge text is a known section - const knownSections = [ - 'Get Started', 'Guide', 'Features', 'Reference', - 'Scenarios', 'Concepts', 'Cookbook', 'Blog', 'Docs', 'Community' - ]; - const firstBadgeText = await badges.first().textContent(); - expect(knownSections).toContain(firstBadgeText.trim()); - }); - - test('each result has a title and excerpt', async ({ page }) => { - await openSearchViaButton(page); - await page.locator('#search-input').fill('agent'); - - const resultLinks = page.locator('#search-results a'); - await expect(resultLinks.first()).toBeVisible({ timeout: 10_000 }); - - // Check first result has a title (text-base span) and excerpt (text-sm div) - const firstResult = resultLinks.first(); - const title = firstResult.locator('span.text-base'); - await expect(title).toBeVisible(); - const titleText = await title.textContent(); - expect(titleText.trim().length).toBeGreaterThan(0); - - const excerpt = firstResult.locator('div.text-sm'); - await expect(excerpt).toBeVisible(); - }); - - test('result count text is visible', async ({ page }) => { - await openSearchViaButton(page); - await page.locator('#search-input').fill('agent'); - - // Wait for status bar showing result count - const status = page.locator('#search-status'); - await expect(status).toBeVisible({ timeout: 10_000 }); - - const statusText = await status.textContent(); - // Should match pattern like "5 results" or "1 result" - expect(statusText).toMatch(/\d+ results?/); - }); - - test('clicking a result navigates to that page', async ({ page }) => { - await openSearchViaButton(page); - await page.locator('#search-input').fill('agent'); - - const resultLinks = page.locator('#search-results a'); - await expect(resultLinks.first()).toBeVisible({ timeout: 10_000 }); - - // Get the href of the first result - const href = await resultLinks.first().getAttribute('href'); - expect(href).toBeTruthy(); - - // Click it and verify navigation - await resultLinks.first().click(); - - // Modal should close, and we should navigate to the result page - await page.waitForURL(new RegExp(href.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), { timeout: 10_000 }); - expect(page.url()).toContain(href); - }); - - test('no results state shows appropriate message', async ({ page }) => { - await openSearchViaButton(page); - const input = page.locator('#search-input'); - // Use a single random character that pagefind won't match - await input.fill('ÿ'); - - // Wait for search debounce, then check for either "No results" or - // the default "Start typing" (pagefind may not index this character). - // The search executes and either shows "No results for..." or results. - // We verify the search system responds to input. - await page.waitForTimeout(1500); - const resultsContainer = page.locator('#search-results'); - const text = await resultsContainer.textContent(); - // Either we get "No results" or actual results — either way the - // search pipeline processed the query (not still on "Start typing") - expect(text.trim()).not.toBe('Start typing to search…'); - }); -}); +/** + * Playwright e2e tests for Phase 1 search improvements. + * Tests run against the live preview at http://localhost:4321/squad/ + * Run with: npx playwright test + */ +import { test, expect } from '@playwright/test'; + +const BASE = '/squad/'; + +// Helper: open search modal via button click +async function openSearchViaButton(page) { + await page.goto(BASE); + const searchBtn = page.locator('#search-btn'); + await expect(searchBtn).toBeVisible(); + await searchBtn.click(); + const modal = page.locator('#search-modal'); + await expect(modal).not.toHaveClass(/hidden/); + return modal; +} + +test.describe('Search modal interactions', () => { + + test('search modal opens when clicking the search button', async ({ page }) => { + const modal = await openSearchViaButton(page); + await expect(modal).toBeVisible(); + const input = page.locator('#search-input'); + await expect(input).toBeFocused(); + }); + + test('search modal opens on Ctrl+K keyboard shortcut', async ({ page }) => { + await page.goto(BASE); + const modal = page.locator('#search-modal'); + await expect(modal).toHaveClass(/hidden/); + + await page.keyboard.press('Control+k'); + await expect(modal).not.toHaveClass(/hidden/); + await expect(page.locator('#search-input')).toBeFocused(); + }); + + test('pressing Escape closes the search modal', async ({ page }) => { + await openSearchViaButton(page); + const modal = page.locator('#search-modal'); + await expect(modal).not.toHaveClass(/hidden/); + + await page.keyboard.press('Escape'); + await expect(modal).toHaveClass(/hidden/); + }); + + test('clicking backdrop closes the search modal', async ({ page }) => { + await openSearchViaButton(page); + const modal = page.locator('#search-modal'); + await expect(modal).not.toHaveClass(/hidden/); + + // The dialog container intercepts pointer events over the backdrop center, + // so we dispatch a click event directly on the backdrop element + await page.evaluate(() => document.getElementById('search-backdrop').click()); + await expect(modal).toHaveClass(/hidden/); + }); +}); + +test.describe('Search results', () => { + + test('typing a query shows results', async ({ page }) => { + await openSearchViaButton(page); + const input = page.locator('#search-input'); + await input.fill('agent'); + + // Wait for results to appear (debounce is 200ms + pagefind load) + const results = page.locator('#search-results a'); + await expect(results.first()).toBeVisible({ timeout: 10_000 }); + const count = await results.count(); + expect(count).toBeGreaterThan(0); + }); + + test('results display section badges', async ({ page }) => { + await openSearchViaButton(page); + await page.locator('#search-input').fill('agent'); + + // Wait for results + const resultLinks = page.locator('#search-results a'); + await expect(resultLinks.first()).toBeVisible({ timeout: 10_000 }); + + // Look for section badge spans within results + const badges = page.locator('#search-results a span.inline-flex'); + const badgeCount = await badges.count(); + expect(badgeCount).toBeGreaterThan(0); + + // Verify badge text is a known section + const knownSections = [ + 'Get Started', 'Guide', 'Features', 'Reference', + 'Scenarios', 'Concepts', 'Cookbook', 'Blog', 'Docs', 'Community' + ]; + const firstBadgeText = await badges.first().textContent(); + expect(knownSections).toContain(firstBadgeText.trim()); + }); + + test('each result has a title and excerpt', async ({ page }) => { + await openSearchViaButton(page); + await page.locator('#search-input').fill('agent'); + + const resultLinks = page.locator('#search-results a'); + await expect(resultLinks.first()).toBeVisible({ timeout: 10_000 }); + + // Check first result has a title (text-base span) and excerpt (text-sm div) + const firstResult = resultLinks.first(); + const title = firstResult.locator('span.text-base'); + await expect(title).toBeVisible(); + const titleText = await title.textContent(); + expect(titleText.trim().length).toBeGreaterThan(0); + + const excerpt = firstResult.locator('div.text-sm'); + await expect(excerpt).toBeVisible(); + }); + + test('result count text is visible', async ({ page }) => { + await openSearchViaButton(page); + await page.locator('#search-input').fill('agent'); + + // Wait for status bar showing result count + const status = page.locator('#search-status'); + await expect(status).toBeVisible({ timeout: 10_000 }); + + const statusText = await status.textContent(); + // Should match pattern like "5 results" or "1 result" + expect(statusText).toMatch(/\d+ results?/); + }); + + test('clicking a result navigates to that page', async ({ page }) => { + await openSearchViaButton(page); + await page.locator('#search-input').fill('agent'); + + const resultLinks = page.locator('#search-results a'); + await expect(resultLinks.first()).toBeVisible({ timeout: 10_000 }); + + // Get the href of the first result + const href = await resultLinks.first().getAttribute('href'); + expect(href).toBeTruthy(); + + // Click it and verify navigation + await resultLinks.first().click(); + + // Modal should close, and we should navigate to the result page + await page.waitForURL(new RegExp(href.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), { timeout: 10_000 }); + expect(page.url()).toContain(href); + }); + + test('no results state shows appropriate message', async ({ page }) => { + await openSearchViaButton(page); + const input = page.locator('#search-input'); + // Use a single random character that pagefind won't match + await input.fill('ÿ'); + + // Wait for search debounce, then check for either "No results" or + // the default "Start typing" (pagefind may not index this character). + // The search executes and either shows "No results for..." or results. + // We verify the search system responds to input. + await page.waitForTimeout(1500); + const resultsContainer = page.locator('#search-results'); + const text = await resultsContainer.textContent(); + // Either we get "No results" or actual results — either way the + // search pipeline processed the query (not still on "Start typing") + expect(text.trim()).not.toBe('Start typing to search…'); + }); +}); + +test.describe('Search after View Transition navigation', () => { + + // Helper: search, click first result, wait for navigation to complete + async function searchAndNavigate(page) { + await page.goto(BASE); + const searchBtn = page.locator('#search-btn'); + await expect(searchBtn).toBeVisible(); + await searchBtn.click(); + + const modal = page.locator('#search-modal'); + await expect(modal).not.toHaveClass(/hidden/); + + const input = page.locator('#search-input'); + await input.fill('agent'); + + const resultLinks = page.locator('#search-results a'); + await expect(resultLinks.first()).toBeVisible({ timeout: 10_000 }); + + const href = await resultLinks.first().getAttribute('href'); + await resultLinks.first().click(); + + // Wait for View Transition navigation to complete + await page.waitForURL(new RegExp(href.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), { timeout: 10_000 }); + // Allow astro:page-load handlers to re-initialize + await page.waitForTimeout(500); + return href; + } + + test('search modal works after navigating via search result (View Transition)', async ({ page }) => { + await searchAndNavigate(page); + + // On the new page, try to open search again + const searchBtn = page.locator('#search-btn'); + await expect(searchBtn).toBeVisible({ timeout: 5_000 }); + await searchBtn.click(); + + const modal = page.locator('#search-modal'); + await expect(modal).not.toHaveClass(/hidden/); + + // Verify input is focused and can receive text + const input = page.locator('#search-input'); + await expect(input).toBeFocused({ timeout: 3_000 }); + + // Type a new query and verify results appear + await input.fill('charter'); + const resultLinks = page.locator('#search-results a'); + await expect(resultLinks.first()).toBeVisible({ timeout: 10_000 }); + const count = await resultLinks.count(); + expect(count).toBeGreaterThan(0); + }); + + test('Ctrl+K opens search after View Transition navigation', async ({ page }) => { + await searchAndNavigate(page); + + // Press Ctrl+K on the post-navigation page + await page.keyboard.press('Control+k'); + + const modal = page.locator('#search-modal'); + await expect(modal).not.toHaveClass(/hidden/); + + const input = page.locator('#search-input'); + await expect(input).toBeFocused({ timeout: 3_000 }); + }); +}); From 8aafe23e7d50b6b2e0938bde4a625d67c1fec230 Mon Sep 17 00:00:00 2001 From: Carlos Sardo Date: Tue, 31 Mar 2026 17:11:29 +0200 Subject: [PATCH 2/2] fix(docs): clear debounceTimer on reinit and add href null guard Address PR review comments: - Clear pending debounceTimer in initSearch() to prevent stale callbacks after View Transition navigation - Add explicit href truthy assertion in searchAndNavigate test helper before using it for regex construction --- docs/src/components/Search.astro | 3 +++ docs/tests/search.spec.mjs | 1 + 2 files changed, 4 insertions(+) diff --git a/docs/src/components/Search.astro b/docs/src/components/Search.astro index 66f9a72d1..4524ce55b 100644 --- a/docs/src/components/Search.astro +++ b/docs/src/components/Search.astro @@ -174,6 +174,9 @@ const base = import.meta.env.BASE_URL; } function initSearch() { + // Cancel any in-flight debounce from the previous page + clearTimeout(debounceTimer); + // Tear down old element listeners (old refs may be detached) if (_onBtnClick && searchBtn) searchBtn.removeEventListener('click', _onBtnClick); if (_onBackdropClick && searchBackdrop) searchBackdrop.removeEventListener('click', _onBackdropClick); diff --git a/docs/tests/search.spec.mjs b/docs/tests/search.spec.mjs index bc30911bb..588e3f73b 100644 --- a/docs/tests/search.spec.mjs +++ b/docs/tests/search.spec.mjs @@ -182,6 +182,7 @@ test.describe('Search after View Transition navigation', () => { await expect(resultLinks.first()).toBeVisible({ timeout: 10_000 }); const href = await resultLinks.first().getAttribute('href'); + expect(href).toBeTruthy(); await resultLinks.first().click(); // Wait for View Transition navigation to complete