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 @@ + +