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 76d0ea0e..0e712e38 100644 Binary files a/frontend/e2e/specs/visual/routes.spec.ts-snapshots/flows-5-dark-visual-linux.png and b/frontend/e2e/specs/visual/routes.spec.ts-snapshots/flows-5-dark-visual-linux.png differ 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 7709f9ab..f112ece8 100644 Binary files a/frontend/e2e/specs/visual/routes.spec.ts-snapshots/flows-5-light-visual-linux.png and b/frontend/e2e/specs/visual/routes.spec.ts-snapshots/flows-5-light-visual-linux.png differ 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" + />