diff --git a/.github/workflows/search.yml b/.github/workflows/search.yml new file mode 100644 index 0000000..9a65e6a --- /dev/null +++ b/.github/workflows/search.yml @@ -0,0 +1,52 @@ +name: Docs search + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: search-${{ github.ref }} + cancel-in-progress: true + +jobs: + search: + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + NEXT_PUBLIC_POSTHOG_KEY: phc_search_test + NEXT_PUBLIC_POSTHOG_HOST: https://us.i.posthog.com + steps: + - uses: actions/checkout@v5 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.13 + - run: bun install --frozen-lockfile + - run: bun run test:search + - run: bun run build + - run: bunx --no-install playwright install --with-deps chromium + - name: Verify production search + run: | + bunx --no-install next start -p 3001 > /tmp/search-server.log 2>&1 & + server_pid=$! + trap 'kill "$server_pid"' EXIT + for attempt in $(seq 1 30); do + if curl -fsS http://localhost:3001/docs/api/search?query=ready > /dev/null; then + break + fi + sleep 1 + done + bun run validate-search + SEARCH_ANALYTICS_TEST=1 bun run test:search:browser + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: search-failures + path: | + test-results/ + /tmp/search-server.log diff --git a/.gitignore b/.gitignore index 79c4ea7..ac19cad 100644 --- a/.gitignore +++ b/.gitignore @@ -46,5 +46,7 @@ content/docs/reference/stack/api-reference/* # serena (local MCP tooling state) .serena/ +playwright-report/ +test-results/ # EQL release manifest extracted by generate-eql-docs.ts (see generate-eql-api-docs.ts) .eql-manifest.release.json diff --git a/IA.md b/IA.md index c1e63a6..03f107d 100644 --- a/IA.md +++ b/IA.md @@ -438,6 +438,8 @@ and the old `/compare/*` paths redirect there (`v2-redirects.mjs`). - [x] Root catch-all routes (`src/app/[...slug]`), llms.mdx mirror, sitemap/llms.txt include v2 - [x] `v2-redirects.mjs` (unconditional) + `validate-redirects` gate in prebuild - [x] `/quickstart` vanity redirect +- [x] Identifier-aware search, weighted code/heading results, page-type filters, + keyboard navigation and search/zero-result analytics (CIP-3350). - [ ] OG images for v2 pages (route only covers legacy tree) - [ ] Correctness CI: snippet type-checking, SQL-vs-EQL-Docker, terminology lint (CIP-3337) - [ ] llms.txt curation + Cloudflare AI crawl policy + md-degradation check (CIP-3339) diff --git a/README.md b/README.md index 2ad6801..8e1b34b 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,65 @@ Open [http://localhost:3001/docs](http://localhost:3001/docs) to view the docs. - `bun run lint` — Run Biome lint check - `bun run format` — Run Biome format +## Search (CIP-3350) + +Search uses Orama 3.1.18 with a custom tokenizer and the current Fumadocs MDX +collection. Standalone code fences are indexed alongside their rendered heading +anchors; navigation cards are excluded. Dots and underscores normalize to the +same namespace separator, identifier suffixes and camelCase parts are searchable, +and prefix matches precede one-character typo fallback. SQL `text_eq` stays +distinct from SDK `TextEq`. + +Titles and headings carry more weight than fenced code, followed by prose. +Results show each page once, at its best matching anchor. The first three remain +in relevance order; additional results group by documentation section. The page +type filter uses frontmatter (`reference`, `guide`, `concept`, `tutorial`). +The legacy `/stack` collection was retired in #116 and must remain excluded. + +The index is built once per server instance from the deployed MDX exports. +There is no separately persisted search index or crawler refresh. Responses +are not cached and include `X-Search-Revision` (content digest) and +`X-Search-Pages` for deployment verification. + +The dialog sends `docs_search` after a query settles for 700 ms (or immediately +on selection), `docs_search_zero_results` for successful empty searches, and +`docs_search_result_selected` on navigation. Properties include `query`, +`page_type`, the displayed `result_count`, and `search_revision`; selections +include `result_url` and position. Repeated query/filter combinations are +deduplicated within one dialog session. Failed and cancelled requests do not +count as content gaps. PostHog must be configured as in `.env.example`. + +Validate against a running production build or a PR preview: + +```bash +bun run test:search +bun run build +bunx --no-install next start -p 3001 +# In another terminal: +bun run validate-search +bunx --no-install playwright install chromium +bun run test:search:browser +# Or use the preview URL, including /docs: +bun run validate-search https://PREVIEW_HOST/docs +SEARCH_BASE_URL=https://PREVIEW_HOST/docs bun run test:search:browser +``` + +To include analytics assertions locally, build with +`NEXT_PUBLIC_POSTHOG_KEY=phc_search_test` and +`NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com`, then run browser tests with +`SEARCH_ANALYTICS_TEST=1`. Browser tests intercept all PostHog ingest requests +and simulate a normal browser for capture; no test events reach PostHog. +The Docs search CI workflow runs these checks against a full production build. + +**Engine decision:** retain Orama. The installed engine supports the tokenizer, +field weights, filters and typo fallback we need, keeps preview indexes tied to +their deployments, and avoids sending the full index to every browser. +[Algolia DocSearch](https://docsearch.algolia.com/docs/who-can-apply/) remains a +fallback if relevance or operational needs outgrow this implementation. Its +application requires an Algolia account and domain-owner verification; no +application is submitted or crawler configured by this change. AI answers remain +out of scope. + ## Contributing We welcome contributions. Open an issue with the `documentation` tag or submit a pull request. @@ -28,4 +87,4 @@ See [SECURITY.md](SECURITY.md) for security policies and reporting process. ## License -See [LICENSE](LICENSE.md) for license information. \ No newline at end of file +See [LICENSE](LICENSE.md) for license information. diff --git a/bun.lock b/bun.lock index 7af7f31..7d41a6c 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "@cipherstash/docs", "dependencies": { + "@orama/orama": "3.1.18", "fumadocs-core": "16.6.0", "fumadocs-mdx": "14.2.7", "fumadocs-ui": "16.6.0", @@ -20,6 +21,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.3.14", + "@playwright/test": "^1.64.1", "@tailwindcss/postcss": "^4.1.18", "@types/jsdom": "^28.0.3", "@types/mdx": "^2.0.13", @@ -268,6 +270,8 @@ "@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="], + "@playwright/test": ["@playwright/test@1.64.1", "", { "dependencies": { "playwright": "1.64.1" }, "bin": { "playwright": "cli.js" } }, "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg=="], + "@posthog/core": ["@posthog/core@1.23.1", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-GViD5mOv/mcbZcyzz3z9CS0R79JzxVaqEz4sP5Dsea178M/j3ZWe6gaHDZB9yuyGfcmIMQ/8K14yv+7QrK4sQQ=="], "@posthog/types": ["@posthog/types@1.354.0", "", {}, "sha512-sfH1PiThX1YWkrZSls6zMuZcJWnvboCnZEJ3Z/OI8WgBmLDJfQpficbuLM3tgSLIchI22TPAkpwdT987iW6XIA=="], @@ -974,6 +978,10 @@ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "playwright": ["playwright@1.64.1", "", { "dependencies": { "playwright-core": "1.64.1" }, "bin": { "playwright": "cli.js" } }, "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA=="], + + "playwright-core": ["playwright-core@1.64.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ=="], + "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], "points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="], diff --git a/package.json b/package.json index 28258d6..2e40cf6 100644 --- a/package.json +++ b/package.json @@ -22,10 +22,14 @@ "check-eql-pin": "tsx scripts/check-eql-pin.ts", "validate-links": "tsx scripts/validate-links.ts", "validate-redirects": "tsx scripts/validate-v2-redirects.ts", + "test:search": "tsx --test scripts/search.test.ts", + "test:search:browser": "playwright test", + "validate-search": "tsx scripts/validate-search.ts", "validate-content": "tsx scripts/validate-content-api.ts", "validate-mermaid": "tsx scripts/validate-mermaid.ts" }, "dependencies": { + "@orama/orama": "3.1.18", "fumadocs-core": "16.6.0", "fumadocs-mdx": "14.2.7", "fumadocs-ui": "16.6.0", @@ -41,6 +45,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.3.14", + "@playwright/test": "^1.64.1", "@tailwindcss/postcss": "^4.1.18", "@types/jsdom": "^28.0.3", "@types/mdx": "^2.0.13", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..5d1e571 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,14 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests", + testMatch: "search.spec.ts", + workers: 1, + timeout: 60_000, + use: { + baseURL: process.env.SEARCH_BASE_URL ?? "http://localhost:3001/docs", + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], +}); diff --git a/scripts/search.test.ts b/scripts/search.test.ts new file mode 100644 index 0000000..a0caa9a --- /dev/null +++ b/scripts/search.test.ts @@ -0,0 +1,148 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createDocsSearch, type SearchPage } from "../src/lib/search/engine"; +import { groupResults } from "../src/lib/search/shared"; +import { tokenize } from "../src/lib/search/tokenizer"; + +function page( + url: string, + heading: string, + contents: string[], + type: SearchPage["pageType"] = "reference", +): SearchPage { + return { + url, + title: url.split("/").at(-1) ?? url, + pageType: type, + structuredData: { + headings: [{ id: "anchor", content: heading }], + contents: contents.map((content) => ({ heading: "anchor", content })), + }, + }; +} + +test("identifier normalization preserves qualified queries and indexes suffixes", () => { + const indexed = tokenize( + "public.eql_v3_text_eq encryptedSupabaseV3 CS_CLIENT_KEY", + undefined, + "code", + ); + for (const query of [ + "text_eq", + "eql_v3.text", + "encryptedSupabase", + "supabase", + "CS_CLIENT_KEY", + "client_key", + ]) { + const [token] = tokenize(query); + assert( + indexed.some((term) => term.startsWith(token)), + query, + ); + } + assert.deepEqual(tokenize("LIKE"), ["like"]); + assert.deepEqual(tokenize("text_eq"), ["text_eq"]); + assert.deepEqual(tokenize("eql_v3.text_eq"), ["eql_v3_text_eq"]); +}); + +test("headings rank over fences, then prose; distinct pages retain matching anchors", async () => { + const search = await createDocsSearch([ + page("/reference/prose", "Overview", ["magic_symbol"]), + page("/reference/code", "Overview", ["```ts\nmagic_symbol()\n```"]), + page("/reference/heading", "magic_symbol", ["Usage"]), + ]); + const results = await search.query("magic_symbol"); + assert.deepEqual( + results.map((r) => r.url), + [ + "/reference/heading#anchor", + "/reference/code#anchor", + "/reference/prose#anchor", + ], + ); +}); + +test("partial identifiers, camelCase, typo fallback, facets and zero results", async () => { + const search = await createDocsSearch([ + page("/reference/eql/text", "Types", [ + "```sql\npublic.eql_v3_text_eq\neql_v3.ord_term_ore(col)\n```", + ]), + page( + "/integrations/supabase/auth", + "Supabase Auth", + ["```ts\nencryptedSupabase()\n```"], + "guide", + ), + page("/stack/legacy", "RetiredOnly", ["old docs"]), + ]); + for (const query of ["text_eq", "eql_v3.text", "ord_term", "ord_term_ore"]) { + assert.equal( + (await search.query(query))[0]?.url, + "/reference/eql/text#anchor", + ); + } + for (const query of [ + "encryptedSupabase", + "encryptedsupabase", + "encryptedSupabse", + "supabase auth", + ]) { + assert.equal( + (await search.query(query))[0]?.url, + "/integrations/supabase/auth#anchor", + ); + } + assert.equal( + (await search.query("encryptedSupabase", "reference")).length, + 0, + ); + assert.equal((await search.query("encryptedSupabase", "guide")).length, 1); + for (const query of [ + "", + " ", + "!!!", + "zzzzunfindable", + "RetiredOnly", + "supabase zzzzunfindable", + ]) { + assert.deepEqual(await search.query(query), [], query); + } +}); + +test("all query terms must match across fields and a deploy changes the revision", async () => { + const old = page("/integrations/supabase", "Authentication", ["original"]); + const search = await createDocsSearch([old]); + assert.equal((await search.query("supabase auth")).length, 1); + assert.equal((await search.query("supabase zzzmissing")).length, 0); + const updated = await createDocsSearch([ + page(old.url, "Authentication", ["newdeploysymbol"]), + ]); + assert.notEqual(search.revision, updated.revision); + assert.equal((await search.query("newdeploysymbol")).length, 0); + assert.equal((await updated.query("newdeploysymbol")).length, 1); +}); + +test("grouping keeps the top three in relevance order", async () => { + const search = await createDocsSearch( + Array.from({ length: 6 }, (_, i) => + page(`/reference/${i}`, "sharedsymbol", []), + ), + ); + const results = await search.query("sharedsymbol"); + const groups = groupResults(results); + assert.deepEqual(groups[0].items, results.slice(0, 3)); + assert.equal(groups.flatMap((group) => group.items).length, results.length); +}); + +test("code excerpts preserve SQL operators and escape sequences", async () => { + const search = await createDocsSearch([ + page("/reference/example", "Example", [ + "```sql\nSELECT * FROM users WHERE id = $1;\n```", + ]), + ]); + assert.match( + (await search.query("SELECT"))[0].excerpt, + /SELECT \* FROM users/, + ); +}); diff --git a/scripts/validate-search.ts b/scripts/validate-search.ts new file mode 100644 index 0000000..c9844e2 --- /dev/null +++ b/scripts/validate-search.ts @@ -0,0 +1,120 @@ +#!/usr/bin/env tsx +import assert from "node:assert/strict"; +import type { DocsSearchResult } from "../src/lib/search/shared"; + +// Run against a local production build OR the PR's deployment. These are page +// expectations, not pinned scores: content can grow without freezing ranking. +const base = (process.argv[2] ?? "http://localhost:3001/docs").replace( + /\/$/, + "", +); +const cases: [string, string[]][] = [ + ["text_eq", ["/reference/eql/text"]], + [ + "ord_term", + [ + "/reference/eql/indexes", + "/reference/eql/functions", + "/reference/eql/sorting", + ], + ], + ["LIKE", ["/reference/eql/text"]], + ["hipaa", ["/security/compliance"]], + ["backfill", ["/guides/migration", "/reference/cli/encrypt"]], + ["supabase auth", ["/integrations/supabase/auth"]], + ["eql_v3.text_eq", ["/reference/eql/text"]], + ["eql_v3.text", ["/reference/eql/text"]], + [ + "ord_ore", + [ + "/reference/eql/core-concepts", + "/reference/eql/text", + "/reference/eql/functions", + ], + ], + [ + "encryptedSupabase", + ["/integrations/supabase", "/integrations/supabase/api-reference"], + ], + [ + "CS_CLIENT_KEY", + [ + "/reference/auth/clients", + "/reference/glossary", + "/reference/workspace/configuration", + "/reference/stack/usage", + "/reference/stack/configuration", + ], + ], + ["unique index", ["/reference/eql/indexes"]], +]; +async function main() { + const revisions = new Set(); + const anchors = new Map(); + let failures = 0; + for (const [query, expected] of cases) { + const response = await fetch( + `${base}/api/search?query=${encodeURIComponent(query)}`, + ); + assert.equal(response.status, 200, query); + const revision = response.headers.get("X-Search-Revision"); + assert(revision, "Missing deployment revision"); + revisions.add(revision); + const results: DocsSearchResult[] = await response.json(); + assert(results.every((r) => !r.url.startsWith("/stack"))); + const top = results.slice(0, 3); + const pass = top.some((r) => expected.includes(r.url.split("#")[0])); + if (!pass) failures++; + console.log( + `${pass ? "✓" : "✗"} ${query}: ${top.map((r) => r.url).join(", ")}`, + ); + for (const result of top) { + const [path, anchor] = result.url.split("#"); + if (!anchor) continue; + let html = anchors.get(path); + if (!html) { + const page = await fetch(`${base}${path === "/" ? "" : path}`); + assert.equal(page.status, 200, path); + html = await page.text(); + anchors.set(path, html); + } + assert(html.includes(`id="${anchor}"`), `Missing anchor: ${result.url}`); + } + } + assert.equal( + revisions.size, + 1, + "Search revision changed during verification", + ); + for (const type of ["reference", "guide", "concept", "tutorial"]) { + const response = await fetch( + `${base}/api/search?query=encryption&type=${type}`, + ); + assert.equal(response.status, 200); + const results: DocsSearchResult[] = await response.json(); + assert(results.length, `No results for ${type} filter`); + assert( + results.every((r) => r.pageType === type), + `Leaked page type: ${type}`, + ); + } + const zero = await fetch(`${base}/api/search?query=zzzzunfindablesymbol`); + assert.deepEqual(await zero.json(), []); + assert.equal( + (await fetch(`${base}/api/search?query=test&type=invalid`)).status, + 400, + ); + assert.equal( + (await fetch(`${base}/api/search?query=${"a".repeat(161)}`)).status, + 400, + ); + assert.equal(failures, 0, `${failures} relevance checks failed`); + console.log( + `✓ Ranking, anchors, filters, retired URLs, empty results and query validation; revision ${[...revisions][0]}`, + ); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/source.config.ts b/source.config.ts index 428f3df..b08c35d 100644 --- a/source.config.ts +++ b/source.config.ts @@ -1,5 +1,8 @@ import type { ShikiTransformer } from "@shikijs/types"; -import { rehypeCodeDefaultOptions } from "fumadocs-core/mdx-plugins"; +import { + rehypeCodeDefaultOptions, + remarkStructureDefaultOptions, +} from "fumadocs-core/mdx-plugins"; import { metaSchema, pageSchema } from "fumadocs-core/source/schema"; import { defineConfig, defineDocs } from "fumadocs-mdx/config"; import lastModifiedPlugin from "fumadocs-mdx/plugins/last-modified"; @@ -150,6 +153,26 @@ export default defineConfig({ lastModifiedPlugin({ filter: (collection) => collection === "v2docs" }), ], mdxOptions: { + // Standalone fences are excluded by default. Search needs the actual API + // symbols in examples, with the same heading IDs as the rendered page. + remarkStructureOptions: { + types: [...remarkStructureDefaultOptions.types, "code"], + stringify: { + filterElement(node) { + if ( + node.type !== "mdxJsxFlowElement" && + node.type !== "mdxJsxTextElement" + ) + return true; + // Navigation cards describe other pages. Indexing their attributes + // lets hub pages crowd out the actual answer (e.g. HIPAA). + if (node.name === "Card" || node.name === "Cards") return false; + return ["File", "TypeTable", "Callout"].includes(node.name ?? "") + ? true + : "children-only"; + }, + }, + }, rehypeCodeOptions: { // Preserve Fumadocs' default Shiki config (dual-theme mode, // parseMetaString) and its default transformers (notation highlight, diff, diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index c6ad2be..1526b5a 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -1,8 +1,45 @@ -import { createFromSource } from "fumadocs-core/search/server"; +import { createDocsSearch } from "@/lib/search/engine"; +import { isPageType, MAX_QUERY_LENGTH } from "@/lib/search/shared"; import { v2source } from "@/lib/source"; -// Index the canonical documentation collection. -export const { GET } = createFromSource(v2source, { - // https://docs.orama.com/docs/orama-js/supported-languages - language: "english", -}); +// One index per server instance, built from this deployment's MDX exports. +// No persisted/crawler index can carry removed pages across deployments. +let index: ReturnType | undefined; +function getIndex() { + index ??= createDocsSearch( + v2source.getPages().map((page) => ({ + url: page.url, + title: page.data.title, + description: page.data.description, + pageType: page.data.type, + structuredData: page.data.structuredData, + })), + ).catch((error) => { + index = undefined; + throw error; + }); + return index; +} + +export async function GET(request: Request) { + const params = new URL(request.url).searchParams; + const query = params.get("query") ?? ""; + const type = params.get("type") ?? ""; + if (query.length > MAX_QUERY_LENGTH || (type && !isPageType(type))) { + return Response.json( + { error: "Invalid search query or page type" }, + { status: 400 }, + ); + } + const search = await getIndex(); + return Response.json( + await search.query(query, isPageType(type) ? type : undefined), + { + headers: { + "Cache-Control": "no-store", + "X-Search-Revision": search.revision, + "X-Search-Pages": String(search.pageCount), + }, + }, + ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index fd09c99..b3fa72e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,4 @@ -import { RootProvider } from "fumadocs-ui/provider/next"; +import { DocsProvider } from "@/components/docs-provider"; import { PostHogProvider } from "@/lib/posthog/provider"; import "./global.css"; import type { Metadata } from "next"; @@ -42,15 +42,7 @@ export default function Layout({ children }: LayoutProps<"/">) { > - - {children} - + {children} diff --git a/src/components/docs-provider.tsx b/src/components/docs-provider.tsx new file mode 100644 index 0000000..8fbf622 --- /dev/null +++ b/src/components/docs-provider.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { RootProvider } from "fumadocs-ui/provider/next"; +import dynamic from "next/dynamic"; +import type { ReactNode } from "react"; + +const SearchDialog = dynamic(() => import("./docs-search")); + +export function DocsProvider({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/src/components/docs-search.tsx b/src/components/docs-search.tsx new file mode 100644 index 0000000..e4fdd89 --- /dev/null +++ b/src/components/docs-search.tsx @@ -0,0 +1,395 @@ +"use client"; + +import { + SearchDialog, + SearchDialogClose, + SearchDialogContent, + SearchDialogHeader, + SearchDialogIcon, + SearchDialogOverlay, + type SharedProps, +} from "fumadocs-ui/components/dialog/search"; +import { + ArrowDown, + ArrowUp, + CornerDownLeft, + FileText, + Hash, +} from "lucide-react"; +import { useRouter } from "next/navigation"; +import { + type KeyboardEvent, + useEffect, + useId, + useMemo, + useRef, + useState, +} from "react"; +import { usePostHog } from "@/lib/posthog/provider"; +import { + type DocsSearchResult, + groupResults, + MAX_QUERY_LENGTH, + type PageType, + pageTypes, +} from "@/lib/search/shared"; + +interface SearchResponse { + key: string; + results: DocsSearchResult[]; + error?: boolean; + revision?: string; +} + +function Highlight({ text, query }: { text: string; query: string }) { + const terms = query.trim().split(/\s+/).filter(Boolean); + if (!terms.length) return text; + const pattern = new RegExp( + `(${terms.map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})`, + "ig", + ); + return text.split(pattern).map((part, i) => + i % 2 ? ( + + {part} + + ) : ( + part + ), + ); +} + +export default function DocsSearchDialog(props: SharedProps) { + const [query, setQuery] = useState(""); + const [pageType, setPageType] = useState(""); + const [response, setResponse] = useState(); + const [retry, setRetry] = useState(0); + const [activeId, setActiveId] = useState(); + const input = useRef(null); + const listId = useId(); + const router = useRouter(); + const posthog = usePostHog(); + const term = query.trim(); + // Retrying starts a new request, but does not represent a new analytics query. + const analyticsKey = `${pageType}:${term}`; + const key = `${retry}:${analyticsKey}`; + const current = response?.key === key ? response : undefined; + const loading = props.open && !!term && !current; + const results = useMemo(() => current?.results ?? [], [current]); + const groups = useMemo(() => groupResults(results), [results]); + const ordered = useMemo( + () => groups.flatMap((group) => group.items), + [groups], + ); + const active = ordered.find((result) => result.id === activeId) ?? ordered[0]; + const optionId = (result: DocsSearchResult) => + `${listId}-${ordered.indexOf(result)}`; + const tracked = useRef(new Set()); + + useEffect(() => { + if (!props.open || !term) return; + const controller = new AbortController(); + const timer = setTimeout(async () => { + try { + const params = new URLSearchParams({ query: term }); + if (pageType) params.set("type", pageType); + const res = await fetch(`/docs/api/search?${params}`, { + signal: controller.signal, + cache: "no-store", + }); + if (!res.ok) throw new Error(`Search returned ${res.status}`); + const results: DocsSearchResult[] = await res.json(); + if (!controller.signal.aborted) { + setActiveId(undefined); + setResponse({ + key, + results, + revision: res.headers.get("X-Search-Revision") ?? undefined, + }); + } + } catch { + if (!controller.signal.aborted) + setResponse({ key, results: [], error: true }); + } + }, 100); + return () => { + clearTimeout(timer); + controller.abort(); + }; + }, [props.open, term, pageType, key]); + + // Wait for a settled query, not every keystroke. Cancel on edits, closing, + // errors and aborted requests; none of those represents a content gap. + useEffect(() => { + if (!props.open || !term || !current || current.error || !posthog) return; + const timer = setTimeout(() => track(current), 700); + return () => clearTimeout(timer); + + function track(result: SearchResponse) { + if (tracked.current.has(analyticsKey)) return; + tracked.current.add(analyticsKey); + const properties = { + query: term, + page_type: pageType || "all", + result_count: result.results.length, + search_revision: result.revision, + }; + posthog?.capture("docs_search", properties); + if (!result.results.length) + posthog?.capture("docs_search_zero_results", properties); + } + }, [props.open, term, pageType, current, posthog, analyticsKey]); + + useEffect(() => { + if (!props.open) { + tracked.current.clear(); + setResponse(undefined); + } + }, [props.open]); + + useEffect(() => { + if (active) + document + .getElementById(`${listId}-${ordered.indexOf(active)}`) + ?.scrollIntoView({ block: "nearest" }); + }, [active, listId, ordered]); + + function select(result: DocsSearchResult) { + // A quick Enter may precede the settled-query timer. + if (!tracked.current.has(analyticsKey)) { + tracked.current.add(analyticsKey); + posthog?.capture("docs_search", { + query: term, + page_type: pageType || "all", + result_count: results.length, + search_revision: current?.revision, + }); + } + posthog?.capture("docs_search_result_selected", { + query: term, + page_type: pageType || "all", + result_url: result.url, + position: ordered.indexOf(result) + 1, + search_revision: current?.revision, + }); + props.onOpenChange(false); + router.push(result.url); + } + + function navigate(event: KeyboardEvent) { + if (event.nativeEvent.isComposing || !ordered.length) return; + const index = active ? ordered.indexOf(active) : 0; + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + const direction = event.key === "ArrowDown" ? 1 : -1; + setActiveId( + ordered[(index + direction + ordered.length) % ordered.length].id, + ); + } else if (event.key === "Enter" && active) { + event.preventDefault(); + select(active); + } + } + + function suggest(value: string) { + setQuery(value); + input.current?.focus(); + } + + return ( + + + { + event.preventDefault(); + input.current?.focus(); + }} + > + + + setQuery(event.target.value)} + onKeyDown={navigate} + maxLength={MAX_QUERY_LENGTH} + role="combobox" + aria-label="Search documentation" + aria-autocomplete="list" + aria-expanded={props.open} + aria-controls={listId} + aria-activedescendant={active ? optionId(active) : undefined} + autoComplete="off" + spellCheck={false} + placeholder="Search docs, API names, or SQL…" + className="min-w-0 flex-1 bg-transparent text-base outline-none placeholder:text-fd-muted-foreground" + /> + + +
+ Filter by page type + {(["", ...pageTypes] as const).map((type) => ( + + ))} +
+
+ {!term && ( +
+

Find an API name, a SQL operator, or your next step.

+
+ {[ + "text_eq", + "ord_term", + "encryptedSupabase", + "supabase auth", + ].map((value) => ( + + ))} +
+
+ )} + {loading && ( + + Searching… + + )} + {current?.error && ( +
+

Search couldn’t load. Please try again.

+ +
+ )} + {!!term && current && !current.error && !results.length && ( +
+

No results for “{term}”.

+

+ Try a shorter identifier + {pageType ? " or choose All types" : " or a different term"}. +

+
+ )} +
+ {groups.map(({ label, items }, groupIndex) => ( +
+

+ {label} +

+ {items.map((result) => ( + + ))} +
+ ))} +
+
+
+ + {current && !current.error && term + ? `${results.length} page${results.length === 1 ? "" : "s"} found` + : "Search documentation"} + + +
+
+
+ ); +} diff --git a/src/lib/search/engine.ts b/src/lib/search/engine.ts new file mode 100644 index 0000000..323eb83 --- /dev/null +++ b/src/lib/search/engine.ts @@ -0,0 +1,208 @@ +import { createHash } from "node:crypto"; +import { create, insertMultiple, search } from "@orama/orama"; +import type { StructuredData } from "fumadocs-core/mdx-plugins/remark-structure"; +import { + type DocsSearchResult, + MAX_QUERY_LENGTH, + type PageType, +} from "./shared"; +import { identifierTokenizer, tokenize } from "./tokenizer"; + +export interface SearchPage { + url: string; + title: string; + description?: string; + pageType?: PageType; + structuredData: StructuredData; +} + +const schema = { + title: "string", + heading: "string", + code: "string", + prose: "string", + all: "string", + pageType: "enum", +} as const; + +function sectionFor(url: string) { + const segments = url.split("/"); + if (segments[1] === "reference") { + return ( + { + eql: "EQL", + stack: "Stack SDK", + cli: "CLI", + proxy: "Proxy", + auth: "Authentication", + workspace: "Workspace", + }[segments[2]] ?? "Reference" + ); + } + return ( + { + integrations: "Integrations", + security: "Security", + guides: "Guides", + concepts: "Concepts", + solutions: "Solutions", + "get-started": "Get started", + }[segments[1]] ?? "Documentation" + ); +} + +function plainText(text: string) { + return text + .replace(/^(`{3,}|~{3,})[^\n]*\n/gm, "") + .replace(/`/g, "") + .replace(/\*\*([^*\n]+)\*\*/g, "$1") + .replace( + /&(lt|gt|amp|quot|#39);/g, + (_, entity: string) => + ({ + lt: "<", + gt: ">", + amp: "&", + quot: '"', + "#39": "'", + })[entity] ?? "", + ) + .replace(/\s+/g, " ") + .trim(); +} + +function excerpt(text: string, query: string) { + const plain = plainText(text); + const terms = query.toLowerCase().split(/\s+/).filter(Boolean); + const positions = terms + .map((term) => plain.toLowerCase().indexOf(term)) + .filter((i) => i >= 0); + const start = Math.max( + 0, + (positions.length ? Math.min(...positions) : 0) - 60, + ); + return `${start ? "…" : ""}${plain.slice(start, start + 220)}${plain.length > start + 220 ? "…" : ""}`; +} + +export async function createDocsSearch(pages: SearchPage[]) { + const db = create({ schema, components: { tokenizer: identifierTokenizer } }); + const records = pages + // The retired tree must not reappear through an accidental loader change. + .filter((page) => !/^\/stack(?:\/|$)/.test(page.url)) + .flatMap((page) => { + const sections = new Map([ + [ + "", + { + heading: "", + code: [] as string[], + prose: [page.description ?? ""], + }, + ], + ...page.structuredData.headings.map( + ({ id, content }) => + [ + id, + { + heading: plainText(content), + code: [] as string[], + prose: [] as string[], + }, + ] as const, + ), + ]); + for (const { heading, content } of page.structuredData.contents) { + const section = sections.get(heading ?? ""); + if (!section) continue; + section[/^\s*(`{3,}|~{3,})/.test(content) ? "code" : "prose"].push( + content, + ); + } + return [...sections].map(([anchor, section]) => ({ + id: `${page.url}${anchor ? `#${anchor}` : ""}`, + page: page.url, + title: page.title, + heading: section.heading, + code: section.code.join("\n"), + prose: section.prose.join("\n"), + // Orama applies threshold within each field. A combined field lets + // "supabase auth" match a page title plus a section heading. + all: [ + page.title, + section.heading, + ...section.code, + ...section.prose, + ].join("\n"), + pageType: page.pageType ?? ("" as const), + section: sectionFor(page.url), + })); + }); + await insertMultiple(db, records); + // Bundled content is the only input: new deploys build a new index, and the + // revision lets preview checks detect stale data without exposing the index. + const revision = createHash("sha256") + .update(JSON.stringify(records)) + .digest("hex") + .slice(0, 16); + const metadata = new Map(records.map((record) => [record.id, record])); + + return { + revision, + pageCount: new Set(records.map((record) => record.page)).size, + async query( + input: string, + pageType?: PageType, + ): Promise { + const query = input.trim().slice(0, MAX_QUERY_LENGTH); + const tokens = tokenize(query); + if (!tokens.length) return []; + const options = { + term: query, + properties: ["title", "heading", "code", "prose", "all"] as const, + boost: { title: 8, heading: 6, code: 3, prose: 1, all: 0.5 }, + threshold: 0, + limit: records.length, + where: pageType ? { pageType: { eq: pageType } } : {}, + }; + let matches = await search(db, { + ...options, + properties: [...options.properties], + tolerance: 0, + }); + // Exact/prefix hits always win. A typo only broadens an otherwise empty + // query; short tokens like SQL LIKE must not turn into unrelated words. + if (!matches.count && tokens.every((token) => token.length >= 5)) { + matches = await search(db, { + ...options, + properties: [...options.properties], + tolerance: 1, + }); + } + const seen = new Set(); + const results: DocsSearchResult[] = []; + for (const hit of matches.hits) { + const record = metadata.get(hit.id); + if (!record || seen.has(record.page)) continue; + seen.add(record.page); + const codeMatches = tokens.some((token) => + tokenize(record.code, undefined, "code").some((word) => + word.startsWith(token), + ), + ); + results.push({ + id: record.id, + url: record.id, + type: "page", + content: record.title, + title: record.title, + heading: record.heading, + section: record.section, + pageType: record.pageType, + excerpt: excerpt(codeMatches ? record.code : record.prose, query), + }); + if (results.length === 15) break; + } + return results; + }, + }; +} diff --git a/src/lib/search/shared.ts b/src/lib/search/shared.ts new file mode 100644 index 0000000..b6629ce --- /dev/null +++ b/src/lib/search/shared.ts @@ -0,0 +1,31 @@ +export const pageTypes = ["reference", "guide", "concept", "tutorial"] as const; +export type PageType = (typeof pageTypes)[number]; +export const MAX_QUERY_LENGTH = 160; + +export interface DocsSearchResult { + id: string; + type: "page"; + url: string; + content: string; + title: string; + heading: string; + section: string; + pageType: PageType | ""; + excerpt: string; +} + +export function isPageType(value: string): value is PageType { + return pageTypes.some((type) => type === value); +} + +/** Keep the top three in relevance order, then group the remaining pages. */ +export function groupResults(results: DocsSearchResult[]) { + const groups = new Map(); + if (results.length) groups.set("Best matches", results.slice(0, 3)); + for (const result of results.slice(3)) { + const group = groups.get(result.section) ?? []; + group.push(result); + groups.set(result.section, group); + } + return [...groups].map(([label, items]) => ({ label, items })); +} diff --git a/src/lib/search/tokenizer.ts b/src/lib/search/tokenizer.ts new file mode 100644 index 0000000..dbea72d --- /dev/null +++ b/src/lib/search/tokenizer.ts @@ -0,0 +1,40 @@ +/** Split camelCase and separators without stemming API names or dropping LIKE. */ +function words(input: string): string[][] { + return ( + input + .normalize("NFKD") + .replace(/\p{M}/gu, "") + .match(/[\p{L}\p{N}_./-]+/gu) ?? [] + ) + .map((word) => word.split(/[_./-]+/).filter(Boolean)) + .filter((parts) => parts.length); +} + +/** + * Dots and underscores are interchangeable namespaces: eql_v3.text_eq also + * finds public.eql_v3_text_eq. Index suffixes at identifier boundaries so + * text_eq / ord_ore / Supabase match qualified and camelCase symbols. Orama + * handles prefixes; there is no quadratic list of every substring. + * + * Query tokens stay whole. Expanding a query into `text`, `eq`, etc. would + * dilute precise identifier searches into unrelated prose. + */ +export function tokenize(input: string, _language?: string, property?: string) { + const tokens = words(input).flatMap((parts) => + property + ? parts.flatMap((part, i) => { + const camel = part + .replace(/([a-z\d])([A-Z])/g, "$1_$2") + .replace(/([A-Z])([A-Z][a-z])/g, "$1_$2") + .split("_"); + return [ + parts.slice(i).join("_"), + ...camel.flatMap((word, j) => [word, camel.slice(j).join("")]), + ]; + }) + : [parts.join("_")], + ); + return [...new Set(tokens.map((token) => token.toLowerCase()))]; +} + +export const identifierTokenizer = { language: "english", tokenize }; diff --git a/tests/search.spec.ts b/tests/search.spec.ts new file mode 100644 index 0000000..133780e --- /dev/null +++ b/tests/search.spec.ts @@ -0,0 +1,302 @@ +import assert from "node:assert/strict"; +import { gunzipSync } from "node:zlib"; +import { test as base, expect, type Page } from "@playwright/test"; + +interface AnalyticsEvent { + event: string; + properties: { query?: string; result_url?: string; result_count?: number }; +} + +// Keep browser checks out of real analytics, including when run on a preview. +const test = base.extend<{ analytics: AnalyticsEvent[] }>({ + analytics: [ + async ({ page }, use) => { + const events: AnalyticsEvent[] = []; + // PostHog intentionally drops webdriver traffic. This local fixture + // exercises normal capture; every outbound ingest request is intercepted. + if (process.env.SEARCH_ANALYTICS_TEST) { + await page.addInitScript(() => { + Object.defineProperty(navigator, "webdriver", { get: () => false }); + Object.defineProperty(navigator, "userAgentData", { + get: () => ({ + brands: [{ brand: "Chromium", version: "156" }], + mobile: false, + platform: "macOS", + }), + }); + }); + } + await page.route("**/docs/ingest/**", async (route) => { + const request = route.request(); + if (new URL(request.url()).pathname.endsWith(".js")) { + await route.fulfill({ + contentType: "application/javascript", + body: "", + }); + return; + } + if (request.method() === "POST" && /\/[ei]\//.test(request.url())) { + const raw = request.postDataBuffer(); + if (raw) { + const text = + raw[0] === 0x1f ? gunzipSync(raw).toString() : raw.toString(); + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + const data = new URLSearchParams(text).get("data"); + if (data) + parsed = JSON.parse(Buffer.from(data, "base64").toString()); + } + if (Array.isArray(parsed)) events.push(...parsed); + else if (parsed && typeof parsed === "object" && "event" in parsed) + events.push(parsed as AnalyticsEvent); + } + } + await route.fulfill({ + json: { + status: 1, + featureFlags: {}, + errorsWhileComputingFlags: false, + }, + }); + }); + await use(events); + }, + { auto: true }, + ], +}); + +async function openSearch( + page: Page, + baseURL: string | undefined, + shortcut = "Meta+k", +) { + assert(baseURL); + await page.goto(baseURL, { waitUntil: "domcontentloaded" }); + // Wait for hydration before sending a shortcut to the global listener. + await expect( + page.getByRole("button", { name: /search/i }).first(), + ).toBeVisible(); + await page.keyboard.press(shortcut); + const input = page.getByRole("combobox", { name: "Search documentation" }); + await expect(input).toBeFocused(); + return input; +} + +test("⌘K, arrows, Enter at an anchor, Escape, and Ctrl+K after navigation", async ({ + page, + baseURL, +}) => { + const input = await openSearch(page, baseURL); + await input.fill("unique index"); + await expect(page.getByRole("option").first()).toContainText("Indexes"); + const firstId = await input.getAttribute("aria-activedescendant"); + assert(firstId); + await input.press("ArrowDown"); + expect(await input.getAttribute("aria-activedescendant")).not.toBe(firstId); + await input.press("ArrowUp"); + await expect(input).toHaveAttribute("aria-activedescendant", firstId); + await input.press("Enter"); + await expect(page).toHaveURL( + /\/docs\/reference\/eql\/indexes#unique-indexes$/, + ); + await expect(page.getByRole("dialog")).toBeHidden(); + await expect(page.locator("#unique-indexes")).toBeInViewport(); + await page.keyboard.press("Control+k"); + await expect(input).toBeFocused(); + await input.press("Escape"); + await expect(page.getByRole("dialog")).toBeHidden(); +}); + +test("page-type filter is keyboard accessible and does not select a result", async ({ + page, + baseURL, +}) => { + const input = await openSearch(page, baseURL, "Control+k"); + await input.fill("encryption"); + await expect(page.getByRole("option").first()).toBeVisible(); + const guide = page.getByRole("button", { name: "guide", exact: true }); + await guide.focus(); + await guide.press("Enter"); + await expect(guide).toHaveAttribute("aria-pressed", "true"); + await expect(input).toBeFocused(); + await expect(page.getByRole("option").first()).toContainText("guide"); + for (const option of await page.getByRole("option").all()) + await expect(option).toContainText("guide"); + await expect(page.getByRole("dialog")).toBeVisible(); +}); + +test("slow responses cannot replace a newer query; errors are retryable", async ({ + page, + baseURL, +}) => { + let release: (() => void) | undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + let oldRequested = false; + await page.route("**/api/search?**", async (route) => { + const query = new URL(route.request().url()).searchParams.get("query"); + if (query === "slow") { + oldRequested = true; + await gate; + await route.fulfill({ json: [] }); + } else if (query === "fail") { + await route.fulfill({ status: 503, json: { error: "Unavailable" } }); + } else await route.continue(); + }); + const input = await openSearch(page, baseURL); + await input.fill("slow"); + await expect.poll(() => oldRequested).toBe(true); + await input.fill("hipaa"); + await expect(page.getByRole("option").first()).toContainText("HIPAA"); + release?.(); + await expect(page.getByRole("option").first()).toContainText("HIPAA"); + await input.fill("fail"); + await expect(page.getByRole("alert")).toContainText("Search couldn’t load"); + await expect(page.getByText(/No results for/)).toHaveCount(0); + await page.unroute("**/api/search?**"); + await page.getByRole("button", { name: "Retry", exact: true }).click(); + await expect(page.getByRole("alert")).toHaveCount(0); + await expect(page.getByRole("listbox")).toHaveAttribute("aria-busy", "false"); +}); + +test("settled queries, selection and zero-result queries reach PostHog once", async ({ + page, + baseURL, + analytics, +}) => { + test.skip( + !process.env.SEARCH_ANALYTICS_TEST, + "Run a server with the dummy PostHog key documented in README.", + ); + const input = await openSearch(page, baseURL); + await input.fill("zzzzunfindablesymbol"); + await expect( + page.getByText("No results for “zzzzunfindablesymbol”."), + ).toBeVisible(); + await expect + .poll( + () => + analytics.filter((e) => e.event === "docs_search_zero_results").length, + { timeout: 15_000 }, + ) + .toBe(1); + const zero = analytics.find((e) => e.event === "docs_search_zero_results"); + expect(zero?.properties).toMatchObject({ + query: "zzzzunfindablesymbol", + result_count: 0, + }); + await input.fill("unique index"); + await expect(page.getByRole("option").first()).toContainText("Indexes"); + await input.press("Enter"); + await expect + .poll( + () => + analytics.filter((e) => e.event === "docs_search_result_selected") + .length, + { timeout: 15_000 }, + ) + .toBe(1); + expect( + analytics.filter( + (e) => e.event === "docs_search" && e.properties.query === "unique index", + ), + ).toHaveLength(1); + expect( + analytics.filter((e) => e.event === "docs_search_zero_results"), + ).toHaveLength(1); +}); + +test("retry preserves analytics deduplication for revisited queries and selection", async ({ + page, + baseURL, + analytics, +}) => { + test.skip( + !process.env.SEARCH_ANALYTICS_TEST, + "Run a server with the dummy PostHog key documented in README.", + ); + let failedRequests = 0; + await page.route("**/api/search?**", async (route) => { + if (new URL(route.request().url()).searchParams.get("query") === "fail") { + failedRequests++; + await route.fulfill({ status: 503, json: { error: "Unavailable" } }); + } else await route.continue(); + }); + const searches = (query: string) => + analytics.filter( + (event) => + event.event === "docs_search" && event.properties.query === query, + ); + const input = await openSearch(page, baseURL); + await input.fill("unique index"); + await expect(page.getByRole("option").first()).toContainText("Indexes"); + await expect + .poll(() => searches("unique index").length, { timeout: 15_000 }) + .toBe(1); + + await input.fill("zzzzunfindablesymbol"); + await expect( + page.getByText("No results for “zzzzunfindablesymbol”."), + ).toBeVisible(); + await expect + .poll( + () => + analytics.filter((event) => event.event === "docs_search_zero_results") + .length, + { timeout: 15_000 }, + ) + .toBe(1); + + await input.fill("fail"); + await expect(page.getByRole("alert")).toContainText("Search couldn’t load"); + await page.getByRole("button", { name: "Retry", exact: true }).click(); + await expect.poll(() => failedRequests).toBe(2); + await expect(page.getByRole("alert")).toContainText("Search couldn’t load"); + + await input.fill("zzzzunfindablesymbol"); + await expect( + page.getByText("No results for “zzzzunfindablesymbol”."), + ).toBeVisible(); + // Let the 700 ms settled-query timer run before testing the selection path. + await page.waitForTimeout(1_000); + await input.fill("unique index"); + await expect(page.getByRole("option").first()).toContainText("Indexes"); + await input.press("Enter"); + // Selection flushes in the same or a later batch than any duplicate query + // events, so wait for it before asserting the final counts. + await expect + .poll( + () => + analytics.filter( + (event) => event.event === "docs_search_result_selected", + ).length, + { timeout: 15_000 }, + ) + .toBe(1); + expect.soft(searches("zzzzunfindablesymbol")).toHaveLength(1); + expect.soft(searches("unique index")).toHaveLength(1); + expect.soft(searches("fail")).toHaveLength(0); + expect( + analytics.filter((event) => event.event === "docs_search_zero_results"), + ).toHaveLength(1); +}); + +test("mobile dialog stays within the viewport and supports touch selection", async ({ + page, + baseURL, +}) => { + await page.setViewportSize({ width: 390, height: 844 }); + const input = await openSearch(page, baseURL); + await input.fill("supabase auth"); + await expect(page.getByRole("option").first()).toContainText("Supabase Auth"); + const bounds = await page.getByRole("dialog").boundingBox(); + assert(bounds); + expect(bounds.x).toBeGreaterThanOrEqual(0); + expect(bounds.x + bounds.width).toBeLessThanOrEqual(390); + expect(bounds.y + bounds.height).toBeLessThanOrEqual(844); + await page.getByRole("option").first().click(); + await expect(page).toHaveURL(/\/docs\/integrations\/supabase\/auth(?:#.*)?$/); +});