diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml
index 7c8a1f745..66a213ef9 100644
--- a/.github/workflows/frontend-ci.yml
+++ b/.github/workflows/frontend-ci.yml
@@ -73,6 +73,10 @@ jobs:
# API server env vars
DJANGO_SETTINGS_MODULE: dandiapi.settings.development
DJANGO_DATABASE_URL: postgres://postgres:postgres@localhost:5432/django
+ # Close DB connections per-request so the threaded `runserver` doesn't
+ # accumulate connections under parallel Playwright load and exhaust
+ # Postgres's max_connections ("sorry, too many clients already").
+ DJANGO_CONN_MAX_AGE: "0"
DJANGO_CELERY_BROKER_URL: amqp://localhost:5672/
DJANGO_MINIO_STORAGE_URL: http://minioAccessKey:minioSecretKey@localhost:9000/django-storage-testing
DJANGO_DANDI_WEB_APP_URL: http://localhost:8085
diff --git a/dandiapi/settings/development.py b/dandiapi/settings/development.py
index 9b5ecff75..0376fc789 100644
--- a/dandiapi/settings/development.py
+++ b/dandiapi/settings/development.py
@@ -75,3 +75,14 @@
DANDI_DEV_EMAIL = 'test-dev@example.com'
DANDI_ADMIN_EMAIL = 'test-admin@example.com'
+
+# `manage.py runserver` spawns a thread per request and, with a long-lived
+# CONN_MAX_AGE (inherited from base settings), holds one Postgres connection per
+# thread for the lifetime of the process. Under parallel load — notably the
+# Playwright E2E suite running with multiple workers — the connection count can
+# climb past Postgres's default `max_connections` ("sorry, too many clients
+# already"). Allow that environment to force per-request connection closing
+# (`DJANGO_CONN_MAX_AGE=0`) so the count stays bounded regardless of test count.
+DATABASES['default']['CONN_MAX_AGE'] = env.float(
+ 'DJANGO_CONN_MAX_AGE', default=DATABASES['default']['CONN_MAX_AGE']
+)
diff --git a/e2e/tests/dandisetsPage.spec.ts b/e2e/tests/dandisetsPage.spec.ts
index 6e3085979..913a891bc 100644
--- a/e2e/tests/dandisetsPage.spec.ts
+++ b/e2e/tests/dandisetsPage.spec.ts
@@ -19,6 +19,9 @@ test.describe("dandisets page", async () => {
// Match a stable substring of the search field's placeholder so this
// doesn't break when the placeholder copy is tweaked.
const searchFieldText = "Search Dandisets";
+ // Vuetify puts role="combobox" on the field's wrapper, but the inner
+ // still exposes the textbox role with the placeholder as its
+ // accessible name — so match it as a textbox.
await page.getByRole('textbox', { name: 'Search Dandisets' }).click();
await page.keyboard.press("Enter");
await page.getByRole("button", { name: "" }).click();
diff --git a/e2e/tests/searchOperators.spec.ts b/e2e/tests/searchOperators.spec.ts
new file mode 100644
index 000000000..8038e95f0
--- /dev/null
+++ b/e2e/tests/searchOperators.spec.ts
@@ -0,0 +1,101 @@
+import { test, expect } from "@playwright/test";
+import { clientUrl, dismissCookieBanner } from "../utils.ts";
+
+// The operator autocomplete dropdown is a pure-frontend feature: the operator
+// list is hardcoded in the component, so these tests don't need any backend
+// data. They exercise the search field on the home page (`/`).
+
+const SEARCH_PLACEHOLDER = /Search Dandisets/;
+
+test.describe("search operator autocomplete", async () => {
+ test("shows the full operator list on focus", async ({ page }) => {
+ await page.goto(clientUrl);
+ await dismissCookieBanner(page);
+
+ const search = page.getByPlaceholder(SEARCH_PLACEHOLDER);
+ await search.click();
+
+ const listbox = page.locator(".operator-suggestions");
+ await expect(listbox).toBeVisible();
+ // Every operator from the help table should be offered.
+ await expect(listbox.getByRole("option")).toHaveCount(10);
+ await expect(listbox.getByText("species:", { exact: true })).toBeVisible();
+ await expect(listbox.getByText("created_after:", { exact: true })).toBeVisible();
+ });
+
+ test("filters operators as you type", async ({ page }) => {
+ await page.goto(clientUrl);
+ await dismissCookieBanner(page);
+
+ const search = page.getByPlaceholder(SEARCH_PLACEHOLDER);
+ await search.click();
+ await search.fill("spe");
+
+ const listbox = page.locator(".operator-suggestions");
+ await expect(listbox.getByRole("option")).toHaveCount(1);
+ await expect(listbox.getByText("species:", { exact: true })).toBeVisible();
+ await expect(listbox.getByText("approach:", { exact: true })).toHaveCount(0);
+ });
+
+ test("inserts the highlighted operator on Enter", async ({ page }) => {
+ await page.goto(clientUrl);
+ await dismissCookieBanner(page);
+
+ const search = page.getByPlaceholder(SEARCH_PLACEHOLDER);
+ await search.click();
+ await search.fill("spe");
+ // The first match auto-highlights while typing a prefix, so Enter inserts
+ // it (rather than submitting the search).
+ await page.keyboard.press("Enter");
+
+ await expect(search).toHaveValue("species:");
+ // Focus stays in the field so the value can be typed straight away.
+ await expect(search).toBeFocused();
+ });
+
+ test("inserts an operator mid-query on the token under the cursor", async ({ page }) => {
+ await page.goto(clientUrl);
+ await dismissCookieBanner(page);
+
+ const search = page.getByPlaceholder(SEARCH_PLACEHOLDER);
+ await search.click();
+ await search.fill("neuropixels appr");
+ await page.keyboard.press("Enter");
+
+ await expect(search).toHaveValue("neuropixels approach:");
+ });
+
+ test("inserts an operator on click", async ({ page }) => {
+ await page.goto(clientUrl);
+ await dismissCookieBanner(page);
+
+ const search = page.getByPlaceholder(SEARCH_PLACEHOLDER);
+ await search.click();
+ await search.fill("file");
+
+ const listbox = page.locator(".operator-suggestions");
+ await listbox.getByText("file_type:", { exact: true }).click();
+
+ await expect(search).toHaveValue("file_type:");
+ });
+
+ test("dismisses the dropdown on Escape and submits on Enter when browsing", async ({ page }) => {
+ await page.goto(clientUrl);
+ await dismissCookieBanner(page);
+
+ const search = page.getByPlaceholder(SEARCH_PLACEHOLDER);
+ await search.click();
+
+ const listbox = page.locator(".operator-suggestions");
+ await expect(listbox).toBeVisible();
+
+ await page.keyboard.press("Escape");
+ await expect(listbox).toBeHidden();
+
+ // With nothing highlighted (browsing the full list), Enter submits the
+ // search and navigates to the search results route.
+ await search.fill("hippocampus");
+ await page.keyboard.press("Enter");
+ await expect(page).toHaveURL(/search=hippocampus/);
+ });
+});
diff --git a/web/src/components/DandisetSearchField.vue b/web/src/components/DandisetSearchField.vue
index 629b80cd2..dfb76a31a 100644
--- a/web/src/components/DandisetSearchField.vue
+++ b/web/src/components/DandisetSearchField.vue
@@ -1,17 +1,25 @@
@@ -63,11 +71,53 @@
+
+
+
+
+
+
+
+ {{ op.key }}:{{ op.example }}
+
+
+ {{ op.description }}
+
+
+
+
+
+
+