test(search): add NL search happy-path Playwright E2E (#739)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m17s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m47s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 24s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m17s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m47s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 24s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
Mock POST /api/search/nl (delayed fixture: 2-name directional + applied keyword), assert loading announcement → chips render → axe-clean in light and dark → removing the keyword chip re-runs a keyword GET with the remaining sender+receiver params. Adds a data-testid wrapper on the NL results region for axe scoping. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
83
frontend/e2e/nl-search.spec.ts
Normal file
83
frontend/e2e/nl-search.spec.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import AxeBuilder from '@axe-core/playwright';
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
// NL search is mocked at the network boundary — Ollama is not required in CI.
|
||||||
|
// CSRF enforcement is bypassed by page.route (the real request is never sent),
|
||||||
|
// so it is only verified in manual full-stack runs (see issue #739 DevOps notes).
|
||||||
|
const interpretation = {
|
||||||
|
resolvedPersons: [
|
||||||
|
{ id: '11111111-1111-1111-1111-111111111111', displayName: 'Walter Raddatz' },
|
||||||
|
{ id: '22222222-2222-2222-2222-222222222222', displayName: 'Emma Raddatz' }
|
||||||
|
],
|
||||||
|
ambiguousPersons: [],
|
||||||
|
dateFrom: '1914-01-01',
|
||||||
|
dateTo: '1918-12-31',
|
||||||
|
keywords: ['krieg'],
|
||||||
|
rawQuery: 'Was hat Walter an Emma im Krieg geschrieben?',
|
||||||
|
keywordsApplied: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const nlResponse = {
|
||||||
|
result: {
|
||||||
|
items: [],
|
||||||
|
totalElements: 0,
|
||||||
|
pageNumber: 0,
|
||||||
|
pageSize: 20,
|
||||||
|
totalPages: 0,
|
||||||
|
undatedCount: 0
|
||||||
|
},
|
||||||
|
interpretation
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('NL (smart) search — happy path', () => {
|
||||||
|
test('toggle → loading → chips → remove chip re-runs keyword search; axe clean light + dark', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
// Deliberate delay so the loading state is assertable before the response arrives.
|
||||||
|
await page.route('**/api/search/nl', async (route) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(nlResponse)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/documents');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
// Switch to smart mode via the toggle pill (keyword label = "Text").
|
||||||
|
await page.getByRole('button', { name: /Text/ }).click();
|
||||||
|
|
||||||
|
const input = page.getByPlaceholder('Titel, Personen, Tags durchsuchen…');
|
||||||
|
await input.fill('Was hat Walter an Emma im Krieg geschrieben?');
|
||||||
|
await input.press('Enter');
|
||||||
|
|
||||||
|
// Loading panel announced to screen readers.
|
||||||
|
await expect(page.getByText(/Archiv wird befragt/)).toBeVisible();
|
||||||
|
|
||||||
|
// Directional chip (Walter → Emma) + keyword chip render once the fixture resolves.
|
||||||
|
await expect(page.getByText('→')).toBeVisible();
|
||||||
|
await expect(page.getByText('Stichwort: krieg')).toBeVisible();
|
||||||
|
|
||||||
|
// Accessibility — light mode.
|
||||||
|
const lightScan = await new AxeBuilder({ page })
|
||||||
|
.include('[data-testid="smart-search-results"]')
|
||||||
|
.analyze();
|
||||||
|
expect(lightScan.violations).toEqual([]);
|
||||||
|
|
||||||
|
// Accessibility — dark mode.
|
||||||
|
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
|
||||||
|
const darkScan = await new AxeBuilder({ page })
|
||||||
|
.include('[data-testid="smart-search-results"]')
|
||||||
|
.analyze();
|
||||||
|
expect(darkScan.violations).toEqual([]);
|
||||||
|
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'light'));
|
||||||
|
|
||||||
|
// Removing the keyword chip re-runs a keyword GET with the remaining resolved
|
||||||
|
// params (sender + receiver from the directional pair).
|
||||||
|
await page.getByRole('button', { name: 'Filter entfernen: Stichwort: krieg' }).click();
|
||||||
|
await page.waitForURL(/senderId=11111111-1111-1111-1111-111111111111/);
|
||||||
|
await expect(page).toHaveURL(/receiverId=22222222-2222-2222-2222-222222222222/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -422,48 +422,50 @@ $effect(() => {
|
|||||||
|
|
||||||
{#if showNlView}
|
{#if showNlView}
|
||||||
<!-- Smart-search results area: loading / error / chips + results / empty / disambiguation. -->
|
<!-- Smart-search results area: loading / error / chips + results / empty / disambiguation. -->
|
||||||
{#if nlLoading}
|
<div data-testid="smart-search-results">
|
||||||
<SmartSearchStatus status="loading" />
|
{#if nlLoading}
|
||||||
{:else if nlError}
|
<SmartSearchStatus status="loading" />
|
||||||
<SmartSearchStatus
|
{:else if nlError}
|
||||||
status="error"
|
<SmartSearchStatus
|
||||||
errorCode={nlError}
|
status="error"
|
||||||
onSwitchToKeyword={switchToKeywordMode}
|
errorCode={nlError}
|
||||||
/>
|
onSwitchToKeyword={switchToKeywordMode}
|
||||||
{:else if nlInterpretation}
|
/>
|
||||||
{#key nlInterpretation}
|
{:else if nlInterpretation}
|
||||||
<div class="mb-4">
|
{#key nlInterpretation}
|
||||||
{#if nlIsAmbiguous}
|
<div class="mb-4">
|
||||||
<DisambiguationPicker
|
{#if nlIsAmbiguous}
|
||||||
persons={nlInterpretation.ambiguousPersons}
|
<DisambiguationPicker
|
||||||
onSelect={selectDisambiguated}
|
persons={nlInterpretation.ambiguousPersons}
|
||||||
/>
|
onSelect={selectDisambiguated}
|
||||||
{:else}
|
/>
|
||||||
<InterpretationChipRow interpretation={nlInterpretation} onRemoveChip={removeChip} />
|
{:else}
|
||||||
{/if}
|
<InterpretationChipRow interpretation={nlInterpretation} onRemoveChip={removeChip} />
|
||||||
</div>
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if !nlIsAmbiguous}
|
{#if !nlIsAmbiguous}
|
||||||
{#if nlHasResults}
|
{#if nlHasResults}
|
||||||
<p class="mb-3 font-sans text-base text-ink-2">
|
<p class="mb-3 font-sans text-base text-ink-2">
|
||||||
{m.docs_result_count({ count: nlResult?.totalElements ?? 0 })}
|
{m.docs_result_count({ count: nlResult?.totalElements ?? 0 })}
|
||||||
</p>
|
</p>
|
||||||
<DocumentList items={nlResult?.items ?? []} canWrite={data.canWrite} sort={sort} />
|
<DocumentList items={nlResult?.items ?? []} canWrite={data.canWrite} sort={sort} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex flex-col items-center justify-center gap-3 py-16 text-center">
|
<div class="flex flex-col items-center justify-center gap-3 py-16 text-center">
|
||||||
<p class="text-sm font-bold text-ink">{m.search_empty_nl()}</p>
|
<p class="text-sm font-bold text-ink">{m.search_empty_nl()}</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={switchToKeywordMode}
|
onclick={switchToKeywordMode}
|
||||||
class="inline-flex min-h-[44px] items-center rounded px-3 py-2 text-sm font-bold text-primary underline underline-offset-4 outline-none hover:text-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy"
|
class="inline-flex min-h-[44px] items-center rounded px-3 py-2 text-sm font-bold text-primary underline underline-offset-4 outline-none hover:text-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||||
>
|
>
|
||||||
{m.search_empty_retry_keyword()}
|
{m.search_empty_retry_keyword()}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/key}
|
||||||
{/key}
|
{/if}
|
||||||
{/if}
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="mt-3 mb-4 hidden lg:block">
|
<div class="mt-3 mb-4 hidden lg:block">
|
||||||
<TimelineDensityFilter
|
<TimelineDensityFilter
|
||||||
|
|||||||
Reference in New Issue
Block a user