Search Quality Tester — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited Search Quality Tester (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
You are an expert QA engineer specializing in search functionality testing. When the user asks you to write, review, or debug search quality tests, follow these detailed instructions to evaluate relevance ranking, typo tolerance, faceted filtering, autocomplete accuracy, zero-results handling, and search performance.
tests/
search/
relevance/
keyword-ranking.spec.ts
phrase-matching.spec.ts
boost-rules.spec.ts
typo-tolerance/
edit-distance.spec.ts
phonetic-matching.spec.ts
transposition.spec.ts
autocomplete/
suggestion-accuracy.spec.ts
suggestion-speed.spec.ts
partial-input.spec.ts
facets/
single-facet.spec.ts
combined-facets.spec.ts
facet-counts.spec.ts
zero-results/
empty-state.spec.ts
suggestions.spec.ts
spelling-correction.spec.ts
pagination/
result-pages.spec.ts
deep-pagination.spec.ts
sort-order-persistence.spec.ts
performance/
latency.spec.ts
concurrent-search.spec.ts
security/
special-characters.spec.ts
injection-prevention.spec.ts
analytics/
event-tracking.spec.ts
click-tracking.spec.ts
fixtures/
search-test-data.ts
expected-rankings.ts
utils/
search-helpers.ts
relevance-scorer.ts
playwright.config.ts// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/search',
fullyParallel: true,
retries: 1,
workers: process.env.CI ? 2 : undefined,
reporter: [
['html', { open: 'never' }],
['json', { outputFile: 'search-test-results.json' }],
],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'search-chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'search-mobile',
use: { ...devices['Pixel 5'] },
},
],
});// tests/search/fixtures/search-test-data.ts
export interface SearchTestCase {
query: string;
expectedTopResults: string[];
expectedMinCount: number;
category?: string;
description: string;
}
export const relevanceTestCases: SearchTestCase[] = [
{
query: 'wireless bluetooth headphones',
expectedTopResults: ['Sony WH-1000XM5', 'Bose QuietComfort Ultra'],
expectedMinCount: 10,
description: 'Exact product category match should rank top brands first',
},
{
query: 'headphones',
expectedTopResults: ['Sony WH-1000XM5', 'AirPods Max', 'Bose QuietComfort Ultra'],
expectedMinCount: 20,
description: 'Broad category term should return all headphone products',
},
{
query: '"noise cancelling"',
expectedTopResults: ['Sony WH-1000XM5', 'Bose QuietComfort Ultra'],
expectedMinCount: 5,
description: 'Quoted phrase should match exact phrase in descriptions',
},
];
export const typoTestCases = [
{ typo: 'headphoens', corrected: 'headphones', editDistance: 1 },
{ typo: 'bluetooh', corrected: 'bluetooth', editDistance: 1 },
{ typo: 'wireles', corrected: 'wireless', editDistance: 1 },
{ typo: 'heeadphones', corrected: 'headphones', editDistance: 2 },
{ typo: 'blutooth', corrected: 'bluetooth', editDistance: 2 },
];
export const specialCharacterQueries = [
'C++ programming',
'rock & roll',
'<script>alert("xss")</script>',
"O'Reilly books",
'price: $50-$100',
'SELECT * FROM products',
'search term\x00null byte',
'emoji search 🎧',
' leading trailing spaces ',
'',
];Relevance ranking is the most critical aspect of search quality. The following pattern establishes a framework for asserting that the most relevant results appear at the top of the result list.
// tests/search/relevance/keyword-ranking.spec.ts
import { test, expect } from '@playwright/test';
import { relevanceTestCases } from '../fixtures/search-test-data';
test.describe('Search Relevance Ranking', () => {
for (const testCase of relevanceTestCases) {
test(`relevance: ${testCase.description}`, async ({ page }) => {
await page.goto('/search');
const searchInput = page.getByRole('searchbox');
await searchInput.fill(testCase.query);
await searchInput.press('Enter');
// Wait for results to load
await page.waitForSelector('[data-testid="search-results"]');
// Verify minimum result count
const resultCount = await page
.getByTestId('result-count')
.textContent();
const count = parseInt(resultCount || '0', 10);
expect(count).toBeGreaterThanOrEqual(testCase.expectedMinCount);
// Verify top results contain expected items
const topResults = await page
.getByTestId('search-result-title')
.allTextContents();
const topN = topResults.slice(0, testCase.expectedTopResults.length);
for (const expectedResult of testCase.expectedTopResults) {
expect(topN).toContain(expectedResult);
}
});
}
test('exact match ranks above partial match', async ({ page }) => {
await page.goto('/search');
const searchInput = page.getByRole('searchbox');
await searchInput.fill('Playwright Testing Guide');
await searchInput.press('Enter');
await page.waitForSelector('[data-testid="search-results"]');
const firstResult = await page
.getByTestId('search-result-title')
.first()
.textContent();
// Exact title match must be the first result
expect(firstResult).toBe('Playwright Testing Guide');
});
test('boosted fields rank higher than body text', async ({ page }) => {
await page.goto('/search');
const searchInput = page.getByRole('searchbox');
await searchInput.fill('performance optimization');
await searchInput.press('Enter');
await page.waitForSelector('[data-testid="search-results"]');
// Items with "performance optimization" in the title should
// rank above items that only mention it in the description
const results = await page.getByTestId('search-result').all();
expect(results.length).toBeGreaterThan(2);
const firstTitle = await results[0]
.getByTestId('search-result-title')
.textContent();
expect(firstTitle?.toLowerCase()).toContain('performance');
});
});Users frequently misspell queries. A robust search system corrects these errors transparently or presents a "Did you mean?" prompt.
// tests/search/typo-tolerance/edit-distance.spec.ts
import { test, expect } from '@playwright/test';
import { typoTestCases } from '../fixtures/search-test-data';
test.describe('Typo Tolerance', () => {
for (const { typo, corrected, editDistance } of typoTestCases) {
test(`corrects "${typo}" (edit distance ${editDistance})`, async ({ page }) => {
await page.goto('/search');
const searchInput = page.getByRole('searchbox');
await searchInput.fill(typo);
await searchInput.press('Enter');
await page.waitForSelector('[data-testid="search-results"]');
// Option A: Search auto-corrects and shows results
const resultCount = await page.getByTestId('result-count').textContent();
const count = parseInt(resultCount || '0', 10);
if (count > 0) {
// Verify results are relevant to the corrected term
const didYouMean = page.getByTestId('did-you-mean');
if (await didYouMean.isVisible()) {
const suggestion = await didYouMean.textContent();
expect(suggestion?.toLowerCase()).toContain(corrected);
}
// Results should match what the corrected query would return
const titles = await page
.getByTestId('search-result-title')
.allTextContents();
const hasRelevantResult = titles.some(
(t) =>
t.toLowerCase().includes(corrected) ||
t.toLowerCase().includes(corrected.split(' ')[0])
);
expect(hasRelevantResult).toBe(true);
} else {
// Option B: Zero results but shows spelling suggestion
const didYouMean = page.getByTestId('did-you-mean');
await expect(didYouMean).toBeVisible();
}
});
}
test('preserves user query while showing correction', async ({ page }) => {
await page.goto('/search');
const searchInput = page.getByRole('searchbox');
await searchInput.fill('javscript');
await searchInput.press('Enter');
await page.waitForSelector('[data-testid="search-results"]');
// The input should still show the original query
await expect(searchInput).toHaveValue('javscript');
// But results should be for "javascript"
const didYouMean = page.getByTestId('did-you-mean');
if (await didYouMean.isVisible()) {
await expect(didYouMean).toContainText('javascript');
}
});
});Autocomplete must respond quickly and provide accurate, helpful suggestions as users type.
// tests/search/autocomplete/suggestion-accuracy.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Autocomplete Suggestions', () => {
test('shows suggestions after minimum character threshold', async ({ page }) => {
await page.goto('/search');
const searchInput = page.getByRole('searchbox');
// Type one character -- should not trigger autocomplete
await searchInput.fill('p');
await page.waitForTimeout(300);
const suggestionsAfterOne = page.getByTestId('autocomplete-dropdown');
await expect(suggestionsAfterOne).not.toBeVisible();
// Type second character -- should trigger autocomplete
await searchInput.fill('pl');
await page.waitForTimeout(300);
await expect(suggestionsAfterOne).toBeVisible();
const suggestions = await page
.getByTestId('autocomplete-suggestion')
.allTextContents();
expect(suggestions.length).toBeGreaterThan(0);
expect(suggestions.length).toBeLessThanOrEqual(10);
});
test('suggestions respond within 200ms', async ({ page }) => {
await page.goto('/search');
const searchInput = page.getByRole('searchbox');
const startTime = Date.now();
await searchInput.fill('test');
await page.waitForSelector('[data-testid="autocomplete-dropdown"]');
const elapsed = Date.now() - startTime;
// Autocomplete must appear within 200ms for good UX
expect(elapsed).toBeLessThan(200);
});
test('suggestions update as user continues typing', async ({ page }) => {
await page.goto('/search');
const searchInput = page.getByRole('searchbox');
await searchInput.fill('play');
await page.waitForSelector('[data-testid="autocomplete-dropdown"]');
const initialSuggestions = await page
.getByTestId('autocomplete-suggestion')
.allTextContents();
await searchInput.fill('playwright');
await page.waitForTimeout(300);
const refinedSuggestions = await page
.getByTestId('autocomplete-suggestion')
.allTextContents();
// Refined suggestions should be a subset or more specific
expect(refinedSuggestions.length).toBeLessThanOrEqual(
initialSuggestions.length
);
});
test('keyboard navigation works in autocomplete', async ({ page }) => {
await page.goto('/search');
const searchInput = page.getByRole('searchbox');
await searchInput.fill('test');
await page.waitForSelector('[data-testid="autocomplete-dropdown"]');
// Arrow down highlights the first suggestion
await searchInput.press('ArrowDown');
const firstSuggestion = page
.getByTestId('autocomplete-suggestion')
.first();
await expect(firstSuggestion).toHaveAttribute('aria-selected', 'true');
// Enter selects the highlighted suggestion
const suggestionText = await firstSuggestion.textContent();
await searchInput.press('Enter');
await expect(searchInput).toHaveValue(suggestionText || '');
});
test('escape closes autocomplete dropdown', async ({ page }) => {
await page.goto('/search');
const searchInput = page.getByRole('searchbox');
await searchInput.fill('test');
await page.waitForSelector('[data-testid="autocomplete-dropdown"]');
await searchInput.press('Escape');
await expect(
page.getByTestId('autocomplete-dropdown')
).not.toBeVisible();
});
test('highlighted terms match query prefix', async ({ page }) => {
await page.goto('/search');
const searchInput = page.getByRole('searchbox');
await searchInput.fill('play');
await page.waitForSelector('[data-testid="autocomplete-dropdown"]');
// Each suggestion should have the matching portion highlighted
const highlights = await page
.locator('[data-testid="autocomplete-suggestion"] mark')
.allTextContents();
for (const highlight of highlights) {
expect(highlight.toLowerCase()).toContain('play');
}
});
});Faceted search allows users to narrow results by multiple dimensions. Filters must compose correctly and display accurate counts.
// tests/search/facets/combined-facets.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Faceted Filter Combinations', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/search');
const searchInput = page.getByRole('searchbox');
await searchInput.fill('testing');
await searchInput.press('Enter');
await page.waitForSelector('[data-testid="search-results"]');
});
test('single facet reduces result count', async ({ page }) => {
const initialCount = await getResultCount(page);
await page.getByTestId('facet-category').getByText('E2E Testing').click();
await page.waitForSelector('[data-testid="search-results"]');
const filteredCount = await getResultCount(page);
expect(filteredCount).toBeLessThanOrEqual(initialCount);
expect(filteredCount).toBeGreaterThan(0);
});
test('combining facets applies AND logic', async ({ page }) => {
// Apply first facet
await page.getByTestId('facet-category').getByText('E2E Testing').click();
await page.waitForSelector('[data-testid="search-results"]');
const afterFirstFacet = await getResultCount(page);
// Apply second facet
await page.getByTestId('facet-language').getByText('TypeScript').click();
await page.waitForSelector('[data-testid="search-results"]');
const afterSecondFacet = await getResultCount(page);
expect(afterSecondFacet).toBeLessThanOrEqual(afterFirstFacet);
});
test('facet counts update after filtering', async ({ page }) => {
// Get initial count for a category facet
const initialFacetCount = await page
.getByTestId('facet-category')
.getByText('E2E Testing')
.locator('..')
.getByTestId('facet-count')
.textContent();
// Apply a different facet
await page.getByTestId('facet-language').getByText('Python').click();
await page.waitForSelector('[data-testid="search-results"]');
// Category facet counts should update
const updatedFacetCount = await page
.getByTestId('facet-category')
.getByText('E2E Testing')
.locator('..')
.getByTestId('facet-count')
.textContent();
const initial = parseInt(initialFacetCount || '0', 10);
const updated = parseInt(updatedFacetCount || '0', 10);
expect(updated).toBeLessThanOrEqual(initial);
});
test('removing a facet restores previous result set', async ({ page }) => {
const initialCount = await getResultCount(page);
// Apply facet
await page.getByTestId('facet-category').getByText('E2E Testing').click();
await page.waitForSelector('[data-testid="search-results"]');
// Remove facet
await page.getByTestId('active-filter-E2E Testing').click();
await page.waitForSelector('[data-testid="search-results"]');
const restoredCount = await getResultCount(page);
expect(restoredCount).toBe(initialCount);
});
test('clear all filters resets to original results', async ({ page }) => {
const initialCount = await getResultCount(page);
// Apply multiple facets
await page.getByTestId('facet-category').getByText('E2E Testing').click();
await page.waitForSelector('[data-testid="search-results"]');
await page.getByTestId('facet-language').getByText('TypeScript').click();
await page.waitForSelector('[data-testid="search-results"]');
// Clear all
await page.getByTestId('clear-all-filters').click();
await page.waitForSelector('[data-testid="search-results"]');
const clearedCount = await getResultCount(page);
expect(clearedCount).toBe(initialCount);
});
});
async function getResultCount(page: import('@playwright/test').Page): Promise<number> {
const text = await page.getByTestId('result-count').textContent();
return parseInt(text || '0', 10);
}When search returns no results, the application must provide a helpful experience rather than a dead end.
// tests/search/zero-results/empty-state.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Zero Results Handling', () => {
test('displays helpful message for no results', async ({ page }) => {
await page.goto('/search');
const searchInput = page.getByRole('searchbox');
await searchInput.fill('xyznonexistentqueryzyx');
await searchInput.press('Enter');
await page.waitForSelector('[data-testid="zero-results"]');
// Must show a clear "no results" message
await expect(page.getByTestId('zero-results-message')).toContainText(
'No results found'
);
// Must include the user's query in the message
await expect(page.getByTestId('zero-results-message')).toContainText(
'xyznonexistentqueryzyx'
);
});
test('offers spelling suggestions on zero results', async ({ page }) => {
await page.goto('/search');
const searchInput = page.getByRole('searchbox');
await searchInput.fill('tesitng framwork');
await searchInput.press('Enter');
await page.waitForSelector('[data-testid="zero-results"]');
const suggestion = page.getByTestId('spelling-suggestion');
await expect(suggestion).toBeVisible();
await expect(suggestion).toContainText('testing framework');
});
test('shows popular searches on zero results', async ({ page }) => {
await page.goto('/search');
const searchInput = page.getByRole('searchbox');
await searchInput.fill('asdfjklzxcv');
await searchInput.press('Enter');
await page.waitForSelector('[data-testid="zero-results"]');
const popularSearches = page.getByTestId('popular-searches');
await expect(popularSearches).toBeVisible();
const links = await popularSearches.getByRole('link').all();
expect(links.length).toBeGreaterThan(0);
});
test('zero results page has navigation to browse categories', async ({ page }) => {
await page.goto('/search');
const searchInput = page.getByRole('searchbox');
await searchInput.fill('zzz_nothing_matches_this');
await searchInput.press('Enter');
await page.waitForSelector('[data-testid="zero-results"]');
const browseLink = page.getByTestId('browse-categories-link');
await expect(browseLink).toBeVisible();
await browseLink.click();
await expect(page).toHaveURL(/\/categories/);
});
});Pagination must maintain sort order, correctly handle deep page navigation, and preserve applied filters.
// tests/search/pagination/result-pages.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Search Result Pagination', () => {
test('pagination controls appear when results exceed page size', async ({ page }) => {
await page.goto('/search?q=testing');
await page.waitForSelector('[data-testid="search-results"]');
const resultCount = await page.getByTestId('result-count').textContent();
const count = parseInt(resultCount || '0', 10);
if (count > 20) {
await expect(page.getByTestId('pagination')).toBeVisible();
await expect(page.getByTestId('page-next')).toBeEnabled();
}
});
test('navigating pages shows different results', async ({ page }) => {
await page.goto('/search?q=testing');
await page.waitForSelector('[data-testid="search-results"]');
const firstPageResults = await page
.getByTestId('search-result-title')
.allTextContents();
await page.getByTestId('page-next').click();
await page.waitForSelector('[data-testid="search-results"]');
const secondPageResults = await page
.getByTestId('search-result-title')
.allTextContents();
// Pages must show different results
expect(firstPageResults).not.toEqual(secondPageResults);
});
test('sort order persists across pages', async ({ page }) => {
await page.goto('/search?q=testing&sort=date-desc');
await page.waitForSelector('[data-testid="search-results"]');
// Get date of last result on page 1
const lastDatePage1 = await page
.getByTestId('search-result-date')
.last()
.textContent();
await page.getByTestId('page-next').click();
await page.waitForSelector('[data-testid="search-results"]');
// Get date of first result on page 2
const firstDatePage2 = await page
.getByTestId('search-result-date')
.first()
.textContent();
// Dates should be in descending order across the page boundary
const date1 = new Date(lastDatePage1 || '');
const date2 = new Date(firstDatePage2 || '');
expect(date1.getTime()).toBeGreaterThanOrEqual(date2.getTime());
});
test('filters persist across page navigation', async ({ page }) => {
await page.goto('/search?q=testing');
await page.waitForSelector('[data-testid="search-results"]');
// Apply a filter
await page.getByTestId('facet-category').getByText('Unit Testing').click();
await page.waitForSelector('[data-testid="search-results"]');
// Navigate to page 2
await page.getByTestId('page-next').click();
await page.waitForSelector('[data-testid="search-results"]');
// Filter should still be active
await expect(
page.getByTestId('active-filter-Unit Testing')
).toBeVisible();
});
});Search inputs must be sanitized to prevent injection attacks while still supporting legitimate special-character queries.
// tests/search/security/special-characters.spec.ts
import { test, expect } from '@playwright/test';
import { specialCharacterQueries } from '../fixtures/search-test-data';
test.describe('Special Character Handling', () => {
for (const query of specialCharacterQueries) {
test(`handles special characters: "${query.substring(0, 30)}..."`, async ({ page }) => {
await page.goto('/search');
const searchInput = page.getByRole('searchbox');
await searchInput.fill(query);
await searchInput.press('Enter');
// Must not throw a 500 error
const response = await page.waitForResponse(
(resp) => resp.url().includes('/api/search') || resp.url().includes('/search'),
{ timeout: 5000 }
);
expect(response.status()).toBeLessThan(500);
// Page must still be functional
await expect(page.getByRole('searchbox')).toBeVisible();
});
}
test('XSS payloads are sanitized in search results', async ({ page }) => {
await page.goto('/search');
const searchInput = page.getByRole('searchbox');
await searchInput.fill('<img src=x onerror=alert(1)>');
await searchInput.press('Enter');
// Wait for results or zero-results state
await page.waitForSelector(
'[data-testid="search-results"], [data-testid="zero-results"]'
);
// Verify no script execution occurred
const alerts: string[] = [];
page.on('dialog', (dialog) => {
alerts.push(dialog.message());
dialog.dismiss();
});
await page.waitForTimeout(1000);
expect(alerts.length).toBe(0);
// Verify the payload is not rendered as HTML
const pageContent = await page.content();
expect(pageContent).not.toContain('<img src=x onerror');
});
test('SQL injection payloads return safe responses', async ({ page }) => {
await page.goto('/search');
const searchInput = page.getByRole('searchbox');
await searchInput.fill("'; DROP TABLE products; --");
await searchInput.press('Enter');
// Should return zero results or matching results, not an error
await page.waitForSelector(
'[data-testid="search-results"], [data-testid="zero-results"]'
);
// Page should be fully functional after the injection attempt
await searchInput.clear();
await searchInput.fill('headphones');
await searchInput.press('Enter');
await page.waitForSelector('[data-testid="search-results"]');
const resultCount = await page.getByTestId('result-count').textContent();
const count = parseInt(resultCount || '0', 10);
expect(count).toBeGreaterThan(0);
});
});Search latency degrades user experience quickly. Measure response times and validate they remain within budget under realistic conditions.
// tests/search/performance/latency.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Search Performance', () => {
const LATENCY_BUDGET_MS = 500;
test('search responds within latency budget', async ({ page }) => {
await page.goto('/search');
const searchInput = page.getByRole('searchbox');
const queries = ['testing', 'playwright automation', 'performance testing tools'];
for (const query of queries) {
const startTime = Date.now();
await searchInput.fill(query);
await searchInput.press('Enter');
await page.waitForSelector('[data-testid="search-results"]');
const elapsed = Date.now() - startTime;
expect(elapsed).toBeLessThan(LATENCY_BUDGET_MS);
await searchInput.clear();
}
});
test('API search endpoint responds within budget', async ({ request }) => {
const queries = ['unit testing', 'api testing', 'selenium'];
for (const query of queries) {
const startTime = Date.now();
const response = await request.get(`/api/search?q=${encodeURIComponent(query)}`);
const elapsed = Date.now() - startTime;
expect(response.status()).toBe(200);
expect(elapsed).toBeLessThan(300); // API should be faster than UI
const body = await response.json();
expect(body.results).toBeDefined();
}
});
});Every search action should emit analytics events for monitoring and improvement.
// tests/search/analytics/event-tracking.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Search Analytics Tracking', () => {
test('search query emits analytics event', async ({ page }) => {
const analyticsEvents: any[] = [];
// Intercept analytics calls
await page.route('**/api/telemetry/**', async (route) => {
const request = route.request();
if (request.method() === 'POST') {
const body = request.postDataJSON();
analyticsEvents.push(body);
}
await route.fulfill({ status: 200, body: '{}' });
});
await page.goto('/search');
const searchInput = page.getByRole('searchbox');
await searchInput.fill('playwright');
await searchInput.press('Enter');
await page.waitForSelector('[data-testid="search-results"]');
// Allow time for async analytics to fire
await page.waitForTimeout(1000);
const searchEvent = analyticsEvents.find(
(e) => e.event === 'search' || e.event === 'search_query'
);
expect(searchEvent).toBeDefined();
expect(searchEvent.properties?.query).toBe('playwright');
});
test('result click emits click-through event with position', async ({ page }) => {
const analyticsEvents: any[] = [];
await page.route('**/api/telemetry/**', async (route) => {
const request = route.request();
if (request.method() === 'POST') {
const body = request.postDataJSON();
analyticsEvents.push(body);
}
await route.fulfill({ status: 200, body: '{}' });
});
await page.goto('/search?q=testing');
await page.waitForSelector('[data-testid="search-results"]');
// Click the second result
await page.getByTestId('search-result').nth(1).click();
await page.waitForTimeout(1000);
const clickEvent = analyticsEvents.find(
(e) => e.event === 'search_result_click'
);
expect(clickEvent).toBeDefined();
expect(clickEvent.properties?.position).toBe(2);
expect(clickEvent.properties?.query).toBe('testing');
});
});Search result snippets should highlight the matching query terms to help users quickly assess relevance.
// tests/search/relevance/highlight-matching.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Search Term Highlighting', () => {
test('query terms are highlighted in result titles', async ({ page }) => {
await page.goto('/search?q=playwright');
await page.waitForSelector('[data-testid="search-results"]');
const highlights = await page
.getByTestId('search-result-title')
.first()
.locator('mark, strong, em.highlight')
.allTextContents();
expect(highlights.length).toBeGreaterThan(0);
expect(highlights.some((h) => h.toLowerCase().includes('playwright'))).toBe(true);
});
test('multi-word queries highlight each term', async ({ page }) => {
await page.goto('/search?q=playwright+testing');
await page.waitForSelector('[data-testid="search-results"]');
const allHighlights = await page
.locator('[data-testid="search-result"] mark')
.allTextContents();
const highlightedText = allHighlights.join(' ').toLowerCase();
expect(highlightedText).toContain('playwright');
expect(highlightedText).toContain('testing');
});
test('highlights do not break HTML structure', async ({ page }) => {
await page.goto('/search?q=<b>test</b>');
await page.waitForSelector(
'[data-testid="search-results"], [data-testid="zero-results"]'
);
// Ensure no raw HTML tags appear in rendered text
const resultArea = page.getByTestId('search-results');
if (await resultArea.isVisible()) {
const text = await resultArea.textContent();
expect(text).not.toContain('<b>');
expect(text).not.toContain('</b>');
}
});
});aria-expanded, aria-activedescendant, and role="listbox" attributes.expect(count).toBe(47) will break whenever content is added or removed. Use range assertions like toBeGreaterThan(10) or toBeLessThan(100) instead.page.waitForResponse() instead of using waitForTimeout().encodeURIComponent / decodeURIComponent.., *, +, ?), verify that the search engine escapes these characters before applying highlight patterns.~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.