diff --git a/.agents/skills/klicker-frontend-ui/SKILL.md b/.agents/skills/klicker-frontend-ui/SKILL.md index 0b1e6af0f3..b2202e1dfd 100644 --- a/.agents/skills/klicker-frontend-ui/SKILL.md +++ b/.agents/skills/klicker-frontend-ui/SKILL.md @@ -43,7 +43,7 @@ Conventions (design system, Tailwind v4, Apollo, i18n, CSP): [docs/frontend-conv ## App boundaries - `frontend-manage` (lecturer), `frontend-pwa` (student; also has a localforage offline side-channel for live-quiz answers — don't bypass `storageHelpers.ts`), `frontend-control` (mobile controller), `auth` (login flows — auth changes also need [docs/auth-model.md](../../../docs/auth-model.md)). -- Knowledge-base management is a reusable package mounted by `frontend-manage`: edit `packages/kb-management`, not duplicate app-local components. Verify `/resources/knowledgeBases` plus the detail route at desktop and mobile widths, both locales, and every changed empty/active/success/failure state. +- Knowledge-base management is a reusable package mounted by `frontend-manage`: edit `packages/kb-management`, not duplicate app-local components. The detail resource workspace uses a semantic metadata table, one `+ Add resource` chooser for Website/Document with Video disabled until supported, and an overflow menu for destructive row actions. Preserve the explicit `ADDED`-before-Ingest lifecycle and keep the chooser non-dismissible after a file upload ticket is requested until confirmation or terminal failure. Verify `/resources/knowledgeBases` plus the detail route at desktop and mobile widths, both locales, and every changed empty/active/success/failure state. - The KB navigation item is an interim `user.privatePreview` discovery gate. Direct catalog/detail URLs must render the localized `KB_PREVIEW_ACCESS_REQUIRED` service error for a non-preview lecturer; never rely on hidden navigation as authorization. - The knowledge-resource Ingest action accepts only the resource identifier. Do not expose transport tuning in the UI unless the GraphQL and ingestion-platform contracts add a real user-controlled setting. - Keep full KB attempt history out of the two-second detail poll. Load the bounded, owner-checked history query only when a lecturer expands a resource, while the parent query carries only the latest run needed for operation status. diff --git a/.agents/skills/klicker-playwright-e2e/SKILL.md b/.agents/skills/klicker-playwright-e2e/SKILL.md index c6bb2817d5..611643c2d7 100644 --- a/.agents/skills/klicker-playwright-e2e/SKILL.md +++ b/.agents/skills/klicker-playwright-e2e/SKILL.md @@ -44,6 +44,7 @@ never download browsers into a DevPod. ```bash # auto-detects routed worktrees / plain devcontainer / host-run apps bash util/run-host-e2e.sh --project=chromium tests/A-login.spec.ts +bash util/run-host-e2e.sh --project=chromium tests/Y-kb-management-ux.spec.ts # inspect the resolved URL + database mapping without running anything bash util/run-host-e2e.sh --print diff --git a/.devcontainer/README.md b/.devcontainer/README.md index 86315b08fd..d7345f06f9 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -169,7 +169,12 @@ replacement. HTTP readiness remains in `devrouter ensure .`; the root build script forces production mode even though the live container exports `NODE_ENV=development`. Rerun ensure after `pnpm run build` so stale Next.js dev output can trigger the single -container-recreate budget. +container-recreate budget. The repository runtime guard also fingerprints +dependencies, clears only the five owned `.next/dev` directories on each true +managed start, and checks semantic readiness for each Next.js app. If a route +returns the known stale `404` HTML response, it requests one bounded full-cache +repair for that app and rechecks the apps; unexpected responses fail closed +without deleting caches. The image also carries uv `0.11.12` and selects Python 3.12, matching the analytics image and lint CI so the root quality gate runs inside the container. diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 463bf05789..bd500e3e3d 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -32,6 +32,7 @@ retry() { echo "[post-create] Installing dependencies (pnpm)..." pnpm install --no-frozen-lockfile +bash ./util/dev-runtime.sh stamp-dependencies # Build the workspace PACKAGES (graphql, prisma, util, markdown, transactional, # types, i18n, ...) the apps import — turbo orders them by their dep graph, and diff --git a/.devcontainer/post-start.sh b/.devcontainer/post-start.sh index 4831dad2c2..71f4c312b8 100755 --- a/.devcontainer/post-start.sh +++ b/.devcontainer/post-start.sh @@ -124,12 +124,66 @@ done # Run every routed app, both Hatchet workers, and both internal MCP services # without Infisical. Devrouter owns generic locking, process-group identity, # and bounded replacement; this repository owns only the application command -# and environment above. -"$DEVROUTER_PROCESS_HELPER" ensure \ - --name klicker-dev \ - --match 'turbo run dev' \ - --log /tmp/dev.log \ - -- pnpm run dev:container +# and environment above. The runtime wrapper detects dependency changes and +# repairs confirmed stale Next.js dev output once before failing closed. +start_managed_runtime() { + local runtime_fingerprint runtime_generation + + runtime_fingerprint="$(bash ./util/dev-runtime.sh fingerprint)" + runtime_generation="$(bash ./util/dev-runtime.sh generation)" + "$DEVROUTER_PROCESS_HELPER" ensure \ + --name klicker-dev \ + --match 'turbo run dev' \ + --log /tmp/dev.log \ + -- bash ./util/dev-runtime.sh start "$runtime_fingerprint" "$runtime_generation" \ + -- pnpm run dev:container +} + +start_managed_runtime + +STALE_NEXT_APPS=() +run_readiness_pass() { + local app status=0 + + STALE_NEXT_APPS=() + for app in auth chat frontend-control frontend-manage frontend-pwa; do + status=0 + bash ./util/dev-runtime.sh wait-app "$app" || status=$? + if [ "$status" -eq 20 ]; then + STALE_NEXT_APPS+=("$app") + elif [ "$status" -ne 0 ]; then + echo "[post-start] ERROR: $app failed semantic readiness; no cache cleanup was attempted." >&2 + echo '[post-start] Inspect /tmp/dev.log for the application failure.' >&2 + return "$status" + fi + done + + if [ "${#STALE_NEXT_APPS[@]}" -gt 0 ]; then + echo "[post-start] Confirmed stale Next.js route state: ${STALE_NEXT_APPS[*]}." >&2 + return 20 + fi + return 0 +} + +READINESS_STATUS=0 +run_readiness_pass || READINESS_STATUS=$? +if [ "$READINESS_STATUS" -eq 20 ]; then + echo "[post-start] Repairing the confirmed stale .next caches once: ${STALE_NEXT_APPS[*]}." + for app in "${STALE_NEXT_APPS[@]}"; do + bash ./util/dev-runtime.sh request-repair "$app" + done + start_managed_runtime + + READINESS_STATUS=0 + run_readiness_pass || READINESS_STATUS=$? + if [ "$READINESS_STATUS" -ne 0 ]; then + echo '[post-start] ERROR: The runtime remained unhealthy after its one repair attempt.' >&2 + echo '[post-start] Inspect /tmp/dev.log; no further cache cleanup was attempted.' >&2 + exit 1 + fi +elif [ "$READINESS_STATUS" -ne 0 ]; then + exit 1 +fi if [ -s /etc/devrouter/mkcert-rootCA.pem ]; then cat < router.push('/resources/knowledgeBases'), - data: { cy: 'knowledge-bases' }, - }, - ] - : []), { key: 'knowledge-bases-item', type: 'link' as const, @@ -131,7 +120,7 @@ function Header({ user }: { user?: User | null }): React.ReactElement { key: 'library-menubar-item', label: t('manage.general.library'), onClick: () => router.push('/'), - active: router.pathname == '/', + active: router.pathname === '/', data: { cy: 'library' }, }, { @@ -139,7 +128,7 @@ function Header({ user }: { user?: User | null }): React.ReactElement { key: 'activities-menubar-item', label: t('shared.generic.activities'), onClick: () => router.push('/activities'), - active: router.pathname == '/activities', + active: router.pathname === '/activities', data: { cy: 'activities' }, }, { @@ -147,7 +136,7 @@ function Header({ user }: { user?: User | null }): React.ReactElement { key: 'courses-menubar-item', label: t('manage.general.courses'), onClick: () => router.push('/courses'), - active: router.pathname == '/courses', + active: router.pathname === '/courses', data: { cy: 'courses' }, }, @@ -158,7 +147,7 @@ function Header({ user }: { user?: User | null }): React.ReactElement { icon: faBolt, active: router.pathname.startsWith('/resources/knowledgeBases') || - router.pathname == '/resources/answerCollections' || + router.pathname === '/resources/answerCollections' || router.pathname === '/resources/chatbots' || router.pathname === '/resources/catalog' || router.pathname === '/resources/userGroups' || @@ -295,7 +284,7 @@ function Header({ user }: { user?: User | null }): React.ReactElement { type: 'link', label: t('shared.generic.logout'), onClick: () => - router.push(process.env.NEXT_PUBLIC_AUTH_URL + '/logout'), + router.push(`${process.env.NEXT_PUBLIC_AUTH_URL}/logout`), data: { cy: 'logout' }, }, ], diff --git a/docs/frontend-conventions.md b/docs/frontend-conventions.md index 0a424d7725..9cd7a3b4ae 100644 --- a/docs/frontend-conventions.md +++ b/docs/frontend-conventions.md @@ -84,6 +84,8 @@ The lecturer routes `apps/frontend-manage/src/pages/resources/knowledgeBases.tsx The catalog uses server search and cursor-driven “load more” rather than loading all owned KBs. The detail page keeps metadata/metrics separate from `packages/kb-management/src/components/KnowledgeBaseResourceList.tsx:KnowledgeBaseResourceList`, which owns server search plus design-system type/status filters, selection, confirmed bulk deletion, the source inspector, and contextual Ingest/Retry/Re-ingest/Delete actions. While any loaded row is `QUEUED`/`PROCESSING`, the two-second interval fetches page zero plus pages known to contain active rows and runs a full loaded-window walk every tenth tick. Cursor or page-length drift triggers an immediate full walk. Promise-only polls use `ApolloClient.query` with `no-cache`; generation fencing, the latest loaded-count ref, and shared cache merge preserve the loaded window and remove rows from selection when they become active. Show indeterminate real-operation progress and safe-to-leave messaging rather than invented percentages. +The detail resource workspace presents that loaded window as a semantic design-system table with source, operation, serving, update, selection, and contextual action metadata. Use one `+ Add resource` action that opens the chooser for Website or Document; keep Video visibly unavailable until its ingestion contract exists. Keep destructive resource deletion in the row overflow menu, and preserve the explicit inspector Ingest action for resources that are only `ADDED`. When a file upload has requested its ticket, keep the chooser open and non-dismissible until upload confirmation or a terminal failure. + The inspector loads the owner-checked five-attempt history lazily. Full attempt history must stay outside the two-second list poll. Lecturer-facing failure detail is localized from stable status/error codes; raw platform messages are not rendered. Transport tuning is not user-controlled. Changes must preserve EN/DE messages, `data-cy` hooks, keyboard/focus behavior, and browser evidence for desktop plus 390 px mobile states, including search/filter, selection/confirmation, empty, active, ready, failed, and replacement-cutover feedback where affected. KB and resource deletion dialogs explain the two observable phases: the item disappears immediately, while stored files and the external index are removed in the background. Success toasts confirm removal from the lecturer view without claiming that external cleanup has already completed. diff --git a/docs/getting-started.md b/docs/getting-started.md index fa9169f470..d3be7a4494 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -87,6 +87,8 @@ The image does include the repository's development toolchain: pnpm `11.5.0`, uv `devrouter doctor --repo .` is the static check. `devrouter ensure .` is the runtime authority: it resolves the checkout-specific overlay and fails unless the actual container aliases, Git mount, managed process, and routes agree. +The managed adapter adds a repository-owned semantic readiness guard. It fingerprints dependencies, keeps dependency changes from reusing stale Next.js dev output, and repairs confirmed stale `404` HTML responses once per startup. Unexpected responses fail closed without cache deletion. Run `pnpm run dev:doctor` for the read-only runtime diagnosis and `pnpm run test:dev-runtime` for the shell-level guard checks. + ### Path B: Host-based Setup (Legacy) Runs all services on your host machine. Needs Traefik (`*.klicker.com` reverse proxy), mkcert, `/etc/hosts` configurations, and Infisical for secret injection. diff --git a/package.json b/package.json index 4faee1908c..59c0a65350 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "dev:cleverreach": "./util/_run_with_infisical.sh --env dev-cleverreach pnpm run dev:raw", "dev:container": "turbo run dev --filter=@klicker-uzh/backend-docker --filter=@klicker-uzh/auth --filter=@klicker-uzh/frontend-pwa --filter=@klicker-uzh/frontend-manage --filter=@klicker-uzh/frontend-control --filter=@klicker-uzh/olat-api --filter=@klicker-uzh/response-api --filter=@klicker-uzh/lti-service --filter=@klicker-uzh/chat --filter=@klicker-uzh/hatchet-worker-general --filter=@klicker-uzh/hatchet-worker-response-processor --filter=@klicker-uzh/mcp-lecturer --filter=@klicker-uzh/mcp-student --concurrency 30", "dev:docs": "turbo run dev:docs", + "dev:doctor": "bash ./util/dev-runtime.sh doctor", "dev:lti": "./util/_run_with_infisical.sh --env dev-lti turbo run dev:lti", "dev:offline": "./util/_run_with_infisical.sh --env dev turbo run dev:offline --concurrency 30", "dev:playwright": "./util/_run_with_infisical.sh --env dev-playwright bash ./util/_with_local_test_origins.sh cross-env NODE_ENV=test turbo run dev:test --concurrency 30", @@ -100,6 +101,7 @@ "syncpack:mismatches": "syncpack list-mismatches", "syncpack:mismatches:fix": "syncpack fix-mismatches", "syncpack:update": "syncpack update", + "test:dev-runtime": "bash ./util/test-dev-runtime.sh", "test:run": "turbo run test:run", "test:run:playwright": "pnpm --filter @klicker-uzh/playwright test:run", "test:watch": "run-p --npm-path pnpm test dev:playwright" diff --git a/packages/i18n/messages/de.ts b/packages/i18n/messages/de.ts index 756e50e11c..a2194d7811 100644 --- a/packages/i18n/messages/de.ts +++ b/packages/i18n/messages/de.ts @@ -1540,6 +1540,7 @@ Da die KlickerUZH-App noch nicht im iOS-App-Store verfügbar ist, folgen Sie die '{resources, plural, one {# Ressource} other {# Ressourcen}} · {chatbots, plural, one {# verknüpfter Chatbot} other {# verknüpfte Chatbots}}', loadMore: 'Weitere Wissensdatenbanken laden', notFound: 'Die Wissensdatenbank konnte nicht gefunden werden.', + detailFallbackTitle: 'Wissensdatenbank', backToList: 'Zurück zu den Wissensdatenbanken', metricsTitle: 'Nutzung und Verknüpfungen', metricVisibleResources: 'Sichtbare Ressourcen', @@ -1574,6 +1575,8 @@ Da die KlickerUZH-App noch nicht im iOS-App-Store verfügbar ist, folgen Sie die linkSuccess: 'Link wurde zur Wissensdatenbank hinzugefügt.', linkError: 'Der Link konnte nicht hinzugefügt werden.', resourcesTitle: 'Ressourcen', + resourceColumn: 'Ressource', + resourceActions: 'Aktionen', resourcesLoadError: 'Die Ressourcen konnten nicht geladen werden.', searchResources: 'Ressourcen suchen', searchResourcesPlaceholder: 'Titel, Dateiname oder URL suchen', @@ -1582,6 +1585,18 @@ Da die KlickerUZH-App noch nicht im iOS-App-Store verfügbar ist, folgen Sie die filterAll: 'Alle', typeFile: 'Datei', typeUrl: 'Link', + addResource: 'Ressource hinzufügen', + addResourceTitle: 'Ressource hinzufügen', + addResourceDescription: + 'Wählen Sie, wie Sie diese Ressource hinzufügen möchten.', + addWebsite: 'Website', + addWebsiteDescription: 'Eine Website-URL zur Verarbeitung registrieren.', + addDocument: 'Dokument', + addDocumentDescription: 'Eine PDF-, TXT- oder Markdown-Datei hochladen.', + addVideo: 'Video', + comingSoon: 'Demnächst verfügbar', + configure: 'Konfigurieren', + backToResourceTypes: 'Zurück', noResourceResults: 'Keine Ressourcen entsprechen diesen Filtern.', resourceResultCount: '{count, plural, =0 {Keine Ressourcen} one {# Ressource} other {# Ressourcen}}', @@ -1589,6 +1604,9 @@ Da die KlickerUZH-App noch nicht im iOS-App-Store verfügbar ist, folgen Sie die selectResource: '„{title}“ auswählen', loadMoreResources: 'Weitere Ressourcen laden', noResources: 'Es wurden noch keine Ressourcen hinzugefügt.', + emptyResourceHint: + 'Verwenden Sie oben «Ressource hinzufügen», um eine Website oder ein Dokument hinzuzufügen.', + updatedAtLabel: 'Aktualisiert', updatedAt: 'Aktualisiert {date}', statusAdded: 'Hinzugefügt', statusQueued: 'In Warteschlange', diff --git a/packages/i18n/messages/en.ts b/packages/i18n/messages/en.ts index 6b87c94e69..e57f12c8ad 100644 --- a/packages/i18n/messages/en.ts +++ b/packages/i18n/messages/en.ts @@ -1531,6 +1531,7 @@ Since the KlickerUZH app is not yet available in the iOS App Store, follow these '{resources, plural, one {# resource} other {# resources}} · {chatbots, plural, one {# connected chatbot} other {# connected chatbots}}', loadMore: 'Load more knowledge bases', notFound: 'The knowledge base could not be found.', + detailFallbackTitle: 'Knowledge base', backToList: 'Back to knowledge bases', metricsTitle: 'Usage and connections', metricVisibleResources: 'Visible resources', @@ -1563,6 +1564,8 @@ Since the KlickerUZH app is not yet available in the iOS App Store, follow these linkSuccess: 'Link added to the knowledge base.', linkError: 'The link could not be added.', resourcesTitle: 'Resources', + resourceColumn: 'Resource', + resourceActions: 'Actions', resourcesLoadError: 'The resources could not be loaded.', searchResources: 'Search resources', searchResourcesPlaceholder: 'Search title, filename or URL', @@ -1571,6 +1574,17 @@ Since the KlickerUZH app is not yet available in the iOS App Store, follow these filterAll: 'All', typeFile: 'File', typeUrl: 'Link', + addResource: 'Add resource', + addResourceTitle: 'Add a resource', + addResourceDescription: 'Choose how you want to add this resource.', + addWebsite: 'Website', + addWebsiteDescription: 'Register a website URL for ingestion.', + addDocument: 'Document', + addDocumentDescription: 'Upload a PDF, TXT or Markdown file.', + addVideo: 'Video', + comingSoon: 'Coming soon', + configure: 'Configure', + backToResourceTypes: 'Back', noResourceResults: 'No resources match these filters.', resourceResultCount: '{count, plural, =0 {No resources} one {# resource} other {# resources}}', @@ -1578,6 +1592,8 @@ Since the KlickerUZH app is not yet available in the iOS App Store, follow these selectResource: 'Select “{title}”', loadMoreResources: 'Load more resources', noResources: 'No resources have been added yet.', + emptyResourceHint: 'Use Add resource above to add a website or document.', + updatedAtLabel: 'Updated', updatedAt: 'Updated {date}', statusAdded: 'Added', statusQueued: 'Queued', diff --git a/packages/kb-management/src/KnowledgeBaseDetail.tsx b/packages/kb-management/src/KnowledgeBaseDetail.tsx index 5a3bbe9f0b..b11e99f3e9 100644 --- a/packages/kb-management/src/KnowledgeBaseDetail.tsx +++ b/packages/kb-management/src/KnowledgeBaseDetail.tsx @@ -1,13 +1,12 @@ import { useQuery } from '@apollo/client' import { GetKbDocument } from '@klicker-uzh/graphql/dist/ops' -import { H2, Skeleton, UserNotification } from '@uzh-bf/design-system' +import { H1, Skeleton, UserNotification } from '@uzh-bf/design-system' import { useFormatter, useTranslations } from 'next-intl' import Link from 'next/link' -import React, { useState } from 'react' +import React, { useRef, useState } from 'react' +import KnowledgeBaseAddResourceModal from './components/KnowledgeBaseAddResourceModal' import KnowledgeBaseChatbotBindings from './components/KnowledgeBaseChatbotBindings' -import KnowledgeBaseFileDropzone from './components/KnowledgeBaseFileDropzone' import KnowledgeBaseResourceList from './components/KnowledgeBaseResourceList' -import KnowledgeBaseUrlForm from './components/KnowledgeBaseUrlForm' import KnowledgeGraphPanel from './components/KnowledgeGraphPanel' import { getGraphQLErrorCode } from './graphqlError' @@ -15,43 +14,48 @@ function KnowledgeBaseDetail({ kbId }: { kbId: string }) { const t = useTranslations() const format = useFormatter() const [resourceRefreshKey, setResourceRefreshKey] = useState(0) + const [addResourceOpen, setAddResourceOpen] = useState(false) + const addResourceTriggerRef = useRef(null) const { data, loading, error, refetch } = useQuery(GetKbDocument, { variables: { id: kbId }, }) if (loading) { return ( -
-
+ + ) + + return embedded ? ( +
{content}
+ ) : ( +
+ {content}
) } diff --git a/packages/kb-management/src/components/KnowledgeBaseResourceList.tsx b/packages/kb-management/src/components/KnowledgeBaseResourceList.tsx index 9a9e405e47..4e0491639d 100644 --- a/packages/kb-management/src/components/KnowledgeBaseResourceList.tsx +++ b/packages/kb-management/src/components/KnowledgeBaseResourceList.tsx @@ -1,37 +1,47 @@ import { + type ApolloQueryResult, NetworkStatus, useApolloClient, useLazyQuery, useMutation, useQuery, - type ApolloQueryResult, } from '@apollo/client' import { + faEllipsisVertical, faFileLines, faLink, + faPlus, faSpinner, } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { GetKbResourceIngestionRunsDocument, GetKbResourcesDocument, + type GetKbResourcesQuery, + type GetKbResourcesQueryVariables, IngestKbResourceDocument, KbIngestionStatus, KbResourceStatus, KbResourceType, - type GetKbResourcesQuery, - type GetKbResourcesQueryVariables, } from '@klicker-uzh/graphql/dist/ops' import { Badge, Button, - H3, + Dropdown, + H2, Modal, SelectField, + ShadcnTable, + ShadcnTableBody, + ShadcnTableCaption, + ShadcnTableCell, + ShadcnTableHead, + ShadcnTableHeader, + ShadcnTableRow, Skeleton, TextField, - UserNotification, toast, + UserNotification, } from '@uzh-bf/design-system' import { useFormatter, useTranslations } from 'next-intl' import React, { @@ -305,10 +315,12 @@ function KnowledgeBaseResourceList({ kbId, refreshKey, onMetricsChanged, + onAddResource, }: { kbId: string refreshKey: number onMetricsChanged: () => Promise + onAddResource: (trigger: HTMLElement) => void }) { const t = useTranslations() const format = useFormatter() @@ -881,18 +893,30 @@ function KnowledgeBaseResourceList({ return (
-

{t('kb.resourcesTitle')}

- {selectedIds.size > 0 ? ( +

{t('kb.resourcesTitle')}

+
- ) : null} + {selectedIds.size > 0 ? ( + + ) : null} +
@@ -997,77 +1021,101 @@ function KnowledgeBaseResourceList({ : t('kb.noResources')}

{!deferredSearch && !typeFilter && !statusFilter ? ( - +

+ {t('kb.emptyResourceHint')} +

) : null}
) : ( <> -
+

{t('kb.resourceResultCount', { count: connection?.totalCount ?? 0, })}

-
-
    - {resources.map((resource) => { - const active = isActiveResource(resource) - return ( -
  • + + {t('kb.resourcesTitle')} + + + + + + + + {t('kb.resourceColumn')} + + -
    - -
    + {t('kb.sourceType')} + + + {t('kb.operationStatus')} + + + {t('kb.servingStatus')} + + + {t('kb.updatedAtLabel')} + + + {t('kb.resourceActions')} + + + + + {resources.map((resource) => { + const active = isActiveResource(resource) + return ( + + + + +
  • - ) - })} -
+ + + ) + })} + + {connection?.pageInfo.hasNextPage ? (
+ + ) + + return embedded ? ( +
{content}
+ ) : ( + ) } diff --git a/packages/kb-management/src/components/KnowledgeGraphPanel.tsx b/packages/kb-management/src/components/KnowledgeGraphPanel.tsx index d2730bea2e..983e4e2af1 100644 --- a/packages/kb-management/src/components/KnowledgeGraphPanel.tsx +++ b/packages/kb-management/src/components/KnowledgeGraphPanel.tsx @@ -24,9 +24,9 @@ import { import type { KnowledgeGraphDataSource } from '@klicker-uzh/shared-components/src/knowledgeGraph/knowledgeGraphState' import { KnowledgeGraphUnavailableError } from '@klicker-uzh/shared-components/src/knowledgeGraph/knowledgeGraphState' import type { KnowledgeGraphResponse } from '@klicker-uzh/types' -import { Badge, Button, H3, SelectField, Switch } from '@uzh-bf/design-system' -import { useFormatter, useTranslations } from 'next-intl' +import { Badge, Button, SelectField, Switch } from '@uzh-bf/design-system' import dynamic from 'next/dynamic' +import { useFormatter, useTranslations } from 'next-intl' import React, { useEffect, useMemo, useState } from 'react' const KnowledgeGraphViewer = dynamic( @@ -213,6 +213,7 @@ function KnowledgeGraphPanel({ kbId }: { kbId: string }) { KbGraphQualityTier.Standard ) const [operationError, setOperationError] = useState(null) + const [detailsOpen, setDetailsOpen] = useState(false) const { data, loading, error, refetch, startPolling, stopPolling } = useQuery( GetKbKnowledgeGraphConfigDocument, { @@ -286,6 +287,22 @@ function KnowledgeGraphPanel({ kbId }: { kbId: string }) { released: t('kb.graphCostStatusReleased'), needsHumanReview: t('kb.graphCostStatusNeedsHumanReview'), } + let graphSummary: string + if (loading && data === undefined) { + graphSummary = t('kb.graphLoading') + } else if (error || config === undefined) { + graphSummary = t('kb.graphLoadError') + } else { + graphSummary = [ + `${t('kb.graphStatusLabel')}: ${statusLabel(config.status, statusLabels)}`, + config.isStale && hasPublishedGraph ? t('kb.graphStale') : null, + config.costStatus === KbGraphCostStatus.NeedsHumanReview + ? costStatusLabel(config.costStatus, costStatusLabels) + : null, + ] + .filter((value): value is string => Boolean(value)) + .join(' · ') + } const handleRebuild = async () => { if (isRebuilding || isActive || !config?.isEnabled) return @@ -296,7 +313,7 @@ function KnowledgeGraphPanel({ kbId }: { kbId: string }) { variables: { kbId, qualityTier: selectedTier }, }) await refetch() - } catch (mutationError) { + } catch { console.error('Failed to rebuild KB knowledge graph', { kbId }) setOperationError(t('kb.graphBuildError')) } @@ -318,222 +335,235 @@ function KnowledgeGraphPanel({ kbId }: { kbId: string }) { } return ( -
setDetailsOpen(event.currentTarget.open)} > -
-

{t('kb.graphTitle')}

-

- {t('kb.graphDescription')} -

-
- - {loading && data === undefined ? ( -

- {t('kb.graphLoading')} -

- ) : error || config === undefined ? ( -
+

{t('kb.graphTitle')}

+ -
- {t('kb.graphLoadError')} - -
-
- ) : ( - <> -
- void handleEnabledChange(enabled)} - disabled={isTogglingEnabled} - data={{ cy: 'kb-knowledge-graph-enabled' }} - /> -

- {config.isEnabled - ? t('kb.graphEnabledDescription') - : t('kb.graphDisabledDescription')} -

- {!config.costConfigurationReady ? ( -

- {t('kb.graphCostUnavailable')} -

- ) : null} -
- - setSelectedTier(value as KbGraphQualityTier) - } - disabled={ - isActive || - isRebuilding || - !config.isEnabled || - !config.costConfigurationReady - } - data={{ cy: 'kb-knowledge-graph-quality-tier' }} - /> + {graphSummary} + + + {t('kb.configure')} + + +
+

{t('kb.graphDescription')}

+ + {loading && data === undefined ? ( +

+ {t('kb.graphLoading')} +

+ ) : error || config === undefined ? ( +
+
+ {t('kb.graphLoadError')}
-

- {t('kb.graphBuildCost', { amount: formattedSelectedEstimate })} -

-
-

- - {t('kb.graphBillingLabel')}: - {' '} - {formattedBillingLabel} -

-

- - {t('kb.graphRemainingQuota')}: - {' '} - {formatMinorUnits( - format, - config.remainingSemesterQuotaMinorUnits, - config.quotaCurrency - )} -

-

- - {t('kb.graphWorstCaseBalance')}: - {' '} - {formatMinorUnits( - format, - config.worstCaseRemainingMinorUnits, - config.quotaCurrency - )} +

+ ) : ( + <> +
+ void handleEnabledChange(enabled)} + disabled={isTogglingEnabled} + data={{ cy: 'kb-knowledge-graph-enabled' }} + /> +

+ {config.isEnabled + ? t('kb.graphEnabledDescription') + : t('kb.graphDisabledDescription')}

-

- {t('kb.graphMaxCost')}:{' '} - {formatMinorUnits( - format, - config.maxCostMinorUnits, - config.quotaCurrency - )} + {!config.costConfigurationReady ? ( +

+ {t('kb.graphCostUnavailable')} +

+ ) : null} +
+ + setSelectedTier(value as KbGraphQualityTier) + } + disabled={ + isActive || + isRebuilding || + !config.isEnabled || + !config.costConfigurationReady + } + data={{ cy: 'kb-knowledge-graph-quality-tier' }} + /> + +
+

+ {t('kb.graphBuildCost', { amount: formattedSelectedEstimate })}

- {config.costStatus ? ( +

- {t('kb.graphCostStatus')}: + {t('kb.graphBillingLabel')}: {' '} - {costStatusLabel(config.costStatus, costStatusLabels)} + {formattedBillingLabel}

- ) : null} - {config.actualCostMinorUnits != null ? ( -

+

- {t('kb.graphActualCost')}: + {t('kb.graphRemainingQuota')}: {' '} {formatMinorUnits( format, - config.actualCostMinorUnits, - config.costCurrency + config.remainingSemesterQuotaMinorUnits, + config.quotaCurrency )}

- ) : null} -
- {config.actualRequestCount != null ? ( -

- {t('kb.graphActualUsage', { - requests: config.actualRequestCount, - inputTokens: config.actualInputTokens ?? 0, - outputTokens: config.actualOutputTokens ?? 0, - embeddingTokens: config.actualEmbeddingTokens ?? 0, - })} -

- ) : null} -
-
- - {t('kb.graphStatusLabel')}: - - - {statusLabel(config.status, statusLabels)} - - {config.isStale && hasPublishedGraph ? ( - {t('kb.graphStale')} +

+ + {t('kb.graphWorstCaseBalance')}: + {' '} + {formatMinorUnits( + format, + config.worstCaseRemainingMinorUnits, + config.quotaCurrency + )} +

+

+ {t('kb.graphMaxCost')}:{' '} + {formatMinorUnits( + format, + config.maxCostMinorUnits, + config.quotaCurrency + )} +

+ {config.costStatus ? ( +

+ + {t('kb.graphCostStatus')}: + {' '} + {costStatusLabel(config.costStatus, costStatusLabels)} +

+ ) : null} + {config.actualCostMinorUnits != null ? ( +

+ + {t('kb.graphActualCost')}: + {' '} + {formatMinorUnits( + format, + config.actualCostMinorUnits, + config.costCurrency + )} +

) : null}
- {config.buildId ? ( -

- {t('kb.graphBuildId', { buildId: config.buildId })} + {config.actualRequestCount != null ? ( +

+ {t('kb.graphActualUsage', { + requests: config.actualRequestCount, + inputTokens: config.actualInputTokens ?? 0, + outputTokens: config.actualOutputTokens ?? 0, + embeddingTokens: config.actualEmbeddingTokens ?? 0, + })}

) : null} +
+
+ + {t('kb.graphStatusLabel')}: + + + {statusLabel(config.status, statusLabels)} + + {config.isStale && hasPublishedGraph ? ( + {t('kb.graphStale')} + ) : null} +
+ {config.buildId ? ( +

+ {t('kb.graphBuildId', { buildId: config.buildId })} +

+ ) : null} +
-
- - {operationError ? ( -

- {operationError} -

- ) : null} -
-

- {t('kb.graphPreviewTitle')} -

- {hasPublishedGraph ? ( - - ) : ( -
- {t('kb.graphPreviewUnavailable')} + {operationError} +

+ ) : null} + + {detailsOpen ? ( +
+

+ {t('kb.graphPreviewTitle')} +

+ {hasPublishedGraph ? ( + + ) : ( +
+ {t('kb.graphPreviewUnavailable')} +
+ )}
- )} -
- - )} -
+ ) : null} + + )} +
+ ) } diff --git a/playwright/tests/Y-kb-management-ux.spec.ts b/playwright/tests/Y-kb-management-ux.spec.ts new file mode 100644 index 0000000000..e5f2899ccc --- /dev/null +++ b/playwright/tests/Y-kb-management-ux.spec.ts @@ -0,0 +1,269 @@ +import { URL_MANAGE } from '../util/constants.js' +import { expect, test } from '../util/fixtures.js' + +test.describe('Knowledge base management workspace', () => { + test('keeps the resource workspace scannable and add flow keyboard-accessible in English and German', async ({ + loginLecturer, + page, + }, testInfo) => { + await loginLecturer() + + const manageUrl = process.env.URL_MANAGE ?? URL_MANAGE + const kbName = `UX review ${Date.now()}` + const resourceTitle = `UX website ${Date.now()}` + let detailPath: string | undefined + + try { + await page.goto(`${manageUrl}/resources/knowledgeBases`) + await expect( + page.getByRole('main').getByRole('heading', { level: 1 }) + ).toBeVisible() + await expect(page.getByTestId('knowledge-base-loading')).toBeHidden() + + await page.getByTestId('create-knowledge-base').click() + await page.getByTestId('knowledge-base-name').fill(kbName) + await page.getByTestId('submit-create-knowledge-base').click() + + const knowledgeBaseLink = page + .getByRole('link') + .filter({ hasText: kbName }) + await expect(knowledgeBaseLink).toBeVisible() + detailPath = new URL( + (await knowledgeBaseLink.getAttribute('href')) ?? '', + manageUrl + ).pathname + await knowledgeBaseLink.click() + + const detail = page.getByTestId('knowledge-base-detail') + await expect(detail.getByRole('heading', { level: 1 })).toContainText( + kbName + ) + await expect(page.getByTestId('kb-metrics')).toBeVisible() + + const chatbotSettings = page.getByTestId('kb-chatbot-settings') + const graphSettings = page.getByTestId('kb-graph-settings') + await expect(chatbotSettings).not.toHaveAttribute('open') + await expect(graphSettings).not.toHaveAttribute('open') + await expect( + chatbotSettings.getByText(/Configure|Konfigurieren/) + ).toBeVisible() + await expect( + graphSettings.getByText(/Configure|Konfigurieren/) + ).toBeVisible() + + await page.getByTestId('add-kb-resource').focus() + await page.getByTestId('add-kb-resource').click() + const modal = page.getByTestId('kb-add-resource-modal') + await expect(modal).toHaveRole('dialog') + await expect(modal).toHaveAttribute( + 'aria-describedby', + 'kb-add-resource-description' + ) + await expect(modal.locator('#kb-add-resource-description')).toBeVisible() + await expect(page.getByTestId('choose-kb-resource-video')).toBeDisabled() + await expect(page.getByTestId('choose-kb-resource-website')).toBeFocused() + + const modalButtons = modal.getByRole('button') + const lastModalButton = modalButtons.last() + await page.getByTestId('choose-kb-resource-website').press('Shift+Tab') + await expect(lastModalButton).toBeFocused() + await lastModalButton.press('Tab') + await expect(page.getByTestId('choose-kb-resource-website')).toBeFocused() + + await page.getByTestId('choose-kb-resource-website').click() + await expect(page.getByTestId('kb-url-title')).toBeFocused() + await page.getByTestId('back-kb-add-resource').click() + await expect(page.getByTestId('choose-kb-resource-website')).toBeFocused() + await page.getByTestId('close-kb-add-resource-modal').click() + await expect(modal).toBeHidden() + await expect(page.getByTestId('add-kb-resource')).toBeFocused() + + let releasePendingUpload = () => {} + let signalUploadStarted = () => {} + let failNextKbMetricsRefresh = false + const pendingUpload = new Promise((resolve) => { + releasePendingUpload = resolve + }) + const uploadStarted = new Promise((resolve) => { + signalUploadStarted = resolve + }) + + await page.route('**/graphql', async (route) => { + const request = route.request() + if (request.method() !== 'POST') { + await route.continue() + return + } + + const operationName = ( + request.postDataJSON() as { operationName?: string } + ).operationName + if (operationName === 'RequestKbFileUpload') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: { + requestKbFileUpload: { + uploadSasURL: 'https://kb-upload.invalid/?sig=test', + containerName: 'kb', + blobName: 'pending.txt', + }, + }, + }), + }) + return + } + if (operationName === 'ConfirmKbFileUpload') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: { confirmKbFileUpload: { id: 'synthetic-resource' } }, + }), + }) + return + } + + if (operationName === 'GetKb' && failNextKbMetricsRefresh) { + failNextKbMetricsRefresh = false + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + errors: [{ message: 'Synthetic metrics refresh failure' }], + }), + }) + return + } + + await route.continue() + }) + await page.route('https://kb-upload.invalid/**', async (route) => { + if (route.request().method() === 'OPTIONS') { + await route.fulfill({ + status: 204, + headers: { + 'access-control-allow-headers': '*', + 'access-control-allow-methods': 'PUT, OPTIONS', + 'access-control-allow-origin': '*', + }, + }) + return + } + + signalUploadStarted() + await pendingUpload + await route.fulfill({ + status: 201, + headers: { + 'access-control-allow-origin': '*', + etag: '"synthetic-etag"', + 'last-modified': new Date(0).toUTCString(), + 'x-ms-request-id': 'synthetic-request', + 'x-ms-version': '2025-11-05', + }, + }) + }) + + await page.getByTestId('add-kb-resource').click() + await page.getByTestId('choose-kb-resource-document').click() + await page.getByTestId('kb-file-input').setInputFiles({ + name: 'pending.txt', + mimeType: 'text/plain', + buffer: Buffer.from('pending upload'), + }) + await uploadStarted + await expect(page.getByTestId('close-kb-add-resource-modal')).toHaveCount( + 0 + ) + await expect(page.getByTestId('back-kb-add-resource')).toHaveCount(0) + await page.keyboard.press('Escape') + await expect(modal).toBeVisible() + + releasePendingUpload() + await expect(modal).toBeHidden() + + await page.getByTestId('add-kb-resource').click() + await page.getByTestId('choose-kb-resource-website').click() + await page.getByTestId('kb-url-title').fill(resourceTitle) + await page + .getByTestId('kb-url') + .fill(`https://example.org/${resourceTitle.replaceAll(' ', '-')}`) + failNextKbMetricsRefresh = true + await page.getByTestId('add-kb-url-resource').click() + await expect(modal).toBeHidden() + await page.reload() + await expect(detail).toBeVisible() + + const resourceTable = page.getByRole('table') + await expect(resourceTable).toBeVisible() + await expect( + resourceTable.getByRole('columnheader', { name: /Resource|Ressource/ }) + ).toBeVisible() + await expect( + resourceTable.getByRole('columnheader', { + name: /Latest ingestion|Letzte Verarbeitung/, + }) + ).toBeVisible() + const resourceRow = resourceTable.getByRole('row').filter({ + hasText: resourceTitle, + }) + await expect(resourceRow).toBeVisible() + await expect( + resourceRow.locator('[data-cy^="kb-resource-status-"]') + ).toContainText(/Added|Hinzugefügt/) + await resourceRow.getByTestId(/inspect-kb-resource-/).click() + await expect(page.getByTestId('kb-resource-inspector')).toBeVisible() + await expect( + page.getByTestId('ingest-kb-resource-inspector') + ).toContainText(/Ingest|Verarbeiten/) + await page.getByTestId('done-kb-resource-inspector').click() + + await page.setViewportSize({ width: 1440, height: 900 }) + await page.screenshot({ + path: testInfo.outputPath('kb-management-en-desktop.png'), + fullPage: true, + }) + + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto(`${manageUrl}/de${detailPath}`) + await expect(page.getByTestId('knowledge-base-detail')).toBeVisible() + await expect(page.getByTestId('add-kb-resource')).toContainText( + 'Ressource hinzufügen' + ) + await expect( + page.getByTestId('kb-chatbot-settings').getByText('Konfigurieren') + ).toBeVisible() + await page.screenshot({ + path: testInfo.outputPath('kb-management-de-desktop.png'), + fullPage: true, + }) + } finally { + if (detailPath) { + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto(`${manageUrl}${detailPath}`) + const resourceRow = page + .getByRole('table') + .getByRole('row') + .filter({ hasText: resourceTitle }) + if (await resourceRow.count()) { + await resourceRow.getByTestId(/kb-resource-actions-/).click() + await page.getByTestId(/delete-kb-resource-/).click() + await page.getByTestId('confirm-delete-kb-resource').click() + await expect(resourceRow).toHaveCount(0) + } + + await page.goto(`${manageUrl}/resources/knowledgeBases`) + const knowledgeBaseRow = page.locator('li').filter({ hasText: kbName }) + if (await knowledgeBaseRow.count()) { + await knowledgeBaseRow + .getByRole('button', { name: /Delete|Löschen/ }) + .click() + await page.getByTestId('confirm-delete-knowledge-base').click() + await expect(knowledgeBaseRow).toHaveCount(0) + } + } + } + }) +}) diff --git a/project/2026-08-24-pr-5540-kb-management-ux-plan.md b/project/2026-08-24-pr-5540-kb-management-ux-plan.md new file mode 100644 index 0000000000..88d3c58a03 --- /dev/null +++ b/project/2026-08-24-pr-5540-kb-management-ux-plan.md @@ -0,0 +1,362 @@ +# Knowledge Base management UX audit and improvement roadmap + +Status: implementation complete; desktop browser proof and Sol final review passed; publication update pending + +Date: 2026-08-24 + +Base snapshot: `1d57f4f11a65698b72916de7c6f14c66422f9293` + +Plan branch: `rs/kb-management-ux` + +PR base: `feat/kb-graph-lifecycle` + +Ultimate target: `v3-ai` + +PR: [#5540](https://github.com/uzh-bf/klicker-uzh/pull/5540) + +Related history: published #5424 (`feat/kb-graph-lifecycle`); no sibling PR changes are in scope. + +## Goal + +Make Knowledge Base management fast to scan and safe to operate. A lecturer should be able to answer three questions immediately: + +1. What is in this Knowledge Base? +2. Is each resource usable by the AI, and what needs attention? +3. How do I add or remove one resource? + +The recommended direction is to make resources the primary workspace, present them in a metadata-rich table, and provide one `+ Add resource` entry point. Website, document upload, and future video support then become choices in one modal instead of separate stacked forms and repeated links. + +## Scope and method + +### In scope + +- The authenticated lecturer flow for the Knowledge Base catalog and detail page. +- Empty and populated states. +- Resource creation, resource search/filtering, inspection, ingestion status, and deletion affordances. +- The surrounding chatbot-binding and knowledge-graph controls insofar as they affect hierarchy and cognitive load. +- English and German desktop behavior at 1440×900. +- A limited keyboard, focus, landmark, and heading check. + +Mobile layout, full WCAG conformance, ingestion correctness, graph quality, chatbot behavior, and production deployment are outside the accepted completion scope. The earlier mobile findings remain historical audit evidence for a separate shared Manage-shell follow-up. + +### Evidence + +The primary audit used the running local branch above with the seeded delegated lecturer fixture and `agent-browser` 0.32.0 in headless Chrome. Screenshots are private and remain uncommitted in `/private/tmp/kb-v3-ai-ux-audit-2026-08-24/`. + +| State | Evidence | What it establishes | +| --- | --- | --- | +| Empty catalog | `05-kb-catalog.png` | The catalog has a clear empty state and a single create action. | +| Empty detail | `06-kb-detail.png` | The detail page introduces metrics, two creation surfaces, chatbot bindings, graph controls, and resources in sequence. | +| Populated detail top | `07-kb-detail-populated.png` | The primary add-file and add-link forms occupy the first working area before the resource list. | +| Lower detail and graph | `13-kb-detail-resources-correct.png` | Chatbot and graph configuration appear before the resource workspace; graph controls can expose unavailable cost configuration. | +| Resource controls | `14-kb-detail-resources-visible.png`, `15-kb-resource-card.png` | Search, type/status filters, selection, two status panels, Inspect, and prominent Delete are all available. | +| Resource inspector | `16-resource-inspector.png` | The current inspector exposes useful metadata but duplicates close affordances and needs a stronger modal focus contract. | +| Mobile detail top | `17-kb-detail-mobile-top.png` | The global header does not reflow at 390px; navigation is clipped and the detail starts with four stacked metric cards and an upload panel. | +| Mobile resource workspace | `19-kb-detail-mobile-resources.png`, `20-kb-detail-mobile-resources-header.png` | Resources are reachable only after a long scroll and each card repeats substantial status and action content vertically. | +| Environment issue | `01-kb-page-initial.png` | The first attempt hit a missing local Manage process and returned 502. This is not a product UX finding. The managed audit process was restored before judging the UI. | + +The audit combined one familiarization pass, a second evidence-capture pass, source inspection, and an independent Sol review. No destructive action, successful ingestion, model call, or external provider call was required. A document upload could not be confirmed because the local Blob request failed before confirmation. + +### Existing strengths + +- The catalog title, create action, search, result count, and compact resource/chatbot metrics are easy to identify. +- The detail page uses consistent spacing, labels, borders, and status language. +- Resource search, type/status filters, empty results, selection limits, bulk deletion, inspection, and background-operation messaging are already present. +- URL syntax validation is inline and uses `aria-invalid` and an associated error message for malformed input. +- Destructive operations require confirmation, and Escape cancels the tested dialogs. + +The earlier Knowledge Base work already provides useful data and lifecycle behavior. This plan changes the presentation and entry points first; it does not propose replacing the ledger-only graph lifecycle or inventing a separate Knowledge Base graph-version lifecycle. The core package preserves the current `BLOB`/`URL` resource model, the four existing metrics, explicit post-creation ingestion, and the current server-backed cursor/polling behavior. + +## Findings register + +Severity uses 0–4: 0 cosmetic, 1 minor friction, 2 material friction, 3 serious task/accessibility barrier, 4 blocking or unsafe. + +### F1 — The global header is fixed-width at mobile size + +Severity: 3 + +Evidence: `17-kb-detail-mobile-top.png`; independent Sol pass. + +At 390px the document is wider than the viewport. The navigation visibly truncates at “Resour…”, while Analytics and account controls are offscreen. This makes orientation and access to other areas unreliable and fails the responsive reflow expectation. + +Source anchor: `apps/frontend-manage/src/components/common/Header.tsx:309-330`. + +This is a real usability issue, but it crosses the KB package boundary. It is therefore a proposed separate Manage-shell follow-up, not a prerequisite for the KB detail redesign. + +### F2 — Dialog focus and description behavior is inconsistent + +Severity: 3 + +The tested create, inspector, and delete dialogs did not reliably move focus into the dialog, trap Tab within it, or return focus to the exact opening trigger. After Escape, focus could remain on the background or land on an unrelated control. The inspector also showed duplicate close affordances, and the browser console reported missing dialog description wiring. + +Evidence: `16-resource-inspector.png`; keyboard spot check; independent Sol pass. + +Source anchors: `packages/kb-management/src/components/CreateKnowledgeBaseModal.tsx:43-57`, `packages/kb-management/src/components/KnowledgeBaseResourceList.tsx:1197-1213`. + +The add-resource chooser must meet the repository’s existing modal contract. A broad shared-dialog repair belongs in a separate task if the current design-system `Modal` cannot satisfy the contract without cross-application changes. + +### F3 — Client URL validation does not express the server’s safety policy + +Severity: 2 + +The client accepts any HTTP(S) URL, while the server rejects private or local targets. A rejected but syntactically valid URL therefore reaches submission and produces a generic failure instead of a field-level explanation and correction path. The same recovery pattern should be checked for upload failures: preserve the selected file context, explain what failed, and offer retry without forcing the user to rediscover the task. + +This finding comes from the independent partner pass and source inspection; the main audit did not probe private targets. It should be verified against the approved server policy before implementation. Structured policy errors are a separate API/product-contract follow-up, not a reason to expand this UI-only redesign. + +Source anchors: `packages/kb-management/src/components/KnowledgeBaseUrlForm.tsx:9-16,40-56`, `packages/kb-management/src/components/KnowledgeBaseFileDropzone.tsx:81-99`, `packages/i18n/messages/en.ts:1551`, and the current URL-policy contract at `docs/domain-model.md:62`. + +### F4 — The primary resource task is buried and each resource card is over-composed + +Severity: 2 + +The detail order is metrics, file upload, URL form, chatbot bindings, graph controls, and only then Resources. On mobile, Resources begins roughly 2,200px down a page of about 3,037px in the captured fixture. Each resource then repeats operation status, serving status, timestamps, Inspect, and a prominent red Delete button. The page asks the user to understand implementation-oriented state before they can scan the resource inventory. + +Evidence: `07-kb-detail-populated.png`, `13-kb-detail-resources-correct.png`, `14-kb-detail-resources-visible.png`, `15-kb-resource-card.png`, `17-kb-detail-mobile-top.png`, `19-kb-detail-mobile-resources.png`, `20-kb-detail-mobile-resources-header.png`. + +Source anchors: `packages/kb-management/src/KnowledgeBaseDetail.tsx:101-201`, `packages/kb-management/src/components/KnowledgeBaseResourceList.tsx:1037-1177`. + +### F5 — Website and video resources are not distinguishable in the current model + +Severity: 2 + +The URL form is presented as “Add a link”. A YouTube resource is rendered as the same generic Link type and uses the same icon/filter path as a website. Users cannot scan or filter the inventory by the actual content type they care about. The current model supports only `BLOB` and `URL`, so the core redesign must not relabel existing URLs as Video. Video remains an announced “Coming soon” option until a backend media-type contract exists. + +Evidence: `19-kb-detail-mobile-resources.png`, `20-kb-detail-mobile-resources-header.png`; independent Sol pass. + +Source anchors: `packages/kb-management/src/components/KnowledgeBaseUrlForm.tsx:75-76`, `packages/kb-management/src/components/KnowledgeBaseResourceList.tsx:918-923,1229-1232`. + +### F6 — Page structure gives assistive technology weak orientation + +Severity: 2 + +Both the catalog and detail content begin with an `H2`, while the shared layout provides a content `
` rather than a `
` landmark. The visual page can look understandable while the semantic page outline remains weak. + +Source anchors: `packages/kb-management/src/KnowledgeBaseManager.tsx:64-75`, `packages/kb-management/src/KnowledgeBaseDetail.tsx:87-100`, `apps/frontend-manage/src/components/Layout.tsx:84-100`. + +### F7 — Resource creation has multiple competing entry points + +Severity: 2 + +The detail page exposes separate upload and link panels near the top, while the empty resource state repeats two links that jump back to those panels. This creates duplicated navigation and forces a choice between controls that belong to one conceptual action: adding a resource. It also makes the page grow as new resource types are added. + +Evidence: `07-kb-detail-populated.png`, `14-kb-detail-resources-visible.png`; source anchors `packages/kb-management/src/KnowledgeBaseDetail.tsx:185-194` and `packages/kb-management/src/components/KnowledgeBaseResourceList.tsx:989-1016`. + +## Review scores + +These are directional scores for this limited audit, not quality gates. F1, F3, and real Video classification remain important follow-ups even though they are outside the core KB-local package. + +- UX heuristics: 6/10. Navigation clarity loses points for F1. Helpful errors loses a point for F3. The “nothing makes me stop and think” heuristic loses a point for the combined F4/F5/F7 presentation and taxonomy friction. +- Refactoring UI: 9/10. Seven of eight diagnostic areas pass in the current implementation; visual hierarchy fails because F4/F5/F7 make the working inventory secondary and visually noisy. +- Accessibility: not a single conformance score. The limited check found reflow risk under WCAG 1.4.10, focus-order risk under 2.4.3, weak landmark/heading structure under 1.3.1 and 2.4.1, and dialog-description warnings. A dedicated accessibility pass remains necessary after redesign. + +## Recommended information architecture + +Use one page with one obvious primary workspace: + +```text +Knowledge Base name and description [ + Add resource ] +Compact summary: resources | storage | pending cleanup | linked consumers + +Resources Search Filters +Semantic data table + +Secondary configuration collapsed by default + Chatbot access expandable + Knowledge graph expandable +``` + +The summary should answer health questions without four large cards. Keep the existing visible-resource, storage, pending-cleanup, and linked-consumer metrics; do not invent an aggregate “AI-ready” metric. Resources should be the first substantial section after identity and summary. Chatbot and graph configuration should remain available but should not compete with the inventory. + +## Improvement roadmap + +The sequence below keeps existing GraphQL mutations and lifecycle behavior wherever possible. It is intentionally presentation-first and should be delivered in small, independently testable slices. + +### R0 — Agree the resource workspace contract + +Addresses: F4, F5, F7; anchors `KnowledgeBaseDetail.tsx:101-201` and `KnowledgeBaseResourceList.tsx:881-1016`. + +Freeze the table columns, current type taxonomy, status vocabulary, action hierarchy, and modal states before changing layout. The current API model supports `BLOB` and `URL`; use File and Link/Web resource as the initial visible types and reserve Video for the unavailable future option. Do not client-sort only the loaded subset or render clickable sortable headers until a server-side ordering contract exists. The repository has `packages/shared-components/src/DataTable.tsx:27`, but it currently owns local sorting/pagination and does not provide the required server-backed load-more, selection, polling, or responsive-column contract. Prefer local semantic design-system table primitives in the KB package unless a narrowly scoped extension is approved. + +The recommended initial row model is: + +| Column | Purpose | +| --- | --- | +| Select | Existing bulk-selection behavior, including the 50-item limit. | +| Type | File or Link/Web resource now, with Video reserved as “Coming soon”. | +| Resource | Title plus filename or hostname. | +| Ingestion | Latest run status, version, and a short actionable failure state. | +| AI availability | Whether the resource is currently available to the AI. | +| Updated | Last resource update. | +| Actions | Inspect as the primary action; overflow menu for ingest/retry/delete. | + +Acceptance: product/design sign-off on these columns, the File/Link labels, the unavailable Video treatment, and the existing explicit post-creation ingestion behavior. Estimate: 0.5–1 day if the existing resource fields are sufficient; no API expansion is part of the core package. + +### R1 — Reorder the detail page and prepare one creation entry point + +Addresses: F4, F7, F6. + +- Add a page-level `H1` and a `
` landmark. +- Keep the back link, name, and description as the identity block. +- Replace the four large metric cards with a compact summary strip. Keep the visible-resource, storage, pending-cleanup, and linked-consumer values; move secondary cleanup/storage detail into an expandable “Capacity details” view if it is not immediately actionable. +- Put Resources directly below the identity and summary. +- Keep the existing creation forms reachable during this transitional slice; remove them and the empty-state jump links only when R3 supplies the working chooser. +- Move Chatbots and Knowledge graph into collapsed secondary sections or a clearly labeled configuration area below the resource workspace. + +Acceptance: at 1440×900 the resource workspace header is visible after the identity block; at 390×844 the resource workspace is reachable in the first meaningful scroll; no creation path disappears before R3 lands. R2 supplies the table rows after this placement slice. Estimate: 1–2 days if the existing sections can be reordered without data-flow changes. + +### R2 — Replace resource cards with a metadata table + +Addresses: F4, F5, F6; source `KnowledgeBaseResourceList.tsx:1037-1177`. + +Use local semantic design-system table primitives in the KB package. Do not reuse `packages/shared-components/src/DataTable.tsx` unchanged: its local sorting/pagination model does not match the resource connection’s server search, cursor load-more, selection, and polling contract. Do not force every desktop column into the 390px layout: preserve Type, Resource, AI availability, and the primary action, and expose the remaining metadata in a row detail/inspector. + +Keep existing search, type/status filters, result count, load-more, selection, bulk delete, polling, and inspector behavior. Reduce the row to one compact status line, use badges or short labels instead of two nested panels, and make Delete an overflow action with confirmation rather than a peer to Inspect. + +Acceptance: a lecturer can scan 20 resources without opening cards; each row exposes type, identity, ingestion state, AI availability, and update time; keyboard selection and bulk deletion retain their current limits; server search, cursor load-more, polling, and active-row fencing remain intact; mobile has no horizontal scroll and does not hide the primary action. Estimate: 2–3 days if local semantic primitives are sufficient, 4–6 days if responsive semantics and selection must be built locally. + +### R3 — Add one `+` resource modal + +Addresses: F5, F7. + +The `+ Add resource` button opens a short choice dialog with three options: + +- **Add website** — available now; opens the existing title/URL form inside the modal or a modal step. +- **Upload document** — available now; opens the existing dropzone and shows accepted formats, size limit, progress, retry, and confirmation states. Cancellation is limited to before an upload ticket is requested unless a separate cleanup contract is approved. +- **Add video** — presented as an announced unavailable “Coming soon” option until the backend contract exists; do not classify or migrate existing URL resources. + +The modal should not auto-ingest unless that behavior is explicitly accepted as the product contract. After creation, show the new row and offer the next action, such as “Ingest”, from the row or inspector. Preserve title and URL/file context on recoverable errors. The upload ticket reserves quota for up to 15 minutes after request (`packages/graphql/src/services/knowledge.ts:1244`); cancellation after that boundary is a lifecycle change, not presentation polish. + +Acceptance: the detail page has one add button and no duplicate creation panels; every current creation path is reachable in at most two deliberate steps; the existing modal contract focuses the first meaningful control, traps focus, and returns focus to `+ Add resource`; the Video option is announced as unavailable; successful creation lands in the table without a page reset and remains `ADDED` until explicit ingestion. The current explicit `ADDED`-then-ingest behavior is anchored at `packages/prisma/src/prisma/schema/knowledge.prisma:9-15,97`, `packages/graphql/src/services/knowledge.ts:1450-1462,1499-1506`, and `packages/kb-management/src/components/KnowledgeBaseResourceList.tsx:754-799`. Estimate: 2–3 days if the existing mutations and forms are retained. + +### R4 — De-emphasize secondary graph and chatbot configuration + +Addresses: the remaining hierarchy and option overload observed in `KnowledgeBaseDetail.tsx:195-196`, `KnowledgeBaseChatbotBindings.tsx:96-202`, and `KnowledgeGraphPanel.tsx:353-430`. + +Keep these controls available, but show a compact summary and an explicit “Configure” disclosure rather than a full configuration surface on every detail load. A collapsed graph section must still expose active polling and queued, processing, failed, stale, and cost-review states. When graph cost configuration is unavailable, explain the prerequisite next to the disabled build action and avoid presenting a wall of empty values. + +Keep the approved ledger-only graph-to-question flow. Every `KBGraphBuild` attempt is one durable row in the append-only build ledger; its status and accounting fields settle in place, while `activeGraphBuildId` and `publishedGraphBuildId` remain the only liveness pointers. This slice must not change GraphQL operations, publication rules, pointers, or create another version identity. See `docs/adr/0017-graph-build-ledger-is-canonical.md:15` and `packages/kb-management/src/components/KnowledgeGraphPanel.tsx:216`. + +Acceptance: a user managing resources can complete the common scan/add/inspect path without passing through chatbot or graph controls; configuration remains discoverable; active graph states do not disappear when collapsed; no graph lifecycle behavior changes. Estimate: 1–2 days after R1, with no API changes expected. + +### R5 — KB-local accessibility, regression proof, and documentation + +Addresses: F2 and F6, plus the affected core slices. + +- Add a KB-local page-level `H1` and `
` to both the catalog and detail surfaces without broadening the shared Manage layout change. Include `packages/kb-management/src/KnowledgeBaseManager.tsx` in the affected target files. +- Use the repository’s existing modal contract for the chooser and existing dialogs: focus the first meaningful control, contain Tab, close on Escape, return focus to the exact trigger, provide an accessible name and description, and render one close control. If the shared `Modal` cannot satisfy this without cross-application work, pause and split X1 below. +- Add a reproducible keyboard matrix for catalog create, add-resource choice, website form, upload flow, inspector, and delete confirmation. Screenshots establish layout only; they do not prove focus behavior. +- Add focused Playwright coverage because the current repository has no KB-management Playwright spec. Verify English and German changed-string parity, 1440×900 and 390×844, plus 320 CSS pixels for the narrowest reflow check. This is not a broad localization audit. +- Record durable keyboard results for each dialog in the new spec or its review evidence: focus entry, Tab containment, Escape cancellation, and exact trigger restoration. Screenshots establish layout only. +- Update `docs/frontend-conventions.md` and `.agents/skills/klicker-frontend-ui/SKILL.md` in the implementation PR when the new table/modal pattern becomes a durable repository convention. + +Acceptance: `scrollWidth` equals the viewport width at 390px and 320px; the KB page has one H1 and one main landmark; focus never escapes an open modal and returns to the opening control; table headers, row/action names, status announcements, and chooser descriptions are exposed; focused tests and browser evidence cover empty, populated, active, ready, failed, and modal states. Estimate: 1–2 days for KB-local work, plus time for any separately authorized shared primitive repair. + +### X1/X2 — Separate follow-ups outside the core KB redesign + +- **X1 Manage shell and shared dialog remediation** — F1 and any F2 failure that cannot be solved within the KB package. Repair the global header at small widths and the shared dialog implementation only under a separate task with Manage-wide browser proof. Do not expand the KB PR into an application-shell refactor. +- **X2 URL policy errors and real Video type** — F3 and the real portion of F5. Confirm the server URL policy and stable GraphQL error contract, then add field-level policy errors and a true media-type taxonomy in a separate API/UI change. Until then, keep the current `BLOB`/`URL` model and show Video only as unavailable “Coming soon”. + +## Proposed execution contract + +The user approved R0 and the named S1–S5 local implementation work in this task. The current goal authorizes scoped code edits, local commits, browser verification, and required read-only reviews; it does not authorize X1/X2, pushes, PR updates, merges, deploys, or shell/API follow-ups outside the named runtime checks. + +If the user approves R0 and the implementation package, the main session is the execution orchestrator and the user remains the product/authority boundary owner. Use a new task branch such as `rs/kb-management-ux` from the exact published #5424 snapshot `77ab853f697b8ffdeb2f9956fd387deb2e6eccb1` or the later base the user names. The proposed delivery is one cohesive UX PR targeting `v3-ai`, stacked on #5424 only while that remains the intended integration base; it must not touch sibling question-generation PRs. Do not implement the redesign on `rs/kb-v3-ai-finalization`, which remains the #5424 finalization worktree. + +Approval ratifies R0 and the named S1–S5 local work only; it does not authorize X1/X2, push, merge, deploy, or any graph/API contract change. The current #5424 audit runtime is deliberately kept running for user testing under its separate worktree; do not stop it while this goal uses it for browser verification. At implementation handoff, stopping and verifying the exact implementation runtime is terminal work unless the user explicitly keeps it running; runtime/worktree deletion remains separately authorized. + +### Slices and ownership + +| Slice | Owner | Dependency | Acceptance | +| --- | --- | --- | --- | +| S1 Resource-first page shell | Main session | R0 contract | Existing four metrics are compacted; Resources precedes configuration; KB-local H1/main; existing creation forms remain reachable until S3. | +| S2 Metadata table | KB implementation executor | S1 | Semantic responsive table preserves server search, cursor load-more, polling, selection, inspector, and actions. | +| S3 Unified add-resource chooser | KB implementation executor | S2 | One `+`; Website and Document use existing mutations; duplicate forms/links are removed only after the chooser works; Video is announced unavailable; creation remains explicit `ADDED` until ingestion. | +| S4 Secondary configuration | Main session | S1–S3 | Chatbot/graph summaries reduce clutter without changing graph polling, pointers, publication rules, or ledger lifecycle. | +| S5 Integrated proof and docs | Main session | S1–S4 | Focused Playwright checks, mandatory browser evidence, package checks, final review, wiki/skill updates, and a clean scoped diff. | +| X1/X2 follow-ups | Separate authorized task | Independent contracts | Manage shell/shared modal or API/media-type changes are planned and verified separately. | + +Execution-tier skip reason for S1: main retains this slice because it spans the catalog/detail semantic shell and the detail-section ordering that S2/S3 must integrate against; delegating it would add coupling at the critical path. + +### Target files and documentation + +The core implementation should stay in the reusable package: + +- `packages/kb-management/src/KnowledgeBaseDetail.tsx` +- `packages/kb-management/src/KnowledgeBaseManager.tsx` +- `packages/kb-management/src/components/KnowledgeBaseResourceList.tsx` +- `packages/kb-management/src/components/KnowledgeBaseFileDropzone.tsx` +- `packages/kb-management/src/components/KnowledgeBaseUrlForm.tsx` +- a new KB-local add-resource chooser component if needed +- `project/screenshots/kb-management-ux---.png` and a durable keyboard-results matrix in the implementation evidence +- `packages/i18n/messages/en.ts` and `packages/i18n/messages/de.ts` +- a new focused spec under `playwright/tests/` for KB catalog/detail flows +- `docs/frontend-conventions.md` and `.agents/skills/klicker-frontend-ui/SKILL.md` when the new pattern is durable + +Do not modify `apps/frontend-manage/src/components/common/Header.tsx`, shared modal primitives, GraphQL schemas, Prisma models, or graph lifecycle code in the core package. Those are X1/X2 or explicitly out of scope. + +### Verification, review, and terminal conditions + +Per slice: inspect the diff, run the affected package checks and formatting, run the focused browser/test proof, obtain the applicable simplifier and slice review, update this plan’s Progress section, and create one conventional commit for the slice. After integration, run the final reviewer before presenting the package as complete. + +Required checks include the affected package typechecks, repository formatting/lint checks, focused GraphQL regression coverage, the new KB Playwright spec in English and German, and browser screenshots at 1440×900. Keep the existing resource lifecycle tests and graph ledger tests unchanged unless a presentation-only selector or assertion requires a narrow update. The boundary owner must confirm any new policy error, media type, upload-ticket cleanup, or shared primitive change before implementation proceeds. + +Pause before implementation or between slices for a material product/API decision, a need to change the current `BLOB`/`URL` contract, a shared Manage-shell or modal change, a graph lifecycle change, a destructive/external action, unavailable required credentials, or a verification blocker that remains after distinct safe approaches. The plan terminates after local checks, browser evidence, required reviews, documentation updates, a clean scoped diff, and stopping/verifying the exact implementation runtime unless explicitly kept running. Publishing, merging, deploying, and runtime/worktree deletion remain separate authorities. + +## Progress + +- [x] Live desktop/mobile audit completed against the exact local snapshot. +- [x] Independent Sol UX audit completed; strengths, findings, and roadmap corrections integrated. +- [x] Planner review completed; scope, existing contracts, table primitive, sequencing, evidence traceability, and execution boundaries corrected. +- [x] Independent Sol final review completed after corrections; roadmap approved with no remaining concerns. +- [x] User approved the KB-local redesign package and implementation goal; local execution is authorized through S5. +- [x] S1 resource-first shell implemented locally: KB catalog/detail use semantic `main`/`H1` landmarks, metrics are compact metadata, and Resources precedes creation/configuration. `@klicker-uzh/kb-management` check and targeted formatting pass. +- [x] S2 metadata table implemented locally: the resource cards are now a semantic design-system table with responsive metadata, server-backed filters/search/load-more/polling, selection, inspector, deletion, and ingestion behavior preserved. `@klicker-uzh/kb-management` check and targeted formatting pass. +- [x] S3 unified add-resource chooser implemented locally: one `+` action opens a KB-local chooser for Website and Document, Video is disabled as coming soon, existing BLOB/URL mutations remain in explicit forms, and the old empty-state links/direct form stack are removed. `@klicker-uzh/kb-management` check and targeted formatting pass. +- [x] S3 accessibility correction completed after review: the chooser now associates its description, traps Tab locally, and restores focus to the exact `+ Add resource` trigger after close. No shared modal change was required. +- [x] S4 secondary configuration implemented locally: chatbot and graph configuration own collapsed disclosures with explicit Configure affordances; graph status, stale, and human-review states remain in the summary, and the expensive graph preview mounts only after expansion. Existing queries, polling, mutations, pointers, and lifecycle contracts are unchanged. The focused Playwright spec covers the English/German workspace, chooser keyboard path, table metadata, and mobile overflow. +- [x] Final static review corrections applied locally: file-upload dismissal is locked after upload starts, chooser dismissal is singular, destructive deletion is in the row overflow menu, the `ADDED`-before-Ingest lifecycle is asserted in the focused spec, and the frontend KB conventions are documented. +- [x] Static S5 package proof complete: Sol final review passed the exact implementation range through `f817a281dd25ceab33d6d13850b8e6d186911457`, the focused spec covers desktop plus 390px and 320px reflow guards, and the full repository typecheck passes 29/29 Turbo tasks after workspace outputs were generated. +- [x] Root formatting passes. Root lint remains environment-blocked in the unrelated analytics package: the host lacks `llvm-ar`, the DevPod lacks a C compiler for its pandas build, and the standalone ruff run exposes 97 pre-existing analytics findings outside this change. +- [x] Host-side Playwright execution is available through `util/run-host-e2e.sh`: it maps routed worktrees, a plain devcontainer, and host-run apps while keeping browser binaries in the shared host cache. The focused lecturer-login smoke passes against the exact linked workspace. +- [x] The latest `origin/v3` local-runtime improvement (`2619be5a2`) is selectively adapted without merging unrelated v3 changes: dependency fingerprinting, bounded stale Next.js cache repair, semantic app readiness, `dev:doctor`, and runtime guard tests are preserved alongside the KB/Azurite startup wiring. The exact linked workspace was re-reconciled successfully with `KB_GRAPH_BLOB_HOST_PORT=10004`; all five Next.js readiness contracts passed. Runtime checks pass for `test:dev-runtime`, `dev:doctor`, formatting, the KB package, and Playwright TypeScript. +- [x] Direct browser verification against the retained workspace reaches the redesigned authenticated detail page, metadata table, and unified add-resource chooser; Website and Document are available and Video is disabled as “Coming soon”. Desktop and 390px screenshots are captured in `/private/tmp/kb-management-ux-add-desktop.png` and `/private/tmp/kb-management-ux-detail-mobile.png`. +- [x] Sol final review of `42773ea45` returned `DONE_WITH_CONCERNS` for one low-severity README cache-policy wording mismatch; the wording now matches the unconditional `.next/dev` cleanup and bounded full-cache repair behavior. The same reviewer’s correction pass over `da6149e29` returned `DONE`; no code, security, architecture, or scope findings remain. +- [x] The reviewed implementation range is based on #5424 head `1d57f4f11a65698b72916de7c6f14c66422f9293` and ends at `9a0792a891e1850bb3b3b284eef4be12f6cbd77a`. Normal publication remains authorized; this plan-only closure update follows that reviewed head before the branch readback is refreshed. +- [x] S5 desktop browser proof passes through `util/run-host-e2e.sh`: the English/German KB journey covers creation, chooser focus handling, the pending document-upload dismissal lock, successful confirmation dismissal, refresh-failure dismissal, the resource table, and the inspector. Mobile and the known shared Manage-header overflow are explicitly outside the accepted completion scope. +- [x] Sol final review of the exact reviewed range confirmed both prior modal findings are resolved and found no correctness, security, architecture, maintainability, desktop UX, or stack-compatibility defects. Its only plan-status finding is closed by this update. + +## Verification plan for implementation + +Each slice should be verified against the same seeded local fixture and a synthetic populated Knowledge Base. The repository currently has no KB-management Playwright spec, so the implementation must add focused coverage rather than treating screenshots as regression protection. + +- Desktop: catalog, empty detail, populated table, add-resource chooser, website form, document upload states, inspector, failed ingestion, and delete confirmation at 1440×900. +- Mobile: deferred to the separate shared Manage-shell follow-up; it is not a completion gate for this desktop-focused package. +- Keyboard: open/close/focus return for every dialog, focus containment, table row selection, filters, load-more, primary row action, and confirmation cancellation. Screenshots alone do not prove these behaviors. +- Assistive technology smoke check: one page-level H1, one main landmark, labeled table headers, row/action names, status announcements, and dialog name/description. +- Localization: English and German strings remain in parity for the chooser, table headers, status labels, errors, and unavailable Video option. +- Regression: retain existing GraphQL resource creation, upload confirmation, ingestion, polling, selection limit, bulk delete, chatbot binding, and graph build tests. +- Browser console: no duplicate keys, unknown DOM props, missing dialog description warnings, or handled-error overlays in the reviewed states. + +The implementation should add only the tests that protect consequential observable behavior. A screenshot matrix and focused interaction checks are more valuable here than broad visual snapshots. + +## Open questions and unreachable states + +These should be resolved before implementation crosses the API or product-contract boundary: + +1. Is Video a planned resource type with a known backend shape, or only a future UI option? The recommendation is to show it disabled as “Coming soon” until the contract exists. +2. Does the repository’s design system expose a lower-level semantic table primitive that can be composed locally? `packages/shared-components/src/DataTable.tsx` exists but is not suitable unchanged because it sorts and paginates the loaded client array. The recommendation is to compose local semantic rows with the design-system table primitives and avoid a new dependency. +3. Should creating a resource leave it un-ingested, as the current lifecycle suggests, or should the modal offer an explicit follow-up “Ingest now” action? The recommendation is explicit follow-up, not hidden automatic work. +4. What exact server URL policy should be surfaced to lecturers? The client/server mismatch must be settled before promising a specific validation message. +5. Which graph cost fields are expected to be configured in this environment? The audit saw build controls disabled because cost configuration was unavailable; this was not treated as a product defect without confirmation. + +The local document-upload path could not be confirmed because the Blob request failed before confirmation. No conclusion about the successful upload UX should be drawn from that failure alone. + +## Boundaries + +This plan does not authorize or include: + +- Merging, closing, deploying, or changing the published PR. +- Backend lifecycle changes, new model/provider integrations, or graph-version entities. +- Deleting the disposable local Knowledge Base or its synthetic resources. +- A German localization pass or full WCAG conformance claim. +- Runtime teardown of the current #5424 audit runtime; it remains available for user feedback and testing until separately requested. diff --git a/util/dev-runtime.sh b/util/dev-runtime.sh new file mode 100755 index 0000000000..a31c81d28b --- /dev/null +++ b/util/dev-runtime.sh @@ -0,0 +1,488 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" +ROOT="${KLICKER_DEV_RUNTIME_ROOT:-$(cd "$(dirname "$SCRIPT_PATH")/.." && pwd)}" +STATE_DIR="${KLICKER_DEV_RUNTIME_STATE_DIR:-$ROOT/.devcontainer/.runtime}" +GENERATION_FILE="$STATE_DIR/generation" +REPAIR_REQUEST_FILE="$STATE_DIR/next-repair-request" +DEPENDENCY_STAMP_FILE="$ROOT/node_modules/.klicker-dependency-fingerprint" +NEXT_APPS=(auth chat frontend-control frontend-manage frontend-pwa) +# Authentication rejects this valid synthetic nested route before any database +# lookup, so the handler must return JSON 401 even when no seed data exists. +CHAT_PROBE_URL='http://localhost:3004/api/chatbots/00000000-0000-4000-8000-000000000000/threads' +STALE_STATUS=20 +WAITING_STATUS=21 +UNEXPECTED_STATUS=22 + +die() { + echo "[dev-runtime] ERROR: $*" >&2 + exit 1 +} + +require_tool() { + command -v "$1" >/dev/null 2>&1 || die "Required command is unavailable: $1" +} + +hash_stream() { + sha256sum | awk '{print $1}' +} + +emit_file_identity() { + local file="$1" + local relative digest + + [ -f "$file" ] || die "Required fingerprint input is missing: $file" + relative="${file#"$ROOT/"}" + digest="$(sha256sum "$file")" + digest="${digest%% *}" + printf 'file\0%s\0sha256\0%s\0' "$relative" "$digest" +} + +dependency_files() { + local relative + + for relative in package.json pnpm-lock.yaml pnpm-workspace.yaml; do + [ -f "$ROOT/$relative" ] || die "Required dependency input is missing: $relative" + printf '%s\n' "$ROOT/$relative" + done + + find "$ROOT/apps" "$ROOT/packages" \ + -mindepth 2 -maxdepth 2 -type f -name package.json -print | + LC_ALL=C sort +} + +dependency_fingerprint() { + { + printf 'format\0klicker-dependencies-v1\0' + while IFS= read -r file; do + emit_file_identity "$file" + done < <(dependency_files) + } | hash_stream +} + +git_head() { + if [ -n "${KLICKER_DEV_RUNTIME_GIT_HEAD:-}" ]; then + printf '%s\n' "$KLICKER_DEV_RUNTIME_GIT_HEAD" + return + fi + + git -C "$ROOT" rev-parse HEAD +} + +next_structure_paths() { + local app route_root + + for app in "${NEXT_APPS[@]}"; do + for route_root in "$ROOT/apps/$app/src/app" "$ROOT/apps/$app/src/pages"; do + [ -d "$route_root" ] || continue + find "$route_root" -type f -print + done + done | LC_ALL=C sort +} + +next_configuration_files() { + local app app_root + + for app in "${NEXT_APPS[@]}"; do + app_root="$ROOT/apps/$app" + find "$app_root" -maxdepth 2 -type f \ + \( -name 'next.config.*' -o -name 'package.json' -o \ + -name 'tsconfig*.json' -o -name 'proxy.*' -o -name 'middleware.*' \) \ + -print + done | LC_ALL=C sort -u +} + +runtime_fingerprint() { + local head path + + head="$(git_head)" + [[ "$head" =~ ^[a-fA-F0-9]{40,64}$ ]] || die "Git HEAD is not a commit digest." + + { + printf 'format\0klicker-dev-runtime-v1\0' + printf 'git-head\0%s\0' "$head" + printf 'dependencies\0%s\0' "$(dependency_fingerprint)" + printf 'node\0%s\0' "$(node --version)" + printf 'pnpm\0%s\0' "$(pnpm --version)" + emit_file_identity "$SCRIPT_PATH" + while IFS= read -r path; do + printf 'route-path\0%s\0' "${path#"$ROOT/"}" + done < <(next_structure_paths) + while IFS= read -r path; do + emit_file_identity "$path" + done < <(next_configuration_files) + } | hash_stream +} + +write_atomic() { + local path="$1" + local value="$2" + local temporary + + mkdir -p "$(dirname "$path")" + temporary="$(mktemp "${path}.tmp.XXXXXX")" + printf '%s\n' "$value" >"$temporary" + mv "$temporary" "$path" +} + +read_generation() { + local generation=0 + + if [ -f "$GENERATION_FILE" ]; then + IFS= read -r generation <"$GENERATION_FILE" || true + fi + [[ "$generation" =~ ^[0-9]+$ ]] || die "Runtime generation is invalid." + printf '%s\n' "$generation" +} + +valid_next_app() { + local candidate="$1" + local app + + for app in "${NEXT_APPS[@]}"; do + [ "$candidate" = "$app" ] && return 0 + done + return 1 +} + +probe_url() { + case "$1" in + auth) echo 'http://localhost:3010/' ;; + chat) echo "$CHAT_PROBE_URL" ;; + frontend-control) echo 'http://localhost:3003/login' ;; + frontend-manage) echo 'http://localhost:3002/login' ;; + frontend-pwa) echo 'http://localhost:3001/login' ;; + *) return 1 ;; + esac +} + +# Chat proves its nested dynamic API route graph through the authentication +# contract above. The other apps prove their static route table through a +# committed shell page that renders HTML without database content, so a 404 +# there can never be a legitimate data-driven miss. +probe_mode() { + case "$1" in + chat) echo 'auth-json' ;; + auth | frontend-control | frontend-manage | frontend-pwa) + echo 'html-shell' + ;; + *) return 1 ;; + esac +} + +request_repair() { + local app="$1" generation pending updated + + valid_next_app "$app" || die "Unsupported Next.js repair target: $app" + require_tool flock + mkdir -p "$STATE_DIR" + exec 9>"$STATE_DIR/lock" + flock -w 10 9 || die "Timed out waiting for the runtime-state lock." + + generation="$(read_generation)" + updated="$app" + if [ -s "$REPAIR_REQUEST_FILE" ]; then + while IFS= read -r pending; do + valid_next_app "$pending" || + die "Invalid pending repair target: $pending." + [ "$pending" = "$app" ] || updated="$updated"$'\n'"$pending" + done <"$REPAIR_REQUEST_FILE" + fi + write_atomic "$REPAIR_REQUEST_FILE" "$updated" + write_atomic "$GENERATION_FILE" "$((generation + 1))" + echo "[dev-runtime] Requested one full .next repair for $app." +} + +stamp_dependencies() { + mkdir -p "$ROOT/node_modules" + write_atomic "$DEPENDENCY_STAMP_FILE" "$(dependency_fingerprint)" +} + +ensure_dependencies() { + local current expected="" + + current="$(dependency_fingerprint)" + if [ -f "$DEPENDENCY_STAMP_FILE" ]; then + IFS= read -r expected <"$DEPENDENCY_STAMP_FILE" || true + fi + + if [ "$current" = "$expected" ]; then + echo '[dev-runtime] Dependency volume matches the current workspace.' + return + fi + + echo '[dev-runtime] Dependency inputs changed; running frozen pnpm install.' + (cd "$ROOT" && pnpm install --frozen-lockfile) + write_atomic "$DEPENDENCY_STAMP_FILE" "$current" +} + +remove_next_dir() { + local target="$1" + local allowed=false app next_dir + + for app in "${NEXT_APPS[@]}"; do + next_dir="$ROOT/apps/$app/.next" + if [ "$target" = "$next_dir" ] || [ "$target" = "$next_dir/dev" ]; then + allowed=true + [ ! -L "$next_dir" ] || die "Refusing symlinked Next.js cache: $next_dir" + [ ! -L "$target" ] || die "Refusing symlinked Next.js cache: $target" + break + fi + done + + [ "$allowed" = true ] || die "Refusing unexpected cache target: $target" + [ -e "$target" ] || return 0 + rm -rf -- "$target" + echo "[dev-runtime] Removed generated cache: ${target#"$ROOT/"}" +} + +apply_cache_policy() { + local app repair_target + + for app in "${NEXT_APPS[@]}"; do + remove_next_dir "$ROOT/apps/$app/.next/dev" + done + + if [ -f "$REPAIR_REQUEST_FILE" ]; then + while IFS= read -r repair_target; do + valid_next_app "$repair_target" || + die "Invalid pending repair target: $repair_target." + remove_next_dir "$ROOT/apps/$repair_target/.next" + done <"$REPAIR_REQUEST_FILE" + rm -f "$REPAIR_REQUEST_FILE" + fi +} + +classify_response() { + local mode="$1" status="$2" + local content_type="${3,,}" + + if [ "$mode" = 'auth-json' ]; then + if [ "$status" = '401' ] && [[ "$content_type" == application/json* ]]; then + echo "ready: HTTP $status $content_type" + return 0 + fi + elif [ "$mode" = 'html-shell' ]; then + if [[ "$status" =~ ^[23][0-9][0-9]$ ]] && + [[ "$content_type" == text/html* ]]; then + echo "ready: HTTP $status $content_type" + return 0 + fi + # Next.js redirect responses often have no body and no content-type; the + # redirect itself proves the committed shell route resolved. + if [[ "$status" =~ ^3[0-9][0-9]$ ]]; then + echo "ready: HTTP $status redirect" + return 0 + fi + else + die "Unknown probe mode: $mode." + fi + + if [ "$status" = '404' ] && [[ "$content_type" == text/html* ]]; then + echo "stale: HTTP $status $content_type" + return "$STALE_STATUS" + fi + echo "unexpected: HTTP $status ${content_type:-unknown-content-type}" + return "$UNEXPECTED_STATUS" +} + +probe_app() { + local app="$1" mode url response status content_type + + mode="$(probe_mode "$app")" || die "No probe contract is defined for: $app" + url="$(probe_url "$app")" || die "No probe URL is defined for: $app" + require_tool curl + if ! response="$(curl --silent --show-error --output /dev/null \ + --write-out $'%{http_code}\t%{content_type}' \ + --connect-timeout 2 --max-time 15 --noproxy '*' \ + "$url" 2>/dev/null)"; then + echo "waiting: $app is not accepting connections" + return "$WAITING_STATUS" + fi + + status="${response%%$'\t'*}" + content_type="${response#*$'\t'}" + classify_response "$mode" "$status" "$content_type" +} + +wait_for_app() { + local app="$1" + local attempt observation status=0 last_observation='' + local stale_count=0 unexpected_count=0 + + require_tool sleep + echo "[dev-runtime] Waiting for the $app readiness contract..." + for ((attempt = 1; attempt <= 90; attempt++)); do + status=0 + observation="$(probe_app "$app")" || status=$? + if [ "$observation" != "$last_observation" ]; then + echo "[dev-runtime] $observation" + last_observation="$observation" + fi + + case "$status" in + 0) + echo "[dev-runtime] $app readiness contract is satisfied." + return 0 + ;; + "$STALE_STATUS") + stale_count=$((stale_count + 1)) + unexpected_count=0 + ;; + "$WAITING_STATUS") + stale_count=0 + unexpected_count=0 + ;; + "$UNEXPECTED_STATUS") + stale_count=0 + unexpected_count=$((unexpected_count + 1)) + ;; + *) + die "$app probe returned unsupported status $status." + ;; + esac + + if [ "$attempt" -ge 10 ] && [ "$stale_count" -ge 5 ]; then + echo "[dev-runtime] Confirmed stale $app route state." >&2 + return "$STALE_STATUS" + fi + if [ "$attempt" -ge 10 ] && [ "$unexpected_count" -ge 3 ]; then + echo "[dev-runtime] $app returned a stable unexpected response; no cache was removed." >&2 + return "$UNEXPECTED_STATUS" + fi + [ "$attempt" -eq 90 ] || sleep 1 + done + + echo "[dev-runtime] $app did not satisfy its readiness contract within 90 seconds." >&2 + return 1 +} + +doctor() { + local app observation status=0 unhealthy=0 + local any_stale=false any_unexpected=false + + for app in "${NEXT_APPS[@]}"; do + status=0 + observation="$(probe_app "$app")" || status=$? + if [ "$status" -eq 0 ]; then + echo "[dev-runtime] $app healthy: $observation" + continue + fi + + unhealthy=1 + echo "[dev-runtime] ERROR: $app unhealthy: $observation" >&2 + if [ "$status" -eq "$STALE_STATUS" ]; then + any_stale=true + else + any_unexpected=true + fi + done + + if [ "$any_stale" = true ]; then + echo '[dev-runtime] Run devrouter ensure . on the host to apply the bounded repair.' >&2 + fi + if [ "$any_unexpected" = true ]; then + echo '[dev-runtime] No cache was removed. Inspect /tmp/dev.log for the application failure.' >&2 + fi + return "$unhealthy" +} + +start_runtime() { + local expected_fingerprint="$1" + local expected_generation="$2" + shift 2 + + [ "${1:-}" = '--' ] || die "start requires -- before the runtime command." + shift + [ "$#" -gt 0 ] || die "start requires a runtime command." + [[ "$expected_fingerprint" =~ ^[a-fA-F0-9]{64}$ ]] || + die "Expected runtime fingerprint is invalid." + [[ "$expected_generation" =~ ^[0-9]+$ ]] || + die "Expected runtime generation is invalid." + [ "$expected_fingerprint" = "$(runtime_fingerprint)" ] || + die "Runtime inputs changed before process start; rerun devrouter ensure." + [ "$expected_generation" = "$(read_generation)" ] || + die "Runtime generation changed before process start; rerun devrouter ensure." + + ensure_dependencies + apply_cache_policy + exec "$@" +} + +usage() { + cat <<'EOF' +Usage: + util/dev-runtime.sh fingerprint + util/dev-runtime.sh dependency-fingerprint + util/dev-runtime.sh generation + util/dev-runtime.sh stamp-dependencies + util/dev-runtime.sh ensure-dependencies + util/dev-runtime.sh request-repair + util/dev-runtime.sh start -- [args...] + util/dev-runtime.sh classify-response + util/dev-runtime.sh probe-app + util/dev-runtime.sh wait-app + util/dev-runtime.sh doctor +EOF +} + +main() { + require_tool sha256sum + require_tool awk + require_tool find + require_tool sort + require_tool mktemp + + case "${1:-}" in + fingerprint) + runtime_fingerprint + ;; + dependency-fingerprint) + dependency_fingerprint + ;; + generation) + read_generation + ;; + stamp-dependencies) + stamp_dependencies + ;; + ensure-dependencies) + ensure_dependencies + ;; + request-repair) + [ "$#" -eq 2 ] || die "request-repair requires one app name." + request_repair "$2" + ;; + start) + [ "$#" -ge 5 ] || die "start requires identity and a command." + shift + start_runtime "$@" + ;; + classify-response) + [ "$#" -eq 4 ] || die "classify-response requires mode, status, and content type." + classify_response "$2" "$3" "$4" + ;; + probe-app) + [ "$#" -eq 2 ] || die "probe-app requires one app name." + probe_app "$2" + ;; + wait-app) + [ "$#" -eq 2 ] || die "wait-app requires one app name." + wait_for_app "$2" + ;; + doctor) + [ "$#" -eq 1 ] || die "doctor takes no arguments." + doctor + ;; + --help|-h) + usage + ;; + *) + usage >&2 + exit 1 + ;; + esac +} + +main "$@" diff --git a/util/run-host-e2e.sh b/util/run-host-e2e.sh index fef9783992..426ffeb3a8 100755 --- a/util/run-host-e2e.sh +++ b/util/run-host-e2e.sh @@ -15,6 +15,7 @@ # Usage: # bash util/run-host-e2e.sh --print # bash util/run-host-e2e.sh --project=chromium tests/A-login.spec.ts +# bash util/run-host-e2e.sh --project=chromium tests/Y-kb-management-ux.spec.ts # pnpm --filter @klicker-uzh/playwright test:host -- --project=chromium tests/A-login.spec.ts # # Environment overrides: diff --git a/util/test-dev-runtime.sh b/util/test-dev-runtime.sh new file mode 100755 index 0000000000..9e836bd71b --- /dev/null +++ b/util/test-dev-runtime.sh @@ -0,0 +1,207 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +RUNTIME_SCRIPT="$REPO_ROOT/util/dev-runtime.sh" +TEST_ROOT="$(mktemp -d)" +trap 'rm -rf "$TEST_ROOT"' EXIT + +fail() { + echo "[test-dev-runtime] FAIL: $*" >&2 + exit 1 +} + +assert_equal() { + [ "$1" = "$2" ] || fail "expected '$1' to equal '$2'" +} + +assert_not_equal() { + [ "$1" != "$2" ] || fail "expected values to differ" +} + +assert_exists() { + [ -e "$1" ] || fail "expected path to exist: $1" +} + +assert_absent() { + [ ! -e "$1" ] || fail "expected path to be absent: $1" +} + +write_file() { + local path="$1" + local content="$2" + + mkdir -p "$(dirname "$path")" + printf '%s\n' "$content" >"$path" +} + +ROOT="$TEST_ROOT/repo" +FAKE_BIN="$TEST_ROOT/bin" +INSTALL_LOG="$TEST_ROOT/install.log" +NEXT_APPS=(auth chat frontend-control frontend-manage frontend-pwa) +mkdir -p "$ROOT/node_modules" "$FAKE_BIN" + +write_file "$ROOT/package.json" '{"packageManager":"pnpm@11.5.0"}' +write_file "$ROOT/pnpm-lock.yaml" 'lockfileVersion: 9' +write_file "$ROOT/pnpm-workspace.yaml" 'packages: [apps/*, packages/*]' +write_file "$ROOT/packages/example/package.json" '{"name":"example"}' + +for app in "${NEXT_APPS[@]}"; do + write_file "$ROOT/apps/$app/package.json" "{\"name\":\"$app\"}" + write_file "$ROOT/apps/$app/next.config.mjs" 'export default {}' +done +write_file "$ROOT/apps/chat/src/app/api/example/route.ts" 'export const GET = true' +write_file "$ROOT/apps/auth/src/pages/index.tsx" 'export default true' + +write_file "$FAKE_BIN/pnpm" '#!/usr/bin/env bash +if [ "${1:-}" = "--version" ]; then + echo "11.5.0" + exit 0 +fi +printf "install\n" >>"$KLICKER_TEST_INSTALL_LOG"' +chmod +x "$FAKE_BIN/pnpm" + +export PATH="$FAKE_BIN:$PATH" +export KLICKER_TEST_INSTALL_LOG="$INSTALL_LOG" +export KLICKER_DEV_RUNTIME_ROOT="$ROOT" +export KLICKER_DEV_RUNTIME_GIT_HEAD=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + +base_fingerprint="$(bash "$RUNTIME_SCRIPT" fingerprint)" +write_file "$ROOT/apps/chat/src/app/api/example/route.ts" 'export const GET = false' +content_fingerprint="$(bash "$RUNTIME_SCRIPT" fingerprint)" +assert_equal "$base_fingerprint" "$content_fingerprint" + +write_file "$ROOT/apps/chat/src/app/api/added/route.ts" 'export const GET = true' +path_fingerprint="$(bash "$RUNTIME_SCRIPT" fingerprint)" +assert_not_equal "$base_fingerprint" "$path_fingerprint" + +write_file "$ROOT/apps/chat/next.config.mjs" 'export default { reactStrictMode: true }' +config_fingerprint="$(bash "$RUNTIME_SCRIPT" fingerprint)" +assert_not_equal "$path_fingerprint" "$config_fingerprint" + +bash "$RUNTIME_SCRIPT" ensure-dependencies >/dev/null +bash "$RUNTIME_SCRIPT" ensure-dependencies >/dev/null +assert_equal "$(wc -l <"$INSTALL_LOG" | tr -d ' ')" '1' + +write_file "$ROOT/pnpm-lock.yaml" 'lockfileVersion: 9.1' +bash "$RUNTIME_SCRIPT" ensure-dependencies >/dev/null +assert_equal "$(wc -l <"$INSTALL_LOG" | tr -d ' ')" '2' + +for app in "${NEXT_APPS[@]}"; do + write_file "$ROOT/apps/$app/.next/dev/cache.bin" 'development cache' + write_file "$ROOT/apps/$app/.next/production.bin" 'production cache' +done + +runtime_fingerprint="$(bash "$RUNTIME_SCRIPT" fingerprint)" +bash "$RUNTIME_SCRIPT" start "$runtime_fingerprint" 0 -- true +for app in "${NEXT_APPS[@]}"; do + assert_absent "$ROOT/apps/$app/.next/dev" + assert_exists "$ROOT/apps/$app/.next/production.bin" +done + +bash "$RUNTIME_SCRIPT" request-repair chat >/dev/null +assert_equal "$(bash "$RUNTIME_SCRIPT" generation)" '1' +write_file "$ROOT/apps/chat/.next/dev/cache.bin" 'stale development cache' +bash "$RUNTIME_SCRIPT" start "$runtime_fingerprint" 1 -- true +assert_absent "$ROOT/apps/chat/.next" +assert_exists "$ROOT/apps/auth/.next/production.bin" +assert_absent "$ROOT/.devcontainer/.runtime/next-repair-request" + +write_file "$TEST_ROOT/outside-cache/marker" 'must survive' +ln -s "$TEST_ROOT/outside-cache" "$ROOT/apps/chat/.next" +if bash "$RUNTIME_SCRIPT" start "$runtime_fingerprint" 1 -- true >/dev/null 2>&1; then + fail 'symlinked cache was accepted' +fi +assert_exists "$TEST_ROOT/outside-cache/marker" + +if bash "$RUNTIME_SCRIPT" request-repair unsupported >/dev/null 2>&1; then + fail 'unsupported repair target was accepted' +fi + +# A stale pass can cover several apps at once: every requested app receives a +# full .next repair in one start, untouched apps keep their production output, +# and repeated requests for the same app stay deduplicated. +rm -f "$ROOT/apps/chat/.next" +for app in "${NEXT_APPS[@]}"; do + write_file "$ROOT/apps/$app/.next/dev/cache.bin" 'development cache' + write_file "$ROOT/apps/$app/.next/production.bin" 'production cache' +done + +bash "$RUNTIME_SCRIPT" request-repair frontend-manage >/dev/null +bash "$RUNTIME_SCRIPT" request-repair chat >/dev/null +bash "$RUNTIME_SCRIPT" request-repair chat >/dev/null +assert_equal "$(bash "$RUNTIME_SCRIPT" generation)" '4' +assert_equal \ + "$(LC_ALL=C sort "$ROOT/.devcontainer/.runtime/next-repair-request" | tr '\n' ' ')" \ + 'chat frontend-manage ' +bash "$RUNTIME_SCRIPT" start "$runtime_fingerprint" 4 -- true +assert_absent "$ROOT/apps/chat/.next" +assert_absent "$ROOT/apps/frontend-manage/.next" +assert_exists "$ROOT/apps/auth/.next/production.bin" +assert_exists "$ROOT/apps/frontend-pwa/.next/production.bin" +assert_absent "$ROOT/.devcontainer/.runtime/next-repair-request" + +assert_equal \ + "$(bash "$RUNTIME_SCRIPT" classify-response auth-json 401 'application/json; charset=utf-8')" \ + 'ready: HTTP 401 application/json; charset=utf-8' + +classification_status=0 +classification_output="$( + bash "$RUNTIME_SCRIPT" classify-response auth-json 404 'text/html; charset=utf-8' +)" || classification_status=$? +assert_equal "$classification_status" '20' +assert_equal "$classification_output" 'stale: HTTP 404 text/html; charset=utf-8' + +classification_status=0 +classification_output="$( + bash "$RUNTIME_SCRIPT" classify-response auth-json 500 application/json +)" || classification_status=$? +assert_equal "$classification_status" '22' +assert_equal "$classification_output" 'unexpected: HTTP 500 application/json' + +classification_status=0 +classification_output="$( + bash "$RUNTIME_SCRIPT" classify-response auth-json 404 application/json +)" || classification_status=$? +assert_equal "$classification_status" '22' +assert_equal "$classification_output" 'unexpected: HTTP 404 application/json' + +assert_equal \ + "$(bash "$RUNTIME_SCRIPT" classify-response html-shell 200 'text/html; charset=utf-8')" \ + 'ready: HTTP 200 text/html; charset=utf-8' +assert_equal \ + "$(bash "$RUNTIME_SCRIPT" classify-response html-shell 307 'text/html')" \ + 'ready: HTTP 307 text/html' +assert_equal \ + "$(bash "$RUNTIME_SCRIPT" classify-response html-shell 307 '')" \ + 'ready: HTTP 307 redirect' + +classification_status=0 +classification_output="$( + bash "$RUNTIME_SCRIPT" classify-response html-shell 404 'text/html' +)" || classification_status=$? +assert_equal "$classification_status" '20' +assert_equal "$classification_output" 'stale: HTTP 404 text/html' + +classification_status=0 +classification_output="$( + bash "$RUNTIME_SCRIPT" classify-response html-shell 500 'text/html' +)" || classification_status=$? +assert_equal "$classification_status" '22' +assert_equal "$classification_output" 'unexpected: HTTP 500 text/html' + +classification_status=0 +classification_output="$( + bash "$RUNTIME_SCRIPT" classify-response html-shell 200 'application/json' +)" || classification_status=$? +assert_equal "$classification_status" '22' +assert_equal "$classification_output" 'unexpected: HTTP 200 application/json' + +if bash "$RUNTIME_SCRIPT" classify-response unknown-mode 200 'text/html' >/dev/null 2>&1; then + fail 'unknown probe mode was accepted' +fi +if bash "$RUNTIME_SCRIPT" probe-app unsupported >/dev/null 2>&1; then + fail 'app without a probe contract was accepted' +fi + +echo '[test-dev-runtime] PASS'