diff --git a/.changeset/fix-content-list-search.md b/.changeset/fix-content-list-search.md new file mode 100644 index 000000000..7c5d73041 --- /dev/null +++ b/.changeset/fix-content-list-search.md @@ -0,0 +1,12 @@ +--- +"emdash": patch +"@emdash-cms/admin": patch +--- + +Make the admin content list search work across the whole collection. Previously the search input filtered `items` already in memory — any entry past the first 100-item API fetch was invisible to search until the user manually paged forward enough to load it. + +`GET /_emdash/api/content/{collection}` now accepts a `q` query parameter — a case-insensitive substring match against whichever of `title`, `name`, `slug` actually exist on the collection's table (introspected via `pragma_table_info` / `information_schema.columns`). LIKE wildcards are escaped; input is trimmed and capped at 200 chars. + +The admin list debounces the input by 300ms and pushes `q` through the infinite-query key so switching searches resets the cursor chain. `ContentPickerModal` migrated to the same server-side search and uses `keepPreviousData` so the dropdown doesn't flash to empty between keystrokes. + +The MCP `content_list` tool also accepts `q`, so agents don't have to post-filter either. diff --git a/packages/admin/src/components/ContentList.tsx b/packages/admin/src/components/ContentList.tsx index b7ee216f6..16f8323d6 100644 --- a/packages/admin/src/components/ContentList.tsx +++ b/packages/admin/src/components/ContentList.tsx @@ -62,6 +62,13 @@ export interface ContentListProps { */ sort?: ContentListSort; onSortChange?: (sort: ContentListSort) => void; + /** + * Controlled search query. When `onSearchChange` is also provided, the + * input becomes parent-controlled and local client-side filtering is + * disabled — the parent is expected to drive filtering via the API. + */ + searchQuery?: string; + onSearchChange?: (query: string) => void; } type ViewTab = "all" | "trash"; @@ -104,35 +111,51 @@ export function ContentList({ urlPattern, sort, onSortChange, + searchQuery: searchQueryProp, + onSearchChange, }: ContentListProps) { const { t } = useLingui(); const [activeTab, setActiveTab] = React.useState("all"); - const [searchQuery, setSearchQuery] = React.useState(""); + const [localSearchQuery, setLocalSearchQuery] = React.useState(""); const [page, setPage] = React.useState(0); - // Reset page when search changes + // Server-driven search kicks in when the parent opts in with onSearchChange. + // In that mode `items` is already the filtered set — we forward the input + // instead of filtering locally. Legacy mode keeps the original client-side + // filter so existing callers (e.g. the picker before it migrated) still + // work. + const serverSideSearch = typeof onSearchChange === "function"; + const searchQuery = serverSideSearch ? (searchQueryProp ?? "") : localSearchQuery; + const handleSearchChange = (e: React.ChangeEvent) => { - setSearchQuery(e.target.value); + const next = e.target.value; + if (serverSideSearch) { + onSearchChange?.(next); + } else { + setLocalSearchQuery(next); + } setPage(0); }; const filteredItems = React.useMemo(() => { - if (!searchQuery) return items; + if (serverSideSearch || !searchQuery) return items; const query = searchQuery.toLowerCase(); return items.filter((item) => getItemTitle(item).toLowerCase().includes(query)); - }, [items, searchQuery]); + }, [items, searchQuery, serverSideSearch]); const totalPages = Math.max(1, Math.ceil(filteredItems.length / PAGE_SIZE)); const paginatedItems = filteredItems.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE); // Auto-fetch next API page when user reaches the last client-side page. - // skip when a search query is active - // filteredItems shrinking would otherwise collapse totalPages to 1 and trigger a spurious fetch + // skip when a *client-side* search is active, because filtering can + // collapse `filteredItems` below the loaded count and trigger a spurious + // fetch. Server-side search has no such concern — the server returns the + // full result set. React.useEffect(() => { - if (page >= totalPages - 1 && hasMore && onLoadMore && !searchQuery) { + if (page >= totalPages - 1 && hasMore && onLoadMore && (serverSideSearch || !searchQuery)) { onLoadMore(); } - }, [page, totalPages, hasMore, onLoadMore, searchQuery]); + }, [page, totalPages, hasMore, onLoadMore, searchQuery, serverSideSearch]); return (
diff --git a/packages/admin/src/components/ContentPickerModal.tsx b/packages/admin/src/components/ContentPickerModal.tsx index 99f6a5406..0731e55fb 100644 --- a/packages/admin/src/components/ContentPickerModal.tsx +++ b/packages/admin/src/components/ContentPickerModal.tsx @@ -8,7 +8,7 @@ import { Button, Dialog, Input, Loader, Select } from "@cloudflare/kumo"; import { useLingui } from "@lingui/react/macro"; import { MagnifyingGlass, FolderOpen, X } from "@phosphor-icons/react"; -import { useQuery } from "@tanstack/react-query"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; import * as React from "react"; import { fetchCollections, fetchContentList, getDraftStatus } from "../lib/api"; @@ -55,13 +55,21 @@ export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPick } }, [collections, selectedCollection]); + // Push search to the server so the picker can find items across the + // entire collection, not just whatever has already been scrolled into + // view. Falls back to no-search when the box is empty. + const searchParam = debouncedSearch.trim() || undefined; const { data: contentResult, isLoading: contentLoading } = useQuery({ - queryKey: ["content-picker", selectedCollection, { limit: 50 }], - queryFn: () => fetchContentList(selectedCollection, { limit: 50 }), + queryKey: ["content-picker", selectedCollection, { limit: 50, q: searchParam }], + queryFn: () => fetchContentList(selectedCollection, { limit: 50, q: searchParam }), enabled: open && !!selectedCollection, + // Keep the previous page's rows visible while the debounced search + // refetches, so the list doesn't flash to empty between keystrokes. + placeholderData: keepPreviousData, }); - // Sync initial page into accumulated items + // Sync initial page into accumulated items. The query re-runs when the + // debounced search changes, so we reset the accumulator each time. React.useEffect(() => { if (contentResult) { setAllItems(contentResult.items); @@ -76,6 +84,7 @@ export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPick const result = await fetchContentList(selectedCollection, { limit: 50, cursor: nextCursor, + q: searchParam, }); setAllItems((prev) => [...prev, ...result.items]); setNextCursor(result.nextCursor); @@ -84,11 +93,8 @@ export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPick } }; - const filteredItems = React.useMemo(() => { - if (!debouncedSearch) return allItems; - const query = debouncedSearch.toLowerCase(); - return allItems.filter((item) => getItemTitle(item).toLowerCase().includes(query)); - }, [allItems, debouncedSearch]); + // Items arrive pre-filtered from the server; alias for readability. + const filteredItems = allItems; // Reset state when modal opens or collection changes React.useEffect(() => { diff --git a/packages/admin/src/lib/api/content.ts b/packages/admin/src/lib/api/content.ts index 7d3fe3451..6c8506d3d 100644 --- a/packages/admin/src/lib/api/content.ts +++ b/packages/admin/src/lib/api/content.ts @@ -143,6 +143,8 @@ export async function fetchContentList( orderBy?: string; /** Sort direction; defaults to "desc" on the server. */ order?: "asc" | "desc"; + /** Case-insensitive substring search across title, name, and slug. */ + q?: string; }, ): Promise> { const params = new URLSearchParams(); @@ -152,6 +154,7 @@ export async function fetchContentList( if (options?.locale) params.set("locale", options.locale); if (options?.orderBy) params.set("orderBy", options.orderBy); if (options?.order) params.set("order", options.order); + if (options?.q) params.set("q", options.q); const url = `${API_BASE}/content/${collection}${params.toString() ? `?${params}` : ""}`; const response = await apiFetch(url); diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index 810cfb897..8777ced1c 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -113,6 +113,7 @@ import { bulkCommentAction, type CommentStatus, } from "./lib/api/comments"; +import { useDebouncedValue } from "./lib/hooks"; import { usePluginPage } from "./lib/plugin-context"; import { getPluginBlocks } from "./lib/pluginBlocks"; import { sanitizeRedirectUrl } from "./lib/url"; @@ -307,9 +308,14 @@ function ContentListPage() { direction: "desc", }); + // Keep the raw search local for instant feedback; debounce before firing + // the API call so typing doesn't stampede the server. + const [searchQuery, setSearchQuery] = React.useState(""); + const debouncedSearch = useDebouncedValue(searchQuery.trim(), 300); + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, error } = useInfiniteQuery({ - queryKey: ["content", collection, { locale: activeLocale, sort }], + queryKey: ["content", collection, { locale: activeLocale, sort, q: debouncedSearch }], queryFn: ({ pageParam }) => fetchContentList(collection, { locale: activeLocale, @@ -317,6 +323,7 @@ function ContentListPage() { limit: 100, orderBy: sort.field, order: sort.direction, + q: debouncedSearch || undefined, }), initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.nextCursor, @@ -435,6 +442,8 @@ function ContentListPage() { urlPattern={collectionConfig.urlPattern} sort={sort} onSortChange={setSort} + searchQuery={searchQuery} + onSearchChange={setSearchQuery} /> ); } diff --git a/packages/admin/tests/components/ContentList.test.tsx b/packages/admin/tests/components/ContentList.test.tsx index abb9bb85e..2db7a8b71 100644 --- a/packages/admin/tests/components/ContentList.test.tsx +++ b/packages/admin/tests/components/ContentList.test.tsx @@ -519,4 +519,30 @@ describe("ContentList", () => { expect(screen.getByRole("button", { name: "Title" }).query()).toBeNull(); }); }); + + describe("controlled search", () => { + it("forwards the typed query to onSearchChange without local filtering", async () => { + const onSearchChange = vi.fn(); + const items = [ + makeItem({ id: "1", data: { title: "Alpha" } }), + makeItem({ id: "2", data: { title: "Beta" } }), + ]; + const screen = await render( + , + ); + + await screen.getByRole("searchbox").fill("beta"); + + expect(onSearchChange).toHaveBeenCalledWith("beta"); + // In controlled mode the component must NOT filter locally — the + // parent is responsible for pushing the next `items` prop. + await expect.element(screen.getByText("Alpha")).toBeInTheDocument(); + await expect.element(screen.getByText("Beta")).toBeInTheDocument(); + }); + }); }); diff --git a/packages/core/src/api/handlers/content.ts b/packages/core/src/api/handlers/content.ts index d7d5b5799..f8cccc58e 100644 --- a/packages/core/src/api/handlers/content.ts +++ b/packages/core/src/api/handlers/content.ts @@ -256,13 +256,18 @@ export async function handleContentList( orderBy?: string; order?: "asc" | "desc"; locale?: string; + /** Case-insensitive substring search over title/name/slug */ + q?: string; }, ): Promise> { try { const repo = new ContentRepository(db); - const where: { status?: string; locale?: string } = {}; + const where: { status?: string; locale?: string; search?: string } = {}; if (params.status) where.status = params.status; if (params.locale) where.locale = params.locale; + if (typeof params.q === "string" && params.q.trim().length > 0) { + where.search = params.q; + } const result = await repo.findMany(collection, { cursor: params.cursor, diff --git a/packages/core/src/api/schemas/content.ts b/packages/core/src/api/schemas/content.ts index ffaa26acc..bcadfc915 100644 --- a/packages/core/src/api/schemas/content.ts +++ b/packages/core/src/api/schemas/content.ts @@ -24,6 +24,8 @@ export const contentListQuery = cursorPaginationQuery orderBy: z.string().optional(), order: z.enum(["asc", "desc"]).optional(), locale: localeCode.optional(), + /** Case-insensitive substring search across title, name, and slug. */ + q: z.string().trim().max(200).optional(), }) .meta({ id: "ContentListQuery" }); diff --git a/packages/core/src/astro/types.ts b/packages/core/src/astro/types.ts index 16dd2c7df..523541c59 100644 --- a/packages/core/src/astro/types.ts +++ b/packages/core/src/astro/types.ts @@ -227,6 +227,7 @@ export interface EmDashHandlers { orderBy?: string; order?: "asc" | "desc"; locale?: string; + q?: string; }, ) => Promise; diff --git a/packages/core/src/database/dialect-helpers.ts b/packages/core/src/database/dialect-helpers.ts index 3f3f73acb..858c0ef64 100644 --- a/packages/core/src/database/dialect-helpers.ts +++ b/packages/core/src/database/dialect-helpers.ts @@ -160,6 +160,30 @@ export async function listTablesLike(db: Kysely, pattern: string): Promise< return result.rows.map((r) => r.name); } +/** + * List column names for a table. Returns an empty array if the table + * does not exist. Used by callers that need to build SQL fragments + * referencing columns whose presence varies between collections. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- accepts any Kysely instance +export async function listTableColumns(db: Kysely, tableName: string): Promise { + if (isPostgres(db)) { + const result = await sql<{ column_name: string }>` + SELECT column_name FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = ${tableName} + `.execute(db); + return result.rows.map((r) => r.column_name); + } + + // SQLite 3.16+ exposes PRAGMAs as table-valued functions that accept + // bound parameters. Guard the identifier defensively anyway. + validateIdentifier(tableName, "table name"); + const result = await sql<{ name: string }>` + SELECT name FROM pragma_table_info(${tableName}) + `.execute(db); + return result.rows.map((r) => r.name); +} + /** * Column type for binary data. * diff --git a/packages/core/src/database/repositories/content.ts b/packages/core/src/database/repositories/content.ts index 4353426d3..fa1329a49 100644 --- a/packages/core/src/database/repositories/content.ts +++ b/packages/core/src/database/repositories/content.ts @@ -1,7 +1,8 @@ -import { sql, type Kysely } from "kysely"; +import { sql, type Kysely, type RawBuilder, type SqlBool } from "kysely"; import { ulid } from "ulidx"; import { slugify } from "../../utils/slugify.js"; +import { listTableColumns } from "../dialect-helpers.js"; import type { Database } from "../types.js"; import { validateIdentifier } from "../validate.js"; import { RevisionRepository } from "./revision.js"; @@ -92,6 +93,37 @@ function escapeRegExp(s: string): string { return s.replace(REGEX_ESCAPE_PATTERN, "\\$&"); } +/** + * Escape LIKE wildcard characters so user-supplied search input can't + * accidentally act as a wildcard. Paired with `ESCAPE '\\'` at the SQL + * call site so the backslash itself works as the escape character. + */ +function escapeLike(value: string): string { + return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_"); +} + +/** + * Hard cap on the search input length; anything longer is almost + * certainly not what a human typed and would just waste a SELECT. + */ +const SEARCH_MAX_LENGTH = 200; + +/** + * Columns that content search targets, in priority order. A column only + * participates if it actually exists on the collection's table — every + * collection has `slug`, but `title` and `name` are user-defined fields. + */ +const SEARCHABLE_COLUMNS = ["title", "name", "slug"] as const; + +/** + * A prepared search filter. Computed once per query so that any callers + * who want to reuse the same predicate across, say, a list fetch and a + * parallel count can share the same normalized pattern + column list. + */ +interface SearchSpec { + toSql: () => RawBuilder; +} + /** * Repository for content CRUD operations * @@ -469,6 +501,10 @@ export class ContentRepository { // Validate order direction to prevent injection const safeOrderDirection = orderDirection.toLowerCase() === "asc" ? "ASC" : "DESC"; + // Resolve the search pattern + the columns it targets once, before + // building the filter. + const searchSpec = await this.resolveSearchSpec(tableName, options.where?.search); + // Build query with parameterized values (no string interpolation) // Note: Dynamic content tables have deleted_at column, cast needed for Kysely let query = this.db @@ -489,6 +525,10 @@ export class ContentRepository { query = query.where("locale" as any, "=", options.where.locale); } + if (searchSpec) { + query = query.where(searchSpec.toSql()); + } + // Handle cursor pagination — decodeCursor throws InvalidCursorError // on malformed input; let it propagate so handlers surface a // structured INVALID_CURSOR rather than silently returning page 1. @@ -754,9 +794,10 @@ export class ContentRepository { */ async count( type: string, - where?: { status?: string; authorId?: string; locale?: string }, + where?: { status?: string; authorId?: string; locale?: string; search?: string }, ): Promise { const tableName = getTableName(type); + const searchSpec = await this.resolveSearchSpec(tableName, where?.search); let query = this.db .selectFrom(tableName as keyof Database) @@ -775,10 +816,51 @@ export class ContentRepository { query = query.where("locale" as any, "=", where.locale); } + if (searchSpec) { + query = query.where(searchSpec.toSql()); + } + const result = await query.executeTakeFirst(); return Number(result?.count || 0); } + /** + * Normalize and introspect the search input. Returns `null` when there's + * nothing to search for — either the caller didn't pass one, the input + * was empty after trimming, or none of the searchable columns exist on + * this collection's table. + */ + private async resolveSearchSpec( + tableName: string, + rawSearch: string | undefined, + ): Promise { + if (typeof rawSearch !== "string") return null; + const trimmed = rawSearch.trim(); + if (!trimmed) return null; + + // Clamp the input; a 200-char search is already well past anything + // a human would type and stops pathological inputs from reaching SQL. + const bounded = trimmed.slice(0, SEARCH_MAX_LENGTH); + const pattern = `%${escapeLike(bounded.toLowerCase())}%`; + + const columns = new Set(await listTableColumns(this.db, tableName)); + const searchable = SEARCHABLE_COLUMNS.filter((col) => columns.has(col)); + if (searchable.length === 0) return null; + + return { + toSql: () => { + // LOWER() on both sides keeps matching case-insensitive on both + // SQLite (where LIKE is already case-insensitive for ASCII) and + // Postgres (where it isn't). + const fragments = searchable.map( + (col) => sql`LOWER(${sql.ref(col)}) LIKE ${pattern} ESCAPE '\\'`, + ); + if (fragments.length === 1) return fragments[0]; + return sql`(${sql.join(fragments, sql` OR `)})`; + }, + }; + } + // get overall statistics (total, published, draft) for a content type in a single query async getStats(type: string): Promise<{ total: number; published: number; draft: number }> { const tableName = getTableName(type); diff --git a/packages/core/src/database/repositories/types.ts b/packages/core/src/database/repositories/types.ts index 2e2016604..bc894b4da 100644 --- a/packages/core/src/database/repositories/types.ts +++ b/packages/core/src/database/repositories/types.ts @@ -78,6 +78,11 @@ export interface FindManyOptions { status?: string; authorId?: string; locale?: string; + /** + * Case-insensitive substring match against searchable text columns + * (currently title, name, slug). Trimmed; empty strings are ignored. + */ + search?: string; }; orderBy?: { field: string; diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index dc4ace5c9..cf6bb22ab 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -1649,6 +1649,7 @@ export class EmDashRuntime { orderBy?: string; order?: "asc" | "desc"; locale?: string; + q?: string; }, ) { return handleContentList(this.db, collection, params); diff --git a/packages/core/src/mcp/server.ts b/packages/core/src/mcp/server.ts index 2f9bf84ff..5643789a5 100644 --- a/packages/core/src/mcp/server.ts +++ b/packages/core/src/mcp/server.ts @@ -460,6 +460,12 @@ export function createMcpServer(): McpServer { .string() .optional() .describe("Filter by locale (e.g. 'en', 'fr'). Only relevant when i18n is enabled."), + q: z + .string() + .trim() + .max(200) + .optional() + .describe("Case-insensitive substring search across title, name, and slug."), }), annotations: { readOnlyHint: true }, }, @@ -477,6 +483,7 @@ export function createMcpServer(): McpServer { orderBy: args.orderBy, order: args.order, locale: args.locale, + q: args.q, }), ); }, diff --git a/packages/core/tests/database/repositories/content.test.ts b/packages/core/tests/database/repositories/content.test.ts index 50d52dcab..2b6c4c703 100644 --- a/packages/core/tests/database/repositories/content.test.ts +++ b/packages/core/tests/database/repositories/content.test.ts @@ -456,6 +456,73 @@ describe("ContentRepository", () => { ).rejects.toThrow(EmDashValidationError); }); }); + + describe("search", () => { + // Regression guard for the admin "search doesn't find existing + // content" bug: clients used to filter client-side against whatever + // pages had already been fetched, so results past the first page + // were invisible. The repo now has to honor search server-side + // across the whole collection. + it("finds items by a title substring match", async () => { + await repo.create({ type: "page", slug: "bleeding", data: { title: "Bleeding" } }); + await repo.create({ type: "page", slug: "unrelated", data: { title: "Unrelated" } }); + + const result = await repo.findMany("page", { where: { search: "leed" } }); + + expect(result.items).toHaveLength(1); + expect(result.items[0]!.slug).toBe("bleeding"); + }); + + it("is case-insensitive", async () => { + await repo.create({ type: "page", slug: "mixed-case", data: { title: "MiXeD CaSe" } }); + + const result = await repo.findMany("page", { where: { search: "mixed case" } }); + + expect(result.items.map((i) => i.slug)).toEqual(["mixed-case"]); + }); + + it("matches against slug when no title column exists on the collection", async () => { + await registry.createCollection({ + slug: "bare", + label: "Bare", + labelSingular: "Bare", + }); + await repo.create({ type: "bare", slug: "target-slug", data: {} }); + await repo.create({ type: "bare", slug: "other-slug", data: {} }); + + const result = await repo.findMany("bare", { where: { search: "target" } }); + + expect(result.items.map((i) => i.slug)).toEqual(["target-slug"]); + }); + + it("escapes LIKE wildcards so they don't act as patterns", async () => { + await repo.create({ type: "page", slug: "normal", data: { title: "Normal" } }); + await repo.create({ type: "page", slug: "literal", data: { title: "Has % in it" } }); + + // A plain `%` in a LIKE pattern would match everything; the + // repo must escape it and only return the row that literally + // contains the character. + const result = await repo.findMany("page", { where: { search: "%" } }); + + expect(result.items.map((i) => i.slug)).toEqual(["literal"]); + }); + + it("ignores empty / whitespace search input", async () => { + const result = await repo.findMany("page", { where: { search: " " } }); + + expect(result.items).toHaveLength(0); // no pages created in this test + }); + + it("handles pathological search input without throwing", async () => { + await repo.create({ type: "page", slug: "shorty", data: { title: "shorty" } }); + + // Input well past the bound should be truncated before reaching + // SQL. We only care that the query succeeds. + const massive = "z".repeat(5000); + + await expect(repo.findMany("page", { where: { search: massive } })).resolves.toBeDefined(); + }); + }); }); describe("update", () => { diff --git a/packages/core/tests/unit/api/content-handlers.test.ts b/packages/core/tests/unit/api/content-handlers.test.ts index 983d03bc8..a9d742039 100644 --- a/packages/core/tests/unit/api/content-handlers.test.ts +++ b/packages/core/tests/unit/api/content-handlers.test.ts @@ -305,3 +305,38 @@ describe("Content Handlers — auto-slug generation", () => { }); }); }); + +describe("Content Handlers — list search", () => { + let db: Kysely; + + beforeEach(async () => { + db = await setupTestDatabaseWithCollections(); + for (let i = 0; i < 8; i++) { + const result = await handleContentCreate(db, "post", { + data: { title: `Post ${i}` }, + }); + if (!result.success) throw new Error("seed failed"); + } + await handleContentCreate(db, "post", { data: { title: "Bleeding" } }); + }); + + afterEach(async () => { + await teardownTestDatabase(db); + }); + + // Regression guard — before this change the admin had to fetch every + // page to find "Bleeding" because `q` wasn't sent to the server. + it("filters items by q (case-insensitive substring)", async () => { + const result = await handleContentList(db, "post", { q: "leed" }); + + expect(result.success).toBe(true); + expect(result.data?.items.map((i) => i.data.title)).toEqual(["Bleeding"]); + }); + + it("ignores an all-whitespace q", async () => { + const result = await handleContentList(db, "post", { q: " " }); + + expect(result.success).toBe(true); + expect(result.data?.items).toHaveLength(9); + }); +});