From 45c84c500f7e8eaff3697716a0faea0a8a29ec21 Mon Sep 17 00:00:00 2001 From: Dmitry Ng <19asdek91@gmail.com> Date: Wed, 29 Jul 2026 16:34:29 +0400 Subject: [PATCH] test(frontend): make the e2e suite falsifiable, and fix what it found (#380) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The e2e suite and CI gates could report green without checking anything: the lint/codegen gate never ran on fork PRs, the codegen freshness check diffed the wrong range, the sticky comment claimed passes it never read, and several visual/palette/contrast/a11y gates were written so they could not fail. Closing those gates then exposed real defects. **E2E and CI.** New coverage for the assistant lifecycle, flow interrupt, live panels, tab deep links, the report route and its exports, session expiry, and uploads/downloads (59 → 125 spec cases). Gates now fail on real regressions: absolute pixel budget, per-tab panel sweeps, scoped a11y waivers, page-error assertions. CI runs on `pull_request`, ranges from the PR base, routes comments by PR identity, and redacts stand secrets from the public artifact. **Markdown editor.** ~20 round-trip and toolbar fixes: table pipe escaping (nested lists, Go template pipelines, headerless tables), fence validity, setext and line-leading escapes, inline code containing a backtick, block toggles under select-all, controls disabled where the command is unavailable or a table cell cannot hold the block, theme-aware code surface. Two perf fixes cut 1 MB document load from 69s to 4s. **Accessibility and UI.** Focus returns to whatever opened a dialog, sheet or menu; icon-only buttons named; `aria-sort` on table columns; syntax tokens retuned to clear AA in both themes; a dedicated link token. Detail and settings pages now distinguish authz denials and partial errors from missing records, survive background refetches without blanking or discarding unsaved edits, and stop naming exported PDFs `*.pdf.pdf`. **Backend.** Passwords capped at the 72 bytes bcrypt can hash (over-long ones previously 500'd after passing validation); four agent options the GraphQL round trip silently dropped are carried through; a flow whose worker fails to start is no longer left in the listing; the Graphiti health check retries before permanently disabling the client. --------- Co-authored-by: Sergey Kozyrenko --- .dockerignore | 7 + .github/scripts/codegen-inputs-changed.sh | 57 ++ .github/workflows/ci.yml | 51 +- .github/workflows/e2e-report.yml | 56 +- .github/workflows/e2e-stand.yml | 57 +- .github/workflows/e2e.yml | 31 +- CLAUDE.md | 2 +- README.md | 6 +- backend/pkg/config/config.go | 7 - backend/pkg/controller/aslog.go | 2 +- backend/pkg/controller/assistant.go | 2 +- backend/pkg/controller/flow.go | 25 +- backend/pkg/csum/chain_summary.go | 10 - backend/pkg/database/converter/analytics.go | 1 - backend/pkg/database/converter/converter.go | 32 +- .../pkg/database/converter/converter_test.go | 75 ++ backend/pkg/database/database.go | 4 +- backend/pkg/graph/generated.go | 338 +++++++- backend/pkg/graph/model/models_gen.go | 4 + backend/pkg/graph/schema.graphqls | 8 + backend/pkg/graph/schema.resolvers.go | 10 - backend/pkg/graphiti/client.go | 21 +- backend/pkg/graphiti/client_test.go | 42 + backend/pkg/providers/custom/custom.go | 2 - backend/pkg/providers/gemini/gemini_test.go | 4 - backend/pkg/providers/pconfig/config.go | 3 - backend/pkg/providers/performer.go | 1 - backend/pkg/providers/performers.go | 1 - backend/pkg/providers/provider.go | 1 - backend/pkg/providers/provider/agents.go | 7 - backend/pkg/providers/subtask_patch.go | 1 - backend/pkg/server/auth/api_token_cache.go | 10 - backend/pkg/server/auth/auth_middleware.go | 1 - backend/pkg/server/auth/session.go | 2 - backend/pkg/server/auth/users_cache.go | 6 - backend/pkg/server/models/init.go | 17 + backend/pkg/server/models/users.go | 4 +- backend/pkg/server/models/users_test.go | 65 ++ backend/pkg/server/services/api_tokens.go | 16 - backend/pkg/terminal/output.go | 5 - backend/pkg/tools/browser.go | 3 - backend/pkg/tools/code.go | 4 - backend/pkg/tools/executor.go | 4 - backend/pkg/tools/flow_manager.go | 2 - backend/pkg/tools/graphiti_search.go | 3 - backend/pkg/tools/guide.go | 4 - backend/pkg/tools/memory.go | 3 - backend/pkg/tools/memory_utils.go | 4 - backend/pkg/tools/search.go | 4 - backend/pkg/tools/terminal.go | 3 - backend/pkg/tools/tools.go | 1 - docker-compose.yml | 2 +- frontend/.gitignore | 1 + frontend/docs/e2e.md | 59 +- frontend/docs/list_detail_pages.md | 50 +- frontend/e2e/affected-routes.ts | 12 +- frontend/e2e/affected-routes.unit.test.ts | 9 + frontend/e2e/ci-codegen-gate.unit.test.ts | 91 +++ frontend/e2e/fixtures/auth.ts | 11 +- frontend/e2e/fixtures/backend.ts | 4 +- frontend/e2e/fixtures/test.ts | 17 +- frontend/e2e/helpers/a11y.ts | 9 + frontend/e2e/helpers/contrast.ts | 216 +++-- frontend/e2e/helpers/contrast.unit.test.ts | 57 ++ frontend/e2e/helpers/pdf.ts | 68 ++ frontend/e2e/helpers/subscriptions.ts | 6 +- frontend/e2e/mock-llm/scenario.mjs | 7 +- frontend/e2e/mock-llm/server.mjs | 5 +- frontend/e2e/mocks/cassette.ts | 15 + frontend/e2e/mocks/cassettes/assistants.ts | 72 ++ .../mocks/cassettes/assistants.unit.test.ts | 28 + frontend/e2e/mocks/cassettes/base.ts | 5 +- frontend/e2e/mocks/cassettes/flows.ts | 174 +++- frontend/e2e/mocks/cassettes/knowledges.ts | 21 +- frontend/e2e/mocks/cassettes/resources.ts | 38 + .../e2e/mocks/cassettes/settings-prompts.ts | 49 +- .../e2e/mocks/cassettes/settings-providers.ts | 246 ++++-- frontend/e2e/mocks/install.ts | 25 +- frontend/e2e/mocks/world.ts | 48 +- frontend/e2e/mocks/world.unit.test.ts | 80 ++ frontend/e2e/mocks/ws-graphql.ts | 8 +- frontend/e2e/mocks/ws-graphql.unit.test.ts | 6 +- frontend/e2e/playwright.config.ts | 26 +- frontend/e2e/route-manifest.unit.test.ts | 47 ++ frontend/e2e/routes.ts | 77 +- frontend/e2e/specs/cross/a11y.spec.ts | 59 +- frontend/e2e/specs/cross/contrast.spec.ts | 134 +++- frontend/e2e/specs/cross/palette.spec.ts | 130 ++- frontend/e2e/specs/cross/responsive.spec.ts | 16 + .../e2e/specs/cross/session-expiry.spec.ts | 143 ++++ frontend/e2e/specs/cross/themes.spec.ts | 22 +- frontend/e2e/specs/crud/api-tokens.spec.ts | 18 + frontend/e2e/specs/crud/knowledges.spec.ts | 106 ++- .../e2e/specs/crud/resources-upload.spec.ts | 134 ++++ frontend/e2e/specs/crud/resources.spec.ts | 181 ++++- .../e2e/specs/crud/template-detail.spec.ts | 71 +- .../e2e/specs/dashboard/dashboard.spec.ts | 8 +- frontend/e2e/specs/flows/assistants.spec.ts | 265 +++++++ frontend/e2e/specs/flows/interrupt.spec.ts | 118 +++ frontend/e2e/specs/flows/live-panels.spec.ts | 78 ++ frontend/e2e/specs/flows/messages.spec.ts | 8 +- frontend/e2e/specs/flows/pager.spec.ts | 63 +- frontend/e2e/specs/flows/reconnect.spec.ts | 25 +- frontend/e2e/specs/flows/report.spec.ts | 123 +++ .../e2e/specs/flows/subscriptions.spec.ts | 58 +- frontend/e2e/specs/flows/tab-deeplink.spec.ts | 70 ++ frontend/e2e/specs/flows/tabs.spec.ts | 24 +- .../e2e/specs/real/account-password.spec.ts | 135 ++++ frontend/e2e/specs/real/flow-run.spec.ts | 15 +- .../e2e/specs/real/resources-download.spec.ts | 67 ++ .../real/resources-upload-limits.spec.ts | 116 +++ frontend/e2e/specs/real/stand-smoke.spec.ts | 8 +- frontend/e2e/specs/settings/account.spec.ts | 193 +++++ .../e2e/specs/settings/prompt-detail.spec.ts | 156 +++- frontend/e2e/specs/settings/providers.spec.ts | 307 ++++++- frontend/e2e/specs/smoke.spec.ts | 8 +- .../flows-5-dark-visual-linux.png | Bin 59926 -> 59943 bytes .../flows-5-light-visual-linux.png | Bin 56794 -> 56786 bytes frontend/e2e/tools/review-sandbox.sh | 187 +++++ .../e2e/tools/review-sandbox.unit.test.ts | 148 ++++ frontend/e2e/tools/run-local-tier.sh | 5 +- frontend/graphql-schema.graphql | 4 + .../detail-navigation/use-navigation.ts | 2 +- .../shared/file-manager/file-manager.tsx | 2 +- .../inline-edit/inline-edit-input.test.tsx | 57 ++ .../shared/inline-edit/inline-edit-input.tsx | 9 +- .../markdown-editor-code-fence.test.ts | 120 ++- .../markdown-editor-extensions.test.ts | 746 +++++++++++++++++- .../markdown-editor-extensions.ts | 447 ++++++++++- .../markdown-editor/markdown-editor-focus.ts | 17 + ...markdown-editor-heading-autoformat.test.ts | 12 + .../markdown-editor-heading-autoformat.ts | 3 +- .../markdown-editor-image-handle.tsx | 6 +- .../markdown-editor-image-src.test.ts | 43 + .../markdown-editor-link-edit-form.tsx | 12 +- .../markdown-editor-link-handle.tsx | 33 +- .../markdown-editor/markdown-editor-marked.ts | 120 ++- .../markdown-editor-table-handles.tsx | 11 +- .../markdown-editor-table-pipes.test.ts | 290 ++++++- .../markdown-editor-table-pipes.ts | 59 +- .../markdown-editor-toolbar-heading.tsx | 79 +- .../markdown-editor-toolbar-image.tsx | 6 +- .../markdown-editor-toolbar-link.tsx | 7 +- .../markdown-editor-toolbar-list.tsx | 75 +- .../markdown-editor-toolbar-url.ts | 18 +- .../markdown-editor-toolbar.test.tsx | 214 +++++ .../markdown-editor-toolbar.tsx | 71 +- .../markdown-editor/markdown-editor.tsx | 2 - frontend/src/components/shared/markdown.tsx | 1 - frontend/src/components/ui/button.tsx | 4 +- frontend/src/components/ui/context-menu.tsx | 2 + .../src/components/ui/data-table.test.tsx | 80 +- frontend/src/components/ui/data-table.tsx | 36 +- frontend/src/components/ui/dialog.test.tsx | 142 ++++ frontend/src/components/ui/dialog.tsx | 45 ++ frontend/src/components/ui/empty.tsx | 2 +- frontend/src/components/ui/sheet.test.tsx | 96 +++ frontend/src/components/ui/sheet.tsx | 2 + .../authentication/password-change-form.tsx | 5 +- .../src/features/flows/agents/flow-agents.tsx | 2 + .../flow-files-attach-resources-dialog.tsx | 1 + .../flows/files/flow-files-pull-dialog.tsx | 2 + .../src/features/flows/files/flow-files.tsx | 6 +- .../src/features/flows/flow-form.test.tsx | 127 +++ frontend/src/features/flows/flow-form.tsx | 17 +- .../flows/flow-scroll-to-latest.test.tsx | 101 +++ .../messages/flow-assistant-messages.tsx | 4 + .../messages/flow-automation-messages.tsx | 2 + .../features/flows/messages/flow-message.tsx | 2 +- .../flows/screenshots/flow-screenshots.tsx | 2 + .../src/features/flows/tasks/flow-tasks.tsx | 2 + .../features/flows/terminal/flow-terminal.tsx | 1 + .../src/features/flows/tools/flow-tools.tsx | 2 + .../vector-stores/flow-vector-stores.tsx | 2 + .../features/knowledges/knowledge-form.tsx | 7 +- .../features/knowledges/knowledge-header.tsx | 16 +- frontend/src/graphql/types.ts | 44 ++ frontend/src/lib/apollo.test.ts | 49 ++ frontend/src/lib/errors.test.ts | 27 + frontend/src/lib/errors.ts | 16 + .../text-filter.test.ts | 0 .../detail-navigation => lib}/text-filter.ts | 8 +- frontend/src/lib/upload-validation.test.ts | 54 +- frontend/src/lib/upload-validation.ts | 15 +- frontend/src/pages/flows/flow-report.test.tsx | 51 ++ frontend/src/pages/flows/flow-report.tsx | 17 +- frontend/src/pages/flows/flow.tsx | 26 +- .../src/pages/knowledges/knowledge.test.tsx | 78 ++ frontend/src/pages/knowledges/knowledge.tsx | 39 +- frontend/src/pages/resources/resources.tsx | 3 +- .../pages/settings/settings-api-tokens.tsx | 2 +- .../src/pages/settings/settings-prompt.tsx | 27 +- .../src/pages/settings/settings-prompts.tsx | 32 +- .../pages/settings/settings-provider.test.tsx | 187 ++++- .../src/pages/settings/settings-provider.tsx | 47 +- .../settings/settings-providers.test.tsx | 105 ++- .../src/pages/settings/settings-providers.tsx | 2 +- frontend/src/pages/templates/template.tsx | 74 +- frontend/src/providers/flow-provider.test.ts | 30 + frontend/src/providers/flow-provider.tsx | 18 +- frontend/src/styles/index.css | 143 +++- 201 files changed, 9166 insertions(+), 921 deletions(-) create mode 100755 .github/scripts/codegen-inputs-changed.sh create mode 100644 frontend/e2e/ci-codegen-gate.unit.test.ts create mode 100644 frontend/e2e/helpers/contrast.unit.test.ts create mode 100644 frontend/e2e/helpers/pdf.ts create mode 100644 frontend/e2e/mocks/cassettes/assistants.ts create mode 100644 frontend/e2e/mocks/cassettes/assistants.unit.test.ts create mode 100644 frontend/e2e/specs/cross/session-expiry.spec.ts create mode 100644 frontend/e2e/specs/crud/resources-upload.spec.ts create mode 100644 frontend/e2e/specs/flows/assistants.spec.ts create mode 100644 frontend/e2e/specs/flows/interrupt.spec.ts create mode 100644 frontend/e2e/specs/flows/live-panels.spec.ts create mode 100644 frontend/e2e/specs/flows/report.spec.ts create mode 100644 frontend/e2e/specs/flows/tab-deeplink.spec.ts create mode 100644 frontend/e2e/specs/real/account-password.spec.ts create mode 100644 frontend/e2e/specs/real/resources-download.spec.ts create mode 100644 frontend/e2e/specs/real/resources-upload-limits.spec.ts create mode 100644 frontend/e2e/specs/settings/account.spec.ts create mode 100755 frontend/e2e/tools/review-sandbox.sh create mode 100644 frontend/e2e/tools/review-sandbox.unit.test.ts create mode 100644 frontend/src/components/shared/inline-edit/inline-edit-input.test.tsx create mode 100644 frontend/src/components/shared/markdown-editor/markdown-editor-focus.ts create mode 100644 frontend/src/components/shared/markdown-editor/markdown-editor-toolbar.test.tsx create mode 100644 frontend/src/components/ui/dialog.test.tsx create mode 100644 frontend/src/components/ui/sheet.test.tsx create mode 100644 frontend/src/features/flows/flow-form.test.tsx create mode 100644 frontend/src/features/flows/flow-scroll-to-latest.test.tsx create mode 100644 frontend/src/lib/errors.test.ts create mode 100644 frontend/src/lib/errors.ts rename frontend/src/{components/shared/detail-navigation => lib}/text-filter.test.ts (100%) rename frontend/src/{components/shared/detail-navigation => lib}/text-filter.ts (87%) create mode 100644 frontend/src/pages/flows/flow-report.test.tsx create mode 100644 frontend/src/pages/knowledges/knowledge.test.tsx create mode 100644 frontend/src/providers/flow-provider.test.ts diff --git a/.dockerignore b/.dockerignore index 5c3616dc..48c900a5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,9 +1,16 @@ frontend/coverage frontend/dist frontend/node_modules +frontend/e2e/test-results +frontend/e2e/playwright-report +frontend/e2e/blob-report +frontend/e2e/.auth +**/*.tsbuildinfo frontend/ssl **/*.log **/*.env +**/.env.* +!**/.env.example **/.DS_Store **/Thumbs.db diff --git a/.github/scripts/codegen-inputs-changed.sh b/.github/scripts/codegen-inputs-changed.sh new file mode 100755 index 00000000..0efa1041 --- /dev/null +++ b/.github/scripts/codegen-inputs-changed.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Decide whether a run has to re-check that frontend/src/graphql/types.ts is fresh. +# Echoes `changed=true|false`. Split out of ci.yml so the range selection is testable +# (frontend/e2e/ci-codegen-gate.unit.test.ts drives it over real git fixtures). +# +# Usage: codegen-inputs-changed.sh +set -uo pipefail + +EVENT="${1:-}" +BEFORE="${2:-}" +BASE_SHA="${3:-}" +HEAD_SHA="${4:-}" + +# On pull_request, `before` is the PR's PREVIOUS HEAD, so a follow-up push diffs a range +# that no longer contains the commit that edited the schema — the gate would skip itself +# on every push after the first. The PR's base is the range that always spans the change. +if [ "$EVENT" = "pull_request" ]; then + base="$BASE_SHA" +else + base="$BEFORE" +fi + +# When the range can't be resolved (new branch, force-push, tag), check anyway. +# stdout is consumed verbatim as a workflow output, so diagnostics go to stderr. +if [ -z "$base" ] || [ "$base" = "0000000000000000000000000000000000000000" ] \ + || ! git cat-file -e "$base^{commit}" 2>/dev/null; then + echo "reason=unresolvable-base" >&2 + echo "changed=true" + exit 0 +fi + +# The generated file is in the list too: a push that edits only it would otherwise skip +# the check, and the drift surfaces later on someone else's unrelated codegen push. +INPUTS=( + backend/pkg/graph/schema.graphqls + frontend/graphql-schema.graphql + frontend/graphql-codegen.ts + frontend/pnpm-lock.yaml + frontend/src/graphql/types.ts +) + +if ! files=$(git diff --name-only "$base" "$HEAD_SHA"); then + echo "reason=diff-failed" >&2 + echo "changed=true" + exit 0 +fi + +for input in "${INPUTS[@]}"; do + case $'\n'"$files"$'\n' in + *$'\n'"$input"$'\n'*) + echo "changed=true" + exit 0 + ;; + esac +done + +echo "changed=false" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 346cdcfb..558d4c44 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,13 +1,23 @@ name: Docker build and push on: + # pull_request so the lint/codegen/test gate runs on fork PRs too: a fork's commits never push to + # this repo, so a push-only trigger let a fork edit the schema without regenerating types.ts (or + # ship a lint/test failure) and merge green, since e2e.yml's tsc build cannot see stale codegen. + # lint-and-test uses no secrets and is fork-safe; docker-build stays gated to main/tags below. + pull_request: + types: [opened, synchronize, reopened] push: branches: - - "**" + - main tags: - "v[0-9]+.[0-9]+.[0-9]+" workflow_dispatch: +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: read @@ -32,7 +42,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: '1.24' + go-version: "1.24" cache: true cache-dependency-path: backend/go.sum @@ -47,8 +57,8 @@ jobs: uses: actions/setup-node@v6 with: node-version-file: frontend/.nvmrc - cache: 'pnpm' - cache-dependency-path: 'frontend/pnpm-lock.yaml' + cache: "pnpm" + cache-dependency-path: "frontend/pnpm-lock.yaml" # Frontend lint and test - name: Frontend - Install dependencies @@ -70,21 +80,14 @@ jobs: # Only when a codegen input moved. The lockfile is included on purpose: a codegen # version bump can change the output, and skipping it there would let types.ts go # stale and fail the next schema push for something its author did not do. - # When the range can't be resolved (new branch, force-push, tag), check anyway. - name: Frontend - Detect GraphQL codegen input changes id: codegen-inputs run: | - base='${{ github.event.before }}' - if [ -z "$base" ] || [ "$base" = "0000000000000000000000000000000000000000" ] \ - || ! git cat-file -e "$base^{commit}" 2>/dev/null; then - echo "changed=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - if git diff --name-only "$base" "${{ github.sha }}" | grep -qE '^(backend/pkg/graph/schema\.graphqls|frontend/graphql-schema\.graphql|frontend/graphql-codegen\.ts|frontend/pnpm-lock\.yaml)$'; then - echo "changed=true" >> "$GITHUB_OUTPUT" - else - echo "changed=false" >> "$GITHUB_OUTPUT" - fi + .github/scripts/codegen-inputs-changed.sh \ + '${{ github.event_name }}' \ + '${{ github.event.before }}' \ + '${{ github.event.pull_request.base.sha }}' \ + '${{ github.sha }}' >> "$GITHUB_OUTPUT" # The app ships the operations compiled into src/graphql/types.ts; regenerate and # diff so it can't drift from graphql-schema.graphql (the codegen input the stand's @@ -133,17 +136,17 @@ jobs: PACKAGE_VER=${LATEST_TAG#v} CURRENT_COMMIT=$(git rev-parse HEAD) TAG_COMMIT=$(git rev-list -n 1 "$LATEST_TAG" 2>/dev/null || echo "") - + if [ "$CURRENT_COMMIT" != "$TAG_COMMIT" ]; then PACKAGE_REV=$(git rev-parse --short HEAD) else PACKAGE_REV="" fi - + LDFLAGS="-X pentagi/pkg/version.PackageName=pentagi -X pentagi/pkg/version.PackageVer=${PACKAGE_VER} -X pentagi/pkg/version.PackageRev=${PACKAGE_REV}" - + echo "Building with version: ${PACKAGE_VER}${PACKAGE_REV:+-$PACKAGE_REV}" - + # Build for AMD64 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "$LDFLAGS" -o /tmp/pentagi-amd64 ./cmd/pentagi echo "✓ Successfully built for linux/amd64" @@ -179,13 +182,13 @@ jobs: # Get latest tag version (without 'v' prefix) LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") VERSION=${LATEST_TAG#v} - + # Get current commit hash CURRENT_COMMIT=$(git rev-parse HEAD) - + # Get commit hash of the latest tag TAG_COMMIT=$(git rev-list -n 1 "$LATEST_TAG" 2>/dev/null || echo "") - + # Set revision only if current commit differs from tag commit if [ "$CURRENT_COMMIT" != "$TAG_COMMIT" ]; then PACKAGE_REV=$(git rev-parse --short HEAD) @@ -205,7 +208,7 @@ jobs: echo "patch=${VERSION}" >> $GITHUB_OUTPUT echo " Docker tags: latest, ${major}, ${major}.${minor}, ${VERSION}" fi - + echo "version=${VERSION}" >> $GITHUB_OUTPUT - name: Generate Docker metadata diff --git a/.github/workflows/e2e-report.yml b/.github/workflows/e2e-report.yml index 24aa7e79..d28ea491 100644 --- a/.github/workflows/e2e-report.yml +++ b/.github/workflows/e2e-report.yml @@ -28,6 +28,7 @@ jobs: timeout-minutes: 5 steps: - name: Download results + id: results continue-on-error: true uses: actions/download-artifact@v4 with: @@ -55,15 +56,25 @@ jobs: const fs = require('fs'); const run = context.payload.workflow_run; - // workflow_run.pull_requests is empty for fork PRs — resolve by head SHA. - let prNumber = run.pull_requests[0]?.number; + // Match the PR whose head is still this run's — one commit can belong to several PRs, so + // taking the first association (either source) could hand the report to a stale or + // foreign one. Same filter on both paths: workflow_run.pull_requests (empty for forks) + // and the head-SHA lookup that covers them. + // workflow_run.pull_requests carries head.repo.name but no full_name, unlike the API + // lookup — enforce the repo only when it is present, so the primary path still matches. + const isThisRun = (pr) => + pr.head?.sha === run.head_sha && + pr.head?.ref === run.head_branch && + (pr.head?.repo?.full_name == null || pr.head.repo.full_name === run.head_repository?.full_name); + + let prNumber = run.pull_requests.find(isThisRun)?.number; if (!prNumber) { const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ owner: context.repo.owner, repo: context.repo.repo, commit_sha: run.head_sha, }); - prNumber = prs[0]?.number; + prNumber = prs.find(isThisRun)?.number; } if (!prNumber) { core.info(`No PR found for ${run.head_sha}; skipping comment.`); @@ -94,7 +105,15 @@ jobs: return; } - let statsLine = 'The run produced no results file (it likely failed before the tests started).'; + // e2e.yml uploads results.json with `if-no-files-found: ignore`, so a run that never + // wrote one publishes no artifact at all: a failed download means the file is absent, + // a successful download with unreadable stats means it is corrupt. + const resultsDownloaded = '${{ steps.results.outcome }}' === 'success'; + + let statsLine = resultsDownloaded + ? 'The results file could not be parsed, so no test counts are available.' + : 'No results artifact was produced for this run.'; + let statsRead = false; let noFailures = false; try { const report = JSON.parse(fs.readFileSync('results/results.json', 'utf8')); @@ -102,24 +121,39 @@ jobs: const { expected, unexpected, flaky, skipped } = report.stats ?? {}; statsLine = `**${n(expected)} passed**, ${n(unexpected)} failed, ${n(flaky)} flaky, ${n(skipped)} skipped.`; noFailures = n(unexpected) === 0; + statsRead = true; } catch (error) { core.info(`results.json unavailable: ${error}`); } - // Both halves are required: results.json is written before an abort can be - // observed, so a run killed by the global timeout still reports zero failures. - // When the jobs API was unreachable, fall back to results.json alone. - const mockPassed = noFailures && (jobsFetched ? mockJob?.conclusion === 'success' : true); + // The job conclusion is load-bearing, not a nicety: results.json is written before a + // global-timeout abort can be observed (its un-run tests land in `skipped`, so + // `unexpected` stays 0), and only the job's own conclusion reflects the abort. So a + // green needs BOTH zero failures AND a successful job; whenever either source is + // unreadable the verdict is unknown — never a green, and never a red. + const jobFailed = jobsFetched && mockJob?.conclusion !== 'success'; + const mockStatus = jobFailed || (statsRead && !noFailures) + ? 'fail' + : statsRead && jobsFetched + ? 'pass' + : 'unknown'; // Key the advisory on the e2e-visual-diffs artifact (uploaded only when the snapshot // step itself failed), not on the visual job's conclusion — a tag-guard, install or // build failure would otherwise read as "snapshots differ". const visualDiffers = '${{ steps.visualdiffs.outcome }}' === 'success'; - const conclusion = !mockPassed ? '❌' : visualDiffers ? '⚠️' : '✅'; + const conclusion = mockStatus === 'fail' ? '❌' : mockStatus === 'unknown' || visualDiffers ? '⚠️' : '✅'; const marker = ''; - const advisoryLine = visualDiffers - ? '⚠️ **Visual snapshots differ** — advisory only, does not block merge. Download the `e2e-visual-diffs` artifact to compare expected / actual / diff.' + const unconfirmedReason = !jobsFetched && !statsRead + ? 'neither the workflow jobs API nor `results.json` could be read' + : !jobsFetched + ? 'the workflow jobs API was unreachable, so this reflects `results.json` alone, which cannot distinguish a clean pass from a run killed mid-flight' + : '`results.json` could not be read, so this reflects the job conclusion alone and no test counts were checked'; + const advisoryLine = mockStatus === 'unknown' + ? `⚠️ **Result unconfirmed** — ${unconfirmedReason}. Check the run before merging.` + : visualDiffers + ? '⚠️ **Visual snapshots differ** — advisory only, does not block merge. Download the `e2e-visual-diffs` artifact to compare expected / actual / diff (a missing baseline uploads actual only).' : null; const body = [ marker, diff --git a/.github/workflows/e2e-stand.yml b/.github/workflows/e2e-stand.yml index b7058167..ccc3dfb5 100644 --- a/.github/workflows/e2e-stand.yml +++ b/.github/workflows/e2e-stand.yml @@ -10,27 +10,26 @@ on: pull_request: types: [labeled] workflow_dispatch: - inputs: - tier: - description: "Run the stand tier" - type: choice - options: - - stand - - all - default: stand permissions: contents: read +# Keyed on the label too: concurrency is evaluated for the whole run before the job's `if`, so a run +# started by an unrelated label would share this group and cancel an approved stand run mid-flight. concurrency: - group: e2e-stand-${{ github.event.pull_request.number || github.run_id }} + group: e2e-stand-${{ github.event.pull_request.number || github.run_id }}-${{ github.event.label.name }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: e2e-stand: + # GitHub withholds secrets from fork `pull_request` runs: without the fork check a labelled fork + # PR holds reviewers for an environment approval and then fails on empty credentials. Assert repo + # identity rather than negating `head.repo.fork`: a deleted fork sends `head.repo: null`, and + # GitHub coerces null and false alike to 0, so the negation lets that PR through. if: > github.event_name == 'workflow_dispatch' || - contains(github.event.pull_request.labels.*.name, 'e2e:stand') + (github.event.label.name == 'e2e:stand' && + github.event.pull_request.head.repo.full_name == github.repository) runs-on: ubuntu-latest timeout-minutes: 30 environment: e2e-stand @@ -78,11 +77,41 @@ jobs: E2E_USER: ${{ secrets.E2E_STAND_USER }} E2E_PASSWORD: ${{ secrets.E2E_STAND_PASSWORD }} - # Traces, videos and screenshots are all disabled on this tier — they would - # embed the live session cookie, the stand URL or the login into a - # public-repo artifact. results.json carries none of those. - - name: Upload report + # Traces, videos and screenshots are disabled on this tier. results.json still + # embeds the stand URL (baseURL in navigation/locator error messages) and the + # user (in locator text) on a failing run, so redact the secrets before the + # public-repo upload — GitHub masks secrets in logs, never in artifacts. + - name: Redact secrets from report + id: redact if: always() + working-directory: frontend + run: | + node -e ' + const fs = require("fs"); + const f = "e2e/test-results/results.json"; + if (!fs.existsSync(f)) process.exit(0); + let s = fs.readFileSync(f, "utf8"); + // Derived forms too, not just the raw value: auth.setup.ts builds a locator name from + // the regex-escaped user, so a failed login writes `user@host\.com` into the error — + // and since this redacts the JSON text, that backslash is itself encoded as `\\`. + const regexEscaped = (v) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const jsonEncoded = (v) => JSON.stringify(v).slice(1, -1); + for (const v of [process.env.E2E_BASE_URL, process.env.E2E_USER, process.env.E2E_PASSWORD]) { + if (!v) continue; + const forms = [v, regexEscaped(v), encodeURIComponent(v)]; + for (const form of new Set([...forms, ...forms.map(jsonEncoded)])) { + s = s.split(form).join(""); + } + } + fs.writeFileSync(f, s); + ' + env: + E2E_BASE_URL: ${{ secrets.E2E_STAND_URL }} + E2E_USER: ${{ secrets.E2E_STAND_USER }} + E2E_PASSWORD: ${{ secrets.E2E_STAND_PASSWORD }} + + - name: Upload report + if: always() && steps.redact.outcome == 'success' uses: actions/upload-artifact@v4 with: name: e2e-stand-report diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index e2d0f898..98d54fe9 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -117,11 +117,14 @@ jobs: # Compare the container's own Playwright build to @playwright/test rather than a # hardcoded literal, so bumping the package without re-tagging the image (or vice - # versa) fails here instead of drifting the pixel baselines. + # versa) fails here instead of drifting the pixel baselines. Read the version from + # the image's own /ms-playwright/.docker-info: this image ships no global + # playwright, so `npx playwright --version` (before pnpm install) would fetch the + # registry latest and compare the package against that, not against the container. - name: Verify container image matches @playwright/test run: | pkg="$(node -p "require('./frontend/package.json').devDependencies['@playwright/test'].replace(/[^0-9.]/g, '')")" - container="$(npx playwright --version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)" + container="$(node -p "JSON.parse(require('fs').readFileSync('/ms-playwright/.docker-info','utf8')).driverVersion")" if [ "$pkg" != "$container" ]; then echo "::error::e2e-visual container ships Playwright $container but @playwright/test is $pkg — bump the container tag to v$pkg-noble and regenerate baselines" exit 1 @@ -153,10 +156,25 @@ jobs: working-directory: frontend run: E2E_VISUAL=1 E2E_VISUAL_CONTAINER=1 npx playwright test -c e2e/playwright.config.ts - # Only on a real snapshot diff (not a tag-guard/install/build failure), so its - # presence is the precise "snapshots differ" signal the report keys the advisory on. - - name: Upload diffs + # The step failing is not the same signal as a snapshot differing: a webServer or build + # failure inside it also exits non-zero and still leaves results.json behind, which would + # publish the artifact and make the report say "snapshots differ". Look for the diff images. + - name: Detect snapshot diffs + id: diffs if: failure() && steps.snapshots.outcome == 'failure' + working-directory: frontend + run: | + # -actual.png too: a missing baseline fails the test but writes no diff image, and only the + # snapshot matcher writes either name — a non-snapshot failure leaves test-failed-N.png. + if find e2e/test-results \( -name '*-diff.png' -o -name '*-actual.png' \) -print -quit | grep -q .; then + echo "found=true" >> "$GITHUB_OUTPUT" + else + echo "found=false" >> "$GITHUB_OUTPUT" + fi + + # Its presence is the precise "snapshots differ" signal the report keys the advisory on. + - name: Upload diffs + if: always() && steps.diffs.outputs.found == 'true' uses: actions/upload-artifact@v4 with: name: e2e-visual-diffs @@ -169,8 +187,7 @@ jobs: e2e-local: if: > github.event_name == 'schedule' || - (github.event_name == 'workflow_dispatch' && - contains(fromJSON('["local", "all"]'), inputs.tier)) + (github.event_name == 'workflow_dispatch' && inputs.tier == 'local') runs-on: ubuntu-latest timeout-minutes: 40 steps: diff --git a/CLAUDE.md b/CLAUDE.md index b92e1953..3a6da8ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co 1. **Always use English** for all interactions, responses, explanations, and questions with users. 2. **Password Complexity Requirements**: For all password-related development (registration, password reset, API token generation, etc.), enforce the same policy in **both** backend and frontend — never rely on frontend validation alone. Source of truth, keep the two in sync: `backend/pkg/server/models/init.go` → `strongPasswordValidatorString` and `frontend/src/features/authentication/password-change-form.tsx` (zod schema). The policy: - - Length 8–100 characters. + - Length 8–72 characters (72 **bytes**, the most bcrypt will hash — a longer value fails inside `bcrypt.GenerateFromPassword`, after validation). - A password is valid if it is **either** 16+ characters (any composition), **or** 8–15 characters containing at least 1 lowercase letter, 1 uppercase letter, 1 number, and 1 special character from `!@#$&*`. ## Project Overview diff --git a/README.md b/README.md index 44e6cf11..f9b315d9 100644 --- a/README.md +++ b/README.md @@ -1943,7 +1943,7 @@ PROXY_URL=http://your-proxy:8080 #### Custom Provider Config and Models (advanced) -By default the Bedrock provider uses a per-agent config and model catalog compiled into the binary. Two optional paths override them without rebuilding: +By default the Bedrock provider uses a per-agent config and model catalog compiled into the binary. One optional path overrides them without rebuilding: - `BEDROCK_CONFIG_PATH` — a YAML file (same shape as the other provider configs) that **replaces** the built-in per-agent model/price assignments. See [`examples/configs/bedrock-glm-flash.provider.yml`](examples/configs/bedrock-glm-flash.provider.yml). @@ -1952,9 +1952,9 @@ This is useful to expose a Bedrock model newer than the compiled-in catalog — With Docker Compose, set the host-side mount source and the in-container path together: ```bash -# host files mounted into the container +# host file mounted into the container at /opt/pentagi/conf/bedrock.provider.yml PENTAGI_BEDROCK_CONFIG_PATH=./examples/configs/bedrock-glm-flash.provider.yml -# tell the backend to read the mounted files +# tell the backend to read the mounted file BEDROCK_CONFIG_PATH=/opt/pentagi/conf/bedrock.provider.yml ``` diff --git a/backend/pkg/config/config.go b/backend/pkg/config/config.go index 1c50af77..bd2c0ec2 100644 --- a/backend/pkg/config/config.go +++ b/backend/pkg/config/config.go @@ -257,7 +257,6 @@ type Config struct { } func NewConfig() (*Config, error) { - // Attempt to load .env file (silently ignore if not present) _ = godotenv.Load() var config Config @@ -282,12 +281,10 @@ func NewConfig() (*Config, error) { } func ensureInstallationID(config *Config) { - // validate current installation ID from environment if config.InstallationID != "" && uuid.Validate(config.InstallationID) == nil { return } - // check local file for installation ID installationIDPath := filepath.Join(config.DataDir, "installation_id") installationID, err := os.ReadFile(installationIDPath) if err != nil { @@ -298,17 +295,14 @@ func ensureInstallationID(config *Config) { config.InstallationID = uuid.New().String() } - // write installation ID to local file _ = os.WriteFile(installationIDPath, []byte(config.InstallationID), 0644) } func ensureLicenseKey(config *Config) { - // validate current license key from environment if config.LicenseKey == "" { return } - // check license key validity, if invalid, set to empty info, err := sdk.IntrospectLicenseKey(config.LicenseKey) if err != nil { config.LicenseKey = "" @@ -364,7 +358,6 @@ func (c *Config) GetSecretPatterns() []patterns.Pattern { continue } - // escape regex special characters escaped := regexp.QuoteMeta(trimmed) pattern := patterns.Pattern{ Name: s.name, diff --git a/backend/pkg/controller/aslog.go b/backend/pkg/controller/aslog.go index ab1a48ef..ff19e8cd 100644 --- a/backend/pkg/controller/aslog.go +++ b/backend/pkg/controller/aslog.go @@ -287,7 +287,7 @@ func (aslw *flowAssistantLogWorker) workerMsgUpdater( contentBuf := bytes.NewBuffer(contentData) thinkingData := make([]byte, 0, defaultMaxMessageLength) thinkingBuf := bytes.NewBuffer(thinkingData) - wasUpdated := false // track if we actually updated the record + wasUpdated := false msgLog, err := aslw.db.GetFlowAssistantLog(ctx, msgID) if err != nil { diff --git a/backend/pkg/controller/assistant.go b/backend/pkg/controller/assistant.go index d32b0aee..d2d3cdab 100644 --- a/backend/pkg/controller/assistant.go +++ b/backend/pkg/controller/assistant.go @@ -559,7 +559,7 @@ func (aw *assistantWorker) PutInput(ctx context.Context, input string, useAgents select { case err := <-ain.done: - return err // nil or error + return err case <-timer.C: return nil // no early error case <-aw.ctx.Done(): diff --git a/backend/pkg/controller/flow.go b/backend/pkg/controller/flow.go index 8cf3a5b5..7b0d684f 100644 --- a/backend/pkg/controller/flow.go +++ b/backend/pkg/controller/flow.go @@ -124,7 +124,7 @@ type flowInput struct { func NewFlowWorker( ctx context.Context, fwc newFlowWorkerCtx, -) (FlowWorker, error) { +) (_ FlowWorker, err error) { ctx, span := obs.Observer.NewSpan(ctx, obs.SpanKindInternal, "controller.NewFlowWorker") defer span.End() @@ -152,6 +152,25 @@ func NewFlowWorker( }) logger.Info("flow created in DB") + // Held separately because `flow` is reassigned below by UpdateFlow, which — like every sqlc query — + // returns a zero-valued row alongside an error, and a cleanup aimed at id 0 deletes nothing. + flowID := flow.ID + + // DeleteFlow is a soft delete and the listings filter on deleted_at, so this is what keeps a flow + // the caller was told it never got out of the UI. Disarmed once the worker goroutine owns the flow — + // from there a failure is the worker's to unwind, not ours. + cleanupFlow := true + defer func() { + if err == nil || !cleanupFlow { + return + } + + // The caller's context is usually already cancelled by whatever failed. + if _, cerr := fwc.db.DeleteFlow(context.WithoutCancel(ctx), flowID); cerr != nil { + logger.WithError(cerr).Error("failed to drop the flow left behind by a failed start") + } + }() + user, err := fwc.db.GetUser(ctx, fwc.userID) if err != nil { logger.WithError(err).Error("failed to get user") @@ -282,6 +301,8 @@ func NewFlowWorker( fw.flowCtx.Publisher.FlowCreated(ctx, flow, containers) + cleanupFlow = false + fw.wg.Add(1) go fw.worker() @@ -630,7 +651,7 @@ func (fw *flowWorker) PutInput( select { case err := <-flin.done: - return err // nil or error + return err case <-timer.C: return nil // no early error case <-fw.ctx.Done(): diff --git a/backend/pkg/csum/chain_summary.go b/backend/pkg/csum/chain_summary.go index bf9e1c78..32b078f2 100644 --- a/backend/pkg/csum/chain_summary.go +++ b/backend/pkg/csum/chain_summary.go @@ -110,7 +110,6 @@ func (s *summarizer) SummarizeChain( chain []llms.MessageContent, tcIDTemplate string, ) ([]llms.MessageContent, error) { - // Skip summarization for empty chains if len(chain) == 0 { return chain, nil } @@ -191,7 +190,6 @@ func summarizeSections( continue } - // Add human message if it exists var humanMessages []llms.MessageContent if section.Header.HumanMessage != nil { humanMessages = append(humanMessages, *section.Header.HumanMessage) @@ -201,7 +199,6 @@ func summarizeSections( go func(section *cast.ChainSection, i int) { defer wg.Done() - // Generate summary summaryText, err := GenerateSummary(ctx, handler, humanMessages, messagesToSummarize) if err != nil { ch <- fmt.Errorf("section %d summary generation failed: %w", i, err) @@ -286,13 +283,11 @@ func summarizeLastSection( messagesToSummarize = append(messagesToSummarize, pair.Messages()...) } - // Add human message if it exists var humanMessages []llms.MessageContent if lastSection.Header.HumanMessage != nil { humanMessages = append(humanMessages, *lastSection.Header.HumanMessage) } - // Generate summary summaryText, err := GenerateSummary(ctx, handler, humanMessages, messagesToSummarize) if err != nil { // If summary generation fails, just keep the most recent messages @@ -405,7 +400,6 @@ func summarizeOversizedBodyPairs( continue } - // Add human message if it exists var humanMessages []llms.MessageContent if section.Header.HumanMessage != nil { humanMessages = append(humanMessages, *section.Header.HumanMessage) @@ -415,7 +409,6 @@ func summarizeOversizedBodyPairs( go func(pair *cast.BodyPair, i int) { defer wg.Done() - // Generate summary summaryText, err := GenerateSummary(ctx, handler, humanMessages, pairMessages) if err != nil { return // It's should collected next step in summarizeLastSection function @@ -549,7 +542,6 @@ func summarizeQAPairs( } } - // Generate summary var ( err error aiSummary string @@ -697,7 +689,6 @@ func convertSectionsHeadersToMessages(sections []*cast.ChainSection) []llms.Mess var messages []llms.MessageContent for _, section := range sections { - // Add human message if it exists if section.Header.HumanMessage != nil { messages = append(messages, *section.Header.HumanMessage) } @@ -715,7 +706,6 @@ func convertSectionsPairsToMessages(sections []*cast.ChainSection) []llms.Messag var messages []llms.MessageContent for _, section := range sections { - // Get all messages from each body pair using the Messages() method for _, pair := range section.Body { pairMessages := pair.Messages() messages = append(messages, pairMessages...) diff --git a/backend/pkg/database/converter/analytics.go b/backend/pkg/database/converter/analytics.go index 809e4d40..546c449a 100644 --- a/backend/pkg/database/converter/analytics.go +++ b/backend/pkg/database/converter/analytics.go @@ -22,7 +22,6 @@ func CalculateSubtaskDuration(subtask database.Subtask, msgchains []database.Msg return 0 } - // Calculate linear duration var linearDuration float64 if subtask.Status == database.SubtaskStatusRunning { // For running subtasks: from created_at to now diff --git a/backend/pkg/database/converter/converter.go b/backend/pkg/database/converter/converter.go index e24a5167..63ab9149 100644 --- a/backend/pkg/database/converter/converter.go +++ b/backend/pkg/database/converter/converter.go @@ -695,6 +695,18 @@ func ConvertAgentConfigToGqlModel(ac *pconfig.AgentConfig) *model.AgentConfig { if ac.PresencePenalty != 0 { result.PresencePenalty = &ac.PresencePenalty } + if ac.MinP != 0 { + result.MinP = &ac.MinP + } + if ac.N != 0 { + result.N = &ac.N + } + if ac.JSON { + result.JSON = &ac.JSON + } + if ac.ResponseMIMEType != "" { + result.ResponseMimeType = &ac.ResponseMIMEType + } if !ac.Reasoning.IsZero() { reasoning := &model.ReasoningConfig{} @@ -796,6 +808,21 @@ func ConvertAgentConfigFromGqlModel(ac *model.AgentConfig) *pconfig.AgentConfig if ac.PresencePenalty != nil { rawConfig["presence_penalty"] = *ac.PresencePenalty } + // BuildOptions gates these on the key being present, not on its value, so a zero has to be + // written as an absent key — otherwise `json: false` would switch JSON mode on and `n: 0` + // would emit an invalid request parameter. + if ac.MinP != nil && *ac.MinP != 0 { + rawConfig["min_p"] = *ac.MinP + } + if ac.N != nil && *ac.N != 0 { + rawConfig["n"] = *ac.N + } + if ac.JSON != nil && *ac.JSON { + rawConfig["json"] = *ac.JSON + } + if ac.ResponseMimeType != nil && *ac.ResponseMimeType != "" { + rawConfig["response_mime_type"] = *ac.ResponseMimeType + } if ac.Reasoning != nil { reasoning := map[string]any{} @@ -932,7 +959,6 @@ func ConvertUsageStats[T UsageStatsRow](stats T) *model.UsageStats { var in, out, cacheIn, cacheOut int64 var costIn, costOut float64 - // Extract fields based on type switch v := any(stats).(type) { case database.GetFlowUsageStatsRow: in, out = v.TotalUsageIn, v.TotalUsageOut @@ -1107,7 +1133,6 @@ func ConvertToolcallsStats[T ToolcallsStatsRow](stats T) *model.ToolcallsStats { var count int64 var duration float64 - // Extract fields based on type switch v := any(stats).(type) { case database.GetFlowToolcallsStatsRow: count, duration = v.TotalCount, v.TotalDurationSeconds @@ -1177,7 +1202,6 @@ func isAgentTool(functionName string) bool { if !exists { return false } - // Agent tools include AgentToolType and StoreAgentResultToolType return toolType == tools.AgentToolType || toolType == tools.StoreAgentResultToolType } @@ -1217,7 +1241,6 @@ func ConvertFunctionToolcallsStatsForFlow(stats []database.GetToolcallsStatsByFu func ConvertFlowsStats[T FlowsStatsRow](stats T) *model.FlowsStats { var flowsCount, tasksCount, subtasksCount, assistantsCount int64 - // Extract fields based on type switch v := any(stats).(type) { case database.GetUserTotalFlowsStatsRow: flowsCount, tasksCount, subtasksCount, assistantsCount = v.TotalFlowsCount, v.TotalTasksCount, v.TotalSubtasksCount, v.TotalAssistantsCount @@ -1235,7 +1258,6 @@ func ConvertFlowsStats[T FlowsStatsRow](stats T) *model.FlowsStats { func ConvertFlowStats[T FlowStatsRow](stats T) *model.FlowStats { var tasksCount, subtasksCount, assistantsCount int64 - // Extract fields based on type switch v := any(stats).(type) { case database.GetFlowStatsRow: tasksCount, subtasksCount, assistantsCount = v.TotalTasksCount, v.TotalSubtasksCount, v.TotalAssistantsCount diff --git a/backend/pkg/database/converter/converter_test.go b/backend/pkg/database/converter/converter_test.go index 5c7805d6..62ff707a 100644 --- a/backend/pkg/database/converter/converter_test.go +++ b/backend/pkg/database/converter/converter_test.go @@ -4,6 +4,7 @@ import ( "testing" "pentagi/pkg/graph/model" + "pentagi/pkg/providers/openai" "pentagi/pkg/providers/pconfig" "github.com/stretchr/testify/assert" @@ -207,3 +208,77 @@ func TestConvertAgentConfigExtraBodyNilStaysNil(t *testing.T) { assert.Nil(t, ConvertAgentConfigToGqlModel(from).ExtraBody) } + +func ptr[T any](value T) *T { return &value } + +func TestConvertAgentConfigCallOptionFieldsRoundTrip(t *testing.T) { + from := ConvertAgentConfigFromGqlModel(&model.AgentConfig{ + Model: "m", + MinP: ptr(0.05), + N: ptr(3), + JSON: ptr(true), + ResponseMimeType: ptr("application/json"), + }) + require.NotNil(t, from) + assert.Equal(t, 0.05, from.MinP) + assert.Equal(t, 3, from.N) + assert.True(t, from.JSON) + assert.Equal(t, "application/json", from.ResponseMIMEType) + + back := ConvertAgentConfigToGqlModel(from) + require.NotNil(t, back) + require.NotNil(t, back.MinP) + require.NotNil(t, back.N) + require.NotNil(t, back.JSON) + require.NotNil(t, back.ResponseMimeType) + assert.Equal(t, 0.05, *back.MinP) + assert.Equal(t, 3, *back.N) + assert.True(t, *back.JSON) + assert.Equal(t, "application/json", *back.ResponseMimeType) +} + +// pconfig gates each emitter on the raw key being PRESENT, not on its value, so a zero written +// through would switch the option on: `json: false` must not reach the LLM as WithJSONMode(). +func TestConvertAgentConfigZeroCallOptionFieldsStayAbsent(t *testing.T) { + from := ConvertAgentConfigFromGqlModel(&model.AgentConfig{ + Model: "m", + MinP: ptr(0.0), + N: ptr(0), + JSON: ptr(false), + ResponseMimeType: ptr(""), + }) + require.NotNil(t, from) + + options := from.BuildOptions() + call := llms.CallOptions{} + for _, option := range options { + option(&call) + } + + assert.False(t, call.JSONMode, "json:false must not enable JSON mode") + assert.Nil(t, call.N, "n:0 must not reach the request as an explicit parameter") + assert.Empty(t, call.ResponseMIMEType) +} + +// The shipped simple_json default carries `json: true`; saving a provider through the UI used to +// strip it, so the reloaded config called the LLM without JSON mode while the built-in one did not. +func TestShippedSimpleJSONKeepsJSONModeThroughTheGraphQLRoundTrip(t *testing.T) { + pc, err := openai.DefaultProviderConfig() + require.NoError(t, err) + require.NotNil(t, pc.SimpleJSON) + require.True(t, pc.SimpleJSON.JSON, "the shipped default is what makes this test meaningful") + + gql := ConvertProviderConfigToGqlModel(pc) + require.NotNil(t, gql) + + restored := ConvertAgentsConfigFromGqlModel(gql) + require.NotNil(t, restored) + require.NotNil(t, restored.SimpleJSON) + + call := llms.CallOptions{} + for _, option := range restored.SimpleJSON.BuildOptions() { + option(&call) + } + + assert.True(t, call.JSONMode, "the simple_json agent must still ask for JSON mode after a save") +} diff --git a/backend/pkg/database/database.go b/backend/pkg/database/database.go index 96abc95a..1c4222d6 100644 --- a/backend/pkg/database/database.go +++ b/backend/pkg/database/database.go @@ -70,15 +70,13 @@ func SanitizeUTF8(msg string) string { } var builder strings.Builder - builder.Grow(len(msg)) // Pre-allocate for efficiency + builder.Grow(len(msg)) for i := 0; i < len(msg); { - // Explicitly skip null bytes if msg[i] == '\x00' { i++ continue } - // Decode rune and check for errors r, size := utf8.DecodeRuneInString(msg[i:]) if r == utf8.RuneError && size == 1 { // Invalid UTF-8 byte, replace with Unicode replacement character diff --git a/backend/pkg/graph/generated.go b/backend/pkg/graph/generated.go index e6c1ac11..aac0cce2 100644 --- a/backend/pkg/graph/generated.go +++ b/backend/pkg/graph/generated.go @@ -78,14 +78,18 @@ type ComplexityRoot struct { AgentConfig struct { ExtraBody func(childComplexity int) int FrequencyPenalty func(childComplexity int) int + JSON func(childComplexity int) int MaxLength func(childComplexity int) int MaxTokens func(childComplexity int) int MinLength func(childComplexity int) int + MinP func(childComplexity int) int Model func(childComplexity int) int + N func(childComplexity int) int PresencePenalty func(childComplexity int) int Price func(childComplexity int) int Reasoning func(childComplexity int) int RepetitionPenalty func(childComplexity int) int + ResponseMimeType func(childComplexity int) int Temperature func(childComplexity int) int TopK func(childComplexity int) int TopP func(childComplexity int) int @@ -1035,6 +1039,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.AgentConfig.FrequencyPenalty(childComplexity), true + case "AgentConfig.json": + if e.complexity.AgentConfig.JSON == nil { + break + } + + return e.complexity.AgentConfig.JSON(childComplexity), true + case "AgentConfig.maxLength": if e.complexity.AgentConfig.MaxLength == nil { break @@ -1056,6 +1067,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.AgentConfig.MinLength(childComplexity), true + case "AgentConfig.minP": + if e.complexity.AgentConfig.MinP == nil { + break + } + + return e.complexity.AgentConfig.MinP(childComplexity), true + case "AgentConfig.model": if e.complexity.AgentConfig.Model == nil { break @@ -1063,6 +1081,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.AgentConfig.Model(childComplexity), true + case "AgentConfig.n": + if e.complexity.AgentConfig.N == nil { + break + } + + return e.complexity.AgentConfig.N(childComplexity), true + case "AgentConfig.presencePenalty": if e.complexity.AgentConfig.PresencePenalty == nil { break @@ -1091,6 +1116,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.AgentConfig.RepetitionPenalty(childComplexity), true + case "AgentConfig.responseMimeType": + if e.complexity.AgentConfig.ResponseMimeType == nil { + break + } + + return e.complexity.AgentConfig.ResponseMimeType(childComplexity), true + case "AgentConfig.temperature": if e.complexity.AgentConfig.Temperature == nil { break @@ -9776,6 +9808,170 @@ func (ec *executionContext) fieldContext_AgentConfig_presencePenalty(_ context.C return fc, nil } +func (ec *executionContext) _AgentConfig_minP(ctx context.Context, field graphql.CollectedField, obj *model.AgentConfig) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_AgentConfig_minP(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.MinP, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*float64) + fc.Result = res + return ec.marshalOFloat2ᚖfloat64(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_AgentConfig_minP(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AgentConfig", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Float does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _AgentConfig_n(ctx context.Context, field graphql.CollectedField, obj *model.AgentConfig) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_AgentConfig_n(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.N, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*int) + fc.Result = res + return ec.marshalOInt2ᚖint(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_AgentConfig_n(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AgentConfig", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _AgentConfig_json(ctx context.Context, field graphql.CollectedField, obj *model.AgentConfig) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_AgentConfig_json(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.JSON, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*bool) + fc.Result = res + return ec.marshalOBoolean2ᚖbool(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_AgentConfig_json(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AgentConfig", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _AgentConfig_responseMimeType(ctx context.Context, field graphql.CollectedField, obj *model.AgentConfig) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_AgentConfig_responseMimeType(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.ResponseMimeType, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_AgentConfig_responseMimeType(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AgentConfig", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _AgentConfig_reasoning(ctx context.Context, field graphql.CollectedField, obj *model.AgentConfig) (ret graphql.Marshaler) { fc, err := ec.fieldContext_AgentConfig_reasoning(ctx, field) if err != nil { @@ -10684,6 +10880,14 @@ func (ec *executionContext) fieldContext_AgentsConfig_simple(_ context.Context, return ec.fieldContext_AgentConfig_frequencyPenalty(ctx, field) case "presencePenalty": return ec.fieldContext_AgentConfig_presencePenalty(ctx, field) + case "minP": + return ec.fieldContext_AgentConfig_minP(ctx, field) + case "n": + return ec.fieldContext_AgentConfig_n(ctx, field) + case "json": + return ec.fieldContext_AgentConfig_json(ctx, field) + case "responseMimeType": + return ec.fieldContext_AgentConfig_responseMimeType(ctx, field) case "reasoning": return ec.fieldContext_AgentConfig_reasoning(ctx, field) case "price": @@ -10756,6 +10960,14 @@ func (ec *executionContext) fieldContext_AgentsConfig_simpleJson(_ context.Conte return ec.fieldContext_AgentConfig_frequencyPenalty(ctx, field) case "presencePenalty": return ec.fieldContext_AgentConfig_presencePenalty(ctx, field) + case "minP": + return ec.fieldContext_AgentConfig_minP(ctx, field) + case "n": + return ec.fieldContext_AgentConfig_n(ctx, field) + case "json": + return ec.fieldContext_AgentConfig_json(ctx, field) + case "responseMimeType": + return ec.fieldContext_AgentConfig_responseMimeType(ctx, field) case "reasoning": return ec.fieldContext_AgentConfig_reasoning(ctx, field) case "price": @@ -10828,6 +11040,14 @@ func (ec *executionContext) fieldContext_AgentsConfig_primaryAgent(_ context.Con return ec.fieldContext_AgentConfig_frequencyPenalty(ctx, field) case "presencePenalty": return ec.fieldContext_AgentConfig_presencePenalty(ctx, field) + case "minP": + return ec.fieldContext_AgentConfig_minP(ctx, field) + case "n": + return ec.fieldContext_AgentConfig_n(ctx, field) + case "json": + return ec.fieldContext_AgentConfig_json(ctx, field) + case "responseMimeType": + return ec.fieldContext_AgentConfig_responseMimeType(ctx, field) case "reasoning": return ec.fieldContext_AgentConfig_reasoning(ctx, field) case "price": @@ -10900,6 +11120,14 @@ func (ec *executionContext) fieldContext_AgentsConfig_assistant(_ context.Contex return ec.fieldContext_AgentConfig_frequencyPenalty(ctx, field) case "presencePenalty": return ec.fieldContext_AgentConfig_presencePenalty(ctx, field) + case "minP": + return ec.fieldContext_AgentConfig_minP(ctx, field) + case "n": + return ec.fieldContext_AgentConfig_n(ctx, field) + case "json": + return ec.fieldContext_AgentConfig_json(ctx, field) + case "responseMimeType": + return ec.fieldContext_AgentConfig_responseMimeType(ctx, field) case "reasoning": return ec.fieldContext_AgentConfig_reasoning(ctx, field) case "price": @@ -10972,6 +11200,14 @@ func (ec *executionContext) fieldContext_AgentsConfig_generator(_ context.Contex return ec.fieldContext_AgentConfig_frequencyPenalty(ctx, field) case "presencePenalty": return ec.fieldContext_AgentConfig_presencePenalty(ctx, field) + case "minP": + return ec.fieldContext_AgentConfig_minP(ctx, field) + case "n": + return ec.fieldContext_AgentConfig_n(ctx, field) + case "json": + return ec.fieldContext_AgentConfig_json(ctx, field) + case "responseMimeType": + return ec.fieldContext_AgentConfig_responseMimeType(ctx, field) case "reasoning": return ec.fieldContext_AgentConfig_reasoning(ctx, field) case "price": @@ -11044,6 +11280,14 @@ func (ec *executionContext) fieldContext_AgentsConfig_refiner(_ context.Context, return ec.fieldContext_AgentConfig_frequencyPenalty(ctx, field) case "presencePenalty": return ec.fieldContext_AgentConfig_presencePenalty(ctx, field) + case "minP": + return ec.fieldContext_AgentConfig_minP(ctx, field) + case "n": + return ec.fieldContext_AgentConfig_n(ctx, field) + case "json": + return ec.fieldContext_AgentConfig_json(ctx, field) + case "responseMimeType": + return ec.fieldContext_AgentConfig_responseMimeType(ctx, field) case "reasoning": return ec.fieldContext_AgentConfig_reasoning(ctx, field) case "price": @@ -11116,6 +11360,14 @@ func (ec *executionContext) fieldContext_AgentsConfig_adviser(_ context.Context, return ec.fieldContext_AgentConfig_frequencyPenalty(ctx, field) case "presencePenalty": return ec.fieldContext_AgentConfig_presencePenalty(ctx, field) + case "minP": + return ec.fieldContext_AgentConfig_minP(ctx, field) + case "n": + return ec.fieldContext_AgentConfig_n(ctx, field) + case "json": + return ec.fieldContext_AgentConfig_json(ctx, field) + case "responseMimeType": + return ec.fieldContext_AgentConfig_responseMimeType(ctx, field) case "reasoning": return ec.fieldContext_AgentConfig_reasoning(ctx, field) case "price": @@ -11188,6 +11440,14 @@ func (ec *executionContext) fieldContext_AgentsConfig_reflector(_ context.Contex return ec.fieldContext_AgentConfig_frequencyPenalty(ctx, field) case "presencePenalty": return ec.fieldContext_AgentConfig_presencePenalty(ctx, field) + case "minP": + return ec.fieldContext_AgentConfig_minP(ctx, field) + case "n": + return ec.fieldContext_AgentConfig_n(ctx, field) + case "json": + return ec.fieldContext_AgentConfig_json(ctx, field) + case "responseMimeType": + return ec.fieldContext_AgentConfig_responseMimeType(ctx, field) case "reasoning": return ec.fieldContext_AgentConfig_reasoning(ctx, field) case "price": @@ -11260,6 +11520,14 @@ func (ec *executionContext) fieldContext_AgentsConfig_searcher(_ context.Context return ec.fieldContext_AgentConfig_frequencyPenalty(ctx, field) case "presencePenalty": return ec.fieldContext_AgentConfig_presencePenalty(ctx, field) + case "minP": + return ec.fieldContext_AgentConfig_minP(ctx, field) + case "n": + return ec.fieldContext_AgentConfig_n(ctx, field) + case "json": + return ec.fieldContext_AgentConfig_json(ctx, field) + case "responseMimeType": + return ec.fieldContext_AgentConfig_responseMimeType(ctx, field) case "reasoning": return ec.fieldContext_AgentConfig_reasoning(ctx, field) case "price": @@ -11332,6 +11600,14 @@ func (ec *executionContext) fieldContext_AgentsConfig_enricher(_ context.Context return ec.fieldContext_AgentConfig_frequencyPenalty(ctx, field) case "presencePenalty": return ec.fieldContext_AgentConfig_presencePenalty(ctx, field) + case "minP": + return ec.fieldContext_AgentConfig_minP(ctx, field) + case "n": + return ec.fieldContext_AgentConfig_n(ctx, field) + case "json": + return ec.fieldContext_AgentConfig_json(ctx, field) + case "responseMimeType": + return ec.fieldContext_AgentConfig_responseMimeType(ctx, field) case "reasoning": return ec.fieldContext_AgentConfig_reasoning(ctx, field) case "price": @@ -11404,6 +11680,14 @@ func (ec *executionContext) fieldContext_AgentsConfig_coder(_ context.Context, f return ec.fieldContext_AgentConfig_frequencyPenalty(ctx, field) case "presencePenalty": return ec.fieldContext_AgentConfig_presencePenalty(ctx, field) + case "minP": + return ec.fieldContext_AgentConfig_minP(ctx, field) + case "n": + return ec.fieldContext_AgentConfig_n(ctx, field) + case "json": + return ec.fieldContext_AgentConfig_json(ctx, field) + case "responseMimeType": + return ec.fieldContext_AgentConfig_responseMimeType(ctx, field) case "reasoning": return ec.fieldContext_AgentConfig_reasoning(ctx, field) case "price": @@ -11476,6 +11760,14 @@ func (ec *executionContext) fieldContext_AgentsConfig_installer(_ context.Contex return ec.fieldContext_AgentConfig_frequencyPenalty(ctx, field) case "presencePenalty": return ec.fieldContext_AgentConfig_presencePenalty(ctx, field) + case "minP": + return ec.fieldContext_AgentConfig_minP(ctx, field) + case "n": + return ec.fieldContext_AgentConfig_n(ctx, field) + case "json": + return ec.fieldContext_AgentConfig_json(ctx, field) + case "responseMimeType": + return ec.fieldContext_AgentConfig_responseMimeType(ctx, field) case "reasoning": return ec.fieldContext_AgentConfig_reasoning(ctx, field) case "price": @@ -11548,6 +11840,14 @@ func (ec *executionContext) fieldContext_AgentsConfig_pentester(_ context.Contex return ec.fieldContext_AgentConfig_frequencyPenalty(ctx, field) case "presencePenalty": return ec.fieldContext_AgentConfig_presencePenalty(ctx, field) + case "minP": + return ec.fieldContext_AgentConfig_minP(ctx, field) + case "n": + return ec.fieldContext_AgentConfig_n(ctx, field) + case "json": + return ec.fieldContext_AgentConfig_json(ctx, field) + case "responseMimeType": + return ec.fieldContext_AgentConfig_responseMimeType(ctx, field) case "reasoning": return ec.fieldContext_AgentConfig_reasoning(ctx, field) case "price": @@ -36725,7 +37025,7 @@ func (ec *executionContext) unmarshalInputAgentConfigInput(ctx context.Context, asMap[k] = v } - fieldsInOrder := [...]string{"model", "maxTokens", "temperature", "topK", "topP", "minLength", "maxLength", "repetitionPenalty", "frequencyPenalty", "presencePenalty", "reasoning", "price", "extraBody"} + fieldsInOrder := [...]string{"model", "maxTokens", "temperature", "topK", "topP", "minLength", "maxLength", "repetitionPenalty", "frequencyPenalty", "presencePenalty", "minP", "n", "json", "responseMimeType", "reasoning", "price", "extraBody"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -36802,6 +37102,34 @@ func (ec *executionContext) unmarshalInputAgentConfigInput(ctx context.Context, return it, err } it.PresencePenalty = data + case "minP": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("minP")) + data, err := ec.unmarshalOFloat2ᚖfloat64(ctx, v) + if err != nil { + return it, err + } + it.MinP = data + case "n": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("n")) + data, err := ec.unmarshalOInt2ᚖint(ctx, v) + if err != nil { + return it, err + } + it.N = data + case "json": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("json")) + data, err := ec.unmarshalOBoolean2ᚖbool(ctx, v) + if err != nil { + return it, err + } + it.JSON = data + case "responseMimeType": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("responseMimeType")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.ResponseMimeType = data case "reasoning": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("reasoning")) data, err := ec.unmarshalOReasoningConfigInput2ᚖpentagiᚋpkgᚋgraphᚋmodelᚐReasoningConfig(ctx, v) @@ -37564,6 +37892,14 @@ func (ec *executionContext) _AgentConfig(ctx context.Context, sel ast.SelectionS out.Values[i] = ec._AgentConfig_frequencyPenalty(ctx, field, obj) case "presencePenalty": out.Values[i] = ec._AgentConfig_presencePenalty(ctx, field, obj) + case "minP": + out.Values[i] = ec._AgentConfig_minP(ctx, field, obj) + case "n": + out.Values[i] = ec._AgentConfig_n(ctx, field, obj) + case "json": + out.Values[i] = ec._AgentConfig_json(ctx, field, obj) + case "responseMimeType": + out.Values[i] = ec._AgentConfig_responseMimeType(ctx, field, obj) case "reasoning": out.Values[i] = ec._AgentConfig_reasoning(ctx, field, obj) case "price": diff --git a/backend/pkg/graph/model/models_gen.go b/backend/pkg/graph/model/models_gen.go index ebfb1370..37872d2b 100644 --- a/backend/pkg/graph/model/models_gen.go +++ b/backend/pkg/graph/model/models_gen.go @@ -45,6 +45,10 @@ type AgentConfig struct { RepetitionPenalty *float64 `json:"repetitionPenalty,omitempty"` FrequencyPenalty *float64 `json:"frequencyPenalty,omitempty"` PresencePenalty *float64 `json:"presencePenalty,omitempty"` + MinP *float64 `json:"minP,omitempty"` + N *int `json:"n,omitempty"` + JSON *bool `json:"json,omitempty"` + ResponseMimeType *string `json:"responseMimeType,omitempty"` Reasoning *ReasoningConfig `json:"reasoning,omitempty"` Price *ModelPrice `json:"price,omitempty"` ExtraBody map[string]interface{} `json:"extraBody,omitempty"` diff --git a/backend/pkg/graph/schema.graphqls b/backend/pkg/graph/schema.graphqls index 7fb3f410..863d7011 100644 --- a/backend/pkg/graph/schema.graphqls +++ b/backend/pkg/graph/schema.graphqls @@ -815,6 +815,10 @@ type AgentConfig { repetitionPenalty: Float frequencyPenalty: Float presencePenalty: Float + minP: Float + n: Int + json: Boolean + responseMimeType: String reasoning: ReasoningConfig price: ModelPrice extraBody: Map @@ -866,6 +870,10 @@ input AgentConfigInput { repetitionPenalty: Float frequencyPenalty: Float presencePenalty: Float + minP: Float + n: Int + json: Boolean + responseMimeType: String reasoning: ReasoningConfigInput price: ModelPriceInput extraBody: Map diff --git a/backend/pkg/graph/schema.resolvers.go b/backend/pkg/graph/schema.resolvers.go index d285d0ef..9eb0d6cf 100644 --- a/backend/pkg/graph/schema.resolvers.go +++ b/backend/pkg/graph/schema.resolvers.go @@ -257,7 +257,6 @@ func (r *mutationResolver) RenameFlow(ctx context.Context, flowID int64, title s err = r.Controller.RenameFlow(ctx, flowID, title) if errors.Is(err, controller.ErrFlowNotFound) { - // if flow worker not found, update flow title in DB and notify about it flow, err := r.DB.UpdateFlowTitle(ctx, database.UpdateFlowTitleParams{ ID: flowID, Title: title, @@ -1956,7 +1955,6 @@ func (r *queryResolver) FlowsExecutionStatsByPeriod(ctx context.Context, period "period": period, }).Debug("get flows execution stats by period") - // Step 1: Get flows for the period type flowInfo struct { ID int64 Title string @@ -1992,23 +1990,19 @@ func (r *queryResolver) FlowsExecutionStatsByPeriod(ctx context.Context, period return nil, fmt.Errorf("unsupported period: %s", period) } - // Step 2: Build stats for each flow using analytics functions result := make([]*model.FlowExecutionStats, 0, len(flows)) for _, flow := range flows { - // Get raw data for this flow tasks, err := r.DB.GetTasksForFlow(ctx, flow.ID) if err != nil { return nil, err } - // Collect task IDs taskIDs := make([]int64, len(tasks)) for i, task := range tasks { taskIDs[i] = task.ID } - // Get subtasks for all tasks var subtasks []database.GetSubtasksForTasksRow if len(taskIDs) > 0 { subtasks, err = r.DB.GetSubtasksForTasks(ctx, taskIDs) @@ -2017,25 +2011,21 @@ func (r *queryResolver) FlowsExecutionStatsByPeriod(ctx context.Context, period } } - // Get msgchains for the flow msgchains, err := r.DB.GetMsgchainsForFlow(ctx, flow.ID) if err != nil { return nil, err } - // Get toolcalls for the flow toolcalls, err := r.DB.GetToolcallsForFlow(ctx, flow.ID) if err != nil { return nil, err } - // Get assistants count for the flow assistantsCount, err := r.DB.GetAssistantsCountForFlow(ctx, flow.ID) if err != nil { return nil, err } - // Build execution stats using analytics functions flowStats := converter.BuildFlowExecutionStats(flow.ID, flow.Title, tasks, subtasks, msgchains, toolcalls, int(assistantsCount)) result = append(result, flowStats) } diff --git a/backend/pkg/graphiti/client.go b/backend/pkg/graphiti/client.go index 3b4c2a43..3e648c24 100644 --- a/backend/pkg/graphiti/client.go +++ b/backend/pkg/graphiti/client.go @@ -45,6 +45,13 @@ type Client struct { timeout time.Duration } +// The verdict is permanent for the process: on failure the caller keeps a disabled client for its whole +// lifetime, so one timeout against a slow endpoint must not decide it. +const ( + healthCheckAttempts = 3 + healthCheckBackoff = 2 * time.Second +) + // NewClient creates a new Graphiti client wrapper func NewClient(url string, timeout time.Duration, enabled bool) (*Client, error) { if !enabled { @@ -53,9 +60,17 @@ func NewClient(url string, timeout time.Duration, enabled bool) (*Client, error) client := graphiti.NewClient(url, graphiti.WithTimeout(timeout)) - _, err := client.HealthCheck() - if err != nil { - return nil, fmt.Errorf("graphiti health check failed: %w", err) + var err error + for attempt := 1; ; attempt++ { + if _, err = client.HealthCheck(); err == nil { + break + } + + if attempt == healthCheckAttempts { + return nil, fmt.Errorf("graphiti health check failed after %d attempts: %w", attempt, err) + } + + time.Sleep(healthCheckBackoff) } return &Client{ diff --git a/backend/pkg/graphiti/client_test.go b/backend/pkg/graphiti/client_test.go index 4e5cc439..23a8e303 100644 --- a/backend/pkg/graphiti/client_test.go +++ b/backend/pkg/graphiti/client_test.go @@ -1,6 +1,9 @@ package graphiti import ( + "net/http" + "net/http/httptest" + "sync/atomic" "testing" "time" @@ -148,3 +151,42 @@ func TestSearchMethods_Disabled(t *testing.T) { }) } } + +func TestNewClient_RetriesAFlakyHealthCheck(t *testing.T) { + t.Parallel() + + var probes atomic.Int32 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if probes.Add(1) == 1 { + w.WriteHeader(http.StatusServiceUnavailable) + + return + } + + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) + })) + defer server.Close() + + client, err := NewClient(server.URL, 5*time.Second, true) + require.NoError(t, err) + assert.True(t, client.IsEnabled()) + assert.EqualValues(t, 2, probes.Load(), "the first probe failed, so a second one had to run") +} + +func TestNewClient_GivesUpAfterEveryAttemptFails(t *testing.T) { + t.Parallel() + + var probes atomic.Int32 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + probes.Add(1) + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer server.Close() + + _, err := NewClient(server.URL, 5*time.Second, true) + require.Error(t, err) + assert.EqualValues(t, healthCheckAttempts, probes.Load()) +} diff --git a/backend/pkg/providers/custom/custom.go b/backend/pkg/providers/custom/custom.go index 327822a9..5e52e275 100644 --- a/backend/pkg/providers/custom/custom.go +++ b/backend/pkg/providers/custom/custom.go @@ -92,10 +92,8 @@ func New( return nil, err } - // Use centralized model loading with prefix filtering models, err := provider.LoadModelsFromHTTP(baseURL, baseKey, httpClient, cfg.LLMServerProvider) if err != nil { - // If loading fails, fallback to empty models list models = pconfig.ModelsConfig{} } diff --git a/backend/pkg/providers/gemini/gemini_test.go b/backend/pkg/providers/gemini/gemini_test.go index 04f81c44..bd95ffb6 100644 --- a/backend/pkg/providers/gemini/gemini_test.go +++ b/backend/pkg/providers/gemini/gemini_test.go @@ -250,7 +250,6 @@ func TestAPIKeyTransportRoundTrip(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // create mock round tripper that captures the request var capturedReq *http.Request mockRT := &mockRoundTripper{ roundTripFunc: func(req *http.Request) (*http.Response, error) { @@ -270,7 +269,6 @@ func TestAPIKeyTransportRoundTrip(t *testing.T) { ProxyURL: "", } - // create test request reqURL := tt.requestURL if tt.requestQuery != "" { reqURL += "?" + tt.requestQuery @@ -280,13 +278,11 @@ func TestAPIKeyTransportRoundTrip(t *testing.T) { t.Fatalf("Failed to create request: %v", err) } - // execute RoundTrip _, err = transport.RoundTrip(req) if err != nil { t.Fatalf("RoundTrip failed: %v", err) } - // verify captured request if capturedReq == nil { t.Fatal("Request was not captured") } diff --git a/backend/pkg/providers/pconfig/config.go b/backend/pkg/providers/pconfig/config.go index fa5c6bdf..4e305e6b 100644 --- a/backend/pkg/providers/pconfig/config.go +++ b/backend/pkg/providers/pconfig/config.go @@ -432,7 +432,6 @@ func (mc *ModelConfig) UnmarshalJSON(data []byte) error { return err } - // Parse each field manually if name, ok := raw["name"].(string); ok { mc.Name = name } @@ -487,7 +486,6 @@ func (mc *ModelConfig) UnmarshalYAML(value *yaml.Node) error { return err } - // Parse each field manually if name, ok := raw["name"].(string); ok { mc.Name = name } @@ -763,7 +761,6 @@ func (ac *AgentConfig) marshalMap() map[string]any { return nil } - // use raw map if available, otherwise create a new one if ac.raw != nil { return ac.raw } diff --git a/backend/pkg/providers/performer.go b/backend/pkg/providers/performer.go index 4e718e52..2ed993cf 100644 --- a/backend/pkg/providers/performer.go +++ b/backend/pkg/providers/performer.go @@ -70,7 +70,6 @@ func (fp *flowProvider) performAgentChain( "msg_chain_id": chainID, })) - // Track execution time for duration calculation lastUpdateTime := time.Now() rollLastUpdateTime := func() float64 { durationDelta := time.Since(lastUpdateTime).Seconds() diff --git a/backend/pkg/providers/performers.go b/backend/pkg/providers/performers.go index d9748a82..1f35d34a 100644 --- a/backend/pkg/providers/performers.go +++ b/backend/pkg/providers/performers.go @@ -194,7 +194,6 @@ func (fp *flowProvider) performSubtasksRefiner( logger.Debug("starting subtasks refiner") - // Track execution time for duration calculation startTime := time.Now() restoreChain := func(msgChain json.RawMessage) ([]llms.MessageContent, error) { diff --git a/backend/pkg/providers/provider.go b/backend/pkg/providers/provider.go index eb996c99..b1aab2ad 100644 --- a/backend/pkg/providers/provider.go +++ b/backend/pkg/providers/provider.go @@ -187,7 +187,6 @@ func (fp *flowProvider) SetProvider(ctx context.Context, newProvider provider.Pr fp.mx.Lock() defer fp.mx.Unlock() - // Update provider-specific fields fp.Provider = newProvider var err error diff --git a/backend/pkg/providers/provider/agents.go b/backend/pkg/providers/provider/agents.go index ba21fb40..3cd3f4f3 100644 --- a/backend/pkg/providers/provider/agents.go +++ b/backend/pkg/providers/provider/agents.go @@ -57,30 +57,23 @@ func testTemplate( prompter templates.Prompter, template string, ) bool { - // If no template provided, skip validation if template == "" { return false } - // Collect one sample to validate the template samples, err := runToolCallIDCollector(ctx, provider, opt, prompter) if err != nil { - // Any error means validation failed return false } - // If no samples collected, validation failed if len(samples) == 0 { return false } - // Validate the template against collected samples if err := templates.ValidatePattern(template, samples); err != nil { - // Template doesn't match return false } - // Template validated successfully return true } diff --git a/backend/pkg/providers/subtask_patch.go b/backend/pkg/providers/subtask_patch.go index ae468452..ae9baf57 100644 --- a/backend/pkg/providers/subtask_patch.go +++ b/backend/pkg/providers/subtask_patch.go @@ -24,7 +24,6 @@ func applySubtaskOperations( "message": patch.Message, }).Debug("applying subtask operations") - // Fix the patch to ensure it is valid patch = fixSubtaskPatch(planned, patch) // Convert database.Subtask to tools.SubtaskInfo with IDs diff --git a/backend/pkg/server/auth/api_token_cache.go b/backend/pkg/server/auth/api_token_cache.go index 9f3d196f..fc5260a1 100644 --- a/backend/pkg/server/auth/api_token_cache.go +++ b/backend/pkg/server/auth/api_token_cache.go @@ -39,25 +39,20 @@ func (tc *TokenCache) SetTTL(ttl time.Duration) { // GetStatus retrieves token status and privileges from cache or database func (tc *TokenCache) GetStatus(tokenID string) (models.TokenStatus, []string, error) { - // check cache first if entry, ok := tc.cache.Load(tokenID); ok { cached := entry.(tokenCacheEntry) if time.Now().Before(cached.expiresAt) { - // return cached "not found" error if cached.notFound { return "", nil, gorm.ErrRecordNotFound } return cached.status, cached.privileges, nil } - // cache entry expired, remove it tc.cache.Delete(tokenID) } - // load from database with role privileges var token models.APIToken if err := tc.db.Where("token_id = ? AND deleted_at IS NULL", tokenID).First(&token).Error; err != nil { if gorm.IsRecordNotFoundError(err) { - // cache negative result (token not found) tc.cache.Store(tokenID, tokenCacheEntry{ notFound: true, expiresAt: time.Now().Add(tc.ttl), @@ -67,13 +62,11 @@ func (tc *TokenCache) GetStatus(tokenID string) (models.TokenStatus, []string, e return "", nil, err } - // load privileges for the token's role var privileges []models.Privilege if err := tc.db.Where("role_id = ?", token.RoleID).Find(&privileges).Error; err != nil { return "", nil, err } - // extract privilege names privNames := make([]string, len(privileges)) for i, priv := range privileges { privNames[i] = priv.Name @@ -82,7 +75,6 @@ func (tc *TokenCache) GetStatus(tokenID string) (models.TokenStatus, []string, e // always add automation privilege for API tokens privNames = append(privNames, PrivilegeAutomation) - // update cache with positive result tc.cache.Store(tokenID, tokenCacheEntry{ status: token.Status, privileges: privNames, @@ -100,13 +92,11 @@ func (tc *TokenCache) Invalidate(tokenID string) { // InvalidateUser removes all tokens for a specific user from cache func (tc *TokenCache) InvalidateUser(userID uint64) { - // load all tokens for this user var tokens []models.APIToken if err := tc.db.Where("user_id = ? AND deleted_at IS NULL", userID).Find(&tokens).Error; err != nil { return } - // invalidate each token in cache for _, token := range tokens { tc.cache.Delete(token.TokenID) } diff --git a/backend/pkg/server/auth/auth_middleware.go b/backend/pkg/server/auth/auth_middleware.go index 266e907d..ab175614 100644 --- a/backend/pkg/server/auth/auth_middleware.go +++ b/backend/pkg/server/auth/auth_middleware.go @@ -123,7 +123,6 @@ func (p *AuthMiddleware) tryUserCookieAuthentication(c *gin.Context) (authResult return authResultFail, errors.New("no permissions granted") } - // Verify session expiration expVal, ok := exp.(int64) if !ok { return authResultFail, errors.New("token claim invalid") diff --git a/backend/pkg/server/auth/session.go b/backend/pkg/server/auth/session.go index e7563c46..26cc9df6 100644 --- a/backend/pkg/server/auth/session.go +++ b/backend/pkg/server/auth/session.go @@ -22,7 +22,6 @@ const ( // MakeCookieStoreKey is function to generate auth and encryption keys for cookie store func MakeCookieStoreKey(globalSalt string) [][]byte { - // Check cache for existing keys if cached, ok := cookieStoreKeys.Load(globalSalt); ok { return cached.([][]byte) } @@ -51,7 +50,6 @@ func MakeCookieStoreKey(globalSalt string) [][]byte { // MakeJWTSigningKey is function to generate signing key for JWT tokens func MakeJWTSigningKey(globalSalt string) []byte { - // Check cache for existing key if cached, ok := jwtSigningKeys.Load(globalSalt); ok { return cached.([]byte) } diff --git a/backend/pkg/server/auth/users_cache.go b/backend/pkg/server/auth/users_cache.go index a3ec9dc0..7263dc8d 100644 --- a/backend/pkg/server/auth/users_cache.go +++ b/backend/pkg/server/auth/users_cache.go @@ -39,25 +39,20 @@ func (uc *UserCache) SetTTL(ttl time.Duration) { // GetUserHash retrieves user hash and status from cache or database func (uc *UserCache) GetUserHash(userID uint64) (string, models.UserStatus, error) { - // check cache first if entry, ok := uc.cache.Load(userID); ok { cached := entry.(userCacheEntry) if time.Now().Before(cached.expiresAt) { - // return cached "not found" error if cached.notFound { return "", "", gorm.ErrRecordNotFound } return cached.hash, cached.status, nil } - // cache entry expired, remove it uc.cache.Delete(userID) } - // load from database var user models.User if err := uc.db.Where("id = ?", userID).First(&user).Error; err != nil { if gorm.IsRecordNotFoundError(err) { - // cache negative result (user not found) uc.cache.Store(userID, userCacheEntry{ notFound: true, expiresAt: time.Now().Add(uc.ttl), @@ -67,7 +62,6 @@ func (uc *UserCache) GetUserHash(userID uint64) (string, models.UserStatus, erro return "", "", err } - // update cache with positive result uc.cache.Store(userID, userCacheEntry{ hash: user.Hash, status: user.Status, diff --git a/backend/pkg/server/models/init.go b/backend/pkg/server/models/init.go index 1847252d..9db52973 100644 --- a/backend/pkg/server/models/init.go +++ b/backend/pkg/server/models/init.go @@ -87,6 +87,22 @@ func strongPasswordValidatorString() validator.Func { } } +// MaxPasswordBytes is the longest password bcrypt.GenerateFromPassword accepts. +const MaxPasswordBytes = 72 + +func passwordLengthValidatorString() validator.Func { + return func(fl validator.FieldLevel) bool { + field := fl.Field() + + switch field.Kind() { + case reflect.String: + return len(field.String()) <= MaxPasswordBytes + default: + return false + } + } +} + var emailFormatRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`) func isRealEmail(email string) bool { @@ -223,6 +239,7 @@ func init() { _ = validate.RegisterValidation("semver", templateValidatorString(semverRegexString)) _ = validate.RegisterValidation("semverex", templateValidatorString(semverexRegexString)) _ = validate.RegisterValidation("stpass", strongPasswordValidatorString()) + _ = validate.RegisterValidation("passlen", passwordLengthValidatorString()) _ = validate.RegisterValidation("vmail", emailValidatorString()) _ = validate.RegisterValidation("realemail", strictEmailValidatorString()) _ = validate.RegisterValidation("oauth_min_scope", oauthMinScope()) diff --git a/backend/pkg/server/models/users.go b/backend/pkg/server/models/users.go index d5486e4a..f53698b5 100644 --- a/backend/pkg/server/models/users.go +++ b/backend/pkg/server/models/users.go @@ -103,7 +103,7 @@ func (u User) Validate(db *gorm.DB) { // UserPassword is model to contain user information type UserPassword struct { - Password string `form:"password" json:"password" validate:"max=100,required" gorm:"column:password;type:TEXT"` + Password string `form:"password" json:"password" validate:"passlen,required" gorm:"column:password;type:TEXT"` User `form:"" json:""` } @@ -168,7 +168,7 @@ func (au AuthCallback) Valid() error { // nolint:lll type Password struct { CurrentPassword string `form:"current_password" json:"current_password" validate:"nefield=Password,min=5,max=100,required" gorm:"-"` - Password string `form:"password" json:"password" validate:"stpass,max=100,required" gorm:"type:TEXT"` + Password string `form:"password" json:"password" validate:"stpass,passlen,required" gorm:"type:TEXT"` ConfirmPassword string `form:"confirm_password" json:"confirm_password" validate:"eqfield=Password" gorm:"-"` } diff --git a/backend/pkg/server/models/users_test.go b/backend/pkg/server/models/users_test.go index 7c6fde29..55922957 100644 --- a/backend/pkg/server/models/users_test.go +++ b/backend/pkg/server/models/users_test.go @@ -1,6 +1,7 @@ package models import ( + "strings" "testing" "github.com/stretchr/testify/assert" @@ -196,6 +197,24 @@ func TestPasswordValid(t *testing.T) { }, wantErr: true, }, + { + name: "new password at the bcrypt limit", + pw: Password{ + CurrentPassword: "OldPass1!abc", + Password: strings.Repeat("a", MaxPasswordBytes), + ConfirmPassword: strings.Repeat("a", MaxPasswordBytes), + }, + wantErr: false, + }, + { + name: "new password over the bcrypt limit", + pw: Password{ + CurrentPassword: "OldPass1!abc", + Password: strings.Repeat("a", MaxPasswordBytes+1), + ConfirmPassword: strings.Repeat("a", MaxPasswordBytes+1), + }, + wantErr: true, + }, } for _, tt := range tests { @@ -309,6 +328,52 @@ func TestUserPasswordValid(t *testing.T) { } assert.Error(t, up.Valid()) }) + + for _, tc := range []struct { + name string + length int + wantErr bool + }{ + {name: "password at the bcrypt limit", length: MaxPasswordBytes, wantErr: false}, + {name: "password over the bcrypt limit", length: MaxPasswordBytes + 1, wantErr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + up := UserPassword{ + Password: strings.Repeat("a", tc.length), + User: User{ + ID: 1, + Hash: "abcdef1234567890abcdef1234567890", + Type: UserTypeLocal, + Mail: "test@example.com", + Status: UserStatusActive, + RoleID: RoleUser, + }, + } + if tc.wantErr { + assert.Error(t, up.Valid()) + } else { + assert.NoError(t, up.Valid()) + } + }) + } + + t.Run("multibyte password within the rune count but over the byte limit", func(t *testing.T) { + t.Parallel() + up := UserPassword{ + // 40 runes, 80 bytes: a rune-counting `max=72` would let this through. + Password: strings.Repeat("é", 40), + User: User{ + ID: 1, + Hash: "abcdef1234567890abcdef1234567890", + Type: UserTypeLocal, + Mail: "test@example.com", + Status: UserStatusActive, + RoleID: RoleUser, + }, + } + assert.Error(t, up.Valid()) + }) } func TestUserPasswordTableName(t *testing.T) { diff --git a/backend/pkg/server/services/api_tokens.go b/backend/pkg/server/services/api_tokens.go index 653a1c42..75ace985 100644 --- a/backend/pkg/server/services/api_tokens.go +++ b/backend/pkg/server/services/api_tokens.go @@ -56,7 +56,6 @@ func NewTokenService( // @Failure 500 {object} response.errorResp "internal error on creating token" // @Router /tokens [post] func (s *TokenService) CreateToken(c *gin.Context) { - // check for default salt if s.globalSalt == "" || s.globalSalt == "salt" { logger.FromContext(c).Errorf("token creation attempted with default salt") response.Error(c, response.ErrTokenCreationDisabled, errors.New("token creation is disabled with default salt")) @@ -79,7 +78,6 @@ func (s *TokenService) CreateToken(c *gin.Context) { return } - // check if name is unique for this user (if provided) if req.Name != nil && *req.Name != "" { var existing models.APIToken err := s.db. @@ -93,7 +91,6 @@ func (s *TokenService) CreateToken(c *gin.Context) { } } - // generate token_id tokenID, err := auth.GenerateTokenID() if err != nil { logger.FromContext(c).WithError(err).Errorf("error generating token ID") @@ -101,10 +98,8 @@ func (s *TokenService) CreateToken(c *gin.Context) { return } - // create JWT claims claims := auth.MakeAPITokenClaims(tokenID, uhash, uid, rid, req.TTL) - // sign token token, err := auth.MakeAPIToken(s.globalSalt, claims) if err != nil { logger.FromContext(c).WithError(err).Errorf("error signing token") @@ -112,7 +107,6 @@ func (s *TokenService) CreateToken(c *gin.Context) { return } - // save to database apiToken := models.APIToken{ TokenID: tokenID, UserID: uid, @@ -162,10 +156,8 @@ func (s *TokenService) ListTokens(c *gin.Context) { query := s.db.Where("deleted_at IS NULL") - // check if user has admin privilege hasAdmin := auth.LookupPerm(prms, "settings.tokens.admin") if !hasAdmin { - // regular user sees only their own tokens query = query.Where("user_id = ?", uid) } @@ -220,7 +212,6 @@ func (s *TokenService) GetToken(c *gin.Context) { return } - // check authorization hasAdmin := auth.LookupPerm(prms, "settings.tokens.admin") if !hasAdmin && token.UserID != uid { logger.FromContext(c).Errorf("user %d attempted to access token of user %d", uid, token.UserID) @@ -283,7 +274,6 @@ func (s *TokenService) UpdateToken(c *gin.Context) { return } - // check authorization hasAdmin := auth.LookupPerm(prms, "settings.tokens.admin") if !hasAdmin && token.UserID != uid { logger.FromContext(c).Errorf("user %d attempted to update token of user %d", uid, token.UserID) @@ -291,10 +281,8 @@ func (s *TokenService) UpdateToken(c *gin.Context) { return } - // update fields updates := make(map[string]any) if req.Name != nil { - // check uniqueness if name is changing if token.Name == nil || *token.Name != *req.Name { if *req.Name != "" { var existing models.APIToken @@ -327,14 +315,12 @@ func (s *TokenService) UpdateToken(c *gin.Context) { return } - // invalidate cache if status changed if req.Status != "" { s.tokenCache.Invalidate(tokenID) // also invalidate all tokens for this user (in case of role change or security event) s.tokenCache.InvalidateUser(token.UserID) } - // reload token if err := s.db.Where("token_id = ?", tokenID).First(&token).Error; err != nil { logger.FromContext(c).WithError(err).Errorf("error reloading token") response.Error(c, response.ErrInternal, err) @@ -381,7 +367,6 @@ func (s *TokenService) DeleteToken(c *gin.Context) { return } - // check authorization hasAdmin := auth.LookupPerm(prms, "settings.tokens.admin") if !hasAdmin && token.UserID != uid { logger.FromContext(c).Errorf("user %d attempted to delete token of user %d", uid, token.UserID) @@ -396,7 +381,6 @@ func (s *TokenService) DeleteToken(c *gin.Context) { return } - // invalidate cache for this token and all user's tokens s.tokenCache.Invalidate(tokenID) s.tokenCache.InvalidateUser(token.UserID) diff --git a/backend/pkg/terminal/output.go b/backend/pkg/terminal/output.go index 51b9b077..75ddd631 100644 --- a/backend/pkg/terminal/output.go +++ b/backend/pkg/terminal/output.go @@ -151,14 +151,11 @@ func RenderMarkdown(markdown string) { // InteractivePromptContext prompts the user for input with support for context cancellation func InteractivePromptContext(ctx context.Context, message string, reader io.Reader) (string, error) { - // Display the prompt infoColor.Printf("%s: ", message) - // Create a channel for the user input inputCh := make(chan string, 1) errCh := make(chan error, 1) - // Start a goroutine to read user input go func() { // Use a buffered reader to properly handle input r, ok := reader.(*os.File) @@ -186,7 +183,6 @@ func InteractivePromptContext(ctx context.Context, message string, reader io.Rea } }() - // Wait for input or context cancellation select { case input := <-inputCh: return input, nil @@ -228,7 +224,6 @@ func GetYesNoInputContext(ctx context.Context, message string, reader io.Reader) // IsMarkdownContent checks if the input string is likely markdown content func IsMarkdownContent(content string) bool { - // Determine if content is likely markdown by checking for common indicators if strings.HasPrefix(content, "#") || strings.Contains(content, "\n#") || strings.Contains(content, "[") && strings.Contains(content, "](") || diff --git a/backend/pkg/tools/browser.go b/backend/pkg/tools/browser.go index 4f5f7f31..a3f2932e 100644 --- a/backend/pkg/tools/browser.go +++ b/backend/pkg/tools/browser.go @@ -42,7 +42,6 @@ var nonHTMLExtensions = []string{ // isBinaryURL returns true when the URL points to a known non-HTML resource. func isBinaryURL(rawURL string) bool { lower := strings.ToLower(rawURL) - // strip query string for extension matching if idx := strings.Index(lower, "?"); idx != -1 { lower = lower[:idx] } @@ -303,7 +302,6 @@ func (b *browser) resolveUrl(targetURL string) (*url.URL, error) { host = u.Host } - // determine if target is private or public isPrivate := false hostIP := net.ParseIP(host) @@ -328,7 +326,6 @@ func (b *browser) resolveUrl(targetURL string) (*url.URL, error) { } } - // select appropriate scraper URL with fallback var scraperURL string if isPrivate { scraperURL = b.scPrvURL diff --git a/backend/pkg/tools/code.go b/backend/pkg/tools/code.go index 238860f2..7891cb58 100644 --- a/backend/pkg/tools/code.go +++ b/backend/pkg/tools/code.go @@ -126,7 +126,6 @@ func (c *code) Handle(ctx context.Context, name string, args json.RawMessage) (s "filters": filters, }) - // Execute multiple queries and collect all documents var allDocs []schema.Document for i, query := range action.Questions { queryLogger := logger.WithFields(logrus.Fields{ @@ -154,7 +153,6 @@ func (c *code) Handle(ctx context.Context, name string, args json.RawMessage) (s "total_docs_before_dedup": len(allDocs), }).Debug("all queries completed") - // Merge, deduplicate, sort by score, and limit results docs := MergeAndDeduplicateDocs(allDocs, codeVectorStoreResultLimit) logger.WithFields(logrus.Fields{ @@ -202,7 +200,6 @@ func (c *code) Handle(ctx context.Context, name string, args json.RawMessage) (s logger.WithError(err).Error("failed to marshal filters") return "", fmt.Errorf("failed to marshal filters: %w", err) } - // Join all queries for logging queriesText := strings.Join(action.Questions, "\n--------------------------------\n") _, _ = c.vslp.PutLog( ctx, @@ -256,7 +253,6 @@ func (c *code) Handle(ctx context.Context, name string, args json.RawMessage) (s "code": action.Code[:min(len(action.Code), 1000)], }) - // Build common metadata for the document. metadata := map[string]any{ "user_id": c.userID, "flow_id": c.flowID, diff --git a/backend/pkg/tools/executor.go b/backend/pkg/tools/executor.go index f9385adc..e63df479 100644 --- a/backend/pkg/tools/executor.go +++ b/backend/pkg/tools/executor.go @@ -126,7 +126,6 @@ func (w *noopObservationWrapper) ctx() context.Context { } func (w *noopObservationWrapper) end(result string, err error, durationSeconds float64) { - // no-op } type customExecutor struct { @@ -258,7 +257,6 @@ func (ce *customExecutor) Execute( return fmt.Sprintf("failed to unmarshal '%s' tool call arguments: %v: fix it", name, err), nil } - // Create observation based on tool type toolType := GetToolType(name) var obsWrapper observationWrapper @@ -273,11 +271,9 @@ func (ce *customExecutor) Execute( // Skip - handlers create RETRIEVER internally obsWrapper = &noopObservationWrapper{context: ctx} default: - // Unknown type - use no-op wrapper obsWrapper = &noopObservationWrapper{context: ctx} } - // Use context from observation wrapper ctx = obsWrapper.ctx() var err error diff --git a/backend/pkg/tools/flow_manager.go b/backend/pkg/tools/flow_manager.go index 00845104..e8d02102 100644 --- a/backend/pkg/tools/flow_manager.go +++ b/backend/pkg/tools/flow_manager.go @@ -838,7 +838,6 @@ func (t *patchFlowSubtasksTool) Handle(ctx context.Context, name string, args js } } - // Verify task_id belongs to this flow. taskBelongsToFlow := false for _, task := range tasks { if task.ID == action.TaskID { @@ -853,7 +852,6 @@ func (t *patchFlowSubtasksTool) Handle(ctx context.Context, name string, args js action.TaskID, GetFlowStatusToolName) } - // Verify there are planned subtasks to patch. planned, err := t.db.GetTaskPlannedSubtasks(ctx, action.TaskID) if err != nil { return "", fmt.Errorf("failed to get planned subtasks for task %d: %w", action.TaskID, err) diff --git a/backend/pkg/tools/graphiti_search.go b/backend/pkg/tools/graphiti_search.go index 7e5c8b8d..fb9a8eaf 100644 --- a/backend/pkg/tools/graphiti_search.go +++ b/backend/pkg/tools/graphiti_search.go @@ -159,7 +159,6 @@ func (t *graphitiSearchTool) Handle(ctx context.Context, name string, args json. searchArgs.Query = strings.TrimSpace(searchArgs.Query) - // Validate required parameters if searchArgs.Query == "" { logger.Error("query parameter is required") return "", fmt.Errorf("query parameter is required") @@ -169,7 +168,6 @@ func (t *graphitiSearchTool) Handle(ctx context.Context, name string, args json. return "", fmt.Errorf("search_type parameter is required") } - // Get group ID from flow context groupID := fmt.Sprintf("flow-%d", t.flowID) ctx, observation := obs.Observer.NewObservation(ctx) @@ -199,7 +197,6 @@ func (t *graphitiSearchTool) Handle(ctx context.Context, name string, args json. Time: time.Now().UTC(), } - // Route to appropriate search method var ( err error result string diff --git a/backend/pkg/tools/guide.go b/backend/pkg/tools/guide.go index a0d304c5..04b6f4a7 100644 --- a/backend/pkg/tools/guide.go +++ b/backend/pkg/tools/guide.go @@ -123,7 +123,6 @@ func (g *guide) Handle(ctx context.Context, name string, args json.RawMessage) ( "type": action.Type, }) - // Execute multiple queries and collect all documents var allDocs []schema.Document for i, query := range action.Questions { queryLogger := logger.WithFields(logrus.Fields{ @@ -151,7 +150,6 @@ func (g *guide) Handle(ctx context.Context, name string, args json.RawMessage) ( "total_docs_before_dedup": len(allDocs), }).Debug("all queries completed") - // Merge, deduplicate, sort by score, and limit results docs := MergeAndDeduplicateDocs(allDocs, guideVectorStoreResultLimit) logger.WithFields(logrus.Fields{ @@ -199,7 +197,6 @@ func (g *guide) Handle(ctx context.Context, name string, args json.RawMessage) ( logger.WithError(err).Error("failed to marshal filters") return "", fmt.Errorf("failed to marshal filters: %w", err) } - // Join all queries for logging queriesText := strings.Join(action.Questions, "\n--------------------------------\n") _, _ = g.vslp.PutLog( ctx, @@ -252,7 +249,6 @@ func (g *guide) Handle(ctx context.Context, name string, args json.RawMessage) ( "guide": action.Guide[:min(len(action.Guide), 1000)], }) - // Build common metadata for the document. metadata := map[string]any{ "user_id": g.userID, "flow_id": g.flowID, diff --git a/backend/pkg/tools/memory.go b/backend/pkg/tools/memory.go index 04aca6a7..2b779b23 100644 --- a/backend/pkg/tools/memory.go +++ b/backend/pkg/tools/memory.go @@ -111,7 +111,6 @@ func (m *memory) Handle(ctx context.Context, name string, args json.RawMessage) logger = logger.WithFields(fields) - // Execute multiple queries and collect all documents var allDocs []schema.Document for i, query := range action.Questions { queryLogger := logger.WithFields(logrus.Fields{ @@ -168,7 +167,6 @@ func (m *memory) Handle(ctx context.Context, name string, args json.RawMessage) "total_docs_before_dedup": len(allDocs), }).Debug("all queries completed") - // Merge, deduplicate, sort by score, and limit results docs := MergeAndDeduplicateDocs(allDocs, memoryVectorStoreResultLimit) logger.WithFields(logrus.Fields{ @@ -224,7 +222,6 @@ func (m *memory) Handle(ctx context.Context, name string, args json.RawMessage) logger.WithError(err).Error("failed to marshal filters") return "", fmt.Errorf("failed to marshal filters: %w", err) } - // Join all queries for logging queriesText := strings.Join(action.Questions, "\n--------------------------------\n") _, _ = m.vslp.PutLog( ctx, diff --git a/backend/pkg/tools/memory_utils.go b/backend/pkg/tools/memory_utils.go index 5d1b2b5c..719ed5eb 100644 --- a/backend/pkg/tools/memory_utils.go +++ b/backend/pkg/tools/memory_utils.go @@ -28,7 +28,6 @@ func MergeAndDeduplicateDocs(docs []schema.Document, maxDocs int) []schema.Docum for _, doc := range docs { hash := hashContent(doc.PageContent) - // If document with this hash already exists, keep the one with higher score if existing, found := docMap[hash]; found { if doc.Score > existing.Score { docMap[hash] = doc @@ -38,18 +37,15 @@ func MergeAndDeduplicateDocs(docs []schema.Document, maxDocs int) []schema.Docum } } - // Convert map to slice result := make([]schema.Document, 0, len(docMap)) for _, doc := range docMap { result = append(result, doc) } - // Sort by score in descending order (highest score first) sort.Slice(result, func(i, j int) bool { return result[i].Score > result[j].Score }) - // Limit to maxDocs if len(result) > maxDocs { result = result[:maxDocs] } diff --git a/backend/pkg/tools/search.go b/backend/pkg/tools/search.go index ab852d89..6af9a482 100644 --- a/backend/pkg/tools/search.go +++ b/backend/pkg/tools/search.go @@ -121,7 +121,6 @@ func (s *search) Handle(ctx context.Context, name string, args json.RawMessage) "answer_type": action.Type, }) - // Execute multiple queries and collect all documents var allDocs []schema.Document for i, query := range action.Questions { queryLogger := logger.WithFields(logrus.Fields{ @@ -149,7 +148,6 @@ func (s *search) Handle(ctx context.Context, name string, args json.RawMessage) "total_docs_before_dedup": len(allDocs), }).Debug("all queries completed") - // Merge, deduplicate, sort by score, and limit results docs := MergeAndDeduplicateDocs(allDocs, searchVectorStoreResultLimit) logger.WithFields(logrus.Fields{ @@ -197,7 +195,6 @@ func (s *search) Handle(ctx context.Context, name string, args json.RawMessage) logger.WithError(err).Error("failed to marshal filters") return "", fmt.Errorf("failed to marshal filters: %w", err) } - // Join all queries for logging queriesText := strings.Join(action.Questions, "\n--------------------------------\n") _, _ = s.vslp.PutLog( ctx, @@ -247,7 +244,6 @@ func (s *search) Handle(ctx context.Context, name string, args json.RawMessage) "answer": action.Answer[:min(len(action.Answer), 1000)], }) - // Build common metadata for the document. metadata := map[string]any{ "user_id": s.userID, "flow_id": s.flowID, diff --git a/backend/pkg/tools/terminal.go b/backend/pkg/tools/terminal.go index c1d2e8dc..503feb4a 100644 --- a/backend/pkg/tools/terminal.go +++ b/backend/pkg/tools/terminal.go @@ -192,14 +192,12 @@ func (t *terminal) ExecCommand( ) (string, error) { containerName := PrimaryTerminalName(t.flowID) - // create options for starting the exec process cmd := []string{ "sh", "-c", command, } - // verify container runtime status isRunning, err := t.dockerClient.IsContainerRunning(ctx, t.containerLID) if err != nil { return "", fmt.Errorf("runtime verification failed: %w", err) @@ -265,7 +263,6 @@ func (t *terminal) getExecResult(ctx context.Context, id string, timeout time.Du defer cancel() } - // attach to the exec process resp, err := t.dockerClient.ContainerExecAttach(ctx, id, container.ExecAttachOptions{ Tty: true, }) diff --git a/backend/pkg/tools/tools.go b/backend/pkg/tools/tools.go index fa2cd322..1d18c137 100644 --- a/backend/pkg/tools/tools.go +++ b/backend/pkg/tools/tools.go @@ -657,7 +657,6 @@ func (fte *flowToolsExecutor) findMissingInContainer(ctx context.Context, entrie return nil, fmt.Errorf("file-check exec failed with exit code %d: %s", inspect.ExitCode, strings.TrimSpace(string(output))) } - // Build a lookup map for O(1) access. byContainerPath := make(map[string]fileSyncEntry, len(entries)) for _, e := range entries { byContainerPath[e.containerPath] = e diff --git a/docker-compose.yml b/docker-compose.yml index cb5f26ce..790d219e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -190,7 +190,7 @@ services: - ${PENTAGI_DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock - ${PENTAGI_LLM_SERVER_CONFIG_PATH:-./example.custom.provider.yml}:/opt/pentagi/conf/custom.provider.yml - ${PENTAGI_OLLAMA_SERVER_CONFIG_PATH:-./example.ollama.provider.yml}:/opt/pentagi/conf/ollama.provider.yml - - ${BEDROCK_CONFIG_PATH:-./example.bedrock.provider.yml}:/opt/pentagi/conf/bedrock.provider.yml + - ${PENTAGI_BEDROCK_CONFIG_PATH:-./example.bedrock.provider.yml}:/opt/pentagi/conf/bedrock.provider.yml - ${PENTAGI_DOCKER_CERT_PATH:-./docker-ssl}:/opt/pentagi/docker/ssl user: root:root # while using docker.sock networks: diff --git a/frontend/.gitignore b/frontend/.gitignore index 866f0df2..74607e0d 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -25,6 +25,7 @@ ssl # Playwright e2e/test-results/ +test-results/ e2e/playwright-report/ e2e/blob-report/ e2e/.auth/ diff --git a/frontend/docs/e2e.md b/frontend/docs/e2e.md index 0fe088be..fc14a6a9 100644 --- a/frontend/docs/e2e.md +++ b/frontend/docs/e2e.md @@ -100,8 +100,19 @@ Key conventions: types — schema or operation drift fails `pnpm typescript`, not the runtime. - **The clock is pinned** (UTC, fixed epoch) on the mock tier: keep cassette timestamps on the `CASSETTE_EPOCH` day or date renders change under you. -- **Unmatched calls fail the test** — the mock tier is hermetic by design; - nothing ever leaks to a real backend. +- **Unmatched HTTP calls fail the test** — every GraphQL POST and REST call needs + a cassette entry or teardown fails. An unmatched _subscription_ is legal (a flow + page opens ~15 and a cassette mocks only the ones it cares about): it gets ack'd + silence, so a typo'd subscription key surfaces as a UI timeout, with the missed + operations in the `unmatched-subscriptions` report attachment. Either way nothing + reaches a real backend: the WebSocket is routed too, and the HTTP route is re-armed + on every page the context opens, so a popup (the report tabs) is mocked like the + page that opened it — Playwright does not inherit a page's routes into its popups. +- **Downloads are the one hole in that.** The browser performs an `` + transfer outside the page, so no route — page- or context-scoped — is ever offered + it: it goes to the `vite preview` proxy and out to whatever `VITE_API_URL` names. A + mock-tier spec must therefore never start one — assert the anchor's `href`/`download` + (see `specs/crud/resources.spec.ts`) and leave the bytes to `specs/real/**`. - Assert errors via `pageerror`/`unhandledrejection` (`expectCleanPage`): the production bundle strips app console output, so console-based asserts are meaningless on the mock tier. @@ -133,14 +144,15 @@ Two conventions the gate reserves: ## Stand tier (Tier 3) Runs the LLM-independent `@stand` smoke against a real deployment. It lives in its -own workflow (`e2e-stand.yml`) so the PR gate never subscribes to `labeled`. Label a -PR `e2e:stand` (or dispatch the workflow with `tier: stand`); the job runs in a -protected `e2e-stand` Environment whose required reviewers approve before any secret -is exposed. That Environment gate — not the job's label condition — is what protects -the secrets, so even a mislabeled or fork-PR run blocks on a human before it can reach -them. The stand's URL and login come from the `E2E_STAND_URL` / `E2E_STAND_USER` / -`E2E_STAND_PASSWORD` secrets (exposed to the tools as `E2E_BASE_URL` / `E2E_USER` / -`E2E_PASSWORD`). +own workflow (`e2e-stand.yml`) so the PR gate never subscribes to `labeled`. Trigger +it by labelling a PR `e2e:stand`, or from **Actions → E2E Stand → Run workflow** +(`workflow_dispatch` takes no inputs). The job's `if` is the first gate: it runs only +for a `workflow_dispatch`, or a labelled PR whose head is **not** a fork — a +mislabeled or fork-PR run skips the job entirely and never reaches the secrets. For a +run that clears that gate, the protected `e2e-stand` Environment is the second gate: +its required reviewers approve before any secret is exposed. The stand's URL and login +come from the `E2E_STAND_URL` / `E2E_STAND_USER` / `E2E_STAND_PASSWORD` secrets +(exposed to the tools as `E2E_BASE_URL` / `E2E_USER` / `E2E_PASSWORD`). Before the browser specs, a **schema-compat pre-flight** (`e2e/tools/schema-compat.mjs`) introspects the stand's live GraphQL schema and @@ -163,6 +175,33 @@ prefix `NODE_TLS_REJECT_UNAUTHORIZED=0`; a real stand has a valid cert). substrate for scoping runs and the exploratory agent to the changed surface; the mapping logic is unit-tested (`e2e/affected-routes.unit.test.ts`). +## Reviewing with agents + +Verifying a claim about a gate usually means breaking something on purpose — deleting a +guard, injecting the regression it should catch, patching a fixture. Do that in a sandbox, +never in your checkout: + +```bash +SANDBOX=$(./e2e/tools/review-sandbox.sh create --dirty --with-deps) +# …mutate and run anything inside $SANDBOX… +./e2e/tools/review-sandbox.sh clean "$SANDBOX" +``` + +`--dirty` carries uncommitted work across (usually what you are reviewing); `--with-deps` +hardlinks `node_modules` so pnpm, vitest and playwright run there — skip it for read-only +work, it costs ~30s. `clean` takes a path under the sandbox root and refuses anything else, +including the root itself; `clean --all` sweeps sandboxes untouched for two hours, so it +cannot take out a concurrent agent's live one. A bare `clean` is an error, not a sweep. + +The sandbox is a `git worktree` under `$TMPDIR`, so a deleted file or an edited spec inside +it cannot reach your tree. `--with-deps` needs the root to be on the repo's own filesystem — +hardlinks cannot cross mounts — so where `$TMPDIR` is its own mount (tmpfs `/tmp`, a separate +`/home`) the tool says so and falls back to `.pentagi-review-sandboxes` beside the repo; +`PENTAGI_SANDBOX_ROOT` overrides both. Two caveats: an absolute path still escapes it, and a tool that +rewrites a dependency **in place** would reach the shared inode — don't hand `--with-deps` +to an agent whose job is patching libraries. Check `git status` in your own tree when a run +finishes; that is the only proof nothing leaked. + ## LLM advisory layer (Phase 3, opt-in) Deterministic specs are the gate; an LLM is only ever an advisory second diff --git a/frontend/docs/list_detail_pages.md b/frontend/docs/list_detail_pages.md index 6002e095..3fe58f47 100644 --- a/frontend/docs/list_detail_pages.md +++ b/frontend/docs/list_detail_pages.md @@ -4,9 +4,12 @@ Reusable surface for **list-and-detail** pages: a filterable/sortable table, a Prev/Next/Sheet toolbar that walks the _same_ filtered subset on detail pages, inline-rename inputs, and the URL-vs-storage state model that keeps them in lockstep. Every list page in the app (`/flows`, `/knowledges`, `/templates`, -`/settings/prompts`, `/settings/providers`, `/settings/api-tokens`) is built -from these pieces, as is each one's detail page — `/settings/api-tokens` is the -exception, editing its rows in place with no detail route of its own. +`/settings/prompts`, `/settings/providers`, `/settings/api-tokens`) renders a +``. The detail half applies to `/flows/:id`, `/knowledges/:id` and +`/templates/:id` only. `/settings/api-tokens` has no detail route at all (it edits +rows in place, through its own state rather than the inline-edit surface below), +and `/settings/prompts/:name` and `/settings/providers/:id` are single-entity +forms with no sibling navigation, so neither has a feature nav hook. > **Scope / how to trust this doc.** It describes code under `frontend/src`. > Signatures below are copied verbatim from source. If a signature and the code @@ -46,8 +49,9 @@ Two rules make the whole thing predictable: 1. **URL is authoritative** for filter + page, so a shared link reproduces the exact view. -2. **Prev/Next walks the same subset** — the detail toolbar runs the same - matcher the list filter uses, so siblings stay in step with the table. +2. **Prev/Next walks the same subset** — the detail toolbar filters on the same + `?q=` the table does, so siblings stay in step with the list. The two + matchers are close but not identical; see the caveat below. ## Mental model @@ -152,11 +156,17 @@ The current page index and the ad-hoc `?q=` filter stay URL-only. ### Prev/Next walks the same subset `useDetailNavigation` subscribes to the URL filter via -`useTableQueryFilterReader`, runs the same pure matcher the list uses, and +`useTableQueryFilterReader`, applies a pure matcher over the same `?q=`, and resolves the Prev / Next siblings + a `"3/10"` position label over that filtered -subset — so the toolbar is always in step with what the user saw in the table. -It also threads the current `?q=` through every prev/next/select destination via -`mergeHrefWithSearchParams`, so navigating siblings never drops the filter. +subset. It also threads the current `?q=` through every prev/next/select +destination via `mergeHrefWithSearchParams`, so navigating siblings never drops +the filter. + +The two matchers are not the same function, and they disagree on diacritics: +the toolbar folds them (`createTextMatcher` normalizes NFKD and strips combining +marks), while `DataTable`'s `globalFilterFn` only lowercases. Searching `cafe` +therefore hides a `café` row from the table while Prev/Next still steps onto it. +Align the two before relying on the subsets being identical. ## Hooks @@ -343,7 +353,7 @@ function DetailNavigationToolbar(props: { renderItem?: (item: T, isCurrent: boolean) => ReactNode; hasSearch?: boolean; // default true — in-sheet search input searchPlaceholder?: string; -}): JSX.Element | null; // null when controller.itemsEmpty +}): JSX.Element; ``` The toolbar composes `` (Prev / position / Next) and @@ -466,8 +476,13 @@ export function EntitiesPage() { const pageHeader = ; - // Canonical 4-branch render gate — pageHeader renders in ALL branches: - if (isLoading) { + // Canonical 4-branch render gate — pageHeader renders in ALL branches. + // Both loading and error gate on "nothing to show yet": the query is + // cache-and-network, so a background revalidation reports loading (and, on + // failure, error) with cached data still present. Without `&& !data` that + // refetch blanks a working list — or an edit form with unsaved changes — + // with the spinner/error for the round-trip. + if (isLoading && entities.length === 0) { return ( <> {pageHeader} @@ -475,8 +490,6 @@ export function EntitiesPage() { ); } - // Show the error surface only when there's no data — a failed background - // refetch must not blank a working list. if (error && entities.length === 0) { return ( <> @@ -619,9 +632,10 @@ legacy keys in on first mount and deletes them. filter (`?q=`, via `useTableState`) and a server-side semantic search (`?qs=`, via page-local `useSearchParams`) are independent — don't conflate them. A copied list recipe only needs the `?q=` one. -- **`DetailNavigationToolbar` renders `null` when `controller.itemsEmpty`** (raw - items empty, pre-filter), so it can mount before the provider's list arrives - without flashing "–/0". +- **`DetailNavigationToolbar` always renders.** It has no empty-list branch, so + it mounts before the provider's list arrives and shows the buttons' own + disabled/"–/0" state until items land. `controller.itemsEmpty` exists for a + caller that wants to skip rendering it, but nothing reads it today. ## Testing @@ -661,3 +675,5 @@ not reintroduce them: | `toolbarProps` spread into `` | No such object. Pass `controller={nav}` + discrete `renderItem`/`sheetIcon`/`sheetTitle`. | | `hooks/use-inline-edit` | Wrong path. It lives at `components/shared/inline-edit/use-inline-edit.ts`. | | `components/shared/data-table` | Wrong path. It lives at `components/ui/data-table.tsx`. | +| `features/providers/use-provider-detail-navigation.ts` | Never existed. `/settings/providers/:id` is a form with no sibling nav — only `flows`, `knowledges` and `templates` have nav hooks. | +| `features/prompts/use-prompt-detail-navigation.ts` | Never existed. Same for `/settings/prompts/:name`. | diff --git a/frontend/e2e/affected-routes.ts b/frontend/e2e/affected-routes.ts index 5e493bd0..14f53b9c 100644 --- a/frontend/e2e/affected-routes.ts +++ b/frontend/e2e/affected-routes.ts @@ -7,15 +7,11 @@ const owns = (dir: string, file: string): boolean => file === dir || file.starts /** * Maps a set of changed repo files to the manifest routes they touch, by - * prefix-matching each change against a route's owning source dirs. This is the - * substrate for selective runs and for scoping the exploratory agent to the - * changed surface. Pure — the CLI and the vitest unit test both drive it. + * prefix-matching each change against a route's owning source dirs. * - * - no frontend change → no routes (a backend-only diff; the caller falls back - * to the full suite, and schema-compat covers the backend contract); - * - a frontend change owned by no route → all routes (shared infra: the e2e - * engine, config, src/lib, a provider used everywhere) — conservative on - * purpose, since over-running is safe and under-running hides a regression; + * - no frontend change → no routes (the caller falls back to the full suite); + * - a frontend change owned by no route → all routes (shared infra: e2e engine, + * config, src/lib, an everywhere-provider) — over-run rather than under-run; * - otherwise → the routes whose `sources` the changes fall under. * * Paths are repo-relative (`frontend/...`); `sources` are `frontend/`-relative. diff --git a/frontend/e2e/affected-routes.unit.test.ts b/frontend/e2e/affected-routes.unit.test.ts index 53e73d66..c8fbed50 100644 --- a/frontend/e2e/affected-routes.unit.test.ts +++ b/frontend/e2e/affected-routes.unit.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import type { RouteSource } from './affected-routes.ts'; import { affectedRoutes } from './affected-routes.ts'; +import { ROUTE_MANIFEST } from './routes.ts'; const MANIFEST: RouteSource[] = [ { @@ -58,4 +59,12 @@ describe('affectedRoutes', () => { expect(result.map((route) => route.path)).toEqual(['/flows']); }); + + // The fake manifest above cannot catch ownership drift in the real one: a shared provider claimed + // by a single route silently scopes a run away from the other routes that mount its consumers. + it('scopes a shared provider to every route that mounts a consumer', () => { + const result = affectedRoutes(['frontend/src/providers/templates-provider.tsx'], ROUTE_MANIFEST); + + expect(result.map((route) => route.path).sort()).toEqual(['/flows/5', '/templates']); + }); }); diff --git a/frontend/e2e/ci-codegen-gate.unit.test.ts b/frontend/e2e/ci-codegen-gate.unit.test.ts new file mode 100644 index 00000000..0895da98 --- /dev/null +++ b/frontend/e2e/ci-codegen-gate.unit.test.ts @@ -0,0 +1,91 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const SCRIPT = join(__dirname, '..', '..', '.github', 'scripts', 'codegen-inputs-changed.sh'); + +let repo = ''; +const sha = { base: '', merge: '', schema: '', unrelated: '' }; + +// -c user.* is passed on every invocation (not just `commit`) because `commit-tree` also refuses +// to run without an author identity, and CI runners have none configured globally. +const git = (...args: string[]) => + execFileSync('git', ['-C', repo, '-c', 'user.email=e2e@example.com', '-c', 'user.name=e2e', ...args], { + encoding: 'utf8', + }).trim(); + +const commit = (path: string, body: string, message: string) => { + mkdirSync(join(repo, path.slice(0, path.lastIndexOf('/'))), { recursive: true }); + writeFileSync(join(repo, path), body); + git('add', '-A'); + git('commit', '-m', message); + + return git('rev-parse', 'HEAD'); +}; + +const run = (event: string, before: string, baseSha: string, headSha: string) => + execFileSync(SCRIPT, [event, before, baseSha, headSha], { cwd: repo, encoding: 'utf8' }).trim(); + +const runReason = (event: string, before: string, baseSha: string, headSha: string) => + spawnSync(SCRIPT, [event, before, baseSha, headSha], { cwd: repo, encoding: 'utf8' }).stderr.trim(); + +// The PR arrives as the merge commit GitHub builds, not as the branch head — the range the gate +// picks has to span the whole PR, not the newest push. +beforeAll(() => { + repo = mkdtempSync(join(tmpdir(), 'codegen-gate-')); + git('init', '-q', '-b', 'main'); + sha.base = commit('README.md', 'base\n', 'base'); + sha.schema = commit('backend/pkg/graph/schema.graphqls', 'type Query { a: Int }\n', 'edit the schema'); + sha.unrelated = commit('README.md', 'base\nmore\n', 'unrelated follow-up'); + sha.merge = git('commit-tree', `${sha.unrelated}^{tree}`, '-p', sha.base, '-p', sha.unrelated, '-m', 'merge'); +}); + +afterAll(() => rmSync(repo, { force: true, recursive: true })); + +describe('codegen freshness gate — range selection', () => { + it('checks a follow-up push whose newest commit did not touch a codegen input', () => { + expect(run('pull_request', sha.schema, sha.base, sha.merge)).toBe('changed=true'); + }); + + it('checks a freshly opened PR, where there is no previous head', () => { + expect(run('pull_request', '', sha.base, sha.merge)).toBe('changed=true'); + }); + + // The answer alone cannot tell this branch from the diff-failed fallback below, which prints + // the same `changed=true`; only the reason distinguishes them. + it('checks a force-push, where the previous head no longer resolves', () => { + expect(run('push', 'b'.repeat(40), '', sha.schema)).toBe('changed=true'); + expect(runReason('push', 'b'.repeat(40), '', sha.schema)).toContain('reason=unresolvable-base'); + }); + + it('checks the first push of a branch, where there is no previous head at all', () => { + expect(run('push', '0'.repeat(40), '', sha.schema)).toBe('changed=true'); + expect(runReason('push', '0'.repeat(40), '', sha.schema)).toContain('reason=unresolvable-base'); + }); + + it('keeps checking a push by its own range', () => { + expect(run('push', sha.base, '', sha.schema)).toBe('changed=true'); + }); + + it('still skips a PR that touches no codegen input', () => { + expect(run('pull_request', '', sha.schema, sha.unrelated)).toBe('changed=false'); + }); + + it('checks a codegen input whose diff is larger than one pipe buffer', () => { + const pad = 'x'.repeat(180); + mkdirSync(join(repo, 'frontend', 'filler'), { recursive: true }); + + for (let i = 0; i < 1000; i += 1) { + writeFileSync(join(repo, 'frontend', 'filler', `${i}-${pad}.txt`), ''); + } + + const bulk = commit('backend/pkg/graph/schema.graphqls', 'type Query { a: Int, b: Int }\n', 'bulk'); + const names = git('diff', '--name-only', sha.unrelated, bulk); + + expect(names.split('\n')[0]).toBe('backend/pkg/graph/schema.graphqls'); + expect(names.length).toBeGreaterThan(64 * 1024); + expect(run('push', sha.unrelated, '', bulk)).toBe('changed=true'); + }); +}); diff --git a/frontend/e2e/fixtures/auth.ts b/frontend/e2e/fixtures/auth.ts index ae74c3c9..bcb3de22 100644 --- a/frontend/e2e/fixtures/auth.ts +++ b/frontend/e2e/fixtures/auth.ts @@ -147,9 +147,16 @@ export const guestInfoEntry = (): RestCassetteEntry => ({ export const seedAuthenticated = async (page: Page): Promise => { await page.addInitScript( - ([key, value]) => { + ([key, value, sentinel]) => { + // Init scripts run on every document, so an unguarded seed would resurrect the session + // the app just cleared and no logout/expiry spec could observe the signed-out state. + if (window.sessionStorage.getItem(String(sentinel))) { + return; + } + + window.sessionStorage.setItem(String(sentinel), '1'); window.localStorage.setItem(String(key), String(value)); }, - [AUTH_STORAGE_KEY, JSON.stringify(seededAuthInfo())] as const, + [AUTH_STORAGE_KEY, JSON.stringify(seededAuthInfo()), 'e2e-auth-seeded'] as const, ); }; diff --git a/frontend/e2e/fixtures/backend.ts b/frontend/e2e/fixtures/backend.ts index e433c148..ed9f6c6e 100644 --- a/frontend/e2e/fixtures/backend.ts +++ b/frontend/e2e/fixtures/backend.ts @@ -33,7 +33,7 @@ export const test = base.extend { + async ({ backend, context, isAuthSeeded, page, world }, use, testInfo) => { if (!backend.installMocks) { await use(); @@ -46,7 +46,7 @@ export const test = base.extend({ - pageErrorLog: async ({ page }, use) => { - await use(watchPageErrors(page)); - }, + pageErrorLog: [ + async ({ page }, use) => { + const log = watchPageErrors(page); + + await use(log); + + // Auto (not opt-in): a spec that never destructured this fixture would assert nothing. + // Console errors stay with `expectCleanPage` — several specs drive 4xx paths that log one. + baseExpect(log.pageErrors, 'no uncaught page errors').toEqual([]); + }, + { auto: true }, + ], }); export const test = mergeTests(backendTest, errorsTest); diff --git a/frontend/e2e/helpers/a11y.ts b/frontend/e2e/helpers/a11y.ts index 433d48f6..688c5336 100644 --- a/frontend/e2e/helpers/a11y.ts +++ b/frontend/e2e/helpers/a11y.ts @@ -12,9 +12,18 @@ import { expect } from '@playwright/test'; */ export interface A11yWaiver { rule: string; + /** + * Flow-detail tab names whose panel owns this debt. Omitted = route-wide (applies to the base + * view and every tab). Scoped so a Files-tab waiver cannot silence the same rule on Assistant. + */ + tabs?: string[]; target: RegExp; } +/** Waivers in force for one scan: route-wide ones always, tab-scoped ones only on their own tab. */ +export const waiversForScan = (waivers: A11yWaiver[] = [], tab?: string): A11yWaiver[] => + waivers.filter((waiver) => !waiver.tabs || (tab !== undefined && waiver.tabs.includes(tab))); + const BLOCKING_IMPACTS = new Set(['critical', 'serious']); export const scanA11y = async (page: Page, route: string, waivers: A11yWaiver[] = []): Promise => { diff --git a/frontend/e2e/helpers/contrast.ts b/frontend/e2e/helpers/contrast.ts index a8e361d4..2716ee8d 100644 --- a/frontend/e2e/helpers/contrast.ts +++ b/frontend/e2e/helpers/contrast.ts @@ -82,68 +82,188 @@ export const mountEditorProbes = async (page: Page, probes: Record => - page.evaluate((name) => { - const element = document.querySelector(`[data-contrast="${name}"]`); +export const parseColorAlpha = (color: string): number => { + const value = color.trim().toLowerCase(); - if (!element?.parentElement) { - throw new Error(`contrast probe "${name}" is not mounted`); + if (value === 'transparent') { + return 0; + } + + if (value.startsWith('#')) { + const digits = value.slice(1); + const hex = digits.length === 4 ? digits.slice(3).repeat(2) : digits.length === 8 ? digits.slice(6) : ''; + + return hex ? parseInt(hex, 16) / 255 : 1; + } + + const args = /^[a-z-]+\(([^)]*)\)$/.exec(value)?.[1]; + + if (args === undefined) { + return 1; + } + + const raw = (args.includes('/') ? args.split('/').at(-1) : args.split(',').at(3))?.trim(); + + if (raw === undefined) { + return 1; + } + + const alpha = raw.endsWith('%') ? Number(raw.slice(0, -1)) / 100 : Number(raw); + + return Number.isFinite(alpha) ? Math.min(Math.max(alpha, 0), 1) : 1; +}; + +// Walk to the first opaque ancestor, not just the parent: a probe inside a code block sits on +// `pre.hljs` through a transparent `code`, and stopping at the parent would measure it against +// nothing. The stack is painted onto a cleared canvas, so a translucent layer with no opaque base +// under it keeps its own colour and reads as a contrast it does not have. +export const resolveGroundLayers = (ancestorBackgrounds: string[]): string[] => { + const stack: string[] = []; + + for (const background of ancestorBackgrounds) { + const alpha = parseColorAlpha(background); + + if (alpha === 0) { + continue; } - const canvas = document.createElement('canvas'); + stack.unshift(background); - canvas.width = 1; - canvas.height = 1; - - const context = canvas.getContext('2d', { willReadFrequently: true }); - - if (!context) { - throw new Error('2d canvas context unavailable'); + if (alpha === 1) { + break; } + } - const paint = (...layers: string[]) => { - context.clearRect(0, 0, 1, 1); + return stack; +}; - for (const layer of layers) { - // An unparseable colour is a silent no-op on fillStyle, leaving the previous - // value — which measures as a spurious ~21:1 pass. Assign it against two - // different sentinels: a colour that applied lands on the same value both - // times, an invalid one keeps whichever sentinel preceded it. - context.fillStyle = '#000000'; - context.fillStyle = layer; - const fromBlack = context.fillStyle; +export const mountFenceProbes = async (page: Page, tokens: string[]): Promise => { + await page.evaluate( + ({ classNames, hostId }) => { + const fence = document.querySelector('.tiptap-content .ProseMirror pre'); + const code = fence?.querySelector('code'); - context.fillStyle = '#ffffff'; - context.fillStyle = layer; - - if (context.fillStyle !== fromBlack) { - throw new Error(`contrast: colour "${layer}" did not apply`); - } - - context.fillRect(0, 0, 1, 1); + if (!fence || !code) { + throw new Error('no code fence mounted to probe'); } - const [r = 0, g = 0, b = 0] = context.getImageData(0, 0, 1, 1).data; + document.getElementById(hostId)?.remove(); + const host = document.createElement('div'); - return { b, g, r }; - }; + host.id = hostId; + host.className = 'tiptap-content'; + host.style.cssText = 'position:fixed;inset:0 auto auto 0;z-index:2147483647'; - const luminance = ({ b, g, r }: { b: number; g: number; r: number }) => { - const channel = (value: number) => { - const ratio = value / 255; + const surface = document.createElement('div'); - return ratio <= 0.03928 ? ratio / 12.92 : ((ratio + 0.055) / 1.055) ** 2.4; + surface.className = 'ProseMirror'; + + const pre = fence.cloneNode(false) as HTMLElement; + const holder = code.cloneNode(false) as HTMLElement; + + for (const className of classNames) { + const probe = document.createElement('span'); + + probe.className = className; + probe.dataset.contrast = className; + probe.textContent = 'Sample'; + holder.append(probe); + } + + pre.append(holder); + surface.append(pre); + host.append(surface); + document.body.append(host); + + const cloned = getComputedStyle(pre).backgroundColor; + const real = getComputedStyle(fence).backgroundColor; + + if (cloned !== real) { + throw new Error(`fence clone sits on ${cloned}, the editor's fence on ${real}`); + } + }, + { classNames: tokens, hostId: 'contrast-fence-probes' }, + ); +}; + +export const measureContrast = async (page: Page, probe: string): Promise => + measureContrastAt(page, `[data-contrast="${probe}"]`); + +export const measureContrastAt = async (page: Page, selector: string): Promise => { + const { ancestors, backgroundColor, color } = await page.evaluate((target) => { + const element = document.querySelector(target); + + if (!element?.parentElement) { + throw new Error(`contrast target "${target}" is not mounted`); + } + + const chain: string[] = []; + + for (let node: HTMLElement | null = element.parentElement; node; node = node.parentElement) { + chain.push(getComputedStyle(node).backgroundColor); + } + + const own = getComputedStyle(element); + + return { ancestors: chain, backgroundColor: own.backgroundColor, color: own.color }; + }, selector); + + return page.evaluate( + ({ ground, text }) => { + const canvas = document.createElement('canvas'); + + canvas.width = 1; + canvas.height = 1; + + const context = canvas.getContext('2d', { willReadFrequently: true }); + + if (!context) { + throw new Error('2d canvas context unavailable'); + } + + const paint = (...layers: string[]) => { + context.clearRect(0, 0, 1, 1); + + for (const layer of layers) { + // An unparseable colour is a silent no-op on fillStyle, leaving the previous + // value — which measures as a spurious ~21:1 pass. Assign it against two + // different sentinels: a colour that applied lands on the same value both + // times, an invalid one keeps whichever sentinel preceded it. + context.fillStyle = '#000000'; + context.fillStyle = layer; + const fromBlack = context.fillStyle; + + context.fillStyle = '#ffffff'; + context.fillStyle = layer; + + if (context.fillStyle !== fromBlack) { + throw new Error(`contrast: colour "${layer}" did not apply`); + } + + context.fillRect(0, 0, 1, 1); + } + + const [r = 0, g = 0, b = 0] = context.getImageData(0, 0, 1, 1).data; + + return { b, g, r }; }; - return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); - }; + const luminance = ({ b, g, r }: { b: number; g: number; r: number }) => { + const channel = (value: number) => { + const ratio = value / 255; - const surface = paint( - getComputedStyle(element.parentElement).backgroundColor, - getComputedStyle(element).backgroundColor, - ); - const label = paint(getComputedStyle(element).color); - const [high = 0, low = 0] = [luminance(label), luminance(surface)].sort((a, b) => b - a); + return ratio <= 0.03928 ? ratio / 12.92 : ((ratio + 0.055) / 1.055) ** 2.4; + }; - return (high + 0.05) / (low + 0.05); - }, probe); + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); + }; + + const surface = paint(...ground); + const label = paint(...ground, text); + const [high = 0, low = 0] = [luminance(label), luminance(surface)].sort((a, b) => b - a); + + return (high + 0.05) / (low + 0.05); + }, + { ground: [...resolveGroundLayers(ancestors), backgroundColor], text: color }, + ); +}; diff --git a/frontend/e2e/helpers/contrast.unit.test.ts b/frontend/e2e/helpers/contrast.unit.test.ts new file mode 100644 index 00000000..f55ce9b0 --- /dev/null +++ b/frontend/e2e/helpers/contrast.unit.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; + +import { parseColorAlpha, resolveGroundLayers } from './contrast.ts'; + +// Every colour below is a verbatim `getComputedStyle(...).backgroundColor` reading from the +// Chromium the e2e suite drives: Chrome leaves oklch/lab/color() in their own function. +describe('parseColorAlpha', () => { + it('reads the alpha of the colour functions Chrome keeps unconverted', () => { + expect(parseColorAlpha('oklch(0.7 0.1 250 / 0.3)')).toBeCloseTo(0.3); + expect(parseColorAlpha('oklch(0.7 0.1 250)')).toBe(1); + expect(parseColorAlpha('oklch(0.216 0.006 56.043 / 0.3)')).toBeCloseTo(0.3); + expect(parseColorAlpha('color(srgb 1 0 0 / 0.4)')).toBeCloseTo(0.4); + expect(parseColorAlpha('color(srgb 1 0 0)')).toBe(1); + expect(parseColorAlpha('lab(50 40 59.5 / 0.5)')).toBeCloseTo(0.5); + }); + + it('reads the alpha of the legacy sRGB forms', () => { + expect(parseColorAlpha('rgba(255, 0, 0, 0.5)')).toBeCloseTo(0.5); + expect(parseColorAlpha('rgba(64, 149, 191, 0.25)')).toBeCloseTo(0.25); + expect(parseColorAlpha('rgba(0, 0, 0, 0)')).toBe(0); + expect(parseColorAlpha('transparent')).toBe(0); + }); + + it('treats an opaque rgb() as opaque instead of reading its blue channel as the alpha', () => { + expect(parseColorAlpha('rgb(255, 0, 0)')).toBe(1); + expect(parseColorAlpha('rgb(24, 24, 27)')).toBe(1); + expect(parseColorAlpha('rgb(0, 0, 0)')).toBe(1); + }); + + it('reads alpha out of hex and percentage forms', () => { + expect(parseColorAlpha('#ff0000')).toBe(1); + expect(parseColorAlpha('#ff000080')).toBeCloseTo(0.5, 2); + expect(parseColorAlpha('#f008')).toBeCloseTo(0.53, 2); + expect(parseColorAlpha('rgb(255 0 0 / 30%)')).toBeCloseTo(0.3); + }); +}); + +describe('resolveGroundLayers', () => { + it('stops on an opaque rgb() ancestor instead of skipping it as invisible', () => { + expect(resolveGroundLayers(['rgba(0, 0, 0, 0)', 'rgb(255, 0, 0)', 'rgb(0, 0, 255)'])).toEqual([ + 'rgb(255, 0, 0)', + ]); + }); + + it('keeps walking under a translucent oklch ancestor instead of grounding on it', () => { + expect(resolveGroundLayers(['oklch(0.7 0.1 250 / 0.3)', 'oklch(0.985 0 0)', 'oklch(0.145 0 0)'])).toEqual([ + 'oklch(0.985 0 0)', + 'oklch(0.7 0.1 250 / 0.3)', + ]); + }); + + it('skips a fully transparent ancestor and keeps the layers outermost first', () => { + expect( + resolveGroundLayers(['rgba(0, 0, 0, 0)', 'color(srgb 1 0 0 / 0.4)', 'rgba(0, 0, 0, 0)', '#101014']), + ).toEqual(['#101014', 'color(srgb 1 0 0 / 0.4)']); + }); +}); diff --git a/frontend/e2e/helpers/pdf.ts b/frontend/e2e/helpers/pdf.ts new file mode 100644 index 00000000..a1560df4 --- /dev/null +++ b/frontend/e2e/helpers/pdf.ts @@ -0,0 +1,68 @@ +import { inflateSync } from 'node:zlib'; + +export interface PdfShape { + /** Embedded font subsets — a document that drew no text embeds none. */ + fonts: number; + isPdf: boolean; + pages: number; + /** Text-showing operators across every content stream. */ + textRuns: number; + trailerComplete: boolean; +} + +const inflateStreams = (pdf: Buffer): Buffer[] => { + const streams: Buffer[] = []; + const marker = Buffer.from('stream'); + const end = Buffer.from('endstream'); + + let from = 0; + + for (;;) { + const start = pdf.indexOf(marker, from); + + if (start === -1) { + return streams; + } + + const stop = pdf.indexOf(end, start); + + if (stop === -1) { + return streams; + } + + // Skip the EOL that must follow the `stream` keyword. + const body = pdf.subarray(start + marker.length, stop); + + try { + streams.push(inflateSync(body.subarray(body[0] === 0x0d ? 2 : 1))); + } catch { + // Not every stream is Flate-encoded (font programs, images) — those are not text. + } + + from = stop + end.length; + } +}; + +/** + * Structural read of a generated PDF. It deliberately does not decode the text: each font subset + * carries its own glyph map, so a merged decode garbles the result. The report's wording is checked + * on the markdown export instead, which is built from the same string and whose bytes are searched + * for the task and result text. + */ +export const inspectPdf = (pdf: Buffer): PdfShape => { + const head = pdf.subarray(0, 5).toString(); + const tail = pdf.subarray(-1024).toString(); + const body = pdf.toString('latin1'); + const streams = inflateStreams(pdf); + + return { + fonts: (body.match(/\/FontFile\d?/g) ?? []).length, + isPdf: head === '%PDF-', + pages: (body.match(/\/Type\s*\/Page[^s]/g) ?? []).length, + textRuns: streams.reduce( + (total, stream) => total + (stream.toString('latin1').match(/\bTJ\b|\bTj\b/g) ?? []).length, + 0, + ), + trailerComplete: tail.includes('%%EOF'), + }; +}; diff --git a/frontend/e2e/helpers/subscriptions.ts b/frontend/e2e/helpers/subscriptions.ts index 955fcf7d..3a7a8cf6 100644 --- a/frontend/e2e/helpers/subscriptions.ts +++ b/frontend/e2e/helpers/subscriptions.ts @@ -2,7 +2,9 @@ import type { Locator, TestInfo } from '@playwright/test'; import { expect } from '@playwright/test'; -export const MESSAGE_ID_TESTID = 'flow-message-id'; +/** The message-id badge carries no accessible name, so it is addressed by its slot like every other + * shadcn primitive in this codebase — `data-testid` is not used in product code. */ +export const MESSAGE_ID_SELECTOR = '[data-slot="flow-message-id"]'; export const attachIdSet = async (testInfo: TestInfo, name: string, ids: string[]): Promise => { await testInfo.attach(name, { @@ -12,7 +14,7 @@ export const attachIdSet = async (testInfo: TestInfo, name: string, ids: string[ }; export const extractMessageIds = async (panel: Locator): Promise => { - const raw = await panel.getByTestId(MESSAGE_ID_TESTID).allTextContents(); + const raw = await panel.locator(MESSAGE_ID_SELECTOR).allTextContents(); return raw.map((text) => text.replace(/\D/g, '')).filter(Boolean); }; diff --git a/frontend/e2e/mock-llm/scenario.mjs b/frontend/e2e/mock-llm/scenario.mjs index c38dbdc4..f845d0de 100644 --- a/frontend/e2e/mock-llm/scenario.mjs +++ b/frontend/e2e/mock-llm/scenario.mjs @@ -35,8 +35,6 @@ export const RULES = [ match: /get_number/, toolCalls: [{ args: { value: 1 }, name: 'get_number' }], }, - // The task reporter summarizes the finished task and only accepts the - // report_result tool. { label: 'task-report', match: /report_result|TASK EXECUTION EVALUATOR AND REPORTER/, @@ -51,8 +49,7 @@ export const RULES = [ }, ], }, - // The refiner re-plans between subtasks and only accepts subtask_patch; - // an empty operations list means "plan unchanged". + // An empty operations list means "plan unchanged". { label: 'subtask-refine', match: /subtask_patch/, @@ -81,7 +78,7 @@ export const RULES = [ // matching that agent's turn. { label: 'subagent-terminal-report', - match: /(?=[\s\S]*"hack_result")(?=[\s\S]*E2E_TERMINAL_OK)/, + match: /^(?=[\s\S]*"hack_result")(?=[\s\S]*E2E_TERMINAL_OK)/, toolCalls: [ { args: { diff --git a/frontend/e2e/mock-llm/server.mjs b/frontend/e2e/mock-llm/server.mjs index a02f90f3..a8354ada 100644 --- a/frontend/e2e/mock-llm/server.mjs +++ b/frontend/e2e/mock-llm/server.mjs @@ -137,7 +137,10 @@ createServer(async (request, response) => { } const rule = pickAnswer(payload); - const toolNames = (payload.tools ?? []).map((tool) => tool.function?.name ?? tool.type).join(','); + // Defensive end to end because this runs outside the try/catch above, where a throw kills + // the process: the top-level guard admits {"tools":5} and {"tools":[null]} alike. + const toolList = Array.isArray(payload.tools) ? payload.tools : []; + const toolNames = toolList.map((tool) => tool?.function?.name ?? tool?.type ?? '?').join(','); console.log(`[mock-llm] ${rule.label}: ${payload.stream ? 'stream' : 'plain'} tools=[${toolNames}]`); respondCompletion(response, payload, rule); diff --git a/frontend/e2e/mocks/cassette.ts b/frontend/e2e/mocks/cassette.ts index 94577f60..8415051f 100644 --- a/frontend/e2e/mocks/cassette.ts +++ b/frontend/e2e/mocks/cassette.ts @@ -28,6 +28,14 @@ export const mergeCassettes = (base: Cassette, override: Cassette): Cassette => }); export interface GraphQLCassetteEntry extends GraphQLPayload, WorldFlagged { + /** + * Served verbatim in place of the `{ data, errors }` envelope. The GraphQL endpoint sits behind + * the same auth middleware as the REST ones, so a rejected request answers with the REST error + * body and never reaches gqlgen. + */ + body?: unknown; + /** A transport-level status the client reads instead of the GraphQL `errors` array. */ + status?: number; variables?: Record; } @@ -51,6 +59,13 @@ export interface RestCassetteEntry extends WorldFlagged { * a broken image while the request still counts as matched. */ contentType?: string; + /** + * Subset match against the request's query string, the counterpart to `bodySubset` for + * endpoints that carry their payload there and send no body — a delete addressed by + * `?paths[]=…` is otherwise answered success whatever it names. Repeated keys are compared + * as the full list of values. + */ + querySubset?: Record; status?: number; } diff --git a/frontend/e2e/mocks/cassettes/assistants.ts b/frontend/e2e/mocks/cassettes/assistants.ts new file mode 100644 index 00000000..2f09258d --- /dev/null +++ b/frontend/e2e/mocks/cassettes/assistants.ts @@ -0,0 +1,72 @@ +import type { ResultOf } from '@graphql-typed-document-node/core'; + +import type { AssistantFragmentFragment, AssistantLogFragmentFragment, AssistantsDocument } from '@/graphql/types'; + +import { MessageLogType, ResultFormat, StatusType } from '@/graphql/types'; + +import type { Cassette } from '../cassette.ts'; + +import { entity, mergeCassettes } from '../cassette.ts'; +import { flowsCassette, PROVIDER } from './flows.ts'; + +const T = '2026-01-15T11:30:00Z'; + +export const makeAssistant = ( + id: string, + title: string, + status: StatusType = StatusType.Waiting, +): AssistantFragmentFragment => + entity('Assistant', { + createdAt: T, + flowId: '5', + id, + provider: PROVIDER, + status, + title, + updatedAt: T, + useAgents: false, + }); + +export const WAITING_ASSISTANT = makeAssistant('11', 'E2E Assistant Waiting'); +export const RUNNING_ASSISTANT = makeAssistant('12', 'E2E Assistant Running', StatusType.Running); +export const CREATED_ASSISTANT = makeAssistant('13', 'E2E Assistant Created'); + +export const ASSISTANT_ANSWER = 'The scan found an open port on 8080.'; + +const assistantLog = (id: string, assistantId: string, message: string): AssistantLogFragmentFragment => + entity('AssistantLog', { + appendPart: false, + assistantId, + createdAt: T, + flowId: '5', + id, + message, + result: ASSISTANT_ANSWER, + resultFormat: ResultFormat.Markdown, + thinking: null, + type: MessageLogType.Answer, + }); + +const seeded: ResultOf = { assistants: [WAITING_ASSISTANT, RUNNING_ASSISTANT] }; + +export const assistantsCassette = (override: Cassette = {}): Cassette => + mergeCassettes( + flowsCassette({ + queries: { + assistantLogs: [ + { + data: { assistantLogs: [assistantLog('41', '11', 'what did the scan find?')] }, + variables: { assistantId: '11', flowId: '5' }, + }, + { + data: { assistantLogs: [assistantLog('42', '12', 'keep going')] }, + variables: { assistantId: '12', flowId: '5' }, + }, + { data: { assistantLogs: [] }, variables: { assistantId: '13', flowId: '5' } }, + ], + assistants: [{ data: seeded, variables: { flowId: '5' } }], + providers: [{ data: { providers: [PROVIDER] } }], + }, + }), + override, + ); diff --git a/frontend/e2e/mocks/cassettes/assistants.unit.test.ts b/frontend/e2e/mocks/cassettes/assistants.unit.test.ts new file mode 100644 index 00000000..0a33e04d --- /dev/null +++ b/frontend/e2e/mocks/cassettes/assistants.unit.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import { MockWorld } from '../world.ts'; +import { assistantsCassette, CREATED_ASSISTANT, RUNNING_ASSISTANT, WAITING_ASSISTANT } from './assistants.ts'; + +const transcript = (world: MockWorld, assistantId: string): undefined | unknown[] => + ( + world.matchGraphQL('assistantLogs', { assistantId, flowId: '5' })?.data as + | undefined + | { assistantLogs: unknown[] } + )?.assistantLogs; + +describe('assistants cassette — assistantLogs', () => { + it('serves an assistant its own transcript on every read, not only the first', () => { + const world = new MockWorld(assistantsCassette()); + + expect(transcript(world, WAITING_ASSISTANT.id)).toHaveLength(1); + expect(transcript(world, WAITING_ASSISTANT.id)).toHaveLength(1); + expect(transcript(world, RUNNING_ASSISTANT.id)).toHaveLength(1); + expect(transcript(world, RUNNING_ASSISTANT.id)).toHaveLength(1); + }); + + it('answers the freshly created assistant with an empty transcript', () => { + const world = new MockWorld(assistantsCassette()); + + expect(transcript(world, CREATED_ASSISTANT.id)).toEqual([]); + }); +}); diff --git a/frontend/e2e/mocks/cassettes/base.ts b/frontend/e2e/mocks/cassettes/base.ts index f7a26753..b6c2238b 100644 --- a/frontend/e2e/mocks/cassettes/base.ts +++ b/frontend/e2e/mocks/cassettes/base.ts @@ -18,9 +18,8 @@ const flows: ResultOf = { flows: [] }; const flowTemplates: ResultOf = { flowTemplates: [] }; -// Live entry, not dead weight: after a websocket reconnect the app re-requests -// resources on the wire with {recursive:true} (removing this red-lights the -// reconnect spec on the 501 gate). +// After a websocket reconnect the app re-requests resources on the wire with {recursive:true}; +// removing this entry red-lights the reconnect spec on the 501 gate. const resources: ResultOf = { resources: [] }; const providers: ResultOf = { providers: [] }; diff --git a/frontend/e2e/mocks/cassettes/flows.ts b/frontend/e2e/mocks/cassettes/flows.ts index 6ab4e314..0c697d27 100644 --- a/frontend/e2e/mocks/cassettes/flows.ts +++ b/frontend/e2e/mocks/cassettes/flows.ts @@ -5,6 +5,7 @@ import type { FlowDocument, FlowFilesDocument, FlowFragmentFragment, + FlowReportDocument, FlowStatsByFlowDocument, MessageLogFragmentFragment, TerminalLogFragmentFragment, @@ -85,11 +86,13 @@ export const VARIED_MESSAGES = [ resultFormat: ResultFormat.Markdown, type: MessageLogType.Report, }), + // Terminal, not Report: `getMessageType` pairs the terminal tool's output with the terminal + // type, so a Report carrying a Terminal body is a state the backend cannot emit. makeMessage('503', '5', { message: '', result: 'e2e-terminal-marker\nexit 0', resultFormat: ResultFormat.Terminal, - type: MessageLogType.Report, + type: MessageLogType.Terminal, }), makeMessage('504', '5', { message: 'run the smoke command', type: MessageLogType.Input }), ]; @@ -179,8 +182,8 @@ export const flowsCassette = (override: Cassette = {}): Cassette => { frames: [ ...FLOW_A_STREAMED_IDS.map((id) => addedFrame(makeMessage(id, '5'), 80)), - // Not a copy-paste: a resubscribe replays what the refetch already - // delivered, and the sentinel after it proves the replay was received. + // A resubscribe replays what the refetch already delivered; the sentinel + // after it proves the replay was received. { ...addedFrame(makeMessage(FLOW_A_RECONNECT_ID, '5'), 0), whenFlag: REPLAY_FLAG, @@ -199,12 +202,38 @@ export const flowsCassette = (override: Cassette = {}): Cassette => override, ); -export const variedMessagesCassette = (): Cassette => +export const FLOW_B_SENTINEL_ID = '205'; +export const PAGER_SWITCH_FLAG = 'pager-switched'; + +/** Holds flow B a frame back so the spec can prove the new stream still delivers after the switch. */ +export const pagerStreamsCassette = (): Cassette => flowsCassette({ - queries: { flow: [{ data: flowQueryData(FLOW_A, VARIED_MESSAGES), variables: { id: '5' } }] }, - subscriptions: { messageLogAdded: [{ frames: [], variables: { flowId: '5' } }] }, + subscriptions: { + messageLogAdded: [ + { + frames: FLOW_A_STREAMED_IDS.map((id) => addedFrame(makeMessage(id, '5'), 80)), + variables: { flowId: '5' }, + }, + { + frames: [ + ...FLOW_B_STREAMED_IDS.map((id) => addedFrame(makeMessage(id, '6'), 80)), + { ...addedFrame(makeMessage(FLOW_B_SENTINEL_ID, '6'), 0), whenFlag: PAGER_SWITCH_FLAG }, + ], + variables: { flowId: '6' }, + }, + ], + }, }); +export const variedMessagesCassette = (override: Cassette = {}): Cassette => + mergeCassettes( + flowsCassette({ + queries: { flow: [{ data: flowQueryData(FLOW_A, VARIED_MESSAGES), variables: { id: '5' } }] }, + subscriptions: { messageLogAdded: [{ frames: [], variables: { flowId: '5' } }] }, + }), + override, + ); + const TABS_TASK = entity('Task', { createdAt: T, flowId: '5', @@ -371,6 +400,139 @@ const flowStatsByFlow: ResultOf = { flowStatsByFlow: entity('FlowStats', { totalAssistantsCount: 1, totalSubtasksCount: 3, totalTasksCount: 1 }), }; +/** Text that exists only in a streamed frame, so a panel that ignores its subscription stays blank. */ +export const STREAMED = { + agent: 'E2E streamed agent step', + search: 'E2E streamed search', + task: 'E2E Streamed Task', + taskRetitled: 'E2E Task Alpha (revised)', + terminal: 'e2e-streamed-terminal-line', + vector: 'E2E streamed recall', +}; + +const streamedFrame = (payload: Record) => ({ delayMs: 60, payload: { data: payload } }); + +export const livePanelsCassette = (): Cassette => + mergeCassettes(flowTabsCassette(), { + subscriptions: { + agentLogAdded: [ + { + frames: [ + streamedFrame({ + agentLogAdded: entity('AgentLog', { + createdAt: T, + executor: AgentType.Pentester, + flowId: '5', + id: '42', + initiator: AgentType.Adviser, + result: 'Streamed step complete', + subtaskId: null, + task: STREAMED.agent, + taskId: '11', + }), + }), + ], + variables: { flowId: '5' }, + }, + ], + searchLogAdded: [ + { + frames: [ + streamedFrame({ + searchLogAdded: entity('SearchLog', { + createdAt: T, + engine: 'duckduckgo', + executor: AgentType.Searcher, + flowId: '5', + id: '52', + initiator: AgentType.Pentester, + query: STREAMED.search, + result: 'Streamed advisories', + subtaskId: null, + taskId: '11', + }), + }), + ], + variables: { flowId: '5' }, + }, + ], + taskCreated: [ + { + frames: [ + streamedFrame({ + taskCreated: entity('Task', { + createdAt: T, + flowId: '5', + id: '12', + input: 'Exploit the open port', + result: '', + status: StatusType.Running, + subtasks: [], + title: STREAMED.task, + updatedAt: T, + }), + }), + ], + variables: { flowId: '5' }, + }, + ], + taskUpdated: [ + { + frames: [ + streamedFrame({ + taskUpdated: { ...TABS_TASK, status: StatusType.Running, title: STREAMED.taskRetitled }, + }), + ], + variables: { flowId: '5' }, + }, + ], + terminalLogAdded: [ + { + frames: [ + streamedFrame({ + terminalLogAdded: terminalLog('303', TerminalLogType.Stdout, STREAMED.terminal), + }), + ], + variables: { flowId: '5' }, + }, + ], + vectorStoreLogAdded: [ + { + frames: [ + streamedFrame({ + vectorStoreLogAdded: entity('VectorStoreLog', { + action: VectorStoreAction.Retrieve, + createdAt: T, + executor: AgentType.Memorist, + filter: '{}', + flowId: '5', + id: '62', + initiator: AgentType.Pentester, + query: STREAMED.vector, + result: 'Streamed recall complete', + subtaskId: null, + taskId: '11', + }), + }), + ], + variables: { flowId: '5' }, + }, + ], + }, + }); + +const flowReportData: ResultOf = { flow: FLOW_A, tasks: [TABS_TASK] }; + +/** The Report menu only appears when the flow query returns tasks, so this overrides `flow` too. */ +export const flowReportCassette = (): Cassette => + flowsCassette({ + queries: { + flow: [{ data: flowTabsData, variables: { id: '5' } }], + flowReport: [{ data: flowReportData, variables: { id: '5' } }], + }, + subscriptions: { messageLogAdded: [{ frames: [], variables: { flowId: '5' } }] }, + }); + export const flowTabsCassette = (): Cassette => flowsCassette({ queries: { diff --git a/frontend/e2e/mocks/cassettes/knowledges.ts b/frontend/e2e/mocks/cassettes/knowledges.ts index e542a931..2ed91c41 100644 --- a/frontend/e2e/mocks/cassettes/knowledges.ts +++ b/frontend/e2e/mocks/cassettes/knowledges.ts @@ -1,6 +1,10 @@ import type { ResultOf } from '@graphql-typed-document-node/core'; -import type { KnowledgeDocumentFragmentFragment, KnowledgeDocumentsDocument } from '@/graphql/types'; +import type { + KnowledgeDocumentDocument, + KnowledgeDocumentFragmentFragment, + KnowledgeDocumentsDocument, +} from '@/graphql/types'; import { KnowledgeAnswerType, KnowledgeDocType } from '@/graphql/types'; @@ -46,3 +50,18 @@ export const knowledgesCassette = (override: Cassette = {}): Cassette => }, override, ); + +const knowledgeDocument: ResultOf = { knowledgeDocument: KNOWLEDGE_DOC }; + +/** Adds the single-document query the detail route issues for KNOWLEDGE_DOC. */ +export const knowledgeDetailCassette = (override: Cassette = {}): Cassette => + knowledgesCassette( + mergeCassettes( + { + queries: { + knowledgeDocument: [{ data: knowledgeDocument, variables: { id: KNOWLEDGE_DOC.id } }], + }, + }, + override, + ), + ); diff --git a/frontend/e2e/mocks/cassettes/resources.ts b/frontend/e2e/mocks/cassettes/resources.ts index b896e3d0..0e715432 100644 --- a/frontend/e2e/mocks/cassettes/resources.ts +++ b/frontend/e2e/mocks/cassettes/resources.ts @@ -33,6 +33,20 @@ export const CREATED_FOLDER: UserResourceFragmentFragment = entity('UserResource userId: '1', }); +export const UPLOADED_RESOURCE = makeRestResource(4, 'uploaded-notes.txt', 42, false); + +/** What the server broadcasts once the upload lands — the list only grows through this frame. */ +export const UPLOADED_FRAGMENT: UserResourceFragmentFragment = entity('UserResource', { + createdAt: '2026-01-15T12:00:00Z', + id: '4', + isDir: false, + name: UPLOADED_RESOURCE.name, + path: UPLOADED_RESOURCE.path, + size: UPLOADED_RESOURCE.size, + updatedAt: '2026-01-15T12:00:00Z', + userId: '1', +}); + const seededList: RestResourceList = { items: [FOLDER_RESOURCE, FILE_RESOURCE], total: 2 }; export const emptyResourcesCassette = (): Cassette => ({ @@ -40,6 +54,30 @@ export const emptyResourcesCassette = (): Cassette => ({ rest: baseRest(), }); +export const RENAMED_PATH = 'renamed-notes.txt'; +export const COPY_DESTINATION = 'archive/notes.txt'; + +/** The write verbs differ per action — move is a PUT, copy a POST, delete a DELETE with the paths in + * the query string. A method mismatch does not 404 here: it falls through to the SPA and answers 200 + * with HTML, so each entry pins the method as well as the payload. */ +export const resourceWrites = (): Cassette['rest'] => ({ + 'DELETE /api/v1/resources/': [ + { body: { data: {}, status: 'success' }, querySubset: { 'paths[]': FILE_RESOURCE.path } }, + ], + 'POST /api/v1/resources/copy': [ + { + body: { data: {}, status: 'success' }, + bodySubset: { destination: COPY_DESTINATION, sources: [FILE_RESOURCE.path] }, + }, + ], + 'PUT /api/v1/resources/move': [ + { + body: { data: {}, status: 'success' }, + bodySubset: { destination: RENAMED_PATH, sources: [FILE_RESOURCE.path] }, + }, + ], +}); + export const resourcesCassette = (override: Cassette = {}): Cassette => mergeCassettes( { diff --git a/frontend/e2e/mocks/cassettes/settings-prompts.ts b/frontend/e2e/mocks/cassettes/settings-prompts.ts index b75b94b4..981a4fd5 100644 --- a/frontend/e2e/mocks/cassettes/settings-prompts.ts +++ b/frontend/e2e/mocks/cassettes/settings-prompts.ts @@ -1,6 +1,10 @@ import type { ResultOf } from '@graphql-typed-document-node/core'; -import type { DefaultPromptFragmentFragment, SettingsPromptsDocument } from '@/graphql/types'; +import type { + DefaultPromptFragmentFragment, + SettingsPromptsDocument, + UserPromptFragmentFragment, +} from '@/graphql/types'; import { PromptType } from '@/graphql/types'; @@ -32,7 +36,7 @@ export const RICH_PROMPT_TEMPLATE = [ '- report every finding with evidence', '', '```bash', - 'nmap -sV {{.Target}}', + 'nmap -sV {{.Target}} --script ', '```', '', '| Field | Value |', @@ -48,6 +52,7 @@ const richPentesterSystem = entity('DefaultPrompt', { const makeSettingsPrompts = ( pentesterSystem: DefaultPromptFragmentFragment = prompt(PromptType.Pentester), + userDefined: UserPromptFragmentFragment[] = [], ): ResultOf => ({ settingsPrompts: entity('PromptsConfig', { default: entity('DefaultPrompts', { @@ -86,7 +91,7 @@ const makeSettingsPrompts = ( wrapAgentTask: prompt(PromptType.TaskAssignmentWrapper), }), }), - userDefined: [], + userDefined, }), }); @@ -107,6 +112,44 @@ export const settingsPromptsCassette = (override: Cassette = {}): Cassette => /** The agent key the prompt detail route resolves from its `:promptId` param. */ export const PROMPT_DETAIL_AGENT = 'pentester'; +/** The operator's own copy of the pentester system prompt: its presence is what switches the editor + * from the create branch to the update branch, and its id is what a save has to carry. */ +export const PROMPT_OVERRIDE = { + id: 'user-prompt-1', + // Shares no line with the default on purpose: an override that merely appends to it makes + // "the default came back" indistinguishable from "nothing happened". + template: ['# Operator override', '', 'This copy replaces the shipped prompt for {{.Target}}.'].join('\n'), +}; + +const promptOverride = (): UserPromptFragmentFragment => + entity('UserPrompt', { + createdAt: '2026-01-15T09:00:00Z', + id: PROMPT_OVERRIDE.id, + template: PROMPT_OVERRIDE.template, + type: PromptType.Pentester, + updatedAt: '2026-01-15T09:00:00Z', + }); + +/** Raised by a spec's deletePrompt entry so the refetch can answer without the override. */ +export const PROMPT_RESET_FLAG = 'prompt-override-deleted'; + +/** The same agent, but already overridden — the branch where Save updates instead of creating. The + * second answer models the row actually being gone, so a reset has a real post-state to observe. */ +export const promptOverrideCassette = (override: Cassette = {}): Cassette => + mergeCassettes( + { + queries: { + ...baseQueries(), + settingsPrompts: [ + { data: makeSettingsPrompts(richPentesterSystem, [promptOverride()]) }, + { data: makeSettingsPrompts(richPentesterSystem), whenFlag: PROMPT_RESET_FLAG }, + ], + }, + rest: baseRest(), + }, + override, + ); + /** Same config, but that agent's system prompt carries RICH_PROMPT_TEMPLATE. */ export const promptDetailCassette = (override: Cassette = {}): Cassette => mergeCassettes( diff --git a/frontend/e2e/mocks/cassettes/settings-providers.ts b/frontend/e2e/mocks/cassettes/settings-providers.ts index 7ca89a22..0eb56049 100644 --- a/frontend/e2e/mocks/cassettes/settings-providers.ts +++ b/frontend/e2e/mocks/cassettes/settings-providers.ts @@ -3,8 +3,12 @@ import type { ResultOf } from '@graphql-typed-document-node/core'; import type { AgentConfigFragmentFragment, AgentsConfigFragmentFragment, + AgentTestResultFragmentFragment, + ModelConfigFragmentFragment, ProviderConfigFragmentFragment, + ProviderTestResultFragmentFragment, SettingsProvidersDocument, + TestResultFragmentFragment, } from '@/graphql/types'; import { ProviderType } from '@/graphql/types'; @@ -16,65 +20,104 @@ import { baseQueries, baseRest } from './base.ts'; const T = '2026-01-15T09:00:00Z'; -const agentConfig = (): AgentConfigFragmentFragment => +/** `n: 1` and `json: true` are the shipped simple_json defaults; they are pinned in the create payload + * because dropping exactly these on the way to the wire is a regression this repo has already had. */ +const agentConfig = (model = 'e2e-model'): AgentConfigFragmentFragment => entity('AgentConfig', { extraBody: null, frequencyPenalty: null, + json: true, maxLength: null, - maxTokens: null, + maxTokens: 4096, minLength: null, - model: 'e2e-model', + minP: null, + model, + n: 1, presencePenalty: null, price: null, reasoning: null, repetitionPenalty: null, + responseMimeType: null, temperature: null, topK: null, topP: null, }); -const agentsConfig = (): AgentsConfigFragmentFragment => +const agentsConfig = (model?: string): AgentsConfigFragmentFragment => entity('AgentsConfig', { - adviser: agentConfig(), - assistant: agentConfig(), - coder: agentConfig(), - enricher: agentConfig(), - generator: agentConfig(), - installer: agentConfig(), - pentester: agentConfig(), - primaryAgent: agentConfig(), - refiner: agentConfig(), - reflector: agentConfig(), - searcher: agentConfig(), - simple: agentConfig(), - simpleJson: agentConfig(), + adviser: agentConfig(model), + assistant: agentConfig(model), + coder: agentConfig(model), + enricher: agentConfig(model), + generator: agentConfig(model), + installer: agentConfig(model), + pentester: agentConfig(model), + primaryAgent: agentConfig(model), + refiner: agentConfig(model), + reflector: agentConfig(model), + searcher: agentConfig(model), + simple: agentConfig(model), + simpleJson: agentConfig(model), }); -const defaultConfig = (id: string, type: ProviderType): ProviderConfigFragmentFragment => +/** + * Defaults are not database rows: the resolver builds them without an ID, so the wire carries the + * bare number `0` for every one. `ProviderConfig.keyFields`'s id-0 branch exists for that shape, and + * a distinct string id here leaves it unexercised. + */ +const defaultConfig = (type: ProviderType): ProviderConfigFragmentFragment => entity('ProviderConfig', { - agents: agentsConfig(), + // Distinct per type, so two defaults collapsing onto one cache entry is observable. + agents: agentsConfig(`e2e-${type}-model`), createdAt: T, - id, - name: `default-${type}`, + id: 0 as unknown as string, + name: type, type, updatedAt: T, }); +const modelConfig = (name: string): ModelConfigFragmentFragment => + entity('ModelConfig', { name, price: null, reasoning: null, thinking: null }); + +// The backend fills a default config AND a model catalog for EVERY type, keyed or not (verified live: +// gemini/deepseek/ollama all return a non-null default and a populated catalog with zero keys). A +// cassette leaving them null/[] encodes a wire state the resolver never emits and leaves the +// create-form's model-dropdown seeding path structurally unreachable. +const allDefaults = () => + entity('DefaultProvidersConfig', { + anthropic: defaultConfig(ProviderType.Anthropic), + bedrock: defaultConfig(ProviderType.Bedrock), + custom: defaultConfig(ProviderType.Custom), + deepseek: defaultConfig(ProviderType.Deepseek), + gemini: defaultConfig(ProviderType.Gemini), + glm: defaultConfig(ProviderType.Glm), + kimi: defaultConfig(ProviderType.Kimi), + minimax: defaultConfig(ProviderType.Minimax), + ollama: defaultConfig(ProviderType.Ollama), + openai: defaultConfig(ProviderType.Openai), + qwen: defaultConfig(ProviderType.Qwen), + }); + +const catalog = (type: ProviderType) => [modelConfig(`e2e-${type}-model`), modelConfig(`e2e-${type}-model-mini`)]; + +const allModels = () => + entity('ProvidersModelsList', { + anthropic: catalog(ProviderType.Anthropic), + bedrock: catalog(ProviderType.Bedrock), + custom: catalog(ProviderType.Custom), + deepseek: catalog(ProviderType.Deepseek), + gemini: catalog(ProviderType.Gemini), + glm: catalog(ProviderType.Glm), + kimi: catalog(ProviderType.Kimi), + minimax: catalog(ProviderType.Minimax), + ollama: catalog(ProviderType.Ollama), + openai: catalog(ProviderType.Openai), + qwen: catalog(ProviderType.Qwen), + }); + const noProviders: ResultOf = { settingsProviders: entity('ProvidersConfig', { - default: entity('DefaultProvidersConfig', { - anthropic: defaultConfig('default-anthropic', ProviderType.Anthropic), - bedrock: null, - custom: null, - deepseek: null, - gemini: null, - glm: null, - kimi: null, - minimax: null, - ollama: null, - openai: defaultConfig('default-openai', ProviderType.Openai), - qwen: null, - }), + default: allDefaults(), enabled: entity('ProvidersReadinessStatus', { anthropic: false, bedrock: false, @@ -88,19 +131,7 @@ const noProviders: ResultOf = { openai: false, qwen: false, }), - models: entity('ProvidersModelsList', { - anthropic: [], - bedrock: [], - custom: [], - deepseek: [], - gemini: [], - glm: [], - kimi: [], - minimax: [], - ollama: [], - openai: [], - qwen: [], - }), + models: allModels(), userDefined: [], }), }; @@ -117,21 +148,27 @@ export const settingsProvidersCassette = (override: Cassette = {}): Cassette => override, ); -const populatedProviders: ResultOf = { +export const SEEDED_PROVIDER = { + id: 'custom-1', + name: 'My Custom Endpoint', + type: ProviderType.Custom, +}; + +export const seededProviderRow = (): ProviderConfigFragmentFragment => + entity('ProviderConfig', { + agents: agentsConfig(), + createdAt: T, + id: SEEDED_PROVIDER.id, + name: SEEDED_PROVIDER.name, + type: SEEDED_PROVIDER.type, + updatedAt: T, + }); + +/** The whole settingsProviders answer with a chosen set of user-defined rows — a delete asserts against + * the follow-up answer, so the list has to be expressible without the deleted row. */ +export const providersList = (...userDefined: ProviderConfigFragmentFragment[]) => ({ settingsProviders: entity('ProvidersConfig', { - default: entity('DefaultProvidersConfig', { - anthropic: defaultConfig('default-anthropic', ProviderType.Anthropic), - bedrock: null, - custom: null, - deepseek: null, - gemini: null, - glm: null, - kimi: null, - minimax: null, - ollama: null, - openai: defaultConfig('default-openai', ProviderType.Openai), - qwen: null, - }), + default: allDefaults(), enabled: entity('ProvidersReadinessStatus', { anthropic: true, bedrock: false, @@ -145,32 +182,73 @@ const populatedProviders: ResultOf = { openai: true, qwen: false, }), - models: entity('ProvidersModelsList', { - anthropic: [], - bedrock: [], - custom: [], - deepseek: [], - gemini: [], - glm: [], - kimi: [], - minimax: [], - ollama: [], - openai: [], - qwen: [], - }), - userDefined: [ - entity('ProviderConfig', { - agents: agentsConfig(), - createdAt: T, - id: 'custom-1', - name: 'My Custom Endpoint', - type: ProviderType.Custom, - updatedAt: T, - }), - ], + models: allModels(), + userDefined, }), +}); + +/** A second row so a delete spec can operate a row that is not index 0, and prove the table survived. */ +export const OTHER_PROVIDER = { + id: 'custom-2', + name: 'Second Endpoint', }; +export const OTHER_PROVIDER_ROW = (): ProviderConfigFragmentFragment => + entity('ProviderConfig', { + agents: agentsConfig(), + createdAt: T, + id: OTHER_PROVIDER.id, + name: OTHER_PROVIDER.name, + type: ProviderType.Custom, + updatedAt: T, + }); + +/** updateProvider answers with ProviderConfig, and Apollo writes it straight onto the row the list + * renders — returning the pre-edit name would make the list contradict the save. */ +export const renamedProviderRow = (name: string): ProviderConfigFragmentFragment => + entity('ProviderConfig', { + agents: agentsConfig(), + createdAt: T, + id: SEEDED_PROVIDER.id, + name, + type: SEEDED_PROVIDER.type, + updatedAt: T, + }); + +const populatedProviders: ResultOf = providersList(seededProviderRow()); + +const testResult = (name: string, result: boolean): TestResultFragmentFragment => + entity('TestResult', { + error: result ? null : 'e2e simulated failure', + latency: 42, + name, + reasoning: false, + result, + streaming: false, + type: 'chat', + }); + +export const agentTestResult = (result = true): AgentTestResultFragmentFragment => + entity('AgentTestResult', { tests: [testResult('chat completion', result)] }); + +/** One agent fails on purpose: a dialog that renders only the passing rows would still look right. */ +export const providerTestResult = (): ProviderTestResultFragmentFragment => + entity('ProviderTestResult', { + adviser: agentTestResult(), + assistant: agentTestResult(), + coder: agentTestResult(), + enricher: agentTestResult(), + generator: agentTestResult(), + installer: agentTestResult(), + pentester: agentTestResult(false), + primaryAgent: agentTestResult(), + refiner: agentTestResult(), + reflector: agentTestResult(), + searcher: agentTestResult(), + simple: agentTestResult(), + simpleJson: agentTestResult(), + }); + export const populatedSettingsProvidersCassette = (override: Cassette = {}): Cassette => mergeCassettes( { diff --git a/frontend/e2e/mocks/install.ts b/frontend/e2e/mocks/install.ts index 0e766d4c..a374d73d 100644 --- a/frontend/e2e/mocks/install.ts +++ b/frontend/e2e/mocks/install.ts @@ -1,20 +1,21 @@ -import type { Page } from '@playwright/test'; +import type { BrowserContext, Route } from '@playwright/test'; import type { MockWorld } from './world.ts'; import { installWsMock } from './ws-graphql.ts'; +const API_GLOB = '**/api/v1/**'; const GRAPHQL_PATH = '/api/v1/graphql'; -export const installMockRoutes = async (page: Page, world: MockWorld): Promise => { - await installWsMock(page, world); +export const installMockRoutes = async (context: BrowserContext, world: MockWorld): Promise => { + await installWsMock(context, world); // Single catch-all over the whole API surface: anything without a cassette // entry fails fast (501 + recorded in world.unmatched, asserted at teardown) // instead of leaking through the vite preview proxy to a live backend. - await page.route('**/api/v1/**', async (route) => { + const serve = async (route: Route) => { const request = route.request(); - const { pathname } = new URL(request.url()); + const { pathname, searchParams } = new URL(request.url()); if (pathname === GRAPHQL_PATH && request.method() === 'POST') { const body = request.postDataJSON() as { @@ -26,10 +27,11 @@ export const installMockRoutes = async (page: Page, world: MockWorld): Promise | undefined) : undefined; - const entry = world.matchRest(request.method(), pathname, body ?? undefined); + const entry = world.matchRest(request.method(), pathname, body ?? undefined, searchParams); if (entry) { await route.fulfill( @@ -66,10 +68,15 @@ export const installMockRoutes = async (page: Page, world: MockWorld): Promise void opened.route(API_GLOB, serve)); + + await Promise.all(context.pages().map((existing) => existing.route(API_GLOB, serve))); }; diff --git a/frontend/e2e/mocks/world.ts b/frontend/e2e/mocks/world.ts index 81028d50..2688b4d1 100644 --- a/frontend/e2e/mocks/world.ts +++ b/frontend/e2e/mocks/world.ts @@ -39,6 +39,17 @@ const isSubsetMatch = (expected?: Record, actual?: Record new Promise((resolve) => setTimeout(resolve, delayMs)); +/** Repeated keys (`paths[]=a&paths[]=b`) collapse to the full list, so a subset match sees both. */ +const queryRecord = (search?: URLSearchParams): Record => + Object.fromEntries( + [...(search?.keys() ?? [])] + .map((key) => [key, search!.getAll(key)]) + .map(([key, values]) => [key, (values as string[]).length > 1 ? values : (values as string[])[0]]), + ); + +export const subscriptionStreamKey = (operationName: string, variables?: Record): string => + `sub:${operationName}:${stableStringify(variables ?? {})}`; + export interface MockSocket { close: (options?: { code?: number; reason?: string }) => unknown; } @@ -100,29 +111,46 @@ export class MockWorld { const candidates = this.eligible(matching); if (matching && candidates?.length) { - return this.nextEntry(`gql:${operationName}:${stableStringify(variables ?? {})}`, candidates, matching); + // Same cursor rule as matchRest below: key on the selected entries' pins, so an + // operation whose variables carry a per-call volatile field still advances its + // sequence instead of re-serving step one. + const signature = matching.map((entry) => stableStringify(entry.variables ?? null)).join('|'); + + return this.nextEntry(`gql:${operationName}:${signature}`, candidates, matching); } } return undefined; } - matchRest(method: string, pathname: string, body?: Record): RestCassetteEntry | undefined { + matchRest( + method: string, + pathname: string, + body?: Record, + search?: URLSearchParams, + ): RestCassetteEntry | undefined { const normalized = pathname.replace(/\/+$/, ''); const key = Object.keys(this.cassette.rest ?? {}).find((candidate) => { const [candidateMethod, candidatePath = ''] = candidate.split(' '); return candidateMethod === method && candidatePath.replace(/\/+$/, '') === normalized; }); - const matching = (key ? this.cassette.rest?.[key] : undefined)?.filter((entry) => - isSubsetMatch(entry.bodySubset, body), + const query = queryRecord(search); + const matching = (key ? this.cassette.rest?.[key] : undefined)?.filter( + (entry) => isSubsetMatch(entry.bodySubset, body) && isSubsetMatch(entry.querySubset, query), ); const candidates = this.eligible(matching); - // Include the body in the cursor key (as the GraphQL half keys on variables): two - // body-differentiated sequences for one path must not reset each other's cursor. + // Key the cursor on which entries the request selected — their pins — not the raw body/query. + // Two payload-differentiated sequences still get distinct keys (their entries pin different + // subsets), but a request carrying a per-call volatile field (an idempotency key, a timestamp) + // no longer fragments its own cursor into a fresh sequence on every call. + const signature = matching + ?.map((entry) => stableStringify([entry.bodySubset ?? null, entry.querySubset ?? null])) + .join('|'); + return matching && candidates?.length - ? this.nextEntry(`rest:${method}:${normalized}:${stableStringify(body ?? {})}`, candidates, matching) + ? this.nextEntry(`rest:${method}:${normalized}:${signature}`, candidates, matching) : undefined; } @@ -136,7 +164,7 @@ export class MockWorld { // Key on the request's variables, not the entry's: an entry written without variables would // otherwise merge every flow's subscribers onto one stream, and the second would join delta-only. - return entry ? { entry, streamKey: `sub:${operationName}:${stableStringify(variables ?? {})}` } : undefined; + return entry ? { entry, streamKey: subscriptionStreamKey(operationName, variables) } : undefined; } raiseFlag(flag: string): void { @@ -157,6 +185,10 @@ export class MockWorld { this.unmatchedSubscriptions.push(description); } + subscriberCount(streamKey: string): number { + return this.streams.get(streamKey)?.subscribers.size ?? 0; + } + subscribeStream(streamKey: string, entry: SubscriptionCassetteEntry, subscriber: StreamSubscriber): () => void { const state = this.streams.get(streamKey) ?? { cursor: 0, isDriving: false, subscribers: new Set() }; diff --git a/frontend/e2e/mocks/world.unit.test.ts b/frontend/e2e/mocks/world.unit.test.ts index dba376e5..d0c6d6f4 100644 --- a/frontend/e2e/mocks/world.unit.test.ts +++ b/frontend/e2e/mocks/world.unit.test.ts @@ -56,6 +56,66 @@ describe('MockWorld matching', () => { expect(world.matchRest('POST', '/api/v1/resources/mkdir/', { path: 'new-folder' })).toBeDefined(); }); + it('pins a query-carrying request on querySubset, the way bodySubset pins a payload', () => { + const world = new MockWorld({ + rest: { + 'DELETE /api/v1/resources/': [ + { body: { deleted: 'notes' }, querySubset: { 'paths[]': 'notes.txt' } }, + { body: { deleted: 'both' }, querySubset: { 'paths[]': ['a.txt', 'b.txt'] } }, + ], + }, + }); + const query = (search: string) => new URLSearchParams(search); + + expect(world.matchRest('DELETE', '/api/v1/resources/', undefined, query('paths[]=notes.txt'))?.body).toEqual({ + deleted: 'notes', + }); + expect( + world.matchRest('DELETE', '/api/v1/resources/', undefined, query('paths[]=a.txt&paths[]=b.txt'))?.body, + ).toEqual({ deleted: 'both' }); + // The path alone must not answer for a payload no entry describes. + expect(world.matchRest('DELETE', '/api/v1/resources/', undefined, query('paths[]=secret.txt'))).toBeUndefined(); + }); + + it('advances a REST sequence even when each request carries a volatile body field', () => { + const world = new MockWorld({ + rest: { + 'POST /api/v1/resources/mkdir': [{ body: { step: 1 } }, { body: { step: 2 } }], + }, + }); + // Each call sends a fresh idempotency key — a field no entry pins. The cursor must key on the + // selected entries, not the raw body, or every call re-serves step 1. + const first = world.matchRest('POST', '/api/v1/resources/mkdir', { idempotencyKey: 'a1' }); + const second = world.matchRest('POST', '/api/v1/resources/mkdir', { idempotencyKey: 'b2' }); + + expect(first?.body).toEqual({ step: 1 }); + expect(second?.body).toEqual({ step: 2 }); + }); + + it('advances a GraphQL sequence even when each request carries a volatile variable', () => { + const world = new MockWorld({ + queries: { flows: [{ data: { step: 1 } }, { data: { step: 2 } }] }, + }); + + expect(world.matchGraphQL('flows', { nonce: 'a' })?.data).toEqual({ step: 1 }); + expect(world.matchGraphQL('flows', { nonce: 'b' })?.data).toEqual({ step: 2 }); + }); + + it('keeps variable-pinned sequences apart from one another', () => { + const world = new MockWorld({ + queries: { + stats: [ + { data: { period: 'week' }, variables: { period: 'week' } }, + { data: { period: 'month' }, variables: { period: 'month' } }, + ], + }, + }); + + expect(world.matchGraphQL('stats', { period: 'week' })?.data).toEqual({ period: 'week' }); + expect(world.matchGraphQL('stats', { period: 'month' })?.data).toEqual({ period: 'month' }); + expect(world.matchGraphQL('stats', { period: 'week' })?.data).toEqual({ period: 'week' }); + }); + it('hides a flag-gated entry until the flag is raised, then lets it outrank the unflagged one', () => { const world = new MockWorld({ mutations: { login: [{ data: { ok: true }, setFlag: 'logged-in' }] }, @@ -67,6 +127,26 @@ describe('MockWorld matching', () => { expect(world.matchGraphQL('info', {})?.data).toEqual({ role: 'user' }); }); + it('prefers the newly-enabled entry over the next unserved one, not merely the next in order', () => { + // Three candidates, so sequencing and the flag rule disagree: with the rule the raised flag + // jumps straight to `flagged`; without it the cursor would just hand out `second`. The + // two-entry cases above cannot tell the two apart. + const world = new MockWorld({ + mutations: { go: [{ data: {}, setFlag: 'go' }] }, + queries: { + stage: [ + { data: { stage: 'first' } }, + { data: { stage: 'second' } }, + { data: { stage: 'flagged' }, whenFlag: 'go' }, + ], + }, + }); + + expect(world.matchGraphQL('stage', {})?.data).toEqual({ stage: 'first' }); + world.matchGraphQL('go', {}); + expect(world.matchGraphQL('stage', {})?.data).toEqual({ stage: 'flagged' }); + }); + it('advances to the newly-enabled entry after a second flag, without replaying the first', () => { const world = new MockWorld({ mutations: { f1: [{ data: {}, setFlag: 'f1' }], f2: [{ data: {}, setFlag: 'f2' }] }, diff --git a/frontend/e2e/mocks/ws-graphql.ts b/frontend/e2e/mocks/ws-graphql.ts index 8273b42e..c27a81ea 100644 --- a/frontend/e2e/mocks/ws-graphql.ts +++ b/frontend/e2e/mocks/ws-graphql.ts @@ -1,4 +1,4 @@ -import type { Page } from '@playwright/test'; +import type { BrowserContext } from '@playwright/test'; import type { MockWorld } from './world.ts'; @@ -54,7 +54,7 @@ export const handleWsConnection = ( case 'connection_init': { // Ack immediately; a frame sent before the ack is a graphql-ws protocol - // violation that closes the socket. Ack-first ordering is pinned by the unit test. + // violation that closes the socket. send({ type: 'connection_ack' }); break; } @@ -98,8 +98,8 @@ export const handleWsConnection = ( }; }; -export const installWsMock = async (page: Page, world: MockWorld): Promise => { - await page.routeWebSocket('**/api/v1/graphql', (ws) => { +export const installWsMock = async (context: BrowserContext, world: MockWorld): Promise => { + await context.routeWebSocket('**/api/v1/graphql', (ws) => { const connection = handleWsConnection(world, ws); ws.onMessage((raw) => connection.onMessage(String(raw))); diff --git a/frontend/e2e/mocks/ws-graphql.unit.test.ts b/frontend/e2e/mocks/ws-graphql.unit.test.ts index e622b7f2..2149958b 100644 --- a/frontend/e2e/mocks/ws-graphql.unit.test.ts +++ b/frontend/e2e/mocks/ws-graphql.unit.test.ts @@ -155,8 +155,12 @@ describe('graphql-ws mock protocol contract', () => { world.dropSockets(); - // graphql-ws resubscribes active sinks after the retry connect. + // graphql-ws resubscribes active sinks in a microtask AFTER the retry connect, so gating on + // the reconnect alone races the resubscribe: raising the flag before the sink re-registers + // delivers seq:2 to an empty subscriber set and the no-replay contract loses it. Gate on the + // server-side resubscription instead. await vi.waitFor(() => expect(retries).toEqual([false, true])); + await vi.waitFor(() => expect(world.subscriberCount('sub:stream:{}')).toBe(1)); world.matchGraphQL('raise'); diff --git a/frontend/e2e/playwright.config.ts b/frontend/e2e/playwright.config.ts index 9f936482..fb5b7fc8 100644 --- a/frontend/e2e/playwright.config.ts +++ b/frontend/e2e/playwright.config.ts @@ -26,7 +26,7 @@ const here = (relative: string) => fileURLToPath(new URL(relative, import.meta.u export const AUTH_STATE_PATH = here('./.auth/user.json'); const TIERS: Record = { - local: { baseURL: process.env.E2E_BASE_URL ?? 'https://localhost:8443', installMocks: false }, + local: { baseURL: process.env.E2E_BASE_URL ?? '', installMocks: false }, mock: { baseURL: `http://localhost:${PREVIEW_PORT}`, installMocks: true }, stand: { baseURL: process.env.E2E_BASE_URL ?? '', installMocks: false }, }; @@ -37,19 +37,20 @@ if (!(rawTier in TIERS)) { const tier = rawTier as BackendTier; -if (tier === 'stand' && !TIERS.stand.baseURL) { - throw new Error('E2E_TIER=stand requires E2E_BASE_URL'); +// Both stand and local hit a real backend and bake the real (paid) flow-run specs, so neither may +// fall back to a default: a bare `E2E_TIER=local` must not silently target the dev stack. run-local-tier.sh +// supplies E2E_BASE_URL for the legitimate path. +if ((tier === 'stand' || tier === 'local') && !TIERS[tier].baseURL) { + throw new Error(`E2E_TIER=${tier} requires E2E_BASE_URL`); } export default defineConfig({ - // A colour change must move enough pixels to fail. The baselines and the run both - // render in the pinned container (the host path is blocked above), so there is no - // host-vs-CI rasterisation noise to absorb — tighten the per-pixel threshold below - // Playwright's default 0.2 so a one-step palette shift is caught, with a small - // maxDiffPixelRatio for anti-aliasing at glyph edges. cross/contrast.spec.ts still - // measures colour numerically as the belt-and-suspenders check. + // Absolute, not a ratio: 1% of a 1280x720 baseline is 9,216 pixels, more than any one foreground + // token covers, so a token could change hue with every baseline still matching. Baselines and + // run both render in the pinned container (the host path is blocked above), so 200px absorbs the + // glyph anti-aliasing without host-vs-CI noise. expect: { - toHaveScreenshot: { maxDiffPixelRatio: 0.01, threshold: 0.02 }, + toHaveScreenshot: { maxDiffPixels: 200, threshold: 0.02 }, }, forbidOnly: isCI, fullyParallel: true, @@ -110,7 +111,10 @@ export default defineConfig({ // Stand traces/videos embed the live session cookie and the setup // project's password fill; CI uploads test-results as a public-repo // artifact, so they must never be recorded on that tier. - trace: tier === 'stand' ? 'off' : 'on-first-retry', + // retain-on-failure, not on-first-retry: the mock PR gate runs retries:0, + // so on-first-retry would never write a trace — yet the docs and the PR + // comment tell you to open trace.zip from that tier's report. + trace: tier === 'stand' ? 'off' : 'retain-on-failure', video: tier === 'stand' ? 'off' : 'retain-on-failure', }, // The gate must test the shipped artifact: keep this on the production build, diff --git a/frontend/e2e/route-manifest.unit.test.ts b/frontend/e2e/route-manifest.unit.test.ts index 9823a70d..d1380793 100644 --- a/frontend/e2e/route-manifest.unit.test.ts +++ b/frontend/e2e/route-manifest.unit.test.ts @@ -1,9 +1,17 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { describe, expect, it } from 'vitest'; import { routes } from '@/lib/routes'; +import { affectedRoutes } from './affected-routes.ts'; import { ROUTE_MANIFEST } from './routes.ts'; +const { RULES } = (await import(pathToFileURL(join(__dirname, 'mock-llm', 'scenario.mjs')).href)) as { + RULES: { label: string; match: RegExp }[]; +}; + /** * Routes deliberately outside the manifest sweeps (nav, visual, a11y, * diff-scoping), each with the reason. Adding a route to lib/routes forces a @@ -75,3 +83,42 @@ describe('ROUTE_MANIFEST completeness', () => { expect(dynamicRouteKeys(routes).sort()).toEqual(Object.keys(DYNAMIC_ROUTES).sort()); }); }); + +const SHELL_DIR = 'components/layouts/main'; + +const shellImports = (): string[] => { + const dir = join(__dirname, '..', 'src', SHELL_DIR); + + return readdirSync(dir) + .filter((file) => file.endsWith('.tsx') && !file.endsWith('.test.tsx')) + .flatMap((file) => [...readFileSync(join(dir, file), 'utf8').matchAll(/from '@\/([^']+)'/g)]) + .flatMap(([, imported]) => (imported ? [`frontend/src/${imported}`] : [])); +}; + +describe('ROUTE_MANIFEST ownership', () => { + it('never scopes a source the app shell imports to a subset of the swept routes', () => { + const underScoped = shellImports().filter( + (file) => affectedRoutes([file], ROUTE_MANIFEST).length < ROUTE_MANIFEST.length, + ); + + expect(underScoped).toEqual([]); + }); +}); + +describe('mock-llm rule matching', () => { + const rule = RULES.find((entry) => entry.label === 'subagent-terminal-report'); + + it('answers only a request carrying both markers', () => { + expect(rule?.match.test('{"tools":["hack_result"],"text":"E2E_TERMINAL_OK"}')).toBe(true); + expect(rule?.match.test('{"tools":["hack_result"]}')).toBe(false); + expect(rule?.match.test('{"text":"E2E_TERMINAL_OK"}')).toBe(false); + }); + + it('rejects a marker-less request without re-scanning from every offset', () => { + const request = JSON.stringify({ messages: 'a'.repeat(120 * 1024) }); + const started = performance.now(); + + expect(rule?.match.test(request)).toBe(false); + expect(performance.now() - started).toBeLessThan(500); + }); +}); diff --git a/frontend/e2e/routes.ts b/frontend/e2e/routes.ts index dcccbb10..e20c2179 100644 --- a/frontend/e2e/routes.ts +++ b/frontend/e2e/routes.ts @@ -7,7 +7,7 @@ import type { Cassette } from './mocks/cassette.ts'; import { apiTokensCassette } from './mocks/cassettes/api-tokens.ts'; import { dashboardCassette } from './mocks/cassettes/dashboard.ts'; -import { flowsCassette, flowTabsCassette } from './mocks/cassettes/flows.ts'; +import { flowsCassette, flowTabsCassette, TABS_FILE_NAME, TABS_SCREENSHOT_URL } from './mocks/cassettes/flows.ts'; import { knowledgesCassette } from './mocks/cassettes/knowledges.ts'; import { resourcesCassette } from './mocks/cassettes/resources.ts'; import { settingsPromptsCassette } from './mocks/cassettes/settings-prompts.ts'; @@ -27,9 +27,31 @@ export interface RouteManifestEntry { */ sources: string[]; /** Radix unmounts inactive tab panels, so one scan of the default view sees none of them. */ - tabs?: string[]; + tabs?: RouteTab[]; } +export interface RouteTab { + name: string; + /** + * Must be absent while the panel loads: a marker that also renders on the + * skeleton lets every per-tab scan pass on an empty panel. + */ + ready: (page: Page) => Locator; +} + +/** Order matters: the flow auto-opens Assistant, so tabs.spec pins that no entry is already open. */ +export const FLOW_DETAIL_TABS: RouteTab[] = [ + { name: 'Dashboard', ready: (page) => page.getByText('Usage by Model & Provider') }, + { name: 'Assistant', ready: (page) => page.getByText('New assistant', { exact: true }) }, + { name: 'Automation', ready: (page) => page.getByText('No active tasks') }, + { name: 'Tasks', ready: (page) => page.getByText('E2E Task Alpha') }, + { name: 'Agents', ready: (page) => page.getByText('E2E agent reconnaissance') }, + { name: 'Searches', ready: (page) => page.getByText('E2E search for the CVE') }, + { name: 'Vector Store', ready: (page) => page.getByText('E2E recall prior findings') }, + { name: 'Files', ready: (page) => page.getByText(TABS_FILE_NAME) }, + { name: 'Screenshots', ready: (page) => page.getByText(TABS_SCREENSHOT_URL) }, +]; + /** * The routes swept by NAV/visual/a11y specs and CI diff-scoping. This is a * subset of the app's routes — route-manifest.unit.test.ts pins the excluded @@ -47,18 +69,20 @@ export const ROUTE_MANIFEST: RouteManifestEntry[] = [ sources: ['src/pages/flows/flows.tsx', 'src/features/flows', 'src/providers/flows-provider.tsx'], }, { - // Message metadata (date + ID) renders at 50% opacity by design; revisit - // with the design pass. + // Message metadata (date + ID) is by design and route-wide; the rest are defects awaiting a + // fix, each scoped to the tab whose panel owns it so it cannot silence the rule elsewhere. a11yWaivers: [ { rule: 'color-contrast', target: /text-muted-foreground\\?\/50/ }, - // The tab-panel ones below are defects awaiting a fix, not accepted design. - { rule: 'aria-progressbar-name', target: /bg-primary/ }, - { rule: 'button-name', target: /span\[data-slot|button\[aria-label/ }, - { rule: 'target-size', target: /text-blue-400|button\[aria-label/ }, - { rule: 'color-contrast', target: /font-semibold\.truncate/ }, - // The same file-manager row metadata waived on /resources — this tab embeds it. - { rule: 'color-contrast', target: /text-muted-foreground\\?\/80/ }, - { rule: 'scrollable-region-focusable', target: /table-container/ }, + { rule: 'aria-progressbar-name', tabs: ['Tasks'], target: /\.bg-primary\\\/20/ }, + { rule: 'button-name', tabs: ['Files'], target: /tooltip-trigger.*size-8\[data-slot="button"\]/ }, + { rule: 'target-size', tabs: ['Files'], target: /text-blue-400|button\[aria-label="Select / }, + // The same file-manager row metadata waived on /resources — the Files tab embeds it. + { rule: 'color-contrast', tabs: ['Files'], target: /text-muted-foreground\\?\/80/ }, + { + rule: 'scrollable-region-focusable', + tabs: ['Dashboard', 'Files'], + target: /\[data-slot="table-container"\]/, + }, ], // Must stay the populated cassette: the empty seed renders none of the sources below. cassette: flowTabsCassette, @@ -73,21 +97,27 @@ export const ROUTE_MANIFEST: RouteManifestEntry[] = [ 'src/providers/flows-provider.tsx', 'src/components/shared/file-manager', 'src/components/dashboard', - 'src/features/resources', + // Assistant-tab FlowForm reads useTemplates, so a provider change reaches this route too. + 'src/providers/templates-provider.tsx', ], - tabs: ['Assistant', 'Dashboard', 'Tasks', 'Agents', 'Searches', 'Vector Store', 'Files', 'Screenshots'], + tabs: FLOW_DETAIL_TABS, }, { cassette: templatesCassette, path: routes.templates, ready: (page) => page.getByRole('row', { name: /E2E Seed Template/ }), - sources: ['src/pages/templates', 'src/providers/templates-provider.tsx'], + // The list file, not the dir: template.tsx sits beside it and no manifest route renders it, + // so owning the dir scopes a detail-only diff here instead of to the run-everything path. + sources: ['src/pages/templates/templates.tsx', 'src/providers/templates-provider.tsx'], }, { cassette: knowledgesCassette, path: routes.knowledges, ready: (page) => page.getByRole('row', { name: /E2E Seed Question/ }), - sources: ['src/pages/knowledges', 'src/features/knowledges', 'src/providers/knowledges-provider.tsx'], + // `src/features/knowledges` is left unowned deliberately: knowledge.tsx is its only importer + // and that route is not swept, so claiming it here would scope its diffs to a route that + // never renders it rather than to the run-everything fallback. + sources: ['src/pages/knowledges/knowledges.tsx', 'src/providers/knowledges-provider.tsx'], }, { cassette: apiTokensCassette, @@ -102,8 +132,9 @@ export const ROUTE_MANIFEST: RouteManifestEntry[] = [ a11yWaivers: [{ rule: 'aria-valid-attr-value', target: /radix-.*-trigger-/ }], cassette: dashboardCassette, path: routes.dashboard, - ready: (page) => page.getByRole('heading', { name: 'Flows Activity Over Time' }), + ready: (page) => page.getByText('E2E Alpha'), sources: ['src/pages/dashboard', 'src/components/dashboard'], + tabs: [{ name: 'Overview', ready: (page) => page.getByRole('cell', { exact: true, name: 'e2e-provider' }) }], }, { cassette: settingsPromptsCassette, @@ -123,13 +154,17 @@ export const ROUTE_MANIFEST: RouteManifestEntry[] = [ // target floor — widening them is a density decision for the file manager. a11yWaivers: [ { rule: 'color-contrast', target: /text-muted-foreground\\?\/80/ }, - // `.rounded` unanchored would also match every rounded-* utility, so a - // future under-sized control anywhere on the page would be waived too. - { rule: 'target-size', target: /\.rounded(?![-\w])|aria-label="Select / }, + // `.rounded` is anchored with a boundary so it can't match every rounded-* utility. axe + // emits the row-select buttons as a bare `button[aria-label="Select "]` with no + // parent path, so the label is the only available anchor — require the button tag at + // least, rather than a bare `aria-label="Select ` that would also waive a non-button. + { rule: 'target-size', target: /\.rounded(?![-\w])|button\[aria-label="Select / }, ], cassette: resourcesCassette, path: routes.resources, ready: (page) => page.getByRole('treeitem', { name: /reports/ }), - sources: ['src/pages/resources', 'src/features/resources', 'src/components/shared/file-manager'], + // `src/features/resources` is left unowned deliberately: main-sidebar.tsx pulls its upload + // hook into every route's shell, so any per-route listing under-reports the rest. + sources: ['src/pages/resources', 'src/components/shared/file-manager'], }, ]; diff --git a/frontend/e2e/specs/cross/a11y.spec.ts b/frontend/e2e/specs/cross/a11y.spec.ts index 9e9fdb77..fb2539bd 100644 --- a/frontend/e2e/specs/cross/a11y.spec.ts +++ b/frontend/e2e/specs/cross/a11y.spec.ts @@ -1,5 +1,7 @@ import { expect, test } from '../../fixtures/test.ts'; -import { scanA11y } from '../../helpers/a11y.ts'; +import { scanA11y, waiversForScan } from '../../helpers/a11y.ts'; +import { expectCleanPage } from '../../helpers/errors.ts'; +import { FILE_RESOURCE, resourcesCassette } from '../../mocks/cassettes/resources.ts'; import { populatedSettingsProvidersCassette } from '../../mocks/cassettes/settings-providers.ts'; import { loginJourneyCassette } from '../../mocks/cassettes/smoke.ts'; import { ROUTE_MANIFEST } from '../../routes.ts'; @@ -48,19 +50,66 @@ for (const theme of THEMES) { await expect(entry.ready(page)).toBeVisible(); // Not redundant: a theme seed that silently stops applying reruns light under a dark label. await expect(page.locator('html')).toHaveClass(theme === 'dark' ? /dark/ : /light/); - await scanA11y(page, entry.path, entry.a11yWaivers); + await scanA11y(page, entry.path, waiversForScan(entry.a11yWaivers)); }); for (const tab of entry.tabs ?? []) { - test(`tab "${tab}" has no axe violations`, async ({ page }) => { + test(`tab "${tab.name}" has no axe violations`, async ({ page }) => { await page.goto(entry.path); await expect(entry.ready(page)).toBeVisible(); await expect(page.locator('html')).toHaveClass(theme === 'dark' ? /dark/ : /light/); - await page.getByRole('tab', { name: tab }).click(); - await scanA11y(page, `${entry.path} [${tab}]`, entry.a11yWaivers); + await page.getByRole('tab', { name: tab.name }).click(); + await expect(tab.ready(page)).toBeVisible(); + await scanA11y( + page, + `${entry.path} [${tab.name}]`, + waiversForScan(entry.a11yWaivers, tab.name), + ); }); } }); } }); } + +test.describe('dialog keyboard contract', { tag: '@cross' }, () => { + test.use({ cassette: resourcesCassette() }); + + // Closing a dialog must hand focus back to whatever opened it. Radix only does that for its own + // DialogTrigger, and this app opens dialogs from controlled state, so without help focus lands on + // and a keyboard user starts again from the top of the page. + test('Escape returns focus to the control that opened the dialog', async ({ page, pageErrorLog }) => { + await page.goto('/resources'); + + const opener = page.getByRole('button', { name: 'New folder' }); + + await opener.focus(); + await page.keyboard.press('Enter'); + await expect(page.getByRole('dialog')).toBeVisible(); + + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog')).toBeHidden(); + + await expect(opener).toBeFocused(); + expectCleanPage(pageErrorLog); + }); + + // The other dialog family: ConfirmationDialog renders its DialogContent unconditionally, so a + // focus hook living on the wrapper would capture whatever had focus when the PAGE mounted and + // restore it on navigation instead. This case is what tells the two apart. + test('Escape returns focus for a dialog whose content is always mounted', async ({ page, pageErrorLog }) => { + await page.goto('/resources'); + await page.getByRole('checkbox', { name: `Select ${FILE_RESOURCE.name}` }).click(); + + const opener = page.getByRole('button', { exact: true, name: 'Delete' }); + + await opener.click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog')).toBeHidden(); + + await expect(opener).toBeFocused(); + expectCleanPage(pageErrorLog); + }); +}); diff --git a/frontend/e2e/specs/cross/contrast.spec.ts b/frontend/e2e/specs/cross/contrast.spec.ts index 0c1756ca..1ec0dec5 100644 --- a/frontend/e2e/specs/cross/contrast.spec.ts +++ b/frontend/e2e/specs/cross/contrast.spec.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs'; import type { BadgeVariant } from '@/components/ui/badge'; +import type { ButtonVariant } from '@/components/ui/button'; import { badgeVariants } from '@/components/ui/badge'; import { buttonVariants } from '@/components/ui/button'; @@ -8,8 +9,39 @@ import { buttonVariants } from '@/components/ui/button'; import type { EditorProbe } from '../../helpers/contrast.ts'; import { expect, test } from '../../fixtures/test.ts'; -import { AA_NORMAL, measureContrast, mountContrastProbes, mountEditorProbes } from '../../helpers/contrast.ts'; +import { + AA_NORMAL, + measureContrast, + measureContrastAt, + mountContrastProbes, + mountEditorProbes, + mountFenceProbes, +} from '../../helpers/contrast.ts'; import { flowsCassette } from '../../mocks/cassettes/flows.ts'; +import { PROMPT_DETAIL_AGENT, promptDetailCassette } from '../../mocks/cassettes/settings-prompts.ts'; + +const HLJS_TOKENS = [ + 'hljs-comment', + 'hljs-keyword', + 'hljs-section', + 'hljs-literal', + 'hljs-string', + 'hljs-attr', + 'hljs-symbol', + 'hljs-built_in', +]; + +const SYNTAX_TOKEN_CLASS: Record = { + 'editor-syntax-attr': 'hljs-attr', + 'editor-syntax-class': 'hljs-built_in', + 'editor-syntax-comment': 'hljs-comment', + 'editor-syntax-entity': 'hljs-section', + 'editor-syntax-fg': 'fence text', + 'editor-syntax-keyword': 'hljs-keyword', + 'editor-syntax-literal': 'hljs-literal', + 'editor-syntax-string': 'hljs-string', + 'editor-syntax-symbol': 'hljs-symbol', +}; const EDITOR_PROBES = { 'editor-accent': { tag: 'a' }, @@ -25,7 +57,16 @@ const declaredEditorTokens = (): string[] => { return [...new Set(css.match(/--editor-[\w-]+/g) ?? [])].map((token) => token.slice(2)); }; -// Keyed off the union so a newly added variant fails to compile until it is probed. +// Keyed off the unions so a newly added variant fails to compile until it is probed. +const BUTTON_VARIANTS = Object.keys({ + default: true, + destructive: true, + ghost: true, + link: true, + outline: true, + secondary: true, +} satisfies Record) as ButtonVariant[]; + const BADGE_VARIANTS = Object.keys({ blue: true, default: true, @@ -46,12 +87,23 @@ test('every editor colour token is probed', { tag: '@cross' }, () => { const probed = new Set(Object.keys(EDITOR_PROBES)); // A `-bg` token is the paired ground of its base probe, measured with it. const uncovered = declaredEditorTokens().filter( - (token) => !probed.has(token) && !(token.endsWith('-bg') && probed.has(token.slice(0, -3))), + (token) => + !probed.has(token) && + !(token.endsWith('-bg') && probed.has(token.slice(0, -3))) && + !(token in SYNTAX_TOKEN_CLASS), ); expect(uncovered).toEqual([]); }); +test('every syntax token is measured through a mounted hljs class', { tag: '@cross' }, () => { + const measured = new Set([...HLJS_TOKENS, 'fence text']); + const declared = declaredEditorTokens().filter((token) => token.startsWith('editor-syntax-')); + + expect(declared.filter((token) => !measured.has(SYNTAX_TOKEN_CLASS[token] ?? ''))).toEqual([]); + expect(declared.sort()).toEqual(Object.keys(SYNTAX_TOKEN_CLASS).sort()); +}); + for (const theme of THEMES) { test.describe(`contrast (${theme})`, { tag: '@cross' }, () => { test.use({ cassette: flowsCassette() }); @@ -84,19 +136,26 @@ for (const theme of THEMES) { } }); - test('the destructive button clears AA at rest and on hover', async ({ page }) => { + test('every button variant clears AA at rest and on hover', async ({ page }) => { await page.goto('/flows'); await expect(page.getByRole('row', { name: /E2E Alpha/ })).toBeVisible(); await expect(page.locator('html')).toHaveClass(theme === 'dark' ? /dark/ : /light/); - await mountContrastProbes(page, { destructive: buttonVariants({ variant: 'destructive' }) }); + await mountContrastProbes( + page, + Object.fromEntries(BUTTON_VARIANTS.map((variant) => [variant, buttonVariants({ variant })])), + ); - expect.soft(await measureContrast(page, 'destructive'), 'button at rest').toBeGreaterThanOrEqual(AA_NORMAL); + for (const variant of BUTTON_VARIANTS) { + expect + .soft(await measureContrast(page, variant), `button ${variant} at rest`) + .toBeGreaterThanOrEqual(AA_NORMAL); - await page.locator('[data-contrast="destructive"]').hover(); - expect - .soft(await measureContrast(page, 'destructive'), 'button on hover') - .toBeGreaterThanOrEqual(AA_NORMAL); + await page.locator(`[data-contrast="${variant}"]`).hover(); + expect + .soft(await measureContrast(page, variant), `button ${variant} on hover`) + .toBeGreaterThanOrEqual(AA_NORMAL); + } }); test('editor highlight tokens clear AA on the editor surface', async ({ page }) => { @@ -113,4 +172,59 @@ for (const theme of THEMES) { } }); }); + + test.describe(`contrast in a code block (${theme})`, { tag: '@cross' }, () => { + test.use({ cassette: promptDetailCassette() }); + + if (theme === 'dark') { + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => window.localStorage.setItem('theme', 'dark')); + }); + } + + test('editor highlight tokens clear AA inside a fence', async ({ page }) => { + await page.goto(`/settings/prompts/${PROMPT_DETAIL_AGENT}`); + + const fence = page.locator('.tiptap-content .ProseMirror pre'); + + await expect(fence).toBeVisible(); + await expect(page.locator('html')).toHaveClass(theme === 'dark' ? /dark/ : /light/); + + // Resolve the token through a probe, not getPropertyValue: the minifier rewrites the authored + // text (`oklch(0.94 …)` ships as `oklch(94% …)`), which never equals the computed colour. + const surface = await page.evaluate(() => { + const probe = document.createElement('div'); + + probe.style.backgroundColor = 'var(--editor-code-bg)'; + document.body.append(probe); + + const resolved = getComputedStyle(probe).backgroundColor; + + probe.remove(); + + return resolved; + }); + + expect(surface).not.toBe(''); + await expect(fence).toHaveCSS('background-color', surface); + + await mountFenceProbes(page, HLJS_TOKENS); + + for (const token of HLJS_TOKENS) { + expect + .soft(await measureContrast(page, token), `${token} inside a code block (${theme})`) + .toBeGreaterThanOrEqual(AA_NORMAL); + } + + for (const token of ['variable', 'tag'] as const) { + await expect(fence.locator(`.template-${token}`).first()).toBeVisible(); + expect + .soft( + await measureContrastAt(page, `.tiptap-content .ProseMirror pre .template-${token}`), + `editor-${token} inside a code block (${theme})`, + ) + .toBeGreaterThanOrEqual(AA_NORMAL); + } + }); + }); } diff --git a/frontend/e2e/specs/cross/palette.spec.ts b/frontend/e2e/specs/cross/palette.spec.ts index c4ed6574..ac0d1a89 100644 --- a/frontend/e2e/specs/cross/palette.spec.ts +++ b/frontend/e2e/specs/cross/palette.spec.ts @@ -1,9 +1,13 @@ +import type { Page } from '@playwright/test'; + import type { BadgeVariant } from '@/components/ui/badge'; import { badgeVariants } from '@/components/ui/badge'; import { buttonVariants } from '@/components/ui/button'; +import { routes } from '@/lib/routes'; import { expect, test } from '../../fixtures/test.ts'; +import { populatedSettingsProvidersCassette } from '../../mocks/cassettes/settings-providers.ts'; import { ROUTE_MANIFEST } from '../../routes.ts'; const BADGE_VARIANTS = Object.keys({ @@ -22,27 +26,88 @@ const BADGE_VARIANTS = Object.keys({ const BUTTON_VARIANTS = ['default', 'destructive', 'ghost', 'link', 'outline', 'secondary'] as const; -const SANCTIONED = new Set( - [ - ...BADGE_VARIANTS.map((variant) => badgeVariants({ variant })), - ...BUTTON_VARIANTS.map((variant) => buttonVariants({ variant })), - ] - .join(' ') - .split(/\s+/), -); - -// A hard-coded hue (`text-green-800`, `bg-[#16a34a]`) rather than a semantic token -// (`bg-primary`, `text-muted-foreground`) or a non-colour utility (`text-xs`). +// A hard-coded colour (`text-green-800`, `bg-[#16a34a]`, `text-white`) rather than a semantic token +// (`bg-primary`, `text-muted-foreground`) or a non-colour utility (`text-xs`). white/black/transparent +// are absolute too: an overlay like `text-white` on a coloured badge is off-palette, not a token. const PALETTE_UTILITY = - /^(?:[a-z-]+:)*(?:bg|text|border|ring|from|via|to|fill|stroke|shadow|outline|decoration|divide|accent|caret|placeholder)-(?:\[[^\]]*\]|(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d{2,3}(?:\/\d{1,3})?)$/; + /^(?:[a-z-]+:)*(?:bg|text|border|ring|from|via|to|fill|stroke|shadow|outline|decoration|divide|accent|caret|placeholder)-(?:white|black|transparent|\[[^\]]*\]|(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d{2,3})(?:\/\d{1,3})?$/; -/** Off-palette colours already on the page, by route. Exact strings: they waive one node, not a rule. */ +/** + * Per-variant, not one union: a flat union sanctions every variant's hue on every badge and button, + * so the wrong-variant reuse this gate exists for reads as legal. + */ +const paletteTokensOf = (classes: string) => classes.split(/\s+/).filter((token) => PALETTE_UTILITY.test(token)); + +const SANCTIONED_VARIANTS = [ + ...BADGE_VARIANTS.map((variant) => ({ + slot: 'badge', + tokens: paletteTokensOf(badgeVariants({ variant })), + variant, + })), + ...BUTTON_VARIANTS.map((variant) => ({ + slot: 'button', + tokens: paletteTokensOf(buttonVariants({ variant })), + variant, + })), +]; + +/** + * Off-palette colours already on the page, keyed by scan surface. A route's default view is keyed by + * its path; a tab panel by `${path} [${tab}]`. Exact strings: they waive one node, not a rule. + */ const ACCEPTED: Record = { - // file-manager.tsx expand-all control; changing it moves pixels, so it goes with the design pass. + // file-manager.tsx expand-all control, on /resources and embedded in the flow Files tab. '/resources': ['button: hover:text-blue-400'], + [`${routes.flow('5')} [Files]`]: ['button: hover:text-blue-400'], }; +const scanOffenders = (page: Page) => + page.evaluate( + ({ pattern, variants }) => { + const palette = new RegExp(pattern); + + return [...document.querySelectorAll('[data-slot="badge"],[data-slot="button"]')].flatMap((element) => { + const slot = element.getAttribute('data-slot') ?? ''; + const used = [...element.classList].filter((token) => palette.test(token)); + + if (!used.length) { + return []; + } + + // Equality, not containment: a single token borrowed from another variant is a + // subset of that variant's set, so containment would wave through exactly the + // wrong-variant reuse this gate exists to catch. + const covered = variants.some( + (candidate) => + candidate.slot === slot && + candidate.tokens.length === used.length && + used.every((token) => candidate.tokens.includes(token)), + ); + + return covered ? [] : used.map((token) => `${slot}: ${token}`); + }); + }, + { pattern: PALETTE_UTILITY.source, variants: SANCTIONED_VARIANTS }, + ); + test.describe('palette compliance', { tag: '@cross' }, () => { + // The manifest sweeps this route with the empty seed, so the provider cards — the surface the + // shipped badge defect actually lived on — render nowhere in the loop below. The a11y and visual + // gates each carry the same dedicated populated sweep. + test.describe('settings providers (populated)', () => { + test.use({ cassette: populatedSettingsProvidersCassette() }); + + test('provider cards carry no off-palette colour', async ({ page }) => { + await page.goto(routes.settings.providers); + await expect(page.getByText('My Custom Endpoint')).toBeVisible(); + + const offenders = await scanOffenders(page); + const key = `${routes.settings.providers} (populated)`; + + expect(offenders.sort(), `off-palette colours on ${key}`).toEqual([...(ACCEPTED[key] ?? [])].sort()); + }); + }); + for (const entry of ROUTE_MANIFEST) { test.describe(entry.path, () => { test.use({ cassette: entry.cassette() }); @@ -51,25 +116,30 @@ test.describe('palette compliance', { tag: '@cross' }, () => { await page.goto(entry.path); await expect(entry.ready(page)).toBeVisible(); - const offenders = await page.evaluate( - ({ pattern, sanctioned }) => { - const palette = new RegExp(pattern); - const allowed = new Set(sanctioned); + const offenders = await scanOffenders(page); - return [...document.querySelectorAll('[data-slot="badge"],[data-slot="button"]')].flatMap( - (element) => - [...element.classList] - .filter((token) => palette.test(token) && !allowed.has(token)) - .map((token) => `${element.getAttribute('data-slot')}: ${token}`), - ); - }, - { pattern: PALETTE_UTILITY.source, sanctioned: [...SANCTIONED] }, + expect(offenders.sort(), `off-palette colours on ${entry.path}`).toEqual( + [...(ACCEPTED[entry.path] ?? [])].sort(), ); - - const accepted = ACCEPTED[entry.path] ?? []; - - expect([...new Set(offenders)], `off-palette colours on ${entry.path}`).toEqual(accepted); }); + + // Tabs mount their panels lazily (Radix unmounts inactive ones), so the default-view scan + // above never sees them — sweep each panel like the a11y gate does. + for (const tab of entry.tabs ?? []) { + test(`tab "${tab.name}" carries no off-palette colour`, async ({ page }) => { + await page.goto(entry.path); + await expect(entry.ready(page)).toBeVisible(); + await page.getByRole('tab', { name: tab.name }).click(); + await expect(tab.ready(page)).toBeVisible(); + + const offenders = await scanOffenders(page); + const key = `${entry.path} [${tab.name}]`; + + expect(offenders.sort(), `off-palette colours on ${key}`).toEqual( + [...(ACCEPTED[key] ?? [])].sort(), + ); + }); + } }); } }); diff --git a/frontend/e2e/specs/cross/responsive.spec.ts b/frontend/e2e/specs/cross/responsive.spec.ts index 8d47f23b..4f29b24a 100644 --- a/frontend/e2e/specs/cross/responsive.spec.ts +++ b/frontend/e2e/specs/cross/responsive.spec.ts @@ -37,6 +37,22 @@ test.describe('responsive', { tag: '@cross' }, () => { }); }); + test.describe('sidebar boundary', () => { + test('768 keeps the sidebar inline, 767 collapses it to the mobile sheet', async ({ page, pageErrorLog }) => { + await page.setViewportSize({ height: 800, width: 768 }); + await page.goto('/flows'); + await expect(page.getByRole('link', { name: 'Dashboard' })).toBeVisible(); + + await page.setViewportSize({ height: 800, width: 767 }); + // `hidden md:block` hides the desktop tree either way, so its absence — the closed mobile + // sheet renders nothing — is the only reading that tells the hook's answer from the CSS. + await expect(page.locator('[data-sidebar="sidebar"]')).toHaveCount(0); + await expect(page.getByRole('link', { name: 'Dashboard' })).toBeHidden(); + expect(await page.evaluate(hasHorizontalOverflow)).toBe(false); + expectCleanPage(pageErrorLog); + }); + }); + test.describe('split boundary', () => { test('1280 shows the resizable split, 1279 merges into one tab row', async ({ page, pageErrorLog }) => { await page.setViewportSize({ height: 800, width: 1280 }); diff --git a/frontend/e2e/specs/cross/session-expiry.spec.ts b/frontend/e2e/specs/cross/session-expiry.spec.ts new file mode 100644 index 00000000..007a4080 --- /dev/null +++ b/frontend/e2e/specs/cross/session-expiry.spec.ts @@ -0,0 +1,143 @@ +import type { Page } from '@playwright/test'; + +import type { Cassette } from '../../mocks/cassette.ts'; + +import { authenticatedInfoEntry, guestInfoEntry } from '../../fixtures/auth.ts'; +import { expect, test } from '../../fixtures/test.ts'; +import { baseQueries, baseRest } from '../../mocks/cassettes/base.ts'; +import { flowsCassette } from '../../mocks/cassettes/flows.ts'; + +const EXPIRED = 'session-expired'; + +const infoTurningGuest = (): Cassette['rest'] => ({ + 'GET /api/v1/info': [authenticatedInfoEntry(), { ...guestInfoEntry(), whenFlag: EXPIRED }], +}); + +const storedUser = (page: Page) => + page.evaluate(() => JSON.parse(window.localStorage.getItem('auth') ?? 'null')?.user ?? null); + +test.describe('session expiry', { tag: '@cross' }, () => { + // A private endpoint answers an expired session with 403 + code AuthRequired, never 401 — pinning + // 401 here would drive an interceptor branch production never reaches. + test.describe('a REST 403', () => { + test.use({ + cassette: { + queries: baseQueries(), + rest: { + ...baseRest(), + ...infoTurningGuest(), + 'GET /api/v1/resources/': [ + { + body: { code: 'AuthRequired', msg: 'auth required', status: 'error' }, + setFlag: EXPIRED, + status: 403, + }, + ], + }, + }, + }); + + test('bounces to the login page with a way back and drops the stored session', async ({ page }) => { + const rejected: string[] = []; + + page.on('request', (request) => { + if (new URL(request.url()).pathname === '/api/v1/resources/') { + rejected.push(request.url()); + } + }); + + await page.goto('/resources'); + + await expect(page).toHaveURL(/\/login\?returnUrl=%2Fresources/); + await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible(); + // The login page's own /info rewrites the key, so only the identity inside it matters. + expect(await storedUser(page), 'the expired identity must not survive in storage').toBeNull(); + + // A login page that kept honouring the return URL would land back on the 403 and loop. + expect(rejected, 'the rejected endpoint is called once').toHaveLength(1); + }); + }); + + // `/api/v1/graphql` is registered on the same private group as the REST endpoints, so an expired + // session is refused by the middleware and gqlgen never runs: the client sees a transport 403 + // carrying the REST error body, not a GraphQL `errors` array. + test.describe('a GraphQL 403', () => { + test.use({ + cassette: flowsCassette({ + queries: { + flows: [ + { + body: { code: 'AuthRequired', msg: 'auth required', status: 'error' }, + setFlag: EXPIRED, + status: 403, + }, + ], + }, + rest: { ...baseRest(), ...infoTurningGuest() }, + }), + }); + + test('re-checks the session and sends the operator to login', async ({ page }) => { + await page.goto('/flows'); + + await expect(page).toHaveURL(/\/login\?returnUrl=%2Fflows/); + await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible(); + }); + }); + + test.describe('a GraphQL auth error', () => { + test.use({ + cassette: flowsCassette({ + queries: { flows: [{ errors: [{ message: 'auth required' }], setFlag: EXPIRED }] }, + rest: { ...baseRest(), ...infoTurningGuest() }, + }), + }); + + test('re-checks the session on an auth-worded resolver error too', async ({ page }) => { + await page.goto('/flows'); + + await expect(page).toHaveURL(/\/login\?returnUrl=%2Fflows/); + await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible(); + }); + }); + + test.describe('the interceptor itself', () => { + test.use({ + cassette: { + queries: baseQueries(), + rest: { + ...baseRest(), + // The login page re-reads /info and would write the key back; failing that read is + // the only way to observe what the interceptor left behind. + 'GET /api/v1/info': [ + authenticatedInfoEntry(), + { body: { code: 'Internal', status: 'error' }, status: 500, whenFlag: EXPIRED }, + ], + 'GET /api/v1/resources/': [ + { + body: { code: 'AuthRequired', msg: 'auth required', status: 'error' }, + setFlag: EXPIRED, + status: 403, + }, + ], + }, + }, + }); + + test('clears the stored session outright, not just its user', async ({ page, pageErrorLog }) => { + await page.goto('/resources'); + + await expect(page).toHaveURL(/\/login/); + expect( + await page.evaluate(() => window.localStorage.getItem('auth')), + 'the key is removed, not overwritten', + ).toBeNull(); + + expect(pageErrorLog.pageErrors, 'no uncaught page errors').toEqual([]); + expect( + pageErrorLog.consoleErrors.filter((text) => !/403|500/.test(text)), + 'only the rejected requests are logged', + ).toEqual([]); + }); + }); +}); diff --git a/frontend/e2e/specs/cross/themes.spec.ts b/frontend/e2e/specs/cross/themes.spec.ts index c3e916d3..743f9201 100644 --- a/frontend/e2e/specs/cross/themes.spec.ts +++ b/frontend/e2e/specs/cross/themes.spec.ts @@ -1,8 +1,12 @@ +import type { Page } from '@playwright/test'; + import { expect, test } from '../../fixtures/test.ts'; import { expectCleanPage } from '../../helpers/errors.ts'; -import { MESSAGE_ID_TESTID } from '../../helpers/subscriptions.ts'; +import { MESSAGE_ID_SELECTOR } from '../../helpers/subscriptions.ts'; import { flowsCassette } from '../../mocks/cassettes/flows.ts'; +const storedTheme = (page: Page) => page.evaluate(() => window.localStorage.getItem('theme')); + test.describe('themes', { tag: '@cross' }, () => { test.use({ cassette: flowsCassette() }); @@ -12,12 +16,15 @@ test.describe('themes', { tag: '@cross' }, () => { await page.getByRole('row', { name: /E2E Alpha/ }).click(); await expect(page.locator('html')).toHaveClass(/dark/); - await expect(page.getByTestId(MESSAGE_ID_TESTID).first()).toBeVisible(); + await expect(page.locator(MESSAGE_ID_SELECTOR).first()).toBeVisible(); await expect(page.locator('.xterm')).toBeVisible(); expectCleanPage(pageErrorLog); }); - test('switches themes from the sidebar user menu', async ({ page, pageErrorLog }) => { + test('switches themes from the sidebar user menu, and the pick outlives the page', async ({ + page, + pageErrorLog, + }) => { await page.goto('/flows'); await expect(page.locator('html')).toHaveClass(/light/); @@ -25,9 +32,18 @@ test.describe('themes', { tag: '@cross' }, () => { await page.getByRole('button', { name: /admin@pentagi\.com/ }).click(); await page.getByRole('tab', { name: 'Dark theme' }).click(); await expect(page.locator('html')).toHaveClass(/dark/); + expect(await storedTheme(page), 'the pick is written, not only applied to the live document').toBe('dark'); + await page.reload(); + await expect(page.locator('html'), 'the reloaded page starts dark').toHaveClass(/dark/); + + await page.getByRole('button', { name: /admin@pentagi\.com/ }).click(); await page.getByRole('tab', { name: 'Light theme' }).click(); await expect(page.locator('html')).toHaveClass(/light/); + expect(await storedTheme(page)).toBe('light'); + + await page.reload(); + await expect(page.locator('html'), 'the reloaded page starts light').toHaveClass(/light/); expectCleanPage(pageErrorLog); }); }); diff --git a/frontend/e2e/specs/crud/api-tokens.spec.ts b/frontend/e2e/specs/crud/api-tokens.spec.ts index 9953bac9..d4d56f36 100644 --- a/frontend/e2e/specs/crud/api-tokens.spec.ts +++ b/frontend/e2e/specs/crud/api-tokens.spec.ts @@ -2,6 +2,7 @@ import type { ResultOf } from '@graphql-typed-document-node/core'; import type { CreateApiTokenDocument, DeleteApiTokenDocument } from '@/graphql/types'; +import { CASSETTE_EPOCH } from '../../fixtures/backend.ts'; import { expect, test } from '../../fixtures/test.ts'; import { expectCleanPage } from '../../helpers/errors.ts'; import { @@ -40,6 +41,10 @@ test.describe('api tokens crud', { tag: '@crud' }, () => { }), }); + // The picker's date and the pinned clock together fix what the form must put on the wire. + const EXPIRES_AT = new Date('2026-01-20T00:00:00Z'); + const EXPECTED_TTL = Math.ceil((EXPIRES_AT.getTime() - CASSETTE_EPOCH.getTime()) / 1000); + test('creates a token through the inline row and reveals the secret', async ({ page, pageErrorLog }) => { await page.goto('/settings/api-tokens'); @@ -57,8 +62,21 @@ test.describe('api tokens crud', { tag: '@crud' }, () => { await page.keyboard.press('Escape'); await expect(submit).toBeEnabled(); + + // The cassette cannot pin a clock-derived value, so the expiry the operator picked is + // asserted on the request itself: without this the mutation matches with any ttl at all, + // including the 60s floor a broken calculateTTL would send. + const createRequest = page.waitForRequest( + (request) => request.method() === 'POST' && request.postDataJSON()?.operationName === 'createAPIToken', + ); + await submit.click(); + const { variables } = (await createRequest).postDataJSON(); + + expect(variables.input.ttl).toBeLessThanOrEqual(EXPECTED_TTL); + expect(variables.input.ttl).toBeGreaterThan(EXPECTED_TTL - 1_000); + const dialog = page.getByRole('dialog'); await expect(dialog.getByText('API Token Created')).toBeVisible(); diff --git a/frontend/e2e/specs/crud/knowledges.spec.ts b/frontend/e2e/specs/crud/knowledges.spec.ts index daf0da8e..56099557 100644 --- a/frontend/e2e/specs/crud/knowledges.spec.ts +++ b/frontend/e2e/specs/crud/knowledges.spec.ts @@ -12,7 +12,12 @@ import { ResultType } from '@/graphql/types'; import { expect, test } from '../../fixtures/test.ts'; import { typeIntoEditor } from '../../helpers/editor.ts'; import { expectCleanPage } from '../../helpers/errors.ts'; -import { KNOWLEDGE_DOC, knowledgesCassette, makeKnowledge } from '../../mocks/cassettes/knowledges.ts'; +import { + KNOWLEDGE_DOC, + knowledgeDetailCassette, + knowledgesCassette, + makeKnowledge, +} from '../../mocks/cassettes/knowledges.ts'; const openNewKnowledgeForm = async (page: Page) => { await page.goto('/knowledges'); @@ -122,4 +127,103 @@ test.describe('knowledges crud', { tag: '@crud' }, () => { expectCleanPage(pageErrorLog); }); }); + + // A real load failure on the detail route used to be mislabelled "not found" and bounced to + // the list with no way back in; it must keep the user on the route behind Retry instead. + test.describe('detail load failure', () => { + test.use({ + cassette: knowledgesCassette({ + queries: { knowledgeDocument: [{ errors: [{ message: 'e2e induced load failure' }] }] }, + }), + }); + + test('shows an in-page error with Retry, not a bounce to the list', async ({ page }) => { + await page.goto(`/knowledges/${KNOWLEDGE_DOC.id}`); + + await expect(page.getByRole('button', { name: 'Try again' })).toBeVisible(); + await expect(page).toHaveURL(new RegExp(`/knowledges/${KNOWLEDGE_DOC.id}$`)); + }); + }); + + // The backend authz denial (graph/context.go) contains "not found", so a naive match would + // mistake it for a missing record and bounce a user who merely lacks access. + test.describe('detail authz denial', () => { + test.use({ + cassette: knowledgesCassette({ + queries: { + knowledgeDocument: [{ errors: [{ message: "requested permission 'knowledge.read' not found" }] }], + }, + }), + }); + + test('does not bounce a permission denial to the list', async ({ page }) => { + await page.goto(`/knowledges/${KNOWLEDGE_DOC.id}`); + + await expect(page.getByRole('button', { name: 'Try again' })).toBeVisible(); + await expect(page).toHaveURL(new RegExp(`/knowledges/${KNOWLEDGE_DOC.id}$`)); + }); + }); + + test.describe('save', () => { + test.use({ + cassette: knowledgesCassette({ + mutations: { + updateKnowledgeDocument: [{ data: { updateKnowledgeDocument: KNOWLEDGE_DOC } as never }], + }, + queries: { + knowledgeDocument: [ + { data: { knowledgeDocument: KNOWLEDGE_DOC }, variables: { id: KNOWLEDGE_DOC.id } }, + ], + }, + }), + }); + + // The write half of the editor: until now the suite proved the document loads and round-trips + // in memory, never that Save puts the edited bytes on the wire. + test('Save sends the edited content and keeps the document id', async ({ page, pageErrorLog }) => { + await page.goto(`/knowledges/${KNOWLEDGE_DOC.id}`); + + await typeIntoEditor(page, 'Content', 'E2E-SAVE-MARK'); + + const request = page.waitForRequest( + (candidate) => + candidate.method() === 'POST' && + candidate.postDataJSON()?.operationName === 'updateKnowledgeDocument', + ); + + await page.getByRole('button', { exact: true, name: 'Save' }).click(); + + const { variables } = (await request).postDataJSON(); + + expect(variables.id).toBe(KNOWLEDGE_DOC.id); + expect(variables.input.content).toContain('E2E-SAVE-MARK'); + expectCleanPage(pageErrorLog); + }); + }); + + test.describe('detail header', () => { + test.use({ cassette: knowledgeDetailCassette() }); + + test('keeps the pager left of save and the actions menu', async ({ page }) => { + await page.goto(`/knowledges/${KNOWLEDGE_DOC.id}`); + await expect(page.getByRole('button', { name: 'Knowledge actions' })).toBeVisible(); + + const labels = await page + .locator('header button') + .evaluateAll((buttons) => + buttons.map((button) => button.getAttribute('aria-label') ?? button.textContent), + ); + const positionOf = (label: string) => labels.findIndex((candidate) => (candidate ?? '').includes(label)); + + // findIndex returns -1 for an absent label, and -1 < any real index, so the ordering below + // passes vacuously when a button is missing. Require presence first. + for (const label of ['Save', 'Previous', 'Next', 'Knowledge actions']) { + expect(positionOf(label), `header is missing the "${label}" button`).toBeGreaterThanOrEqual(0); + } + + expect(positionOf('Previous')).toBeLessThan(positionOf('Save')); + expect(positionOf('Next')).toBeLessThan(positionOf('Save')); + expect(positionOf('Save')).toBeLessThan(positionOf('Knowledge actions')); + }); + }); }); diff --git a/frontend/e2e/specs/crud/resources-upload.spec.ts b/frontend/e2e/specs/crud/resources-upload.spec.ts new file mode 100644 index 00000000..55f06da2 --- /dev/null +++ b/frontend/e2e/specs/crud/resources-upload.spec.ts @@ -0,0 +1,134 @@ +import type { Page } from '@playwright/test'; + +import { expect, test } from '../../fixtures/test.ts'; +import { expectCleanPage } from '../../helpers/errors.ts'; +import { resourcesCassette, UPLOADED_FRAGMENT, UPLOADED_RESOURCE } from '../../mocks/cassettes/resources.ts'; + +const UPLOAD_ENDPOINT = '/api/v1/resources/'; + +const pickFiles = (page: Page, files: Array<{ buffer: Buffer; mimeType: string; name: string }>) => + // The sidebar's quick-upload mounts an input of its own; this is the library page's. + page.locator('main input[name="resource-upload"]').setInputFiles(files); + +const textFile = (name: string, body: string) => ({ + buffer: Buffer.from(body), + mimeType: 'text/plain', + name, +}); + +test.describe('resources upload', { tag: '@crud' }, () => { + test.describe('accepted', () => { + test.use({ + cassette: resourcesCassette({ + rest: { + 'POST /api/v1/resources/': [ + { + body: { data: { items: [UPLOADED_RESOURCE], total: 1 }, status: 'success' }, + setFlag: 'resource-uploaded', + }, + ], + }, + subscriptions: { + resourceAdded: [ + { + frames: [ + { + payload: { data: { resourceAdded: UPLOADED_FRAGMENT } }, + whenFlag: 'resource-uploaded', + }, + ], + }, + ], + }, + }), + }); + + test('sends the picked file as multipart and shows it in the library', async ({ page, pageErrorLog }) => { + await page.goto('/resources'); + await expect(page.getByRole('treeitem', { name: /notes\.txt/ })).toBeVisible(); + + const posted = page.waitForRequest( + (request) => request.method() === 'POST' && new URL(request.url()).pathname === UPLOAD_ENDPOINT, + ); + + await pickFiles(page, [textFile(UPLOADED_RESOURCE.name, 'e2e upload payload')]); + + const body = (await posted).postData() ?? ''; + + expect((await posted).headers()['content-type']).toMatch(/^multipart\/form-data; boundary=/); + // The field name is the contract with the Go handler: rename it and the server sees + // an upload with no files at all. + expect(body).toContain('name="files"'); + expect(body).toContain(`filename="${UPLOADED_RESOURCE.name}"`); + expect(body).toContain('e2e upload payload'); + + await expect(page.getByText('File uploaded')).toBeVisible(); + await expect(page.getByRole('treeitem', { name: new RegExp(UPLOADED_RESOURCE.name) })).toBeVisible(); + expectCleanPage(pageErrorLog); + }); + }); + + test.describe('a zero-byte file', () => { + test.use({ + cassette: resourcesCassette({ + rest: { + 'POST /api/v1/resources/': [ + { body: { data: { items: [UPLOADED_RESOURCE], total: 1 }, status: 'success' } }, + ], + }, + }), + }); + + // The Go handler bounds a part only from above (UploadResources rejects fh.Size over the cap, + // never a zero one), so refusing an empty file here would make `.gitkeep` unuploadable through + // the UI while the API accepts it. + test('reaches the wire like any other', async ({ page, pageErrorLog }) => { + await page.goto('/resources'); + await expect(page.getByRole('treeitem', { name: /notes\.txt/ })).toBeVisible(); + + const posted = page.waitForRequest( + (request) => request.method() === 'POST' && new URL(request.url()).pathname === UPLOAD_ENDPOINT, + ); + + await pickFiles(page, [textFile('hollow.txt', '')]); + + expect((await posted).postData() ?? '').toContain('filename="hollow.txt"'); + await expect(page.getByText('is empty')).toHaveCount(0); + expectCleanPage(pageErrorLog); + }); + }); + + test.describe('refused by the backend', () => { + test.use({ + cassette: resourcesCassette({ + rest: { + 'POST /api/v1/resources/': [ + { + // The Go response layer writes `msg` on every error, so this copy — not the + // client's own 409 fallback — is what an operator reads. + body: { + code: 'Resources.AlreadyExists', + msg: 'resource already exists', + status: 'error', + }, + status: 409, + }, + ], + }, + }), + }); + + test('a name clash is explained instead of failing silently', async ({ page, pageErrorLog }) => { + await page.goto('/resources'); + await expect(page.getByRole('treeitem', { name: /notes\.txt/ })).toBeVisible(); + + await pickFiles(page, [textFile('notes.txt', 'same name, new bytes')]); + + await expect(page.getByText('Upload failed')).toBeVisible(); + await expect(page.getByText('resource already exists', { exact: true })).toBeVisible(); + + expect(pageErrorLog.pageErrors, 'no uncaught page errors').toEqual([]); + expect(pageErrorLog.consoleErrors.filter((text) => !text.includes('409'))).toEqual([]); + }); + }); +}); diff --git a/frontend/e2e/specs/crud/resources.spec.ts b/frontend/e2e/specs/crud/resources.spec.ts index e16bbe33..ae33e0a1 100644 --- a/frontend/e2e/specs/crud/resources.spec.ts +++ b/frontend/e2e/specs/crud/resources.spec.ts @@ -1,10 +1,25 @@ import type { ResultOf } from '@graphql-typed-document-node/core'; +import type { Page } from '@playwright/test'; import type { ResourceAddedDocument } from '@/graphql/types'; import { expect, test } from '../../fixtures/test.ts'; import { expectCleanPage } from '../../helpers/errors.ts'; -import { CREATED_FOLDER, emptyResourcesCassette, resourcesCassette } from '../../mocks/cassettes/resources.ts'; +import { + COPY_DESTINATION, + CREATED_FOLDER, + emptyResourcesCassette, + FILE_RESOURCE, + FOLDER_RESOURCE, + RENAMED_PATH, + resourcesCassette, + resourceWrites, +} from '../../mocks/cassettes/resources.ts'; + +interface DownloadClick { + download: string; + href: string; +} test.describe('resources', { tag: '@coverage' }, () => { test.describe('listing', () => { @@ -72,6 +87,170 @@ test.describe('resources', { tag: '@coverage' }, () => { await page.goto('/resources'); await expect(page.getByText('No resources yet')).toBeVisible(); + // Scope to the drop zone (its hint is unique) so the toolbar's Upload button is excluded. + const dropZone = page.locator('div').filter({ hasText: 'Up to 300 MB per file' }).last(); + + await expect(dropZone.getByRole('button', { name: 'Upload files' })).toBeVisible(); + expectCleanPage(pageErrorLog); + }); + }); + + // Every write here is a different verb on a path that also serves the SPA: a wrong method answers + // 200 with HTML instead of 404, so these tests pin the method and the payload, not just the toast. + test.describe('write verbs', () => { + test.use({ cassette: resourcesCassette({ rest: resourceWrites() }) }); + + const rowActions = (page: Page, name: string) => + page.getByRole('treeitem', { name: new RegExp(name) }).getByRole('button', { name: 'Row actions' }); + + test('rename issues a PUT to /resources/move carrying the typed destination', async ({ + page, + pageErrorLog, + }) => { + await page.goto('/resources'); + + const request = page.waitForRequest( + (candidate) => + candidate.method() === 'PUT' && new URL(candidate.url()).pathname === '/api/v1/resources/move', + ); + + await rowActions(page, FILE_RESOURCE.name).click(); + await page.getByRole('menuitem', { name: 'Rename or move' }).click(); + + const dialog = page.getByRole('dialog'); + + await dialog.getByLabel('New path').fill(RENAMED_PATH); + await dialog.getByRole('button', { exact: true, name: 'Move' }).click(); + + expect((await request).postDataJSON()).toMatchObject({ + destination: RENAMED_PATH, + sources: [FILE_RESOURCE.path], + }); + expectCleanPage(pageErrorLog); + }); + + test('copy issues a POST to /resources/copy', async ({ page, pageErrorLog }) => { + await page.goto('/resources'); + + const request = page.waitForRequest( + (candidate) => + candidate.method() === 'POST' && new URL(candidate.url()).pathname === '/api/v1/resources/copy', + ); + + await rowActions(page, FILE_RESOURCE.name).click(); + await page.getByRole('menuitem', { name: 'Copy to…' }).click(); + + const dialog = page.getByRole('dialog'); + + await dialog.getByLabel('Destination path').fill(COPY_DESTINATION); + await dialog.getByRole('button', { exact: true, name: 'Copy' }).click(); + + expect((await request).postDataJSON()).toMatchObject({ + destination: COPY_DESTINATION, + sources: [FILE_RESOURCE.path], + }); + expectCleanPage(pageErrorLog); + }); + + // Copying onto an existing name must stop at a confirmation instead of overwriting silently: + // the request may not leave until the user takes the second decision. + test('a colliding destination raises the replace guard before any request', async ({ page }) => { + await page.goto('/resources'); + + let copyRequests = 0; + + page.on('request', (candidate) => { + if (new URL(candidate.url()).pathname === '/api/v1/resources/copy') { + copyRequests += 1; + } + }); + + await rowActions(page, FILE_RESOURCE.name).click(); + await page.getByRole('menuitem', { name: 'Copy to…' }).click(); + await page.getByRole('dialog').getByLabel('Destination path').fill(FOLDER_RESOURCE.name); + await page.getByRole('dialog').getByRole('button', { exact: true, name: 'Copy' }).click(); + + await expect(page.getByRole('dialog', { name: 'Replace existing item?' })).toBeVisible(); + expect(copyRequests, 'nothing is sent while the guard is open').toBe(0); + + await page.getByRole('button', { name: 'Cancel' }).click(); + expect(copyRequests, 'cancelling the guard sends nothing at all').toBe(0); + }); + + test('delete issues a DELETE whose query carries the row path', async ({ page, pageErrorLog }) => { + await page.goto('/resources'); + + const request = page.waitForRequest( + (candidate) => + candidate.method() === 'DELETE' && new URL(candidate.url()).pathname === '/api/v1/resources/', + ); + + await rowActions(page, FILE_RESOURCE.name).click(); + await page.getByRole('menuitem', { name: 'Delete' }).click(); + await page.getByRole('dialog').getByRole('button', { exact: true, name: 'Delete' }).click(); + + expect(new URL((await request).url()).searchParams.getAll('paths[]')).toEqual([FILE_RESOURCE.path]); + expectCleanPage(pageErrorLog); + }); + + // An `` transfer is carried out by the browser outside the page, so no Playwright + // route — page- or context-scoped — is ever offered it: one started here leaves for whatever + // the preview proxy targets. The bytes are asserted in specs/real/resources-download.spec.ts. + test('the row download action links at the row, saved under its name', async ({ page, pageErrorLog }) => { + await page.goto('/resources'); + await rowActions(page, FILE_RESOURCE.name).click(); + + const link = page.getByRole('menuitem', { name: 'Download' }); + + await expect(link).toHaveAttribute('download', FILE_RESOURCE.name); + + const href = new URL((await link.getAttribute('href')) ?? '', page.url()); + + expect(href.pathname).toBe('/api/v1/resources/download'); + expect(href.searchParams.getAll('paths[]')).toEqual([FILE_RESOURCE.path]); + expectCleanPage(pageErrorLog); + }); + + // Multi-select is a different code path: one transfer carrying every selected path, saved + // under an archive name. Neither is derivable from the single-row case. + test('the bulk action asks for every selected path in one archive', async ({ page, pageErrorLog }) => { + // Its anchor is created, clicked and dropped inside one handler, so nothing survives in + // the DOM to read — record the click and swallow it instead of starting a transfer. + await page.addInitScript(() => { + const recorded: DownloadClick[] = []; + + (window as unknown as { e2eDownloads: DownloadClick[] }).e2eDownloads = recorded; + + const { click } = HTMLAnchorElement.prototype; + + HTMLAnchorElement.prototype.click = function (this: HTMLAnchorElement) { + if (!this.hasAttribute('download')) { + click.call(this); + + return; + } + + recorded.push({ download: this.download, href: this.href }); + }; + }); + await page.goto('/resources'); + await page.getByRole('checkbox', { name: `Select ${FILE_RESOURCE.name}` }).click(); + await page.getByRole('checkbox', { name: `Select ${FOLDER_RESOURCE.name}` }).click(); + await page.getByRole('button', { exact: true, name: 'Download' }).click(); + + const clicks = await page.evaluate( + () => (window as unknown as { e2eDownloads: DownloadClick[] }).e2eDownloads, + ); + + expect(clicks, 'one transfer for the whole selection').toHaveLength(1); + + const { download, href } = clicks[0]!; + + expect(new URL(href).pathname).toBe('/api/v1/resources/download'); + expect(new URL(href).searchParams.getAll('paths[]').sort()).toEqual( + [FILE_RESOURCE.path, FOLDER_RESOURCE.path].sort(), + ); + expect(download).toMatch(/\.zip$/); expectCleanPage(pageErrorLog); }); }); diff --git a/frontend/e2e/specs/crud/template-detail.spec.ts b/frontend/e2e/specs/crud/template-detail.spec.ts index 39ec9403..7cbe1db0 100644 --- a/frontend/e2e/specs/crud/template-detail.spec.ts +++ b/frontend/e2e/specs/crud/template-detail.spec.ts @@ -5,7 +5,13 @@ import { expectCleanPage } from '../../helpers/errors.ts'; import { RICH_TEMPLATE_TEXT, TEMPLATE_DETAIL, templateDetailCassette } from '../../mocks/cassettes/templates.ts'; test.describe('template detail', { tag: '@coverage' }, () => { - test.use({ cassette: templateDetailCassette() }); + test.use({ + cassette: templateDetailCassette({ + mutations: { + updateFlowTemplate: [{ data: { updateFlowTemplate: TEMPLATE_DETAIL } as never }], + }, + }), + }); const EDITOR = 'Template content'; @@ -16,7 +22,7 @@ test.describe('template detail', { tag: '@coverage' }, () => { await page.keyboard.press('Escape'); }; - test('keeps the pager between save and the actions menu', async ({ page }) => { + test('keeps the pager left of save and the actions menu', async ({ page }) => { await page.goto(`/templates/${TEMPLATE_DETAIL.id}`); await expect(page.getByRole('button', { name: 'Template actions' })).toBeVisible(); @@ -25,8 +31,15 @@ test.describe('template detail', { tag: '@coverage' }, () => { .evaluateAll((buttons) => buttons.map((button) => button.getAttribute('aria-label') ?? button.textContent)); const positionOf = (label: string) => labels.findIndex((candidate) => (candidate ?? '').includes(label)); - expect(positionOf('Save')).toBeLessThan(positionOf('Previous')); - expect(positionOf('Next')).toBeLessThan(positionOf('Template actions')); + // findIndex returns -1 for an absent label, and -1 < any real index, so the ordering below + // passes vacuously when a button is missing. Require presence first. + for (const label of ['Save', 'Previous', 'Next', 'Template actions']) { + expect(positionOf(label), `header is missing the "${label}" button`).toBeGreaterThanOrEqual(0); + } + + expect(positionOf('Previous')).toBeLessThan(positionOf('Save')); + expect(positionOf('Next')).toBeLessThan(positionOf('Save')); + expect(positionOf('Save')).toBeLessThan(positionOf('Template actions')); }); test('loads the template body into the editor byte-exact', async ({ page, pageErrorLog }) => { @@ -66,4 +79,54 @@ test.describe('template detail', { tag: '@coverage' }, () => { expect(raw).toContain('E2E-MARK'); expectCleanPage(pageErrorLog); }); + + // A real load failure must offer Retry in place, not the "Template not found" card that a + // genuine 404 shows — the two used to collapse into the same dead-end. + + // Save is the only operation that can destroy a user's template, and no spec has ever let it reach + // the wire — the round-trip units stop at the editor boundary. + test('Save sends the edited body verbatim, placeholders intact', async ({ page, pageErrorLog }) => { + await page.goto(`/templates/${TEMPLATE_DETAIL.id}`); + + const editor = page.getByRole('textbox', { name: EDITOR }); + + await expect(editor.getByRole('heading', { name: 'Recon' })).toBeVisible(); + await editor.click(); + await page.keyboard.press('ControlOrMeta+End'); + await editor.pressSequentially(' E2E-SAVE-MARK'); + + const request = page.waitForRequest( + (candidate) => + candidate.method() === 'POST' && candidate.postDataJSON()?.operationName === 'updateFlowTemplate', + ); + + await page.getByRole('button', { exact: true, name: 'Save' }).click(); + + const { variables } = (await request).postDataJSON(); + + expect(variables.templateId).toBe(TEMPLATE_DETAIL.id); + expect(variables.input.text).toContain('E2E-SAVE-MARK'); + + for (const atom of ['# Recon', '{{TARGET}}', 'nmap -sV']) { + expect(variables.input.text, `"${atom}" survived the save`).toContain(atom); + } + + expectCleanPage(pageErrorLog); + }); + + test.describe('load failure', () => { + test.use({ + cassette: templateDetailCassette({ + queries: { flowTemplate: [{ errors: [{ message: 'e2e induced load failure' }] }] }, + }), + }); + + test('shows an in-page error with Retry, not "not found"', async ({ page }) => { + await page.goto(`/templates/${TEMPLATE_DETAIL.id}`); + + await expect(page.getByRole('button', { name: 'Try again' })).toBeVisible(); + await expect(page.getByText('Template not found')).toBeHidden(); + await expect(page).toHaveURL(new RegExp(`/templates/${TEMPLATE_DETAIL.id}$`)); + }); + }); }); diff --git a/frontend/e2e/specs/dashboard/dashboard.spec.ts b/frontend/e2e/specs/dashboard/dashboard.spec.ts index 66c2c2bd..b34d9c1c 100644 --- a/frontend/e2e/specs/dashboard/dashboard.spec.ts +++ b/frontend/e2e/specs/dashboard/dashboard.spec.ts @@ -26,8 +26,12 @@ test.describe('dashboard', { tag: '@coverage' }, () => { test('overview tab loads lazily and renders metrics and usage tables', async ({ page, pageErrorLog }) => { await page.goto('/dashboard'); - // Lazy: the overview metrics are not mounted while the Analytics tab is active. - await expect(page.getByRole('heading', { exact: true, name: 'Total Flows' })).toBeHidden(); + // The route is lazy, so this must wait for the Analytics panel first: a bare negative + // assert is satisfied by "nothing has rendered yet" and can never fail. + await expect(page.getByRole('heading', { name: 'Flows Activity Over Time' })).toBeVisible(); + // not.toBeAttached, not toBeHidden — the latter also passes for a mounted-but-hidden node, + // so it would not catch the panel being eagerly mounted (all its queries firing). + await expect(page.getByRole('heading', { exact: true, name: 'Total Flows' })).not.toBeAttached(); await page.getByRole('tab', { name: 'Overview' }).click(); for (const title of ['Total Flows', 'Tool Calls', 'Total Tokens', 'Total Cost']) { diff --git a/frontend/e2e/specs/flows/assistants.spec.ts b/frontend/e2e/specs/flows/assistants.spec.ts new file mode 100644 index 00000000..5e667263 --- /dev/null +++ b/frontend/e2e/specs/flows/assistants.spec.ts @@ -0,0 +1,265 @@ +import type { ResultOf } from '@graphql-typed-document-node/core'; +import type { Page } from '@playwright/test'; + +import type { CallAssistantDocument, DeleteAssistantDocument } from '@/graphql/types'; + +import { ResultType, StatusType } from '@/graphql/types'; + +import { expect, test } from '../../fixtures/test.ts'; +import { expectCleanPage } from '../../helpers/errors.ts'; +import { + assistantsCassette, + CREATED_ASSISTANT, + makeAssistant, + RUNNING_ASSISTANT, + WAITING_ASSISTANT, +} from '../../mocks/cassettes/assistants.ts'; +import { PROVIDER } from '../../mocks/cassettes/flows.ts'; + +const openAssistantTab = async (page: Page) => { + await page.goto('/flows/5'); + await page.getByRole('tab', { name: 'Assistant' }).click(); + await expect(page.getByPlaceholder('Search messages...')).toBeVisible(); +}; + +const openPicker = async (page: Page) => { + await page.getByRole('button', { name: 'Select assistant' }).click(); + await expect(page.getByPlaceholder('Search assistants...')).toBeVisible(); +}; + +const operationRequest = (page: Page, operationName: string) => + page.waitForRequest( + (request) => request.method() === 'POST' && request.postDataJSON()?.operationName === operationName, + ); + +test.describe('flow assistants', { tag: '@flows' }, () => { + test.describe('create', () => { + test.use({ + cassette: assistantsCassette({ + mutations: { + createAssistant: [ + { + data: { + createAssistant: { + assistant: CREATED_ASSISTANT, + flow: { __typename: 'Flow', id: '5' }, + }, + } as never, + setFlag: 'assistant-created', + variables: { flowId: '5', input: 'plan the recon', modelProvider: PROVIDER.name }, + }, + ], + }, + subscriptions: { + assistantCreated: [ + { + frames: [ + { + payload: { data: { assistantCreated: CREATED_ASSISTANT } }, + whenFlag: 'assistant-created', + }, + ], + }, + ], + }, + }), + }); + + test('creates an assistant from the empty composer and selects it', async ({ page, pageErrorLog }) => { + await openAssistantTab(page); + await openPicker(page); + await page.getByRole('option', { name: 'Create new assistant' }).click(); + + const composer = page.getByPlaceholder('Type a message to create a new assistant...'); + const picker = page.getByRole('button', { name: 'Select assistant' }); + + await expect(composer).toBeVisible(); + await expect(picker).toHaveText('New'); + await composer.fill('plan the recon'); + + // A brand-new assistant inherits no provider. The only one on offer is also the one the + // previously selected assistant used — the case a defaults-sync that ignores the picked + // value silently reverts. + await page.getByRole('button', { name: 'Select Provider' }).click(); + await page.getByRole('menuitem', { name: PROVIDER.name }).click(); + + const request = operationRequest(page, 'createAssistant'); + + await page.getByRole('button', { name: 'Submit' }).click(); + + expect((await request).postDataJSON().variables).toEqual({ + flowId: '5', + input: 'plan the recon', + modelProvider: PROVIDER.name, + useAgents: false, + }); + + // `Create new assistant` leaves the picker on an explicit "none" that the + // fall-back-to-first selection never overrides — only the create response does. + await expect(picker).toHaveText(/1$/); + + await openPicker(page); + await expect(page.getByRole('option', { name: new RegExp(CREATED_ASSISTANT.title) })).toBeVisible(); + expectCleanPage(pageErrorLog); + }); + }); + + test.describe('call', () => { + const called: ResultOf = { callAssistant: ResultType.Success }; + + test.use({ + cassette: assistantsCassette({ + mutations: { + callAssistant: [ + { + data: called, + variables: { assistantId: '11', flowId: '5', input: 'and the other ports?' }, + }, + ], + }, + }), + }); + + test('sends a follow-up to the assistant the picker already selected', async ({ page, pageErrorLog }) => { + await openAssistantTab(page); + + const composer = page.getByPlaceholder('Continue the conversation...'); + + await composer.fill('and the other ports?'); + + const request = operationRequest(page, 'callAssistant'); + + await page.getByRole('button', { name: 'Submit' }).click(); + + expect((await request).postDataJSON().variables).toEqual({ + assistantId: WAITING_ASSISTANT.id, + flowId: '5', + input: 'and the other ports?', + useAgents: false, + }); + await expect(composer).toHaveValue(''); + expectCleanPage(pageErrorLog); + }); + }); + + test.describe('stop', () => { + test.use({ + cassette: assistantsCassette({ + mutations: { + stopAssistant: [ + { + data: { + stopAssistant: makeAssistant( + RUNNING_ASSISTANT.id, + RUNNING_ASSISTANT.title, + StatusType.Waiting, + ), + }, + setFlag: 'assistant-stopped', + variables: { assistantId: '12', flowId: '5' }, + }, + ], + }, + subscriptions: { + assistantUpdated: [ + { + frames: [ + { + payload: { + data: { + assistantUpdated: makeAssistant( + RUNNING_ASSISTANT.id, + RUNNING_ASSISTANT.title, + StatusType.Waiting, + ), + }, + }, + whenFlag: 'assistant-stopped', + }, + ], + }, + ], + }, + }), + }); + + test('stops the running assistant and hands the composer back', async ({ page, pageErrorLog }) => { + await openAssistantTab(page); + await openPicker(page); + await page.getByRole('option', { name: new RegExp(RUNNING_ASSISTANT.title) }).click(); + + const stop = page.getByRole('button', { name: 'Cancel' }); + + await expect(stop).toBeVisible(); + + const request = operationRequest(page, 'stopAssistant'); + + await stop.click(); + + expect((await request).postDataJSON().variables).toEqual({ + assistantId: RUNNING_ASSISTANT.id, + flowId: '5', + }); + await expect(page.getByPlaceholder('Continue the conversation...')).toBeVisible(); + expectCleanPage(pageErrorLog); + }); + }); + + test.describe('delete', () => { + const deleted: ResultOf = { deleteAssistant: ResultType.Success }; + + test.use({ + cassette: assistantsCassette({ + mutations: { + deleteAssistant: [ + { + data: deleted, + setFlag: 'assistant-deleted', + variables: { assistantId: '12', flowId: '5' }, + }, + ], + }, + subscriptions: { + assistantDeleted: [ + { + frames: [ + { + payload: { data: { assistantDeleted: RUNNING_ASSISTANT } }, + whenFlag: 'assistant-deleted', + }, + ], + }, + ], + }, + }), + }); + + test('deletes an assistant through the confirm dialog', async ({ page, pageErrorLog }) => { + await openAssistantTab(page); + await openPicker(page); + + const doomed = page.getByRole('option', { name: new RegExp(RUNNING_ASSISTANT.title) }); + + await doomed.hover(); + await doomed.getByRole('button').click(); + + const dialog = page.getByRole('dialog'); + + await expect(dialog.getByText('Delete Assistant')).toBeVisible(); + + const request = operationRequest(page, 'deleteAssistant'); + + await dialog.getByRole('button', { name: 'Delete' }).click(); + + expect((await request).postDataJSON().variables).toEqual({ + assistantId: RUNNING_ASSISTANT.id, + flowId: '5', + }); + + await openPicker(page); + await expect(page.getByRole('option', { name: new RegExp(WAITING_ASSISTANT.title) })).toBeVisible(); + await expect(page.getByRole('option', { name: new RegExp(RUNNING_ASSISTANT.title) })).toBeHidden(); + expectCleanPage(pageErrorLog); + }); + }); +}); diff --git a/frontend/e2e/specs/flows/interrupt.spec.ts b/frontend/e2e/specs/flows/interrupt.spec.ts new file mode 100644 index 00000000..7aeaab2a --- /dev/null +++ b/frontend/e2e/specs/flows/interrupt.spec.ts @@ -0,0 +1,118 @@ +import type { ResultOf } from '@graphql-typed-document-node/core'; +import type { Page } from '@playwright/test'; + +import type { PutUserInputDocument, StopFlowDocument } from '@/graphql/types'; + +import { ResultType, StatusType } from '@/graphql/types'; + +import { expect, test } from '../../fixtures/test.ts'; +import { expectCleanPage } from '../../helpers/errors.ts'; +import { + FLOW_A_INITIAL_IDS, + FLOW_B, + flowQueryData, + flowsCassette, + makeFlow, + makeMessage, + PROVIDER, +} from '../../mocks/cassettes/flows.ts'; + +const WAITING_FLOW = makeFlow('5', 'E2E Alpha', StatusType.Waiting); + +const openFlowA = async (page: Page) => { + await page.goto('/flows/5?tab=automation'); + await expect(page.locator('header').getByText('E2E Alpha', { exact: true })).toBeVisible(); +}; + +const operationRequest = (page: Page, operationName: string) => + page.waitForRequest( + (request) => request.method() === 'POST' && request.postDataJSON()?.operationName === operationName, + ); + +test.describe('flow interrupt', { tag: '@flows' }, () => { + test.describe('stop', () => { + const stopped: ResultOf = { stopFlow: ResultType.Success }; + + test.use({ + cassette: flowsCassette({ + mutations: { stopFlow: [{ data: stopped, setFlag: 'flow-stopped', variables: { flowId: '5' } }] }, + subscriptions: { + flowUpdated: [ + { + frames: [{ payload: { data: { flowUpdated: WAITING_FLOW } }, whenFlag: 'flow-stopped' }], + }, + ], + }, + }), + }); + + test('stops a running flow from the composer and hands the input back', async ({ page, pageErrorLog }) => { + await openFlowA(page); + + const stop = page.getByRole('button', { name: 'Cancel' }); + + await expect(stop, 'a running flow offers Stop, not Submit').toBeVisible(); + await expect(page.getByRole('button', { name: 'Submit' })).toBeHidden(); + + const request = operationRequest(page, 'stopFlow'); + + await stop.click(); + expect((await request).postDataJSON().variables).toEqual({ flowId: '5' }); + + await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible(); + await expect(page.getByPlaceholder('Provide additional context or instructions...')).toBeVisible(); + expectCleanPage(pageErrorLog); + }); + }); + + test.describe('user input', () => { + const accepted: ResultOf = { putUserInput: ResultType.Success }; + + test.use({ + cassette: flowsCassette({ + mutations: { + putUserInput: [ + { + data: accepted, + variables: { flowId: '5', input: 'follow up on the open port' }, + }, + ], + }, + queries: { + flow: [ + { + data: flowQueryData( + WAITING_FLOW, + FLOW_A_INITIAL_IDS.map((id) => makeMessage(id, '5')), + ), + variables: { id: '5' }, + }, + ], + // The list query normalizes into the same cache entry, so a Running row here + // would overwrite the status the detail query just delivered. + flows: [{ data: { flows: [WAITING_FLOW, FLOW_B] } }], + }, + }), + }); + + test('sends the typed message to the waiting flow', async ({ page, pageErrorLog }) => { + await openFlowA(page); + + const composer = page.getByPlaceholder('Provide additional context or instructions...'); + + await composer.fill('follow up on the open port'); + + const request = operationRequest(page, 'putUserInput'); + + await page.getByRole('button', { name: 'Submit' }).click(); + + expect((await request).postDataJSON().variables).toEqual({ + flowId: '5', + input: 'follow up on the open port', + modelProvider: PROVIDER.name, + }); + await expect(composer).toHaveValue(''); + expectCleanPage(pageErrorLog); + }); + }); +}); diff --git a/frontend/e2e/specs/flows/live-panels.spec.ts b/frontend/e2e/specs/flows/live-panels.spec.ts new file mode 100644 index 00000000..e6b290cc --- /dev/null +++ b/frontend/e2e/specs/flows/live-panels.spec.ts @@ -0,0 +1,78 @@ +import { expect, test } from '../../fixtures/test.ts'; +import { expectCleanPage } from '../../helpers/errors.ts'; +import { MESSAGE_ID_SELECTOR } from '../../helpers/subscriptions.ts'; +import { readTerminalBuffer } from '../../helpers/terminal.ts'; +import { + livePanelsCassette, + makeMessage, + STREAMED, + VARIED_MESSAGES, + variedMessagesCassette, +} from '../../mocks/cassettes/flows.ts'; + +const REVISED_ANSWER = 'Revised plan after the retry'; + +test.describe('live panels', { tag: '@flows' }, () => { + test.use({ cassette: livePanelsCassette() }); + + // Each panel below is fed by a subscription and nothing else: the seeded query answers hold + // none of these strings, so a frame wired to the wrong cache field leaves its panel stale. + test('every streamed panel picks up its own frame', async ({ page, pageErrorLog }) => { + await page.goto('/flows/5'); + await expect(page.locator('header').getByRole('button', { name: 'Toggle favorite' })).toBeEnabled(); + + await page.getByRole('tab', { name: 'Tasks' }).click(); + await expect(page.getByText(STREAMED.task)).toBeVisible(); + await expect(page.getByText(STREAMED.taskRetitled)).toBeVisible(); + + await page.getByRole('tab', { name: 'Agents' }).click(); + await expect(page.getByText(STREAMED.agent)).toBeVisible(); + + await page.getByRole('tab', { name: 'Searches' }).click(); + await expect(page.getByText(STREAMED.search)).toBeVisible(); + + await page.getByRole('tab', { name: 'Vector Store' }).click(); + await expect(page.getByText(STREAMED.vector)).toBeVisible(); + + await page.getByRole('tab', { name: 'Terminal' }).click(); + await expect(async () => { + expect(await readTerminalBuffer(page)).toContain(STREAMED.terminal); + }).toPass(); + + expectCleanPage(pageErrorLog); + }); + + test.describe('a revised message', () => { + test.use({ + cassette: variedMessagesCassette({ + subscriptions: { + messageLogUpdated: [ + { + frames: [ + { + delayMs: 60, + payload: { + data: { + messageLogUpdated: makeMessage('501', '5', { message: REVISED_ANSWER }), + }, + }, + }, + ], + variables: { flowId: '5' }, + }, + ], + }, + }), + }); + + test('replaces the message in place instead of appending a second one', async ({ page, pageErrorLog }) => { + await page.goto('/flows/5?tab=automation'); + + await expect(page.getByText(REVISED_ANSWER)).toBeVisible(); + await expect(page.getByText(VARIED_MESSAGES[0]!.message)).toBeHidden(); + await expect(page.locator(MESSAGE_ID_SELECTOR)).toHaveCount(VARIED_MESSAGES.length); + + expectCleanPage(pageErrorLog); + }); + }); +}); diff --git a/frontend/e2e/specs/flows/messages.spec.ts b/frontend/e2e/specs/flows/messages.spec.ts index 18d8e0af..7b4c1643 100644 --- a/frontend/e2e/specs/flows/messages.spec.ts +++ b/frontend/e2e/specs/flows/messages.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from '../../fixtures/test.ts'; import { expectCleanPage } from '../../helpers/errors.ts'; +import { MESSAGE_ID_SELECTOR } from '../../helpers/subscriptions.ts'; import { variedMessagesCassette } from '../../mocks/cassettes/flows.ts'; type XtermHost = { @@ -36,7 +37,7 @@ test.describe('flow message rendering', { tag: '@flows' }, () => { pageErrorLog, }) => { await page.goto('/flows/5?tab=automation'); - await expect(page.getByTestId('flow-message-id')).toHaveCount(4); + await expect(page.locator(MESSAGE_ID_SELECTOR)).toHaveCount(4); const thinkingToggle = page.getByText('Show thinking'); @@ -50,6 +51,11 @@ test.describe('flow message rendering', { tag: '@flows' }, () => { await expect(page.getByRole('heading', { name: 'Report' })).toBeVisible(); await expect(page.getByText('Hide details').first()).toBeVisible(); + // Only a report auto-expands, so the terminal message starts collapsed and its xterm is + // not mounted — the panel's is the only one on screen until the toggle is used. + await expect(page.locator('.xterm')).toHaveCount(1); + await page.getByText('Show details').click(); + await expect(page.locator('.xterm')).toHaveCount(2); await expect(async () => { expect(await page.evaluate(anyTerminalContains, 'e2e-terminal-marker')).toBe(true); diff --git a/frontend/e2e/specs/flows/pager.spec.ts b/frontend/e2e/specs/flows/pager.spec.ts index e32c9666..58cfedbb 100644 --- a/frontend/e2e/specs/flows/pager.spec.ts +++ b/frontend/e2e/specs/flows/pager.spec.ts @@ -8,7 +8,7 @@ test.describe('flow pager', { tag: ['@flows', '@smoke'] }, () => { test.describe('with a report to show', () => { test.use({ cassette: flowTabsCassette() }); - test('keeps the variable action left of the fixed ones', async ({ page }) => { + test('keeps the variable Report action left of the pager and fixed actions', async ({ page }) => { await page.goto('/flows/5'); await expect(page.locator('header').getByRole('button', { name: 'Report' })).toBeVisible(); @@ -17,52 +17,59 @@ test.describe('flow pager', { tag: ['@flows', '@smoke'] }, () => { .evaluateAll((buttons) => buttons.map((button) => button.getAttribute('aria-label') ?? '')); const positionOf = (label: string) => labels.findIndex((candidate) => candidate.startsWith(label)); - expect(positionOf('Report')).toBeLessThan(positionOf('Toggle favorite')); - expect(positionOf('Toggle favorite')).toBeLessThan(positionOf('Previous')); - expect(positionOf('Next')).toBeLessThan(positionOf('Flow actions')); + // findIndex returns -1 for an absent label, and -1 < any real index, so the ordering below + // passes vacuously when a button is missing. Require presence first. + for (const label of ['Report', 'Toggle favorite', 'Previous', 'Next', 'Flow actions']) { + expect(positionOf(label), `header is missing the "${label}" button`).toBeGreaterThanOrEqual(0); + } + + expect(positionOf('Report')).toBeLessThan(positionOf('Previous')); + expect(positionOf('Previous')).toBeLessThan(positionOf('Next')); + expect(positionOf('Next')).toBeLessThan(positionOf('Toggle favorite')); + expect(positionOf('Toggle favorite')).toBeLessThan(positionOf('Flow actions')); }); }); test('steps to the sibling flow and back without passing through the list', async ({ page, pageErrorLog }) => { const header = page.locator('header'); + await page.addInitScript(() => { + const trail: string[] = []; + + (window as unknown as { __routeTrail: string[] }).__routeTrail = trail; + + for (const method of ['pushState', 'replaceState'] as const) { + const original = history[method].bind(history); + + history[method] = (state: unknown, unused: string, url?: null | string | URL) => { + original(state, unused, url); + trail.push(window.location.pathname); + }; + } + }); + await page.goto('/flows/5'); await expect(header.getByText('E2E Alpha')).toBeVisible(); - await page.route('**/graphql', async (route) => { - await new Promise((resolve) => setTimeout(resolve, 300)); - await route.fallback(); + // Drop the router's own normalising replaceState from the initial load. + await page.evaluate(() => { + (window as unknown as { __routeTrail: string[] }).__routeTrail.length = 0; }); await header.getByRole('button', { name: 'Next' }).click(); - const samples = await page.evaluate(async () => { - const taken = []; - - for (let index = 0; index < 10; index += 1) { - taken.push({ - hasPager: !!document.querySelector('header button[aria-label="Next"]'), - isSiblingShown: document.querySelector('header')?.textContent?.includes('E2E Beta') ?? false, - path: window.location.pathname, - }); - await new Promise((resolve) => setTimeout(resolve, 30)); - } - - return taken; - }); - await expect(page).toHaveURL(/\/flows\/6$/); await expect(header.getByText('E2E Beta')).toBeVisible(); - - // Guards the delay above as much as the pager: without it the sibling lands inside the - // first sample and the loop measures nothing. - expect(samples.filter((sample) => !sample.isSiblingShown).length).toBeGreaterThan(2); - expect(samples.every((sample) => sample.hasPager)).toBe(true); - expect(samples.map((sample) => sample.path)).not.toContain('/flows'); + await expect(header.getByRole('button', { name: 'Next' })).toBeVisible(); await header.getByRole('button', { name: 'Previous' }).click(); await expect(page).toHaveURL(/\/flows\/5$/); + await expect(header.getByText('E2E Alpha')).toBeVisible(); + + const trail = await page.evaluate(() => (window as unknown as { __routeTrail: string[] }).__routeTrail); + + expect(trail, 'the pager must step straight between siblings').toEqual(['/flows/6', '/flows/5']); expectCleanPage(pageErrorLog); }); }); diff --git a/frontend/e2e/specs/flows/reconnect.spec.ts b/frontend/e2e/specs/flows/reconnect.spec.ts index cc2288cd..d8b24650 100644 --- a/frontend/e2e/specs/flows/reconnect.spec.ts +++ b/frontend/e2e/specs/flows/reconnect.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from '../../fixtures/test.ts'; import { expectCleanPage } from '../../helpers/errors.ts'; import { dropAndReconnect } from '../../helpers/reconnect.ts'; -import { assertNoDuplicates, extractMessageIds, MESSAGE_ID_TESTID } from '../../helpers/subscriptions.ts'; +import { assertNoDuplicates, extractMessageIds, MESSAGE_ID_SELECTOR } from '../../helpers/subscriptions.ts'; import { FLOW_A_INITIAL_IDS, FLOW_A_RECONNECT_ID, @@ -19,17 +19,34 @@ test.describe('flow reconnect', { tag: ['@flows', '@smoke'] }, () => { test.use({ cassette: flowsCassette() }); test('reconciles the missed delta exactly once after a socket drop', async ({ page, pageErrorLog, world }) => { + // The GraphQL sweep after a reconnect skips the resources slot (it is cache-only), so the + // REST re-hydration rides on the `ws:reconnected` event alone. Count the calls rather than + // arming a one-shot expectation: the mount-time hydration would satisfy that on its own. + const resourceLoads: string[] = []; + + page.on('request', (request) => { + if (request.method() === 'GET' && new URL(request.url()).pathname.startsWith('/api/v1/resources')) { + resourceLoads.push(request.url()); + } + }); + await page.goto('/flows'); await page.getByRole('row', { name: /E2E Alpha/ }).click(); - await expect(page.getByTestId(MESSAGE_ID_TESTID)).toHaveCount(BEFORE_DROP.length); + await expect(page.locator(MESSAGE_ID_SELECTOR)).toHaveCount(BEFORE_DROP.length); + + const loadsBeforeDrop = resourceLoads.length; await dropAndReconnect(page, world); - await expect(page.getByTestId(MESSAGE_ID_TESTID)).toHaveCount(AFTER_RECONNECT.length); + await expect + .poll(() => resourceLoads.length, { message: 'the reconnect re-hydrates the REST resources slot' }) + .toBe(loadsBeforeDrop + 1); + + await expect(page.locator(MESSAGE_ID_SELECTOR)).toHaveCount(AFTER_RECONNECT.length); world.raiseFlag(REPLAY_FLAG); - await expect(page.getByTestId(MESSAGE_ID_TESTID)).toHaveCount(AFTER_REPLAY.length); + await expect(page.locator(MESSAGE_ID_SELECTOR)).toHaveCount(AFTER_REPLAY.length); const ids = await extractMessageIds(page.locator('body')); diff --git a/frontend/e2e/specs/flows/report.spec.ts b/frontend/e2e/specs/flows/report.spec.ts new file mode 100644 index 00000000..13148c46 --- /dev/null +++ b/frontend/e2e/specs/flows/report.spec.ts @@ -0,0 +1,123 @@ +import type { Page } from '@playwright/test'; + +import { expect, test } from '../../fixtures/test.ts'; +import { expectCleanPage } from '../../helpers/errors.ts'; +import { inspectPdf } from '../../helpers/pdf.ts'; +import { flowReportCassette } from '../../mocks/cassettes/flows.ts'; + +const REPORT_FILE = /^report_flow_5_e2e_alpha_2026\d{10}\.md$/; +const REPORT_PDF = /^report_flow_5_e2e_alpha_2026\d{10}\.pdf$/; + +const openReportMenu = async (page: Page) => { + await page.goto('/flows/5'); + await page.getByRole('button', { name: 'Report' }).click(); +}; + +test.describe('flow report', { tag: '@flows' }, () => { + test.use({ cassette: flowReportCassette() }); + + test('the standalone route renders the generated markdown', async ({ page, pageErrorLog }) => { + await page.goto('/flows/5/report'); + + await expect(page.getByRole('heading', { name: /E2E Alpha/ })).toBeVisible(); + await expect(page.getByRole('heading', { level: 3, name: /E2E Task Alpha/ })).toBeVisible(); + await expect(page.getByText('Ports 22, 80 open')).toBeVisible(); + // The contents links are slugs of the rendered headings; a mismatch scrolls nowhere. + await expect(page.getByRole('link', { name: /E2E Subtask Scan/ })).toHaveAttribute('href', /#.+/); + + expectCleanPage(pageErrorLog); + }); + + test('"Open web view" opens the report in its own tab', async ({ page, pageErrorLog }) => { + await openReportMenu(page); + + const popup = page.waitForEvent('popup'); + + await page.getByRole('menuitem', { name: 'Open web view' }).click(); + + const opened = await popup; + + expect(new URL(opened.url()).pathname).toBe('/flows/5/report'); + // This tab is mocked only because the fixture re-arms the route on every page the context + // opens; without that it talks to the preview proxy and the report never arrives. + await expect(opened.getByRole('heading', { name: /E2E Alpha/ })).toBeVisible(); + await opened.close(); + expectCleanPage(pageErrorLog); + }); + + test('"Download PDF" opens the silent print route', async ({ page, pageErrorLog }) => { + await openReportMenu(page); + + const popup = page.waitForEvent('popup'); + + await page.getByRole('menuitem', { name: 'Download PDF' }).click(); + + const opened = await popup; + const url = new URL(opened.url()); + + expect(url.pathname).toBe('/flows/5/report'); + // `download` starts the PDF and `silent` closes the tab; without it the export looks hung. + expect(url.searchParams.get('download')).toBe('true'); + expect(url.searchParams.get('silent')).toBe('true'); + await opened.close(); + expectCleanPage(pageErrorLog); + }); + + test('"Download MD" writes the report to a named file', async ({ page, pageErrorLog }) => { + await openReportMenu(page); + + const download = page.waitForEvent('download'); + + await page.getByRole('menuitem', { name: 'Download MD' }).click(); + + const file = await download; + + expect(file.suggestedFilename()).toMatch(REPORT_FILE); + + const stream = await file.createReadStream(); + const body = (await stream.toArray()).join(''); + + expect(body).toContain('E2E Task Alpha'); + expect(body).toContain('Ports 22, 80 open'); + expectCleanPage(pageErrorLog); + }); + + test('"Copy to clipboard" puts the same report on the clipboard', async ({ context, page, pageErrorLog }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + await openReportMenu(page); + await page.getByRole('menuitem', { name: 'Copy to clipboard' }).click(); + + await expect(page.getByText('Report copied to clipboard')).toBeVisible(); + expect(await page.evaluate(() => navigator.clipboard.readText())).toContain('E2E Task Alpha'); + expectCleanPage(pageErrorLog); + }); + + test('the print route writes a real PDF, not just a file with the right name', async ({ page, pageErrorLog }) => { + const download = page.waitForEvent('download'); + + await page.goto('/flows/5/report?download=true'); + + const file = await download; + + expect(file.suggestedFilename()).toMatch(REPORT_PDF); + + const stream = await file.createReadStream(); + const chunks: Buffer[] = []; + + for await (const chunk of stream) { + chunks.push(Buffer.from(chunk)); + } + + const shape = inspectPdf(Buffer.concat(chunks)); + + // A generator that silently produced a blank document, or an error page saved under a .pdf + // name, passes a filename check and fails every line of this one. + expect(shape.isPdf && shape.trailerComplete, 'a complete PDF document').toBe(true); + expect(shape.pages, 'the report paginated').toBeGreaterThan(0); + // The seeded report is small; the threshold only has to be above what a title-only or blank + // document produces, which is what a silently failing generator emits. + expect(shape.textRuns, 'glyphs were actually drawn').toBeGreaterThan(5); + expect(shape.fonts, 'a font subset was embedded').toBeGreaterThan(0); + expectCleanPage(pageErrorLog); + }); +}); diff --git a/frontend/e2e/specs/flows/subscriptions.spec.ts b/frontend/e2e/specs/flows/subscriptions.spec.ts index 79457b82..723d5633 100644 --- a/frontend/e2e/specs/flows/subscriptions.spec.ts +++ b/frontend/e2e/specs/flows/subscriptions.spec.ts @@ -5,15 +5,19 @@ import { assertNoDuplicates, attachIdSet, extractMessageIds, - MESSAGE_ID_TESTID, + MESSAGE_ID_SELECTOR, } from '../../helpers/subscriptions.ts'; import { FLOW_A_INITIAL_IDS, FLOW_A_STREAMED_IDS, FLOW_B_INITIAL_IDS, + FLOW_B_SENTINEL_ID, FLOW_B_STREAMED_IDS, flowsCassette, + PAGER_SWITCH_FLAG, + pagerStreamsCassette, } from '../../mocks/cassettes/flows.ts'; +import { subscriptionStreamKey } from '../../mocks/world.ts'; const FLOW_A_IDS = [...FLOW_A_INITIAL_IDS, ...FLOW_A_STREAMED_IDS]; const FLOW_B_IDS = [...FLOW_B_INITIAL_IDS, ...FLOW_B_STREAMED_IDS]; @@ -25,7 +29,7 @@ test.describe('flow subscriptions', { tag: ['@flows', '@smoke'] }, () => { await page.goto('/flows'); await page.getByRole('row', { name: /E2E Alpha/ }).click(); - await expect(page.getByTestId(MESSAGE_ID_TESTID)).toHaveCount(FLOW_A_IDS.length); + await expect(page.locator(MESSAGE_ID_SELECTOR)).toHaveCount(FLOW_A_IDS.length); const ids = await extractMessageIds(page.locator('body')); @@ -38,13 +42,13 @@ test.describe('flow subscriptions', { tag: ['@flows', '@smoke'] }, () => { test('keeps concurrent flows exact-set disjoint across a round trip', async ({ page, pageErrorLog }, testInfo) => { await page.goto('/flows'); await page.getByRole('row', { name: /E2E Alpha/ }).click(); - await expect(page.getByTestId(MESSAGE_ID_TESTID)).toHaveCount(FLOW_A_IDS.length); + await expect(page.locator(MESSAGE_ID_SELECTOR)).toHaveCount(FLOW_A_IDS.length); const idsA = await extractMessageIds(page.locator('body')); await page.goBack(); await page.getByRole('row', { name: /E2E Beta/ }).click(); - await expect(page.getByTestId(MESSAGE_ID_TESTID)).toHaveCount(FLOW_B_IDS.length); + await expect(page.locator(MESSAGE_ID_SELECTOR)).toHaveCount(FLOW_B_IDS.length); const idsB = await extractMessageIds(page.locator('body')); @@ -55,7 +59,7 @@ test.describe('flow subscriptions', { tag: ['@flows', '@smoke'] }, () => { await page.goBack(); await page.getByRole('row', { name: /E2E Alpha/ }).click(); - await expect(page.getByTestId(MESSAGE_ID_TESTID)).toHaveCount(FLOW_A_IDS.length); + await expect(page.locator(MESSAGE_ID_SELECTOR)).toHaveCount(FLOW_A_IDS.length); const idsAgain = await extractMessageIds(page.locator('body')); @@ -63,3 +67,47 @@ test.describe('flow subscriptions', { tag: ['@flows', '@smoke'] }, () => { expectCleanPage(pageErrorLog); }); }); + +/** + * Leaving via `/flows` unmounts FlowProvider (app.tsx wraps only `flows/:flowId`), so stream A is + * already closed there. Only the pager keeps the provider mounted and merely swaps the subscription + * variables — the one path where a superseded stream can still write into the flow on screen. + */ +test.describe('flow subscriptions across a pager switch', { tag: '@flows' }, () => { + test.use({ cassette: pagerStreamsCassette() }); + + test('drops the stream it switched away from', async ({ page, pageErrorLog, world }, testInfo) => { + const streamA = subscriptionStreamKey('messageLogAdded', { flowId: '5' }); + const streamB = subscriptionStreamKey('messageLogAdded', { flowId: '6' }); + + await page.goto('/flows/5'); + await expect(page.locator(MESSAGE_ID_SELECTOR)).toHaveCount(FLOW_A_IDS.length); + expect(world.subscriberCount(streamA), 'flow A must be streaming before the switch').toBe(1); + + const idsA = await extractMessageIds(page.locator('body')); + + await page.locator('header').getByRole('button', { name: 'Next' }).click(); + await expect(page).toHaveURL(/\/flows\/6$/); + await expect(page.locator(MESSAGE_ID_SELECTOR)).toHaveCount(FLOW_B_IDS.length); + + // The DOM cannot witness this leak: `messageLogs` is keyed by flowId, so a frame carrying + // {flowId:'5'} is written to flow A's cache slot and never rendered under flow 6 however long + // the stale stream stays open. The live subscriber set is the only place it shows. + await expect.poll(() => world.subscriberCount(streamA), 'the superseded stream must be torn down').toBe(0); + expect(world.subscriberCount(streamB), 'the switched-to flow must be streaming').toBe(1); + + world.raiseFlag(PAGER_SWITCH_FLAG); + + await expect(page.locator(MESSAGE_ID_SELECTOR).filter({ hasText: FLOW_B_SENTINEL_ID })).toHaveCount(1); + + const idsB = await extractMessageIds(page.locator('body')); + + expect([...idsB].sort(), 'the switched-to flow renders exactly its own stream').toEqual( + [...FLOW_B_IDS, FLOW_B_SENTINEL_ID].sort(), + ); + assertNoDuplicates(idsB); + assertDisjoint(idsA, idsB); + await attachIdSet(testInfo, 'pager-flow-6-message-ids', idsB); + expectCleanPage(pageErrorLog); + }); +}); diff --git a/frontend/e2e/specs/flows/tab-deeplink.spec.ts b/frontend/e2e/specs/flows/tab-deeplink.spec.ts new file mode 100644 index 00000000..3e0d4c22 --- /dev/null +++ b/frontend/e2e/specs/flows/tab-deeplink.spec.ts @@ -0,0 +1,70 @@ +import type { Page } from '@playwright/test'; + +import { expect, test } from '../../fixtures/test.ts'; +import { expectCleanPage } from '../../helpers/errors.ts'; +import { FLOW_A, flowQueryData, flowsCassette } from '../../mocks/cassettes/flows.ts'; + +/** A flow whose message list stays empty, so the auto-detect resolves to Assistant. */ +const emptyFlowCassette = () => + flowsCassette({ + queries: { flow: [{ data: flowQueryData(FLOW_A, []), variables: { id: '5' } }] }, + subscriptions: { messageLogAdded: [{ frames: [], variables: { flowId: '5' } }] }, + }); + +const expectSelectedTab = async (page: Page, name: string) => { + await expect(page.getByRole('tab', { name })).toHaveAttribute('aria-selected', 'true'); +}; + +test.describe('flow tab deep link', { tag: '@flows' }, () => { + test.describe('over a populated flow', () => { + test.use({ cassette: flowsCassette() }); + + test('?tab=assistant beats the auto-detect that would have shown Automation', async ({ + page, + pageErrorLog, + }) => { + await page.goto('/flows/5?tab=assistant'); + await expectSelectedTab(page, 'Assistant'); + await expect(page.getByText('New assistant', { exact: true })).toBeVisible(); + + expectCleanPage(pageErrorLog); + }); + }); + + test.describe('over a flow with no messages', () => { + test.use({ cassette: emptyFlowCassette() }); + + test('?tab=automation beats the auto-detect that would have shown Assistant', async ({ + page, + pageErrorLog, + }) => { + await page.goto('/flows/5?tab=automation'); + await expectSelectedTab(page, 'Automation'); + await expect(page.getByRole('tabpanel', { name: 'Automation' })).toBeVisible(); + + expectCleanPage(pageErrorLog); + }); + + test('a tab click records itself in the URL', async ({ page, pageErrorLog }) => { + await page.goto('/flows/5'); + await expectSelectedTab(page, 'Assistant'); + + await page.getByRole('tab', { name: 'Automation' }).click(); + + await expect(page).toHaveURL(/[?&]tab=automation/); + await expectSelectedTab(page, 'Automation'); + + expectCleanPage(pageErrorLog); + }); + + test('an unknown tab falls back to the auto-detect', async ({ page, pageErrorLog }) => { + await page.goto('/flows/5?tab=not-a-tab'); + await expectSelectedTab(page, 'Assistant'); + // The bogus value is left alone rather than rewritten, so a shared link keeps working + // once the tab it names exists again. + await expect(page).toHaveURL(/[?&]tab=not-a-tab/); + + expectCleanPage(pageErrorLog); + }); + }); +}); diff --git a/frontend/e2e/specs/flows/tabs.spec.ts b/frontend/e2e/specs/flows/tabs.spec.ts index 216d75b3..0adbb7d5 100644 --- a/frontend/e2e/specs/flows/tabs.spec.ts +++ b/frontend/e2e/specs/flows/tabs.spec.ts @@ -1,20 +1,7 @@ import { expect, test } from '../../fixtures/test.ts'; import { expectCleanPage } from '../../helpers/errors.ts'; -import { - flowTabsCassette, - TABS_FILE_NAME, - TABS_SCREENSHOT_NAME, - TABS_SCREENSHOT_URL, -} from '../../mocks/cassettes/flows.ts'; - -const TABS = [ - { marker: 'E2E Task Alpha', name: 'Tasks' }, - { marker: 'E2E agent reconnaissance', name: 'Agents' }, - { marker: 'E2E search for the CVE', name: 'Searches' }, - { marker: 'E2E recall prior findings', name: 'Vector Store' }, - { marker: TABS_FILE_NAME, name: 'Files' }, - { marker: TABS_SCREENSHOT_URL, name: 'Screenshots' }, -] as const; +import { flowTabsCassette, TABS_SCREENSHOT_NAME } from '../../mocks/cassettes/flows.ts'; +import { FLOW_DETAIL_TABS } from '../../routes.ts'; test.describe('flow detail tabs', { tag: '@flows' }, () => { test.use({ cassette: flowTabsCassette() }); @@ -23,9 +10,12 @@ test.describe('flow detail tabs', { tag: '@flows' }, () => { await page.goto('/flows/5'); await expect(page.locator('header').getByRole('button', { name: 'Toggle favorite' })).toBeEnabled(); - for (const { marker, name } of TABS) { + for (const { name, ready } of FLOW_DETAIL_TABS) { + // Pins that the click is what reveals the panel. Ordering this sweep so a tab is already + // open when its turn comes (the flow auto-opens Assistant) makes its iteration a no-op. + await expect(ready(page), `"${name}" must not be on screen before its own click`).not.toBeAttached(); await page.getByRole('tab', { name }).click(); - await expect(page.getByText(marker)).toBeVisible(); + await expect(ready(page)).toBeVisible(); } expectCleanPage(pageErrorLog); diff --git a/frontend/e2e/specs/real/account-password.spec.ts b/frontend/e2e/specs/real/account-password.spec.ts new file mode 100644 index 00000000..7d2df616 --- /dev/null +++ b/frontend/e2e/specs/real/account-password.spec.ts @@ -0,0 +1,135 @@ +import type { APIRequestContext } from '@playwright/test'; + +import { request as apiRequest, expect, test } from '@playwright/test'; + +const INITIAL_PASSWORD = 'InitPass1!e2e'; +const MAX_BYTES = 72; +const AT_LIMIT = 'a'.repeat(MAX_BYTES); +const OVER_LIMIT = 'a'.repeat(MAX_BYTES + 1); +const MULTIBYTE_OVER_LIMIT = 'é'.repeat(40); + +const changePassword = ( + api: APIRequestContext, + body: { confirm_password?: string; current_password: string; password: string }, +) => api.put('/api/v1/user/password', { data: { confirm_password: body.password, ...body } }); + +const login = (api: APIRequestContext, mail: string, password: string) => + api.post('/api/v1/auth/login', { data: { mail, password } }); + +const ADMIN_USER = process.env.E2E_USER ?? 'admin@pentagi.com'; +const ADMIN_PASSWORD = process.env.E2E_PASSWORD ?? 'admin'; + +test.describe('account password at the trust boundary', { tag: '@real' }, () => { + // Deliberately bypasses the form: a client-side guard is one DevTools click from gone, so the + // matrix has to reach the endpoint the way an attacker would. + test('enforces the password policy on the endpoint, not just in the form', async ({ baseURL }) => { + const admin = await apiRequest.newContext({ baseURL, ignoreHTTPSErrors: true }); + const victim = await apiRequest.newContext({ baseURL, ignoreHTTPSErrors: true }); + + const adminLogin = await login(admin, ADMIN_USER, ADMIN_PASSWORD); + + expect(adminLogin.status(), 'seeded admin credentials').toBe(200); + + const mail = `e2e-pwd-${Date.now()}@pentagi.com`; + const created = await admin.post('/api/v1/users/', { + data: { + mail, + name: `e2epwd${Date.now()}`, + password: INITIAL_PASSWORD, + role_id: 2, + status: 'active', + type: 'local', + }, + }); + + expect(created.status(), 'throwaway user created').toBe(201); + + const hash = (await created.json()).data.hash as string; + + try { + const tooLongAtCreate = await admin.post('/api/v1/users/', { + data: { + mail: `e2e-long-${Date.now()}@pentagi.com`, + name: `e2elong${Date.now()}`, + password: OVER_LIMIT, + role_id: 2, + status: 'active', + type: 'local', + }, + }); + + expect(tooLongAtCreate.status(), 'over-length password at user creation').toBe(400); + + expect((await login(victim, mail, INITIAL_PASSWORD)).status()).toBe(200); + + for (const [name, body, wantStatus, wantCode] of [ + [ + 'wrong current password', + { current_password: 'NotTheOne1!', password: 'NewPass1!abc' }, + 403, + 'Users.ChangePasswordCurrentUser.InvalidCurrentPassword', + ], + [ + 'confirmation mismatch', + { + confirm_password: 'NewPass1!abcd', + current_password: INITIAL_PASSWORD, + password: 'NewPass1!abc', + }, + 400, + 'Users.ChangePasswordCurrentUser.InvalidPassword', + ], + [ + 'seven characters with every class', + { current_password: INITIAL_PASSWORD, password: 'Ab1$cde' }, + 400, + 'Users.ChangePasswordCurrentUser.InvalidPassword', + ], + [ + 'fifteen characters without a special', + { current_password: INITIAL_PASSWORD, password: 'Abcdefghij12345' }, + 400, + 'Users.ChangePasswordCurrentUser.InvalidPassword', + ], + [ + 'one byte over the bcrypt limit', + { current_password: INITIAL_PASSWORD, password: OVER_LIMIT }, + 400, + 'Users.ChangePasswordCurrentUser.InvalidPassword', + ], + [ + 'within the rune count but over the byte limit', + { current_password: INITIAL_PASSWORD, password: MULTIBYTE_OVER_LIMIT }, + 400, + 'Users.ChangePasswordCurrentUser.InvalidPassword', + ], + ] as const) { + const response = await changePassword(victim, body); + + expect([name, response.status(), (await response.json()).code]).toEqual([name, wantStatus, wantCode]); + } + + expect((await login(victim, mail, INITIAL_PASSWORD)).status(), 'no rejected row was saved').toBe(200); + + expect( + ( + await changePassword(victim, { current_password: INITIAL_PASSWORD, password: 'Abcdefghij123$4' }) + ).status(), + 'fifteen characters with every class is the short-branch maximum', + ).toBe(200); + expect( + (await changePassword(victim, { current_password: 'Abcdefghij123$4', password: AT_LIMIT })).status(), + 'exactly the bcrypt limit is accepted', + ).toBe(200); + + expect((await login(victim, mail, AT_LIMIT)).status(), 'the accepted password authenticates').toBe(200); + expect((await login(victim, mail, INITIAL_PASSWORD)).status(), 'the replaced password does not').not.toBe( + 200, + ); + } finally { + await admin.delete(`/api/v1/users/${hash}`); + await admin.dispose(); + await victim.dispose(); + } + }); +}); diff --git a/frontend/e2e/specs/real/flow-run.spec.ts b/frontend/e2e/specs/real/flow-run.spec.ts index 3f4218c8..ad260c76 100644 --- a/frontend/e2e/specs/real/flow-run.spec.ts +++ b/frontend/e2e/specs/real/flow-run.spec.ts @@ -1,10 +1,13 @@ import { expect, test } from '@playwright/test'; +import { MESSAGE_ID_SELECTOR } from '../../helpers/subscriptions.ts'; import { readTerminalBuffer } from '../../helpers/terminal.ts'; test.describe('real backend flow run', { tag: '@real' }, () => { test('runs a flow end-to-end through the mock LLM', async ({ page }) => { - test.setTimeout(240_000); + // Above the sum of the step timeouts below (60+30+90+90+90+60 = 420s), so a slow-but-passing + // run is not killed mid-step with a generic timeout that masks the real state. + test.setTimeout(450_000); const pageErrors: string[] = []; @@ -12,7 +15,13 @@ test.describe('real backend flow run', { tag: '@real' }, () => { await page.goto('/flows/new'); await page.getByPlaceholder(/Describe what you would like PentAGI to test/).fill('Say hello'); - await page.getByRole('button', { name: 'Submit' }).click(); + + // The form is invalid until the providers query lands, so a cold stack keeps Submit + // disabled for a while — clicking straight away spends the whole test timeout on it. + const submit = page.getByRole('button', { name: 'Submit' }); + + await expect(submit).toBeEnabled({ timeout: 60_000 }); + await submit.click(); await expect(page).toHaveURL(/\/flows\/\d+/, { timeout: 30_000 }); // Scope the list assertion to this attempt's flow id: a retry runs @@ -21,7 +30,7 @@ test.describe('real backend flow run', { tag: '@real' }, () => { // violation. const flowId = new URL(page.url()).pathname.match(/\d+/)?.[0] ?? ''; - await expect(page.getByTestId('flow-message-id').first()).toBeVisible({ timeout: 90_000 }); + await expect(page.locator(MESSAGE_ID_SELECTOR).first()).toBeVisible({ timeout: 90_000 }); await expect(page.getByText('Hello from the e2e mock LLM!').first()).toBeVisible({ timeout: 90_000 }); // The scenario's `uname -a` exec must stream its sandbox output back diff --git a/frontend/e2e/specs/real/resources-download.spec.ts b/frontend/e2e/specs/real/resources-download.spec.ts new file mode 100644 index 00000000..ada44d91 --- /dev/null +++ b/frontend/e2e/specs/real/resources-download.spec.ts @@ -0,0 +1,67 @@ +import { expect, test } from '@playwright/test'; + +const CONTENT = 'e2e download payload\nsecond line\n'; +const ZIP_MAGIC = 'PK'; +const END_OF_CENTRAL_DIRECTORY = Buffer.from([0x50, 0x4b, 0x05, 0x06]); +const CENTRAL_DIRECTORY_ENTRY = 0x02014b50; + +/** Entry names straight out of the central directory — an empty archive is a valid zip too. */ +const zipEntryNames = (archive: Buffer): string[] => { + const end = archive.lastIndexOf(END_OF_CENTRAL_DIRECTORY); + + if (end === -1) { + throw new Error(`not a zip archive: ${archive.subarray(0, 16).toString('hex')}`); + } + + const names: string[] = []; + let cursor = archive.readUInt32LE(end + 16); + + for (let left = archive.readUInt16LE(end + 10); left > 0; left -= 1) { + expect(archive.readUInt32LE(cursor), 'central directory entry header').toBe(CENTRAL_DIRECTORY_ENTRY); + + const nameLength = archive.readUInt16LE(cursor + 28); + + names.push(archive.subarray(cursor + 46, cursor + 46 + nameLength).toString()); + cursor += 46 + nameLength + archive.readUInt16LE(cursor + 30) + archive.readUInt16LE(cursor + 32); + } + + return names; +}; + +test.describe('resources download at the endpoint', { tag: '@real' }, () => { + // The browser performs an `` transfer outside the page context, so the mock tier can + // only see the URL it asked for. Whether those bytes come back is decided here. + test('returns the stored bytes, and a zip when several paths are asked for', async ({ request }) => { + const stamp = Date.now(); + const name = `e2e-download-${stamp}.txt`; + const second = `e2e-download-${stamp}-b.txt`; + + for (const file of [name, second]) { + const upload = await request.post('/api/v1/resources/', { + multipart: { files: { buffer: Buffer.from(CONTENT), mimeType: 'text/plain', name: file } }, + }); + + expect(upload.status(), `seed ${file}`).toBe(200); + } + + try { + const single = await request.get('/api/v1/resources/download', { params: { 'paths[]': name } }); + + expect(single.status()).toBe(200); + expect(await single.text(), 'the file comes back byte for byte').toBe(CONTENT); + + // Several paths cannot be one file, so the server has to archive them; a plain 200 with the + // first file's bytes would look like success to any caller that only checks the status. + const archive = await request.get(`/api/v1/resources/download?paths[]=${name}&paths[]=${second}`); + + expect(archive.status()).toBe(200); + + const body = await archive.body(); + + expect(body.subarray(0, 2).toString(), 'a zip, not a bare file').toBe(ZIP_MAGIC); + expect(zipEntryNames(body).sort(), 'both files are inside it').toEqual([name, second].sort()); + } finally { + await request.delete(`/api/v1/resources/?paths[]=${name}&paths[]=${second}`); + } + }); +}); diff --git a/frontend/e2e/specs/real/resources-upload-limits.spec.ts b/frontend/e2e/specs/real/resources-upload-limits.spec.ts new file mode 100644 index 00000000..86e9b82f --- /dev/null +++ b/frontend/e2e/specs/real/resources-upload-limits.spec.ts @@ -0,0 +1,116 @@ +import type { APIRequestContext } from '@playwright/test'; + +import { expect, test } from '@playwright/test'; + +const BOUNDARY = 'e2e-upload-boundary'; + +/** Builds the multipart body by hand: the point is to send what the client refuses to build. */ +const multipart = (files: Array<{ body: string; name: string }>): Buffer => { + const parts = files + .map( + ({ body, name }) => + `--${BOUNDARY}\r\nContent-Disposition: form-data; name="files"; filename="${name}"\r\n` + + `Content-Type: application/octet-stream\r\n\r\n${body}\r\n`, + ) + .join(''); + + return Buffer.from(`${parts}--${BOUNDARY}--\r\n`); +}; + +const upload = (request: APIRequestContext, files: Array<{ body: string; name: string }>) => + request.post('/api/v1/resources/', { + data: multipart(files), + headers: { 'content-type': `multipart/form-data; boundary=${BOUNDARY}` }, + }); + +test.describe('resources upload limits at the endpoint', { tag: '@real' }, () => { + // The browser-side guard is one DevTools click from gone, so what matters is the set of rules the + // endpoint itself keeps — a smaller set than the client's, as the empty-file case below records. + // The size caps (300 MB per file, 2 GB per batch) are left to the backend's own tests: proving + // them here means pushing gigabytes through the stand. + test('caps a batch at 1000 files and writes nothing from an over-cap batch', async ({ request }) => { + const stamp = Date.now(); + const many = Array.from({ length: 1001 }, (_, index) => ({ + body: 'x', + name: `e2e-many-${stamp}-${index}.txt`, + })); + + const tooMany = await upload(request, many); + + // 400, but not from the handler's own count check: Go's multipart reader caps a form at 1000 + // parts and answers `multipart: message too large` first (confirmed in the backend log), which + // makes `resources.MaxUploadFiles` unreachable for this endpoint. What this pins is the + // user-visible contract — 1001 is refused and nothing is written. + expect(tooMany.status(), 'a batch past 1000 files is refused').toBe(400); + + const afterReject = await request.get('/api/v1/resources/', { params: { recursive: 'true' } }); + const rejectedPaths = ((await afterReject.json()).data.items ?? []).map((item: { path: string }) => item.path); + + expect( + rejectedPaths.filter((path: string) => path.startsWith(`e2e-many-${stamp}`)), + 'a rejected batch writes nothing at all, not a partial prefix', + ).toEqual([]); + + // Exactly on the cap is the other half of the boundary: a server that rejected 1000 as well + // would look just as "safe" and be wrong. + const atCap = await upload(request, many.slice(0, 1000)); + + expect(atCap.status(), 'a batch exactly on the cap').toBe(200); + + // Repeated keys, not an object: `Object.fromEntries` keeps only the last of 1000 identical + // `paths[]` entries and would delete one file while leaving 999 on the stand. + const cleanup = new URLSearchParams(); + + many.slice(0, 1000).forEach(({ name }) => cleanup.append('paths[]', name)); + + const deleted = await request.delete(`/api/v1/resources/?${cleanup}`); + + expect(deleted.status(), 'the seeded batch is removed again').toBe(200); + }); + + // What licenses the client to send a 0-byte file at all: the endpoint stores it. A client-side + // refusal here would only have hidden an upload the API accepts. + test('stores an empty file rather than refusing it', async ({ request }) => { + const name = `e2e-hollow-${Date.now()}.txt`; + const accepted = await upload(request, [{ body: '', name }]); + + expect(accepted.status(), 'a 0-byte part is not a server-side error').toBe(200); + + const [stored] = (await accepted.json()).data.items ?? []; + + expect(stored?.path, 'it is created, not silently dropped').toBe(name); + expect(stored?.size, 'as a zero-byte resource').toBe(0); + + await request.delete('/api/v1/resources/', { params: { 'paths[]': name } }); + }); + + test('refuses a batch that carries no file part at all', async ({ request }) => { + const nothing = await upload(request, []); + + expect(nothing.status(), 'at least one file is required').toBe(400); + }); + + // The server sanitises rather than rejects, which is a legitimate choice — what must hold is that + // nothing lands outside the library. Asserting a 400 here would pin an implementation the server + // does not have; asserting the stored path is what actually protects the filesystem. + test('strips a traversal out of the file name instead of honouring it', async ({ request }) => { + const name = `e2e-escape-${Date.now()}.txt`; + const escaped = await upload(request, [{ body: 'owned', name: `../${name}` }]); + + expect(escaped.status(), 'the request is answered, not crashed').toBeLessThan(500); + + const listing = await request.get('/api/v1/resources/', { params: { recursive: 'true' } }); + const paths: string[] = ((await listing.json()).data.items ?? []).map((item: { path: string }) => item.path); + + expect( + paths.filter((path) => path.includes('..')), + 'no stored path may climb out', + ).toEqual([]); + expect( + paths.filter((path) => path.endsWith(name)), + 'it landed under the sanitised name', + ).toEqual([name]); + + await request.delete('/api/v1/resources/', { params: { 'paths[]': name } }); + }); +}); diff --git a/frontend/e2e/specs/real/stand-smoke.spec.ts b/frontend/e2e/specs/real/stand-smoke.spec.ts index e806e6f8..44989512 100644 --- a/frontend/e2e/specs/real/stand-smoke.spec.ts +++ b/frontend/e2e/specs/real/stand-smoke.spec.ts @@ -11,6 +11,10 @@ const ROUTES = [ { emptyTitle: 'No API tokens configured', path: '/settings/api-tokens', title: 'API Tokens' }, ]; +// The data assertion waits on a real backend query, so it must not run on the 5s default expect +// timeout — a healthy but slow stand would flake. The config overrides only the screenshot timeout. +const STAND_DATA_TIMEOUT = 30_000; + test.describe('stand smoke', { tag: '@stand' }, () => { for (const { emptyTitle, path, title } of ROUTES) { test(`renders ${path} without a page error`, async ({ page }) => { @@ -29,7 +33,9 @@ test.describe('stand smoke', { tag: '@stand' }, () => { // settings-sidebar at runtime, the breadcrumb title is a span. await expect(page.locator('span[aria-current="page"]')).toHaveText(title); // Either branch: a query error renders neither, and a stand may hold no rows. - await expect(page.locator('[data-slot="table"]').first().or(page.getByText(emptyTitle))).toBeVisible(); + await expect(page.locator('[data-slot="table"]').first().or(page.getByText(emptyTitle))).toBeVisible({ + timeout: STAND_DATA_TIMEOUT, + }); expect(pageErrors, `uncaught errors on ${path}`).toEqual([]); }); } diff --git a/frontend/e2e/specs/settings/account.spec.ts b/frontend/e2e/specs/settings/account.spec.ts new file mode 100644 index 00000000..c8aa9358 --- /dev/null +++ b/frontend/e2e/specs/settings/account.spec.ts @@ -0,0 +1,193 @@ +import type { Locator, Page } from '@playwright/test'; + +import type { Cassette } from '../../mocks/cassette.ts'; + +import { SEEDED_USER } from '../../fixtures/auth.ts'; +import { expect, test } from '../../fixtures/test.ts'; +import { expectCleanPage } from '../../helpers/errors.ts'; +import { baseQueries, baseRest } from '../../mocks/cassettes/base.ts'; + +const CURRENT_PASSWORD = 'CurrentPass1!'; +// The server-side half of this boundary lives in specs/real/account-password.spec.ts. +const MAX_LENGTH_PASSWORD = 'n'.repeat(72); + +const accountCassette = (rest: Cassette['rest'] = {}): Cassette => ({ + queries: baseQueries(), + rest: { ...baseRest(), ...rest }, +}); + +const section = (page: Page, title: string): Locator => + page.locator('[data-slot="card"]').filter({ has: page.locator('[data-slot="card-title"]', { hasText: title }) }); + +const openSection = async (page: Page, title: string): Promise => { + const card = section(page, title); + + await card.getByRole('button', { name: 'Change' }).click(); + + return card; +}; + +const fillPasswordForm = async (page: Page, next: string, confirm = next): Promise => { + await page.getByLabel('Current Password').fill(CURRENT_PASSWORD); + await page.getByLabel('New Password', { exact: true }).fill(next); + await page.getByLabel('Confirm New Password').fill(confirm); +}; + +const submitPasswordForm = (page: Page): Promise => page.getByRole('button', { name: 'Update Password' }).click(); + +test.describe('account settings', { tag: '@settings' }, () => { + test.describe('read', () => { + test.use({ cassette: accountCassette() }); + + test('renders the signed-in identity and every editable section', async ({ page, pageErrorLog }) => { + await page.goto('/settings/account'); + + await expect(page.getByRole('heading', { exact: true, name: SEEDED_USER.name })).toBeVisible(); + await expect(page.getByText('Member since January 2026')).toBeVisible(); + await expect(page.getByText('Local account')).toBeVisible(); + await expect(section(page, 'Display name').getByText(SEEDED_USER.name)).toBeVisible(); + await expect(section(page, 'Email address').getByText(SEEDED_USER.mail)).toBeVisible(); + await expect(section(page, 'Password').getByText('••••••••••••')).toBeVisible(); + + expectCleanPage(pageErrorLog); + }); + }); + + test.describe('password validation', () => { + test.use({ cassette: accountCassette() }); + + // The cassette deliberately holds no `PUT /user/password` entry: adding one to "fix" a + // failure here would destroy the oracle. + test('rejects each invalid password without touching the network', async ({ page, pageErrorLog }) => { + const attempts: string[] = []; + + page.on('request', (request) => { + if (request.method() === 'PUT' && new URL(request.url()).pathname === '/api/v1/user/password') { + attempts.push(request.url()); + } + }); + + await page.goto('/settings/account'); + await openSection(page, 'Password'); + + // Only the first row clicks: the submit disables itself once a submitted form is invalid, + // and every later row is then validated live on change. + await fillPasswordForm(page, 'NewPass1!abc', 'OtherPass1!abc'); + await submitPasswordForm(page); + await expect(page.getByText("Passwords don't match")).toBeVisible(); + + await fillPasswordForm(page, 'newpass1'); + await expect(page.getByText('Password must be either longer than 15 characters')).toBeVisible(); + + await fillPasswordForm(page, 'short1!'); + await expect(page.getByText('Password must be at least 8 characters')).toBeVisible(); + + await fillPasswordForm(page, `${MAX_LENGTH_PASSWORD}x`); + await expect(page.getByText('Password must not exceed 72 characters')).toBeVisible(); + + await fillPasswordForm(page, CURRENT_PASSWORD); + await expect(page.getByText('New password must be different from current password')).toBeVisible(); + + expect(attempts, 'no invalid password may reach the backend').toEqual([]); + expectCleanPage(pageErrorLog); + }); + }); + + test.describe('password submit', () => { + test.use({ + cassette: accountCassette({ + 'PUT /api/v1/user/password': [ + { + body: { data: {}, status: 'success' }, + bodySubset: { + confirm_password: MAX_LENGTH_PASSWORD, + current_password: CURRENT_PASSWORD, + password: MAX_LENGTH_PASSWORD, + }, + }, + ], + }), + }); + + test('sends the three snake_case fields and closes the section', async ({ page, pageErrorLog }) => { + await page.goto('/settings/account'); + await openSection(page, 'Password'); + await fillPasswordForm(page, MAX_LENGTH_PASSWORD); + await submitPasswordForm(page); + + await expect(page.getByText('Password successfully changed')).toBeVisible(); + await expect(section(page, 'Password').getByText('••••••••••••')).toBeVisible(); + + expectCleanPage(pageErrorLog); + }); + }); + + test.describe('password rejection', () => { + test.use({ + cassette: accountCassette({ + 'PUT /api/v1/user/password': [ + { + body: { + code: 'Users.ChangePasswordCurrentUser.InvalidCurrentPassword', + msg: 'invalid current password', + status: 'error', + }, + status: 403, + }, + ], + }), + }); + + test('translates the backend error code instead of showing the raw message', async ({ page, pageErrorLog }) => { + await page.goto('/settings/account'); + await openSection(page, 'Password'); + await fillPasswordForm(page, 'NewPass1!abc'); + await submitPasswordForm(page); + + await expect(page.getByText('Current password is incorrect')).toBeVisible(); + await expect(page.getByText('invalid current password')).toBeHidden(); + await expect(page.getByRole('button', { name: 'Update Password' })).toBeVisible(); + + expect(pageErrorLog.pageErrors, 'no uncaught page errors').toEqual([]); + expect( + pageErrorLog.consoleErrors.filter((text) => !text.includes('403')), + 'the rejected request is the only console error', + ).toEqual([]); + }); + }); + + test.describe('name and email', () => { + test.use({ + cassette: accountCassette({ + 'PUT /api/v1/user/email': [ + { + body: { data: {}, status: 'success' }, + bodySubset: { current_password: CURRENT_PASSWORD, mail: 'renamed@pentagi.com' }, + }, + ], + 'PUT /api/v1/user/name': [ + { body: { data: {}, status: 'success' }, bodySubset: { name: 'E2E renamed admin' } }, + ], + }), + }); + + test('sends the display name and the credential-guarded email change', async ({ page, pageErrorLog }) => { + await page.goto('/settings/account'); + + const nameCard = await openSection(page, 'Display name'); + + await nameCard.getByLabel('Display name').fill('E2E renamed admin'); + await nameCard.getByRole('button', { name: 'Update Name' }).click(); + await expect(page.getByText('Name successfully updated')).toBeVisible(); + + const emailCard = await openSection(page, 'Email address'); + + await emailCard.getByLabel('Current Password').fill(CURRENT_PASSWORD); + await emailCard.getByLabel('New Email').fill('renamed@pentagi.com'); + await emailCard.getByRole('button', { name: 'Update Email' }).click(); + await expect(page.getByText('Email successfully updated')).toBeVisible(); + + expectCleanPage(pageErrorLog); + }); + }); +}); diff --git a/frontend/e2e/specs/settings/prompt-detail.spec.ts b/frontend/e2e/specs/settings/prompt-detail.spec.ts index c61f6b1b..d26150a3 100644 --- a/frontend/e2e/specs/settings/prompt-detail.spec.ts +++ b/frontend/e2e/specs/settings/prompt-detail.spec.ts @@ -4,12 +4,34 @@ import { expect, test } from '../../fixtures/test.ts'; import { expectCleanPage } from '../../helpers/errors.ts'; import { PROMPT_DETAIL_AGENT, + PROMPT_OVERRIDE, + PROMPT_RESET_FLAG, promptDetailCassette, + promptOverrideCassette, RICH_PROMPT_TEMPLATE, } from '../../mocks/cassettes/settings-prompts.ts'; test.describe('settings prompt detail', { tag: '@coverage' }, () => { - test.use({ cassette: promptDetailCassette() }); + test.use({ + cassette: promptDetailCassette({ + mutations: { + createPrompt: [ + { + data: { + createPrompt: { + __typename: 'UserPrompt', + createdAt: '2026-01-15T12:00:00Z', + id: '77', + template: 'saved', + type: 'pentester', + updatedAt: '2026-01-15T12:00:00Z', + }, + } as never, + }, + ], + }, + }), + }); const EDITOR = 'System prompt template'; @@ -57,4 +79,136 @@ test.describe('settings prompt detail', { tag: '@coverage' }, () => { expect(raw).toContain('E2E-MARK'); expectCleanPage(pageErrorLog); }); + + // The editor is where a Go template's byte fidelity is decided, and Save is the only operation + // that can destroy one. Until now no spec let it reach the wire, so the whole form → mutation hop + // was unverified: the round-trip units cannot see it. + test('Save sends the edited template verbatim, atoms intact', async ({ page, pageErrorLog }) => { + await page.goto(`/settings/prompts/${PROMPT_DETAIL_AGENT}`); + + const editor = page.getByRole('textbox', { name: EDITOR }); + + await expect(editor.getByRole('heading', { name: 'Pentester' })).toBeVisible(); + + await editor.click(); + await page.keyboard.press('ControlOrMeta+End'); + await editor.pressSequentially(' E2E-SAVE-MARK'); + + const request = page.waitForRequest( + (candidate) => candidate.method() === 'POST' && candidate.postDataJSON()?.operationName === 'createPrompt', + ); + + await page.getByRole('button', { exact: true, name: 'Save' }).click(); + + const { variables } = (await request).postDataJSON(); + + expect(variables.template, 'the edit reached the wire').toContain('E2E-SAVE-MARK'); + + for (const atom of ['# Pentester', '{{.Target}}', '{{.Scope}}', '```bash', '| Scope | {{.Scope}} |']) { + expect(variables.template, `"${atom}" survived the save`).toContain(atom); + } + + expectCleanPage(pageErrorLog); + }); +}); + +test.describe('settings prompt detail, already overridden', { tag: '@coverage' }, () => { + const EDITOR = 'System prompt template'; + + test.describe('save', () => { + test.use({ + cassette: promptOverrideCassette({ + mutations: { + updatePrompt: [ + { + data: { + updatePrompt: { + __typename: 'UserPrompt', + createdAt: '2026-01-15T09:00:00Z', + id: PROMPT_OVERRIDE.id, + template: 'saved', + type: 'pentester', + updatedAt: '2026-01-15T12:00:00Z', + }, + } as never, + variables: { promptId: PROMPT_OVERRIDE.id }, + }, + ], + }, + }), + }); + + test('Save updates the existing override instead of creating another', async ({ page, pageErrorLog }) => { + await page.goto(`/settings/prompts/${PROMPT_DETAIL_AGENT}`); + + const editor = page.getByRole('textbox', { name: EDITOR }); + + await expect(editor.getByRole('heading', { name: 'Operator override' })).toBeVisible(); + + await editor.click(); + await page.keyboard.press('ControlOrMeta+End'); + await editor.pressSequentially(' E2E-UPDATE-MARK'); + + const request = page.waitForRequest( + (candidate) => + candidate.method() === 'POST' && candidate.postDataJSON()?.operationName === 'updatePrompt', + ); + + await page.getByRole('button', { exact: true, name: 'Save' }).click(); + + const { variables } = (await request).postDataJSON(); + + expect(variables.promptId, "the operator's own row is the one rewritten").toBe(PROMPT_OVERRIDE.id); + expect(variables.template).toContain('E2E-UPDATE-MARK'); + expect(variables.template, 'the override travels whole').toContain('# Operator override'); + expectCleanPage(pageErrorLog); + }); + }); + + test.describe('reset', () => { + test.use({ + cassette: promptOverrideCassette({ + mutations: { + deletePrompt: [ + { + data: { deletePrompt: 'success' } as never, + setFlag: PROMPT_RESET_FLAG, + variables: { promptId: PROMPT_OVERRIDE.id }, + }, + ], + }, + }), + }); + + test('Reset deletes the override and puts the default back in the editor', async ({ page, pageErrorLog }) => { + await page.goto(`/settings/prompts/${PROMPT_DETAIL_AGENT}`); + + const editor = page.getByRole('textbox', { name: EDITOR }); + + await expect(editor.getByRole('heading', { name: 'Operator override' })).toBeVisible(); + + await page.getByRole('button', { name: 'Prompt actions' }).click(); + await page.getByRole('menuitem', { name: 'Reset' }).click(); + + const request = page.waitForRequest( + (candidate) => + candidate.method() === 'POST' && candidate.postDataJSON()?.operationName === 'deletePrompt', + ); + + await page.getByRole('dialog').getByRole('button', { exact: true, name: 'Reset' }).click(); + + expect((await request).postDataJSON().variables).toEqual({ promptId: PROMPT_OVERRIDE.id }); + + // Wait for the dialog to go first: a role-based locator does not resolve while the modal + // holds the background out of the a11y tree, and Playwright reads "0 elements" as hidden — + // so asserting through the open dialog passes whatever the editor actually shows. + await expect(page.getByRole('dialog')).toBeHidden(); + + // Deleting the row is only half the operation: the editor has to fall back to the default, + // or the operator is left looking at text that no longer exists anywhere. + await expect(editor.getByRole('heading', { name: 'Pentester' })).toBeVisible(); + await expect(editor.getByRole('heading', { name: 'Operator override' })).toBeHidden(); + expectCleanPage(pageErrorLog); + }); + }); }); diff --git a/frontend/e2e/specs/settings/providers.spec.ts b/frontend/e2e/specs/settings/providers.spec.ts index 876b770f..5ab403ce 100644 --- a/frontend/e2e/specs/settings/providers.spec.ts +++ b/frontend/e2e/specs/settings/providers.spec.ts @@ -1,6 +1,24 @@ +import type { Page } from '@playwright/test'; + import { expect, test } from '../../fixtures/test.ts'; import { expectCleanPage } from '../../helpers/errors.ts'; -import { settingsProvidersCassette } from '../../mocks/cassettes/settings-providers.ts'; +import { + agentTestResult, + OTHER_PROVIDER, + OTHER_PROVIDER_ROW, + populatedSettingsProvidersCassette, + providersList, + providerTestResult, + renamedProviderRow, + SEEDED_PROVIDER, + seededProviderRow, + settingsProvidersCassette, +} from '../../mocks/cassettes/settings-providers.ts'; + +/** The per-agent Test control repeats its name for all 13 agents, so it is addressed through the + * accordion item that names the agent. */ +const agentRow = (page: Page, agentName: string) => + page.locator('[data-slot="accordion-item"]').filter({ hasText: agentName }); test.describe('settings providers', { tag: '@coverage' }, () => { test.use({ cassette: settingsProvidersCassette() }); @@ -27,3 +45,290 @@ test.describe('settings providers', { tag: '@coverage' }, () => { expectCleanPage(pageErrorLog); }); }); + +test.describe('settings providers create form', { tag: '@coverage' }, () => { + // Needs a cassette where the type is enabled: the create form bounces `?type=` for a disabled + // one, so the empty seed would just redirect to the list. + test.use({ cassette: populatedSettingsProvidersCassette() }); + + // A selected type seeds each agent's model from that type's catalog. The catalog must carry the + // shape the backend actually emits (a non-empty model list for every type, keyed or not); an + // empty catalog makes this whole path unreachable and any assertion on it vacuous. `?type=` is + // the URL the type picker produces, driven directly to avoid the dirty-form navigation blocker. + test('seeds the agent model from the selected type catalog', async ({ page, pageErrorLog }) => { + await page.goto('/settings/providers/new?type=anthropic'); + + await page.getByRole('button', { name: /^Adviser/ }).click(); + await page.getByRole('button', { name: 'Open model list' }).first().click(); + + // A catalog-only model (not the seeded agent default), so it can only appear if the type's + // model list reached the dropdown — the path an empty catalog leaves unreachable. + await expect(page.getByRole('option', { name: 'e2e-anthropic-model-mini' })).toBeVisible(); + + expectCleanPage(pageErrorLog); + }); +}); + +test.describe('settings providers write path', { tag: '@coverage' }, () => { + const CREATED_NAME = 'E2E created provider'; + + test.use({ + cassette: populatedSettingsProvidersCassette({ + mutations: { + createProvider: [ + { + data: { createProvider: { __typename: 'ResultType', result: 'success' } } as never, + variables: { name: CREATED_NAME, type: 'anthropic' }, + }, + ], + }, + }), + }); + + // The create form is seeded from the type's defaults and then serialises the whole agents map back. + // Fields the user never touches are exactly the ones that get dropped on the way to the wire — this + // asserts the payload, because the UI looks identical either way. + test('create sends every agent field it was seeded with, zero-ish values included', async ({ + page, + pageErrorLog, + }) => { + await page.goto('/settings/providers/new?type=anthropic'); + + await expect(page.getByLabel('Name', { exact: true })).toBeVisible(); + await page.getByLabel('Name', { exact: true }).fill(CREATED_NAME); + + const request = page.waitForRequest( + (candidate) => + candidate.method() === 'POST' && candidate.postDataJSON()?.operationName === 'createProvider', + ); + + await page.getByRole('button', { exact: true, name: 'Create' }).click(); + + const { variables } = (await request).postDataJSON(); + + expect(variables.name).toBe(CREATED_NAME); + expect(variables.type).toBe('anthropic'); + expect(Object.keys(variables.agents), 'every agent is serialised, not just the edited one').toHaveLength(13); + expect(variables.agents.simpleJson).toMatchObject({ + json: true, + maxTokens: 4096, + model: 'e2e-anthropic-model', + n: 1, + }); + expectCleanPage(pageErrorLog); + }); +}); + +test.describe('settings provider edit paths', { tag: '@coverage' }, () => { + const RENAMED = 'E2E renamed endpoint'; + + const editCassette = (override: Parameters[0] = {}) => + populatedSettingsProvidersCassette(override); + + const openProvider = async (page: Page) => { + await page.goto(`/settings/providers/${SEEDED_PROVIDER.id}`); + await expect(page.getByLabel('Name', { exact: true })).toHaveValue(SEEDED_PROVIDER.name); + }; + + const mutationRequest = (page: Page, operationName: string) => + page.waitForRequest( + (candidate) => candidate.method() === 'POST' && candidate.postDataJSON()?.operationName === operationName, + ); + + test.describe('update', () => { + test.use({ + cassette: editCassette({ + mutations: { + updateProvider: [ + { + data: { updateProvider: renamedProviderRow(RENAMED) } as never, + variables: { name: RENAMED, providerId: SEEDED_PROVIDER.id }, + }, + ], + }, + }), + }); + + test('Save carries the id of the provider being edited', async ({ page, pageErrorLog }) => { + await openProvider(page); + await page.getByLabel('Name', { exact: true }).fill(RENAMED); + + const request = mutationRequest(page, 'updateProvider'); + + await page.getByRole('button', { exact: true, name: 'Save' }).click(); + + const { variables } = (await request).postDataJSON(); + + expect(variables.providerId).toBe(SEEDED_PROVIDER.id); + expect(variables.name).toBe(RENAMED); + expect(Object.keys(variables.agents), 'the whole agents map is resubmitted').toHaveLength(13); + await expect(page, 'a saved provider returns to the list').toHaveURL(/\/settings\/providers$/); + expectCleanPage(pageErrorLog); + }); + }); + + test.describe('delete from the editor', () => { + test.use({ + cassette: editCassette({ + mutations: { + deleteProvider: [ + { + data: { deleteProvider: 'success' } as never, + variables: { providerId: SEEDED_PROVIDER.id }, + }, + ], + }, + }), + }); + + test('Delete confirms by name, sends the id, and returns to the list', async ({ page, pageErrorLog }) => { + await openProvider(page); + await page.getByRole('button', { name: 'Provider actions' }).click(); + await page.getByRole('menuitem', { name: 'Delete' }).click(); + + const dialog = page.getByRole('dialog'); + + // A confirm dialog wired to the wrong row shows the wrong name here instead of deleting silently. + await expect(dialog).toContainText(SEEDED_PROVIDER.name); + + const request = mutationRequest(page, 'deleteProvider'); + + await dialog.getByRole('button', { name: 'Delete' }).click(); + + expect((await request).postDataJSON().variables).toEqual({ providerId: SEEDED_PROVIDER.id }); + await expect(page).toHaveURL(/\/settings\/providers$/); + expectCleanPage(pageErrorLog); + }); + }); + + test.describe('delete from the list', () => { + test.use({ + cassette: editCassette({ + mutations: { + deleteProvider: [ + { + data: { deleteProvider: 'success' } as never, + setFlag: 'provider-deleted', + variables: { providerId: OTHER_PROVIDER.id }, + }, + ], + }, + queries: { + settingsProviders: [ + { data: providersList(seededProviderRow(), OTHER_PROVIDER_ROW()) }, + { data: providersList(seededProviderRow()), whenFlag: 'provider-deleted' }, + ], + }, + }), + }); + + test('the row menu deletes the row it belongs to', async ({ page, pageErrorLog }) => { + await page.goto('/settings/providers'); + + // The second row on purpose: a menu wired to a fixed index would still send the first + // row's id, and a spec that operated row one could not tell the two apart. + const row = page.getByRole('row', { name: new RegExp(OTHER_PROVIDER.name) }); + + await expect(row).toBeVisible(); + await row.hover(); + await row.getByRole('button', { name: 'Open menu' }).click(); + await page.getByRole('menuitem', { name: 'Delete' }).click(); + + const request = mutationRequest(page, 'deleteProvider'); + + await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click(); + + expect((await request).postDataJSON().variables).toEqual({ providerId: OTHER_PROVIDER.id }); + // The row goes only because the refetch answers without it, and the sibling proves the + // table itself survived — a vanished table would satisfy a bare toBeHidden() too. + await expect(row).toBeHidden(); + await expect(page.getByRole('row', { name: new RegExp(SEEDED_PROVIDER.name) })).toBeVisible(); + expectCleanPage(pageErrorLog); + }); + }); + + test.describe('test one agent', () => { + test.use({ + cassette: editCassette({ + mutations: { + testAgent: [ + { + data: { testAgent: agentTestResult() } as never, + variables: { agentType: 'adviser', type: SEEDED_PROVIDER.type }, + }, + ], + }, + }), + }); + + test("a single agent's Test sends that agent, its type, and shows the verdict", async ({ + page, + pageErrorLog, + }) => { + await openProvider(page); + + const request = mutationRequest(page, 'testAgent'); + + await agentRow(page, 'Adviser').getByRole('button', { exact: true, name: 'Test' }).click(); + + const { variables } = (await request).postDataJSON(); + + expect(variables.agentType, 'the enum the backend keys the agent by').toBe('adviser'); + expect(variables.type).toBe(SEEDED_PROVIDER.type); + expect(variables.agent, 'the edited agent config travels with the request').toMatchObject({ + json: true, + maxTokens: 4096, + model: 'e2e-model', + n: 1, + }); + + const dialog = page.getByRole('dialog'); + + // Only the agent that was tested appears — the dialog is keyed by the clicked agent, so a + // handler that tested the wrong one would name it here. + await expect(dialog.getByRole('heading', { name: 'Adviser 1/1 passed' })).toBeVisible(); + await expect(dialog.getByRole('heading', { name: /passed/ })).toHaveCount(1); + expectCleanPage(pageErrorLog); + }); + }); + + test.describe('test the whole provider', () => { + test.use({ + cassette: editCassette({ + mutations: { + testProvider: [ + { + data: { testProvider: providerTestResult() } as never, + variables: { type: SEEDED_PROVIDER.type }, + }, + ], + }, + }), + }); + + test('the header Test sends every agent and renders a verdict per agent', async ({ page, pageErrorLog }) => { + await openProvider(page); + + const request = mutationRequest(page, 'testProvider'); + + await page.locator('header').getByRole('button', { exact: true, name: 'Test' }).click(); + + const { variables } = (await request).postDataJSON(); + + expect(Object.keys(variables.agents), 'the whole map is tested, not one agent').toHaveLength(13); + expect(variables.type).toBe(SEEDED_PROVIDER.type); + + const dialog = page.getByRole('dialog'); + + await expect(dialog.getByRole('heading', { name: /passed/ }), 'one row per agent').toHaveCount(13); + await expect(dialog.getByRole('heading', { name: 'Pentester 0/1 passed' })).toBeVisible(); + + await dialog.getByRole('button', { name: 'Pentester 0/1 passed' }).click(); + + // The failing agent's own error must reach the operator; a dialog that renders only the + // verdict leaves them with nothing to act on. + await expect(dialog.getByText('e2e simulated failure')).toBeVisible(); + expectCleanPage(pageErrorLog); + }); + }); +}); diff --git a/frontend/e2e/specs/smoke.spec.ts b/frontend/e2e/specs/smoke.spec.ts index fba878c0..a9f4d616 100644 --- a/frontend/e2e/specs/smoke.spec.ts +++ b/frontend/e2e/specs/smoke.spec.ts @@ -40,9 +40,13 @@ test.describe('smoke', { tag: '@smoke' }, () => { await expect(page.getByText('Login failed. Please try again.').first()).toBeVisible(); await expect(page).toHaveURL(/\/login/); - // react-hook-form leaves Submit disabled after a failed submit until - // an input changes. + // Disabled by FormSubmitButton's `requireValid` gate (`isSubmitted && !isValid`), not by + // the 401: the typed values are still schema-valid, and only a change recomputes validity. await expect(page.getByRole('button', { name: 'Sign in' })).toBeDisabled(); + + await page.getByRole('textbox', { name: 'Password' }).fill('another-password'); + + await expect(page.getByRole('button', { name: 'Sign in' })).toBeEnabled(); // The 401 logs an expected browser console error, but the path must raise no // uncaught JS exception / unhandled rejection. expect(pageErrorLog.pageErrors).toEqual([]); diff --git a/frontend/e2e/specs/visual/routes.spec.ts-snapshots/flows-5-dark-visual-linux.png b/frontend/e2e/specs/visual/routes.spec.ts-snapshots/flows-5-dark-visual-linux.png index 76d0ea0e6e2886f053372051e8415c9c743c40d6..0e712e38b13b23024d281f16c3df05fbeac6b8ea 100644 GIT binary patch literal 59943 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 literal 59926 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 z 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 VfTdBJ|CAg6bnokH72mUa@jv+YB|88B diff --git a/frontend/e2e/specs/visual/routes.spec.ts-snapshots/flows-5-light-visual-linux.png b/frontend/e2e/specs/visual/routes.spec.ts-snapshots/flows-5-light-visual-linux.png index 7709f9abc7d065c76e7eb85d52e915d4949aa3c6..f112ece88f2a19606c3f42311c41030780b40dab 100644 GIT binary patch literal 56786 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 M@HtxT{_~Z802<9dxBvhE literal 56794 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 O$N99oL#^HU8~*`KQ8cvx diff --git a/frontend/e2e/tools/review-sandbox.sh b/frontend/e2e/tools/review-sandbox.sh new file mode 100755 index 00000000..f8e02236 --- /dev/null +++ b/frontend/e2e/tools/review-sandbox.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +# A throwaway checkout for agents that need to MUTATE code to test something — inject a +# regression, delete a guard, probe a fixture. Review runs kept doing that in the live tree: +# probe specs left behind, tracked files edited, and once an `rm -rf` of a directory the run +# did not create. +# +# Usage: +# review-sandbox.sh create [--dirty] [--with-deps] # prints the sandbox path on stdout +# review-sandbox.sh clean # one sandbox, refuses anything else +# review-sandbox.sh clean --all # sweep sandboxes untouched for 2h +# +# --dirty carry the uncommitted working tree across, for a scope that includes it +# --with-deps hardlink node_modules in, for agents that run pnpm/playwright/vitest +# +# A git worktree carries no ignored files, so node_modules has to be supplied. It is hardlinked +# rather than symlinked because pnpm treats a symlinked node_modules as foreign and tries to +# purge and reinstall it — through the link, into the real tree. Hardlinks share data, so the +# copy is cheap, but a tool that rewrites a dependency IN PLACE (rather than the usual +# write-temp-then-rename) would still reach the original: don't hand --with-deps to an agent +# whose job is patching libraries. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +SANDBOX_ROOT="${PENTAGI_SANDBOX_ROOT:-${TMPDIR:-/tmp}}/pentagi-review-sandboxes" +# A hardlink cannot cross filesystems, so --with-deps is unusable when $TMPDIR is its own mount +# (tmpfs /tmp, a separate /home, a container volume): `cp -al` fails outright and no sandbox is +# built. This sibling of the repo is on the repo's filesystem by construction. +# Under the escape hatch BOTH roots move: `clean --all` sweeps this one too, so a caller that cannot +# afford a sweep of the repo's parent (the unit test, which otherwise rm -rf's real directories on the +# developer's machine) needs a single variable that contains every path this script may delete. +FALLBACK_ROOT="${PENTAGI_SANDBOX_ROOT:+${PENTAGI_SANDBOX_ROOT}/.pentagi-review-sandboxes}" +FALLBACK_ROOT="${FALLBACK_ROOT:-$(dirname "$REPO_ROOT")/.pentagi-review-sandboxes}" + +# Pick a root node_modules can actually be hardlinked into: probe with a real link, fall back to +# the repo's own filesystem when the probe hits EXDEV. +linkable_root() { + local probe="$SANDBOX_ROOT/.linkprobe" + + mkdir -p "$SANDBOX_ROOT" + + if ln "${BASH_SOURCE[0]}" "$probe" 2>/dev/null; then + rm -f "$probe" + echo "$SANDBOX_ROOT" + + return + fi + + if ! mkdir -p "$FALLBACK_ROOT" 2>/dev/null; then + echo "review-sandbox: $SANDBOX_ROOT is on another filesystem than the repo and" \ + "$FALLBACK_ROOT is not writable — set PENTAGI_SANDBOX_ROOT to a same-filesystem path" >&2 + exit 2 + fi + + echo "review-sandbox: hardlinks cannot reach $SANDBOX_ROOT — using $FALLBACK_ROOT" >&2 + echo "$FALLBACK_ROOT" +} + +create() { + local carry_dirty="" with_deps="" path root="$SANDBOX_ROOT" + + for arg in "$@"; do + case "$arg" in + --dirty) carry_dirty=1 ;; + --with-deps) with_deps=1 ;; + *) + echo "unknown option: $arg" >&2 + exit 2 + ;; + esac + done + + if [[ -n "$with_deps" ]]; then + root="$(linkable_root)" + fi + + path="$root/wt-$$-$(date +%s)" + mkdir -p "$root" + git -C "$REPO_ROOT" worktree add --detach --quiet "$path" HEAD + # Anything that fails from here leaves a worktree behind, and the caller reads stdout as the + # sandbox path — so take the worktree down and say so on stderr rather than printing a stub. + trap 'clean "$path" >/dev/null 2>&1; echo "review-sandbox: failed to prepare $path" >&2' ERR + + if [[ -n "$carry_dirty" ]]; then + # --binary or a regenerated PNG baseline aborts the patch ("cannot apply binary patch + # without full index line"). Untracked files are absent from the diff entirely, so a new + # spec would never reach the sandbox — copy them separately, minus the ignored ones. + if ! git -C "$REPO_ROOT" diff --quiet HEAD; then + git -C "$REPO_ROOT" diff HEAD --binary | git -C "$path" apply --allow-empty + fi + + while IFS= read -r -d '' file; do + mkdir -p "$path/$(dirname "$file")" + cp "$REPO_ROOT/$file" "$path/$file" + done < <(git -C "$REPO_ROOT" ls-files --others --exclude-standard -z) + fi + + if [[ -n "$with_deps" ]]; then + for dir in frontend/node_modules node_modules; do + [[ -d "$REPO_ROOT/$dir" ]] && cp -al "$REPO_ROOT/$dir" "$path/$dir" + done + fi + + echo "$path" +} + +# `git worktree remove` exits 128 for the main working tree and for any directory that is not a +# worktree, so the rm fallback is what runs for every wrong argument — and being left of `||` it +# does not trip `set -e`. Resolve the target and refuse anything outside the sandbox root before +# going near it. The sweep is opt-in and age-filtered for the same reason: this root is shared by +# every agent on the host, so a bare `clean` used to take out a concurrent agent's live sandbox. +STALE_MINUTES=120 + +# A worktree directory's own mtime moves only when a top-level entry is added or removed, so it dates +# the sandbox's creation — an agent editing frontend/src/... for hours never refreshes it. Age the +# sandbox by the newest write anywhere inside it instead. +recently_touched() { + [[ -n "$(find "$1" -mmin "-$STALE_MINUTES" -print -quit 2>/dev/null)" ]] +} + +# A strict child of either sandbox root — never a root itself, so a sibling like -old and the +# root's own path are both rejected. +contained() { + local candidate="$1" root + + for root in "$SANDBOX_ROOT" "$FALLBACK_ROOT"; do + mkdir -p "$root" + + case "$candidate" in + "$(cd "$root" && pwd -P)"/?*) return 0 ;; + esac + done + + return 1 +} + +clean() { + local target="${1:-}" root resolved + + case "$target" in + --all) + for root in "$SANDBOX_ROOT" "$FALLBACK_ROOT"; do + # The roots are derived, not passed in: naming them is how a caller — or a test — + # can tell where this is about to delete. + echo "swept $root" + for path in "$root"/wt-*; do + if [[ -d "$path" ]] && ! recently_touched "$path"; then + rm -rf "$path" + fi + done + done + ;; + '') + echo "review-sandbox: clean needs a sandbox path, or --all to sweep stale ones" >&2 + exit 2 + ;; + *) + resolved="$(cd "$(dirname "$target")" 2>/dev/null && pwd -P || true)/$(basename "$target")" + + case "$resolved" in + */. | */..) resolved="" ;; + *) contained "$resolved" || resolved="" ;; + esac + + if [[ -z "$resolved" ]]; then + echo "review-sandbox: refusing to clean '$target' — not a sandbox under" \ + "$SANDBOX_ROOT or $FALLBACK_ROOT" >&2 + exit 2 + fi + + git -C "$REPO_ROOT" worktree remove --force "$resolved" || rm -rf "$resolved" + ;; + esac + + git -C "$REPO_ROOT" worktree prune +} + +case "${1:-}" in + create) + shift + create "$@" + ;; + clean) clean "${2:-}" ;; + *) + echo "usage: $(basename "$0") create [--dirty] [--with-deps] | clean |--all" >&2 + exit 2 + ;; +esac diff --git a/frontend/e2e/tools/review-sandbox.unit.test.ts b/frontend/e2e/tools/review-sandbox.unit.test.ts new file mode 100644 index 00000000..7d81500a --- /dev/null +++ b/frontend/e2e/tools/review-sandbox.unit.test.ts @@ -0,0 +1,148 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +const SCRIPT = join(__dirname, 'review-sandbox.sh'); + +let sandboxRoot = ''; + +// TMPDIR alone does NOT contain this script: `clean --all` also sweeps a fallback root derived from +// the script's own location, so a bare `pnpm test` used to rm -rf real directories in the repo's +// parent. PENTAGI_SANDBOX_ROOT moves both roots, which is what keeps a run inside its own tree. +const run = (...args: string[]) => { + try { + const stdout = execFileSync(SCRIPT, args, { + encoding: 'utf8', + env: { ...process.env, PENTAGI_SANDBOX_ROOT: sandboxRoot, TMPDIR: sandboxRoot }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + return { code: 0, stdout: stdout.trim() }; + } catch (error) { + const failure = error as { status: number; stdout: string }; + + return { code: failure.status, stdout: failure.stdout.trim() }; + } +}; + +// -h, because the checkout contains symlinks and touch would otherwise age their targets while the +// links themselves keep a current mtime — which is what the sweep reads. +const backdate = (sandbox: string) => + execFileSync('find', [sandbox, '-exec', 'touch', '-h', '-t', '202001010000', '{}', '+']); + +beforeEach(() => { + sandboxRoot = mkdtempSync(join(tmpdir(), 'sandbox-test-')); +}); + +afterEach(() => { + rmSync(sandboxRoot, { force: true, recursive: true }); + // The worktrees a case created are registered in the real repo, so dropping the directory is + // only half of it — an unpruned registration would follow the developer around. + execFileSync('git', ['worktree', 'prune'], { cwd: join(__dirname, '..', '..') }); +}); + +describe('review-sandbox clean — containment', () => { + it('removes a sandbox it created', () => { + const { stdout: sandbox } = run('create'); + + expect(existsSync(sandbox)).toBe(true); + expect(run('clean', sandbox).code).toBe(0); + expect(existsSync(sandbox)).toBe(false); + }); + + it('refuses a path outside the sandbox root and leaves it intact', () => { + const outsider = mkdtempSync(join(tmpdir(), 'outsider-')); + + writeFileSync(join(outsider, 'keep-me'), 'payload'); + + expect(run('clean', outsider).code).not.toBe(0); + expect(existsSync(join(outsider, 'keep-me'))).toBe(true); + + rmSync(outsider, { force: true, recursive: true }); + }); + + it('refuses the sandbox root itself, and a sibling of it', () => { + const { stdout: sandbox } = run('create'); + const root = join(sandboxRoot, 'pentagi-review-sandboxes'); + + expect(run('clean', root).code).not.toBe(0); + expect(run('clean', `${root}-old`).code).not.toBe(0); + expect(existsSync(sandbox)).toBe(true); + }); + + it('refuses a traversal that lands outside the root', () => { + const { stdout: sandbox } = run('create'); + + expect(run('clean', `${sandbox}/../../..`).code).not.toBe(0); + expect(existsSync(sandbox)).toBe(true); + }); +}); + +describe('review-sandbox create — where the sandbox lands', () => { + // `clean --all` also sweeps a root derived from the script's own location, which no TMPDIR can + // move: before PENTAGI_SANDBOX_ROOT governed it too, running this very file deleted real stale + // sandboxes from the repo's parent directory on the developer's machine. + it('sweeps no root outside the one it was given', () => { + const swept = run('clean', '--all') + .stdout.split('\n') + .filter((line) => line.startsWith('swept ')) + .map((line) => line.slice('swept '.length)); + + expect(swept.length, 'clean --all names every root it sweeps').toBeGreaterThan(0); + + for (const root of swept) { + expect(root.startsWith(sandboxRoot), `${root} is outside ${sandboxRoot}`).toBe(true); + } + }); + + it('honours PENTAGI_SANDBOX_ROOT, the escape hatch for a $TMPDIR on another filesystem', () => { + const override = mkdtempSync(join(tmpdir(), 'override-root-')); + const sandbox = execFileSync(SCRIPT, ['create'], { + encoding: 'utf8', + env: { ...process.env, PENTAGI_SANDBOX_ROOT: override, TMPDIR: sandboxRoot }, + }).trim(); + + expect(sandbox.startsWith(`${override}/pentagi-review-sandboxes/`)).toBe(true); + + execFileSync(SCRIPT, ['clean', sandbox], { + env: { ...process.env, PENTAGI_SANDBOX_ROOT: override, TMPDIR: sandboxRoot }, + }); + expect(existsSync(sandbox)).toBe(false); + + rmSync(override, { force: true, recursive: true }); + }); +}); + +describe('review-sandbox clean — the sweep', () => { + it('refuses a bare clean rather than taking the whole root', () => { + const { stdout: sandbox } = run('create'); + + expect(run('clean').code).not.toBe(0); + expect(existsSync(sandbox)).toBe(true); + }); + + it('sweeps only what has gone stale', () => { + const { stdout: fresh } = run('create'); + + expect(run('clean', '--all').code).toBe(0); + expect(existsSync(fresh), 'a live sandbox survives the sweep').toBe(true); + + backdate(fresh); + expect(run('clean', '--all').code).toBe(0); + expect(existsSync(fresh), 'a stale sandbox is swept').toBe(false); + }); + + it('keeps a sandbox an agent is still working in, however deep the edit sits', () => { + const { stdout: busy } = run('create'); + + backdate(busy); + writeFileSync(join(busy, 'frontend', 'src', 'probe.tsx'), 'export const probe = 1;\n'); + + expect(run('clean', '--all').code).toBe(0); + expect(existsSync(busy), 'a live sandbox survives the sweep').toBe(true); + + expect(run('clean', busy).code).toBe(0); + }); +}); diff --git a/frontend/e2e/tools/run-local-tier.sh b/frontend/e2e/tools/run-local-tier.sh index 2e83c0f5..f46ffcb3 100755 --- a/frontend/e2e/tools/run-local-tier.sh +++ b/frontend/e2e/tools/run-local-tier.sh @@ -23,9 +23,8 @@ compose() { # sandboxes for any flow id starting with 9. remove_e2e_sandboxes() { docker ps -aq --filter 'name=^/?pentagi-terminal-9[0-9]{4,}$' | xargs -r docker rm -f - # Each sandbox also gets a `-data` volume, created on the socket - # outside the compose project — removing the container leaves it behind, so - # every Tier-2 run used to add one more dangling volume. + # Each sandbox also gets a `-data` volume, created on the socket outside the compose + # project — removing the container leaves it behind, so it needs its own cleanup. docker volume ls -q --filter 'name=^pentagi-terminal-9[0-9]{4,}-data$' | xargs -r docker volume rm } diff --git a/frontend/graphql-schema.graphql b/frontend/graphql-schema.graphql index 115c39e1..0547b3d0 100644 --- a/frontend/graphql-schema.graphql +++ b/frontend/graphql-schema.graphql @@ -321,6 +321,10 @@ fragment agentConfigFragment on AgentConfig { repetitionPenalty frequencyPenalty presencePenalty + minP + n + json + responseMimeType reasoning { mode effort diff --git a/frontend/src/components/shared/detail-navigation/use-navigation.ts b/frontend/src/components/shared/detail-navigation/use-navigation.ts index 67541ff3..3f7fddf7 100644 --- a/frontend/src/components/shared/detail-navigation/use-navigation.ts +++ b/frontend/src/components/shared/detail-navigation/use-navigation.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react'; -import { createTextMatcher } from './text-filter'; +import { createTextMatcher } from '@/lib/text-filter'; interface NavigationInput { currentId: null | string | undefined; diff --git a/frontend/src/components/shared/file-manager/file-manager.tsx b/frontend/src/components/shared/file-manager/file-manager.tsx index 895174f5..e576a066 100644 --- a/frontend/src/components/shared/file-manager/file-manager.tsx +++ b/frontend/src/components/shared/file-manager/file-manager.tsx @@ -365,7 +365,7 @@ export function FileManager({ return ( +

+ + Delete resource + This cannot be undone. + + + + ); +}; + +const DialogFromDropdown = () => { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + + Row actions + + setIsOpen(true)}>Delete + + + + + Delete resource + This cannot be undone. + + + + ); +}; + +describe('Dialog focus return', () => { + it('returns focus to the button that opened the dialog', async () => { + const user = userEvent.setup(); + render(); + + const opener = screen.getByRole('button', { name: 'Delete resource' }); + await user.click(opener); + await screen.findByRole('dialog'); + + await user.keyboard('{Escape}'); + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + expect(document.activeElement).toBe(opener); + }); + + it('returns focus to the menu trigger when the dialog was opened from a menu item', async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByRole('button', { name: 'Row actions' }); + await user.click(trigger); + await user.click(await screen.findByRole('menuitem', { name: 'Delete' })); + await screen.findByRole('dialog'); + + await user.keyboard('{Escape}'); + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + expect(document.activeElement).toBe(trigger); + }); +}); + +const DialogFromContextMenu = () => { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + + Row + + setIsOpen(true)}>Delete + + + + + Delete resource + This cannot be undone. + + + + ); +}; + +describe('Dialog opened from a context menu', () => { + it('returns focus to the context-menu trigger', async () => { + const user = userEvent.setup(); + + render(); + + const trigger = document.querySelector('[data-slot="context-menu-trigger"]')!; + + await user.pointer({ keys: '[MouseRight]', target: trigger }); + await user.click(await screen.findByRole('menuitem', { name: 'Delete' })); + await screen.findByRole('dialog'); + + await user.keyboard('{Escape}'); + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + expect(document.activeElement).toBe(trigger); + }); +}); diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index b0915fe5..6efcbab6 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -34,6 +34,10 @@ function DialogContent({ children, className, ...props }: React.ComponentProps + {/* Radix returns focus to its own DialogTrigger; these dialogs are opened from state, + and their content is often unmounted before Radix's close sequence runs, so its + `onCloseAutoFocus` never fires. */} + {children} () => { + if (opener?.isConnected) { + opener.focus(); + } + }, + [opener], + ); + + return null; +} + +function resolveOpener(): HTMLElement | null { + const active = document.activeElement as HTMLElement | null; + const menu = active?.closest('[role="menu"]'); + + if (!menu) { + return active; + } + + // Radix labels dropdown content with its trigger's id; context-menu content carries no such + // link, so the still-open trigger has to be found by slot. + const triggerId = menu.getAttribute('aria-labelledby'); + const trigger = triggerId + ? document.getElementById(triggerId) + : document.querySelector('[data-slot="context-menu-trigger"][data-state="open"]'); + + return trigger ?? active; +} + export { Dialog, DialogClose, @@ -128,4 +172,5 @@ export { DialogHeader, DialogTitle, DialogTrigger, + FocusReturn, }; diff --git a/frontend/src/components/ui/empty.tsx b/frontend/src/components/ui/empty.tsx index 67d75537..a714a549 100644 --- a/frontend/src/components/ui/empty.tsx +++ b/frontend/src/components/ui/empty.tsx @@ -54,7 +54,7 @@ function EmptyDescription({ className, ...props }: React.ComponentProps<'p'>) { return (
a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4', + 'text-muted-foreground [&>a:hover]:text-link text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4', className, )} data-slot="empty-description" diff --git a/frontend/src/components/ui/sheet.test.tsx b/frontend/src/components/ui/sheet.test.tsx new file mode 100644 index 00000000..9f70a4cd --- /dev/null +++ b/frontend/src/components/ui/sheet.test.tsx @@ -0,0 +1,96 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { describe, expect, it } from 'vitest'; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; + +import { Sheet, SheetContent, SheetDescription, SheetTitle } from './sheet'; + +const SheetFromButton = () => { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + + + + Details + Everything about this row. + + + + ); +}; + +const SheetFromDropdown = () => { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + + Row actions + + setIsOpen(true)}>Details + + + + + Details + Everything about this row. + + + + ); +}; + +describe('Sheet focus return', () => { + it('returns focus to the button that opened the sheet', async () => { + const user = userEvent.setup(); + render(); + + const opener = screen.getByRole('button', { name: 'Open details' }); + await user.click(opener); + await screen.findByRole('dialog'); + + await user.keyboard('{Escape}'); + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + expect(document.activeElement).toBe(opener); + }); + + it('returns focus to the menu trigger when the sheet was opened from a menu item', async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByRole('button', { name: 'Row actions' }); + await user.click(trigger); + await user.click(await screen.findByRole('menuitem', { name: 'Details' })); + await screen.findByRole('dialog'); + + await user.keyboard('{Escape}'); + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + expect(document.activeElement).toBe(trigger); + }); +}); diff --git a/frontend/src/components/ui/sheet.tsx b/frontend/src/components/ui/sheet.tsx index ff21cd4a..3735bec4 100644 --- a/frontend/src/components/ui/sheet.tsx +++ b/frontend/src/components/ui/sheet.tsx @@ -5,6 +5,7 @@ import { cva, type VariantProps } from 'class-variance-authority'; import { X } from 'lucide-react'; import * as React from 'react'; +import { FocusReturn } from '@/components/ui/dialog'; import { cn } from '@/lib/utils'; function Sheet({ ...props }: React.ComponentProps) { @@ -88,6 +89,7 @@ function SheetContent({ children, className, container, overlay = true, side = ' data-slot="sheet-content" {...props} > + {/* Not "Close": a sheet with its own footer Close button would diff --git a/frontend/src/features/authentication/password-change-form.tsx b/frontend/src/features/authentication/password-change-form.tsx index 96058bb1..218b7900 100644 --- a/frontend/src/features/authentication/password-change-form.tsx +++ b/frontend/src/features/authentication/password-change-form.tsx @@ -17,7 +17,10 @@ const passwordChangeSchema = z newPassword: z .string() .min(8, { message: 'Password must be at least 8 characters' }) - .max(100, { message: 'Password must not exceed 100 characters' }) + // bcrypt, which hashes it server-side, refuses anything longer than 72 bytes. + .refine((password) => new TextEncoder().encode(password).length <= 72, { + message: 'Password must not exceed 72 characters', + }) .refine( (password) => { if (password.length > 15) { diff --git a/frontend/src/features/flows/agents/flow-agents.tsx b/frontend/src/features/flows/agents/flow-agents.tsx index d13fb7c2..9823cff1 100644 --- a/frontend/src/features/flows/agents/flow-agents.tsx +++ b/frontend/src/features/flows/agents/flow-agents.tsx @@ -157,6 +157,7 @@ function FlowAgents() { {field.value && ( { form.reset({ search: '' }); setDebouncedSearchValue(''); @@ -206,6 +207,7 @@ function FlowAgents() { {!isScrolledToBottom && ( )} - {!isMobile && ( - - controller={flowNav} - renderItem={renderFlowItem} - sheetIcon={} - sheetTitle="Flows" - /> - )} ; return { AppHeader: Pass, - AppHeaderAction: Pass, + AppHeaderAction: Action, AppHeaderActions: Pass, AppHeaderContent: Pass, AppHeaderTitle: Pass, @@ -92,8 +119,14 @@ vi.mock('sonner', () => ({ toast: { error: vi.fn(), info: vi.fn(), success: vi.f import SettingsProvider from './settings-provider'; beforeEach(() => { + mutate.mockClear(); navigate.mockClear(); + vi.mocked(toast.error).mockClear(); setSearch(''); + state.providerId = 'new'; + queryResult.data = { settingsProviders }; + queryResult.error = undefined; + queryResult.loading = false; }); describe('SettingsProvider create-form type guards', () => { @@ -124,4 +157,134 @@ describe('SettingsProvider create-form type guards', () => { expect(navigate).not.toHaveBeenCalled(); }); + + // cache-and-network means an error can arrive with cached data still present; the form must + // survive it rather than flip to the full-page error screen. + it('keeps the form on a refetch error while cached data is present', () => { + setSearch('type=anthropic'); + queryResult.error = new Error('e2e induced refetch failure'); + render(); + + expect(screen.queryByText('Error loading provider data')).not.toBeInTheDocument(); + expect(navigate).not.toHaveBeenCalled(); + }); + + // The loading branch runs before the error branch, so it needs the same guard: a background + // refetch reports loading:true with cached data and must not blank the form to the spinner. + it('keeps the form on a background refetch while cached data is present', () => { + setSearch('type=anthropic'); + queryResult.loading = true; + render(); + + expect(screen.queryByText('Loading provider data...')).not.toBeInTheDocument(); + expect(navigate).not.toHaveBeenCalled(); + }); + + const expandAgent = () => { + if (!screen.queryByLabelText('Temperature')) { + fireEvent.click(screen.getByRole('button', { name: /Simple/ })); + } + }; + + const temperatureInput = () => screen.getByLabelText('Temperature') as HTMLInputElement; + + it('preserves an in-flight agent edit on the create form across a background refetch', () => { + setSearch('type=openai'); + const { rerender } = render(); + + expandAgent(); + expect(temperatureInput().value).toBe('0.7'); + + fireEvent.change(temperatureInput(), { target: { value: '1.5' } }); + + // A refetch that carries no change leaves `data` referentially equal and is inert, so the + // payload has to actually differ for this to exercise the seeding effect. + queryResult.data = { + settingsProviders: { ...settingsProviders, userDefined: [...userDefined] }, + }; + rerender(); + + expect(temperatureInput().value).toBe('1.5'); + }); + + it('still re-seeds the agents when the type changes mid-edit', () => { + setSearch('type=openai'); + const { rerender } = render(); + + expandAgent(); + fireEvent.change(temperatureInput(), { target: { value: '1.5' } }); + + setSearch('type=anthropic'); + rerender(); + expandAgent(); + + expect(temperatureInput().value).toBe('0.2'); + }); + + it('preserves an in-flight edit across a background refetch', () => { + state.providerId = 'edit-1'; + const { rerender } = render(); + + const nameInput = screen.getByPlaceholderText('Enter provider name') as HTMLInputElement; + + expect(nameInput.value).toBe('Persisted Name'); + + fireEvent.change(nameInput, { target: { value: 'My Unsaved Edit' } }); + + queryResult.data = { settingsProviders }; + rerender(); + + expect((screen.getByPlaceholderText('Enter provider name') as HTMLInputElement).value).toBe('My Unsaved Edit'); + }); +}); + +describe('SettingsProvider save feedback', () => { + const toggleAgent = () => fireEvent.click(screen.getByRole('button', { name: /Simple/ })); + + const saveButton = () => screen.getByRole('button', { name: 'Create' }); + + const renderCreateForm = () => { + setSearch('type=openai'); + render(); + fireEvent.change(screen.getByPlaceholderText('Enter provider name'), { target: { value: 'My Provider' } }); + }; + + const typeInvalidExtraBody = () => { + toggleAgent(); + fireEvent.change(screen.getByLabelText('Extra Body (JSON)'), { target: { value: '{ nope' } }); + toggleAgent(); + }; + + it('toasts an agent-level error raised inside a collapsed panel', async () => { + renderCreateForm(); + typeInvalidExtraBody(); + + expect(screen.queryByLabelText('Extra Body (JSON)')).not.toBeInTheDocument(); + + fireEvent.click(saveButton()); + + await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(1)); + expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('Must be a valid JSON object')); + expect(mutate).not.toHaveBeenCalled(); + }); + + it('toasts again when the same invalid form is submitted twice', async () => { + renderCreateForm(); + typeInvalidExtraBody(); + + fireEvent.click(saveButton()); + await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(1)); + + fireEvent.click(saveButton()); + await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(2)); + }); + + it('runs the create mutation when the form is valid', async () => { + renderCreateForm(); + + fireEvent.click(saveButton()); + + await waitFor(() => expect(mutate).toHaveBeenCalledTimes(1)); + expect(toast.error).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/src/pages/settings/settings-provider.tsx b/frontend/src/pages/settings/settings-provider.tsx index 0cbda151..31e74ba6 100644 --- a/frontend/src/pages/settings/settings-provider.tsx +++ b/frontend/src/pages/settings/settings-provider.tsx @@ -12,9 +12,10 @@ import { Trash2, XCircle, } from 'lucide-react'; -import { type ComponentProps, useEffect, useMemo, useState } from 'react'; +import { type ComponentProps, type FormEvent, useEffect, useMemo, useRef, useState } from 'react'; import { type Control, + type FieldErrors, type FieldPath, type FieldValues, useController, @@ -611,10 +612,13 @@ const agentConfigSchema = z .object({ extraBody: optionalJsonObject, frequencyPenalty: optionalNumber, + json: z.boolean().nullable().optional(), maxLength: optionalNumber, maxTokens: optionalNumber, minLength: optionalNumber, + minP: optionalNumber, model: requiredString('Model is required'), + n: optionalNumber, presencePenalty: optionalNumber, price: z .object({ @@ -634,6 +638,7 @@ const agentConfigSchema = z .nullable() .optional(), repetitionPenalty: optionalNumber, + responseMimeType: z.string().nullable().optional(), temperature: optionalNumber, topK: optionalNumber, topP: optionalNumber, @@ -886,10 +891,15 @@ export const transformFormToGraphQL = ( const config: AgentConfigInput = { extraBody: data?.extraBody?.trim() ? (JSON.parse(data.extraBody) as Record) : null, frequencyPenalty: data?.frequencyPenalty ?? null, + // Not user-editable: carried through so saving a provider does not strip what the + // shipped defaults set (json drives WithJSONMode on the simple_json agent). + json: data?.json ?? null, maxLength: data?.maxLength ?? null, maxTokens: data?.maxTokens ?? null, minLength: data?.minLength ?? null, + minP: data?.minP ?? null, model: data?.model ?? '', + n: data?.n ?? null, presencePenalty: data?.presencePenalty ?? null, price: data?.price && @@ -912,6 +922,7 @@ export const transformFormToGraphQL = ( } : null, repetitionPenalty: data?.repetitionPenalty ?? null, + responseMimeType: data?.responseMimeType ?? null, temperature: data?.temperature ?? null, topK: data?.topK ?? null, topP: data?.topP ?? null, @@ -1203,12 +1214,17 @@ function SettingsProvider() { name: undefined, type: undefined, }, + // The seeding effect re-runs on every settingsProviders refetch (cache-and-network + + // replaceWithIncoming gives a fresh `data`); without this a background refetch landing + // mid-edit silently wipes the unsaved form. + resetOptions: { keepDirtyValues: true }, schema: formSchema, }); const { control, formState, handleSubmit: handleFormSubmit, reset, setValue, trigger, watch } = form; const { isDirty } = useFormState({ control }); + const seededTypeRef = useRef(null); useEffect(() => { if (submitError) { @@ -1279,6 +1295,17 @@ function SettingsProvider() { return; } + // setValue is outside the form's keepDirtyValues, so a background refetch re-runs this effect + // with a fresh `data` identity and overwrites agent edits the user has not saved. Re-seed only + // when the type actually changed — that is the case where the previous type's agents are wrong. + const isSameType = seededTypeRef.current === selectedType; + + seededTypeRef.current = selectedType; + + if (isSameType && form.getFieldState('agents').isDirty) { + return; + } + const defaultProvider = data.settingsProviders.default[selectedType as keyof typeof data.settingsProviders.default]; @@ -1509,6 +1536,18 @@ function SettingsProvider() { } }; + const handleInvalidSubmit = (errors: FieldErrors) => { + setSubmitError( + `Please fix the following validation errors:\n\n${formatFormErrors(errors as Record)}`, + ); + }; + + const handleFormEvent = async (event: FormEvent) => { + setSubmitError(null); + + await handleFormSubmit(handleSubmit, handleInvalidSubmit)(event); + }; + const handleDelete = () => { if (isNew || !providerId) { return; @@ -1605,7 +1644,7 @@ function SettingsProvider() { } }; - if (loading) { + if (loading && !data) { return ( <> @@ -1625,7 +1664,7 @@ function SettingsProvider() { ); } - if (error) { + if (error && !data) { return ( <> @@ -2002,7 +2041,7 @@ function SettingsProvider() { className="flex min-h-0 flex-1 flex-col" id="provider-form" noValidate - onSubmit={handleFormSubmit(handleSubmit)} + onSubmit={handleFormEvent} > {isDesktop ? ( ({ enabled: {} as Record | undefined })); - -vi.mock('@apollo/client/react', () => ({ - useMutation: () => [vi.fn(), {}], - useQuery: () => ({ data: { settingsProviders: { enabled: state.enabled } } }), -})); - -vi.mock('react-router-dom', async (importOriginal) => ({ - ...(await importOriginal()), - useNavigate: () => vi.fn(), -})); - -import { SettingsProvidersHeader } from './settings-providers'; +import { ProviderType } from '@/graphql/types'; const ALL_TYPES = [ 'anthropic', @@ -30,11 +19,57 @@ const ALL_TYPES = [ 'qwen', ]; -beforeEach(() => { - state.enabled = Object.fromEntries(ALL_TYPES.map((type) => [type, type !== 'minimax' && type !== 'custom'])); +const emptyProvider = { agents: {} }; + +const makeData = (userDefined: unknown[]) => ({ + settingsProviders: { + default: { anthropic: emptyProvider, openai: emptyProvider }, + enabled: Object.fromEntries(ALL_TYPES.map((type) => [type, type !== 'minimax' && type !== 'custom'])), + models: {}, + userDefined, + }, }); +const queryResult = vi.hoisted(() => ({ + current: { data: undefined, error: undefined, loading: false, refetch: () => {} } as Record, +})); + +vi.mock('@apollo/client/react', () => ({ + useMutation: () => [vi.fn(), {}], + useQuery: () => queryResult.current, +})); + +vi.mock('react-router-dom', async (importOriginal) => ({ + ...(await importOriginal()), + useNavigate: () => vi.fn(), +})); + +vi.mock('@/hooks/use-table-state', () => ({ + useTableState: () => ({ filter: '', pageIndex: 0, setFilter: vi.fn(), setPage: vi.fn() }), +})); + +// AppHeader pulls in SidebarTrigger (needs a SidebarProvider context); stub the family so the +// list's load-state branches render without that scaffolding. SettingsProvidersHeader builds its +// own trigger from a plain Button, so this does not touch the create-menu tests. +vi.mock('@/components/layouts/app/app-header', () => { + const Pass = ({ children }: { children?: React.ReactNode }) =>
{children}
; + + return { + AppHeader: Pass, + AppHeaderAction: Pass, + AppHeaderActions: Pass, + AppHeaderContent: Pass, + AppHeaderTitle: Pass, + }; +}); + +import SettingsProviders, { SettingsProvidersHeader } from './settings-providers'; + describe('SettingsProvidersHeader create menu', () => { + beforeEach(() => { + queryResult.current = { data: makeData([]), error: undefined, loading: false, refetch: () => {} }; + }); + it('offers only provider types whose API key is configured', async () => { const user = userEvent.setup(); render(); @@ -48,7 +83,17 @@ describe('SettingsProvidersHeader create menu', () => { }); it('shows a placeholder, not an empty menu, when no type is enabled', async () => { - state.enabled = Object.fromEntries(ALL_TYPES.map((type) => [type, false])); + queryResult.current = { + data: { + settingsProviders: { + ...makeData([]).settingsProviders, + enabled: Object.fromEntries(ALL_TYPES.map((type) => [type, false])), + }, + }, + error: undefined, + loading: false, + refetch: () => {}, + }; const user = userEvent.setup(); render(); @@ -58,3 +103,31 @@ describe('SettingsProvidersHeader create menu', () => { expect(screen.queryByRole('menuitem', { name: /OpenAI/ })).not.toBeInTheDocument(); }); }); + +describe('SettingsProviders list load states', () => { + const seeded = [ + { + agents: {}, + createdAt: '2026-01-15T00:00:00Z', + id: '1', + name: 'Seeded Provider', + type: ProviderType.Custom, + updatedAt: '2026-01-15T00:00:00Z', + }, + ]; + + // cache-and-network flips loading true with cached rows still present; the table must survive + // it rather than flip to the full-page spinner. + it('keeps the populated table on a background refetch instead of flashing the loader', () => { + queryResult.current = { data: makeData(seeded), error: undefined, loading: true, refetch: () => {} }; + + render( + + + , + ); + + expect(screen.getByText('Seeded Provider')).toBeInTheDocument(); + expect(screen.queryByText('Loading providers...')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/settings/settings-providers.tsx b/frontend/src/pages/settings/settings-providers.tsx index cfd8766e..fe0c9181 100644 --- a/frontend/src/pages/settings/settings-providers.tsx +++ b/frontend/src/pages/settings/settings-providers.tsx @@ -404,7 +404,7 @@ function SettingsProviders() {
); - if (isLoading) { + if (isLoading && !data) { return ( <> {pageHeader} diff --git a/frontend/src/pages/templates/template.tsx b/frontend/src/pages/templates/template.tsx index 5f87e393..e1e5f441 100644 --- a/frontend/src/pages/templates/template.tsx +++ b/frontend/src/pages/templates/template.tsx @@ -15,6 +15,7 @@ import { DetailNavigationToolbar, } from '@/components/shared/detail-navigation'; import { DetailSplitLayout } from '@/components/shared/detail-split-layout'; +import { ErrorState } from '@/components/shared/error-state'; import { InlineEditInput, useInlineEdit } from '@/components/shared/inline-edit'; import { type EditorViewMode, EditorViewModeToggle, MarkdownEditorField } from '@/components/shared/markdown-editor'; import { UnsavedChangesDialog, useUnsavedChangesGuard } from '@/components/shared/unsaved-changes'; @@ -39,6 +40,7 @@ import { useTemplateDetailNavigation } from '@/features/templates/use-template-d import { FlowTemplateDocument } from '@/graphql/types'; import { useAppForm } from '@/hooks/use-app-form'; import { useBreakpoint } from '@/hooks/use-breakpoint'; +import { isNotFoundError } from '@/lib/errors'; import { routes } from '@/lib/routes'; import { cn } from '@/lib/utils'; import { type Template, useTemplates } from '@/providers/templates-provider'; @@ -233,9 +235,23 @@ const renderTemplateItem = (item: Template, isCurrent: boolean): ReactNode => ( {item.title} ); +// One React element serves every `/templates/:templateId`, so without a key the form instance — which sets +// `keepDirtyValues` so a subscription resync cannot wipe an unsaved body — carried one template's edited text +// onto the next template and Save wrote it to the wrong row. Keying by id gives each entity its own form, the +// way knowledge.tsx already keys . It also stops `/templates/new` inheriting an abandoned draft. function Template() { - const navigate = useNavigate(); const { templateId } = useParams<{ templateId?: string }>(); + + return ( + + ); +} + +function TemplateForm({ templateId }: { templateId?: string }) { + const navigate = useNavigate(); const { createTemplate, deleteTemplate, updateTemplate } = useTemplates(); const { isDesktop, isMobile } = useBreakpoint(); @@ -261,10 +277,17 @@ function Template() { stopEdit: handleTemplateRenameCancel, } = useInlineEdit({ resetKey: templateId }); - const { data: templateData, loading: isLoadingTemplate } = useQuery( - FlowTemplateDocument, - templateId && !isNew ? { variables: { templateId } } : skipToken, - ); + const { + data: templateData, + error: templateError, + loading: isLoadingTemplate, + refetch: refetchTemplate, + } = useQuery(FlowTemplateDocument, templateId && !isNew ? { variables: { templateId } } : skipToken); + + const template = templateData?.flowTemplate; + // A real load failure that left nothing to show, as opposed to a genuine not-found: the page + // renders it as an in-page ErrorState + Retry instead of the "not found" card. Mirrors flow. + const templateLoadError = templateError && !template && !isNotFoundError(templateError) ? templateError : undefined; // `values` re-syncs the form whenever the cache refreshes (an inline rename, a refetch), while // `keepDirtyValues` preserves the user's in-flight edits — without it an external re-emit would @@ -345,7 +368,9 @@ function Template() { await createTemplate(values.title, values.text); } else if (templateId) { await updateTemplate(templateId, { text: values.text, title: values.title }); - reset(values, { keepDefaultValues: false }); + // See knowledge-form.tsx: the form-level `resetOptions` are merged into every manual + // reset, so a post-save reset inherits `keepDirtyValues` unless it opts out. + reset(values, { keepDefaultValues: false, keepDirtyValues: false }); } return true; @@ -413,7 +438,7 @@ function Template() { }, [pendingPreset, setValue]); const hasTemplate = !!templateData?.flowTemplate; - const isTemplatePending = !isNew && (isLoadingTemplate || !hasTemplate); + const isTemplatePending = !isNew && !hasTemplate; const isTemplateMissing = !isNew && !isLoadingTemplate && !hasTemplate; const pageHeader = ( @@ -456,14 +481,6 @@ function Template() { {!isTemplateMissing && ( - } - label={isNew ? 'Create' : 'Save'} - loading={isSaving} - type="submit" - /> {!isNew && !isMobile && ( controller={templateNav} @@ -472,6 +489,14 @@ function Template() { sheetTitle="Templates" /> )} + } + label={isNew ? 'Create' : 'Save'} + loading={isSaving} + type="submit" + />