diff --git a/.github/scripts/compose_telegram_message.py b/.github/scripts/compose_telegram_message.py index 413f5bf..94e4706 100644 --- a/.github/scripts/compose_telegram_message.py +++ b/.github/scripts/compose_telegram_message.py @@ -20,6 +20,7 @@ ("sonarqube", "SONAR_BACKEND_RESULT"), ("sonarqube-frontend", "SONAR_FRONTEND_RESULT"), ("docker", "DOCKER_RESULT"), + ("frontend-e2e-smoke", "FRONTEND_E2E_SMOKE_RESULT"), ) TOTAL_LINE = "- Total: {value}" SKIPPED_LINE = "- ⏭️ Skipped: {value}" @@ -225,6 +226,7 @@ def needs_overall_status() -> str: os.environ.get("SONAR_BACKEND_RESULT", "unknown"), os.environ.get("SONAR_FRONTEND_RESULT", "unknown"), os.environ.get("DOCKER_RESULT", "unknown"), + os.environ.get("FRONTEND_E2E_SMOKE_RESULT", "unknown"), ] lowered = [r.lower() for r in tracked_results] @@ -238,7 +240,7 @@ def needs_overall_status() -> str: def _is_relevant_job(name: str) -> bool: if name == "Notify Telegram": return False - if name in ("Build common", "Frontend (Angular)", "Docker Build"): + if name in ("Build common", "Frontend (Angular)", "Docker Build", "Frontend E2E Smoke"): return True return name.startswith(("Unit - ", "Integration - ", SONAR_JOB_PREFIX)) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d7b80f..dd72fcf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -452,11 +452,69 @@ jobs: - name: Build frontend image run: docker build -f frontend/Dockerfile -t frontend:ci frontend - # --- 8. Telegram notification ------------------------------------------------- + # --- 8. Frontend E2E smoke (docker compose) ----------------------------------- + frontend-e2e-smoke: + name: Frontend E2E Smoke + runs-on: ubuntu-latest + needs: [frontend] + permissions: + contents: read + actions: read + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Use Node 20 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.0.0 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install frontend dependencies + working-directory: frontend + run: npm ci --ignore-scripts + + - name: Install Playwright Chromium deps + working-directory: frontend + run: npx playwright install --with-deps chromium + + - name: Prepare .env for Compose + run: cp .env.example .env + + - name: Start stack for E2E smoke + run: docker compose up -d --build + + - name: Wait for frontend endpoint + shell: bash + run: | + for i in {1..60}; do + if curl -fsS http://localhost:4200 > /dev/null; then + echo "Frontend is up" + exit 0 + fi + sleep 2 + done + echo "Frontend did not become ready in time" + exit 1 + + - name: Run frontend E2E smoke + working-directory: frontend + run: npm run test:e2e-smoke + + - name: Dump compose logs on failure + if: failure() + run: docker compose logs --tail=200 + + - name: Stop stack + if: always() + run: docker compose down -v + + # --- 9. Telegram notification ------------------------------------------------- notify-telegram: name: Notify Telegram runs-on: ubuntu-latest - needs: [backend-unit, backend-integration, frontend, sonarqube, sonarqube-frontend, docker] + needs: [backend-unit, backend-integration, frontend, sonarqube, sonarqube-frontend, docker, frontend-e2e-smoke] if: ${{ always() }} env: TELEGRAM_TO: ${{ secrets.TELEGRAM_TO }} @@ -496,6 +554,7 @@ jobs: SONAR_BACKEND_RESULT: ${{ needs.sonarqube.result }} SONAR_FRONTEND_RESULT: ${{ needs.sonarqube-frontend.result }} DOCKER_RESULT: ${{ needs.docker.result }} + FRONTEND_E2E_SMOKE_RESULT: ${{ needs.frontend-e2e-smoke.result }} - name: Notify Telegram if: ${{ env.TELEGRAM_TO != '' && env.TELEGRAM_TOKEN != '' }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 421cfd2..b576ea2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -197,6 +197,28 @@ npm run test:ci npm run build --if-present ``` +### Frontend E2E smoke + +Verifies the full pipeline end-to-end: seeds search events, waits for Redis prefix index to populate, opens the UI in a headless browser, and asserts seeded suggestions order by score with correct click-through behavior. + +Requires Docker Desktop running and `.env` populated (`Copy-Item .env.example .env`). + +```powershell +# Stack must already be up +Set-Location .\frontend +npm run test:e2e-smoke +``` + +To start the stack, run, and tear down: + +```powershell +docker compose up -d --build +Set-Location .\frontend +npm run test:e2e-smoke +Set-Location .. +docker compose down -v +``` + ### End-to-end sanity checks ```powershell @@ -205,6 +227,13 @@ docker compose logs --tail=100 cdc-service docker compose exec redis redis-cli ZREVRANGE autocomplete:ja 0 9 WITHSCORES ``` +For a fully automated end-to-end check, use the smoke test: + +```powershell +Set-Location .\frontend +npm run test:e2e-smoke +``` + --- ## Pull Request Process @@ -212,15 +241,16 @@ docker compose exec redis redis-cli ZREVRANGE autocomplete:ja 0 9 WITHSCORES 1. Create a branch from `main`. 2. Implement changes with tests. 3. Run relevant backend/frontend checks locally. -4. Verify docs/configs are in sync when needed: +4. Run E2E smoke test if pipeline behavior was affected: `npm run test:e2e-smoke` from `frontend/`. +5. Verify docs/configs are in sync when needed: - migrations + `infra/postgres` - `proxy.conf.json` + `nginx.conf` -5. Open a PR to `main` with: +6. Open a PR to `main` with: - change summary - affected pipeline stage(s) - test evidence -6. Address review comments. -7. Merge after CI passes. +7. Address review comments. +8. Merge after CI passes (including `frontend-e2e-smoke` job). --- @@ -232,6 +262,7 @@ docker compose exec redis redis-cli ZREVRANGE autocomplete:ja 0 9 WITHSCORES - [ ] Debezium parsing uses envelope (`payload.after`) - [ ] Schema changes mirrored in both migration locations - [ ] Relevant unit/integration/frontend tests are updated +- [ ] E2E smoke passes locally (`npm run test:e2e-smoke`) if pipeline behavior changed - [ ] `frontend/proxy.conf.json` and `frontend/nginx.conf` are aligned (if routes changed) - [ ] No secrets committed; logs/examples are redacted - [ ] Commit messages follow Conventional Commits diff --git a/README.md b/README.md index df5ce20..13527bf 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,51 @@ npm run test npm run build ``` +Frontend test references: +- Unit tests: `frontend/src/app/**/*.spec.ts` +- E2E smoke script: `frontend/scripts/e2e-smoke.js` + +### Frontend E2E Smoke Test + +End-to-end smoke test that validates the full pipeline (search -> Kafka/CDC -> Redis -> autocomplete API -> UI). The script itself assumes the stack is already running. + +**Prerequisites:** Docker Desktop running, `.env` populated (copy from `.env.example`). + +```powershell +# One-command local run (requires the stack to already be up) +Set-Location .\frontend +npm run test:e2e-smoke +``` + +To start the stack first and then run the test: + +```powershell +docker compose up -d --build +Set-Location .\frontend +npm run test:e2e-smoke +Set-Location .. +docker compose down -v +``` + +The script (`frontend/scripts/e2e-smoke.js`): +- Waits for Debezium connector readiness (`RUNNING`) before seeding events. +- Seeds unique search queries via `/api/search`. +- Polls `/api/complete` until suggestions appear (up to 180 s) to tolerate fresh-stack warm-up. +- Opens the UI in Playwright headless Chromium, types the prefix, asserts seeded suggestions are rendered in descending score order. +- Clicks the top suggestion and verifies it is copied to the input field. + +`FRONTEND_URL` environment variable overrides the default `http://localhost:4200`: + +```powershell +$env:FRONTEND_URL = "http://localhost:4200"; npm run test:e2e-smoke +``` + +`DEBEZIUM_STATUS_URL` can override the default connector status endpoint (`http://localhost:8083/connectors/postgres-connector/status`): + +```powershell +$env:DEBEZIUM_STATUS_URL = "http://localhost:8083/connectors/postgres-connector/status"; npm run test:e2e-smoke +``` + ## CI Pipeline GitHub Actions workflow: `.github/workflows/ci.yml` runs on every push to `main` and on pull requests. @@ -257,8 +302,14 @@ GitHub Actions workflow: `.github/workflows/ci.yml` runs on every push to `main` - Does not push; useful for validating builds on PRs. - Runs after backend-unit, backend-integration, and frontend complete. -8. **notify-telegram** (final stage) - - Sends a Telegram notification with overall CI status. +8. **frontend-e2e-smoke** + - Runs after the `frontend` job; requires Docker Compose. + - Starts the full stack (`docker compose up -d --build`), waits for `http://localhost:4200`. + - Runs `npm run test:e2e-smoke` from `frontend/` — seeds search events, polls autocomplete API, verifies seeded suggestions order by score and click-through behavior. + - Dumps compose logs on failure; always tears down with `docker compose down -v`. + +9. **notify-telegram** (final stage) + - Sends a Telegram notification with overall CI status including `frontend-e2e-smoke` result. - Requires `TELEGRAM_TO` and `TELEGRAM_TOKEN` secrets; skips silently if unavailable. - Runs after all other jobs, regardless of their result. @@ -421,8 +472,9 @@ docker compose exec kafka kafka-topics --bootstrap-server kafka:9092 --list 1. Run backend unit tests: `mvn -B test` from each service directory. 2. Run frontend tests: `npm run test:ci` from `frontend/`. -3. Verify build: `docker compose build` (or individual `docker build` commands). -4. Check code formatting via SonarQube locally if possible, or rely on CI feedback. +3. Run E2E smoke test if pipeline behavior was affected: `npm run test:e2e-smoke` from `frontend/` (requires stack to be up). +4. Verify build: `docker compose build` (or individual `docker build` commands). +5. Check code formatting via SonarQube locally if possible, or rely on CI feedback. ### Operational Checklist diff --git a/autocomplete-service/src/main/java/lt/satsyuk/autocomplete/service/AutocompleteQueryService.java b/autocomplete-service/src/main/java/lt/satsyuk/autocomplete/service/AutocompleteQueryService.java index 3df2d74..32d0a00 100644 --- a/autocomplete-service/src/main/java/lt/satsyuk/autocomplete/service/AutocompleteQueryService.java +++ b/autocomplete-service/src/main/java/lt/satsyuk/autocomplete/service/AutocompleteQueryService.java @@ -41,8 +41,9 @@ public Flux suggest(String prefix, int limit) { Range range = Range.closed(0L, (long) limit - 1); return redisTemplate.opsForZSet() - .reverseRange(redisPrefix + normalizedPrefix, range) - .map(q -> new AutocompleteEntry(q, 0.0)); + .reverseRangeWithScores(redisPrefix + normalizedPrefix, range) + .filter(tuple -> tuple.getValue() != null) + .map(tuple -> new AutocompleteEntry(tuple.getValue(), tuple.getScore() == null ? 0.0 : tuple.getScore())); } private static String normalizePrefix(String raw) { diff --git a/autocomplete-service/src/test/java/lt/satsyuk/autocomplete/integration/AutocompleteServiceRedisIT.java b/autocomplete-service/src/test/java/lt/satsyuk/autocomplete/integration/AutocompleteServiceRedisIT.java index b4b3158..8ba387e 100644 --- a/autocomplete-service/src/test/java/lt/satsyuk/autocomplete/integration/AutocompleteServiceRedisIT.java +++ b/autocomplete-service/src/test/java/lt/satsyuk/autocomplete/integration/AutocompleteServiceRedisIT.java @@ -1,5 +1,7 @@ package lt.satsyuk.autocomplete.integration; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.server.LocalServerPort; @@ -22,6 +24,8 @@ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class AutocompleteServiceRedisIT { + private static final String AUTOCOMPLETE_KEY = "autocomplete:ja"; + @Container static final GenericContainer redis = new GenericContainer<>(DockerImageName.parse("redis:7.2-alpine")) .withExposedPorts(6379); @@ -39,7 +43,8 @@ static void configure(DynamicPropertyRegistry registry) { @Test void completeReturnsEntriesFromRedisSortedSet() { try (Jedis jedis = new Jedis(redis.getHost(), redis.getMappedPort(6379))) { - jedis.zadd("autocomplete:ja", 5.0d, "java"); + jedis.del(AUTOCOMPLETE_KEY); + jedis.zadd(AUTOCOMPLETE_KEY, 5.0d, "java"); } HttpRequest request = HttpRequest.newBuilder() @@ -51,9 +56,38 @@ void completeReturnsEntriesFromRedisSortedSet() { HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); assertThat(response.statusCode()).isEqualTo(200); assertThat(response.body()).contains("\"query\":\"java\""); + assertThat(response.body()).contains("\"score\":5.0"); } catch (Exception e) { throw new RuntimeException(e); } } + + @Test + void completeReturnsStrictJsonContract() throws Exception { + try (Jedis jedis = new Jedis(redis.getHost(), redis.getMappedPort(6379))) { + jedis.del(AUTOCOMPLETE_KEY); + jedis.zadd(AUTOCOMPLETE_KEY, 5.0d, "java"); + jedis.zadd(AUTOCOMPLETE_KEY, 3.0d, "javascript"); + } + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + port + "/api/complete?q=ja&limit=2")) + .GET() + .build(); + + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + assertThat(response.statusCode()).isEqualTo(200); + + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode actual = objectMapper.readTree(response.body()); + JsonNode expected = objectMapper.readTree(""" + [ + {"query":"java","score":5.0}, + {"query":"javascript","score":3.0} + ] + """); + + assertThat(actual).isEqualTo(expected); + } } diff --git a/autocomplete-service/src/test/java/lt/satsyuk/autocomplete/service/AutocompleteQueryServiceTest.java b/autocomplete-service/src/test/java/lt/satsyuk/autocomplete/service/AutocompleteQueryServiceTest.java index e1143e8..6ac8da3 100644 --- a/autocomplete-service/src/test/java/lt/satsyuk/autocomplete/service/AutocompleteQueryServiceTest.java +++ b/autocomplete-service/src/test/java/lt/satsyuk/autocomplete/service/AutocompleteQueryServiceTest.java @@ -7,6 +7,7 @@ import org.mockito.ArgumentMatchers; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.DefaultTypedTuple; import org.springframework.data.domain.Range; import org.springframework.data.redis.core.ReactiveStringRedisTemplate; import org.springframework.data.redis.core.ReactiveZSetOperations; @@ -37,15 +38,18 @@ void setUp() { @Test void returnsSuggestionsFromConfiguredRedisPrefixWithTrimmedLowercaseKey() { when(redisTemplate.opsForZSet()).thenReturn(zSetOperations); - when(zSetOperations.reverseRange(eq("autocomplete:ja"), ArgumentMatchers.>any())) - .thenReturn(Flux.just("java", "javascript")); + when(zSetOperations.reverseRangeWithScores(eq("autocomplete:ja"), ArgumentMatchers.>any())) + .thenReturn(Flux.just( + new DefaultTypedTuple<>("java", 7.0), + new DefaultTypedTuple<>("javascript", 3.0) + )); StepVerifier.create(service.suggest(" Ja ", 2)) - .expectNext(new AutocompleteEntry("java", 0.0)) - .expectNext(new AutocompleteEntry("javascript", 0.0)) + .expectNext(new AutocompleteEntry("java", 7.0)) + .expectNext(new AutocompleteEntry("javascript", 3.0)) .verifyComplete(); - verify(zSetOperations).reverseRange(eq("autocomplete:ja"), ArgumentMatchers.>any()); + verify(zSetOperations).reverseRangeWithScores(eq("autocomplete:ja"), ArgumentMatchers.>any()); } @Test diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1ddf8a7..24f7446 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -31,6 +31,7 @@ "karma-coverage": "^2.2.1", "karma-jasmine": "^5.1.0", "karma-jasmine-html-reporter": "^2.2.0", + "playwright": "^1.59.1", "typescript": "~5.3.0" } }, @@ -9804,6 +9805,53 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.4.35", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", diff --git a/frontend/package.json b/frontend/package.json index a9c5457..75c5ec0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,9 @@ "start": "ng serve --proxy-config proxy.conf.json", "build": "ng build", "test": "ng test", - "test:ci": "ng test --no-watch --no-progress --browsers ChromeHeadless --code-coverage" + "test:ci": "ng test --no-watch --no-progress --browsers ChromeHeadless --code-coverage", + "test:e2e-smoke": "node scripts/e2e-smoke.js", + "test:e2e-smoke:install": "playwright install --with-deps chromium || playwright install chromium" }, "private": true, "dependencies": { @@ -33,6 +35,7 @@ "karma-coverage": "^2.2.1", "karma-jasmine": "^5.1.0", "karma-jasmine-html-reporter": "^2.2.0", + "playwright": "^1.59.1", "typescript": "~5.3.0" } } diff --git a/frontend/scripts/e2e-smoke.js b/frontend/scripts/e2e-smoke.js new file mode 100644 index 0000000..3ab62ec --- /dev/null +++ b/frontend/scripts/e2e-smoke.js @@ -0,0 +1,179 @@ +const assert = require('node:assert/strict'); +const { chromium } = require('playwright'); + +const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:4200'; +const DEBEZIUM_STATUS_URL = + process.env.DEBEZIUM_STATUS_URL || 'http://localhost:8083/connectors/postgres-connector/status'; +const POLL_TIMEOUT_MS = 180000; +const POLL_INTERVAL_MS = 2000; +const RETRIABLE_STATUS_CODES = new Set([502, 503, 504]); + +function buildUrl(path) { + return new URL(path, FRONTEND_URL).toString(); +} + +async function waitForDebeziumConnector() { + const deadline = Date.now() + POLL_TIMEOUT_MS; + + while (Date.now() < deadline) { + try { + const response = await fetch(DEBEZIUM_STATUS_URL); + if (!response.ok) { + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + continue; + } + + const payload = await response.json(); + const connectorState = payload?.connector?.state; + const hasRunningTask = Array.isArray(payload?.tasks) && payload.tasks.some((task) => task?.state === 'RUNNING'); + + if (connectorState === 'RUNNING' && hasRunningTask) { + return; + } + } catch { + // Connector may still be bootstrapping; keep polling. + } + + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } + + throw new Error(`Timed out waiting for Debezium connector readiness: ${DEBEZIUM_STATUS_URL}`); +} + +async function sendSearch(query) { + const deadline = Date.now() + POLL_TIMEOUT_MS; + let lastStatus = 'unknown'; + + while (Date.now() < deadline) { + let response; + try { + response = await fetch(buildUrl(`/api/search?q=${encodeURIComponent(query)}`)); + } catch { + lastStatus = 'network-error'; + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + continue; + } + + if (response.ok) { + return; + } + + lastStatus = response.status; + if (!RETRIABLE_STATUS_CODES.has(response.status)) { + throw new Error(`Search request failed for '${query}': HTTP ${response.status}`); + } + + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } + + throw new Error(`Search request timed out for '${query}'. Last status: ${lastStatus}`); +} + +async function fetchSuggestions(prefix) { + let response; + try { + response = await fetch(buildUrl(`/api/complete?q=${encodeURIComponent(prefix)}&limit=10`)); + } catch { + return null; + } + + if (!response.ok) { + if (RETRIABLE_STATUS_CODES.has(response.status)) { + return null; + } + throw new Error(`Autocomplete request failed for '${prefix}': HTTP ${response.status}`); + } + + return response.json(); +} + +async function waitForSuggestions(prefix, requiredQueries) { + const deadline = Date.now() + POLL_TIMEOUT_MS; + let lastEntries = []; + + while (Date.now() < deadline) { + lastEntries = await fetchSuggestions(prefix); + if (!Array.isArray(lastEntries)) { + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + continue; + } + const values = new Set(lastEntries.map((entry) => entry.query)); + const allPresent = requiredQueries.every((query) => values.has(query)); + if (allPresent) { + return lastEntries; + } + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } + + throw new Error( + `Timed out waiting for suggestions. Prefix='${prefix}', expected=${requiredQueries.join(', ')}, actual=${JSON.stringify(lastEntries)}` + ); +} + +async function run() { + const runId = Date.now().toString(36); + const topQuery = `e2esmoke${runId}java`; + const secondQuery = `e2esmoke${runId}javascript`; + const prefix = `e2esmoke${runId}ja`; + + await waitForDebeziumConnector(); + + await sendSearch(topQuery); + await sendSearch(topQuery); + await sendSearch(topQuery); + await sendSearch(secondQuery); + + const apiEntries = await waitForSuggestions(prefix, [topQuery, secondQuery]); + + const browser = await chromium.launch({ headless: true }); + + try { + const page = await browser.newPage(); + await page.goto(FRONTEND_URL, { waitUntil: 'domcontentloaded' }); + + const input = page.locator('[data-testid="search-input"]'); + await input.click(); + await page.keyboard.type(prefix, { delay: 30 }); + + await page.waitForFunction( + () => document.querySelectorAll('[data-testid="suggestion-item"]').length > 0, + null, + { timeout: 10000 } + ); + + const renderedSuggestions = await page.$$eval('[data-testid="suggestion-item"]', (nodes) => + nodes.map((node) => node.textContent.trim()) + ); + + assert.equal(renderedSuggestions[0], topQuery, `Expected top suggestion '${topQuery}', got '${renderedSuggestions[0]}'`); + assert.ok(renderedSuggestions.includes(secondQuery), `Expected suggestions to include '${secondQuery}'`); + + await page.locator('[data-testid="suggestion-item"]').first().click(); + const selectedValue = await input.inputValue(); + assert.equal(selectedValue, topQuery, 'Clicking first suggestion should copy it to input'); + + console.log('E2E smoke passed'); + console.log( + JSON.stringify( + { + frontendUrl: FRONTEND_URL, + prefix, + apiEntries, + renderedSuggestions, + selectedValue + }, + null, + 2 + ) + ); + } finally { + await browser.close(); + } +} + +run().catch((error) => { + console.error('E2E smoke failed'); + console.error(error); + process.exit(1); +}); + diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html index 002361e..082a7e6 100644 --- a/frontend/src/app/app.component.html +++ b/frontend/src/app/app.component.html @@ -3,6 +3,7 @@

