diff --git a/package-lock.json b/package-lock.json index d8121870f..0d3d11f67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wordplay", - "version": "0.16.13", + "version": "0.16.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wordplay", - "version": "0.16.13", + "version": "0.16.14", "bundleDependencies": [ "shared-types" ], @@ -20,6 +20,7 @@ "firebase-admin": "^13", "firebase-functions": "^6", "firebase-functions-test": "^3", + "fuse.js": "^7.1.0", "graphemer": "^1.4.0", "matter-js": "^0.20", "pitchy": "^4.1.0", @@ -64,6 +65,7 @@ } }, "functions/src/shared": { + "name": "shared-types", "version": "1.0.0", "license": "ISC", "devDependencies": { @@ -7133,6 +7135,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuse.js": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz", + "integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, "node_modules/gaxios": { "version": "6.7.1", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", diff --git a/package.json b/package.json index a63e97ccb..81a0e3280 100644 --- a/package.json +++ b/package.json @@ -49,10 +49,6 @@ "devDependencies": { "@google-cloud/translate": "^9", "@playwright/test": "^1", - "svelte": "^5", - "svelte-check": "^4", - "svelte-jester": "^5", - "svelte-preprocess": "^6.0", "@sveltejs/adapter-static": "^3", "@sveltejs/kit": "^2.20.6", "@sveltejs/vite-plugin-svelte": "^5", @@ -71,6 +67,10 @@ "prettier": "^3", "prettier-plugin-svelte": "^3", "run-script-os": "^1", + "svelte": "^5", + "svelte-check": "^4", + "svelte-jester": "^5", + "svelte-preprocess": "^6.0", "ts-jest": "^29", "ts-json-schema-generator": "^2", "tslib": "^2", @@ -90,13 +90,14 @@ "firebase-admin": "^13", "firebase-functions": "^6", "firebase-functions-test": "^3", + "fuse.js": "^7.1.0", "graphemer": "^1.4.0", "matter-js": "^0.20", "pitchy": "^4.1.0", "recoverable-random": "^1.0.5", + "shared-types": "file:./functions/src/shared", "uuid": "^11", - "zod": "^3.23.8", - "shared-types": "file:./functions/src/shared" + "zod": "^3.23.8" }, "browserslist": [ "defaults", @@ -104,5 +105,8 @@ ], "bundledDependencies": [ "shared-types" + ], + "bundleDependencies": [ + "shared-types" ] } diff --git a/src/components/app/ProjectPreview.svelte b/src/components/app/ProjectPreview.svelte index dab6dec54..6762afe7f 100644 --- a/src/components/app/ProjectPreview.svelte +++ b/src/components/app/ProjectPreview.svelte @@ -35,6 +35,8 @@ children?: import('svelte').Snippet; anonymize?: boolean; showCollaborators?: boolean; + /** Search term for highlighting matches in project names */ + searchTerm?: string; } function findCharacterName(value: Value): string | null { @@ -71,6 +73,7 @@ children, anonymize = true, showCollaborators = false, + searchTerm = '', }: Props = $props(); // Clone the project and get its initial value, then stop the project's evaluator. @@ -166,6 +169,7 @@ const user = getUser(); let path = $derived(link ?? project.getLink(true)); + /** See if this is a public project being viewed by someone who isn't a creator or collaborator */ let audience = $derived(isAudience($user, project)); @@ -223,14 +227,38 @@ {#if name}
{#if action} - {project.getName()} + {#if searchTerm.trim()} + {@const name = project.getName()} + {@const searchLower = searchTerm.toLowerCase()} + {@const textLower = name.toLowerCase()} + {@const index = textLower.indexOf(searchLower)} + {#if index !== -1} + {name.substring(0, index)}{name.substring(index, index + searchTerm.length)}{name.substring(index + searchTerm.length)} + {:else} + {name} + {/if} + {:else} + {project.getName()} + {/if} {:else} {#if project.getName().length === 0} {:else} - {project.getName()}{/if}{name.substring(index, index + searchTerm.length)}{name.substring(index + searchTerm.length)} + {:else} + {name} + {/if} + {:else} + {project.getName()} + {/if}{/if} {#if navigating && `${navigating.to?.url.pathname}${navigating.to?.url.search}` === path} {:else}{@render children?.()} @@ -353,4 +381,12 @@ gap: var(--wordplay-spacing); row-gap: var(--wordplay-spacing); } + + .search-highlight { + background-color: #ffffff; + color: #1f2937; + padding: 0 2px; + border-radius: 2px; + font-weight: 600; + } \ No newline at end of file diff --git a/src/components/app/ProjectPreviewSet.svelte b/src/components/app/ProjectPreviewSet.svelte index 624b1b7c1..16dc33df8 100644 --- a/src/components/app/ProjectPreviewSet.svelte +++ b/src/components/app/ProjectPreviewSet.svelte @@ -34,6 +34,7 @@ children?: Snippet; anonymize?: boolean; showCollaborators?: boolean; + searchTerm?: string; } let { @@ -44,6 +45,7 @@ children, anonymize = true, showCollaborators = false, + searchTerm = '', }: Props = $props(); function sortProjects(projects: Project[]): Project[] { @@ -63,6 +65,7 @@ link={project.getLink(true)} {anonymize} {showCollaborators} + {searchTerm} >
{#if edit} + {/if} + {#if copy} + + {/if} + {@const removeMeta = remove(project)} + {#if removeMeta} + removeMeta.action()} + icon={removeMeta.label} + > + {/if} +
+ {@render children?.()} + + + + +
+ + \ No newline at end of file diff --git a/src/examples/examples.test.ts b/src/examples/examples.test.ts index 20f77965b..12c0d1be1 100644 --- a/src/examples/examples.test.ts +++ b/src/examples/examples.test.ts @@ -14,11 +14,12 @@ function readProjects(dir: string): SerializedProject[] { const proj: SerializedProject[] = []; readdirSync(path.join('static', dir), { withFileTypes: true }).forEach( (file) => { - if (file.isFile()) { + if (file.isFile() && file.name.endsWith('.wp')) { const text = readFileSync( path.join('static', dir, file.name), 'utf8', ); + console.log('parsing ' + file.name); const project = parseSerializedProject( text, file.name.split('.')[0], @@ -177,6 +178,8 @@ test.each([...projects])( ); const value = evaluator.getInitialValue(); evaluator.stop(); + console.log(example.name); + console.log(value?.toWordplay()); expect(value).not.toBeInstanceOf(ExceptionValue); }, ); diff --git a/src/locale/en-US.json b/src/locale/en-US.json index 73442b573..fef5bf9a0 100644 --- a/src/locale/en-US.json +++ b/src/locale/en-US.json @@ -4904,6 +4904,9 @@ "shared": "Shared", "archived": "Archived" }, + "search": { + "description": "Search projects and files" + }, "button": { "newproject": "new project", "editproject": "edit this project", diff --git a/src/routes/projects/+page.svelte b/src/routes/projects/+page.svelte index 5a9ba239e..6cfe96d50 100644 --- a/src/routes/projects/+page.svelte +++ b/src/routes/projects/+page.svelte @@ -5,12 +5,14 @@ import Header from '@components/app/Header.svelte'; import Notice from '@components/app/Notice.svelte'; import ProjectPreviewSet from '@components/app/ProjectPreviewSet.svelte'; + import Spinning from '@components/app/Spinning.svelte'; import Subheader from '@components/app/Subheader.svelte'; import Writing from '@components/app/Writing.svelte'; import MarkupHTMLView from '@components/concepts/MarkupHTMLView.svelte'; import { getUser } from '@components/project/Contexts'; import Button from '@components/widgets/Button.svelte'; + import TextField from '@components/widgets/TextField.svelte'; import Title from '@components/widgets/Title.svelte'; import { Galleries, Projects, locales } from '@db/Database'; import type Project from '@db/projects/Project'; @@ -19,6 +21,7 @@ COPY_SYMBOL, EDIT_SYMBOL, } from '../../parser/Symbols'; + import Fuse from 'fuse.js'; const user = getUser(); @@ -36,20 +39,76 @@ // Whether to show an error let deleteError = $state(false); + + // Search functionality + let searchTerm = $state(''); + + // Fuse.js configuration for fuzzy search + const fuseOptions = { + includeScore: true, + threshold: 0.4, // 0.0 = exact match, 1.0 = match anything + ignoreLocation: true, // Don't care where in string match occurs + keys: ['name', 'files.name'] + }; + + // Create searchable data structure for projects + function createSearchableProjects(projects: Project[]) { + return projects.map(project => ({ + project: project, + name: project.getName(), + files: project.getSources().map(source => ({ + name: source.getPreferredName($locales.getLocales()) + })) + })); + } + + // Search scope: project names and source file names with Fuse.js + function searchInProjects(projects: Project[], searchTerm: string): Project[] { + if (!searchTerm.trim()) return projects; + + const searchableProjects = createSearchableProjects(projects); + const fuse = new Fuse(searchableProjects, fuseOptions); + const results = fuse.search(searchTerm); + + // Return projects in order of best match + return results.map(result => result.item.project); + } - let owned: Project[] = $derived( + let allOwnedProjects = $derived( Projects.allEditableProjects.filter( (p) => p.getOwner() === $user?.uid || !p.hasOwner(), - ), + ) ); - let shared: Project[] = $derived( + let allSharedProjects = $derived( $user === null ? [] : Projects.allEditableProjects.filter( (p) => p.hasOwner() && p.getOwner() !== $user.uid, - ), + ) + ); + + // Add archived projects to search scope + let allArchivedProjects = $derived( + Projects.allArchivedProjects.filter( + (p) => p.getOwner() === $user?.uid || !p.hasOwner(), + ) + ); + + let owned: Project[] = $derived( + searchInProjects(allOwnedProjects, searchTerm) + ); + + let shared: Project[] = $derived( + searchInProjects(allSharedProjects, searchTerm) + ); + + // Include archived projects in search results + let archived: Project[] = $derived( + searchInProjects(allArchivedProjects, searchTerm) ); + + @@ -59,6 +118,19 @@
l.ui.page.projects.header} /> l.ui.page.projects.projectprompt} /> + + +
+ l.ui.page.projects.search.description} + fill={true} + /> +
+ + { const newProjectID = Projects.copy( @@ -70,37 +142,52 @@ }} /> - l.ui.page.projects.button.editproject, - action: (project) => goto(project.getLink(false)), - label: EDIT_SYMBOL, - }} - copy={{ - description: (l) => l.ui.project.button.duplicate, - action: (project) => - goto(Projects.duplicate(project).getLink(false)), - label: COPY_SYMBOL, - }} - remove={(project) => { - return { - prompt: (l) => l.ui.page.projects.confirm.archive.prompt, - description: (l) => - l.ui.page.projects.confirm.archive.description, - action: () => Projects.archiveProject(project.getID(), true), - label: '🗑️', - }; - }} - anonymize={false} - showCollaborators={true} - /> + {#if searchTerm.trim() && owned.length === 0 && shared.length === 0 && archived.length === 0} + +
+

+ No projects found for "{searchTerm}" +

+

+ Try checking your spelling or using different keywords. Our search is forgiving of typos, but you might want to try a shorter search term. +

+
+
+ {:else} + l.ui.page.projects.button.editproject, + action: (project) => goto(project.getLink(false)), + label: EDIT_SYMBOL, + }} + copy={{ + description: (l) => l.ui.project.button.duplicate, + action: (project) => + goto(Projects.duplicate(project).getLink(false)), + label: COPY_SYMBOL, + }} + remove={(project) => { + return { + prompt: (l) => l.ui.page.projects.confirm.archive.prompt, + description: (l) => + l.ui.page.projects.confirm.archive.description, + action: () => Projects.archiveProject(project.getID(), true), + label: '🗑️', + }; + }} + anonymize={false} + showCollaborators={true} + /> + {/if} {#if shared.length > 0} l.ui.page.projects.subheader.shared} /> l.ui.page.projects.button.editproject, action: (project) => goto(project.getLink(false)), @@ -118,6 +205,43 @@ /> {/if} + + {#if searchTerm.trim() && archived.length > 0} + l.ui.page.projects.subheader.archived} /> + l.ui.page.projects.button.unarchive, + action: (project) => + Projects.archiveProject(project.getID(), false), + label: '↑🗑️', + }} + copy={false} + anonymize={false} + showCollaborators={true} + remove={(project) => + $user && project.getOwner() === $user.uid + ? { + prompt: (l) => + l.ui.page.projects.confirm.delete.prompt, + description: (l) => + l.ui.page.projects.confirm.delete.description, + action: () => { + deleteError = false; + try { + Projects.deleteProject(project.getID()); + } catch (error) { + deleteError = true; + console.error(error); + } + }, + label: CANCEL_SYMBOL, + } + : false} + /> + {/if} + {#if Projects.allArchivedProjects.length > 0} l.ui.page.projects.subheader.archived} /> @@ -195,4 +319,27 @@ .add { margin-left: calc(2 * var(--wordplay-spacing)); } + + .search-container { + margin: var(--wordplay-spacing) 0; + padding: 0 calc(2 * var(--wordplay-spacing)); + } + + .no-results { + text-align: center; + } + + .no-results-message { + font-size: 1.1em; + margin-bottom: var(--wordplay-spacing); + color: var(--wordplay-background); + } + + .no-results-suggestion { + font-size: 0.9em; + line-height: 1.4; + color: var(--wordplay-background); + } + + diff --git a/src/routes/projects/PageText.ts b/src/routes/projects/PageText.ts index b51764d17..595d81f68 100644 --- a/src/routes/projects/PageText.ts +++ b/src/routes/projects/PageText.ts @@ -20,6 +20,11 @@ type PageText = { /** Header for the unarchived project list */ archived: string; }; + /** Search functionality */ + search: { + /** Description for the search field */ + description: string; + }; button: { /** Create a new project */ newproject: string; diff --git a/src/routes/projects/search.test.ts b/src/routes/projects/search.test.ts new file mode 100644 index 000000000..6022dd057 --- /dev/null +++ b/src/routes/projects/search.test.ts @@ -0,0 +1,181 @@ +import Fuse from 'fuse.js'; +import { beforeEach, describe, expect, it } from 'vitest'; + +// Mock project data for testing +const mockProjects = [ + { + project: { getName: () => 'Object Oriented Programming', getSources: () => [] }, + name: 'Object Oriented Programming', + files: [] + }, + { + project: { getName: () => 'Data Structures & Algorithms', getSources: () => [] }, + name: 'Data Structures & Algorithms', + files: [] + }, + { + project: { getName: () => 'Test Project', getSources: () => [] }, + name: 'Test Project', + files: [] + }, + { + project: { + getName: () => 'Main Project', + getSources: () => [ + { getPreferredName: () => 'main.wp' }, + { getPreferredName: () => 'utils.js' } + ] + }, + name: 'Main Project', + files: [ + { name: 'main.wp' }, + { name: 'utils.js' } + ] + }, + // Add archived projects for testing + { + project: { getName: () => 'Archived Math Project', getSources: () => [] }, + name: 'Archived Math Project', + files: [] + }, + { + project: { getName: () => 'Old Science Experiment', getSources: () => [] }, + name: 'Old Science Experiment', + files: [] + } +]; + +describe('Search Functionality', () => { + let fuse: Fuse; + + beforeEach(() => { + const fuseOptions = { + includeScore: true, + threshold: 0.4, + ignoreLocation: true, + keys: ['name', 'files.name'] + }; + fuse = new Fuse(mockProjects, fuseOptions); + }); + + describe('Exact Matches', () => { + it('should find exact project name matches', () => { + const results = fuse.search('Object Oriented Programming'); + expect(results).toHaveLength(1); + expect(results[0].item.name).toBe('Object Oriented Programming'); + }); + + it('should find exact file name matches', () => { + const results = fuse.search('main.wp'); + expect(results).toHaveLength(1); + expect(results[0].item.name).toBe('Main Project'); + }); + }); + + describe('Fuzzy Matches', () => { + it('should find projects with typos', () => { + const results = fuse.search('Objct'); + expect(results).toHaveLength(4); + expect(results[0].item.name).toBe('Object Oriented Programming'); + }); + + it('should find projects with missing characters', () => { + const results = fuse.search('algoritm'); + expect(results).toHaveLength(1); + expect(results[0].item.name).toBe('Data Structures & Algorithms'); + }); + + it('should find projects with extra characters', () => { + const results = fuse.search('Testt'); + expect(results).toHaveLength(1); + expect(results[0].item.name).toBe('Test Project'); + }); + }); + + describe('Partial Matches', () => { + it('should find partial project name matches', () => { + const results = fuse.search('Object'); + expect(results).toHaveLength(4); + expect(results[0].item.name).toBe('Object Oriented Programming'); + }); + + it('should find partial file name matches', () => { + const results = fuse.search('main'); + expect(results).toHaveLength(2); + expect(results[0].item.name).toBe('Main Project'); + }); + }); + + describe('Case Insensitive', () => { + it('should find matches regardless of case', () => { + const results = fuse.search('object'); + expect(results).toHaveLength(4); + expect(results[0].item.name).toBe('Object Oriented Programming'); + }); + + it('should find file matches regardless of case', () => { + const results = fuse.search('MAIN'); + expect(results).toHaveLength(2); + expect(results[0].item.name).toBe('Main Project'); + }); + }); + + describe('Archived Projects', () => { + it('should find archived projects in search results', () => { + const results = fuse.search('Archived'); + expect(results).toHaveLength(1); + expect(results[0].item.name).toBe('Archived Math Project'); + }); + + it('should find archived projects by partial name', () => { + const results = fuse.search('Math'); + expect(results).toHaveLength(1); + expect(results[0].item.name).toBe('Archived Math Project'); + }); + + it('should find archived projects by content keywords', () => { + const results = fuse.search('Science'); + expect(results).toHaveLength(1); + expect(results[0].item.name).toBe('Old Science Experiment'); + }); + }); + + describe('No Matches', () => { + it('should return empty results for non-matching terms', () => { + const results = fuse.search('nonexistent'); + expect(results).toHaveLength(0); + }); + + it('should return empty results for empty search', () => { + const results = fuse.search(''); + expect(results).toHaveLength(0); + }); + }); + + describe('Multiple Matches', () => { + it('should rank results by match quality', () => { + const results = fuse.search('project'); + expect(results).toHaveLength(3); + // Test Project should rank higher than Main Project for "project" + expect(results[0].item.name).toBe('Test Project'); + expect(results[1].item.name).toBe('Main Project'); + expect(results[2].item.name).toBe('Archived Math Project'); + }); + }); + + describe('Threshold Behavior', () => { + it('should respect threshold setting', () => { + // Create a more strict fuse instance + const strictFuse = new Fuse(mockProjects, { + includeScore: true, + threshold: 0.2, // More strict + ignoreLocation: true, + keys: ['name', 'files.name'] + }); + + const results = strictFuse.search('Objct'); + // With stricter threshold, this might not match + expect(results.length).toBeLessThanOrEqual(1); + }); + }); +}); \ No newline at end of file diff --git a/static/schemas/LocaleText.json b/static/schemas/LocaleText.json index a4bc35c20..42ca24a42 100644 --- a/static/schemas/LocaleText.json +++ b/static/schemas/LocaleText.json @@ -11375,6 +11375,20 @@ "description": "Explanation for the project page", "type": "string" }, + "search": { + "additionalProperties": false, + "description": "Search functionality", + "properties": { + "description": { + "description": "Description for the search field", + "type": "string" + } + }, + "required": [ + "description" + ], + "type": "object" + }, "subheader": { "additionalProperties": false, "description": "Buttons on the project page", @@ -11403,6 +11417,7 @@ "galleryprompt", "add", "subheader", + "search", "button", "confirm", "error" diff --git a/tests/end2end/search.spec.ts b/tests/end2end/search.spec.ts new file mode 100644 index 000000000..a42f560ca --- /dev/null +++ b/tests/end2end/search.spec.ts @@ -0,0 +1,156 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Project Search Feature', () => { + test.beforeEach(async ({ page }) => { + // Navigate to projects page + await page.goto('/projects'); + }); + + test('should display search bar', async ({ page }) => { + // Check if search input is visible + const searchInput = page.locator('input[placeholder="Search projects and files"]'); + await expect(searchInput).toBeVisible(); + + // Check if search icon is present + const searchIcon = page.locator('.search-icon'); + await expect(searchIcon).toBeVisible(); + }); + + test('should filter projects in real-time', async ({ page }) => { + const searchInput = page.locator('input[placeholder="Search projects and files"]'); + + // Type a search term + await searchInput.fill('test'); + + // Wait for search to complete + await page.waitForTimeout(500); + + // Check that filtered results are shown + const projectCards = page.locator('.project'); + await expect(projectCards).toHaveCount({ min: 1 }); + }); + + test('should show no results message for non-matching search', async ({ page }) => { + const searchInput = page.locator('input[placeholder="Search projects and files"]'); + + // Type a search term that shouldn't match anything + await searchInput.fill('nonexistentproject123'); + + // Wait for search to complete + await page.waitForTimeout(500); + + // Check that no results message is shown + const noResultsMessage = page.locator('.no-results-message'); + await expect(noResultsMessage).toBeVisible(); + await expect(noResultsMessage).toContainText('No projects found for'); + }); + + test('should highlight matching text', async ({ page }) => { + const searchInput = page.locator('input[placeholder="Search projects and files"]'); + + // Type a search term that should match project names + await searchInput.fill('test'); + + // Wait for search to complete + await page.waitForTimeout(500); + + // Check that highlighted text is present + const highlightedText = page.locator('.search-highlight'); + await expect(highlightedText).toBeVisible(); + }); + + test('should handle fuzzy search with typos', async ({ page }) => { + const searchInput = page.locator('input[placeholder="Search projects and files"]'); + + // Type a search term with a typo + await searchInput.fill('projct'); + + // Wait for search to complete + await page.waitForTimeout(500); + + // Should still find projects with "project" in the name + const projectCards = page.locator('.project'); + await expect(projectCards).toHaveCount({ min: 1 }); + }); + + test('should find archived projects in search results', async ({ page }) => { + const searchInput = page.locator('input[placeholder="Search projects and files"]'); + + // Type a search term that should match archived projects + await searchInput.fill('archived'); + + // Wait for search to complete + await page.waitForTimeout(500); + + // Should find archived projects + const projectCards = page.locator('.project'); + await expect(projectCards).toHaveCount({ min: 1 }); + + // Check that archived projects section is visible + const archivedSection = page.locator('text=Archived'); + await expect(archivedSection).toBeVisible(); + }); + + test('should clear search when input is cleared', async ({ page }) => { + const searchInput = page.locator('input[placeholder="Search projects and files"]'); + + // Type a search term + await searchInput.fill('test'); + await page.waitForTimeout(500); + + // Clear the search + await searchInput.clear(); + await page.waitForTimeout(500); + + // Should show all projects again + const projectCards = page.locator('.project'); + await expect(projectCards).toHaveCount({ min: 1 }); + + // No results message should not be visible + const noResultsMessage = page.locator('.no-results-message'); + await expect(noResultsMessage).not.toBeVisible(); + }); + + test('should maintain search state during navigation', async ({ page }) => { + const searchInput = page.locator('input[placeholder="Search projects and files"]'); + + // Type a search term + await searchInput.fill('test'); + await page.waitForTimeout(500); + + // Navigate away and back + await page.goto('/'); + await page.goto('/projects'); + + // Search term should be preserved + await expect(searchInput).toHaveValue('test'); + }); + + test('should handle special characters in search', async ({ page }) => { + const searchInput = page.locator('input[placeholder="Search projects and files"]'); + + // Test with special characters + const specialSearches = ['test@', 'test#', 'test$', 'test%', 'test&']; + + for (const searchTerm of specialSearches) { + await searchInput.fill(searchTerm); + await page.waitForTimeout(500); + + // Should not crash and should handle gracefully + await expect(page).not.toHaveURL(/error/); + } + }); + + test('should handle very long search terms', async ({ page }) => { + const searchInput = page.locator('input[placeholder="Search projects and files"]'); + + // Type a very long search term + const longSearchTerm = 'a'.repeat(1000); + await searchInput.fill(longSearchTerm); + await page.waitForTimeout(500); + + // Should not crash and should show no results + const noResultsMessage = page.locator('.no-results-message'); + await expect(noResultsMessage).toBeVisible(); + }); +}); \ No newline at end of file