diff --git a/.env.example b/.env.example index 7282c5a2e..1dc0c1654 100644 --- a/.env.example +++ b/.env.example @@ -2,8 +2,10 @@ # default baked into docker-compose.yml (see ${VAR:-default} references) -- # `docker compose up` succeeds from a clean checkout with no .env file at # all for the default profile. The optional MCP profile requires measured -# quota inputs below. Other defaults are throwaway local-dev-only credentials, not -# production secrets; see docs/adr/0001-demo-identity-and-data-boundary.md. +# quota inputs below; the optional `llm` profile requires a configured +# contextual-orchestrator provider. Other defaults are throwaway local-dev-only +# credentials, not production secrets; see +# docs/adr/0001-demo-identity-and-data-boundary.md. # Host ports deliberately avoid each service's own default (5432, 6379, # 8080) -- a dev machine commonly already runs its own Postgres/Redis/local @@ -36,7 +38,9 @@ MCP_RATE_LIMIT_WINDOW_SECONDS= # Optional. Empty = every LLM/vision channel is unavailable (Null client, # dropped and renormalized -- never a placeholder score). Point these at a -# running contextual-orchestrator to turn the channels on. +# running contextual-orchestrator to turn the channels on. For the repository's +# pinned local orchestrator, configure these for that service and use +# `make up-llm`; plain `make up` keeps the LLM runtime out of the default graph. ORCHESTRATOR_BASE_URL= ORCHESTRATOR_API_KEY= diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9a73249f6..bef53fa02 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -64,7 +64,7 @@ jobs: run: uv run --frozen python -m pytest -q frontend: - name: Frontend lint, test, build + name: Frontend lint, test, build, browser acceptance if: github.event_name != 'pull_request' || (github.event.action != 'closed' && github.event.pull_request.draft == false) runs-on: ubuntu-latest steps: @@ -100,3 +100,73 @@ jobs: - name: Build Storybook working-directory: frontend run: pnpm run build-storybook + + - name: Install Playwright Chromium + working-directory: frontend + run: pnpm exec playwright install --with-deps chromium + + - name: Start authenticated product stack + run: | + touch "$HOME/.env" + docker compose --env-file "$HOME/.env" up -d --build --wait + for attempt in $(seq 1 60); do + if curl --fail --silent --show-error \ + http://localhost:18080/realms/master/.well-known/openid-configuration \ + >/dev/null; then + exit 0 + fi + sleep 2 + done + echo "Keycloak did not become ready" >&2 + exit 1 + + - name: Seed synthetic authenticated product data + run: | + # The seed utility intentionally keeps psycopg2 in the dev extra. Install + # it only in this disposable seed container, not the backend runtime image. + docker compose --env-file "$HOME/.env" run --rm --no-deps \ + -v "$PWD/scripts:/app/scripts:ro" \ + -v "$PWD/migrations:/app/migrations:ro" \ + backend uv run --frozen --extra backend --extra dev python /app/scripts/seed_demo_data.py \ + --postgres-dsn postgresql://lineageweave:lineageweave_dev_only@postgres:5432/lineageweave \ + --keycloak-base-url http://keycloak:8080 \ + --keycloak-admin-password admin_dev_only \ + --valkey-url redis://valkey:6379/0 \ + --backend-base-url http://backend:8000 + + - name: Pin signed cross-share browser fixture + run: | + docker compose --env-file "$HOME/.env" exec -T postgres \ + psql -X -v ON_ERROR_STOP=1 -U lineageweave -d lineageweave \ + -c "update report_leftover_pair set leftover_map_cross_share = -0.24;" + + - name: Run authenticated grouping-comparison browser acceptance + id: browser_acceptance + working-directory: frontend + run: | + set -o pipefail + pnpm exec playwright test grouping-comparison-cross-share.spec.ts --project=chromium \ + 2>&1 | tee playwright-grouping-comparison.log + + - name: Retain Playwright failure evidence + if: failure() && steps.browser_acceptance.outcome == 'failure' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4 + with: + name: playwright-grouping-comparison-${{ github.run_id }}-${{ github.run_attempt }} + path: | + frontend/playwright-grouping-comparison.log + frontend/test-results + if-no-files-found: error + retention-days: 7 + + - name: Capture product stack logs on browser failure + if: failure() && steps.browser_acceptance.outcome == 'failure' + run: | + touch "$HOME/.env" + docker compose --env-file "$HOME/.env" logs --no-color --tail=400 + + - name: Tear down product stack + if: always() + run: | + touch "$HOME/.env" + docker compose --env-file "$HOME/.env" down -v --remove-orphans diff --git a/Makefile b/Makefile index b6764b72f..260216627 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: up down logs smoke seed ps load-http load-mcp +.PHONY: up up-llm down logs smoke seed ps load-http load-mcp # Keep provider credentials outside the repository. Compose interpolation must # read the same home env file as the orchestrator container's env_file. @@ -7,6 +7,12 @@ COMPOSE := docker compose --env-file "$$HOME/.env" up: $(COMPOSE) up -d +# Opt in to the pinned contextual-orchestrator service. Provider credentials +# remain external to the repository; absent credentials fail that optional +# service closed instead of making the default product profile unbootable. +up-llm: + $(COMPOSE) --profile llm up -d + down: $(COMPOSE) down diff --git a/backend/app/config.py b/backend/app/config.py index 0fea9a591..c3568909d 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -26,12 +26,12 @@ class Settings: keycloak_base_url: str keycloak_realm: str keycloak_client_id: str - # The issuer string real tokens actually carry -- whatever hostname the - # browser/client used to log in (Keycloak's hostname-strict=false mode - # reflects the request's Host header into the `iss` claim). This is - # deliberately a *separate* setting from keycloak_base_url: inside - # docker-compose the two differ (internal DNS name vs. the - # host-published port a browser actually hits). + # The issuer string real tokens actually carry -- the identity provider's + # configured public URL, which Compose pins with KC_HOSTNAME so every + # caller (browser or in-network service) sees one issuer. It stays a + # *separate* setting from keycloak_base_url because the two differ inside + # docker-compose: the issuer is the host-published URL a browser reaches, + # while keycloak_base_url is the internal DNS name used only for JWKS. keycloak_issuer: str # Production may use the organization's Keyverse OIDC issuer. The # keycloak fields above remain the explicit local-development fallback. diff --git a/backend/app/report_ingestion.py b/backend/app/report_ingestion.py index b4bfb3187..9a3e2f59c 100644 --- a/backend/app/report_ingestion.py +++ b/backend/app/report_ingestion.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import math import re from collections import defaultdict from datetime import datetime, timezone @@ -28,6 +29,15 @@ _SOURCE_CONTEXT_PRESENT_SQL = source_context_present_sql("p") +def _finite_float_or_none(value: Any) -> float | None: + """Return a strict-JSON finite float for an optional persisted numeric.""" + + if value is None: + return None + result = float(value) + return result if math.isfinite(result) else None + + def parse_period_code(period_code: str) -> tuple[str, int, int]: """Return ``(kind, year, week_or_month)`` or raise ValueError.""" week = _WEEK_PERIOD.fullmatch(period_code) @@ -811,10 +821,8 @@ async def fetch_period_reports( if row["leftover_map_unexplained"] is None else float(row["leftover_map_unexplained"]) ), - "leftover_map_cross_share": ( - None - if row["leftover_map_cross_share"] is None - else float(row["leftover_map_cross_share"]) + "leftover_map_cross_share": _finite_float_or_none( + row["leftover_map_cross_share"] ), "leftover_map_reconstruction": ( None @@ -1053,6 +1061,7 @@ async def fetch_period_comparison( select lp.grouping_kind, lp.grouping_key, lp.pair_kind, lp.post_id, lp.criterion_code, lp.leftover_distance, lp.leftover_residual, lp.leftover_map_reconstruction, lp.leftover_map_unexplained_share, + lp.leftover_map_cross_share, p.post_title, p.visibility_code, p.corporate_entity_id, ({_SOURCE_CONTEXT_PRESENT_SQL}) as has_real_source_context from report_leftover_pair lp @@ -1143,6 +1152,9 @@ async def fetch_period_comparison( if pair["leftover_map_unexplained_share"] is None else float(pair["leftover_map_unexplained_share"]) ), + "leftover_map_cross_share": _finite_float_or_none( + pair["leftover_map_cross_share"] + ), "visibility_code": pair["visibility_code"], "corporate_entity_id": str(pair["corporate_entity_id"]), "has_real_source_context": bool(pair["has_real_source_context"]), diff --git a/backend/tests/test_period_comparison_cross_share.py b/backend/tests/test_period_comparison_cross_share.py new file mode 100644 index 000000000..0d9635fc8 --- /dev/null +++ b/backend/tests/test_period_comparison_cross_share.py @@ -0,0 +1,77 @@ +"""Regression for persisted cross-share transport on the comparison read model.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from backend.app import report_ingestion + + +class _ComparisonConnection: + """Minimal asyncpg-shaped fixture for one comparison grouping.""" + + def __init__(self) -> None: + self.leftover_query = "" + + async def fetch(self, query: str, *_args: Any) -> list[dict[str, Any]]: + if "from report_period_score" in query: + return [{"grouping_kind": "process_unit", "grouping_key": "PU-1", "mean_theta": 0.25, "post_count": 4, "link_method": "fixture"}] + if "from report_member_score" in query: + return [] + if "from report_leftover_pair" in query: + self.leftover_query = query + return [ + { + "grouping_kind": "process_unit", + "grouping_key": "PU-1", + "pair_kind": pair_kind, + "post_id": f"post-{index}", + "criterion_code": "criterion-a", + "leftover_distance": 1.0, + "leftover_residual": -0.5, + "leftover_map_reconstruction": None, + "leftover_map_unexplained_share": None, + "leftover_map_cross_share": cross_share, + "post_title": f"Post {index}", + "visibility_code": "public", + "corporate_entity_id": f"entity-{index}", + "has_real_source_context": True, + } + for index, (pair_kind, cross_share) in enumerate( + ( + ("closest", 0.12), + ("farthest", 0.0), + ("closest", -0.25), + ("farthest", None), + ("closest", float("nan")), + ("farthest", float("inf")), + ("closest", float("-inf")), + ), + start=1, + ) + ] + if "from report_leftover_map_coverage" in query or "from report_leftover_map_axis" in query: + return [] + raise AssertionError(f"unexpected query: {query}") + + +def test_period_comparison_transports_persisted_cross_share(monkeypatch) -> None: + """Preserve finite signed x values and normalize null or non-finite x to null.""" + + async def _label(_conn: Any, _kind: str, _key: str) -> str: + return "Process unit 1" + + monkeypatch.setattr(report_ingestion, "resolve_grouping_label", _label) + connection = _ComparisonConnection() + payload = asyncio.run(report_ingestion.fetch_period_comparison(connection, "2026-W02")) + assert "lp.leftover_map_cross_share" in connection.leftover_query + assert [pair["leftover_map_cross_share"] for pair in payload[0]["leftover_pairs"]] == [ + 0.12, + 0.0, + -0.25, + None, + None, + None, + None, + ] diff --git a/backend/tests/test_period_report_cross_share_postgres.py b/backend/tests/test_period_report_cross_share_postgres.py new file mode 100644 index 000000000..389cbc2a3 --- /dev/null +++ b/backend/tests/test_period_report_cross_share_postgres.py @@ -0,0 +1,91 @@ +"""PostgreSQL regression for primary-report cross-share JSON safety.""" + +from __future__ import annotations + +import asyncio +import json +import math +import uuid + +import asyncpg +import jwt +import psycopg2 +import pytest + +from backend.app.report_ingestion import fetch_period_reports +from backend.tests.test_api import seeded_db as _seeded_db_fixture +from scripts.seed_demo_data import _seed_demo_period_report + +seeded_db = _seeded_db_fixture + + +@pytest.fixture(scope="module") +def demo_analyst_token() -> str: + """Supply only the subject claim needed by the PostgreSQL seed fixture.""" + + return jwt.encode({"sub": str(uuid.uuid4())}, key="", algorithm="none") + + +def test_fetch_period_reports_normalizes_persisted_nonfinite_cross_share( + seeded_db, +) -> None: + """PostgreSQL non-finite numerics become null while finite signs survive.""" + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + "insert into process_unit (corporate_entity_id, process_unit_code, process_unit_name) " + "select corporate_entity_id, 'TEST-PU-FINITE-REPORT', 'Finite report unit' " + "from source_post where post_id = %s returning process_unit_id", + (seeded_db["own_private_post_id"],), + ) + process_unit_id = cur.fetchone()[0] + cur.execute( + "select author_account_id, corporate_entity_id from source_post where post_id = %s", + (seeded_db["own_private_post_id"],), + ) + author_id, corp_id = cur.fetchone() + _seed_demo_period_report(cur, author_id, corp_id, process_unit_id) + cur.execute( + "select grouping_kind, grouping_key, post_id, pair_kind " + "from report_leftover_pair where period_code = '2026-W02' " + "order by grouping_kind, grouping_key, pair_kind, post_id" + ) + pair_rows = cur.fetchall() + assert len(pair_rows) >= 7, pair_rows + injected = [0.12, 0.0, -0.25, None, "NaN", "Infinity", "-Infinity"] + for index, (kind, key, post_id, pair_kind) in enumerate(pair_rows): + cur.execute( + "update report_leftover_pair set leftover_map_cross_share = %s " + "where grouping_kind = %s and grouping_key = %s " + "and period_code = '2026-W02' and pair_kind = %s and post_id = %s", + (injected[index % len(injected)], kind, key, pair_kind, post_id), + ) + finally: + admin_conn.close() + + async def _read_reports(): + conn = await asyncpg.connect(seeded_db["dsn"]) + try: + return [ + await fetch_period_reports(conn, kind, "2026-W02") + for kind in ("process_unit", "corporate_entity", "thread_group") + ] + finally: + await conn.close() + + payloads = asyncio.run(_read_reports()) + shares = [ + pair["leftover_map_cross_share"] + for payload in payloads + for report in payload + for pair in report["leftover_pairs"] + ] + assert shares + assert all(share is None or math.isfinite(share) for share in shares) + assert 0.12 in shares + assert 0.0 in shares + assert -0.25 in shares + assert None in shares + json.dumps(payloads, allow_nan=False) diff --git a/backend/tests/test_report_ingestion_cross_share_nonfinite.py b/backend/tests/test_report_ingestion_cross_share_nonfinite.py new file mode 100644 index 000000000..ce01dbb90 --- /dev/null +++ b/backend/tests/test_report_ingestion_cross_share_nonfinite.py @@ -0,0 +1,102 @@ +"""Regression for primary report cross-share JSON safety.""" + +from __future__ import annotations + +import asyncio +import json +from datetime import datetime, timezone +from decimal import Decimal + +from backend.app import report_ingestion + + +class _PrimaryReportConnection: + """Minimal asyncpg-compatible boundary for one persisted report pair.""" + + def __init__(self, cross_share: Decimal | None) -> None: + self.cross_share = cross_share + + async def fetch(self, query: str, *_args: object) -> list[dict[str, object]]: + if "from report_period_score" in query: + return [ + { + "grouping_kind": "thread_group", + "grouping_key": "synthetic-thread", + "period_code": "2026-W02", + "rubric_version": report_ingestion.RUBRIC_VERSION, + "selected_model": "rasch", + "mean_theta": 0.1, + "mean_theta_sd": 0.2, + "post_count": 2, + "item_count": 1, + "fit_loglik": -1.0, + "fit_converged": True, + "calibration_score": 0.9, + "computed_at": datetime(2026, 1, 5, tzinfo=timezone.utc), + "link_method": "synthetic", + "anchor_period_code": None, + "delta_mean_theta": None, + } + ] + if "from report_member_score" in query: + return [] + if "from report_item_information" in query: + return [] + if "from report_leftover_pair" in query: + assert "lp.leftover_map_cross_share" in query + return [ + { + "grouping_key": "synthetic-thread", + "pair_kind": "closest", + "post_id": "00000000-0000-0000-0000-000000000001", + "post_title": "Synthetic report post", + "criterion_code": "synthetic_criterion", + "leftover_distance": 0.5, + "leftover_residual": 0.4, + "observed_response": None, + "expected_response": None, + "leftover_map_rank": None, + "leftover_map_unexplained": None, + "leftover_map_cross_share": self.cross_share, + "leftover_map_reconstruction": None, + "leftover_map_unexplained_share": None, + "leftover_map_explained_share": None, + "leftover_map_person_axis_1": None, + "leftover_map_person_axis_2": None, + "leftover_map_item_axis_1": None, + "leftover_map_item_axis_2": None, + "visibility_code": "public", + "corporate_entity_id": "00000000-0000-0000-0000-000000000002", + "process_unit_id": None, + "has_real_source_context": False, + } + ] + if "from report_leftover_map_axis" in query: + return [] + if "from report_leftover_map_coverage" in query: + return [] + raise AssertionError(f"unexpected report query: {query}") + + +def test_fetch_period_reports_normalizes_only_nonfinite_cross_share() -> None: + """Finite signed values survive; non-finite persisted values become JSON-safe null.""" + cases = ( + (Decimal("0.42"), 0.42), + (Decimal("0"), 0.0), + (Decimal("-0.24"), -0.24), + (None, None), + (Decimal("NaN"), None), + (Decimal("Infinity"), None), + (Decimal("-Infinity"), None), + ) + for persisted, expected in cases: + payload = asyncio.run( + report_ingestion.fetch_period_reports( + _PrimaryReportConnection(persisted), # type: ignore[arg-type] + "thread_group", + "2026-W02", + ) + ) + actual = payload[0]["leftover_pairs"][0]["leftover_map_cross_share"] + assert actual == expected + json.dumps(payload, allow_nan=False) diff --git a/backend/tests/test_report_ingestion_unexplained_share.py b/backend/tests/test_report_ingestion_unexplained_share.py index 0c6f6dff3..d17a50458 100644 --- a/backend/tests/test_report_ingestion_unexplained_share.py +++ b/backend/tests/test_report_ingestion_unexplained_share.py @@ -27,6 +27,7 @@ async def fetch(self, query: str, *_args: object) -> list[dict[str, object]]: "leftover_distance": 0.5, "leftover_residual": 0.4, "leftover_map_reconstruction": Decimal("0.25"), + "leftover_map_cross_share": None, "visibility_code": "public", "corporate_entity_id": "00000000-0000-0000-0000-000000000002", "has_real_source_context": False, diff --git a/docker-compose.yml b/docker-compose.yml index d0a2422aa..da5c1c415 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -86,7 +86,11 @@ services: KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak KC_DB_USERNAME: ${POSTGRES_USER:-lineageweave} KC_DB_PASSWORD: ${POSTGRES_PASSWORD:-lineageweave_dev_only} - KC_HOSTNAME: localhost + # Pin the full public URL, not a bare host. Keycloak 26's hostname v2 + # derives the ``iss`` claim from the request host, so a bare "localhost" + # makes an in-network call to keycloak:8080 mint an issuer the backend + # rejects. A full URL here keeps one issuer for every caller. + KC_HOSTNAME: http://localhost:${KEYCLOAK_PORT:-18080} KC_HOSTNAME_STRICT: "false" KC_HTTP_ENABLED: "true" KC_HEALTH_ENABLED: "true" @@ -98,8 +102,11 @@ services: condition: service_healthy orchestrator: - # Consume the paper-grounded orchestration service from main; inference - # remains behind its authenticated OpenAI-compatible boundary. + # LLM/vision is an optional product capability. The default profile must + # remain runnable without provider credentials, matching .env.example; + # enabling this profile consumes the canonical contextual-orchestrator + # boundary instead of creating a LineageWeave-local provider path. + profiles: ["llm"] build: context: ./docker/contextual-orchestrator dockerfile: Dockerfile @@ -129,11 +136,6 @@ services: interval: 5s timeout: 3s retries: 10 - # Warm-up window: failures inside start_period do not consume the retry - # budget, so a booting orchestrator that becomes healthy within 50s is - # never counted against retries. A dead service trips the gate at - # ~100s (start_period + 10 x 5s), matching the previous retries: 20 - # budget exactly; the win is boot tolerance, not faster dead detection. start_period: 50s backend: @@ -142,16 +144,10 @@ services: dockerfile: backend/Dockerfile environment: DATABASE_URL: postgresql://${POSTGRES_USER:-lineageweave}:${POSTGRES_PASSWORD:-lineageweave_dev_only}@postgres:5432/${POSTGRES_DB:-lineageweave} - # Internal DNS name for JWKS fetches (always reachable from inside the - # compose network); KEYCLOAK_ISSUER is the *external*, host-published - # URL a browser/token actually carries -- see backend/app/config.py. KEYCLOAK_BASE_URL: http://keycloak:8080 KEYCLOAK_ISSUER: http://localhost:${KEYCLOAK_PORT:-18080}/realms/lineageweave-demo KEYCLOAK_REALM: lineageweave-demo KEYCLOAK_CLIENT_ID: lineageweave-frontend - # Production may set these to the real Keyverse OIDC provider. Empty - # values keep this stack on its explicit local Keycloak development mode; - # no Keyverse-shaped identity service is created by Compose. KEYVERSE_ISSUER: ${KEYVERSE_ISSUER:-} KEYVERSE_CLIENT_ID: ${KEYVERSE_CLIENT_ID:-} KEYVERSE_AUDIENCE: ${KEYVERSE_AUDIENCE:-} @@ -168,11 +164,10 @@ services: OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-lineageweave} OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-} # Empty by default: every LLM/vision channel stays the Null client - # (dropped, not faked). Set these to a running contextual-orchestrator - # to turn the channels on. Provider credentials use LLM_GATEWAY_API_URL / - # LLM_GATEWAY_API_KEY in the orchestrator's private env file. - ORCHESTRATOR_BASE_URL: ${ORCHESTRATOR_BASE_URL:-http://orchestrator:8000} - ORCHESTRATOR_API_KEY: ${ORCHESTRATOR_API_KEY:-${CONTEXTUAL_ORCHESTRATOR_TOKEN:-lineageweave-orchestrator-dev-only}} + # (dropped, not faked). Opt into the llm Compose profile and point these + # at its contextual-orchestrator service to turn the channels on. + ORCHESTRATOR_BASE_URL: ${ORCHESTRATOR_BASE_URL:-} + ORCHESTRATOR_API_KEY: ${ORCHESTRATOR_API_KEY:-} SEARXNG_BASE_URL: http://searxng:8080 TEPP_TRANSPORT_URL: ${TEPP_TRANSPORT_URL:-} TEPP_API_KEY: ${TEPP_API_KEY:-} @@ -180,8 +175,6 @@ services: NARUON_CALENDAR_BASE_URL: ${NARUON_CALENDAR_BASE_URL:-} NARUON_CALENDAR_SERVICE_TOKEN: ${NARUON_CALENDAR_SERVICE_TOKEN:-} RANKWEAVE_DISABLED: ${RANKWEAVE_DISABLED:-} - # Process HMAC for ontology source-window continuation. Empty keeps the - # truncated-without-cursor contract. Never reuse an OIDC or orchestrator secret. ONTOLOGY_SOURCE_CURSOR_SECRET: ${ONTOLOGY_SOURCE_CURSOR_SECRET:-} ports: - "${BACKEND_PORT:-18420}:8000" @@ -190,8 +183,6 @@ services: condition: service_healthy database_migration: condition: service_completed_successfully - orchestrator: - condition: service_healthy keycloak: condition: service_started valkey: @@ -222,17 +213,13 @@ services: OIDC_JWKS_URI: ${OIDC_JWKS_URI:-} OIDC_CLOCK_SKEW_SECONDS: ${OIDC_CLOCK_SKEW_SECONDS:-5} VALKEY_URL: redis://valkey:6379/0 - ORCHESTRATOR_BASE_URL: ${ORCHESTRATOR_BASE_URL:-http://orchestrator:8000} - ORCHESTRATOR_API_KEY: ${ORCHESTRATOR_API_KEY:-${CONTEXTUAL_ORCHESTRATOR_TOKEN:-lineageweave-orchestrator-dev-only}} - # Local Keycloak mints this exact fixed audience. Production Keyverse - # deployments configure both values together outside this demo stack. + ORCHESTRATOR_BASE_URL: ${ORCHESTRATOR_BASE_URL:-} + ORCHESTRATOR_API_KEY: ${ORCHESTRATOR_API_KEY:-} MCP_RESOURCE_URL: http://localhost:18001/mcp MCP_AUDIENCE: http://localhost:18001/mcp MCP_ALLOWED_HOSTS: localhost:*,127.0.0.1:*,mcp:8001 MCP_ALLOWED_ORIGINS: ${MCP_ALLOWED_ORIGINS:-} MCP_MAX_REQUEST_BYTES: ${MCP_MAX_REQUEST_BYTES:-65536} - # No guessed quota: operators must supply values justified by the k6 - # capacity artifact for their deployment before enabling this profile. MCP_RATE_LIMIT_REQUESTS: ${MCP_RATE_LIMIT_REQUESTS:-} MCP_RATE_LIMIT_WINDOW_SECONDS: ${MCP_RATE_LIMIT_WINDOW_SECONDS:-} ports: @@ -242,8 +229,6 @@ services: condition: service_healthy database_migration: condition: service_completed_successfully - orchestrator: - condition: service_healthy keycloak: condition: service_started valkey: diff --git a/frontend/e2e/grouping-comparison-cross-share.spec.ts b/frontend/e2e/grouping-comparison-cross-share.spec.ts new file mode 100644 index 000000000..4618d56d6 --- /dev/null +++ b/frontend/e2e/grouping-comparison-cross-share.spec.ts @@ -0,0 +1,89 @@ +import { devices, expect, test, type Page } from "@playwright/test"; +import { loginAsDemoAdmin } from "./support/auth.ts"; + +const CROSS_SHARE = "2R̂U/R² -0.24"; +const CROSS_SHARE_NAME = /2R̂U\/R² -0\.24/; +const PIXEL_7 = devices["Pixel 7"]; + +async function openGroupingComparison(page: Page) { + await loginAsDemoAdmin(page); + await page.locator(".language-switcher select").selectOption("en"); + await page.getByRole("button", { name: "게시판" }).click(); + + const advancedTools = page.locator("details.advanced-review-tools"); + await expect(advancedTools).toBeVisible(); + if (!(await advancedTools.getAttribute("open"))) { + await advancedTools.locator("summary").click(); + } + + const comparison = page.getByLabel("Grouping comparison"); + await expect(comparison).toBeVisible(); + return comparison; +} + +async function expectNoHorizontalOverflow(page: Page) { + expect( + await page.evaluate( + "document.documentElement.scrollWidth <= Math.ceil(window.innerWidth) + 1", + ), + ).toBe(true); +} + +test("keeps persisted cross-share actionable in the rendered accessibility tree", async ({ page }) => { + const comparison = await openGroupingComparison(page); + const pair = comparison.getByRole("button", { name: CROSS_SHARE_NAME }).first(); + + await expect(pair).toBeVisible(); + await expect(pair).toContainText(CROSS_SHARE); + await expect(pair.getByText(CROSS_SHARE, { exact: true })).toHaveAttribute("aria-hidden", "true"); + + // Pointer hit-testing is part of acceptance, but a trial click avoids changing + // the report state before keyboard and responsive checks run on the same node. + await pair.hover(); + await pair.click({ trial: true }); + + await pair.focus(); + await expect(pair).toBeFocused(); + await page.keyboard.press("Shift+Tab"); + await page.keyboard.press("Tab"); + await expect(pair).toBeFocused(); + + await page.setViewportSize({ width: 390, height: 844 }); + await expect(pair).toBeVisible(); + await expectNoHorizontalOverflow(page); + + // Exercise every locale the exact product head exposes. When the canonical + // translation-ledger owner adds ES/DE/FR, this loop covers them without a + // LineageWeave-local locale fork. + const localeSelect = page.locator(".language-switcher select"); + const locales = await localeSelect.locator("option").evaluateAll((options) => + options.map((option) => option.getAttribute("value") ?? ""), + ); + expect(locales).toEqual(expect.arrayContaining(["en", "ko", "zh", "ja", "vi"])); + for (const locale of locales) { + await localeSelect.selectOption(locale); + await expect(comparison.getByRole("button", { name: CROSS_SHARE_NAME }).first()).toBeVisible(); + await expectNoHorizontalOverflow(page); + } +}); + +test.describe("touch interaction", () => { + // `defaultBrowserType` is worker-scoped, so a describe-local override must + // apply only Pixel 7 browser-context options or Playwright aborts collection. + test.use({ + userAgent: PIXEL_7.userAgent, + viewport: PIXEL_7.viewport, + deviceScaleFactor: PIXEL_7.deviceScaleFactor, + isMobile: PIXEL_7.isMobile, + hasTouch: PIXEL_7.hasTouch, + }); + + test("keeps the cross-share pair tappable on a phone viewport", async ({ page }) => { + const comparison = await openGroupingComparison(page); + const pair = comparison.getByRole("button", { name: CROSS_SHARE_NAME }).first(); + + await expect(pair).toBeVisible(); + await pair.tap({ trial: true }); + await expectNoHorizontalOverflow(page); + }); +}); diff --git a/frontend/e2e/support/auth.ts b/frontend/e2e/support/auth.ts index 5f3740ade..241eb054d 100644 --- a/frontend/e2e/support/auth.ts +++ b/frontend/e2e/support/auth.ts @@ -1,27 +1,26 @@ import type { Page } from "@playwright/test"; -/** - * Synthetic demo credentials seeded by `make seed` -- never a real account. - * See `backend/tests/test_api.py`'s `_fetch_demo_analyst_token` for the - * same login this drives through the real Keycloak realm. - */ -const DEMO_USERNAME = "demo.analyst"; +/** Synthetic demo identities seeded by the local product stack; never real accounts. */ const DEMO_PASSWORD = "lineageweave-demo-only"; +type DemoUsername = "demo.analyst" | "demo.admin"; -/** - * Logs in through the real Keycloak-hosted login form (OIDC redirect, - * not a token injected into storage) so the e2e suite exercises the same - * authorization-code flow a reader actually goes through. - * - * Next action: call this once per test before interacting with any - * authenticated destination. - */ -export async function loginAsDemoAnalyst(page: Page): Promise { +/** Exercise the real Keycloak authorization-code form instead of injecting a token. */ +async function loginAsDemoUser(page: Page, username: DemoUsername): Promise { await page.goto("/"); await page.getByRole("button", { name: "Log in" }).click(); await page.waitForURL(/\/realms\/lineageweave-demo\/protocol\/openid-connect\/auth/); - await page.getByLabel("Username or email").fill(DEMO_USERNAME); + await page.getByLabel("Username or email").fill(username); await page.getByLabel("Password", { exact: true }).fill(DEMO_PASSWORD); await page.getByRole("button", { name: "Sign In" }).click(); await page.waitForURL((url) => !url.pathname.includes("/realms/")); } + +/** Log in with the ABAC-narrowed synthetic analyst identity. */ +export async function loginAsDemoAnalyst(page: Page): Promise { + await loginAsDemoUser(page, "demo.analyst"); +} + +/** Log in with the synthetic product-admin identity used by advanced report tools. */ +export async function loginAsDemoAdmin(page: Page): Promise { + await loginAsDemoUser(page, "demo.admin"); +} diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index a3fb286f5..d69dc2e36 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -2,10 +2,12 @@ import { defineConfig, devices } from "@playwright/test"; /** * Runs against the already-running docker-compose stack (`make up`), not a - * dev-server Playwright starts itself -- the app needs Postgres, Keycloak, - * Valkey, and the orchestrator alongside it, which `webServer` can't provide. - * Point `LINEAGEWEAVE_E2E_BASE_URL` at a different origin if the compose - * port mapping changes. + * dev-server Playwright starts itself -- authenticated product paths need + * Postgres, Keycloak, Valkey, backend, and frontend together. LLM/vision is a + * separate optional Compose profile; browser contracts that do not invoke it + * must not require provider credentials just to boot. Point + * `LINEAGEWEAVE_E2E_BASE_URL` at a different origin if the compose port mapping + * changes. */ export default defineConfig({ testDir: "./e2e", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index fb9e4befd..239a5a221 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -929,6 +929,7 @@ describe("App, authenticated", () => { leftover_map_reconstruction: 0.248, leftover_map_explained_share: 0.76, leftover_map_unexplained_share: 0.02, + leftover_map_cross_share: -0.24, }, ], leftover_map_coverage: { @@ -4404,6 +4405,14 @@ describe("App, authenticated", () => { name: /open leftover closest pair from comparison: public post.*leftover map comparison unexplained leftover share U²\/R² 0\.02/i, }); expect(unexplainedSharePair).toHaveTextContent("U²/R² 0.02"); + const comparisonCrossSharePair = screen.getByRole("button", { + name: /open leftover closest pair from comparison: public post.*2R̂U\/R² -0\.24/i, + }); + expect(comparisonCrossSharePair).toHaveTextContent("2R̂U/R² -0.24"); + expect(within(comparisonCrossSharePair).getByText("2R̂U/R² -0.24")).toHaveAttribute( + "aria-hidden", + "true", + ); await waitFor(() => expect(fetchMock).toHaveBeenCalledWith( expect.stringContaining("/api/reports/thread_group/2026-W02"), diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e959ea751..90e99e380 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -155,6 +155,7 @@ import { formatLeftoverMapUnexplainedShare, LEFTOVER_MAP_COMPARE_UNEXPLAINED_SHARE_LABEL, } from "./leftoverMapUnexplainedShare"; +import { formatLeftoverMapCrossShare } from "./leftoverMapCrossShare"; import { leftoverMapCompareAxisShare, leftoverMapCompareAxisSingular, @@ -4116,6 +4117,9 @@ function ReportsPanel({ const unexplainedShare = formatLeftoverMapUnexplainedShare( pair.leftover_map_unexplained_share, ); + const crossShare = formatLeftoverMapCrossShare( + pair.leftover_map_cross_share, + ); const pairAccessibleName = `Open leftover ${pair.pair_kind} pair from comparison: ${pair.post_title} · ${criterion}${ reconstruction ? ` · ${t(LEFTOVER_MAP_COMPARE_RECONSTRUCTION_LABEL)} ${reconstruction}` @@ -4128,7 +4132,7 @@ function ReportsPanel({ unexplainedShare ? ` · ${t(LEFTOVER_MAP_COMPARE_UNEXPLAINED_SHARE_LABEL)} ${unexplainedShare}` : "" - }`; + }${crossShare ? ` · ${crossShare}` : ""}`; return (
  • ) : null} + {crossShare ? ( + + ) : null}
  • ); diff --git a/frontend/src/components/WorkspaceNav.css b/frontend/src/components/WorkspaceNav.css new file mode 100644 index 000000000..d5a1ba651 --- /dev/null +++ b/frontend/src/components/WorkspaceNav.css @@ -0,0 +1,33 @@ +/* The legacy shell stylesheet hides the workspace GNB below 768px for a drawer + that is not implemented. Keep the same semantic navigation and product tools + reachable on phones instead of rendering inaccessible controls off-screen. */ +@media (max-width: 768px) { + .workspace-gnb.workspace-gnb-responsive { + display: flex; + flex-wrap: wrap; + align-items: stretch; + height: auto; + min-height: var(--gnb-height); + padding: 0.5rem 1rem; + gap: 0.25rem 0.5rem; + } + + .workspace-gnb-responsive .workspace-gnb-item { + flex: 1 1 calc(50% - 0.25rem); + justify-content: center; + min-height: var(--size-control-min); + height: auto; + padding: 0.5rem 0.75rem; + } + + .workspace-gnb-responsive .workspace-gnb-tools { + flex: 1 0 100%; + width: 100%; + margin-left: 0; + } + + .workspace-gnb-responsive .language-switcher, + .workspace-gnb-responsive .language-switcher select { + width: 100%; + } +} diff --git a/frontend/src/components/WorkspaceNav.tsx b/frontend/src/components/WorkspaceNav.tsx index 933bde9f5..f793b9cac 100644 --- a/frontend/src/components/WorkspaceNav.tsx +++ b/frontend/src/components/WorkspaceNav.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import { ANALYST_GNB_ITEMS, type AnalystGnbId } from "../gnbChrome"; import { t } from "../i18n"; +import "./WorkspaceNav.css"; export type WorkspaceDestination = AnalystGnbId | "admin"; @@ -12,7 +13,10 @@ export type WorkspaceNavProps = { export function WorkspaceNav({ destination, onChange, tools }: WorkspaceNavProps) { return ( -