Autocomplete Demo

Autocomplete Demo
-
    +
    • -
    • diff --git a/frontend/src/app/services/autocomplete.service.spec.ts b/frontend/src/app/services/autocomplete.service.spec.ts index 9d1004c..9aed896 100644 --- a/frontend/src/app/services/autocomplete.service.spec.ts +++ b/frontend/src/app/services/autocomplete.service.spec.ts @@ -59,6 +59,40 @@ describe('AutocompleteService', () => { req.flush(mockEntries); }); + it('sorts suggestions by score descending on the client', () => { + const backendOrder: AutocompleteEntry[] = [ + { query: 'javascript', score: 3 }, + { query: 'java', score: 5 } + ]; + + service.fetchSuggestions('ja').subscribe(entries => { + expect(entries).toEqual([ + { query: 'java', score: 5 }, + { query: 'javascript', score: 3 } + ]); + }); + + const req = http.expectOne(r => r.url === '/api/complete'); + req.flush(backendOrder); + }); + + it('uses query as a tie-breaker when scores are equal', () => { + const backendOrder: AutocompleteEntry[] = [ + { query: 'javascript', score: 5 }, + { query: 'java', score: 5 } + ]; + + service.fetchSuggestions('ja').subscribe(entries => { + expect(entries).toEqual([ + { query: 'java', score: 5 }, + { query: 'javascript', score: 5 } + ]); + }); + + const req = http.expectOne(r => r.url === '/api/complete'); + req.flush(backendOrder); + }); + it('trims and lowercases query before sending HTTP request', () => { const mockEntries: AutocompleteEntry[] = [{ query: 'java', score: 5 }]; diff --git a/frontend/src/app/services/autocomplete.service.ts b/frontend/src/app/services/autocomplete.service.ts index d687bef..bb9a03e 100644 --- a/frontend/src/app/services/autocomplete.service.ts +++ b/frontend/src/app/services/autocomplete.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators'; +import { debounceTime, distinctUntilChanged, map, switchMap } from 'rxjs/operators'; import { Observable, Subject, of } from 'rxjs'; export interface AutocompleteEntry { @@ -36,7 +36,18 @@ export class AutocompleteService { } return this.http.get(`/api/complete`, { params: { q: normalized, limit: 10 } - }); + }).pipe( + map(entries => [...entries].sort((a, b) => { + const scoreDiff = b.score - a.score; + if (scoreDiff !== 0) { + return scoreDiff; + } + if (a.query === b.query) { + return 0; + } + return a.query < b.query ? -1 : 1; + })) + ); } sendSearch(q: string): Observable {