From 3fb2f74277f31c284e47ed06527b00e8b8d7189c Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Thu, 5 Nov 2026 15:47:11 +0530 Subject: [PATCH 1/3] Ranking pages card: a fixed table layout keeps a long path inside the card The page column truncates with the full path in its title, and the rank column has a fixed width. Also correct the strip's comments: its count is of the device shown. --- apps/web/src/features/rankings/ranking-pages.tsx | 10 ++++++---- apps/web/src/features/rankings/strip/rank-strip.tsx | 7 +++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/web/src/features/rankings/ranking-pages.tsx b/apps/web/src/features/rankings/ranking-pages.tsx index d74d2050..f179de81 100644 --- a/apps/web/src/features/rankings/ranking-pages.tsx +++ b/apps/web/src/features/rankings/ranking-pages.tsx @@ -62,15 +62,16 @@ export function RankingPagesCard({ trigger, pages, checkedAt, timeZone }: Rankin const t = useTranslations('rankings.pages'); const rank = useTranslations('rankings.rank'); return ( - +

{t('title', { count: pages.length })}

- + {/* A fixed layout keeps a long path inside the card: the page column takes what the rank leaves and truncates */} +
- @@ -78,11 +79,12 @@ export function RankingPagesCard({ trigger, pages, checkedAt, timeZone }: Rankin {pages.map((page, index) => ( -
{t('page')} + {rank('rank')}
+ {page.url.path} diff --git a/apps/web/src/features/rankings/strip/rank-strip.tsx b/apps/web/src/features/rankings/strip/rank-strip.tsx index 342fff5d..5c319a83 100644 --- a/apps/web/src/features/rankings/strip/rank-strip.tsx +++ b/apps/web/src/features/rankings/strip/rank-strip.tsx @@ -20,8 +20,8 @@ export interface RankStripProps { /** * Summary metric strip of the filtered keywords (24-page-templates.md section 9.3). - * Four counts of tracked keywords (a keyword on each device is two): Keywords (with checked count), Top 3, Top 10 - * and Top 20 (each of checked count). The device chosen narrows all four. + * Four counts of tracked keywords: Keywords (with checked count), Top 3, Top 10 and Top 20 (each of checked + * count). The filters carry the device shown, so each keyword counts once and Keywords equals the table's count. */ export function RankStrip({ scope, filters, total, enabled = true }: RankStripProps): ReactElement { const t = useTranslations('rankings.strip'); @@ -54,8 +54,7 @@ export function RankStrip({ scope, filters, total, enabled = true }: RankStripPr }; // The strip ignores rows without a check date for rank counts (24-page-templates.md section 9.3). - // The count of keywords is of tracked keywords, one for each device: both devices chosen, a keyword counts - // twice, as its Top 3, Top 10 and Top 20 do, and one device chosen counts only its own. + // The count of keywords is of the device shown, like the table's: a keyword tracked on both devices is one. const items: MetricStripItem[] = [ { id: 'tracked', From ef1101c69d0796e966fc45e448325dac36c9e144 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Thu, 5 Nov 2026 15:52:27 +0530 Subject: [PATCH 2/3] Fix the five unit tests that fail CI on main The strip test follows the mock's current counts, the bucket test counts the desktop rows the table shows, the invite test uses a refusal the mock still gives (a password may contain the email name now), and the mock's budget month follows Date.now so a month change does not move it. --- apps/web/src/features/auth/invite-screen.test.tsx | 4 ++-- apps/web/src/features/rankings/rankings.test.tsx | 9 ++++++--- apps/web/src/features/rankings/strip/rank-strip.test.tsx | 6 +++--- apps/web/src/mocks/handlers/costs.ts | 2 +- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/apps/web/src/features/auth/invite-screen.test.tsx b/apps/web/src/features/auth/invite-screen.test.tsx index 3e8d9375..885159c7 100644 --- a/apps/web/src/features/auth/invite-screen.test.tsx +++ b/apps/web/src/features/auth/invite-screen.test.tsx @@ -77,8 +77,8 @@ describe('InviteScreen', () => { it('puts the reason the API refuses a password on the password field, keeping the name', async () => { const rendered = await openInvitation(); - await register(rendered, 'lena-2026-password'); - expect(await screen.findByText('Do not use your email address in the password.')).toBeInTheDocument(); + await register(rendered, 'short'); + expect(await screen.findByText(/^Use at least \d+ characters\.$/)).toBeInTheDocument(); expect(screen.getByLabelText('Your name')).toHaveValue('Lena Vogel'); }); diff --git a/apps/web/src/features/rankings/rankings.test.tsx b/apps/web/src/features/rankings/rankings.test.tsx index 1af6a233..61a8c30d 100644 --- a/apps/web/src/features/rankings/rankings.test.tsx +++ b/apps/web/src/features/rankings/rankings.test.tsx @@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import en from '../../../messages/en.json'; -import type { TrackedKeywordState } from '@/lib/api/types'; +import type { TrackedKeywordRow, TrackedKeywordState } from '@/lib/api/types'; import { MOCK_ENTITY_IDS } from '@/mocks/entity-fixtures'; import { MOCK_KEYWORDS, MOCK_TRACKED_KEYWORDS } from '@/mocks/keyword-fixtures'; import { KEYWORD_TABLE } from '@/mocks/keyword-table'; @@ -509,10 +509,11 @@ describe('Rankings', () => { }); /** The fixture keywords as the mocks state them now: what the API will answer for each active row. */ -function fixtureStates(): { text: string; state: TrackedKeywordState }[] { +function fixtureStates(): { text: string; device: TrackedKeywordRow['device']; state: TrackedKeywordState }[] { const texts = new Map(MOCK_KEYWORDS.map((keyword) => [keyword.keyword_id, keyword.text])); return MOCK_TRACKED_KEYWORDS.filter((row) => row.is_active).map((row) => ({ text: texts.get(row.keyword_id) ?? '', + device: row.device, state: rankStateOf( row.tracked_keyword_id, row.created_at, @@ -967,8 +968,10 @@ describe('Rankings rank filters', () => { asked.some((query) => query.get('filter[rank_gte]') === '4' && query.get('filter[rank_lte]') === '10'), ).toBe(true), ); + // The table shows one device, desktop unless mobile is chosen const within4to10 = fixtureStates().filter( - ({ state }) => state.current.rank !== null && state.current.rank >= 4 && state.current.rank <= 10, + ({ device, state }) => + device === 'desktop' && state.current.rank !== null && state.current.rank >= 4 && state.current.rank <= 10, ); expect(within4to10.length).toBeGreaterThan(0); await waitFor(() => expect(keywordsIn(grid)).toHaveLength(within4to10.length)); diff --git a/apps/web/src/features/rankings/strip/rank-strip.test.tsx b/apps/web/src/features/rankings/strip/rank-strip.test.tsx index 24563d1b..9e97a96b 100644 --- a/apps/web/src/features/rankings/strip/rank-strip.test.tsx +++ b/apps/web/src/features/rankings/strip/rank-strip.test.tsx @@ -35,11 +35,11 @@ describe('RankStrip', () => { expect(await screen.findByText(String(activeCount))).toBeInTheDocument(); expect(await screen.findByText('55 checked')).toBeInTheDocument(); expect(screen.getByText('Top 3')).toBeInTheDocument(); - expect(screen.getByText('6')).toBeInTheDocument(); + expect(screen.getByText('7')).toBeInTheDocument(); expect(screen.getByText('Top 10')).toBeInTheDocument(); expect(screen.getByText('12')).toBeInTheDocument(); expect(screen.getByText('Top 20')).toBeInTheDocument(); - expect(screen.getByText('28')).toBeInTheDocument(); + expect(screen.getByText('23')).toBeInTheDocument(); expect(screen.getAllByText('of 55 checked')).toHaveLength(3); }); @@ -85,7 +85,7 @@ describe('RankStrip', () => { expect(screen.getByText('Top 10')).toBeInTheDocument(); expect(screen.getByText('12')).toBeInTheDocument(); expect(screen.getByText('Top 20')).toBeInTheDocument(); - expect(screen.getByText('28')).toBeInTheDocument(); + expect(screen.getByText('23')).toBeInTheDocument(); expect(screen.getByText('The summary did not load.')).toBeInTheDocument(); }); diff --git a/apps/web/src/mocks/handlers/costs.ts b/apps/web/src/mocks/handlers/costs.ts index 1711e2d5..8e87228f 100644 --- a/apps/web/src/mocks/handlers/costs.ts +++ b/apps/web/src/mocks/handlers/costs.ts @@ -90,7 +90,7 @@ const keyOf = (event: CostEvent, key: GroupKey): string => { }; /** Today in UTC, as the API's default dates are. */ -const today = (): string => new Date().toISOString().slice(0, 10); +const today = (): string => new Date(Date.now()).toISOString().slice(0, 10); export const costHandlers: HttpHandler[] = [ mock('get', `${W}/costs`, ({ request, cookies, params }) => { From b94a7df018c48dce99bd0158b75b3fbee2fbb849 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Thu, 5 Nov 2026 16:05:07 +0530 Subject: [PATCH 3/3] CI: run the backend tests on 4 workers so the job finishes inside its timeout The backend job ran 4,120 tests one at a time and needed about 45 minutes, so it hit its 15 minute limit on every run and no image was ever pushed. With pytest-xdist on 4 workers (each with its own PostgreSQL, Redis and object store containers) the full suite with coverage takes 7.5 minutes on a 4-CPU machine. The job limit goes to 30 minutes for a slower runner. The workflow also runs when infra/compose/postgres/initdb changes, because the tests build their database from those role scripts. --- .github/workflows/backend.yml | 8 +- backend/check.sh | 5 +- backend/pyproject.toml | 1 + backend/uv.lock | 206 +++++++++++++++++++--------------- 4 files changed, 125 insertions(+), 95 deletions(-) diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 7304c3d8..c9bef58d 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -3,9 +3,10 @@ name: backend on: push: branches: [main] - paths: ["backend/**", ".github/workflows/backend.yml"] + # The tests build their database from the role scripts in infra/compose/postgres/initdb (tests/conftest.py) + paths: ["backend/**", "infra/compose/postgres/initdb/**", ".github/workflows/backend.yml"] pull_request: - paths: ["backend/**", ".github/workflows/backend.yml"] + paths: ["backend/**", "infra/compose/postgres/initdb/**", ".github/workflows/backend.yml"] concurrency: group: backend-${{ github.ref }} @@ -17,7 +18,8 @@ permissions: jobs: checks: runs-on: ubuntu-24.04 - timeout-minutes: 15 + # 4,120 tests on 4 workers take about 7.5 minutes on a 4-CPU machine; this leaves room for a slower runner + timeout-minutes: 30 defaults: run: working-directory: backend diff --git a/backend/check.sh b/backend/check.sh index ff8a639a..246ecfde 100755 --- a/backend/check.sh +++ b/backend/check.sh @@ -6,4 +6,7 @@ uv run ruff check . uv run ruff format --check . uv run mypy uv run lint-imports -uv run pytest "$@" +# Four workers, each with its own PostgreSQL, Redis and object store containers (the session fixtures run +# once per worker). The suite waits on the databases far more than on the CPU, so this fits the 4-vCPU CI +# runner and a 4-CPU Colima. Pass -n 0 to run serially. +uv run pytest -n 4 "$@" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index b20d391f..921e0955 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -162,6 +162,7 @@ dev = [ "pytest>=9.1.1", "pytest-asyncio>=1.4.0", "pytest-cov>=7.1.0", + "pytest-xdist>=3.8", "ruff>=0.16.9", "testcontainers[postgres,redis]>=4.15.0", "types-aiobotocore[s3]>=3.9.1", diff --git a/backend/uv.lock b/backend/uv.lock index 51b56b11..3128e698 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -459,6 +459,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/75/23/529140fe1aab80fc6992f93a706deec709140a6397439139a054e1515c45/docker-7.2.0-py3-none-any.whl", hash = "sha256:a3f45fdeb9165e2d25d9a1d02ddf3bc70fb572cf5ebbf9b58558c22caf29b71f", size = 148775, upload-time = "2026-07-09T14:53:45.224Z" }, ] +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + [[package]] name = "fastapi" version = "0.142.1" @@ -692,6 +701,99 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, ] +[[package]] +name = "idraft" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "aiobotocore" }, + { name = "aiohttp" }, + { name = "alembic" }, + { name = "argon2-cffi" }, + { name = "asyncpg" }, + { name = "cryptography" }, + { name = "fastapi" }, + { name = "google-re2" }, + { name = "httpx" }, + { name = "idna" }, + { name = "publicsuffixlist" }, + { name = "pyahocorasick" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyotp" }, + { name = "python-multipart" }, + { name = "redis" }, + { name = "sentry-sdk", extra = ["fastapi"] }, + { name = "sqlalchemy", extra = ["asyncio"] }, + { name = "structlog" }, + { name = "taskiq" }, + { name = "taskiq-redis" }, + { name = "tzdata" }, + { name = "uvicorn", extra = ["standard"] }, + { name = "zstandard" }, +] + +[package.dev-dependencies] +dev = [ + { name = "asgi-lifespan" }, + { name = "asyncpg-stubs" }, + { name = "hypothesis" }, + { name = "import-linter" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-xdist" }, + { name = "ruff" }, + { name = "testcontainers", extra = ["redis"] }, + { name = "types-aiobotocore", extra = ["s3"] }, +] + +[package.metadata] +requires-dist = [ + { name = "aiobotocore", specifier = ">=3.9.1" }, + { name = "aiohttp", specifier = ">=3.14.3" }, + { name = "alembic", specifier = ">=1.20.0" }, + { name = "argon2-cffi", specifier = ">=25.1.0" }, + { name = "asyncpg", specifier = ">=0.31.0" }, + { name = "cryptography", specifier = ">=50.0.1" }, + { name = "fastapi", specifier = ">=0.142.1" }, + { name = "google-re2", specifier = "==1.1.20251105" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "idna", specifier = "==3.20" }, + { name = "publicsuffixlist", specifier = "==1.0.2.20260925" }, + { name = "pyahocorasick", specifier = "==2.3.1" }, + { name = "pydantic", specifier = ">=2.13.5" }, + { name = "pydantic-settings", specifier = ">=2.15.0" }, + { name = "pyotp", specifier = ">=2.10.0" }, + { name = "python-multipart", specifier = ">=0.0.32" }, + { name = "redis", specifier = ">=8.1.0" }, + { name = "sentry-sdk", extras = ["fastapi"], specifier = ">=2.71.0" }, + { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.1.1" }, + { name = "structlog", specifier = ">=26.1.0" }, + { name = "taskiq", specifier = ">=0.13.0" }, + { name = "taskiq-redis", specifier = ">=1.2.4" }, + { name = "tzdata", specifier = ">=2026.4" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.54.0" }, + { name = "zstandard", specifier = ">=0.25.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "asgi-lifespan", specifier = ">=2.1.0" }, + { name = "asyncpg-stubs", specifier = ">=0.31.3" }, + { name = "hypothesis", specifier = ">=6.168.3" }, + { name = "import-linter", specifier = ">=2.15" }, + { name = "mypy", specifier = ">=2.3.1" }, + { name = "pytest", specifier = ">=9.1.1" }, + { name = "pytest-asyncio", specifier = ">=1.4.0" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, + { name = "pytest-xdist", specifier = ">=3.8" }, + { name = "ruff", specifier = ">=0.16.9" }, + { name = "testcontainers", extras = ["postgres", "redis"], specifier = ">=4.15.0" }, + { name = "types-aiobotocore", extras = ["s3"], specifier = ">=3.9.1" }, +] + [[package]] name = "import-linter" version = "2.15" @@ -1097,6 +1199,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1235,97 +1350,6 @@ fastapi = [ { name = "fastapi" }, ] -[[package]] -name = "idraft" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "aiobotocore" }, - { name = "aiohttp" }, - { name = "alembic" }, - { name = "argon2-cffi" }, - { name = "asyncpg" }, - { name = "cryptography" }, - { name = "fastapi" }, - { name = "google-re2" }, - { name = "httpx" }, - { name = "idna" }, - { name = "publicsuffixlist" }, - { name = "pyahocorasick" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "pyotp" }, - { name = "python-multipart" }, - { name = "redis" }, - { name = "sentry-sdk", extra = ["fastapi"] }, - { name = "sqlalchemy", extra = ["asyncio"] }, - { name = "structlog" }, - { name = "taskiq" }, - { name = "taskiq-redis" }, - { name = "tzdata" }, - { name = "uvicorn", extra = ["standard"] }, - { name = "zstandard" }, -] - -[package.dev-dependencies] -dev = [ - { name = "asgi-lifespan" }, - { name = "asyncpg-stubs" }, - { name = "hypothesis" }, - { name = "import-linter" }, - { name = "mypy" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "pytest-cov" }, - { name = "ruff" }, - { name = "testcontainers", extra = ["redis"] }, - { name = "types-aiobotocore", extra = ["s3"] }, -] - -[package.metadata] -requires-dist = [ - { name = "aiobotocore", specifier = ">=3.9.1" }, - { name = "aiohttp", specifier = ">=3.14.3" }, - { name = "alembic", specifier = ">=1.20.0" }, - { name = "argon2-cffi", specifier = ">=25.1.0" }, - { name = "asyncpg", specifier = ">=0.31.0" }, - { name = "cryptography", specifier = ">=50.0.1" }, - { name = "fastapi", specifier = ">=0.142.1" }, - { name = "google-re2", specifier = "==1.1.20251105" }, - { name = "httpx", specifier = ">=0.28.1" }, - { name = "idna", specifier = "==3.20" }, - { name = "publicsuffixlist", specifier = "==1.0.2.20260925" }, - { name = "pyahocorasick", specifier = "==2.3.1" }, - { name = "pydantic", specifier = ">=2.13.5" }, - { name = "pydantic-settings", specifier = ">=2.15.0" }, - { name = "pyotp", specifier = ">=2.10.0" }, - { name = "python-multipart", specifier = ">=0.0.32" }, - { name = "redis", specifier = ">=8.1.0" }, - { name = "sentry-sdk", extras = ["fastapi"], specifier = ">=2.71.0" }, - { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.1.1" }, - { name = "structlog", specifier = ">=26.1.0" }, - { name = "taskiq", specifier = ">=0.13.0" }, - { name = "taskiq-redis", specifier = ">=1.2.4" }, - { name = "tzdata", specifier = ">=2026.4" }, - { name = "uvicorn", extras = ["standard"], specifier = ">=0.54.0" }, - { name = "zstandard", specifier = ">=0.25.0" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "asgi-lifespan", specifier = ">=2.1.0" }, - { name = "asyncpg-stubs", specifier = ">=0.31.3" }, - { name = "hypothesis", specifier = ">=6.168.3" }, - { name = "import-linter", specifier = ">=2.15" }, - { name = "mypy", specifier = ">=2.3.1" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "pytest-asyncio", specifier = ">=1.4.0" }, - { name = "pytest-cov", specifier = ">=7.1.0" }, - { name = "ruff", specifier = ">=0.16.9" }, - { name = "testcontainers", extras = ["postgres", "redis"], specifier = ">=4.15.0" }, - { name = "types-aiobotocore", extras = ["s3"], specifier = ">=3.9.1" }, -] - [[package]] name = "six" version = "1.17.0"