diff --git a/.env.example b/.env.example index be9f16cee..93694097b 100644 --- a/.env.example +++ b/.env.example @@ -14,16 +14,5 @@ POSTHOG_PROJECT_ID= # DO_NOT_TRACK=1 # cross-vendor opt-out # NGAF_TELEMETRY_DISABLED=1 # package-specific opt-out -# Cockpit shell analytics (apps/cockpit) -NEXT_PUBLIC_COCKPIT_POSTHOG_TOKEN= -NEXT_PUBLIC_COCKPIT_CAPTURE_LOCAL=false - -# Cockpit iframe → cockpit-shell /ingest proxy (Spec 1D). -# Production: full absolute URL (e.g. https://cockpit.threadplane.ai/ingest). -# Leave empty in dev to let RunMode derive it from window.location.origin. -NEXT_PUBLIC_COCKPIT_INGEST_HOST= - -# CORS origin allowed to POST to cockpit's /ingest from iframes (Spec 1D). -# Production: https://examples.threadplane.ai -# Leave empty in dev — wildcard '*' is used. -NEXT_PUBLIC_COCKPIT_IFRAME_ORIGIN= +# Server-only destination for the legacy Cockpit redirect service. +COCKPIT_WEBSITE_ORIGIN=https://threadplane.ai diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d196c25b6..ba59e244e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -264,30 +264,16 @@ jobs: cache: npm - run: npm ci - run: npx nx build cockpit --skip-nx-cache - # cockpit-docs and cockpit-registry carry `test` targets that nothing in - # CI invoked: `nx test` does not walk `^test`, and the `library` job runs - # a hardcoded LIBS list that excludes both. Name them here so their specs - # actually execute. All three share the `scope:cockpit` tag, so ci-scope - # already gates this job correctly for changes under either library. - - run: npx nx run-many -t test --projects=cockpit,cockpit-docs,cockpit-registry --skip-nx-cache - # apps/cockpit owns a real Playwright suite (e2e/control-plane.spec.ts) - # behind `nx e2e cockpit` that nothing invoked: the cockpit-e2e matrix - # only dispatches caps derived from cockpit/**, and no other job named - # the target — so it had never run in CI since #921 added it. It lives - # here rather than in its own job because it is cheap (7 tests, ~25s - # once the dev servers are up) and reuses this job's `npm ci`; the - # `cockpit` scope already gates the shell, and required-pr-checks - # already aggregates this job. - - name: Cache Playwright browsers - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - with: - path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} - restore-keys: | - playwright-${{ runner.os }}- - - run: npx playwright install --with-deps chromium - - run: npx nx e2e cockpit --skip-nx-cache - + # cockpit-docs, cockpit-registry and workspace-react carry `test` targets + # that nothing in CI invoked: `nx test` does not walk `^test`, and the + # `library` job runs a hardcoded LIBS list that excludes all three. Name + # them here so their specs actually execute. They all share the + # `scope:cockpit` tag, so ci-scope already gates this job correctly for + # changes under any of them. workspace-react's only other scope tag is + # `scope:shared`, which is not a SCOPE_KEY — adding it to LIBS would not + # have run it, because a workspace-react change never flips `library`. + - run: npx nx lint workspace-react + - run: npx nx run-many -t test --projects=cockpit,cockpit-docs,cockpit-registry,workspace-react --skip-nx-cache cockpit-examples-build: name: Cockpit — build all examples needs: ci-scope @@ -346,7 +332,7 @@ jobs: node-version: 22 cache: npm - run: npm ci - - run: npx tsx apps/cockpit/scripts/deploy-smoke.ts --url https://cockpit.threadplane.ai --dry-run + - run: npx tsx apps/cockpit/scripts/deploy-smoke.ts --url https://cockpit.threadplane.ai --mode preview --dry-run examples-chat-smoke: name: examples/chat — python smoke @@ -795,6 +781,8 @@ jobs: - growth-lifecycle - lifecycle runs-on: ubuntu-latest + outputs: + runtime_parent_preview_origin: ${{ steps.deploy_website.outputs.preview_origin }} # Only deploy on pushes to main, not on pull requests if: github.ref == 'refs/heads/main' && github.event_name == 'push' permissions: @@ -880,7 +868,7 @@ jobs: changed_files="$(git diff --name-only "$base_sha" "$head_sha")" deploy_relevant=false - if printf '%s\n' "$changed_files" | grep -E '^(\.github/workflows/ci\.yml|vercel\.(json|cockpit\.json|examples\.json)|apps/(website|cockpit)/.*|cockpit/.*|examples/chat/.*|libs/.*|scripts/(assemble-examples|deploy-smoke|demo-middleware|langgraph-proxy|rate-limit)\.ts|scripts/assemble-demo\.ts)$' >/dev/null; then + if printf '%s\n' "$changed_files" | grep -E '^(\.github/workflows/ci\.yml|runtime-parent-origins\.json|vercel\.(json|cockpit\.json|examples\.json)|apps/(website|cockpit)/.*|cockpit/.*|examples/chat/.*|libs/.*|scripts/(assemble-examples|deploy-smoke|demo-middleware|generate-runtime-parent-origins|langgraph-proxy|rate-limit)\.ts|scripts/assemble-demo\.ts)$' >/dev/null; then deploy_relevant=true fi @@ -899,7 +887,10 @@ jobs: if printf '%s\n' "$changed_files" | grep -E '^cockpit/.*/angular/' >/dev/null; then examples_changed=true fi - if printf '%s\n' "$changed_files" | grep -E '^(vercel\.examples\.json|scripts/(assemble-examples|examples-middleware|langgraph-proxy|upstash-rate-limit)\.ts)$' >/dev/null; then + if printf '%s\n' "$changed_files" | grep -E '^(runtime-parent-origins\.json|vercel\.examples\.json|scripts/(assemble-examples|examples-middleware|generate-runtime-parent-origins|langgraph-proxy|upstash-rate-limit)\.ts)$' >/dev/null; then + examples_changed=true + fi + if printf '%s\n' "$changed_files" | grep -E '^(\.github/workflows/ci\.yml|vercel\.json|apps/website/.*)$' >/dev/null; then examples_changed=true fi # Any libs/ change retriggers examples deploy. Previous hand-maintained @@ -958,7 +949,7 @@ jobs: echo "website=$website_changed" >> "$GITHUB_OUTPUT" echo "cockpit=$cockpit_changed" >> "$GITHUB_OUTPUT" - name: Cache Playwright browsers - if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.website == 'true' + if: steps.freshness.outputs.stale != 'true' && (steps.affected.outputs.website == 'true' || steps.affected.outputs.cockpit == 'true') uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.cache/ms-playwright @@ -966,7 +957,7 @@ jobs: restore-keys: | playwright-${{ runner.os }}- - name: Install Playwright browsers - if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.website == 'true' + if: steps.freshness.outputs.stale != 'true' && (steps.affected.outputs.website == 'true' || steps.affected.outputs.cockpit == 'true') run: npx playwright install --with-deps chromium - name: Prepare website Vercel project if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.website == 'true' @@ -977,21 +968,20 @@ jobs: EOF npx vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }} rm -rf .vercel/output - - name: Build and deploy website to Vercel (production) + - name: Deploy immutable Website preview if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.website == 'true' id: deploy_website run: | npx vercel build --prod --token=${{ secrets.VERCEL_TOKEN }} - url=$(npx vercel deploy --prebuilt --archive=tgz --prod --yes --token=${{ secrets.VERCEL_TOKEN }} | tail -n 1) - echo "deployment_url=$url" >> "$GITHUB_OUTPUT" - - name: Verify deployed website - if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.website == 'true' - run: npx nx e2e website --skip-nx-cache - env: - BASE_URL: https://threadplane.ai + url=$(npx vercel deploy --prebuilt --archive=tgz --prod --skip-domain --yes --token=${{ secrets.VERCEL_TOKEN }} | tail -n 1) + preview_origin=$(node -e 'const parsed = new URL(process.argv[1]); if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) throw new Error("Vercel returned a non-origin Website preview URL"); process.stdout.write(parsed.origin)' "$url") + echo "deployment_url=$preview_origin" >> "$GITHUB_OUTPUT" + echo "preview_origin=$preview_origin" >> "$GITHUB_OUTPUT" - name: Build and assemble Angular examples if: steps.freshness.outputs.stale != 'true' && steps.examples_changed.outputs.changed == 'true' run: npx tsx scripts/assemble-examples.ts + env: + RUNTIME_PARENT_PREVIEW_ORIGINS: ${{ steps.deploy_website.outputs.preview_origin }} - name: Deploy Angular examples to Vercel (production) if: steps.freshness.outputs.stale != 'true' && steps.examples_changed.outputs.changed == 'true' working-directory: deploy/examples @@ -1002,6 +992,38 @@ jobs: EOF npx vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }} npx vercel deploy --prebuilt --prod --yes --token=${{ secrets.VERCEL_TOKEN }} + - name: Verify Website preview runtime embedding policy + if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.website == 'true' + run: npx playwright test apps/website/e2e/platform-production-smoke.spec.ts --config apps/website/playwright.config.ts --grep "unified runtime embedding policy" --reporter=list + env: + PRODUCTION_SMOKE: 'true' + BASE_URL: ${{ steps.deploy_website.outputs.preview_origin }} + WEBSITE_URL: ${{ steps.deploy_website.outputs.preview_origin }} + EXAMPLES_URL: https://examples.threadplane.ai + RUNTIME_PARENT_PREVIEW_ORIGINS: ${{ steps.deploy_website.outputs.preview_origin }} + - name: Check this commit is still the tip before Website promotion + if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.website == 'true' + id: website_promotion_freshness + run: | + tip="$(git ls-remote origin refs/heads/main | cut -f1)" + if [ -z "$tip" ]; then + echo "::error::Could not resolve the tip of main after Website preview verification; refusing promotion." + exit 1 + fi + if [ "$tip" != "${{ github.sha }}" ]; then + echo "fresh=false" >> "$GITHUB_OUTPUT" + echo "::warning::main advanced to ${tip} during Website preview verification; leaving the production alias untouched for the newer run." + else + echo "fresh=true" >> "$GITHUB_OUTPUT" + fi + - name: Promote verified Website artifact unchanged + if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.website == 'true' && steps.website_promotion_freshness.outputs.fresh == 'true' + run: npx vercel promote "${{ steps.deploy_website.outputs.deployment_url }}" --yes --token=${{ secrets.VERCEL_TOKEN }} + - name: Verify deployed website + if: steps.freshness.outputs.stale != 'true' && ((steps.affected.outputs.website == 'true' && steps.website_promotion_freshness.outputs.fresh == 'true') || steps.affected.outputs.cockpit == 'true') + run: npx nx e2e website --skip-nx-cache + env: + BASE_URL: https://threadplane.ai - name: Prepare cockpit Vercel project if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.cockpit == 'true' run: | @@ -1011,25 +1033,43 @@ jobs: EOF npx vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }} rm -rf .vercel/output - - name: Build and deploy cockpit to Vercel (production) + - name: Build cockpit redirect service if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.cockpit == 'true' - id: deploy_cockpit + env: + COCKPIT_WEBSITE_ORIGIN: https://threadplane.ai run: | npx vercel build --prod --local-config vercel.cockpit.json --token=${{ secrets.VERCEL_TOKEN }} - url=$(npx vercel deploy --prebuilt --archive=tgz --prod --yes --token=${{ secrets.VERCEL_TOKEN }} | tail -n 1) + - name: Deploy immutable cockpit artifact + if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.cockpit == 'true' + id: deploy_cockpit + run: | + url=$(npx vercel deploy --prebuilt --archive=tgz --prod --skip-domain --yes --env COCKPIT_WEBSITE_ORIGIN=https://threadplane.ai --token=${{ secrets.VERCEL_TOKEN }} | tail -n 1) echo "deployment_url=$url" >> "$GITHUB_OUTPUT" - - name: Verify deployed cockpit + - name: Exhaustively verify immutable cockpit preview if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.cockpit == 'true' run: | - npx tsx apps/cockpit/scripts/deploy-smoke.ts --url https://cockpit.threadplane.ai --retries 20 --retry-delay-ms 5000 - - # Advance only when the whole job succeeded and actually promoted. On a - # stale run, a failure, or a partial deploy the marker stays put, so the - # next run's range still covers whatever did not ship. Never `always()`: - # advancing past unshipped work is the bug this step exists to prevent. - - name: Record this commit as promoted - if: success() && steps.freshness.outputs.stale != 'true' - run: git push origin --force "${{ github.sha }}:refs/deploy/last-promoted" + npx tsx apps/cockpit/scripts/deploy-smoke.ts --url "${{ steps.deploy_cockpit.outputs.deployment_url }}" --mode preview --retries 20 --retry-delay-ms 5000 + - name: Check this commit is still the tip before cockpit promotion + if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.cockpit == 'true' + id: cockpit_promotion_freshness + run: | + tip="$(git ls-remote origin refs/heads/main | cut -f1)" + if [ -z "$tip" ]; then + echo "::error::Could not resolve the tip of main after preview verification; refusing the irreversible Cockpit redirect promotion." + exit 1 + fi + if [ "$tip" != "${{ github.sha }}" ]; then + echo "fresh=false" >> "$GITHUB_OUTPUT" + echo "::warning::main advanced to ${tip} during Cockpit preview verification; leaving the production alias untouched for the newer run." + else + echo "fresh=true" >> "$GITHUB_OUTPUT" + fi + - name: Promote verified cockpit artifact unchanged + if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.cockpit == 'true' && steps.cockpit_promotion_freshness.outputs.fresh == 'true' + run: npx vercel promote "${{ steps.deploy_cockpit.outputs.deployment_url }}" --yes --token=${{ secrets.VERCEL_TOKEN }} + - name: Verify production cockpit redirects + if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.cockpit == 'true' && steps.cockpit_promotion_freshness.outputs.fresh == 'true' + run: npx tsx apps/cockpit/scripts/deploy-smoke.ts --url https://cockpit.threadplane.ai --mode production --retries 20 --retry-delay-ms 5000 demo-deploy: name: Canonical demo → Vercel @@ -1275,11 +1315,15 @@ jobs: production-smoke: name: Production smoke - needs: [deploy, demo-deploy] + needs: [deploy, demo-deploy, ag-ui-demo-deploy] runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' && github.event_name == 'push' + permissions: + contents: write steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: 22 @@ -1298,12 +1342,35 @@ jobs: playwright-${{ runner.os }}- - run: npx playwright install --with-deps chromium - name: Run production smoke tests - run: npx playwright test apps/cockpit/e2e/production-smoke.spec.ts --reporter=list + run: npx playwright test apps/website/e2e/platform-production-smoke.spec.ts --config apps/website/playwright.config.ts --reporter=list env: - BASE_URL: https://cockpit.threadplane.ai + PRODUCTION_SMOKE: 'true' + BASE_URL: https://threadplane.ai + COCKPIT_URL: https://cockpit.threadplane.ai + WEBSITE_URL: https://threadplane.ai EXAMPLES_URL: https://examples.threadplane.ai + RUNTIME_PARENT_PREVIEW_ORIGINS: ${{ needs.deploy.outputs.runtime_parent_preview_origin }} DEMO_URL: https://demo.threadplane.ai OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + - name: Check this commit is still the tip before recording promotion + id: freshness + run: | + tip="$(git ls-remote origin refs/heads/main | cut -f1)" + if [ -z "$tip" ]; then + echo "::error::Could not resolve the tip of main; refusing to advance the deploy marker." + exit 1 + fi + if [ "$tip" != "${{ github.sha }}" ]; then + echo "stale=true" >> "$GITHUB_OUTPUT" + echo "::warning::main advanced to ${tip}; leaving refs/deploy/last-promoted untouched for the newer run." + else + echo "stale=false" >> "$GITHUB_OUTPUT" + fi + # Advance only after immutable preview verification, promotion, + # representative redirects, and the full cross-host platform smoke pass. + - name: Record this commit as promoted + if: success() && steps.freshness.outputs.stale != 'true' + run: git push origin --force "${{ github.sha }}:refs/deploy/last-promoted" posthog-sync-plan: name: PostHog — dashboards-as-code drift check diff --git a/apps/cockpit/ag-ui-agent-url.spec.ts b/apps/cockpit/ag-ui-agent-url.spec.ts index e440970cb..96d8e0a5a 100644 --- a/apps/cockpit/ag-ui-agent-url.spec.ts +++ b/apps/cockpit/ag-ui-agent-url.spec.ts @@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { capabilities } from './scripts/capability-registry'; +import { inspectRuntimeTargetSource } from './runtime-wiring-audit'; /** * Guard for a failure mode production smoke cannot see. @@ -39,27 +40,45 @@ describe('AG-UI agent URL is resolved against ', () => { for (const cap of agentBackedCapabilities) { it(`${cap.product}/${cap.topic} resolves its agent URL relative to the base href`, () => { - const configPath = join( + const angularRoot = join( repoRoot, 'cockpit', cap.product, cap.topic, - 'angular/src/app/app.config.ts' + 'angular/src' ); - const source = readFileSync(configPath, 'utf8'); + const configPath = join(angularRoot, 'app/app.config.ts'); + const configSource = readFileSync(configPath, 'utf8'); + const entryPoints = ['main.ts', 'main.cockpit.ts'].map((fileName) => ({ + path: join(angularRoot, fileName), + source: readFileSync(join(angularRoot, fileName), 'utf8'), + })); + for (const entryPoint of entryPoints) { + const bootstrapCalls = inspectRuntimeTargetSource( + entryPoint.source, + entryPoint.path, + 'ag-ui' + ).bootstrapCalls; + expect( + bootstrapCalls, + `${entryPoint.path} must supply sharedUrl with ${AGENT_URL_EXPR} so it ` + + `resolves under the deployed ` + ).toHaveLength(1); + expect(bootstrapCalls[0].runtimeProperties['sharedUrl']).toBe( + AGENT_URL_EXPR + ); + } + const providerCalls = inspectRuntimeTargetSource( + configSource, + configPath, + 'ag-ui' + ).providerCalls; expect( - source, - `${configPath} must build the agent URL with ${AGENT_URL_EXPR} so it ` + - `resolves under the deployed ` - ).toContain(AGENT_URL_EXPR); - - // A root-absolute literal silently 404s in production; see the note above. - expect( - source, - `${configPath} hardcodes a root-absolute agent URL, which does not ` + - `survive the rewrite in scripts/assemble-examples.ts` - ).not.toMatch(/url:\s*['"`]\/agent/); + providerCalls, + `${configPath} must source the runtime URL from the generation-scoped connection` + ).toHaveLength(1); + expect(providerCalls[0].properties['url']).toBe('connection.url'); }); } }); diff --git a/apps/cockpit/cockpit-capability-wiring.spec.ts b/apps/cockpit/cockpit-capability-wiring.spec.ts index 3bbc3f764..114d9173c 100644 --- a/apps/cockpit/cockpit-capability-wiring.spec.ts +++ b/apps/cockpit/cockpit-capability-wiring.spec.ts @@ -3,9 +3,537 @@ import { cockpitManifest, } from '@threadplane/cockpit-registry'; import { capabilities } from './scripts/capability-registry'; -import { buildNavigationTree } from '@threadplane/cockpit-shell'; -import { existsSync, readFileSync } from 'node:fs'; +import { + auditRuntimeTargetSource, + hasExactImportBinding, + inspectRuntimeTargetSource, + type AngularProviderRecord, + type BootstrapCallRecord, + type CanonicalProviderCall, + type ExactImportBinding, + type ProviderRegistrationOwner, +} from './runtime-wiring-audit'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const cockpitRoot = fileURLToPath(new URL('../../cockpit/', import.meta.url)); + +function angularSourceFiles(projectRoot: string): string[] { + const visit = (directory: string): string[] => + readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const entryPath = resolve(directory, entry.name); + if (entry.isDirectory()) return visit(entryPath); + return entry.isFile() && entry.name.endsWith('.ts') ? [entryPath] : []; + }); + + return visit(resolve(projectRoot, 'src')); +} + +interface ExpectedRuntimeWiring { + project: string; + rootComponent: string; + providerSource: string; + assistantId?: string; + sharedUrl?: string; + agentRef?: string; + retainedProviderProperties?: Readonly>; +} + +const persistenceOnThreadIdExpression = `(id: string) => { + activeThreadIdState.set(id); + + // Only add if not already tracked + const existing = threadsState(); + if (!existing.some((t) => t.id === id)) { + threadCounter++; + threadsState.set([ + ...existing, + { id, label: \`Thread \${threadCounter}\` }, + ]); + } + }`; + +/** + * Audited against the pre-runtime-target HEAD entrypoints and provider calls. + * Keeping this explicit is intentional: a permissive `environment.*` matcher + * allowed assistant IDs and typed refs to be silently swapped between demos. + */ +const expectedRuntimeWiring: ExpectedRuntimeWiring[] = [ + { + project: 'cockpit-langgraph-streaming-angular', + rootComponent: 'StreamingComponent', + providerSource: 'src/app/app.config.ts', + assistantId: 'environment.streamingAssistantId', + agentRef: 'STREAMING_AGENT', + }, + { + project: 'cockpit-langgraph-persistence-angular', + rootComponent: 'PersistenceComponent', + providerSource: 'src/app/persistence.component.ts', + assistantId: 'environment.streamingAssistantId', + retainedProviderProperties: { + onThreadId: persistenceOnThreadIdExpression, + }, + }, + { + project: 'cockpit-langgraph-interrupts-angular', + rootComponent: 'InterruptsComponent', + providerSource: 'src/app/app.config.ts', + assistantId: 'environment.streamingAssistantId', + }, + { + project: 'cockpit-langgraph-memory-angular', + rootComponent: 'MemoryComponent', + providerSource: 'src/app/app.config.ts', + assistantId: 'environment.streamingAssistantId', + }, + { + project: 'cockpit-langgraph-durable-execution-angular', + rootComponent: 'DurableExecutionComponent', + providerSource: 'src/app/app.config.ts', + assistantId: 'environment.streamingAssistantId', + }, + { + project: 'cockpit-langgraph-subgraphs-angular', + rootComponent: 'SubgraphsComponent', + providerSource: 'src/app/app.config.ts', + assistantId: 'environment.streamingAssistantId', + agentRef: 'SUBGRAPHS_AGENT', + retainedProviderProperties: { + transcriptNodeNames: "['answer']", + }, + }, + { + project: 'cockpit-langgraph-time-travel-angular', + rootComponent: 'TimeTravelComponent', + providerSource: 'src/app/app.config.ts', + assistantId: 'environment.streamingAssistantId', + }, + { + project: 'cockpit-langgraph-deployment-runtime-angular', + rootComponent: 'DeploymentRuntimeComponent', + providerSource: 'src/app/app.config.ts', + assistantId: 'environment.deploymentRuntimeAssistantId', + }, + { + project: 'cockpit-langgraph-client-tools-angular', + rootComponent: 'ClientToolsComponent', + providerSource: 'src/app/app.config.ts', + assistantId: 'environment.clientToolsAssistantId', + agentRef: 'CLIENT_TOOLS_AGENT_REF', + }, + { + project: 'cockpit-deep-agents-planning-angular', + rootComponent: 'PlanningComponent', + providerSource: 'src/app/app.config.ts', + assistantId: 'environment.streamingAssistantId', + }, + { + project: 'cockpit-deep-agents-filesystem-angular', + rootComponent: 'FilesystemComponent', + providerSource: 'src/app/app.config.ts', + assistantId: 'environment.streamingAssistantId', + }, + { + project: 'cockpit-deep-agents-subagents-angular', + rootComponent: 'SubagentsComponent', + providerSource: 'src/app/app.config.ts', + assistantId: 'environment.streamingAssistantId', + retainedProviderProperties: { + subagentToolNames: "['task']", + }, + }, + { + project: 'cockpit-deep-agents-memory-angular', + rootComponent: 'MemoryComponent', + providerSource: 'src/app/app.config.ts', + assistantId: 'environment.streamingAssistantId', + }, + { + project: 'cockpit-deep-agents-skills-angular', + rootComponent: 'SkillsComponent', + providerSource: 'src/app/app.config.ts', + assistantId: 'environment.streamingAssistantId', + }, + { + project: 'cockpit-chat-messages-angular', + rootComponent: 'MessagesComponent', + providerSource: 'src/app/app.config.ts', + assistantId: 'environment.streamingAssistantId', + agentRef: 'MESSAGES_AGENT', + }, + { + project: 'cockpit-chat-input-angular', + rootComponent: 'InputComponent', + providerSource: 'src/app/app.config.ts', + assistantId: 'environment.streamingAssistantId', + }, + { + project: 'cockpit-chat-interrupts-angular', + rootComponent: 'InterruptsComponent', + providerSource: 'src/app/app.config.ts', + assistantId: 'environment.streamingAssistantId', + }, + { + project: 'cockpit-chat-tool-calls-angular', + rootComponent: 'ToolCallsComponent', + providerSource: 'src/app/app.config.ts', + assistantId: 'environment.streamingAssistantId', + }, + { + project: 'cockpit-chat-subagents-angular', + rootComponent: 'SubagentsComponent', + providerSource: 'src/app/app.config.ts', + assistantId: 'environment.streamingAssistantId', + retainedProviderProperties: { + subagentToolNames: "['task']", + }, + }, + { + project: 'cockpit-chat-threads-angular', + rootComponent: 'ThreadsComponent', + providerSource: 'src/app/threads.component.ts', + assistantId: 'environment.streamingAssistantId', + retainedProviderProperties: { + threadId: 'activeThreadIdState', + onThreadId: '(id: string) => activeThreadIdState.set(id)', + }, + }, + { + project: 'cockpit-chat-timeline-angular', + rootComponent: 'TimelineComponent', + providerSource: 'src/app/app.config.ts', + assistantId: 'environment.streamingAssistantId', + }, + { + project: 'cockpit-chat-generative-ui-angular', + rootComponent: 'GenerativeUiComponent', + providerSource: 'src/app/app.config.ts', + assistantId: 'environment.generativeUiAssistantId', + }, + { + project: 'cockpit-chat-debug-angular', + rootComponent: 'DebugPageComponent', + providerSource: 'src/app/app.config.ts', + assistantId: 'environment.streamingAssistantId', + }, + { + project: 'cockpit-chat-theming-angular', + rootComponent: 'ThemingComponent', + providerSource: 'src/app/app.config.ts', + assistantId: 'environment.streamingAssistantId', + }, + { + project: 'cockpit-chat-a2ui-angular', + rootComponent: 'A2uiComponent', + providerSource: 'src/app/app.config.ts', + assistantId: 'environment.a2uiAssistantId', + }, + ...[ + ['cockpit-ag-ui-interrupts-angular', 'InterruptsComponent'], + ['cockpit-ag-ui-streaming-angular', 'StreamingComponent'], + ['cockpit-ag-ui-tool-views-angular', 'ToolViewsComponent'], + ['cockpit-ag-ui-json-render-angular', 'JsonRenderComponent'], + ['cockpit-ag-ui-client-tools-angular', 'ClientToolsComponent'], + ['cockpit-ag-ui-a2ui-angular', 'A2uiComponent'], + ['cockpit-ag-ui-subagents-angular', 'SubagentsComponent'], + [ + 'cockpit-runtimes-microsoft-agent-framework-angular', + 'MicrosoftAgentFrameworkComponent', + ], + ['cockpit-runtimes-aws-strands-angular', 'AwsStrandsComponent'], + ['cockpit-runtimes-mastra-angular', 'MastraComponent'], + ].map(([project, rootComponent]) => ({ + project, + rootComponent, + providerSource: 'src/app/app.config.ts', + sharedUrl: "new URL('agent', document.baseURI).pathname", + })), +]; +const expectedRuntimeWiringByProject = new Map( + expectedRuntimeWiring.map((expected) => [expected.project, expected]) +); + +const expectedRootImportSourceByProject = new Map([ + ['cockpit-langgraph-streaming-angular', './app/streaming.component'], + ['cockpit-langgraph-persistence-angular', './app/persistence.component'], + ['cockpit-langgraph-interrupts-angular', './app/interrupts.component'], + ['cockpit-langgraph-memory-angular', './app/memory.component'], + [ + 'cockpit-langgraph-durable-execution-angular', + './app/durable-execution.component', + ], + ['cockpit-langgraph-subgraphs-angular', './app/subgraphs.component'], + ['cockpit-langgraph-time-travel-angular', './app/time-travel.component'], + [ + 'cockpit-langgraph-deployment-runtime-angular', + './app/deployment-runtime.component', + ], + ['cockpit-langgraph-client-tools-angular', './app/client-tools.component'], + ['cockpit-deep-agents-planning-angular', './app/planning.component'], + ['cockpit-deep-agents-filesystem-angular', './app/filesystem.component'], + ['cockpit-deep-agents-subagents-angular', './app/subagents.component'], + ['cockpit-deep-agents-memory-angular', './app/memory.component'], + ['cockpit-deep-agents-skills-angular', './app/skills.component'], + ['cockpit-chat-messages-angular', './app/messages.component'], + ['cockpit-chat-input-angular', './app/input.component'], + ['cockpit-chat-interrupts-angular', './app/interrupts.component'], + ['cockpit-chat-tool-calls-angular', './app/tool-calls.component'], + ['cockpit-chat-subagents-angular', './app/subagents.component'], + ['cockpit-chat-threads-angular', './app/threads.component'], + ['cockpit-chat-timeline-angular', './app/timeline.component'], + ['cockpit-chat-generative-ui-angular', './app/generative-ui.component'], + ['cockpit-chat-debug-angular', './app/debug.component'], + ['cockpit-chat-theming-angular', './app/theming.component'], + ['cockpit-chat-a2ui-angular', './app/a2ui.component'], + ['cockpit-ag-ui-interrupts-angular', './app/interrupts.component'], + ['cockpit-ag-ui-streaming-angular', './app/streaming.component'], + ['cockpit-ag-ui-tool-views-angular', './app/tool-views.component'], + ['cockpit-ag-ui-json-render-angular', './app/json-render.component'], + ['cockpit-ag-ui-client-tools-angular', './app/client-tools.component'], + ['cockpit-ag-ui-a2ui-angular', './app/a2ui.component'], + ['cockpit-ag-ui-subagents-angular', './app/subagents.component'], + [ + 'cockpit-runtimes-microsoft-agent-framework-angular', + './app/microsoft-agent-framework.component', + ], + ['cockpit-runtimes-aws-strands-angular', './app/aws-strands.component'], + ['cockpit-runtimes-mastra-angular', './app/mastra.component'], +]); + +type SemanticBindingInspection = ExactImportBinding; +const hasExactSemanticBinding = hasExactImportBinding; + +function auditEntrypointMetadata( + bootstrap: BootstrapCallRecord | undefined, + expected: ExpectedRuntimeWiring, + adapter: 'ag-ui' | 'langgraph' +): string[] { + const mismatches: string[] = []; + if (!bootstrap) return ['executable bootstrap call']; + if (bootstrap.rootComponent !== expected.rootComponent) { + mismatches.push(`root ${expected.rootComponent}`); + } + const bindingBootstrap = bootstrap as BootstrapCallRecord & { + rootComponentBinding?: SemanticBindingInspection; + environmentBindings?: readonly SemanticBindingInspection[]; + operationReporterBinding?: SemanticBindingInspection; + }; + const rootImportSource = expectedRootImportSourceByProject.get( + expected.project + ); + if ( + !rootImportSource || + !hasExactSemanticBinding( + bindingBootstrap.rootComponentBinding, + rootImportSource, + expected.rootComponent + ) + ) { + mismatches.push(`root import ${expected.rootComponent} from ${rootImportSource}`); + } + if ( + bootstrap.appConfigArgument !== 'appConfig' || + !bootstrap.hasCanonicalAppConfigBinding + ) { + mismatches.push('canonical appConfig import argument'); + } + if (!bootstrap.hasCanonicalHarnessBinding) { + mismatches.push('canonical bootstrap harness import'); + } + if (!bootstrap.hasCanonicalCallOwner) { + mismatches.push('sole top-level bootstrap owner'); + } + const runtimeBootstrap = bootstrap as BootstrapCallRecord & { + hasCanonicalRuntimeOptions?: boolean; + hasPristineAgUrlGlobals?: boolean; + }; + if (!runtimeBootstrap.hasCanonicalRuntimeOptions) { + mismatches.push('canonical runtime options grammar'); + } + if (bootstrap.runtimeProperties['adapter'] !== `'${adapter}'`) { + mismatches.push(`adapter '${adapter}'`); + } + if (adapter === 'langgraph') { + if ( + bindingBootstrap.environmentBindings?.length !== 2 || + !bindingBootstrap.environmentBindings.every((binding) => + hasExactSemanticBinding( + binding, + './environments/environment', + 'environment' + ) + ) + ) { + mismatches.push('environment import from ./environments/environment'); + } + if ( + !hasExactSemanticBinding( + bindingBootstrap.operationReporterBinding, + '@threadplane/langgraph', + 'ɵLANGGRAPH_RUNTIME_OPERATION_REPORTER' + ) + ) { + mismatches.push('LangGraph operation reporter import'); + } + if ( + bootstrap.runtimeProperties['sharedApiUrl'] !== + 'environment.langGraphApiUrl' + ) { + mismatches.push('sharedApiUrl environment.langGraphApiUrl'); + } + if (bootstrap.runtimeProperties['assistantId'] !== expected.assistantId) { + mismatches.push(`assistantId ${expected.assistantId}`); + } + if ( + bootstrap.runtimeProperties['operationReporterToken'] !== + 'ɵLANGGRAPH_RUNTIME_OPERATION_REPORTER' + ) { + mismatches.push( + 'operationReporterToken ɵLANGGRAPH_RUNTIME_OPERATION_REPORTER' + ); + } + } else { + if (!runtimeBootstrap.hasPristineAgUrlGlobals) { + mismatches.push('pristine URL/document globals'); + } + if ( + !hasExactSemanticBinding( + bindingBootstrap.operationReporterBinding, + '@threadplane/ag-ui', + 'ɵAG_UI_RUNTIME_OPERATION_REPORTER' + ) + ) { + mismatches.push('AG-UI operation reporter import'); + } + if (bootstrap.runtimeProperties['sharedUrl'] !== expected.sharedUrl) { + mismatches.push(`sharedUrl ${expected.sharedUrl}`); + } + if ( + bootstrap.runtimeProperties['operationReporterToken'] !== + 'ɵAG_UI_RUNTIME_OPERATION_REPORTER' + ) { + mismatches.push( + 'operationReporterToken ɵAG_UI_RUNTIME_OPERATION_REPORTER' + ); + } + } + if (!bootstrap.hasRedactedCatch) mismatches.push('redacted catch owner'); + return mismatches; +} + +function hasExpectedProviderProperties( + providerCalls: CanonicalProviderCall[], + expected: Readonly> +): boolean { + return providerCalls.some((call) => + Object.entries(expected).every( + ([property, expression]) => call.properties[property] === expression + ) + ); +} + +function auditProviderProvenance( + sources: ReadonlyArray<{ + relativeFileName: string; + providerCalls: readonly CanonicalProviderCall[]; + }>, + expectedSource: string, + expectedOwner: ProviderRegistrationOwner +): string[] { + const actual = sources + .filter(({ providerCalls }) => providerCalls.length > 0) + .map(({ relativeFileName, providerCalls }) => ({ + relativeFileName, + count: providerCalls.length, + owners: providerCalls.map(({ owner }) => owner), + })); + return actual.length === 1 && + actual[0].relativeFileName === expectedSource && + actual[0].count === 1 && + JSON.stringify(actual[0].owners) === JSON.stringify([expectedOwner]) + ? [] + : [ + `Agent provider provenance ${JSON.stringify( + actual + )}; expected exactly one in ${expectedSource}`, + ]; +} + +function expectedProviderOwner( + expected: ExpectedRuntimeWiring +): ProviderRegistrationOwner { + return expected.providerSource === 'src/app/app.config.ts' + ? { kind: 'appConfig' } + : { kind: 'component', component: expected.rootComponent }; +} + +function auditThreadsRootProviders( + sources: ReadonlyArray<{ + relativeFileName: string; + angularProviders: readonly AngularProviderRecord[]; + }> +): string[] { + const expectedFile = 'src/app/app.config.ts'; + const providers = sources.flatMap(({ relativeFileName, angularProviders }) => + angularProviders.map((provider) => ({ relativeFileName, provider })) + ); + const mismatches: string[] = []; + const assertOne = ( + token: string, + valid: (provider: AngularProviderRecord) => boolean + ): void => { + const matches = providers.filter( + ({ provider }) => provider.provideToken === token + ); + if ( + matches.length !== 1 || + matches[0].relativeFileName !== expectedFile || + !valid(matches[0].provider) + ) { + mismatches.push(`${token} canonical provider in ${expectedFile}`); + } + }; + const canonicalConnection = (provider: AngularProviderRecord): boolean => + provider.canonicalFactory && + provider.connectionDeclaration === + 'const connection = injectCockpitRuntimeConnection()' && + provider.connectionCallCount === 1 && + !provider.connectionWrites; + const hasTokenBinding = ( + provider: AngularProviderRecord, + token: string + ): boolean => + hasExactSemanticBinding( + ( + provider as AngularProviderRecord & { + provideTokenBinding?: SemanticBindingInspection; + } + ).provideTokenBinding, + '@threadplane/langgraph', + token + ); + + assertOne( + 'LANGGRAPH_THREADS_CONFIG', + (provider) => + hasTokenBinding(provider, 'LANGGRAPH_THREADS_CONFIG') && + canonicalConnection(provider) && + provider.returnExpression === '{ apiUrl: connection.apiUrl }' && + JSON.stringify(provider.returnedProperties) === + JSON.stringify({ apiUrl: 'connection.apiUrl' }) + ); + assertOne( + 'LANGGRAPH_CLIENT_OPTIONS', + (provider) => + hasTokenBinding(provider, 'LANGGRAPH_CLIENT_OPTIONS') && + canonicalConnection(provider) && + provider.returnExpression === 'connection.clientOptions' && + Object.keys(provider.returnedProperties).length === 0 + ); + return mismatches; +} /** * The cockpit site is assembled from three lists that nothing forced to agree: @@ -80,27 +608,2072 @@ describe('cockpit capability wiring', () => { expect(orphans).toEqual([]); }); - it('surfaces every manifest product in the navigation tree', () => { - const manifestProducts = [ - ...new Set(cockpitManifest.map((entry) => entry.product)), - ]; - const navigationProducts = buildNavigationTree(cockpitManifest).map( - (product) => product.product + it('mirrors explicit runtime adapters for exactly 35 configurable and six static Angular applications', () => { + const filesystemProjects = readdirSync(cockpitRoot, { + withFileTypes: true, + }) + .filter((product) => product.isDirectory()) + .flatMap((product) => { + const productRoot = resolve(cockpitRoot, product.name); + return readdirSync(productRoot, { withFileTypes: true }) + .filter((topic) => topic.isDirectory()) + .flatMap((topic) => { + const projectPath = resolve( + productRoot, + topic.name, + 'angular/project.json' + ); + if (!existsSync(projectPath)) return []; + const project = JSON.parse(readFileSync(projectPath, 'utf8')) as { + name: string; + }; + return [project.name]; + }); + }) + .sort(); + const registryProjects = capabilities + .map((capability) => capability.angularProject) + .sort(); + const manifestByKey = new Map( + cockpitManifest + .filter((entry) => entry.entryKind === 'capability') + .map((entry) => [manifestKey(entry), entry.runtimeAdapter]) + ); + const descriptorByKey = new Map( + capabilityModules.map((descriptor) => [ + manifestKey(descriptor.manifestIdentity), + descriptor.runtimeAdapter, + ]) + ); + const registryAdapters = capabilities.map( + (capability) => capability.runtimeAdapter + ); + const mismatches = capabilities + .map((capability) => { + const key = `${capability.product}/core-capabilities/${capability.topic}`; + return { + key, + registry: capability.runtimeAdapter, + descriptor: descriptorByKey.get(key), + manifest: manifestByKey.get(key), + }; + }) + .filter( + ({ registry, descriptor, manifest }) => + registry !== descriptor || registry !== manifest + ); + + expect(filesystemProjects).toEqual(registryProjects); + expect( + registryAdapters.filter((adapter) => adapter !== 'none') + ).toHaveLength(35); + expect( + registryAdapters.filter((adapter) => adapter === 'none') + ).toHaveLength(6); + expect(registryAdapters).not.toContain(undefined); + expect(mismatches).toEqual([]); + }); + + it('routes every configurable Angular application through the generation-scoped runtime connection', () => { + const configurable = capabilities.filter( + ( + capability + ): capability is typeof capability & { + runtimeAdapter: 'ag-ui' | 'langgraph'; + } => capability.runtimeAdapter !== 'none' ); + const unmigrated = configurable.flatMap((capability) => { + const projectRoot = resolve( + cockpitRoot, + capability.product, + capability.topic, + 'angular' + ); + const entryPoints = ['main.ts', 'main.cockpit.ts'].map((fileName) => ({ + fileName, + source: readFileSync(resolve(projectRoot, 'src', fileName), 'utf8'), + })); + const sourceFiles = angularSourceFiles(projectRoot) + .map((fileName) => ({ + fileName, + relativeFileName: fileName.slice(projectRoot.length + 1), + source: readFileSync(fileName, 'utf8'), + })) + .filter( + ({ relativeFileName }) => + !relativeFileName.startsWith('src/environments/') + ); + const adapter = capability.runtimeAdapter; + const missing: string[] = []; + const expected = expectedRuntimeWiringByProject.get( + capability.angularProject + ); + + for (const { fileName, source } of entryPoints) { + const bootstrapCalls = inspectRuntimeTargetSource( + source, + fileName, + adapter + ).bootstrapCalls; + if (bootstrapCalls.length !== 1) { + missing.push(`${fileName}: exactly one executable bootstrap owner`); + continue; + } + if (!expected) { + missing.push(`${fileName}: audited runtime metadata table entry`); + continue; + } + for (const mismatch of auditEntrypointMetadata( + bootstrapCalls[0], + expected, + adapter + )) { + missing.push(`${fileName}: ${mismatch}`); + } + } - expect([...manifestProducts].sort()).toEqual( - [...navigationProducts].sort() + const sourceAudits = sourceFiles.map((file) => ({ + ...file, + inspection: inspectRuntimeTargetSource( + file.source, + file.relativeFileName, + adapter + ), + })); + if (expected) { + missing.push( + ...auditProviderProvenance( + sourceAudits.map(({ relativeFileName, inspection }) => ({ + relativeFileName, + providerCalls: inspection.providerCalls, + })), + expected.providerSource, + expectedProviderOwner(expected) + ) + ); + } + if (capability.angularProject === 'cockpit-chat-threads-angular') { + missing.push( + ...auditThreadsRootProviders( + sourceAudits.map(({ relativeFileName, inspection }) => ({ + relativeFileName, + angularProviders: inspection.angularProviders, + })) + ) + ); + } + for (const { relativeFileName, inspection } of sourceAudits) { + for (const issue of inspection.issues) { + missing.push(`${relativeFileName}: ${issue.kind} (${issue.detail})`); + } + } + + return missing.length === 0 + ? [] + : [{ project: capability.angularProject, missing }]; + }); + + expect(configurable).toHaveLength(35); + expect(unmigrated).toEqual([]); + }); + + it.each([ + [ + 'helper storage alias', + `const targetCache = window.localStorage;\nexport const readTarget = () => targetCache.getItem('runtime-target');`, + 'browser-state-read', + ], + [ + 'dot global key', + `export const key = globalThis.runtimeApiKey;`, + 'global-runtime-secret-read', + ], + [ + 'session storage bracket alias', + `const cache = window['session' + 'Storage'];\nexport const read = () => cache.getItem('target');`, + 'browser-state-read', + ], + [ + 'IndexedDB dot alias', + `const database = globalThis.indexedDB;\nexport const read = () => database.open('runtime');`, + 'browser-state-read', + ], + [ + 'bracket global target', + `export const target = window['runtimeTarget'];`, + 'global-runtime-secret-read', + ], + [ + 'aliased global key', + `const browserGlobal = globalThis;\nexport const key = browserGlobal.runtimeApiKey;`, + 'browser-state-read', + ], + [ + 'reverse-ordered global alias bracket endpoint', + `const earlierAlias = laterAlias;\nconst laterAlias = globalThis;\nexport const endpoint = earlierAlias['runtimeEndpoint'];`, + 'browser-state-read', + ], + [ + 'location href', + `export const endpoint = new URL(location.href).searchParams.get('endpoint');`, + 'browser-state-read', + ], + [ + 'location shorthand', + `export const runtimeInput = { location };`, + 'browser-state-read', + ], + [ + 'location read outside an unrelated shadowing scope', + `function render(location: string) { return { location }; }\nexport const runtimeInput = { location };`, + 'browser-state-read', + ], + [ + 'history state', + `export const target = history.state.runtimeTarget;`, + 'browser-state-read', + ], + [ + 'cookie bracket access', + `export const key = document['cookie'];`, + 'browser-state-read', + ], + [ + 'destructured cookie access', + `const { cookie: runtimeCookie } = document;\nexport { runtimeCookie };`, + 'browser-state-read', + ], + [ + 'query helper', + `export const key = new URLSearchParams('?apiKey=x').get('apiKey');`, + 'browser-state-read', + ], + [ + 'aliased provider with imported config', + `import { provideAgent as wireAgent } from '@threadplane/langgraph';\nimport { runtimeConfig } from './runtime-config';\nexport const providers = [wireAgent(runtimeConfig)];`, + 'noncanonical-provider-wiring', + ], + [ + 'namespace provider with direct config', + `import * as agentApi from '@threadplane/ag-ui';\nconst config = { url: '/agent' };\nexport const providers = [agentApi.provideAgent(config)];`, + 'noncanonical-provider-wiring', + ], + [ + 'locally aliased provider with direct config', + `import { provideAgent } from '@threadplane/ag-ui';\nconst wire = provideAgent;\nexport const providers = [wire({ url: '/agent' })];`, + 'noncanonical-provider-wiring', + ], + [ + 'factory returning imported config', + `import { provideAgent } from '@threadplane/langgraph';\nimport { runtimeConfig } from './runtime-config';\nexport const providers = [provideAgent(() => runtimeConfig)];`, + 'noncanonical-provider-wiring', + ], + [ + 'aliased direct Agent construction', + `import { Agent as RuntimeAgent } from '@threadplane/ag-ui';\nexport const agent = new RuntimeAgent({ url: '/agent' });`, + 'direct-agent-construction', + ], + [ + 'imported key helper', + `import { runtimeApiKey as key } from './runtime-target';\nexport { key };`, + 'imported-runtime-secret', + ], + [ + 'module-global target cache', + `let runtimeTargetCache: { url: string } | undefined;\nexport const read = () => runtimeTargetCache;`, + 'module-global-runtime-cache', + ], + [ + 'module-global API key', + `let apiKey: string | undefined;\nexport const read = () => apiKey;`, + 'module-global-runtime-cache', + ], + [ + 'helper target logging', + `export function debug(runtimeTarget: unknown) { console.info('target', runtimeTarget); }`, + 'runtime-secret-log', + ], + [ + 'aliased helper target logging', + `export function debug(runtimeTarget: unknown) { const payload = runtimeTarget; console.info(payload); }`, + 'runtime-secret-log', + ], + ])('catches the adversarial %s bypass', (_label, source, expectedKind) => { + expect( + auditRuntimeTargetSource(source, 'src/app/runtime-helper.ts').map( + ({ kind }) => kind + ) + ).toContain(expectedKind); + }); + + it('rejects aliased provider and connection imports even when their factory is otherwise valid', () => { + const source = ` + import { provideAgent as wireAgent } from '@threadplane/langgraph'; + import { injectCockpitRuntimeConnection as useConnection } from '@threadplane/cockpit-telemetry'; + export const providers = [wireAgent(() => { + const connection = useConnection(); + if (connection.adapter !== 'langgraph') throw new Error('incompatible runtime'); + return { + apiUrl: connection.apiUrl, + assistantId: connection.assistantId, + clientOptions: connection.clientOptions, + }; + })]; + `; + + expect( + auditRuntimeTargetSource(source, 'src/app/app.config.ts').map( + ({ kind }) => kind + ) + ).toContain('noncanonical-provider-wiring'); + }); + + it.each([ + [ + 'helper-return environment config', + `import { environment } from '../environments/environment';\nexport const makeConfig = () => ({ apiUrl: environment.langGraphApiUrl });`, + 'environment-config-outside-entrypoint', + ], + [ + 'runtime connection typed cache', + `let value: CockpitRuntimeConnection | undefined;\nexport const read = () => value;`, + 'module-global-runtime-cache', + ], + [ + 'structurally typed key cache', + `let value: { apiKey: string } | undefined;\nexport const read = () => value;`, + 'module-global-runtime-cache', + ], + [ + 'structurally typed endpoint cache', + `let value: { endpoint: string } | undefined;\nexport const read = () => value;`, + 'module-global-runtime-cache', + ], + [ + 'runtime target initializer cache', + `const value = { runtimeTarget: '/agent' };\nexport const read = () => value;`, + 'module-global-runtime-cache', + ], + [ + 'runtime session typed cache', + `let value: RuntimeTargetSession | undefined;\nexport const read = () => value;`, + 'module-global-runtime-cache', + ], + [ + 'destructured provider', + `import * as agentApi from '@threadplane/ag-ui';\nconst { provideAgent } = agentApi;\nexport const providers = [provideAgent(() => ({ url: '/agent' }))];`, + 'noncanonical-provider-wiring', + ], + [ + 'reverse provider alias', + `import { provideAgent as wireAgent } from '@threadplane/ag-ui';\nconst provideAgent = wireAgent;\nexport const providers = [provideAgent(() => ({ url: '/agent' }))];`, + 'noncanonical-provider-wiring', + ], + [ + 'computed connection field', + `import { injectCockpitRuntimeConnection } from '@threadplane/cockpit-telemetry';\nimport { provideAgent } from '@threadplane/ag-ui';\nexport const providers = [provideAgent(() => { const connection = injectCockpitRuntimeConnection(); return { ['url']: connection.url }; })];`, + 'noncanonical-provider-wiring', + ], + [ + 'spread connection fields', + `import { injectCockpitRuntimeConnection } from '@threadplane/cockpit-telemetry';\nimport { provideAgent } from '@threadplane/ag-ui';\nexport const providers = [provideAgent(() => { const connection = injectCockpitRuntimeConnection(); return { ...connection }; })];`, + 'noncanonical-provider-wiring', + ], + [ + 'shorthand connection field', + `import { injectCockpitRuntimeConnection } from '@threadplane/cockpit-telemetry';\nimport { provideAgent } from '@threadplane/ag-ui';\nexport const providers = [provideAgent(() => { const connection = injectCockpitRuntimeConnection(); const url = connection.url; return { url }; })];`, + 'noncanonical-provider-wiring', + ], + [ + 'mutable connection declaration', + `import { injectCockpitRuntimeConnection } from '@threadplane/cockpit-telemetry';\nimport { provideAgent } from '@threadplane/ag-ui';\nexport const providers = [provideAgent(() => { let connection = injectCockpitRuntimeConnection(); return { url: connection.url }; })];`, + 'noncanonical-provider-wiring', + ], + [ + 'connection reassignment', + `import { injectCockpitRuntimeConnection } from '@threadplane/cockpit-telemetry';\nimport { provideAgent } from '@threadplane/ag-ui';\nexport const providers = [provideAgent(() => { const connection = injectCockpitRuntimeConnection(); connection = injectCockpitRuntimeConnection(); return { url: connection.url }; })];`, + 'noncanonical-provider-wiring', + ], + [ + 'return before canonical connection declaration', + `import { injectCockpitRuntimeConnection } from '@threadplane/cockpit-telemetry';\nimport { provideAgent } from '@threadplane/ag-ui';\nexport const providers = [provideAgent(() => { return { url: connection.url }; const connection = injectCockpitRuntimeConnection(); if (connection.adapter !== 'ag-ui') { throw new Error('incompatible runtime'); } })];`, + 'noncanonical-provider-wiring', + ], + [ + 'unreachable statement before canonical connection declaration', + `import { injectCockpitRuntimeConnection } from '@threadplane/cockpit-telemetry';\nimport { provideAgent } from '@threadplane/ag-ui';\nexport const providers = [provideAgent(() => { throw new Error('unreachable'); const connection = injectCockpitRuntimeConnection(); if (connection.adapter !== 'ag-ui') { throw new Error('incompatible runtime'); } return { url: connection.url }; })];`, + 'noncanonical-provider-wiring', + ], + [ + 'nested alternate Agent return', + `import { injectCockpitRuntimeConnection } from '@threadplane/cockpit-telemetry';\nimport { provideAgent } from '@threadplane/ag-ui';\nexport const providers = [provideAgent(() => { const connection = injectCockpitRuntimeConnection(); if (connection.adapter !== 'ag-ui') { throw new Error('incompatible runtime'); } if (flag) return { url: connection.url }; return { url: connection.url }; })];`, + 'noncanonical-provider-wiring', + ], + [ + 'connection passed to mutating helper', + `import { injectCockpitRuntimeConnection } from '@threadplane/cockpit-telemetry';\nimport { provideAgent } from '@threadplane/ag-ui';\nexport const providers = [provideAgent(() => { const connection = injectCockpitRuntimeConnection(); if (connection.adapter !== 'ag-ui') { throw new Error('incompatible runtime'); } mutate(connection); return { url: connection.url }; })];`, + 'noncanonical-provider-wiring', + ], + [ + 'Object.assign connection mutation', + `import { injectCockpitRuntimeConnection } from '@threadplane/cockpit-telemetry';\nimport { provideAgent } from '@threadplane/ag-ui';\nexport const providers = [provideAgent(() => { const connection = injectCockpitRuntimeConnection(); if (connection.adapter !== 'ag-ui') { throw new Error('incompatible runtime'); } Object.assign(connection, { url: '/wrong' }); return { url: connection.url }; })];`, + 'noncanonical-provider-wiring', + ], + [ + 'provider element alias declaration', + `import * as api from '@threadplane/ag-ui';\nconst wire = api['provideAgent'];\nexport const providers = [wire(() => ({ url: '/agent' }))];`, + 'noncanonical-provider-wiring', + ], + [ + 'provider renamed destructuring declaration', + `import * as api from '@threadplane/ag-ui';\nconst { provideAgent: wire } = api;\nexport const providers = [wire(() => ({ url: '/agent' }))];`, + 'noncanonical-provider-wiring', + ], + [ + 'window alias declaration', + `const browser = window;\nexport const value = browser.localStorage;`, + 'browser-state-read', + ], + [ + 'document alias declaration', + `const doc = document;\nexport const value = doc.cookie;`, + 'browser-state-read', + ], + [ + 'self alias declaration', + `const browser = self;\nexport const value = browser.location;`, + 'browser-state-read', + ], + [ + 'function-local window alias', + `export function read() { const w = window; return w.localStorage; }`, + 'browser-state-read', + ], + [ + 'function-local document alias', + `export function read() { const d = document; return d.cookie; }`, + 'browser-state-read', + ], + [ + 'function-local destructured session storage', + `export function read() { const { sessionStorage: cache } = window; return cache; }`, + 'browser-state-read', + ], + [ + 'function-local reverse browser alias chain', + `export function read() { const w = browser; const browser = globalThis; return w.localStorage; }`, + 'browser-state-read', + ], + [ + 'storage from a window parent alias', + `const parentWindow = window.parent;\nexport const cache = parentWindow.localStorage;`, + 'browser-state-read', + ], + [ + 'cookie destructured from an arbitrary helper result', + `const { cookie } = readHeaders();\nexport { cookie };`, + 'browser-state-read', + ], + [ + 'runtime key in a default parameter', + `export function read(config = { apiKey: 'secret' }) { return config; }`, + 'global-runtime-secret-read', + ], + [ + 'runtime target returned from a helper argument', + `export function read(config: { runtimeTarget: string }) { return config.runtimeTarget; }`, + 'global-runtime-secret-read', + ], + [ + 'authorization assignment on an arbitrary base', + `export function write(headers: Record) { headers.authorization = 'secret'; }`, + 'global-runtime-secret-read', + ], + [ + 'computed API key read on an arbitrary base', + `export function read(config: Record) { return config['api' + 'Key']; }`, + 'global-runtime-secret-read', + ], + [ + 'sensitive member on a shadowed browser parameter', + `export function read(window: { localStorage: unknown }) { return window.localStorage; }`, + 'browser-state-read', + ], + [ + 'location href through a parent alias', + `const parentWindow = window.parent;\nexport const endpoint = parentWindow.location.href;`, + 'browser-state-read', + ], + [ + 'history state through an arbitrary helper', + `export const target = helper.history.state;`, + 'browser-state-read', + ], + ])('rejects the canonical-policy bypass: %s', (_label, source, kind) => { + expect( + auditRuntimeTargetSource(source, 'src/app/runtime-helper.ts').map( + (issue) => issue.kind + ) + ).toContain(kind); + }); + + it.each([ + [ + 'imported location', + `import { location } from './schema';\nexport const schema = { location };`, + ], + [ + 'local location', + `export function schema(location: string) { return { location }; }`, + ], + ['parent window handle', `export const parentWindow = window['parent'];`], + ['literal console', `console.info('component mounted');`], + [ + 'non-sensitive compound identifiers', + `export const linkTarget = '_blank';\nexport const sessionLabel = 'one';\nexport const connectionStatus = 'ready';\nexport const endpointCount = 2;\nexport const keyboardNavigation = true;`, + ], + [ + 'non-sensitive console tokens', + `console.info('keyboard navigation', { connectionStatus: 'ready', endpointCount: 2 });`, + ], + ])('allows harmless source: %s', (_label, source) => { + expect(auditRuntimeTargetSource(source, 'src/app/component.ts')).toEqual( + [] ); + }); - for (const product of buildNavigationTree(cockpitManifest)) { - const entries = product.sections.flatMap((section) => section.entries); - expect({ product: product.product, empty: entries.length === 0 }).toEqual( - { - product: product.product, - empty: false, + it('rejects Agent providers copied to orphan files, moved to the wrong file, or duplicated', () => { + const canonicalSource = ` + import { injectCockpitRuntimeConnection } from '@threadplane/cockpit-telemetry'; + import { provideAgent } from '@threadplane/ag-ui'; + export const appConfig = { providers: [provideAgent(() => { + const connection = injectCockpitRuntimeConnection(); + if (connection.adapter !== 'ag-ui') { + throw new Error('incompatible runtime'); } + return { url: connection.url }; + })] }; + `; + const source = (relativeFileName: string) => ({ + relativeFileName, + providerCalls: inspectRuntimeTargetSource( + canonicalSource, + relativeFileName, + 'ag-ui' + ).providerCalls, + }); + const expected = source('src/app/app.config.ts'); + const orphan = source('src/app/orphan.ts'); + + expect( + auditProviderProvenance([expected], expected.relativeFileName, { + kind: 'appConfig', + }) + ).toEqual([]); + expect( + auditProviderProvenance([orphan], expected.relativeFileName, { + kind: 'appConfig', + }) + ).not.toEqual([]); + expect( + auditProviderProvenance([expected, orphan], expected.relativeFileName, { + kind: 'appConfig', + }) + ).not.toEqual([]); + expect( + auditProviderProvenance( + [ + { + relativeFileName: expected.relativeFileName, + providerCalls: [ + ...expected.providerCalls, + ...expected.providerCalls, + ], + }, + ], + expected.relativeFileName, + { kind: 'appConfig' } + ) + ).not.toEqual([]); + }); + + it('pins provideAgent calls to their executable Angular registration owner', () => { + const preamble = ` + import { Component } from '@angular/core'; + import { injectCockpitRuntimeConnection } from '@threadplane/cockpit-telemetry'; + import { provideAgent } from '@threadplane/langgraph'; + `; + const providerCall = `provideAgent(() => { + const connection = injectCockpitRuntimeConnection(); + if (connection.adapter !== 'langgraph') { + throw new Error('incompatible runtime'); + } + return { + apiUrl: connection.apiUrl, + assistantId: connection.assistantId, + clientOptions: connection.clientOptions, + }; + })`; + const inspect = (source: string) => + inspectRuntimeTargetSource( + `${preamble}\n${source}`, + 'src/app/app.config.ts', + 'langgraph' + ); + const rejected = (source: string): boolean => + inspect(source).issues.some( + ({ kind }) => kind === 'noncanonical-provider-wiring' + ); + + const appConfig = inspect( + `export const appConfig = { providers: [${providerCall}] };` + ); + expect(appConfig.issues).toEqual([]); + expect(appConfig.providerCalls[0]?.owner).toEqual({ kind: 'appConfig' }); + const typeOnlyAppConfigReference = inspect( + `export const appConfig = { providers: [${providerCall}] }; type AppConfigShape = typeof appConfig;` + ); + expect(typeOnlyAppConfigReference.issues).toEqual([]); + expect(typeOnlyAppConfigReference.providerCalls[0]?.owner).toEqual({ + kind: 'appConfig', + }); + + const component = inspect(` + @Component({ providers: [${providerCall}], template: '' }) + export class PersistenceComponent {} + `); + expect(component.issues).toEqual([]); + expect(component.providerCalls[0]?.owner).toEqual({ + kind: 'component', + component: 'PersistenceComponent', + }); + + expect(rejected(`${providerCall};`)).toBe(true); + expect( + rejected(`const appConfig = { providers: [${providerCall}] };`) + ).toBe(true); + expect( + rejected( + `export let appConfig = { providers: [${providerCall}] }; appConfig = { providers: [] };` + ) + ).toBe(true); + expect( + rejected( + `export const appConfig = { providers: [${providerCall}] }; appConfig.providers = [];` + ) + ).toBe(true); + expect( + rejected( + `export const appConfig = { providers: [${providerCall}] }; appConfig.providers.splice(0, 1);` + ) + ).toBe(true); + expect( + rejected( + `export const appConfig = { providers: [${providerCall}] }; appConfig.providers.pop();` + ) + ).toBe(true); + expect( + rejected( + `export const appConfig = { providers: [${providerCall}] }; registerProviders(appConfig);` + ) + ).toBe(true); + expect( + rejected( + `export const appConfig = { providers: [${providerCall}] }; Object.defineProperty(appConfig, 'providers', { value: [] });` + ) + ).toBe(true); + expect( + rejected( + `export const appConfig = { providers: [${providerCall}], mutate: () => appConfig.providers.pop() };` + ) + ).toBe(true); + expect( + rejected( + `export const appConfig = { providers: [${providerCall}] }; export const appConfig = { providers: [] };` + ) + ).toBe(true); + expect( + rejected( + `const deadProviders = [${providerCall}]; export const appConfig = { providers: [] };` + ) + ).toBe(true); + const wrongComponent = inspect( + `@Component({ providers: [${providerCall}], template: '' }) export class WrongComponent {}` + ); + expect(wrongComponent.issues).toEqual([]); + expect( + auditProviderProvenance( + [ + { + relativeFileName: 'src/app/persistence.component.ts', + providerCalls: wrongComponent.providerCalls, + }, + ], + 'src/app/persistence.component.ts', + { kind: 'component', component: 'PersistenceComponent' } + ) + ).not.toEqual([]); + expect( + rejected( + `export const appConfig = { providers: [...baseProviders, ${providerCall}] };` + ) + ).toBe(true); + expect( + rejected( + `const providers = [${providerCall}]; export const appConfig = { providers };` + ) + ).toBe(true); + expect( + rejected(`export const appConfig = { ['providers']: [${providerCall}] };`) + ).toBe(true); + expect( + rejected( + `export const appConfig = { providers: [${providerCall}], providers: [] };` + ) + ).toBe(true); + expect( + rejected( + `export const appConfig = { providers: [${providerCall}], ...override };` + ) + ).toBe(true); + expect( + rejected( + `@Component({ providers: [...baseProviders, ${providerCall}], template: '' }) export class PersistenceComponent {}` + ) + ).toBe(true); + expect( + rejected( + `@Component({ ['providers']: [${providerCall}], template: '' }) export class PersistenceComponent {}` + ) + ).toBe(true); + const inspectWithoutComponentImport = (source: string) => + inspectRuntimeTargetSource( + `${preamble.replace( + "import { Component } from '@angular/core';", + '' + )}\n${source}`, + 'src/app/persistence.component.ts', + 'langgraph' + ); + const rejectsRawComponentOwner = (source: string): boolean => + inspectWithoutComponentImport(source).issues.some( + ({ kind }) => kind === 'noncanonical-provider-wiring' + ); + expect( + rejectsRawComponentOwner( + `function Component(_metadata: unknown) { return () => undefined; } + @Component({ providers: [${providerCall}], template: '' }) export class PersistenceComponent {}` + ) + ).toBe(true); + expect( + rejectsRawComponentOwner( + `import { Component as NgComponent } from '@angular/core'; + @NgComponent({ providers: [${providerCall}], template: '' }) export class PersistenceComponent {}` + ) + ).toBe(true); + expect( + rejected( + `const Component = fakeComponent; + @Component({ providers: [${providerCall}], template: '' }) export class PersistenceComponent {}` + ) + ).toBe(true); + expect( + rejected( + `Component = fakeComponent; + @Component({ providers: [${providerCall}], template: '' }) export class PersistenceComponent {}` + ) + ).toBe(true); + }); + + it('rejects comment-only, wrong, helper-indirected, and duplicate Threads root providers', () => { + const inspect = ( + source: string, + relativeFileName = 'src/app/app.config.ts' + ) => { + const inspection = inspectRuntimeTargetSource( + source, + relativeFileName, + 'langgraph' ); + return { + relativeFileName, + angularProviders: inspection.angularProviders, + issues: inspection.issues, + }; + }; + const factory = (body: string) => `() => { + const connection = injectCockpitRuntimeConnection(); + if (connection.adapter !== 'langgraph') { + throw new Error('incompatible runtime'); + } + ${body} + }`; + const validSource = ` + import { injectCockpitRuntimeConnection } from '@threadplane/cockpit-telemetry'; + import { LANGGRAPH_CLIENT_OPTIONS, LANGGRAPH_THREADS_CONFIG } from '@threadplane/langgraph'; + export const appConfig = { providers: [ + { provide: LANGGRAPH_THREADS_CONFIG, useFactory: ${factory( + 'return { apiUrl: connection.apiUrl };' + )} }, + { provide: LANGGRAPH_CLIENT_OPTIONS, useFactory: ${factory( + 'return connection.clientOptions;' + )} }, + ] }; + `; + + expect(auditThreadsRootProviders([inspect(validSource)])).toEqual([]); + expect( + auditThreadsRootProviders([ + inspect( + validSource.replace( + "from '@threadplane/langgraph';", + "from './fake-langgraph';" + ) + ), + ]) + ).not.toEqual([]); + expect( + auditThreadsRootProviders([ + inspect( + validSource.replace( + "import { LANGGRAPH_CLIENT_OPTIONS, LANGGRAPH_THREADS_CONFIG } from '@threadplane/langgraph';", + 'const LANGGRAPH_CLIENT_OPTIONS = fakeClientOptions; const LANGGRAPH_THREADS_CONFIG = fakeThreadsConfig;' + ) + ), + ]) + ).not.toEqual([]); + expect( + auditThreadsRootProviders([ + inspect(` + // provide: LANGGRAPH_THREADS_CONFIG, useFactory: () => ({ apiUrl: connection.apiUrl }) + export const appConfig = { providers: [ + { provide: LANGGRAPH_CLIENT_OPTIONS, useFactory: ${factory( + 'return connection.clientOptions;' + )} }, + ] }; + `), + ]) + ).toContain( + 'LANGGRAPH_THREADS_CONFIG canonical provider in src/app/app.config.ts' + ); + expect( + auditThreadsRootProviders([inspect(validSource, 'src/app/orphan.ts')]) + ).toEqual([ + 'LANGGRAPH_THREADS_CONFIG canonical provider in src/app/app.config.ts', + 'LANGGRAPH_CLIENT_OPTIONS canonical provider in src/app/app.config.ts', + ]); + expect( + auditThreadsRootProviders([ + inspect( + validSource.replace( + 'return { apiUrl: connection.apiUrl };', + 'return { apiUrl: other.apiUrl };' + ) + ), + ]) + ).toContain( + 'LANGGRAPH_THREADS_CONFIG canonical provider in src/app/app.config.ts' + ); + expect( + auditThreadsRootProviders([ + inspect( + validSource.replace( + `useFactory: ${factory('return connection.clientOptions;')}`, + 'useFactory: makeClientOptions' + ) + ), + ]) + ).toContain( + 'LANGGRAPH_CLIENT_OPTIONS canonical provider in src/app/app.config.ts' + ); + expect( + auditThreadsRootProviders([ + inspect( + validSource.replace( + '] };', + `, { provide: LANGGRAPH_THREADS_CONFIG, useFactory: ${factory( + 'return { apiUrl: connection.apiUrl };' + )} }] };` + ) + ), + ]) + ).toContain( + 'LANGGRAPH_THREADS_CONFIG canonical provider in src/app/app.config.ts' + ); + const earlyThreadsFactory = `() => { + return { apiUrl: connection.apiUrl }; + const connection = injectCockpitRuntimeConnection(); + if (connection.adapter !== 'langgraph') { + throw new Error('incompatible runtime'); + } + }`; + expect( + auditThreadsRootProviders([ + inspect( + validSource.replace( + factory('return { apiUrl: connection.apiUrl };'), + earlyThreadsFactory + ) + ), + ]) + ).toContain( + 'LANGGRAPH_THREADS_CONFIG canonical provider in src/app/app.config.ts' + ); + const nestedClientFactory = `() => { + const connection = injectCockpitRuntimeConnection(); + if (connection.adapter !== 'langgraph') { + throw new Error('incompatible runtime'); + } + if (flag) return connection.clientOptions; + return connection.clientOptions; + }`; + expect( + auditThreadsRootProviders([ + inspect( + validSource.replace( + factory('return connection.clientOptions;'), + nestedClientFactory + ) + ), + ]) + ).toContain( + 'LANGGRAPH_CLIENT_OPTIONS canonical provider in src/app/app.config.ts' + ); + const rejectFactory = ( + validFactory: string, + invalidFactory: string, + token: 'LANGGRAPH_THREADS_CONFIG' | 'LANGGRAPH_CLIENT_OPTIONS' + ): void => { + expect( + auditThreadsRootProviders([ + inspect(validSource.replace(validFactory, invalidFactory)), + ]) + ).toContain(`${token} canonical provider in src/app/app.config.ts`); + }; + const threadsReturn = 'return { apiUrl: connection.apiUrl };'; + const clientReturn = 'return connection.clientOptions;'; + rejectFactory( + factory(threadsReturn), + `() => { + const connection = injectCockpitRuntimeConnection(); + throw new Error('unreachable'); + if (connection.adapter !== 'langgraph') { throw new Error('incompatible runtime'); } + ${threadsReturn} + }`, + 'LANGGRAPH_THREADS_CONFIG' + ); + rejectFactory( + factory(threadsReturn), + `() => { + const connection = injectCockpitRuntimeConnection(); + if (connection.adapter !== 'langgraph') { throw new Error('incompatible runtime'); } + if (flag) ${threadsReturn} + ${threadsReturn} + }`, + 'LANGGRAPH_THREADS_CONFIG' + ); + rejectFactory( + factory(clientReturn), + `() => { + ${clientReturn} + const connection = injectCockpitRuntimeConnection(); + if (connection.adapter !== 'langgraph') { throw new Error('incompatible runtime'); } + }`, + 'LANGGRAPH_CLIENT_OPTIONS' + ); + rejectFactory( + factory(clientReturn), + `() => { + const connection = injectCockpitRuntimeConnection(); + throw new Error('unreachable'); + if (connection.adapter !== 'langgraph') { throw new Error('incompatible runtime'); } + ${clientReturn} + }`, + 'LANGGRAPH_CLIENT_OPTIONS' + ); + rejectFactory( + factory(threadsReturn), + factory(`mutate(connection); ${threadsReturn}`), + 'LANGGRAPH_THREADS_CONFIG' + ); + rejectFactory( + factory(clientReturn), + factory(`Reflect.set(connection, 'clientOptions', {}); ${clientReturn}`), + 'LANGGRAPH_CLIENT_OPTIONS' + ); + rejectFactory( + factory(threadsReturn), + `(injectCockpitRuntimeConnection = fakeInjector) => { + const connection = injectCockpitRuntimeConnection(); + if (connection.adapter !== 'langgraph') { throw new Error('incompatible runtime'); } + ${threadsReturn} + }`, + 'LANGGRAPH_THREADS_CONFIG' + ); + rejectFactory( + factory(clientReturn), + `function injectCockpitRuntimeConnection() { + const connection = injectCockpitRuntimeConnection(); + if (connection.adapter !== 'langgraph') { throw new Error('incompatible runtime'); } + ${clientReturn} + }`, + 'LANGGRAPH_CLIENT_OPTIONS' + ); + const deadProviders = ` + export const appConfig = { providers: [] }; + ({ provide: LANGGRAPH_THREADS_CONFIG, useFactory: ${factory( + 'return { apiUrl: connection.apiUrl };' + )} }); + ({ provide: LANGGRAPH_CLIENT_OPTIONS, useFactory: ${factory( + 'return connection.clientOptions;' + )} }); + `; + expect(auditThreadsRootProviders([inspect(deadProviders)])).not.toEqual([]); + const trailingSpread = validSource.replace( + '] };', + ', ...runtimeProviderOverrides] };' + ); + expect(auditThreadsRootProviders([inspect(trailingSpread)])).not.toEqual( + [] + ); + expect(inspect(trailingSpread).issues.map(({ kind }) => kind)).toContain( + 'noncanonical-provider-wiring' + ); + const computedProvider = validSource.replace( + 'provide: LANGGRAPH_THREADS_CONFIG', + "['provide']: LANGGRAPH_THREADS_CONFIG" + ); + expect(inspect(computedProvider).issues.map(({ kind }) => kind)).toContain( + 'noncanonical-provider-wiring' + ); + const spreadProvider = validSource.replace( + '{ provide: LANGGRAPH_THREADS_CONFIG', + '{ ...override, provide: LANGGRAPH_THREADS_CONFIG' + ); + expect(inspect(spreadProvider).issues.map(({ kind }) => kind)).toContain( + 'noncanonical-provider-wiring' + ); + expect( + inspect( + `export const appConfig = { providers: [{ provide, useFactory }] };` + ).issues.map(({ kind }) => kind) + ).toContain('noncanonical-provider-wiring'); + }); + + it('preserves the exact audited root, default, typed-ref, and provider-option metadata', () => { + const configurable = capabilities.filter( + ( + capability + ): capability is typeof capability & { + runtimeAdapter: 'ag-ui' | 'langgraph'; + } => capability.runtimeAdapter !== 'none' + ); + const mismatches: Array<{ project: string; mismatch: string }> = []; + + expect(expectedRuntimeWiringByProject.size).toBe(35); + expect(expectedRootImportSourceByProject.size).toBe(35); + expect( + expectedRuntimeWiring.filter(({ assistantId }) => assistantId) + ).toHaveLength(25); + expect( + expectedRuntimeWiring.filter(({ sharedUrl }) => sharedUrl) + ).toHaveLength(10); + expect( + expectedRuntimeWiring.filter(({ agentRef }) => agentRef) + ).toHaveLength(4); + expect([...expectedRuntimeWiringByProject.keys()].sort()).toEqual( + configurable.map(({ angularProject }) => angularProject).sort() + ); + expect([...expectedRootImportSourceByProject.keys()].sort()).toEqual( + configurable.map(({ angularProject }) => angularProject).sort() + ); + + for (const capability of configurable) { + const expected = expectedRuntimeWiringByProject.get( + capability.angularProject + ); + if (!expected) continue; + const projectRoot = resolve( + cockpitRoot, + capability.product, + capability.topic, + 'angular' + ); + const entryPoints = ['main.ts', 'main.cockpit.ts'].map((fileName) => ({ + fileName, + source: readFileSync(resolve(projectRoot, 'src', fileName), 'utf8'), + })); + const providerSources = angularSourceFiles(projectRoot) + .filter((fileName) => !fileName.includes('/src/environments/')) + .map((fileName) => { + const relativeFileName = fileName.slice(projectRoot.length + 1); + return { + relativeFileName, + providerCalls: inspectRuntimeTargetSource( + readFileSync(fileName, 'utf8'), + relativeFileName, + capability.runtimeAdapter + ).providerCalls, + }; + }); + const providerCalls = providerSources.flatMap( + ({ providerCalls }) => providerCalls + ); + for (const mismatch of auditProviderProvenance( + providerSources, + expected.providerSource, + expectedProviderOwner(expected) + )) { + mismatches.push({ + project: capability.angularProject, + mismatch, + }); + } + + for (const provider of providerCalls) { + const bindingProvider = provider as CanonicalProviderCall & { + provideAgentBinding?: SemanticBindingInspection; + agentRefBinding?: SemanticBindingInspection; + }; + const providerModule = + capability.runtimeAdapter === 'ag-ui' + ? '@threadplane/ag-ui' + : '@threadplane/langgraph'; + if ( + !hasExactSemanticBinding( + bindingProvider.provideAgentBinding, + providerModule, + 'provideAgent' + ) + ) { + mismatches.push({ + project: capability.angularProject, + mismatch: `provideAgent import from ${providerModule}`, + }); + } + if ( + expected.agentRef && + !hasExactSemanticBinding( + bindingProvider.agentRefBinding, + './agent-ref', + expected.agentRef + ) + ) { + mismatches.push({ + project: capability.angularProject, + mismatch: `typed ref ${expected.agentRef} import from ./agent-ref`, + }); + } + } + + for (const { fileName, source } of entryPoints) { + const bootstrap = inspectRuntimeTargetSource( + source, + fileName, + capability.runtimeAdapter + ).bootstrapCalls[0]; + for (const mismatch of auditEntrypointMetadata( + bootstrap, + expected, + capability.runtimeAdapter + )) { + mismatches.push({ + project: capability.angularProject, + mismatch: `${fileName}: ${mismatch}`, + }); + } + } + + const actualAgentRefs = providerCalls + .map(({ agentRef }) => agentRef) + .filter((agentRef): agentRef is string => !!agentRef) + .sort(); + const expectedAgentRefs = expected.agentRef ? [expected.agentRef] : []; + if ( + JSON.stringify(actualAgentRefs) !== JSON.stringify(expectedAgentRefs) + ) { + mismatches.push({ + project: capability.angularProject, + mismatch: `typed refs ${ + actualAgentRefs.join(', ') || '(none)' + }; expected ${expectedAgentRefs.join(', ') || '(none)'}`, + }); + } + if ( + expected.retainedProviderProperties && + !hasExpectedProviderProperties( + providerCalls, + expected.retainedProviderProperties + ) + ) { + mismatches.push({ + project: capability.angularProject, + mismatch: `retained provider properties ${JSON.stringify( + expected.retainedProviderProperties + )}`, + }); + } } + + expect(mismatches).toEqual([]); + }); + + it('rejects swapped roots, assistants, shared defaults, and typed refs', () => { + const langGraphExpected: ExpectedRuntimeWiring = { + project: 'fixture', + rootComponent: 'StreamingComponent', + providerSource: 'src/app/app.config.ts', + assistantId: 'environment.streamingAssistantId', + agentRef: 'STREAMING_AGENT', + }; + const validLangGraph = ` + void bootstrapWithCockpitHarness(StreamingComponent, appConfig, { + runtime: { + adapter: 'langgraph', + sharedApiUrl: environment.langGraphApiUrl, + assistantId: environment.streamingAssistantId, + operationReporterToken: ɵLANGGRAPH_RUNTIME_OPERATION_REPORTER, + }, + }).catch(() => undefined); + `; + const bootstrapRecord = ( + source: string, + adapter: 'ag-ui' | 'langgraph' + ): BootstrapCallRecord | undefined => + inspectRuntimeTargetSource(source, 'main.ts', adapter).bootstrapCalls[0]; + + expect( + auditEntrypointMetadata( + bootstrapRecord( + `${validLangGraph.replace( + 'StreamingComponent', + 'MessagesComponent' + )}\n// Expected: bootstrapWithCockpitHarness(StreamingComponent, appConfig, ...)`, + 'langgraph' + ), + langGraphExpected, + 'langgraph' + ) + ).toContain('root StreamingComponent'); + expect( + auditEntrypointMetadata( + bootstrapRecord( + `${validLangGraph.replace( + 'environment.streamingAssistantId', + 'environment.clientToolsAssistantId' + )}\n// assistantId: environment.streamingAssistantId`, + 'langgraph' + ), + langGraphExpected, + 'langgraph' + ) + ).toContain('assistantId environment.streamingAssistantId'); + expect( + auditEntrypointMetadata( + bootstrapRecord( + validLangGraph.replace( + 'environment.langGraphApiUrl', + 'environment.otherApiUrl' + ), + 'langgraph' + ), + langGraphExpected, + 'langgraph' + ) + ).toContain('sharedApiUrl environment.langGraphApiUrl'); + + const agUiExpected: ExpectedRuntimeWiring = { + project: 'fixture', + rootComponent: 'StreamingComponent', + providerSource: 'src/app/app.config.ts', + sharedUrl: "new URL('agent', document.baseURI).pathname", + }; + expect( + auditEntrypointMetadata( + bootstrapRecord( + `void bootstrapWithCockpitHarness(StreamingComponent, appConfig, { + runtime: { + adapter: 'ag-ui', + sharedUrl: '/wrong-agent', + operationReporterToken: ɵAG_UI_RUNTIME_OPERATION_REPORTER, + }, + }).catch(() => undefined);`, + 'ag-ui' + ), + agUiExpected, + 'ag-ui' + ) + ).toContain("sharedUrl new URL('agent', document.baseURI).pathname"); + const canonicalProviderPreamble = ` + import { injectCockpitRuntimeConnection } from '@threadplane/cockpit-telemetry'; + import { provideAgent } from '@threadplane/langgraph'; + `; + const executableWrongRef = `${canonicalProviderPreamble} + provideAgent(MESSAGES_AGENT, () => { + const connection = injectCockpitRuntimeConnection(); + return { + apiUrl: connection.apiUrl, + assistantId: connection.assistantId, + clientOptions: connection.clientOptions, + }; + }); + `; + expect( + inspectRuntimeTargetSource( + executableWrongRef, + 'src/app/app.config.ts', + 'langgraph' + ).providerCalls.map(({ agentRef }) => agentRef) + ).toEqual(['MESSAGES_AGENT']); + expect( + inspectRuntimeTargetSource( + `${canonicalProviderPreamble}// provideAgent(STREAMING_AGENT, () => ({}))`, + 'src/app/app.config.ts', + 'langgraph' + ).providerCalls + ).toEqual([]); + const commentOnlyOption = `${canonicalProviderPreamble} + provideAgent(() => { + const connection = injectCockpitRuntimeConnection(); + return { + apiUrl: connection.apiUrl, + assistantId: connection.assistantId, + clientOptions: connection.clientOptions, + // subagentToolNames: ['task'] + }; + }); + `; + expect( + hasExpectedProviderProperties( + inspectRuntimeTargetSource( + commentOnlyOption, + 'src/app/app.config.ts', + 'langgraph' + ).providerCalls, + { subagentToolNames: "['task']" } + ) + ).toBe(false); + }); + + it('requires bootstrap argument two to resolve to the canonical appConfig import', () => { + type AppConfigBootstrapRecord = BootstrapCallRecord & { + appConfigArgument?: string; + hasCanonicalAppConfigBinding?: boolean; + hasCanonicalHarnessBinding?: boolean; + hasCanonicalCallOwner?: boolean; + }; + const runtimeOptions = `{ + runtime: { + adapter: 'langgraph', + sharedApiUrl: environment.langGraphApiUrl, + assistantId: environment.streamingAssistantId, + operationReporterToken: ɵLANGGRAPH_RUNTIME_OPERATION_REPORTER, + }, + }`; + const inspectBootstrap = (source: string): AppConfigBootstrapRecord => + inspectRuntimeTargetSource(source, 'main.ts', 'langgraph') + .bootstrapCalls[0] as AppConfigBootstrapRecord; + const call = (config: string) => + `void bootstrapWithCockpitHarness(StreamingComponent, ${config}, ${runtimeOptions}).catch(() => undefined);`; + const canonicalImport = + `import { bootstrapWithCockpitHarness } from '@threadplane/cockpit-telemetry'; + import { appConfig } from './app/app.config';`; + + expect(inspectBootstrap(`${canonicalImport}\n${call('appConfig')}`)).toMatchObject({ + appConfigArgument: 'appConfig', + hasCanonicalAppConfigBinding: true, + hasCanonicalHarnessBinding: true, + hasCanonicalCallOwner: true, + }); + expect( + inspectBootstrap( + `${canonicalImport}\ntype AppConfigShape = typeof appConfig;\n${call( + 'appConfig' + )}` + ).hasCanonicalAppConfigBinding + ).toBe(true); + expect( + inspectBootstrap( + `${canonicalImport}\nstrip(appConfig);\n${call('appConfig')}` + ).hasCanonicalAppConfigBinding + ).toBe(false); + expect( + inspectBootstrap( + `${canonicalImport}\nconst configAlias = appConfig;\n${call( + 'appConfig' + )}` + ).hasCanonicalAppConfigBinding + ).toBe(false); + expect( + inspectBootstrap( + `${canonicalImport}\nvoid appConfig;\n${call('appConfig')}` + ).hasCanonicalAppConfigBinding + ).toBe(false); + expect( + inspectBootstrap( + `${canonicalImport}\nqueueMicrotask(() => inspectConfig(appConfig));\n${call( + 'appConfig' + )}` + ).hasCanonicalAppConfigBinding + ).toBe(false); + expect( + inspectBootstrap(`${canonicalImport}\n${call('{ providers: [] }')}`) + .hasCanonicalAppConfigBinding + ).toBe(false); + expect( + inspectBootstrap(`${canonicalImport}\n${call('otherConfig')}`) + .hasCanonicalAppConfigBinding + ).toBe(false); + expect( + inspectBootstrap( + `import { bootstrapWithCockpitHarness } from '@threadplane/cockpit-telemetry'; + import { appConfig as config } from './app/app.config';\n${call( + 'config' + )}` + ).hasCanonicalAppConfigBinding + ).toBe(false); + expect( + inspectBootstrap( + `${canonicalImport}\nfunction start(appConfig: object) { ${call( + 'appConfig' + )} }` + ).hasCanonicalAppConfigBinding + ).toBe(false); + expect( + inspectBootstrap( + `${canonicalImport}\nappConfig = otherConfig;\n${call('appConfig')}` + ).hasCanonicalAppConfigBinding + ).toBe(false); + expect( + inspectBootstrap(`${canonicalImport}\n${call('{ ...appConfig }')}`) + .hasCanonicalAppConfigBinding + ).toBe(false); + expect( + inspectBootstrap(`${canonicalImport}\n${call('resolveConfig(appConfig)')}`) + .hasCanonicalAppConfigBinding + ).toBe(false); + expect( + inspectBootstrap( + `import { bootstrapWithCockpitHarness } from '@threadplane/cockpit-telemetry'; + import { appConfig } from './app/not-app.config';\n${call( + 'appConfig' + )}` + ).hasCanonicalAppConfigBinding + ).toBe(false); + + const fakeHarness = inspectBootstrap( + `import { appConfig } from './app/app.config'; + const bootstrapWithCockpitHarness = fakeHarness; + ${call('appConfig')}` + ); + expect(fakeHarness.hasCanonicalHarnessBinding).toBe(false); + expect(fakeHarness.hasCanonicalCallOwner).toBe(true); + expect( + inspectRuntimeTargetSource( + `import { bootstrapWithCockpitHarness as bootstrap } from '@threadplane/cockpit-telemetry'; + import { appConfig } from './app/app.config'; + void bootstrap(StreamingComponent, appConfig, ${runtimeOptions}).catch(() => undefined);`, + 'main.ts', + 'langgraph' + ).bootstrapCalls + ).toEqual([]); + expect( + inspectBootstrap( + `${canonicalImport} + bootstrapWithCockpitHarness = fakeHarness; + ${call('appConfig')}` + ).hasCanonicalHarnessBinding + ).toBe(false); + + const loopShadow = inspectBootstrap( + `${canonicalImport} + for (const bootstrapWithCockpitHarness of harnesses) { + ${call('appConfig')} + }` + ); + expect(loopShadow.hasCanonicalHarnessBinding).toBe(false); + expect(loopShadow.hasCanonicalCallOwner).toBe(false); + const namedFunctionShadow = inspectBootstrap( + `${canonicalImport} + const start = function bootstrapWithCockpitHarness() { + ${call('appConfig')} + };` + ); + expect(namedFunctionShadow.hasCanonicalHarnessBinding).toBe(false); + expect(namedFunctionShadow.hasCanonicalCallOwner).toBe(false); + const namedClassShadow = inspectBootstrap( + `${canonicalImport} + const Runner = class bootstrapWithCockpitHarness { + static start() { ${call('appConfig')} } + };` + ); + expect(namedClassShadow.hasCanonicalHarnessBinding).toBe(false); + expect(namedClassShadow.hasCanonicalCallOwner).toBe(false); + const duplicateCalls = inspectRuntimeTargetSource( + `${canonicalImport}\n${call('appConfig')}\n${call('appConfig')}`, + 'main.ts', + 'langgraph' + ).bootstrapCalls; + expect(duplicateCalls).toHaveLength(2); + expect( + duplicateCalls.every( + (record) => + !(record as AppConfigBootstrapRecord).hasCanonicalCallOwner + ) + ).toBe(true); + }); + + it('pins every semantic runtime identifier to its exact stable import', () => { + type SemanticBootstrapRecord = BootstrapCallRecord & { + rootComponentBinding?: SemanticBindingInspection; + environmentBindings?: readonly SemanticBindingInspection[]; + operationReporterBinding?: SemanticBindingInspection; + }; + type SemanticProviderCall = CanonicalProviderCall & { + provideAgentBinding?: SemanticBindingInspection; + agentRefBinding?: SemanticBindingInspection; + }; + const runtimeOptions = `{ + runtime: { + adapter: 'langgraph', + sharedApiUrl: environment.langGraphApiUrl, + assistantId: environment.streamingAssistantId, + operationReporterToken: ɵLANGGRAPH_RUNTIME_OPERATION_REPORTER, + }, + }`; + const bootstrapImports = ` + import { ɵLANGGRAPH_RUNTIME_OPERATION_REPORTER } from '@threadplane/langgraph'; + import { bootstrapWithCockpitHarness } from '@threadplane/cockpit-telemetry'; + import { appConfig } from './app/app.config'; + import { StreamingComponent } from './app/streaming.component'; + import { environment } from './environments/environment'; + `; + const bootstrapCall = `void bootstrapWithCockpitHarness( + StreamingComponent, + appConfig, + ${runtimeOptions} + ).catch(() => undefined);`; + const inspectBootstrap = (source: string): SemanticBootstrapRecord => + inspectRuntimeTargetSource(source, 'main.ts', 'langgraph') + .bootstrapCalls[0] as SemanticBootstrapRecord; + const canonicalBootstrap = inspectBootstrap( + `${bootstrapImports}\n${bootstrapCall}` + ); + expect( + hasExactSemanticBinding( + canonicalBootstrap.rootComponentBinding, + './app/streaming.component', + 'StreamingComponent' + ) + ).toBe(true); + expect(canonicalBootstrap.environmentBindings).toHaveLength(2); + expect( + canonicalBootstrap.environmentBindings?.every((binding) => + hasExactSemanticBinding( + binding, + './environments/environment', + 'environment' + ) + ) + ).toBe(true); + expect( + hasExactSemanticBinding( + canonicalBootstrap.operationReporterBinding, + '@threadplane/langgraph', + 'ɵLANGGRAPH_RUNTIME_OPERATION_REPORTER' + ) + ).toBe(true); + expect( + hasExactSemanticBinding( + inspectBootstrap( + `${bootstrapImports.replace( + "import { StreamingComponent } from './app/streaming.component';", + "import { OtherComponent as StreamingComponent } from './app/wrong.component';" + )}\n${bootstrapCall}` + ).rootComponentBinding, + './app/streaming.component', + 'StreamingComponent' + ) + ).toBe(false); + expect( + hasExactSemanticBinding( + inspectBootstrap( + `${bootstrapImports.replace( + "import { StreamingComponent } from './app/streaming.component';", + 'const StreamingComponent = FakeComponent;' + )}\n${bootstrapCall}` + ).rootComponentBinding, + './app/streaming.component', + 'StreamingComponent' + ) + ).toBe(false); + expect( + inspectBootstrap( + `${bootstrapImports.replace( + "import { environment } from './environments/environment';", + 'const environment = fakeEnvironment;' + )}\n${bootstrapCall}` + ).environmentBindings?.some((binding) => binding.canonical) + ).toBe(false); + expect( + inspectBootstrap( + `${bootstrapImports.replace( + "import { environment } from './environments/environment';", + "import { fakeEnvironment as environment } from './fake-environment';" + )}\n${bootstrapCall}` + ).environmentBindings?.some((binding) => binding.canonical) + ).toBe(false); + expect( + hasExactSemanticBinding( + inspectBootstrap( + `${bootstrapImports.replace( + "import { ɵLANGGRAPH_RUNTIME_OPERATION_REPORTER } from '@threadplane/langgraph';", + 'const ɵLANGGRAPH_RUNTIME_OPERATION_REPORTER = fakeReporter;' + )}\n${bootstrapCall}` + ).operationReporterBinding, + '@threadplane/langgraph', + 'ɵLANGGRAPH_RUNTIME_OPERATION_REPORTER' + ) + ).toBe(false); + const providerFactory = `() => { + const connection = injectCockpitRuntimeConnection(); + if (connection.adapter !== 'langgraph') { + throw new Error('incompatible runtime'); + } + return { + apiUrl: connection.apiUrl, + assistantId: connection.assistantId, + clientOptions: connection.clientOptions, + }; + }`; + const inspectProvider = (imports: string): SemanticProviderCall | undefined => + inspectRuntimeTargetSource( + `${imports} + import { injectCockpitRuntimeConnection } from '@threadplane/cockpit-telemetry'; + export const appConfig = { providers: [ + provideAgent(STREAMING_AGENT, ${providerFactory}) + ] };`, + 'src/app/app.config.ts', + 'langgraph' + ).providerCalls[0] as SemanticProviderCall | undefined; + const canonicalProvider = inspectProvider(` + import { provideAgent } from '@threadplane/langgraph'; + import { STREAMING_AGENT } from './agent-ref'; + `); + expect( + hasExactSemanticBinding( + canonicalProvider?.provideAgentBinding, + '@threadplane/langgraph', + 'provideAgent' + ) + ).toBe(true); + expect( + hasExactSemanticBinding( + canonicalProvider?.agentRefBinding, + './agent-ref', + 'STREAMING_AGENT' + ) + ).toBe(true); + expect( + hasExactSemanticBinding( + inspectProvider(` + import { provideAgent } from './fake-agent'; + import { STREAMING_AGENT } from './agent-ref'; + `)?.provideAgentBinding, + '@threadplane/langgraph', + 'provideAgent' + ) + ).toBe(false); + expect( + hasExactSemanticBinding( + inspectProvider(` + const provideAgent = fakeProvideAgent; + import { STREAMING_AGENT } from './agent-ref'; + `)?.provideAgentBinding, + '@threadplane/langgraph', + 'provideAgent' + ) + ).toBe(false); + expect( + hasExactSemanticBinding( + inspectProvider(` + import { provideAgent } from '@threadplane/langgraph'; + const STREAMING_AGENT = fakeAgentRef; + `)?.agentRefBinding, + './agent-ref', + 'STREAMING_AGENT' + ) + ).toBe(false); + expect( + hasExactSemanticBinding( + inspectProvider(` + import { provideAgent } from '@threadplane/langgraph'; + import { STREAMING_AGENT } from './fake-agent-ref'; + `)?.agentRefBinding, + './agent-ref', + 'STREAMING_AGENT' + ) + ).toBe(false); + }); + + it('allows the LangGraph environment import only as the two runtime property roots', () => { + const imports = ` + import { ɵLANGGRAPH_RUNTIME_OPERATION_REPORTER } from '@threadplane/langgraph'; + import { bootstrapWithCockpitHarness } from '@threadplane/cockpit-telemetry'; + import { appConfig } from './app/app.config'; + import { StreamingComponent } from './app/streaming.component'; + import { environment } from './environments/environment'; + `; + const bootstrapCall = `void bootstrapWithCockpitHarness( + StreamingComponent, + appConfig, + { + runtime: { + adapter: 'langgraph', + sharedApiUrl: environment.langGraphApiUrl, + assistantId: environment.streamingAssistantId, + operationReporterToken: ɵLANGGRAPH_RUNTIME_OPERATION_REPORTER, + }, + } + ).catch(() => undefined);`; + const inspectEnvironmentBindings = (extraSource = '') => + inspectRuntimeTargetSource( + `${imports}\n${extraSource}\n${bootstrapCall}`, + 'main.ts', + 'langgraph' + ).bootstrapCalls[0]?.environmentBindings ?? []; + const hasCanonicalEnvironmentBindings = (extraSource = '') => { + const bindings = inspectEnvironmentBindings(extraSource); + return ( + bindings.length === 2 && + bindings.every((binding) => + hasExactSemanticBinding( + binding, + './environments/environment', + 'environment' + ) + ) + ); + }; + + expect(hasCanonicalEnvironmentBindings()).toBe(true); + expect( + hasCanonicalEnvironmentBindings( + 'type EnvironmentShape = typeof environment;' + ) + ).toBe(true); + expect(hasCanonicalEnvironmentBindings('retarget(environment);')).toBe( + false + ); + expect( + hasCanonicalEnvironmentBindings('const environmentAlias = environment;') + ).toBe(false); + expect(hasCanonicalEnvironmentBindings('void environment;')).toBe(false); + expect( + hasCanonicalEnvironmentBindings( + 'queueMicrotask(() => retarget(environment));' + ) + ).toBe(false); + expect( + hasCanonicalEnvironmentBindings( + "environment.langGraphApiUrl = 'https://other.example';" + ) + ).toBe(false); + }); + + it('requires exact runtime option objects and pristine AG URL globals', () => { + type RuntimeBootstrapRecord = BootstrapCallRecord & { + hasCanonicalRuntimeOptions?: boolean; + hasPristineAgUrlGlobals?: boolean; + }; + const imports = ` + import { ɵAG_UI_RUNTIME_OPERATION_REPORTER } from '@threadplane/ag-ui'; + import { bootstrapWithCockpitHarness } from '@threadplane/cockpit-telemetry'; + import { appConfig } from './app/app.config'; + import { StreamingComponent } from './app/streaming.component'; + `; + const runtime = `{ + adapter: 'ag-ui', + sharedUrl: new URL('agent', document.baseURI).pathname, + operationReporterToken: ɵAG_UI_RUNTIME_OPERATION_REPORTER, + }`; + const call = (options: string) => + `void bootstrapWithCockpitHarness(StreamingComponent, appConfig, ${options}).catch(() => undefined);`; + const inspectBootstrap = (source: string): RuntimeBootstrapRecord => + inspectRuntimeTargetSource(source, 'main.ts', 'ag-ui') + .bootstrapCalls[0] as RuntimeBootstrapRecord; + + expect(inspectBootstrap(`${imports}\n${call(`{ runtime: ${runtime} }`)}`)).toMatchObject({ + hasCanonicalRuntimeOptions: true, + hasPristineAgUrlGlobals: true, + }); + expect( + inspectBootstrap( + `${imports}\n${call(`{ ...outerDefaults, runtime: ${runtime} }`)}` + ).hasCanonicalRuntimeOptions + ).toBe(false); + expect( + inspectBootstrap( + `${imports}\n${call(`{ runtime: { ...runtimeDefaults, + adapter: 'ag-ui', + sharedUrl: new URL('agent', document.baseURI).pathname, + operationReporterToken: ɵAG_UI_RUNTIME_OPERATION_REPORTER, + } }`)}` + ).hasCanonicalRuntimeOptions + ).toBe(false); + expect( + inspectBootstrap( + `${imports}\n${call(`{ runtime: ${runtime}, runtime: fakeRuntime }`)}` + ).hasCanonicalRuntimeOptions + ).toBe(false); + expect( + inspectBootstrap( + `${imports}\n${call(`{ ['runtime']: ${runtime} }`)}` + ).hasCanonicalRuntimeOptions + ).toBe(false); + expect( + inspectBootstrap( + `${imports}\n${call(`{ runtime: { + adapter: 'ag-ui', + sharedUrl: new URL('agent', document.baseURI).pathname, + operationReporterToken: ɵAG_UI_RUNTIME_OPERATION_REPORTER, + adapter: fakeAdapter, + } }`)}` + ).hasCanonicalRuntimeOptions + ).toBe(false); + expect( + inspectBootstrap( + `${imports}\n${call(`{ runtime: { + adapter: 'ag-ui', + sharedUrl, + operationReporterToken: ɵAG_UI_RUNTIME_OPERATION_REPORTER, + } }`)}` + ).hasCanonicalRuntimeOptions + ).toBe(false); + expect( + inspectBootstrap( + `${imports}\n${call(`{ runtime: { + adapter: 'ag-ui', + sharedUrl: new URL('agent', document.baseURI).pathname, + operationReporterToken, + } }`)}` + ).hasCanonicalRuntimeOptions + ).toBe(false); + expect( + inspectBootstrap( + `${imports}\n${call(`{ runtime: { + adapter: 'ag-ui', + sharedUrl: new URL('agent', document.baseURI).pathname, + ['operationReporterToken']: ɵAG_UI_RUNTIME_OPERATION_REPORTER, + } }`)}` + ).hasCanonicalRuntimeOptions + ).toBe(false); + expect( + inspectBootstrap( + `${imports}\n${call(`{ runtime: { + adapter: 'ag-ui', + sharedUrl: new URL('agent', document.baseURI).pathname, + operationReporterToken: ɵAG_UI_RUNTIME_OPERATION_REPORTER, + harmlessExtra: true, + } }`)}` + ).hasCanonicalRuntimeOptions + ).toBe(false); + + expect( + inspectBootstrap( + `${imports}\nconst URL = FakeURL;\n${call(`{ runtime: ${runtime} }`)}` + ).hasPristineAgUrlGlobals + ).toBe(false); + expect( + inspectBootstrap( + `${imports}\nimport { URL } from './fake-url';\n${call( + `{ runtime: ${runtime} }` + )}` + ).hasPristineAgUrlGlobals + ).toBe(false); + expect( + inspectBootstrap( + `${imports}\nimport document from './fake-document';\n${call( + `{ runtime: ${runtime} }` + )}` + ).hasPristineAgUrlGlobals + ).toBe(false); + expect( + inspectBootstrap( + `${imports}\nconst document = fakeDocument;\n${call( + `{ runtime: ${runtime} }` + )}` + ).hasPristineAgUrlGlobals + ).toBe(false); + expect( + inspectBootstrap( + `${imports}\nURL = FakeURL;\n${call(`{ runtime: ${runtime} }`)}` + ).hasPristineAgUrlGlobals + ).toBe(false); + expect( + inspectBootstrap( + `${imports}\nObject.assign(document, fakeDocument);\n${call( + `{ runtime: ${runtime} }` + )}` + ).hasPristineAgUrlGlobals + ).toBe(false); + expect( + inspectBootstrap( + `${imports}\nmutateGlobal(URL);\n${call(`{ runtime: ${runtime} }`)}` + ).hasPristineAgUrlGlobals + ).toBe(false); + expect( + inspectBootstrap( + `${imports}\nmutateGlobal(document);\n${call( + `{ runtime: ${runtime} }` + )}` + ).hasPristineAgUrlGlobals + ).toBe(false); + expect( + inspectBootstrap( + `${imports}\n${call(`{ runtime: { + adapter: 'ag-ui', + sharedUrl: new RuntimeURL('agent', document.baseURI).pathname, + operationReporterToken: ɵAG_UI_RUNTIME_OPERATION_REPORTER, + } }`)}` + ).hasCanonicalRuntimeOptions + ).toBe(false); + expect( + inspectBootstrap( + `${imports}\nfunction start(URL: typeof globalThis.URL) { + ${call(`{ runtime: ${runtime} }`)} + }` + ).hasPristineAgUrlGlobals + ).toBe(false); + expect( + inspectBootstrap( + `${imports}\nfor (const document of documents) { + ${call(`{ runtime: ${runtime} }`)} + }` + ).hasPristineAgUrlGlobals + ).toBe(false); + expect( + inspectBootstrap( + `${imports}\ntry { throw fakeDocument; } catch (document) { + ${call(`{ runtime: ${runtime} }`)} + }` + ).hasPristineAgUrlGlobals + ).toBe(false); + expect( + inspectBootstrap( + `${imports}\nconst start = function URL() { + ${call(`{ runtime: ${runtime} }`)} + };` + ).hasPristineAgUrlGlobals + ).toBe(false); + }); + + it('preserves typed, option-bearing, and component-scoped provider patterns', () => { + const read = (path: string): string => + readFileSync(resolve(cockpitRoot, path), 'utf8'); + const providerCalls = (path: string): CanonicalProviderCall[] => + inspectRuntimeTargetSource(read(path), path, 'langgraph').providerCalls; + const typed = providerCalls( + 'langgraph/streaming/angular/src/app/app.config.ts' + ); + const subagents = providerCalls( + 'chat/subagents/angular/src/app/app.config.ts' + ); + const subgraphs = providerCalls( + 'langgraph/subgraphs/angular/src/app/app.config.ts' + ); + const persistenceConfig = providerCalls( + 'langgraph/persistence/angular/src/app/app.config.ts' + ); + const persistenceComponent = providerCalls( + 'langgraph/persistence/angular/src/app/persistence.component.ts' + ); + const threadsConfig = inspectRuntimeTargetSource( + read('chat/threads/angular/src/app/app.config.ts'), + 'src/app/app.config.ts', + 'langgraph' + ).angularProviders; + const threadsComponent = providerCalls( + 'chat/threads/angular/src/app/threads.component.ts' + ); + + expect(typed.map(({ agentRef }) => agentRef)).toEqual(['STREAMING_AGENT']); + expect(typed[0].properties['clientOptions']).toBe( + 'connection.clientOptions' + ); + expect(subagents[0].properties['subagentToolNames']).toBe("['task']"); + expect(subgraphs[0].properties['transcriptNodeNames']).toBe("['answer']"); + + expect(persistenceConfig).toEqual([]); + expect(persistenceComponent).toHaveLength(1); + expect(persistenceComponent[0].properties['onThreadId']).toBe( + persistenceOnThreadIdExpression + ); + + expect(threadsComponent).toHaveLength(1); + expect(threadsComponent[0].properties['threadId']).toBe( + 'activeThreadIdState' + ); + expect(threadsComponent[0].properties['onThreadId']).toBe( + '(id: string) => activeThreadIdState.set(id)' + ); + expect( + auditThreadsRootProviders([ + { + relativeFileName: 'src/app/app.config.ts', + angularProviders: threadsConfig, + }, + ]) + ).toEqual([]); + }); + + it('preserves required non-connection entrypoint behavior while migrating bootstrap ownership', () => { + const timelineCockpit = readFileSync( + resolve(cockpitRoot, 'chat/timeline/angular/src/main.cockpit.ts'), + 'utf8' + ); + + expect(timelineCockpit).toContain( + "import { installEmbeddedTheme } from '@threadplane/example-layouts';" + ); + expect( + timelineCockpit.match(/installEmbeddedTheme\s*\(\s*\)\s*;/g) + ).toHaveLength(1); + expect(timelineCockpit.indexOf('installEmbeddedTheme();')).toBeLessThan( + timelineCockpit.indexOf('bootstrapWithCockpitHarness(') + ); + }); + + it('keeps Render Angular applications static and outside adapter reporter graphs', () => { + const staticCapabilities = capabilities.filter( + (capability) => capability.runtimeAdapter === 'none' + ); + const violations = staticCapabilities.flatMap((capability) => { + const projectRoot = resolve( + cockpitRoot, + capability.product, + capability.topic, + 'angular' + ); + const source = angularSourceFiles(projectRoot) + .map((fileName) => readFileSync(fileName, 'utf8')) + .join('\n'); + const forbidden = [ + 'injectCockpitRuntimeConnection', + 'ɵAG_UI_RUNTIME_OPERATION_REPORTER', + 'ɵLANGGRAPH_RUNTIME_OPERATION_REPORTER', + "from '@threadplane/ag-ui'", + "from '@threadplane/langgraph'", + "adapter: 'ag-ui'", + "adapter: 'langgraph'", + ].filter((needle) => source.includes(needle)); + return forbidden.length === 0 + ? [] + : [{ project: capability.angularProject, forbidden }]; + }); + + expect(staticCapabilities).toHaveLength(6); + expect(violations).toEqual([]); }); it('keeps every registry product inside the CockpitProduct union', () => { @@ -123,13 +2696,13 @@ describe('cockpit capability wiring', () => { ) as { references?: Array<{ path: string }> }; expect( - tsconfig.references?.filter((reference) => + (tsconfig.references ?? []).filter((reference) => reference.path.startsWith('../../cockpit/') ) ).toEqual([]); }); - it('includes external capability content assets in the Cockpit build inputs', () => { + it('keeps redirect deployment inputs without tracing interactive content assets', () => { const project = JSON.parse( readFileSync(resolveCockpitConfig('project.json'), 'utf8') ) as { @@ -140,15 +2713,8 @@ describe('cockpit capability wiring', () => { expect(project.targets.build.inputs).toEqual([ 'default', 'deploymentConfig', - 'contentAssets', '^default', ]); - expect(project.namedInputs['contentAssets']).toEqual([ - '{workspaceRoot}/cockpit/**/prompts/**', - '{workspaceRoot}/cockpit/**/angular/src/**', - '{workspaceRoot}/cockpit/**/python/src/**', - '{workspaceRoot}/cockpit/**/docs/**', - '{workspaceRoot}/deployments/ag-ui-mastra/*.mjs', - ]); + expect(project.namedInputs['contentAssets']).toBeUndefined(); }); }); diff --git a/apps/cockpit/cockpit-e2e-wiring.spec.ts b/apps/cockpit/cockpit-e2e-wiring.spec.ts index 7f766d87f..ecbf1f05f 100644 --- a/apps/cockpit/cockpit-e2e-wiring.spec.ts +++ b/apps/cockpit/cockpit-e2e-wiring.spec.ts @@ -41,7 +41,10 @@ function listProjectJsonFiles(root: string): string[] { return out.sort(); } -function listFiles(root: string, predicate: (filePath: string) => boolean): string[] { +function listFiles( + root: string, + predicate: (filePath: string) => boolean +): string[] { const out: string[] = []; const stack = [root]; @@ -75,9 +78,11 @@ function activeCockpitE2eWiring(): E2eWiring[] { return { project, projectJsonPath }; }) .filter(({ project, projectJsonPath }) => { - return project.name?.startsWith('cockpit-') && + return ( + project.name?.startsWith('cockpit-') && projectJsonPath.includes('/angular/') && - Boolean(project.targets?.['e2e']); + Boolean(project.targets?.['e2e']) + ); }) .map(({ project, projectJsonPath }) => { const projectRoot = dirname(projectJsonPath); @@ -96,12 +101,20 @@ function activeCockpitE2eWiring(): E2eWiring[] { // Post-port-registry migration: ports are imported from // cockpit/ports.mjs rather than living as literals in // global-setup-impl.ts. Look them up by project name. - const ports = portsFor(project.name) as { angular: number; langgraph: number }; + const ports = portsFor(project.name) as { + angular: number; + langgraph: number; + }; const langgraphPort = ports.langgraph; const angularPort = ports.angular; if (!project.name || !langgraphCwd || !langgraphPort || !angularPort) { - throw new Error(`Unable to parse e2e wiring for ${relative(repoRoot, projectJsonPath)}`); + throw new Error( + `Unable to parse e2e wiring for ${relative( + repoRoot, + projectJsonPath + )}` + ); } return { @@ -115,43 +128,27 @@ function activeCockpitE2eWiring(): E2eWiring[] { } describe('cockpit e2e wiring', () => { - it('keeps the representative production smoke deterministic and scoped to Recheck', () => { - const smoke = readRepoFile('apps/cockpit/e2e/production-smoke.spec.ts'); - const start = smoke.indexOf( - "test('representative runtime reports Ready after Recheck and records Activity'" + it('keeps production platform smoke under Website ownership', () => { + const smoke = readRepoFile( + 'apps/website/e2e/platform-production-smoke.spec.ts' ); - const end = smoke.indexOf("test('favicon resolves after redirects'", start); - const representative = smoke.slice(start, end); - - expect(start).toBeGreaterThan(-1); - expect(end).toBeGreaterThan(start); - expect(representative).toContain('test.setTimeout(60_000)'); - expect(representative).toContain("getByText('Ready', { exact: true })"); - expect(representative).toContain( - "getByRole('button', { name: 'Recheck' })" + const websiteConfig = readRepoFile('apps/website/playwright.config.ts'); + + expect( + existsSync(join(repoRoot, 'apps/cockpit/e2e/production-smoke.spec.ts')) + ).toBe(false); + expect(smoke).toContain('getCanonicalWebsiteWorkspaceHref'); + expect(smoke).toContain('smokeCase.location'); + expect(smoke).toContain('getWorkspaceDestinationPath'); + expect(smoke).toContain('EXAMPLES_URL'); + expect(smoke).toContain('DEMO_URL'); + expect(smoke).toContain('AG_UI_TOPICS'); + expect(smoke).not.toMatch(/Cockpit navigation|Recheck|runtime_ready/); + expect(websiteConfig).toContain("environment['PRODUCTION_SMOKE']"); + expect(websiteConfig).toContain( + 'environment: WebsitePlaywrightEnvironment = process.env' ); - expect(representative).toContain('await expect(checkEvents).toHaveCount(1)'); - expect(representative).toContain('await expect(readyEvents).toHaveCount(1)'); - expect(representative).toContain('await expect(checkEvents).toHaveCount(2)'); - expect(representative).toContain('await expect(readyEvents).toHaveCount(2)'); - expect(representative).not.toContain( - "getByRole('button', { name: 'Reload runtime' })" - ); - }); - - it('keeps the production favicon redirect covered', () => { - const smoke = readRepoFile('apps/cockpit/e2e/production-smoke.spec.ts'); - const start = smoke.indexOf("test('favicon resolves after redirects'"); - const end = smoke.indexOf( - "test.describe('Production: canonical demo sends runtime telemetry'", - start - ); - const favicon = smoke.slice(start, end); - - expect(start).toBeGreaterThan(-1); - expect(end).toBeGreaterThan(start); - expect(favicon).toContain('request.get(`${COCKPIT_URL}/favicon.ico`)'); - expect(favicon).toContain('expect(response.status()).toBeLessThan(400)'); + expect(websiteConfig).toContain('platform-production-smoke.spec.ts'); }); it('does not leave cockpit e2e spec files outside Nx e2e targets', () => { @@ -162,13 +159,14 @@ describe('cockpit e2e wiring', () => { targets?: Record; }; return [dirname(projectJsonPath), project] as const; - }), + }) ); const orphanSpecs: string[] = []; for (const specPath of listFiles( join(repoRoot, 'cockpit'), - (filePath) => filePath.includes('/angular/e2e/') && filePath.endsWith('.spec.ts'), + (filePath) => + filePath.includes('/angular/e2e/') && filePath.endsWith('.spec.ts') )) { const projectRoot = specPath.slice(0, specPath.indexOf('/e2e/')); const project = projects.get(projectRoot); @@ -185,20 +183,34 @@ describe('cockpit e2e wiring', () => { const activeE2e = activeCockpitE2eWiring(); for (const wiring of activeE2e) { - const capability = capabilities.find((c) => c.angularProject === wiring.project); + const capability = capabilities.find( + (c) => c.angularProject === wiring.project + ); if (!capability) { errors.push(`${wiring.project}: missing capability registry entry`); continue; } if (capability.port !== wiring.angularPort) { - errors.push(`${wiring.project}: registry port ${capability.port} != global setup angularPort ${wiring.angularPort}`); + errors.push( + `${wiring.project}: registry port ${capability.port} != global setup angularPort ${wiring.angularPort}` + ); } - if (capability.pythonPort !== undefined && capability.pythonPort !== wiring.langgraphPort) { - errors.push(`${wiring.project}: registry pythonPort ${capability.pythonPort} != global setup langgraphPort ${wiring.langgraphPort}`); + if ( + capability.pythonPort !== undefined && + capability.pythonPort !== wiring.langgraphPort + ) { + errors.push( + `${wiring.project}: registry pythonPort ${capability.pythonPort} != global setup langgraphPort ${wiring.langgraphPort}` + ); } - if (capability.pythonDir !== undefined && capability.pythonDir !== wiring.langgraphCwd) { - errors.push(`${wiring.project}: registry pythonDir ${capability.pythonDir} != global setup langgraphCwd ${wiring.langgraphCwd}`); + if ( + capability.pythonDir !== undefined && + capability.pythonDir !== wiring.langgraphCwd + ) { + errors.push( + `${wiring.project}: registry pythonDir ${capability.pythonDir} != global setup langgraphCwd ${wiring.langgraphCwd}` + ); } // Post-port-registry: proxy.conf.mjs templates the target from @@ -211,16 +223,23 @@ describe('cockpit e2e wiring', () => { if (existsSync(proxyMjs)) { const text = readFileSync(proxyMjs, 'utf8'); if (!text.includes(`portsFor('${wiring.project}')`)) { - errors.push(`${wiring.project}: proxy.conf.mjs does not call portsFor('${wiring.project}')`); + errors.push( + `${wiring.project}: proxy.conf.mjs does not call portsFor('${wiring.project}')` + ); } } else if (existsSync(proxyJson)) { // Legacy (allowed for AG-UI exception only); not expected for any // cap reaching this code path. - const proxy = JSON.parse(readFileSync(proxyJson, 'utf8')) as Record; + const proxy = JSON.parse(readFileSync(proxyJson, 'utf8')) as Record< + string, + { target?: string } + >; const target = proxy['/api']?.target; const expectedTarget = `http://localhost:${wiring.langgraphPort}`; if (target !== expectedTarget) { - errors.push(`${wiring.project}: proxy target ${target} != ${expectedTarget}`); + errors.push( + `${wiring.project}: proxy target ${target} != ${expectedTarget}` + ); } } else { errors.push(`${wiring.project}: missing proxy.conf (no .mjs or .json)`); @@ -228,14 +247,29 @@ describe('cockpit e2e wiring', () => { const scriptsDir = join(wiring.projectRoot, 'e2e/scripts'); if (existsSync(scriptsDir)) { - for (const script of readdirSync(scriptsDir).filter((name) => name.startsWith('record-'))) { + for (const script of readdirSync(scriptsDir).filter((name) => + name.startsWith('record-') + )) { const scriptPath = join(scriptsDir, script); const text = readFileSync(scriptPath, 'utf8'); if (!text.includes(wiring.langgraphCwd)) { - errors.push(`${wiring.project}: ${relative(repoRoot, scriptPath)} does not reference ${wiring.langgraphCwd}`); + errors.push( + `${wiring.project}: ${relative( + repoRoot, + scriptPath + )} does not reference ${wiring.langgraphCwd}` + ); } - if (wiring.langgraphCwd !== 'cockpit/langgraph/streaming/python' && text.includes('cockpit/langgraph/streaming/python')) { - errors.push(`${wiring.project}: ${relative(repoRoot, scriptPath)} still references cockpit/langgraph/streaming/python`); + if ( + wiring.langgraphCwd !== 'cockpit/langgraph/streaming/python' && + text.includes('cockpit/langgraph/streaming/python') + ) { + errors.push( + `${wiring.project}: ${relative( + repoRoot, + scriptPath + )} still references cockpit/langgraph/streaming/python` + ); } } } @@ -248,22 +282,28 @@ describe('cockpit e2e wiring', () => { // Every cap with an e2e target is covered by definition — no per-cap // literal needed in ci.yml. const dispatcherCoversAllCaps = workflow.includes( - 'fromJson(needs.cockpit-e2e-dispatcher.outputs.caps)', + 'fromJson(needs.cockpit-e2e-dispatcher.outputs.caps)' ); if (dispatcherCoversAllCaps) { continue; } if (!workflow.includes(wiring.project)) { - errors.push(`${wiring.project}: ${workflowPath} does not run the e2e target`); + errors.push( + `${wiring.project}: ${workflowPath} does not run the e2e target` + ); } // Matrix-migrated jobs (cockpit-e2e) template the working-directory via // `${{ matrix.cap.python }}`; the python path appears in the matrix // entry (e.g. `python: cockpit/chat/foo/python`) instead. Accept either // form so matrix and non-matrix jobs both pass. - const literalUvSync = workflow.includes(`working-directory: ${wiring.langgraphCwd}`); + const literalUvSync = workflow.includes( + `working-directory: ${wiring.langgraphCwd}` + ); const matrixEntry = workflow.includes(`python: ${wiring.langgraphCwd}`); if (!literalUvSync && !matrixEntry) { - errors.push(`${wiring.project}: ${workflowPath} does not pre-sync ${wiring.langgraphCwd}`); + errors.push( + `${wiring.project}: ${workflowPath} does not pre-sync ${wiring.langgraphCwd}` + ); } } } @@ -274,12 +314,19 @@ describe('cockpit e2e wiring', () => { it('keeps capability-registry ports aligned with cockpit/ports.mjs', async () => { const mismatches: string[] = []; for (const cap of capabilities) { - const ports = portsFor(cap.angularProject) as { angular: number; langgraph: number }; + const ports = portsFor(cap.angularProject) as { + angular: number; + langgraph: number; + }; if (cap.port !== ports.angular) { - mismatches.push(`${cap.id}: registry.port ${cap.port} !== ports.angular ${ports.angular}`); + mismatches.push( + `${cap.id}: registry.port ${cap.port} !== ports.angular ${ports.angular}` + ); } if (cap.pythonPort !== undefined && cap.pythonPort !== ports.langgraph) { - mismatches.push(`${cap.id}: registry.pythonPort ${cap.pythonPort} !== ports.langgraph ${ports.langgraph}`); + mismatches.push( + `${cap.id}: registry.pythonPort ${cap.pythonPort} !== ports.langgraph ${ports.langgraph}` + ); } } expect(mismatches).toEqual([]); @@ -294,7 +341,7 @@ describe('cockpit e2e wiring', () => { const errors: string[] = []; const capProjects = listProjectJsonFiles(join(repoRoot, 'cockpit')) - .filter((p) => !p.includes('/ag-ui/')) // AG-UI has no python; out of scope + .filter((p) => !p.includes('/ag-ui/')) // AG-UI has no python; out of scope .filter((p) => p.includes('/angular/') || p.includes('/python/')) .map((p) => ({ path: p, @@ -320,7 +367,9 @@ describe('cockpit e2e wiring', () => { // Python caps with a smoke target must also trigger cockpit_smoke. if (p.includes('/python/') && project.targets?.['smoke']) { if (!tags.has('scope:cockpit-smoke')) { - errors.push(`${relPath}: has smoke target but missing scope:cockpit-smoke`); + errors.push( + `${relPath}: has smoke target but missing scope:cockpit-smoke` + ); } } } @@ -330,14 +379,21 @@ describe('cockpit e2e wiring', () => { it('smoke job represents every cockpit product with a real smoke target', () => { const ci = readFileSync(join(repoRoot, '.github/workflows/ci.yml'), 'utf8'); - const smokeLine = ci.split('\n').find((l) => l.includes('-t smoke --projects=')) ?? ''; - const listed = (smokeLine.match(/--projects=(\S+)/)?.[1] ?? '').split(',').filter(Boolean); + const smokeLine = + ci.split('\n').find((l) => l.includes('-t smoke --projects=')) ?? ''; + const listed = (smokeLine.match(/--projects=(\S+)/)?.[1] ?? '') + .split(',') + .filter(Boolean); // Every listed cockpit python project must actually declare a smoke target. const missingTarget = listed.filter((name) => { - const cap = capabilities.find((c) => c.angularProject.replace('-angular', '-python') === name); + const cap = capabilities.find( + (c) => c.angularProject.replace('-angular', '-python') === name + ); if (!cap?.pythonDir) return true; // listed a project not backed by a registry cap with python - const pj = JSON.parse(readFileSync(join(repoRoot, cap.pythonDir, 'project.json'), 'utf8')); + const pj = JSON.parse( + readFileSync(join(repoRoot, cap.pythonDir, 'project.json'), 'utf8') + ); return !pj.targets?.smoke; }); expect(missingTarget).toEqual([]); @@ -345,9 +401,12 @@ describe('cockpit e2e wiring', () => { // Every product must be represented by a listed project. const products = [...new Set(capabilities.map((c) => c.product))]; const uncovered = products.filter( - (product) => !capabilities.some( - (c) => c.product === product && listed.includes(c.angularProject.replace('-angular', '-python')), - ), + (product) => + !capabilities.some( + (c) => + c.product === product && + listed.includes(c.angularProject.replace('-angular', '-python')) + ) ); expect(uncovered).toEqual([]); }); diff --git a/apps/cockpit/e2e/control-plane.spec.ts b/apps/cockpit/e2e/control-plane.spec.ts deleted file mode 100644 index 649ded2b7..000000000 --- a/apps/cockpit/e2e/control-plane.spec.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { expect, test, type Page } from '@playwright/test'; - -const route = '/langgraph/core-capabilities/streaming/overview/python'; -const RUN_RAIL_ITEM = /^Run(?:,|$)/; - -declare global { - interface Window { - __cockpitAboutBlankMounted?: boolean; - __cockpitRuntimePhases?: string[]; - } -} - -async function installRuntimeObservation(page: Page) { - await page.addInitScript(() => { - window.__cockpitAboutBlankMounted = false; - window.__cockpitRuntimePhases = []; - - const inspect = () => { - for (const frame of document.querySelectorAll('iframe')) { - if (frame.getAttribute('src') === 'about:blank') { - window.__cockpitAboutBlankMounted = true; - } - } - for (const status of document.querySelectorAll('[data-runtime-phase]')) { - const phase = status.getAttribute('data-runtime-phase'); - if (phase && !window.__cockpitRuntimePhases?.includes(phase)) { - window.__cockpitRuntimePhases?.push(phase); - } - } - }; - - new MutationObserver(inspect).observe(document, { - attributes: true, - attributeFilter: ['src', 'data-runtime-phase'], - childList: true, - subtree: true, - }); - document.addEventListener('DOMContentLoaded', inspect, { once: true }); - }); -} - -async function expectNoHorizontalOverflow(page: Page, label: string) { - const overflow = await page.evaluate( - () => - document.documentElement.scrollWidth - - document.documentElement.clientWidth - ); - expect(overflow, label).toBeLessThanOrEqual(1); -} - -test.describe('Cockpit operational control plane', () => { - test('completes the real Angular handshake without blank or unresponsive states', async ({ - page, - }) => { - await installRuntimeObservation(page); - await page.goto(route); - - await expect(page.getByText('Ready', { exact: true })).toBeVisible(); - expect(await page.evaluate(() => window.__cockpitAboutBlankMounted)).toBe( - false - ); - expect( - await page.evaluate(() => window.__cockpitRuntimePhases) - ).not.toContain('unresponsive'); - - await page.getByRole('button', { name: 'Activity' }).click(); - await expect( - page.locator('[data-activity-kind="runtime_check_requested"]') - ).toHaveCount(1); - await expect( - page.locator('[data-activity-kind="runtime_ready"]') - ).toHaveCount(1); - await page.getByRole('button', { name: 'Close Activity' }).click(); - - await page.getByRole('button', { name: 'Recheck' }).click(); - await expect(page.getByText('Ready', { exact: true })).toBeVisible(); - await page.getByRole('button', { name: 'Activity' }).click(); - await expect( - page.locator('[data-activity-kind="runtime_check_requested"]') - ).toHaveCount(2); - await expect( - page.locator('[data-activity-kind="runtime_ready"]') - ).toHaveCount(2); - expect( - await page.evaluate(() => window.__cockpitRuntimePhases) - ).not.toContain('unresponsive'); - }); - - for (const viewport of [ - { width: 1440, height: 900, surface: 'desktop' }, - { width: 768, height: 900, surface: 'tablet' }, - { width: 390, height: 844, surface: 'mobile' }, - { width: 320, height: 844, surface: 'compact mobile' }, - ] as const) { - test(`${viewport.surface} keeps operational controls reachable`, async ({ - page, - }) => { - await page.setViewportSize(viewport); - await page.goto(route); - await expectNoHorizontalOverflow(page, `Cockpit at ${viewport.width}px`); - - const desktopNavigation = page.locator( - '[data-cockpit-desktop-navigation]' - ); - const mobileTrigger = page.getByRole('button', { - name: 'Open navigation', - }); - if (viewport.width >= 768) { - await expect(desktopNavigation).toBeVisible(); - await expect(mobileTrigger).toBeHidden(); - await expect( - desktopNavigation.getByRole('button', { name: RUN_RAIL_ITEM }) - ).toBeVisible(); - if (viewport.width >= 1024) { - await expect( - page.getByRole('button', { name: 'Runtime', exact: true }) - ).toBeVisible(); - await page.getByRole('button', { name: 'Activity' }).click(); - await expect( - page.getByRole('heading', { name: 'Activity' }) - ).toBeVisible(); - } else { - const contextTrigger = page.getByRole('button', { - name: 'Open context', - }); - await expect(contextTrigger).toBeVisible(); - await contextTrigger.click(); - const contextDialog = page.getByRole('dialog', { - name: 'Cockpit control plane context', - }); - await expect( - contextDialog.getByRole('button', { - name: 'Runtime', - exact: true, - }) - ).toBeVisible(); - await page.keyboard.press('Escape'); - await expect(contextDialog).toBeHidden(); - await expect(contextTrigger).toBeFocused(); - - await desktopNavigation - .getByRole('button', { name: 'Activity' }) - .click(); - await expect(contextDialog).toBeVisible(); - await expect( - contextDialog.getByRole('heading', { name: 'Activity' }) - ).toBeVisible(); - } - } else { - await expect(desktopNavigation).toBeHidden(); - await expect(mobileTrigger).toBeVisible(); - const triggerBox = await mobileTrigger.boundingBox(); - expect(triggerBox?.width).toBeGreaterThanOrEqual(44); - expect(triggerBox?.height).toBeGreaterThanOrEqual(44); - - await mobileTrigger.click(); - const dialog = page.getByRole('dialog', { - name: 'Cockpit control plane', - }); - await expect(dialog).toBeVisible(); - await expect(page.locator('[data-cockpit-workspace]')).toHaveAttribute( - 'inert', - '' - ); - await expect( - dialog.getByRole('button', { name: RUN_RAIL_ITEM }) - ).toBeVisible(); - await dialog.getByRole('button', { name: 'Activity' }).click(); - await expect( - dialog.getByRole('heading', { name: 'Activity' }) - ).toBeVisible(); - await dialog.getByRole('button', { name: 'Close Activity' }).click(); - await expect( - dialog.getByRole('button', { name: RUN_RAIL_ITEM }) - ).toBeVisible(); - await expect( - dialog.getByRole('button', { name: 'Runtime', exact: true }) - ).toBeVisible(); - - const close = dialog.getByRole('button', { name: 'Close navigation' }); - const closeBox = await close.boundingBox(); - expect(closeBox?.width).toBeGreaterThanOrEqual(44); - expect(closeBox?.height).toBeGreaterThanOrEqual(44); - await page.keyboard.press('Escape'); - await expect(dialog).toBeHidden(); - await expect(mobileTrigger).toBeFocused(); - } - }); - } - - test('forced colors preserve control boundaries and keyboard focus', async ({ - page, - }) => { - await page.emulateMedia({ forcedColors: 'active' }); - await page.goto(route); - - const runtime = page.getByRole('button', { name: 'Runtime', exact: true }); - await runtime.focus(); - const styles = await runtime.evaluate((element) => { - const style = getComputedStyle(element); - return { - borderWidth: style.borderTopWidth, - outlineStyle: style.outlineStyle, - outlineWidth: style.outlineWidth, - }; - }); - expect(Number.parseFloat(styles.borderWidth)).toBeGreaterThan(0); - expect(styles.outlineStyle).not.toBe('none'); - expect(Number.parseFloat(styles.outlineWidth)).toBeGreaterThan(0); - }); - - test('reduced motion disables loader and drawer animation', async ({ - page, - }) => { - await page.emulateMedia({ reducedMotion: 'reduce' }); - await page.route('http://localhost:4300/**', (request) => request.abort()); - await page.setViewportSize({ width: 390, height: 844 }); - await page.goto(route); - await page.getByRole('button', { name: 'Open navigation' }).click(); - - const dialog = page.getByRole('dialog', { name: 'Cockpit control plane' }); - const loader = dialog.locator('.cockpit-runtime-status-loader'); - await expect(loader).toBeVisible(); - expect( - await loader.evaluate( - (element) => getComputedStyle(element).animationName - ) - ).toBe('none'); - - const panel = page.locator('.cockpit-mobile-control-plane-panel'); - await expect(panel).toBeVisible(); - const motion = await panel.evaluate((element) => { - const style = getComputedStyle(element); - return { - animationName: style.animationName, - transitionDuration: style.transitionDuration, - }; - }); - expect(motion.animationName).toBe('none'); - expect(motion.transitionDuration).toBe('0s'); - }); -}); diff --git a/apps/cockpit/instrumentation-client.ts b/apps/cockpit/instrumentation-client.ts deleted file mode 100644 index 376dc3047..000000000 --- a/apps/cockpit/instrumentation-client.ts +++ /dev/null @@ -1,20 +0,0 @@ -// SPDX-License-Identifier: MIT -import posthog from 'posthog-js'; -import { getCockpitSessionId } from './src/lib/analytics/distinct-id'; -import { shouldCaptureAnalytics } from '@threadplane/telemetry/browser'; - -const token = process.env.NEXT_PUBLIC_COCKPIT_POSTHOG_TOKEN; -const captureLocal = process.env.NEXT_PUBLIC_COCKPIT_CAPTURE_LOCAL === 'true'; -const host = typeof window === 'undefined' ? undefined : window.location.host; - -if (shouldCaptureAnalytics({ token, captureLocal, host })) { - posthog.init(token!, { - api_host: '/ingest', - ui_host: 'https://us.posthog.com', - persistence: 'memory', - bootstrap: { distinctID: getCockpitSessionId() }, - autocapture: false, - capture_pageview: false, - defaults: '2026-01-30', - }); -} diff --git a/apps/cockpit/next.config.spec.ts b/apps/cockpit/next.config.spec.ts index fcb857dc1..b7191b49f 100644 --- a/apps/cockpit/next.config.spec.ts +++ b/apps/cockpit/next.config.spec.ts @@ -3,30 +3,13 @@ import { describe, expect, it } from 'vitest'; import { nextConfig as config } from './next.config'; describe('cockpit next.config', () => { - it('exposes posthog-js rewrites under /ingest', async () => { - expect(typeof config.rewrites).toBe('function'); - const rewrites = await config.rewrites!(); - const list = Array.isArray(rewrites) ? rewrites : rewrites.beforeFiles ?? []; - const sources = list.map((r: { source: string }) => r.source); - expect(sources).toContain('/ingest/static/:path*'); - expect(sources).toContain('/ingest/:path*'); - const staticRule = list.find((r: { source: string }) => r.source === '/ingest/static/:path*'); - expect(staticRule.destination).toBe('https://us-assets.i.posthog.com/static/:path*'); - const apiRule = list.find((r: { source: string }) => r.source === '/ingest/:path*'); - expect(apiRule.destination).toBe('https://us.i.posthog.com/:path*'); + it('keeps exact trailing-slash paths visible to the redirect route', () => { + expect(config.skipTrailingSlashRedirect).toBe(true); }); - it('attaches CORS headers to /ingest/* responses', async () => { - expect(typeof config.headers).toBe('function'); - const rules = await config.headers!(); - const ingestRule = rules.find((r: { source: string }) => r.source === '/ingest/:path*'); - expect(ingestRule).toBeDefined(); - const headerKeys = ingestRule.headers.map((h: { key: string }) => h.key); - expect(headerKeys).toContain('Access-Control-Allow-Origin'); - expect(headerKeys).toContain('Access-Control-Allow-Methods'); - expect(headerKeys).toContain('Access-Control-Allow-Headers'); - expect(headerKeys).toContain('Access-Control-Max-Age'); - const methods = ingestRule.headers.find((h: { key: string }) => h.key === 'Access-Control-Allow-Methods'); - expect(methods.value).toBe('POST, OPTIONS'); + it('does not retain interactive shell rewrites, headers, or content tracing', () => { + expect(config.rewrites).toBeUndefined(); + expect(config.headers).toBeUndefined(); + expect(config.outputFileTracingIncludes).toBeUndefined(); }); }); diff --git a/apps/cockpit/next.config.ts b/apps/cockpit/next.config.ts index 6801053d8..05551f7ea 100644 --- a/apps/cockpit/next.config.ts +++ b/apps/cockpit/next.config.ts @@ -8,42 +8,7 @@ const cockpitAppDir = dirname(fileURLToPath(import.meta.url)); export const nextConfig: WithNxOptions = { nx: {}, outputFileTracingRoot: join(cockpitAppDir, '../..'), - outputFileTracingIncludes: { - '/*': [ - '../../cockpit/**/*.md', - '../../cockpit/**/*.py', - '../../cockpit/**/*.ts', - // The mastra runtime's backend assets live outside cockpit/ — the - // Node hosting service IS that topic's backend (no python lane). - '../../deployments/ag-ui-mastra/*.mjs', - '../../nx.json', - ], - }, skipTrailingSlashRedirect: true, - rewrites: async () => [ - { - source: '/ingest/static/:path*', - destination: 'https://us-assets.i.posthog.com/static/:path*', - }, - { - source: '/ingest/:path*', - destination: 'https://us.i.posthog.com/:path*', - }, - ], - headers: async () => [ - { - source: '/ingest/:path*', - headers: [ - { - key: 'Access-Control-Allow-Origin', - value: process.env.NEXT_PUBLIC_COCKPIT_IFRAME_ORIGIN ?? '*', - }, - { key: 'Access-Control-Allow-Methods', value: 'POST, OPTIONS' }, - { key: 'Access-Control-Allow-Headers', value: 'Content-Type, Authorization' }, - { key: 'Access-Control-Max-Age', value: '86400' }, - ], - }, - ], }; const plugins = [withNx]; diff --git a/apps/cockpit/package.json b/apps/cockpit/package.json index 68a1e837e..365f40570 100644 --- a/apps/cockpit/package.json +++ b/apps/cockpit/package.json @@ -3,14 +3,9 @@ "version": "0.0.1", "private": true, "dependencies": { - "@radix-ui/react-slot": "^1.1.0", - "@threadplane/workspace-react": "*", - "class-variance-authority": "^0.7.0", - "clsx": "^2.1.1", + "@threadplane/cockpit-registry": "*", "next": "~16.1.6", - "posthog-js": "^1.372.6", "react": "^19.0.0", - "react-dom": "^19.0.0", - "tailwind-merge": "^2.5.0" + "react-dom": "^19.0.0" } } diff --git a/apps/cockpit/playwright.config.ts b/apps/cockpit/playwright.config.ts deleted file mode 100644 index 9a9843c7e..000000000 --- a/apps/cockpit/playwright.config.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { defineConfig, devices } from '@playwright/test'; - -const cockpitHost = '127.0.0.1'; -const cockpitPort = '4201'; -const cockpitURL = `http://${cockpitHost}:${cockpitPort}`; -const runtimeURL = 'http://localhost:4300'; -const reuseExistingServer = - process.env['PLAYWRIGHT_REUSE_EXISTING_SERVER'] === 'true'; -export default defineConfig({ - testDir: './e2e', - testMatch: 'control-plane.spec.ts', - outputDir: '../../test-results/cockpit', - fullyParallel: false, - retries: process.env['CI'] ? 2 : 0, - use: { - baseURL: cockpitURL, - }, - projects: [ - { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, - }, - ], - webServer: [ - { - command: - "NEXT_PUBLIC_COCKPIT_RUNTIME_BASE_URL='' npx nx serve cockpit --port 4201 --hostname 127.0.0.1", - cwd: '../..', - url: cockpitURL, - reuseExistingServer, - }, - { - command: - 'npx nx run cockpit-langgraph-streaming-angular:serve:cockpit --port 4300', - cwd: '../..', - url: runtimeURL, - reuseExistingServer, - }, - ], -}); diff --git a/apps/cockpit/postcss.config.mjs b/apps/cockpit/postcss.config.mjs deleted file mode 100644 index 2e3384d2f..000000000 --- a/apps/cockpit/postcss.config.mjs +++ /dev/null @@ -1,6 +0,0 @@ -// apps/cockpit/postcss.config.mjs -export default { - plugins: { - '@tailwindcss/postcss': {}, - }, -}; diff --git a/apps/cockpit/project.json b/apps/cockpit/project.json index 5cad9513d..1ebaf75a1 100644 --- a/apps/cockpit/project.json +++ b/apps/cockpit/project.json @@ -12,9 +12,7 @@ "targets": { "build": { "executor": "@nx/next:build", - "outputs": [ - "{options.outputPath}" - ], + "outputs": ["{options.outputPath}"], "defaultConfiguration": "production", "options": { "outputPath": "dist/apps/cockpit" @@ -27,12 +25,7 @@ "outputPath": "dist/apps/cockpit" } }, - "inputs": [ - "default", - "deploymentConfig", - "contentAssets", - "^default" - ] + "inputs": ["default", "deploymentConfig", "^default"] }, "serve": { "executor": "@nx/next:server", @@ -63,12 +56,6 @@ "executor": "@nx/eslint:lint", "outputs": ["{options.outputFile}"] }, - "e2e": { - "executor": "@nx/playwright:playwright", - "options": { - "config": "apps/cockpit/playwright.config.ts" - } - }, "serve-streaming": { "executor": "nx:run-commands", "options": { @@ -169,19 +156,15 @@ } }, "namedInputs": { - "contentAssets": [ - "{workspaceRoot}/cockpit/**/prompts/**", - "{workspaceRoot}/cockpit/**/angular/src/**", - "{workspaceRoot}/cockpit/**/python/src/**", - "{workspaceRoot}/cockpit/**/docs/**", - "{workspaceRoot}/deployments/ag-ui-mastra/*.mjs" - ], "deploymentConfig": [ "{workspaceRoot}/vercel.cockpit.json", "{workspaceRoot}/vercel.examples.json", "{workspaceRoot}/vercel.demo.json", "{workspaceRoot}/scripts/assemble-demo.ts", "{workspaceRoot}/scripts/assemble-examples.ts", + "{workspaceRoot}/scripts/generate-runtime-parent-origins.ts", + "{workspaceRoot}/runtime-parent-origins.json", + "{workspaceRoot}/libs/cockpit-runtime-bridge/src/lib/generated-runtime-parent-origins.ts", "{workspaceRoot}/scripts/demo-middleware.ts", "{workspaceRoot}/scripts/langgraph-proxy.ts", "{workspaceRoot}/scripts/rate-limit.ts", diff --git a/apps/cockpit/runtime-wiring-audit.ts b/apps/cockpit/runtime-wiring-audit.ts new file mode 100644 index 000000000..968d60670 --- /dev/null +++ b/apps/cockpit/runtime-wiring-audit.ts @@ -0,0 +1,2161 @@ +import * as ts from 'typescript'; + +export type CompatibleRuntimeAdapter = 'ag-ui' | 'langgraph'; + +export type RuntimeWiringAuditKind = + | 'browser-state-read' + | 'global-runtime-secret-read' + | 'imported-runtime-secret' + | 'environment-config-outside-entrypoint' + | 'module-global-runtime-cache' + | 'direct-agent-provider-config' + | 'noncanonical-provider-wiring' + | 'direct-agent-construction' + | 'runtime-secret-log'; + +export interface RuntimeWiringAuditIssue { + kind: RuntimeWiringAuditKind; + detail: string; +} + +export interface CanonicalProviderCall { + adapter: CompatibleRuntimeAdapter; + agentRef?: string; + provideAgentBinding?: ExactImportBinding; + agentRefBinding?: ExactImportBinding; + properties: Readonly>; + owner?: ProviderRegistrationOwner; +} + +export type ProviderRegistrationOwner = + | { readonly kind: 'appConfig' } + | { readonly kind: 'component'; readonly component: string }; + +export interface BootstrapCallRecord { + rootComponent?: string; + rootComponentBinding?: ExactImportBinding; + appConfigArgument?: string; + hasCanonicalAppConfigBinding: boolean; + hasCanonicalHarnessBinding: boolean; + hasCanonicalCallOwner: boolean; + hasCanonicalRuntimeOptions: boolean; + hasPristineAgUrlGlobals: boolean; + environmentBindings: readonly ExactImportBinding[]; + operationReporterBinding?: ExactImportBinding; + runtimeProperties: Readonly>; + hasRedactedCatch: boolean; +} + +export interface AngularProviderRecord { + provideToken: string; + provideTokenBinding?: ExactImportBinding; + connectionInjectorBinding?: ExactImportBinding; + useFactoryExpression?: string; + connectionDeclaration?: string; + connectionCallCount: number; + connectionWrites: boolean; + canonicalFactory: boolean; + returnExpression?: string; + returnedProperties: Readonly>; +} + +export interface ExactImportBinding { + readonly identifier: string; + readonly moduleName?: string; + readonly importedName?: string; + readonly canonical: boolean; +} + +export function hasExactImportBinding( + binding: ExactImportBinding | undefined, + moduleName: string, + importedName: string +): boolean { + return ( + binding?.canonical === true && + binding.moduleName === moduleName && + binding.importedName === importedName + ); +} + +export interface RuntimeTargetSourceInspection { + issues: RuntimeWiringAuditIssue[]; + providerCalls: CanonicalProviderCall[]; + bootstrapCalls: BootstrapCallRecord[]; + angularProviders: AngularProviderRecord[]; +} + +const browserStateNames = new Set([ + 'localStorage', + 'sessionStorage', + 'indexedDB', + 'cookieStore', + 'URLSearchParams', + 'location', + 'history', +]); +const documentStateNames = new Set(['cookie', 'location', 'URL', 'referrer']); +const unconditionalBrowserMemberNames = new Set([ + 'localStorage', + 'sessionStorage', + 'indexedDB', + 'cookie', + 'href', + 'search', + 'hash', + 'pushState', + 'replaceState', +]); +const unconditionalSecretMemberNames = new Set([ + 'authorization', + 'apiKey', + 'runtimeApiKey', + 'runtimeTarget', + 'customEndpoint', +]); +const agentModule = + /^(?:@threadplane\/(?:ag-ui|langgraph)|@ag-ui\/|@langchain\/langgraph)/; + +function identifierTokens(value: string): string[] { + return value + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .split(/[^A-Za-z0-9]+/) + .filter(Boolean) + .map((token) => token.toLowerCase()); +} + +function hasSequence(tokens: string[], sequence: string[]): boolean { + return tokens.some((_, index) => + sequence.every((token, offset) => tokens[index + offset] === token) + ); +} + +function isSensitiveRuntimeName(value: string): boolean { + const tokens = identifierTokens(value); + const endpointIndex = tokens.indexOf('endpoint'); + return ( + hasSequence(tokens, ['api', 'key']) || + hasSequence(tokens, ['api', 'url']) || + hasSequence(tokens, ['assistant', 'id']) || + hasSequence(tokens, ['runtime', 'target']) || + hasSequence(tokens, ['cockpit', 'runtime', 'connection']) || + hasSequence(tokens, ['custom', 'endpoint']) || + tokens.includes('credential') || + tokens.includes('authorization') || + (tokens.length === 1 && tokens[0] === 'connection') || + (endpointIndex >= 0 && tokens[endpointIndex + 1] !== 'count') + ); +} + +function isSensitiveLogName(value: string): boolean { + const tokens = identifierTokens(value); + return ( + isSensitiveRuntimeName(value) || + (tokens.length === 1 && + ['connection', 'target', 'key', 'message', 'endpoint'].includes( + tokens[0] + )) || + hasSequence(tokens, ['runtime', 'message']) + ); +} + +function staticPropertyName(expression: ts.Expression): string | undefined { + if (ts.isStringLiteralLike(expression)) return expression.text; + if ( + ts.isBinaryExpression(expression) && + expression.operatorToken.kind === ts.SyntaxKind.PlusToken + ) { + const left = staticPropertyName(expression.left); + const right = staticPropertyName(expression.right); + return left === undefined || right === undefined ? undefined : left + right; + } + return undefined; +} + +function propertyNameText(name: ts.PropertyName): string | undefined { + if (ts.isIdentifier(name) || ts.isStringLiteralLike(name)) return name.text; + return ts.isComputedPropertyName(name) + ? staticPropertyName(name.expression) + : undefined; +} + +function accessChain(expression: ts.Expression): string[] { + if (ts.isIdentifier(expression)) return [expression.text]; + if (ts.isPropertyAccessExpression(expression)) { + return [...accessChain(expression.expression), expression.name.text]; + } + if (ts.isElementAccessExpression(expression)) { + const property = expression.argumentExpression + ? staticPropertyName(expression.argumentExpression) + : undefined; + return property ? [...accessChain(expression.expression), property] : []; + } + return []; +} + +function bindingNameContains(name: ts.BindingName, expected: string): boolean { + if (ts.isIdentifier(name)) return name.text === expected; + return name.elements.some( + (element) => + ts.isBindingElement(element) && + bindingNameContains(element.name, expected) + ); +} + +function bindingPropertyContains( + name: ts.BindingName, + expected: string +): boolean { + if (ts.isIdentifier(name)) return false; + return name.elements.some((element) => { + if (!ts.isBindingElement(element)) return false; + const property = element.propertyName; + return ( + (property && + ((ts.isIdentifier(property) && property.text === expected) || + (ts.isStringLiteralLike(property) && property.text === expected))) || + bindingPropertyContains(element.name, expected) + ); + }); +} + +function expressionRootIdentifier( + expression: ts.Expression +): ts.Identifier | undefined { + if (ts.isIdentifier(expression)) return expression; + if ( + ts.isPropertyAccessExpression(expression) || + ts.isElementAccessExpression(expression) + ) { + return expressionRootIdentifier(expression.expression); + } + return undefined; +} + +function functionParameters( + node: ts.Node +): ts.NodeArray | undefined { + if ( + ts.isFunctionDeclaration(node) || + ts.isFunctionExpression(node) || + ts.isArrowFunction(node) || + ts.isMethodDeclaration(node) || + ts.isConstructorDeclaration(node) || + ts.isGetAccessorDeclaration(node) || + ts.isSetAccessorDeclaration(node) + ) { + return node.parameters; + } + return undefined; +} + +function isLexicallyDeclared(identifier: ts.Identifier): boolean { + const expected = identifier.text; + for ( + let current: ts.Node | undefined = identifier.parent; + current; + current = current.parent + ) { + const parameters = functionParameters(current); + if ( + parameters?.some((parameter) => + bindingNameContains(parameter.name, expected) + ) + ) { + return true; + } + if (ts.isBlock(current) || ts.isSourceFile(current)) { + for (const statement of current.statements) { + if (ts.isVariableStatement(statement)) { + if ( + statement.declarationList.declarations.some((declaration) => + bindingNameContains(declaration.name, expected) + ) + ) { + return true; + } + } + if ( + ts.isSourceFile(current) && + ts.isImportDeclaration(statement) && + statement.importClause + ) { + if (statement.importClause.name?.text === expected) return true; + const bindings = statement.importClause.namedBindings; + if ( + bindings && + ((ts.isNamespaceImport(bindings) && + bindings.name.text === expected) || + (ts.isNamedImports(bindings) && + bindings.elements.some( + (specifier) => specifier.name.text === expected + ))) + ) { + return true; + } + } + } + } + } + return false; +} + +function isLocallyShadowed( + identifier: ts.Identifier, + expected: string +): boolean { + for ( + let current: ts.Node | undefined = identifier.parent; + current && !ts.isSourceFile(current); + current = current.parent + ) { + const parameters = functionParameters(current); + if ( + parameters?.some((parameter) => + bindingNameContains(parameter.name, expected) + ) + ) { + return true; + } + if (ts.isCatchClause(current) && current.variableDeclaration) { + if (bindingNameContains(current.variableDeclaration.name, expected)) { + return true; + } + } + if ( + (ts.isFunctionDeclaration(current) || + ts.isFunctionExpression(current) || + ts.isClassDeclaration(current) || + ts.isClassExpression(current)) && + current.name?.text === expected + ) { + return true; + } + if ( + (ts.isForStatement(current) || + ts.isForInStatement(current) || + ts.isForOfStatement(current)) && + current.initializer && + ts.isVariableDeclarationList(current.initializer) && + current.initializer.declarations.some((declaration) => + bindingNameContains(declaration.name, expected) + ) + ) { + return true; + } + if (ts.isBlock(current)) { + for (const statement of current.statements) { + if ( + ts.isVariableStatement(statement) && + statement.declarationList.declarations.some((declaration) => + bindingNameContains(declaration.name, expected) + ) + ) { + return true; + } + if ( + (ts.isFunctionDeclaration(statement) || + ts.isClassDeclaration(statement) || + ts.isEnumDeclaration(statement)) && + statement.name?.text === expected + ) { + return true; + } + } + } + } + return false; +} + +function walk(node: ts.Node, visit: (node: ts.Node) => void): void { + visit(node); + ts.forEachChild(node, (child) => walk(child, visit)); +} + +interface LexicalScope { + readonly parent?: LexicalScope; + readonly bindings: Map; +} + +function bindingNames(name: ts.BindingName): string[] { + if (ts.isIdentifier(name)) return [name.text]; + return name.elements.flatMap((element) => + ts.isBindingElement(element) ? bindingNames(element.name) : [] + ); +} + +function browserAliasDeclarations( + sourceFile: ts.SourceFile, + globals: ReadonlySet +): ts.VariableDeclaration[] { + const sourceScope: LexicalScope = { bindings: new Map() }; + const declarationScopes = new Map(); + + const declare = ( + scope: LexicalScope, + name: ts.BindingName, + binding: ts.VariableDeclaration | 'shadow' + ): void => { + for (const identifier of bindingNames(name)) { + scope.bindings.set(identifier, binding); + } + }; + + const collect = (node: ts.Node, parentScope: LexicalScope): void => { + let scope = parentScope; + if (node !== sourceFile && (ts.isBlock(node) || functionParameters(node))) { + scope = { parent: parentScope, bindings: new Map() }; + for (const parameter of functionParameters(node) ?? []) { + declare(scope, parameter.name, 'shadow'); + } + } + if (ts.isImportDeclaration(node) && node.importClause) { + if (node.importClause.name) { + sourceScope.bindings.set(node.importClause.name.text, 'shadow'); + } + const imports = node.importClause.namedBindings; + if (imports && ts.isNamespaceImport(imports)) { + sourceScope.bindings.set(imports.name.text, 'shadow'); + } else if (imports && ts.isNamedImports(imports)) { + for (const specifier of imports.elements) { + sourceScope.bindings.set(specifier.name.text, 'shadow'); + } + } + } + if (ts.isVariableDeclaration(node)) { + declare(scope, node.name, node); + declarationScopes.set(node, scope); + } + ts.forEachChild(node, (child) => collect(child, scope)); + }; + collect(sourceFile, sourceScope); + + const resolve = ( + scope: LexicalScope, + name: string + ): ts.VariableDeclaration | 'shadow' | undefined => { + for ( + let current: LexicalScope | undefined = scope; + current; + current = current.parent + ) { + const binding = current.bindings.get(name); + if (binding) return binding; + } + return undefined; + }; + const memo = new Map(); + const visiting = new Set(); + const isBrowserAlias = (declaration: ts.VariableDeclaration): boolean => { + const cached = memo.get(declaration); + if (cached !== undefined) return cached; + if (visiting.has(declaration)) return false; + visiting.add(declaration); + const initializer = declaration.initializer; + const scope = declarationScopes.get(declaration); + let result = false; + if (initializer && scope && ts.isIdentifier(initializer)) { + const binding = resolve(scope, initializer.text); + result = + (globals.has(initializer.text) && binding === undefined) || + (binding !== undefined && + binding !== 'shadow' && + isBrowserAlias(binding)); + } + visiting.delete(declaration); + memo.set(declaration, result); + return result; + }; + + return [...declarationScopes.keys()].filter(isBrowserAlias); +} + +function nodeContainsSensitiveName( + node: ts.Node, + isSensitive: (value: string) => boolean, + skipFunctionBodies = false +): boolean { + let sensitive = false; + const visit = (child: ts.Node): void => { + if (sensitive) return; + if (ts.isIdentifier(child) && isSensitive(child.text)) sensitive = true; + if (ts.isStringLiteralLike(child) && isSensitive(child.text)) { + sensitive = true; + } + if ( + skipFunctionBodies && + (ts.isArrowFunction(child) || + ts.isFunctionExpression(child) || + ts.isFunctionDeclaration(child)) + ) { + return; + } + ts.forEachChild(child, visit); + }; + visit(node); + return sensitive; +} + +function isDeclarationIdentifier(identifier: ts.Identifier): boolean { + const parent = identifier.parent; + return ( + ((ts.isVariableDeclaration(parent) || + ts.isParameter(parent) || + ts.isPropertyDeclaration(parent) || + ts.isPropertySignature(parent) || + ts.isMethodDeclaration(parent) || + ts.isMethodSignature(parent) || + ts.isFunctionDeclaration(parent) || + ts.isFunctionExpression(parent) || + ts.isClassDeclaration(parent) || + ts.isClassExpression(parent) || + ts.isInterfaceDeclaration(parent) || + ts.isTypeAliasDeclaration(parent)) && + parent.name === identifier) || + (ts.isBindingElement(parent) && + (parent.name === identifier || parent.propertyName === identifier)) || + (ts.isPropertyAssignment(parent) && parent.name === identifier) || + (ts.isPropertyAccessExpression(parent) && parent.name === identifier) || + (ts.isImportSpecifier(parent) && + (parent.name === identifier || parent.propertyName === identifier)) + ); +} + +function isTypeOnlyIdentifierReference(identifier: ts.Identifier): boolean { + for ( + let current: ts.Node | undefined = identifier.parent; + current && !ts.isStatement(current); + current = current.parent + ) { + if (ts.isTypeNode(current)) return true; + } + return false; +} + +function directReturnedObject( + factory: ts.ArrowFunction | ts.FunctionExpression +): ts.ObjectLiteralExpression | undefined { + if (ts.isObjectLiteralExpression(factory.body)) return factory.body; + if (!ts.isBlock(factory.body)) return undefined; + const returns = factory.body.statements.filter(ts.isReturnStatement); + return returns.length === 1 && + returns[0].expression && + ts.isObjectLiteralExpression(returns[0].expression) + ? returns[0].expression + : undefined; +} + +function directReturnedExpression( + factory: ts.ArrowFunction | ts.FunctionExpression +): ts.Expression | undefined { + if (!ts.isBlock(factory.body)) return factory.body; + const returns = factory.body.statements.filter(ts.isReturnStatement); + return returns.length === 1 ? returns[0].expression : undefined; +} + +function connectionFactoryMetadata( + factory: ts.ArrowFunction | ts.FunctionExpression, + sourceFile: ts.SourceFile, + expectedAdapter: CompatibleRuntimeAdapter +): Pick< + AngularProviderRecord, + | 'connectionDeclaration' + | 'connectionCallCount' + | 'connectionWrites' + | 'canonicalFactory' + | 'returnExpression' + | 'returnedProperties' +> { + const connectionDeclarations: ts.VariableDeclaration[] = []; + if (ts.isBlock(factory.body)) { + for (const statement of factory.body.statements) { + if ( + !ts.isVariableStatement(statement) || + !(statement.declarationList.flags & ts.NodeFlags.Const) || + statement.declarationList.declarations.length !== 1 + ) { + continue; + } + for (const declaration of statement.declarationList.declarations) { + if ( + ts.isIdentifier(declaration.name) && + declaration.name.text === 'connection' && + declaration.initializer && + ts.isCallExpression(declaration.initializer) && + ts.isIdentifier(declaration.initializer.expression) && + declaration.initializer.expression.text === + 'injectCockpitRuntimeConnection' && + declaration.initializer.arguments.length === 0 + ) { + connectionDeclarations.push(declaration); + } + } + } + } + let connectionCallCount = 0; + let connectionWrites = false; + let returnCount = 0; + walk(factory.body, (node) => { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'injectCockpitRuntimeConnection' + ) { + connectionCallCount++; + } + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + node.operatorToken.kind <= ts.SyntaxKind.LastAssignment && + expressionRootIdentifier(node.left)?.text === 'connection' + ) { + connectionWrites = true; + } + if ( + (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) && + expressionRootIdentifier(node.operand)?.text === 'connection' + ) { + connectionWrites = true; + } + if ( + ts.isDeleteExpression(node) && + expressionRootIdentifier(node.expression)?.text === 'connection' + ) { + connectionWrites = true; + } + if (ts.isReturnStatement(node)) returnCount++; + }); + const returned = directReturnedExpression(factory); + const returnedProperties: Record = {}; + if (returned && ts.isObjectLiteralExpression(returned)) { + for (const property of returned.properties) { + if (ts.isPropertyAssignment(property) && ts.isIdentifier(property.name)) { + returnedProperties[property.name.text] = + property.initializer.getText(sourceFile); + } + } + } + const statements = ts.isBlock(factory.body) ? factory.body.statements : []; + const declarationStatement = statements[0]; + const guardStatement = statements[1]; + const returnStatement = statements[2]; + const canonicalDeclaration = + !!declarationStatement && + ts.isVariableStatement(declarationStatement) && + !!(declarationStatement.declarationList.flags & ts.NodeFlags.Const) && + declarationStatement.declarationList.declarations.length === 1 && + connectionDeclarations.length === 1 && + declarationStatement.declarationList.declarations[0] === + connectionDeclarations[0]; + const canonicalGuard = (() => { + if ( + !guardStatement || + !ts.isIfStatement(guardStatement) || + guardStatement.elseStatement || + !ts.isBinaryExpression(guardStatement.expression) || + guardStatement.expression.operatorToken.kind !== + ts.SyntaxKind.ExclamationEqualsEqualsToken || + !ts.isPropertyAccessExpression(guardStatement.expression.left) || + !ts.isIdentifier(guardStatement.expression.left.expression) || + guardStatement.expression.left.expression.text !== 'connection' || + guardStatement.expression.left.name.text !== 'adapter' || + !ts.isStringLiteralLike(guardStatement.expression.right) || + guardStatement.expression.right.text !== expectedAdapter || + !ts.isBlock(guardStatement.thenStatement) || + guardStatement.thenStatement.statements.length !== 1 + ) { + return false; + } + const thrown = guardStatement.thenStatement.statements[0]; + return ( + ts.isThrowStatement(thrown) && + !!thrown.expression && + ts.isNewExpression(thrown.expression) && + ts.isIdentifier(thrown.expression.expression) && + thrown.expression.expression.text === 'Error' && + thrown.expression.arguments?.length === 1 && + ts.isStringLiteralLike(thrown.expression.arguments[0]) && + thrown.expression.arguments[0].text === 'incompatible runtime' + ); + })(); + const canonicalReturn = + !!returnStatement && + ts.isReturnStatement(returnStatement) && + !!returnStatement.expression && + returnStatement.expression === returned && + returnCount === 1; + + walk(factory.body, (node) => { + if (!ts.isIdentifier(node) || node.text !== 'connection') return; + const parent = node.parent; + const isDeclaration = + ts.isVariableDeclaration(parent) && parent.name === node; + const access = + ts.isPropertyAccessExpression(parent) && parent.expression === node + ? parent + : undefined; + const isGuardAccess = + !!access && + access.name.text === 'adapter' && + !!guardStatement && + ts.isIfStatement(guardStatement) && + ts.isBinaryExpression(guardStatement.expression) && + guardStatement.expression.left === access; + let isDirectReturnAccess = false; + if (access && returnStatement && ts.isReturnStatement(returnStatement)) { + if (returnStatement.expression === access) { + isDirectReturnAccess = access.name.text === 'clientOptions'; + } else if ( + ts.isPropertyAssignment(access.parent) && + access.parent.initializer === access && + ts.isIdentifier(access.parent.name) && + access.parent.name.text === access.name.text && + (access.name.text === 'url' || + access.name.text === 'apiUrl' || + access.name.text === 'assistantId' || + access.name.text === 'clientOptions') + ) { + isDirectReturnAccess = true; + } + } + if (!isDeclaration && !isGuardAccess && !isDirectReturnAccess) { + connectionWrites = true; + } + }); + const canonicalFactory = + ts.isArrowFunction(factory) && + factory.parameters.length === 0 && + statements.length === 3 && + canonicalDeclaration && + canonicalGuard && + canonicalReturn && + connectionCallCount === 1 && + !connectionWrites; + return { + ...(connectionDeclarations.length === 1 + ? { + connectionDeclaration: `const ${connectionDeclarations[0].getText( + sourceFile + )}`, + } + : {}), + connectionCallCount, + connectionWrites, + canonicalFactory, + ...(returned ? { returnExpression: returned.getText(sourceFile) } : {}), + returnedProperties, + }; +} + +function providerAdapter( + moduleName: string +): CompatibleRuntimeAdapter | undefined { + if (moduleName === '@threadplane/ag-ui') return 'ag-ui'; + if (moduleName === '@threadplane/langgraph') return 'langgraph'; + return undefined; +} + +function directUniqueProperties( + object: ts.ObjectLiteralExpression +): Map | undefined { + const properties = new Map(); + for (const property of object.properties) { + if ( + !ts.isPropertyAssignment(property) || + !ts.isIdentifier(property.name) || + properties.has(property.name.text) + ) { + return undefined; + } + properties.set(property.name.text, property); + } + return properties; +} + +function agSharedUrlGlobalIdentifiers( + expression: ts.Expression +): { url: ts.Identifier; document: ts.Identifier } | undefined { + if ( + !ts.isPropertyAccessExpression(expression) || + expression.name.text !== 'pathname' || + !ts.isNewExpression(expression.expression) + ) { + return undefined; + } + const urlCall = expression.expression; + if ( + !ts.isIdentifier(urlCall.expression) || + urlCall.expression.text !== 'URL' || + urlCall.arguments?.length !== 2 || + !ts.isStringLiteralLike(urlCall.arguments[0]) || + urlCall.arguments[0].text !== 'agent' + ) { + return undefined; + } + const baseUri = urlCall.arguments[1]; + if ( + !ts.isPropertyAccessExpression(baseUri) || + baseUri.name.text !== 'baseURI' || + !ts.isIdentifier(baseUri.expression) || + baseUri.expression.text !== 'document' + ) { + return undefined; + } + return { url: urlCall.expression, document: baseUri.expression }; +} + +function hasRedactedCatch(call: ts.CallExpression): boolean { + const catchAccess = call.parent; + if ( + !ts.isPropertyAccessExpression(catchAccess) || + catchAccess.expression !== call || + catchAccess.name.text !== 'catch' + ) { + return false; + } + const catchCall = catchAccess.parent; + if ( + !ts.isCallExpression(catchCall) || + catchCall.expression !== catchAccess || + catchCall.arguments.length !== 1 + ) { + return false; + } + const handler = catchCall.arguments[0]; + return ( + ts.isArrowFunction(handler) && + ts.isIdentifier(handler.body) && + handler.body.text === 'undefined' + ); +} + +function hasCanonicalTopLevelBootstrapOwner( + call: ts.CallExpression, + sourceFile: ts.SourceFile +): boolean { + if (!hasRedactedCatch(call)) return false; + const catchAccess = call.parent; + if (!ts.isPropertyAccessExpression(catchAccess)) return false; + const catchCall = catchAccess.parent; + if (!ts.isCallExpression(catchCall)) return false; + const voidExpression = catchCall.parent; + return ( + ts.isVoidExpression(voidExpression) && + voidExpression.expression === catchCall && + ts.isExpressionStatement(voidExpression.parent) && + voidExpression.parent.expression === voidExpression && + voidExpression.parent.parent === sourceFile + ); +} + +export function inspectRuntimeTargetSource( + source: string, + fileName: string, + expectedAdapter?: CompatibleRuntimeAdapter +): RuntimeTargetSourceInspection { + const sourceFile = ts.createSourceFile( + fileName, + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ); + const issues: RuntimeWiringAuditIssue[] = []; + const providerCalls: CanonicalProviderCall[] = []; + const bootstrapCalls: BootstrapCallRecord[] = []; + const angularProviders: AngularProviderRecord[] = []; + const canonicalProviderAdapters = new Set(); + const importBindingsByLocalName = new Map< + string, + Array<{ + readonly moduleName: string; + readonly importedName: string; + readonly directUnaliased: boolean; + }> + >(); + let canonicalConnectionImport = false; + let canonicalComponentImportCount = 0; + let canonicalAppConfigImportCount = 0; + let canonicalHarnessImportCount = 0; + let harnessSymbolImportCount = 0; + const directConstructorNames = new Set(); + const directFactoryNames = new Set(); + const agentNamespaces = new Set(); + const directBrowserGlobals = new Set([ + 'window', + 'document', + 'globalThis', + 'self', + ]); + const taintedLogNames = new Set(); + const isEntrypoint = /(?:^|\/)main(?:\.cockpit)?\.ts$/.test(fileName); + + const report = (kind: RuntimeWiringAuditKind, detail: string): void => { + if ( + !issues.some((issue) => issue.kind === kind && issue.detail === detail) + ) { + issues.push({ kind, detail }); + } + }; + + const recordImportBinding = ( + localName: string, + moduleName: string, + importedName: string, + directUnaliased: boolean + ): void => { + const bindings = importBindingsByLocalName.get(localName) ?? []; + bindings.push({ moduleName, importedName, directUnaliased }); + importBindingsByLocalName.set(localName, bindings); + }; + + for (const statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement) || !statement.importClause) continue; + const moduleName = ts.isStringLiteralLike(statement.moduleSpecifier) + ? statement.moduleSpecifier.text + : ''; + if ( + !isEntrypoint && + /(?:^|\/|\.)environments?(?:\/|\.|$)/i.test(moduleName) + ) { + report( + 'environment-config-outside-entrypoint', + `environment import ${moduleName}` + ); + } + const bindings = statement.importClause.namedBindings; + if (statement.importClause.name) { + recordImportBinding( + statement.importClause.name.text, + moduleName, + 'default', + true + ); + } + if (bindings && ts.isNamespaceImport(bindings)) { + recordImportBinding(bindings.name.text, moduleName, '*', false); + } + if (statement.importClause.name && agentModule.test(moduleName)) { + directConstructorNames.add(statement.importClause.name.text); + } + if ( + bindings && + ts.isNamespaceImport(bindings) && + agentModule.test(moduleName) + ) { + agentNamespaces.add(bindings.name.text); + } + if (!bindings || !ts.isNamedImports(bindings)) continue; + for (const specifier of bindings.elements) { + const importedName = (specifier.propertyName ?? specifier.name).text; + const localName = specifier.name.text; + recordImportBinding( + localName, + moduleName, + importedName, + !specifier.propertyName && localName === importedName + ); + const adapter = providerAdapter(moduleName); + if (adapter && importedName === 'provideAgent') { + if (specifier.propertyName || localName !== 'provideAgent') { + report( + 'noncanonical-provider-wiring', + `provideAgent must use its direct named import from ${moduleName}` + ); + } else { + canonicalProviderAdapters.add(adapter); + } + } + if ( + importedName === 'injectCockpitRuntimeConnection' && + moduleName === '@threadplane/cockpit-telemetry' + ) { + if (specifier.propertyName || localName !== importedName) { + report( + 'noncanonical-provider-wiring', + 'injectCockpitRuntimeConnection must use its direct named import' + ); + } else { + canonicalConnectionImport = true; + } + } + if ( + moduleName === '@angular/core' && + importedName === 'Component' && + !specifier.propertyName && + localName === 'Component' + ) { + canonicalComponentImportCount++; + } + if ( + isEntrypoint && + moduleName === './app/app.config' && + importedName === 'appConfig' && + !specifier.propertyName && + localName === 'appConfig' + ) { + canonicalAppConfigImportCount++; + } + if ( + isEntrypoint && + moduleName === '@threadplane/cockpit-telemetry' && + importedName === 'bootstrapWithCockpitHarness' + ) { + harnessSymbolImportCount++; + if (!specifier.propertyName && localName === importedName) { + canonicalHarnessImportCount++; + } + } + if (agentModule.test(moduleName)) { + if (/^(?:Agent|Client|HttpAgent|LangGraphClient)$/.test(importedName)) { + directConstructorNames.add(localName); + } + if ( + /^(?:createAgent|createLangGraphClient|createClient)$/.test( + importedName + ) + ) { + directFactoryNames.add(localName); + } + } + if ( + !/(?:^|\/|\.)environments?(?:\/|\.|$)/i.test(moduleName) && + !( + moduleName === '@threadplane/cockpit-telemetry' && + importedName === 'injectCockpitRuntimeConnection' + ) && + (isSensitiveRuntimeName(importedName) || + isSensitiveRuntimeName(localName)) + ) { + report( + 'imported-runtime-secret', + `sensitive import ${importedName} from ${moduleName}` + ); + } + } + } + + for (const statement of sourceFile.statements) { + if (!ts.isVariableStatement(statement)) continue; + for (const declaration of statement.declarationList.declarations) { + const semanticNodes: ts.Node[] = [declaration.name]; + if (declaration.type) semanticNodes.push(declaration.type); + if (declaration.initializer) semanticNodes.push(declaration.initializer); + if ( + semanticNodes.some((node) => + nodeContainsSensitiveName(node, isSensitiveRuntimeName, true) + ) + ) { + report( + 'module-global-runtime-cache', + `module-global ${declaration.name.getText( + sourceFile + )} has runtime-shaped name, type, or value` + ); + } + } + } + + for (const declaration of browserAliasDeclarations( + sourceFile, + directBrowserGlobals + )) { + report( + 'browser-state-read', + `browser alias ${declaration.name.getText(sourceFile)}` + ); + } + + const writtenRuntimeBindings = new Set(); + walk(sourceFile, (node) => { + let target: ts.Expression | undefined; + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + node.operatorToken.kind <= ts.SyntaxKind.LastAssignment + ) { + target = node.left; + } else if ( + ts.isPrefixUnaryExpression(node) || + ts.isPostfixUnaryExpression(node) + ) { + target = node.operand; + } else if (ts.isDeleteExpression(node)) { + target = node.expression; + } + const targetRoot = target ? expressionRootIdentifier(target) : undefined; + if (targetRoot) { + writtenRuntimeBindings.add(targetRoot.text); + } + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + ((node.expression.expression.getText(sourceFile) === 'Object' && + node.expression.name.text === 'assign') || + (node.expression.expression.getText(sourceFile) === 'Reflect' && + node.expression.name.text === 'set')) + ) { + const firstRoot = node.arguments[0] + ? expressionRootIdentifier(node.arguments[0]) + : undefined; + if (firstRoot) { + writtenRuntimeBindings.add(firstRoot.text); + } + } + }); + const topLevelLocalBindingCount = (name: string): number => + sourceFile.statements.reduce((count, statement) => { + if (ts.isVariableStatement(statement)) { + return ( + count + + statement.declarationList.declarations.filter((declaration) => + bindingNameContains(declaration.name, name) + ).length + ); + } + if ( + (ts.isFunctionDeclaration(statement) || + ts.isClassDeclaration(statement) || + ts.isEnumDeclaration(statement)) && + statement.name?.text === name + ) { + return count + 1; + } + return count; + }, 0); + const topLevelImportBindingCount = (name: string): number => + sourceFile.statements.reduce((count, statement) => { + if (!ts.isImportDeclaration(statement) || !statement.importClause) { + return count; + } + let additions = statement.importClause.name?.text === name ? 1 : 0; + const bindings = statement.importClause.namedBindings; + if (bindings && ts.isNamespaceImport(bindings)) { + additions += bindings.name.text === name ? 1 : 0; + } else if (bindings && ts.isNamedImports(bindings)) { + additions += bindings.elements.filter( + (specifier) => specifier.name.text === name + ).length; + } + return count + additions; + }, 0); + const exactBinding = (identifier: ts.Identifier): ExactImportBinding => { + const imports = importBindingsByLocalName.get(identifier.text) ?? []; + const imported = imports.length === 1 ? imports[0] : undefined; + return { + identifier: identifier.text, + ...(imported + ? { + moduleName: imported.moduleName, + importedName: imported.importedName, + } + : {}), + canonical: + !!imported && + imported.directUnaliased && + topLevelImportBindingCount(identifier.text) === 1 && + topLevelLocalBindingCount(identifier.text) === 0 && + !writtenRuntimeBindings.has(identifier.text) && + !isLocallyShadowed(identifier, identifier.text), + }; + }; + const exactFactoryBinding = ( + factory: ts.ArrowFunction | ts.FunctionExpression, + name: string + ): ExactImportBinding | undefined => { + const identifiers: ts.Identifier[] = []; + walk(factory.body, (node) => { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === name + ) { + identifiers.push(node.expression); + } + }); + return identifiers.length === 1 ? exactBinding(identifiers[0]) : undefined; + }; + const hasOnlyExecutableReferences = ( + expectedIdentifiers: readonly ts.Identifier[], + name: string + ): boolean => { + const references: ts.Identifier[] = []; + walk(sourceFile, (node) => { + if ( + ts.isIdentifier(node) && + node.text === name && + !isDeclarationIdentifier(node) && + !isTypeOnlyIdentifierReference(node) + ) { + references.push(node); + } + }); + return ( + references.length === expectedIdentifiers.length && + expectedIdentifiers.every((identifier) => references.includes(identifier)) + ); + }; + const isSoleExecutableReference = ( + expectedIdentifier: ts.Identifier, + name: string + ): boolean => hasOnlyExecutableReferences([expectedIdentifier], name); + const stableComponentBinding = + canonicalComponentImportCount === 1 && + topLevelImportBindingCount('Component') === 1 && + topLevelLocalBindingCount('Component') === 0 && + !writtenRuntimeBindings.has('Component'); + + const providerRegistrationOwners = new Map< + ts.CallExpression, + ProviderRegistrationOwner + >(); + const appConfigProviderArrays: ts.ArrayLiteralExpression[] = []; + const directProvidersArray = ( + object: ts.ObjectLiteralExpression, + ownerLabel: string, + required: boolean + ): ts.ArrayLiteralExpression | undefined => { + if (object.properties.some(ts.isSpreadAssignment)) { + report( + 'noncanonical-provider-wiring', + `${ownerLabel} object may not contain spreads` + ); + return undefined; + } + const providersMembers = object.properties.filter( + (property) => + (ts.isPropertyAssignment(property) || + ts.isShorthandPropertyAssignment(property) || + ts.isMethodDeclaration(property)) && + propertyNameText(property.name) === 'providers' + ); + if (providersMembers.length === 0 && !required) return undefined; + if ( + providersMembers.length !== 1 || + !ts.isPropertyAssignment(providersMembers[0]) || + !ts.isIdentifier(providersMembers[0].name) || + !ts.isArrayLiteralExpression(providersMembers[0].initializer) + ) { + report( + 'noncanonical-provider-wiring', + `${ownerLabel} requires one explicit direct providers array` + ); + return undefined; + } + const providers = providersMembers[0].initializer; + if (providers.elements.some(ts.isSpreadElement)) { + report( + 'noncanonical-provider-wiring', + `${ownerLabel} providers array may not contain spreads` + ); + return undefined; + } + return providers; + }; + const registerDirectProviderCalls = ( + providers: ts.ArrayLiteralExpression, + owner: ProviderRegistrationOwner + ): void => { + for (const element of providers.elements) { + if ( + ts.isCallExpression(element) && + ts.isIdentifier(element.expression) && + element.expression.text === 'provideAgent' + ) { + providerRegistrationOwners.set(element, owner); + } + } + }; + const appConfigDeclarations = sourceFile.statements.flatMap((statement) => + ts.isVariableStatement(statement) + ? statement.declarationList.declarations.filter( + (declaration) => + ts.isIdentifier(declaration.name) && + declaration.name.text === 'appConfig' + ) + : [] + ); + const appConfigDeclarationStatement = + appConfigDeclarations.length === 1 + ? (appConfigDeclarations[0].parent.parent as ts.VariableStatement) + : undefined; + const appConfigDeclarationIndex = appConfigDeclarationStatement + ? sourceFile.statements.indexOf(appConfigDeclarationStatement) + : -1; + let appConfigReferencedInInitializer = false; + if (appConfigDeclarations[0]?.initializer) { + walk(appConfigDeclarations[0].initializer, (node) => { + if ( + ts.isIdentifier(node) && + node.text === 'appConfig' && + !isDeclarationIdentifier(node) && + !isTypeOnlyIdentifierReference(node) + ) { + appConfigReferencedInInitializer = true; + } + }); + } + let appConfigReferencedAfterDeclaration = false; + if (appConfigDeclarationIndex >= 0) { + for (const statement of sourceFile.statements.slice( + appConfigDeclarationIndex + 1 + )) { + walk(statement, (node) => { + if ( + ts.isIdentifier(node) && + node.text === 'appConfig' && + !isDeclarationIdentifier(node) && + !isTypeOnlyIdentifierReference(node) + ) { + appConfigReferencedAfterDeclaration = true; + } + }); + } + } + const stableAppConfigBinding = + appConfigDeclarations.length === 1 && + topLevelLocalBindingCount('appConfig') === 1 && + topLevelImportBindingCount('appConfig') === 0 && + !writtenRuntimeBindings.has('appConfig') && + !appConfigReferencedInInitializer && + !appConfigReferencedAfterDeclaration; + if (appConfigDeclarations.length > 0 && !stableAppConfigBinding) { + report( + 'noncanonical-provider-wiring', + 'appConfig registration owner must have one stable top-level binding' + ); + } + for (const appConfig of appConfigDeclarations) { + const statement = appConfig.parent.parent; + const exported = + ts.isVariableStatement(statement) && + (ts.getModifiers(statement) ?? []).some( + (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword + ) && + !!(statement.declarationList.flags & ts.NodeFlags.Const) && + statement.declarationList.declarations.length === 1; + if (!exported || !stableAppConfigBinding) { + report( + 'noncanonical-provider-wiring', + 'appConfig registration owner must be one exported top-level const' + ); + continue; + } + if ( + !appConfig.initializer || + !ts.isObjectLiteralExpression(appConfig.initializer) + ) { + report( + 'noncanonical-provider-wiring', + 'appConfig must be initialized with a direct object literal' + ); + continue; + } + const providers = directProvidersArray( + appConfig.initializer, + 'appConfig', + true + ); + if (!providers) continue; + appConfigProviderArrays.push(providers); + registerDirectProviderCalls(providers, { kind: 'appConfig' }); + } + walk(sourceFile, (node) => { + if ( + !ts.isClassDeclaration(node) || + !node.name || + node.parent !== sourceFile + ) { + return; + } + const decorators = ts.canHaveDecorators(node) + ? ts.getDecorators(node) ?? [] + : []; + for (const decorator of decorators) { + if ( + !ts.isCallExpression(decorator.expression) || + !ts.isIdentifier(decorator.expression.expression) || + decorator.expression.expression.text !== 'Component' + ) { + continue; + } + if (!stableComponentBinding) { + report( + 'noncanonical-provider-wiring', + '@Component provider owner requires the stable direct Component import from @angular/core' + ); + continue; + } + const metadata = decorator.expression.arguments[0]; + if (!metadata || !ts.isObjectLiteralExpression(metadata)) continue; + const providers = directProvidersArray( + metadata, + `@Component ${node.name.text}`, + false + ); + if (providers) { + registerDirectProviderCalls(providers, { + kind: 'component', + component: node.name.text, + }); + } + } + }); + + walk(sourceFile, (node) => { + if (ts.isVariableDeclaration(node)) { + const initializer = node.initializer; + const declaresProvider = + bindingNameContains(node.name, 'provideAgent') || + bindingPropertyContains(node.name, 'provideAgent'); + const aliasesProvider = + !!initializer && + ((ts.isIdentifier(initializer) && + initializer.text === 'provideAgent') || + (ts.isPropertyAccessExpression(initializer) && + initializer.name.text === 'provideAgent') || + (ts.isElementAccessExpression(initializer) && + !!initializer.argumentExpression && + staticPropertyName(initializer.argumentExpression) === + 'provideAgent') || + (ts.isIdentifier(node.name) && node.name.text === 'provideAgent')); + if (declaresProvider || aliasesProvider) { + report( + 'noncanonical-provider-wiring', + 'provideAgent may not be destructured, reversed, or locally aliased' + ); + } + } + + if (ts.isNewExpression(node) && ts.isIdentifier(node.expression)) { + if (directConstructorNames.has(node.expression.text)) { + report('direct-agent-construction', `new ${node.expression.text}`); + } + } + if ( + ts.isNewExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + agentNamespaces.has(node.expression.expression.getText(sourceFile)) + ) { + report( + 'direct-agent-construction', + `new ${node.expression.getText(sourceFile)}` + ); + } + + if (!ts.isCallExpression(node)) return; + if ( + ts.isIdentifier(node.expression) && + directFactoryNames.has(node.expression.text) + ) { + report('direct-agent-construction', `call ${node.expression.text}`); + } + if ( + ts.isPropertyAccessExpression(node.expression) && + agentNamespaces.has(node.expression.expression.getText(sourceFile)) && + /(?:Agent|Client)/.test(node.expression.name.text) + ) { + report( + 'direct-agent-construction', + `call ${node.expression.getText(sourceFile)}` + ); + } + if ( + ts.isPropertyAccessExpression(node.expression) && + node.expression.name.text === 'provideAgent' + ) { + report( + 'noncanonical-provider-wiring', + 'provideAgent must be called as its direct imported identifier' + ); + return; + } + if ( + !ts.isIdentifier(node.expression) || + node.expression.text !== 'provideAgent' + ) { + return; + } + const owner = providerRegistrationOwners.get(node); + if (!owner) { + report( + 'noncanonical-provider-wiring', + 'provideAgent must be a direct element of executable appConfig.providers or @Component.providers' + ); + } + if (canonicalProviderAdapters.size !== 1) { + report( + 'noncanonical-provider-wiring', + 'provideAgent call requires exactly one direct named adapter import' + ); + } + const adapter = + canonicalProviderAdapters.size === 1 + ? [...canonicalProviderAdapters][0] + : expectedAdapter; + if (!adapter) return; + const provideAgentBinding = exactBinding(node.expression); + const expectedProviderModule = + adapter === 'ag-ui' ? '@threadplane/ag-ui' : '@threadplane/langgraph'; + if ( + !hasExactImportBinding( + provideAgentBinding, + expectedProviderModule, + 'provideAgent' + ) + ) { + report( + 'noncanonical-provider-wiring', + `provideAgent requires its stable import from ${expectedProviderModule}` + ); + } + if (expectedAdapter && adapter !== expectedAdapter) { + report( + 'noncanonical-provider-wiring', + `provideAgent adapter ${adapter} does not match ${expectedAdapter}` + ); + } + const factory = node.arguments.at(-1); + if ( + !factory || + (!ts.isArrowFunction(factory) && !ts.isFunctionExpression(factory)) + ) { + report( + 'direct-agent-provider-config', + 'provideAgent requires an inline factory' + ); + return; + } + const agentRefArgument = + node.arguments.length === 2 ? node.arguments[0] : undefined; + if ( + node.arguments.length > 2 || + (agentRefArgument && !ts.isIdentifier(agentRefArgument)) + ) { + report( + 'noncanonical-provider-wiring', + 'typed provider refs must be a direct identifier first argument' + ); + } + if ( + agentRefArgument && + ts.isIdentifier(agentRefArgument) && + !hasExactImportBinding( + exactBinding(agentRefArgument), + './agent-ref', + agentRefArgument.text + ) + ) { + report( + 'noncanonical-provider-wiring', + `typed provider ref ${agentRefArgument.text} requires its stable ./agent-ref import` + ); + } + if (!canonicalConnectionImport) { + report( + 'noncanonical-provider-wiring', + 'provider source requires direct injectCockpitRuntimeConnection import' + ); + } + const connectionInjectorBinding = exactFactoryBinding( + factory, + 'injectCockpitRuntimeConnection' + ); + if ( + !hasExactImportBinding( + connectionInjectorBinding, + '@threadplane/cockpit-telemetry', + 'injectCockpitRuntimeConnection' + ) + ) { + report( + 'noncanonical-provider-wiring', + 'provider factory requires the stable telemetry connection injector import' + ); + } + const connectionMetadata = connectionFactoryMetadata( + factory, + sourceFile, + adapter + ); + if (!connectionMetadata.canonicalFactory) { + report( + 'noncanonical-provider-wiring', + 'provider factory must contain only the ordered connection declaration, adapter guard, and sole return' + ); + } + const returned = directReturnedObject(factory); + if (!returned) { + report( + 'noncanonical-provider-wiring', + 'provider factory must return a direct object literal' + ); + return; + } + const properties: Record = {}; + for (const property of returned.properties) { + if ( + !ts.isPropertyAssignment(property) || + !ts.isIdentifier(property.name) + ) { + report( + 'noncanonical-provider-wiring', + 'provider object forbids spread, computed, method, and shorthand members' + ); + continue; + } + properties[property.name.text] = property.initializer.getText(sourceFile); + } + const required = + adapter === 'ag-ui' + ? ['url'] + : ['apiUrl', 'assistantId', 'clientOptions']; + for (const property of required) { + if (properties[property] !== `connection.${property}`) { + report( + 'noncanonical-provider-wiring', + `${property} must be an explicit ${property}: connection.${property} assignment` + ); + } + } + providerCalls.push({ + adapter, + provideAgentBinding, + ...(agentRefArgument && ts.isIdentifier(agentRefArgument) + ? { + agentRef: agentRefArgument.text, + agentRefBinding: exactBinding(agentRefArgument), + } + : {}), + properties, + ...(owner ? { owner } : {}), + }); + }); + + for (const providers of appConfigProviderArrays) { + const seenTokens = new Set(); + for (const element of providers.elements) { + if (!ts.isObjectLiteralExpression(element)) continue; + const hasSpread = element.properties.some(ts.isSpreadAssignment); + const relevantMembers = element.properties.filter((property) => { + if ( + !ts.isPropertyAssignment(property) && + !ts.isShorthandPropertyAssignment(property) && + !ts.isMethodDeclaration(property) + ) { + return false; + } + const name = propertyNameText(property.name); + return name === 'provide' || name === 'useFactory'; + }); + const provideMembers = relevantMembers.filter( + (property) => + property.name !== undefined && + propertyNameText(property.name) === 'provide' + ); + const factoryMembers = relevantMembers.filter( + (property) => + property.name !== undefined && + propertyNameText(property.name) === 'useFactory' + ); + const canonicalMembers = + !hasSpread && + provideMembers.length === 1 && + factoryMembers.length === 1 && + ts.isPropertyAssignment(provideMembers[0]) && + ts.isIdentifier(provideMembers[0].name) && + ts.isPropertyAssignment(factoryMembers[0]) && + ts.isIdentifier(factoryMembers[0].name); + if (hasSpread || provideMembers.length > 0 || factoryMembers.length > 0) { + if (!canonicalMembers) { + report( + 'noncanonical-provider-wiring', + 'Angular provider objects require explicit unique provide/useFactory assignments and no spreads' + ); + } + } + if (!canonicalMembers) continue; + const provide = provideMembers[0] as ts.PropertyAssignment; + const useFactory = factoryMembers[0] as ts.PropertyAssignment; + const token = provide.initializer.getText(sourceFile); + if (seenTokens.has(token)) { + report( + 'noncanonical-provider-wiring', + `duplicate Angular provider token ${token}` + ); + } + seenTokens.add(token); + const factory = useFactory.initializer; + const provider: AngularProviderRecord = { + provideToken: token, + ...(ts.isIdentifier(provide.initializer) + ? { provideTokenBinding: exactBinding(provide.initializer) } + : {}), + useFactoryExpression: factory.getText(sourceFile), + connectionCallCount: 0, + connectionWrites: false, + canonicalFactory: false, + returnedProperties: {}, + }; + if (ts.isArrowFunction(factory) || ts.isFunctionExpression(factory)) { + const metadata = connectionFactoryMetadata( + factory, + sourceFile, + 'langgraph' + ); + const connectionInjectorBinding = exactFactoryBinding( + factory, + 'injectCockpitRuntimeConnection' + ); + Object.assign(provider, metadata, { + ...(connectionInjectorBinding + ? { connectionInjectorBinding } + : {}), + canonicalFactory: + canonicalConnectionImport && + hasExactImportBinding( + connectionInjectorBinding, + '@threadplane/cockpit-telemetry', + 'injectCockpitRuntimeConnection' + ) && + metadata.canonicalFactory, + }); + } + angularProviders.push(provider); + } + } + + const bootstrapCallNodes: ts.CallExpression[] = []; + const harnessExecutableReferences: ts.Identifier[] = []; + walk(sourceFile, (node) => { + if ( + ts.isIdentifier(node) && + node.text === 'bootstrapWithCockpitHarness' && + !isDeclarationIdentifier(node) && + !isTypeOnlyIdentifierReference(node) + ) { + harnessExecutableReferences.push(node); + } + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'bootstrapWithCockpitHarness' + ) { + bootstrapCallNodes.push(node); + } + }); + for (const node of bootstrapCallNodes) { + const root = node.arguments[0]; + const appConfig = node.arguments[1]; + const options = node.arguments[2]; + const runtimeProperties: Record = {}; + const environmentBindings: ExactImportBinding[] = []; + const environmentReferenceIdentifiers: ts.Identifier[] = []; + let operationReporterBinding: ExactImportBinding | undefined; + let hasCanonicalRuntimeOptions = false; + let hasPristineAgUrlGlobals = expectedAdapter !== 'ag-ui'; + if (options && ts.isObjectLiteralExpression(options)) { + const outerProperties = directUniqueProperties(options); + const runtime = outerProperties?.get('runtime'); + if ( + outerProperties?.size === 1 && + runtime && + ts.isObjectLiteralExpression(runtime.initializer) + ) { + const directRuntimeProperties = directUniqueProperties( + runtime.initializer + ); + if (directRuntimeProperties) { + for (const property of directRuntimeProperties.values()) { + const propertyName = (property.name as ts.Identifier).text; + runtimeProperties[propertyName] = + property.initializer.getText(sourceFile); + if ( + propertyName === 'operationReporterToken' && + ts.isIdentifier(property.initializer) + ) { + operationReporterBinding = exactBinding(property.initializer); + } + if ( + (propertyName === 'sharedApiUrl' || + propertyName === 'assistantId') && + expressionRootIdentifier(property.initializer)?.text === + 'environment' + ) { + const environmentIdentifier = expressionRootIdentifier( + property.initializer + ) as ts.Identifier; + environmentReferenceIdentifiers.push(environmentIdentifier); + environmentBindings.push( + exactBinding(environmentIdentifier) + ); + } + } + const requiredNames = + expectedAdapter === 'langgraph' + ? [ + 'adapter', + 'sharedApiUrl', + 'assistantId', + 'operationReporterToken', + ] + : expectedAdapter === 'ag-ui' + ? ['adapter', 'sharedUrl', 'operationReporterToken'] + : []; + const adapterProperty = directRuntimeProperties.get('adapter'); + const reporterProperty = directRuntimeProperties.get( + 'operationReporterToken' + ); + const expectedReporter = + expectedAdapter === 'langgraph' + ? 'ɵLANGGRAPH_RUNTIME_OPERATION_REPORTER' + : 'ɵAG_UI_RUNTIME_OPERATION_REPORTER'; + const sharedUrlProperty = directRuntimeProperties.get('sharedUrl'); + const agGlobals = sharedUrlProperty + ? agSharedUrlGlobalIdentifiers(sharedUrlProperty.initializer) + : undefined; + hasCanonicalRuntimeOptions = + requiredNames.length > 0 && + directRuntimeProperties.size === requiredNames.length && + requiredNames.every((name) => + directRuntimeProperties.has(name) + ) && + !!adapterProperty && + ts.isStringLiteralLike(adapterProperty.initializer) && + adapterProperty.initializer.text === expectedAdapter && + !!reporterProperty && + ts.isIdentifier(reporterProperty.initializer) && + reporterProperty.initializer.text === expectedReporter && + (expectedAdapter !== 'ag-ui' || !!agGlobals); + if (expectedAdapter === 'ag-ui' && agGlobals) { + const pristineGlobal = ( + identifier: ts.Identifier, + name: string + ): boolean => + identifier.text === name && + topLevelImportBindingCount(name) === 0 && + topLevelLocalBindingCount(name) === 0 && + !writtenRuntimeBindings.has(name) && + !isLocallyShadowed(identifier, name) && + isSoleExecutableReference(identifier, name); + hasPristineAgUrlGlobals = + pristineGlobal(agGlobals.url, 'URL') && + pristineGlobal(agGlobals.document, 'document'); + } + } + } + } + if ( + expectedAdapter === 'langgraph' && + !hasOnlyExecutableReferences( + environmentReferenceIdentifiers, + 'environment' + ) + ) { + hasCanonicalRuntimeOptions = false; + for (const [index, binding] of environmentBindings.entries()) { + environmentBindings[index] = { ...binding, canonical: false }; + } + } + bootstrapCalls.push({ + ...(root && ts.isIdentifier(root) ? { rootComponent: root.text } : {}), + ...(root && ts.isIdentifier(root) + ? { rootComponentBinding: exactBinding(root) } + : {}), + ...(appConfig + ? { appConfigArgument: appConfig.getText(sourceFile) } + : {}), + hasCanonicalAppConfigBinding: + !!appConfig && + ts.isIdentifier(appConfig) && + appConfig.text === 'appConfig' && + canonicalAppConfigImportCount === 1 && + hasExactImportBinding( + exactBinding(appConfig), + './app/app.config', + 'appConfig' + ) && + isSoleExecutableReference(appConfig, 'appConfig'), + hasCanonicalHarnessBinding: + canonicalHarnessImportCount === 1 && + harnessSymbolImportCount === 1 && + hasExactImportBinding( + exactBinding(node.expression as ts.Identifier), + '@threadplane/cockpit-telemetry', + 'bootstrapWithCockpitHarness' + ) && + harnessExecutableReferences.length === 1 && + harnessExecutableReferences[0] === node.expression, + hasCanonicalCallOwner: + bootstrapCallNodes.length === 1 && + hasCanonicalTopLevelBootstrapOwner(node, sourceFile), + hasCanonicalRuntimeOptions, + hasPristineAgUrlGlobals, + environmentBindings, + ...(operationReporterBinding ? { operationReporterBinding } : {}), + runtimeProperties, + hasRedactedCatch: hasRedactedCatch(node), + }); + } + + const isGlobalObjectIdentifier = ( + expression: ts.Expression + ): expression is ts.Identifier => + ts.isIdentifier(expression) && + (expression.text === 'window' || + expression.text === 'globalThis' || + expression.text === 'self') && + !isLexicallyDeclared(expression); + + walk(sourceFile, (node) => { + const reportUnconditionalMember = (property: string): void => { + if (unconditionalBrowserMemberNames.has(property)) { + report('browser-state-read', `sensitive member ${property}`); + } + if (unconditionalSecretMemberNames.has(property)) { + report('global-runtime-secret-read', `sensitive member ${property}`); + } + }; + if (ts.isPropertyAccessExpression(node)) { + reportUnconditionalMember(node.name.text); + const chain = accessChain(node); + const locationIndex = chain.lastIndexOf('location'); + const historyIndex = chain.lastIndexOf('history'); + if ( + (locationIndex >= 0 && + chain + .slice(locationIndex + 1) + .some((member) => + ['href', 'search', 'hash', 'searchParams'].includes(member) + )) || + (historyIndex >= 0 && + chain + .slice(historyIndex + 1) + .some((member) => + ['state', 'pushState', 'replaceState'].includes(member) + )) + ) { + report( + 'browser-state-read', + `sensitive browser chain ${chain.join('.')}` + ); + } + } + if (ts.isElementAccessExpression(node) && node.argumentExpression) { + const property = staticPropertyName(node.argumentExpression); + if (property) reportUnconditionalMember(property); + const chain = accessChain(node); + const locationIndex = chain.lastIndexOf('location'); + const historyIndex = chain.lastIndexOf('history'); + if ( + (locationIndex >= 0 && + chain + .slice(locationIndex + 1) + .some((member) => ['href', 'search', 'hash'].includes(member))) || + (historyIndex >= 0 && + chain + .slice(historyIndex + 1) + .some((member) => + ['state', 'pushState', 'replaceState'].includes(member) + )) + ) { + report( + 'browser-state-read', + `sensitive browser chain ${chain.join('.')}` + ); + } + } + if ( + ts.isPropertyAssignment(node) || + ts.isShorthandPropertyAssignment(node) || + ts.isMethodDeclaration(node) + ) { + const property = propertyNameText(node.name); + if (property) reportUnconditionalMember(property); + } + if (ts.isBindingElement(node)) { + const property = node.propertyName + ? propertyNameText(node.propertyName) + : ts.isIdentifier(node.name) + ? node.name.text + : undefined; + if (property) reportUnconditionalMember(property); + } + if ( + ts.isIdentifier(node) && + browserStateNames.has(node.text) && + !isLexicallyDeclared(node) && + !isDeclarationIdentifier(node) + ) { + const isPropertyName = + (ts.isPropertyAccessExpression(node.parent) && + node.parent.name === node) || + (ts.isPropertyAssignment(node.parent) && node.parent.name === node); + if (!isPropertyName) { + report('browser-state-read', `browser state identifier ${node.text}`); + } + } + if (ts.isPropertyAccessExpression(node)) { + if (isGlobalObjectIdentifier(node.expression)) { + const property = node.name.text; + if ( + browserStateNames.has(property) || + isSensitiveRuntimeName(property) + ) { + report( + isSensitiveRuntimeName(property) + ? 'global-runtime-secret-read' + : 'browser-state-read', + `sensitive global property ${node.getText(sourceFile)}` + ); + } + } + if ( + ts.isIdentifier(node.expression) && + !isLexicallyDeclared(node.expression) && + ((node.expression.text === 'document' && + documentStateNames.has(node.name.text)) || + node.expression.text === 'location' || + node.expression.text === 'history') + ) { + report( + 'browser-state-read', + `browser state property ${node.getText(sourceFile)}` + ); + } + } + if (ts.isElementAccessExpression(node)) { + const property = node.argumentExpression + ? staticPropertyName(node.argumentExpression) + : undefined; + if (isGlobalObjectIdentifier(node.expression)) { + if (property === 'parent') return; + if ( + !property || + browserStateNames.has(property) || + isSensitiveRuntimeName(property) + ) { + report( + property && isSensitiveRuntimeName(property) + ? 'global-runtime-secret-read' + : 'browser-state-read', + `sensitive global bracket read ${node.getText(sourceFile)}` + ); + } + } + if ( + ts.isIdentifier(node.expression) && + node.expression.text === 'document' && + !isLexicallyDeclared(node.expression) && + (!property || documentStateNames.has(property)) + ) { + report( + 'browser-state-read', + `document state read ${node.getText(sourceFile)}` + ); + } + } + if (ts.isBindingElement(node)) { + const property = node.propertyName + ? ts.isIdentifier(node.propertyName) || + ts.isStringLiteralLike(node.propertyName) + ? node.propertyName.text + : undefined + : ts.isIdentifier(node.name) + ? node.name.text + : undefined; + const declaration = node.parent.parent; + if ( + property && + ts.isVariableDeclaration(declaration) && + declaration.initializer && + ts.isIdentifier(declaration.initializer) && + declaration.initializer.text === 'document' && + !isLexicallyDeclared(declaration.initializer) && + documentStateNames.has(property) + ) { + report('browser-state-read', `destructured document state ${property}`); + } + } + }); + + walk(sourceFile, (node) => { + if ( + ts.isParameter(node) && + ts.isIdentifier(node.name) && + isSensitiveLogName(node.name.text) + ) { + taintedLogNames.add(node.name.text); + } + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + ((node.initializer && + nodeContainsSensitiveName(node.initializer, isSensitiveLogName)) || + isSensitiveLogName(node.name.text)) + ) { + taintedLogNames.add(node.name.text); + } + }); + let taintChanged = true; + while (taintChanged) { + taintChanged = false; + walk(sourceFile, (node) => { + if ( + !ts.isVariableDeclaration(node) || + !ts.isIdentifier(node.name) || + !node.initializer || + taintedLogNames.has(node.name.text) + ) { + return; + } + let readsTainted = false; + walk(node.initializer, (child) => { + if (ts.isIdentifier(child) && taintedLogNames.has(child.text)) { + readsTainted = true; + } + }); + if (readsTainted) { + taintedLogNames.add(node.name.text); + taintChanged = true; + } + }); + } + walk(sourceFile, (node) => { + if ( + !ts.isCallExpression(node) || + !ts.isPropertyAccessExpression(node.expression) || + !ts.isIdentifier(node.expression.expression) || + node.expression.expression.text !== 'console' || + isLexicallyDeclared(node.expression.expression) + ) { + return; + } + const logsSensitive = node.arguments.some((argument) => { + if (ts.isStringLiteralLike(argument)) { + return isSensitiveLogName(argument.text); + } + let sensitive = nodeContainsSensitiveName(argument, isSensitiveLogName); + walk(argument, (child) => { + if (ts.isIdentifier(child) && taintedLogNames.has(child.text)) { + sensitive = true; + } + }); + return sensitive; + }); + if (logsSensitive) { + report('runtime-secret-log', `console.${node.expression.name.text}`); + } + }); + + return { issues, providerCalls, bootstrapCalls, angularProviders }; +} + +export function auditRuntimeTargetSource( + source: string, + fileName: string, + expectedAdapter?: CompatibleRuntimeAdapter +): RuntimeWiringAuditIssue[] { + return inspectRuntimeTargetSource(source, fileName, expectedAdapter).issues; +} diff --git a/apps/cockpit/scripts/capability-registry.ts b/apps/cockpit/scripts/capability-registry.ts index ff45dd078..6c88291b8 100644 --- a/apps/cockpit/scripts/capability-registry.ts +++ b/apps/cockpit/scripts/capability-registry.ts @@ -1,3 +1,5 @@ +import type { RuntimeAdapter } from '@threadplane/cockpit-registry'; + /** * Single source of truth for all cockpit capability examples. * Used by serve, build, test, and deploy scripts. @@ -19,6 +21,7 @@ export type CapabilityFramework = 'langgraph' | 'microsoft-agent-framework' | 'a export interface Capability { id: string; + runtimeAdapter: RuntimeAdapter; /** * 'runtimes' is the one-capability-many-runtimes axis * (cockpit/runtimes//): non-LangGraph AG-UI backends measured @@ -42,55 +45,55 @@ export interface Capability { // includes this file — see the deploy-gate hazard note in // scripts/assemble-examples.ts before assuming a green main run deployed them. export const capabilities: readonly Capability[] = [ - { id: 'streaming', product: 'langgraph', topic: 'streaming', angularProject: 'cockpit-langgraph-streaming-angular', port: 4300, pythonPort: 5300, pythonDir: 'cockpit/langgraph/streaming/python', graphName: 'streaming' }, - { id: 'persistence', product: 'langgraph', topic: 'persistence', angularProject: 'cockpit-langgraph-persistence-angular', port: 4301, pythonPort: 5301, pythonDir: 'cockpit/langgraph/persistence/python', graphName: 'persistence' }, - { id: 'interrupts', product: 'langgraph', topic: 'interrupts', angularProject: 'cockpit-langgraph-interrupts-angular', port: 4302, pythonPort: 5302, pythonDir: 'cockpit/langgraph/interrupts/python', graphName: 'interrupts' }, - { id: 'memory', product: 'langgraph', topic: 'memory', angularProject: 'cockpit-langgraph-memory-angular', port: 4303, pythonPort: 5303, pythonDir: 'cockpit/langgraph/memory/python', graphName: 'memory' }, - { id: 'durable-execution', product: 'langgraph', topic: 'durable-execution', angularProject: 'cockpit-langgraph-durable-execution-angular', port: 4304, pythonPort: 5304, pythonDir: 'cockpit/langgraph/durable-execution/python', graphName: 'durable-execution' }, - { id: 'subgraphs', product: 'langgraph', topic: 'subgraphs', angularProject: 'cockpit-langgraph-subgraphs-angular', port: 4305, pythonPort: 5305, pythonDir: 'cockpit/langgraph/subgraphs/python', graphName: 'subgraphs' }, - { id: 'time-travel', product: 'langgraph', topic: 'time-travel', angularProject: 'cockpit-langgraph-time-travel-angular', port: 4306, pythonPort: 5306, pythonDir: 'cockpit/langgraph/time-travel/python', graphName: 'time-travel' }, - { id: 'deployment-runtime', product: 'langgraph', topic: 'deployment-runtime', angularProject: 'cockpit-langgraph-deployment-runtime-angular', port: 4307, pythonPort: 5307, pythonDir: 'cockpit/langgraph/deployment-runtime/python', graphName: 'deployment-runtime' }, - { id: 'langgraph-client-tools', product: 'langgraph', topic: 'client-tools', angularProject: 'cockpit-langgraph-client-tools-angular', port: 4308, pythonPort: 5308, pythonDir: 'cockpit/langgraph/client-tools/python', graphName: 'client-tools' }, - { id: 'da-planning', product: 'deep-agents', topic: 'planning', angularProject: 'cockpit-deep-agents-planning-angular', port: 4310, pythonPort: 5310, pythonDir: 'cockpit/deep-agents/planning/python', graphName: 'da-planning' }, - { id: 'da-filesystem', product: 'deep-agents', topic: 'filesystem', angularProject: 'cockpit-deep-agents-filesystem-angular', port: 4311, pythonPort: 5311, pythonDir: 'cockpit/deep-agents/filesystem/python', graphName: 'da-filesystem' }, - { id: 'da-subagents', product: 'deep-agents', topic: 'subagents', angularProject: 'cockpit-deep-agents-subagents-angular', port: 4312, pythonPort: 5312, pythonDir: 'cockpit/deep-agents/subagents/python', graphName: 'subagents' }, - { id: 'da-memory', product: 'deep-agents', topic: 'memory', angularProject: 'cockpit-deep-agents-memory-angular', port: 4313, pythonPort: 5313, pythonDir: 'cockpit/deep-agents/memory/python', graphName: 'da-memory' }, - { id: 'da-skills', product: 'deep-agents', topic: 'skills', angularProject: 'cockpit-deep-agents-skills-angular', port: 4314, pythonPort: 5314, pythonDir: 'cockpit/deep-agents/skills/python', graphName: 'da-skills' }, + { id: 'streaming', runtimeAdapter: 'langgraph', product: 'langgraph', topic: 'streaming', angularProject: 'cockpit-langgraph-streaming-angular', port: 4300, pythonPort: 5300, pythonDir: 'cockpit/langgraph/streaming/python', graphName: 'streaming' }, + { id: 'persistence', runtimeAdapter: 'langgraph', product: 'langgraph', topic: 'persistence', angularProject: 'cockpit-langgraph-persistence-angular', port: 4301, pythonPort: 5301, pythonDir: 'cockpit/langgraph/persistence/python', graphName: 'persistence' }, + { id: 'interrupts', runtimeAdapter: 'langgraph', product: 'langgraph', topic: 'interrupts', angularProject: 'cockpit-langgraph-interrupts-angular', port: 4302, pythonPort: 5302, pythonDir: 'cockpit/langgraph/interrupts/python', graphName: 'interrupts' }, + { id: 'memory', runtimeAdapter: 'langgraph', product: 'langgraph', topic: 'memory', angularProject: 'cockpit-langgraph-memory-angular', port: 4303, pythonPort: 5303, pythonDir: 'cockpit/langgraph/memory/python', graphName: 'memory' }, + { id: 'durable-execution', runtimeAdapter: 'langgraph', product: 'langgraph', topic: 'durable-execution', angularProject: 'cockpit-langgraph-durable-execution-angular', port: 4304, pythonPort: 5304, pythonDir: 'cockpit/langgraph/durable-execution/python', graphName: 'durable-execution' }, + { id: 'subgraphs', runtimeAdapter: 'langgraph', product: 'langgraph', topic: 'subgraphs', angularProject: 'cockpit-langgraph-subgraphs-angular', port: 4305, pythonPort: 5305, pythonDir: 'cockpit/langgraph/subgraphs/python', graphName: 'subgraphs' }, + { id: 'time-travel', runtimeAdapter: 'langgraph', product: 'langgraph', topic: 'time-travel', angularProject: 'cockpit-langgraph-time-travel-angular', port: 4306, pythonPort: 5306, pythonDir: 'cockpit/langgraph/time-travel/python', graphName: 'time-travel' }, + { id: 'deployment-runtime', runtimeAdapter: 'langgraph', product: 'langgraph', topic: 'deployment-runtime', angularProject: 'cockpit-langgraph-deployment-runtime-angular', port: 4307, pythonPort: 5307, pythonDir: 'cockpit/langgraph/deployment-runtime/python', graphName: 'deployment-runtime' }, + { id: 'langgraph-client-tools', runtimeAdapter: 'langgraph', product: 'langgraph', topic: 'client-tools', angularProject: 'cockpit-langgraph-client-tools-angular', port: 4308, pythonPort: 5308, pythonDir: 'cockpit/langgraph/client-tools/python', graphName: 'client-tools' }, + { id: 'da-planning', runtimeAdapter: 'langgraph', product: 'deep-agents', topic: 'planning', angularProject: 'cockpit-deep-agents-planning-angular', port: 4310, pythonPort: 5310, pythonDir: 'cockpit/deep-agents/planning/python', graphName: 'da-planning' }, + { id: 'da-filesystem', runtimeAdapter: 'langgraph', product: 'deep-agents', topic: 'filesystem', angularProject: 'cockpit-deep-agents-filesystem-angular', port: 4311, pythonPort: 5311, pythonDir: 'cockpit/deep-agents/filesystem/python', graphName: 'da-filesystem' }, + { id: 'da-subagents', runtimeAdapter: 'langgraph', product: 'deep-agents', topic: 'subagents', angularProject: 'cockpit-deep-agents-subagents-angular', port: 4312, pythonPort: 5312, pythonDir: 'cockpit/deep-agents/subagents/python', graphName: 'subagents' }, + { id: 'da-memory', runtimeAdapter: 'langgraph', product: 'deep-agents', topic: 'memory', angularProject: 'cockpit-deep-agents-memory-angular', port: 4313, pythonPort: 5313, pythonDir: 'cockpit/deep-agents/memory/python', graphName: 'da-memory' }, + { id: 'da-skills', runtimeAdapter: 'langgraph', product: 'deep-agents', topic: 'skills', angularProject: 'cockpit-deep-agents-skills-angular', port: 4314, pythonPort: 5314, pythonDir: 'cockpit/deep-agents/skills/python', graphName: 'da-skills' }, // Render capabilities - { id: 'r-spec-rendering', product: 'render', topic: 'spec-rendering', angularProject: 'cockpit-render-spec-rendering-angular', port: 4401, pythonPort: 5401, pythonDir: 'cockpit/render/spec-rendering/python', graphName: 'r-spec-rendering' }, - { id: 'r-element-rendering', product: 'render', topic: 'element-rendering', angularProject: 'cockpit-render-element-rendering-angular', port: 4402, pythonPort: 5402, pythonDir: 'cockpit/render/element-rendering/python', graphName: 'r-element-rendering' }, - { id: 'r-state-management', product: 'render', topic: 'state-management', angularProject: 'cockpit-render-state-management-angular', port: 4403, pythonPort: 5403, pythonDir: 'cockpit/render/state-management/python', graphName: 'r-state-management' }, - { id: 'r-registry', product: 'render', topic: 'registry', angularProject: 'cockpit-render-registry-angular', port: 4404, pythonPort: 5404, pythonDir: 'cockpit/render/registry/python', graphName: 'r-registry' }, - { id: 'r-repeat-loops', product: 'render', topic: 'repeat-loops', angularProject: 'cockpit-render-repeat-loops-angular', port: 4405, pythonPort: 5405, pythonDir: 'cockpit/render/repeat-loops/python', graphName: 'r-repeat-loops' }, - { id: 'r-computed-functions', product: 'render', topic: 'computed-functions', angularProject: 'cockpit-render-computed-functions-angular', port: 4406, pythonPort: 5406, pythonDir: 'cockpit/render/computed-functions/python', graphName: 'r-computed-functions' }, + { id: 'r-spec-rendering', runtimeAdapter: 'none', product: 'render', topic: 'spec-rendering', angularProject: 'cockpit-render-spec-rendering-angular', port: 4401, pythonPort: 5401, pythonDir: 'cockpit/render/spec-rendering/python', graphName: 'r-spec-rendering' }, + { id: 'r-element-rendering', runtimeAdapter: 'none', product: 'render', topic: 'element-rendering', angularProject: 'cockpit-render-element-rendering-angular', port: 4402, pythonPort: 5402, pythonDir: 'cockpit/render/element-rendering/python', graphName: 'r-element-rendering' }, + { id: 'r-state-management', runtimeAdapter: 'none', product: 'render', topic: 'state-management', angularProject: 'cockpit-render-state-management-angular', port: 4403, pythonPort: 5403, pythonDir: 'cockpit/render/state-management/python', graphName: 'r-state-management' }, + { id: 'r-registry', runtimeAdapter: 'none', product: 'render', topic: 'registry', angularProject: 'cockpit-render-registry-angular', port: 4404, pythonPort: 5404, pythonDir: 'cockpit/render/registry/python', graphName: 'r-registry' }, + { id: 'r-repeat-loops', runtimeAdapter: 'none', product: 'render', topic: 'repeat-loops', angularProject: 'cockpit-render-repeat-loops-angular', port: 4405, pythonPort: 5405, pythonDir: 'cockpit/render/repeat-loops/python', graphName: 'r-repeat-loops' }, + { id: 'r-computed-functions', runtimeAdapter: 'none', product: 'render', topic: 'computed-functions', angularProject: 'cockpit-render-computed-functions-angular', port: 4406, pythonPort: 5406, pythonDir: 'cockpit/render/computed-functions/python', graphName: 'r-computed-functions' }, // Chat capabilities - { id: 'c-messages', product: 'chat', topic: 'messages', angularProject: 'cockpit-chat-messages-angular', port: 4501, pythonPort: 5501, pythonDir: 'cockpit/chat/messages/python', graphName: 'c-messages' }, - { id: 'c-input', product: 'chat', topic: 'input', angularProject: 'cockpit-chat-input-angular', port: 4502, pythonPort: 5502, pythonDir: 'cockpit/chat/input/python', graphName: 'c-input' }, - { id: 'c-interrupts', product: 'chat', topic: 'interrupts', angularProject: 'cockpit-chat-interrupts-angular', port: 4503, pythonPort: 5503, pythonDir: 'cockpit/chat/interrupts/python', graphName: 'c-interrupts' }, - { id: 'c-tool-calls', product: 'chat', topic: 'tool-calls', angularProject: 'cockpit-chat-tool-calls-angular', port: 4504, pythonPort: 5504, pythonDir: 'cockpit/chat/tool-calls/python', graphName: 'c-tool-calls' }, - { id: 'c-subagents', product: 'chat', topic: 'subagents', angularProject: 'cockpit-chat-subagents-angular', port: 4505, pythonPort: 5505, pythonDir: 'cockpit/chat/subagents/python', graphName: 'c-subagents' }, - { id: 'c-threads', product: 'chat', topic: 'threads', angularProject: 'cockpit-chat-threads-angular', port: 4506, pythonPort: 5506, pythonDir: 'cockpit/chat/threads/python', graphName: 'c-threads' }, - { id: 'c-timeline', product: 'chat', topic: 'timeline', angularProject: 'cockpit-chat-timeline-angular', port: 4507, pythonPort: 5507, pythonDir: 'cockpit/chat/timeline/python', graphName: 'c-timeline' }, - { id: 'c-generative-ui', product: 'chat', topic: 'generative-ui', angularProject: 'cockpit-chat-generative-ui-angular', port: 4508, pythonPort: 5508, pythonDir: 'cockpit/chat/generative-ui/python', graphName: 'c-generative-ui' }, - { id: 'c-debug', product: 'chat', topic: 'debug', angularProject: 'cockpit-chat-debug-angular', port: 4509, pythonPort: 5509, pythonDir: 'cockpit/chat/debug/python', graphName: 'c-debug' }, - { id: 'c-theming', product: 'chat', topic: 'theming', angularProject: 'cockpit-chat-theming-angular', port: 4510, pythonPort: 5510, pythonDir: 'cockpit/chat/theming/python', graphName: 'c-theming' }, - { id: 'c-a2ui', product: 'chat', topic: 'a2ui', angularProject: 'cockpit-chat-a2ui-angular', port: 4511, pythonPort: 5511, pythonDir: 'cockpit/chat/a2ui/python', graphName: 'c-a2ui' }, + { id: 'c-messages', runtimeAdapter: 'langgraph', product: 'chat', topic: 'messages', angularProject: 'cockpit-chat-messages-angular', port: 4501, pythonPort: 5501, pythonDir: 'cockpit/chat/messages/python', graphName: 'c-messages' }, + { id: 'c-input', runtimeAdapter: 'langgraph', product: 'chat', topic: 'input', angularProject: 'cockpit-chat-input-angular', port: 4502, pythonPort: 5502, pythonDir: 'cockpit/chat/input/python', graphName: 'c-input' }, + { id: 'c-interrupts', runtimeAdapter: 'langgraph', product: 'chat', topic: 'interrupts', angularProject: 'cockpit-chat-interrupts-angular', port: 4503, pythonPort: 5503, pythonDir: 'cockpit/chat/interrupts/python', graphName: 'c-interrupts' }, + { id: 'c-tool-calls', runtimeAdapter: 'langgraph', product: 'chat', topic: 'tool-calls', angularProject: 'cockpit-chat-tool-calls-angular', port: 4504, pythonPort: 5504, pythonDir: 'cockpit/chat/tool-calls/python', graphName: 'c-tool-calls' }, + { id: 'c-subagents', runtimeAdapter: 'langgraph', product: 'chat', topic: 'subagents', angularProject: 'cockpit-chat-subagents-angular', port: 4505, pythonPort: 5505, pythonDir: 'cockpit/chat/subagents/python', graphName: 'c-subagents' }, + { id: 'c-threads', runtimeAdapter: 'langgraph', product: 'chat', topic: 'threads', angularProject: 'cockpit-chat-threads-angular', port: 4506, pythonPort: 5506, pythonDir: 'cockpit/chat/threads/python', graphName: 'c-threads' }, + { id: 'c-timeline', runtimeAdapter: 'langgraph', product: 'chat', topic: 'timeline', angularProject: 'cockpit-chat-timeline-angular', port: 4507, pythonPort: 5507, pythonDir: 'cockpit/chat/timeline/python', graphName: 'c-timeline' }, + { id: 'c-generative-ui', runtimeAdapter: 'langgraph', product: 'chat', topic: 'generative-ui', angularProject: 'cockpit-chat-generative-ui-angular', port: 4508, pythonPort: 5508, pythonDir: 'cockpit/chat/generative-ui/python', graphName: 'c-generative-ui' }, + { id: 'c-debug', runtimeAdapter: 'langgraph', product: 'chat', topic: 'debug', angularProject: 'cockpit-chat-debug-angular', port: 4509, pythonPort: 5509, pythonDir: 'cockpit/chat/debug/python', graphName: 'c-debug' }, + { id: 'c-theming', runtimeAdapter: 'langgraph', product: 'chat', topic: 'theming', angularProject: 'cockpit-chat-theming-angular', port: 4510, pythonPort: 5510, pythonDir: 'cockpit/chat/theming/python', graphName: 'c-theming' }, + { id: 'c-a2ui', runtimeAdapter: 'langgraph', product: 'chat', topic: 'a2ui', angularProject: 'cockpit-chat-a2ui-angular', port: 4511, pythonPort: 5511, pythonDir: 'cockpit/chat/a2ui/python', graphName: 'c-a2ui' }, // AG-UI capabilities (uvicorn ag-ui-langgraph backend; not deployed to LangSmith) - { id: 'ag-ui-interrupts', product: 'ag-ui', topic: 'interrupts', angularProject: 'cockpit-ag-ui-interrupts-angular', port: 4320, pythonPort: 5320, pythonDir: 'cockpit/ag-ui/interrupts/python' }, - { id: 'ag-ui-streaming', product: 'ag-ui', topic: 'streaming', angularProject: 'cockpit-ag-ui-streaming-angular', port: 4321, pythonPort: 5321, pythonDir: 'cockpit/ag-ui/streaming/python' }, - { id: 'ag-ui-tool-views', product: 'ag-ui', topic: 'tool-views', angularProject: 'cockpit-ag-ui-tool-views-angular', port: 4322, pythonPort: 5322, pythonDir: 'cockpit/ag-ui/tool-views/python' }, - { id: 'ag-ui-json-render', product: 'ag-ui', topic: 'json-render', angularProject: 'cockpit-ag-ui-json-render-angular', port: 4323, pythonPort: 5323, pythonDir: 'cockpit/ag-ui/json-render/python' }, - { id: 'ag-ui-client-tools', product: 'ag-ui', topic: 'client-tools', angularProject: 'cockpit-ag-ui-client-tools-angular', port: 4325, pythonPort: 5325, pythonDir: 'cockpit/ag-ui/client-tools/python' }, - { id: 'ag-ui-a2ui', product: 'ag-ui', topic: 'a2ui', angularProject: 'cockpit-ag-ui-a2ui-angular', port: 4324, pythonPort: 5324, pythonDir: 'cockpit/ag-ui/a2ui/python' }, - { id: 'ag-ui-subagents', product: 'ag-ui', topic: 'subagents', angularProject: 'cockpit-ag-ui-subagents-angular', port: 4326, pythonPort: 5326, pythonDir: 'cockpit/ag-ui/subagents/python' }, + { id: 'ag-ui-interrupts', runtimeAdapter: 'ag-ui', product: 'ag-ui', topic: 'interrupts', angularProject: 'cockpit-ag-ui-interrupts-angular', port: 4320, pythonPort: 5320, pythonDir: 'cockpit/ag-ui/interrupts/python' }, + { id: 'ag-ui-streaming', runtimeAdapter: 'ag-ui', product: 'ag-ui', topic: 'streaming', angularProject: 'cockpit-ag-ui-streaming-angular', port: 4321, pythonPort: 5321, pythonDir: 'cockpit/ag-ui/streaming/python' }, + { id: 'ag-ui-tool-views', runtimeAdapter: 'ag-ui', product: 'ag-ui', topic: 'tool-views', angularProject: 'cockpit-ag-ui-tool-views-angular', port: 4322, pythonPort: 5322, pythonDir: 'cockpit/ag-ui/tool-views/python' }, + { id: 'ag-ui-json-render', runtimeAdapter: 'ag-ui', product: 'ag-ui', topic: 'json-render', angularProject: 'cockpit-ag-ui-json-render-angular', port: 4323, pythonPort: 5323, pythonDir: 'cockpit/ag-ui/json-render/python' }, + { id: 'ag-ui-client-tools', runtimeAdapter: 'ag-ui', product: 'ag-ui', topic: 'client-tools', angularProject: 'cockpit-ag-ui-client-tools-angular', port: 4325, pythonPort: 5325, pythonDir: 'cockpit/ag-ui/client-tools/python' }, + { id: 'ag-ui-a2ui', runtimeAdapter: 'ag-ui', product: 'ag-ui', topic: 'a2ui', angularProject: 'cockpit-ag-ui-a2ui-angular', port: 4324, pythonPort: 5324, pythonDir: 'cockpit/ag-ui/a2ui/python' }, + { id: 'ag-ui-subagents', runtimeAdapter: 'ag-ui', product: 'ag-ui', topic: 'subagents', angularProject: 'cockpit-ag-ui-subagents-angular', port: 4326, pythonPort: 5326, pythonDir: 'cockpit/ag-ui/subagents/python' }, // Runtime-portability examples (one capability, many runtimes; AG-UI-served // like the AG-UI caps, but the backend is genuinely non-LangGraph) - { id: 'rt-maf', product: 'runtimes', topic: 'microsoft-agent-framework', angularProject: 'cockpit-runtimes-microsoft-agent-framework-angular', port: 4330, pythonPort: 5330, pythonDir: 'cockpit/runtimes/microsoft-agent-framework/python', framework: 'microsoft-agent-framework' }, - { id: 'rt-strands', product: 'runtimes', topic: 'aws-strands', angularProject: 'cockpit-runtimes-aws-strands-angular', port: 4331, pythonPort: 5331, pythonDir: 'cockpit/runtimes/aws-strands/python', framework: 'aws-strands' }, + { id: 'rt-maf', runtimeAdapter: 'ag-ui', product: 'runtimes', topic: 'microsoft-agent-framework', angularProject: 'cockpit-runtimes-microsoft-agent-framework-angular', port: 4330, pythonPort: 5330, pythonDir: 'cockpit/runtimes/microsoft-agent-framework/python', framework: 'microsoft-agent-framework' }, + { id: 'rt-strands', runtimeAdapter: 'ag-ui', product: 'runtimes', topic: 'aws-strands', angularProject: 'cockpit-runtimes-aws-strands-angular', port: 4331, pythonPort: 5331, pythonDir: 'cockpit/runtimes/aws-strands/python', framework: 'aws-strands' }, // No pythonDir: the Mastra topic's backend is the hand-written Node // service deployments/ag-ui-mastra (start it locally on pythonPort — here // meaning "backend port" — for dev/e2e; see that service's README). - { id: 'rt-mastra', product: 'runtimes', topic: 'mastra', angularProject: 'cockpit-runtimes-mastra-angular', port: 4332, pythonPort: 5332, framework: 'mastra' }, + { id: 'rt-mastra', runtimeAdapter: 'ag-ui', product: 'runtimes', topic: 'mastra', angularProject: 'cockpit-runtimes-mastra-angular', port: 4332, pythonPort: 5332, framework: 'mastra' }, ] as const; export function findCapability(id: string): Capability | undefined { diff --git a/apps/cockpit/scripts/deploy-smoke.spec.ts b/apps/cockpit/scripts/deploy-smoke.spec.ts index 659b5767d..a22b5184d 100644 --- a/apps/cockpit/scripts/deploy-smoke.spec.ts +++ b/apps/cockpit/scripts/deploy-smoke.spec.ts @@ -1,125 +1,344 @@ +import { cockpitManifest } from '@threadplane/cockpit-registry'; +import { createServer } from 'node:http'; import { describe, expect, it, vi } from 'vitest'; import { - getRegistryWebsiteDestinations, - getRedirectDisabledProbePath, + RAW_MALFORMED_REQUEST_TARGETS, + buildRedirectSmokeCases, parseDeploySmokeArgs, + requestExactTarget, runDeploySmoke, + type RedirectSmokeRequest, + type RedirectSmokeResponse, } from './deploy-smoke'; -describe('deploy smoke helper', () => { - it('parses the deploy smoke command line', () => { +const previewUrl = 'https://immutable-preview.vercel.app'; + +const responseFor = ( + request: RedirectSmokeRequest, + cases = buildRedirectSmokeCases('preview') +): RedirectSmokeResponse => { + const smokeCase = cases.find( + (candidate) => + candidate.path === request.path && + JSON.stringify(candidate.headers ?? {}) === + JSON.stringify(request.headers ?? {}) + ); + if (!smokeCase) throw new Error(`Unexpected request ${request.path}`); + return { + status: smokeCase.expectedStatus, + headers: smokeCase.expectedLocation + ? { location: smokeCase.expectedLocation } + : {}, + }; +}; + +describe('redirect deploy smoke contract', () => { + it('parses explicit preview and production modes', () => { expect( parseDeploySmokeArgs([ '--url', - 'https://cockpit.threadplane.ai', + previewUrl, + '--mode', + 'preview', '--dry-run', '--retries', '5', '--retry-delay-ms', '1000', - '--website-url', - 'https://threadplane.ai', ]) ).toEqual({ - url: 'https://cockpit.threadplane.ai', - websiteUrl: 'https://threadplane.ai', - expectedTitle: 'Cockpit', + url: previewUrl, + mode: 'preview', dryRun: true, retries: 5, retryDelayMs: 1000, }); + + expect( + parseDeploySmokeArgs([ + '--url', + 'https://cockpit.threadplane.ai', + '--mode', + 'production', + ]).mode + ).toBe('production'); + expect(() => parseDeploySmokeArgs(['--mode', 'other'])).toThrow( + /--mode must be preview or production/ + ); }); - it('derives unique canonical Website destinations from the registry', () => { - const destinations = getRegistryWebsiteDestinations(); + it('enumerates every registry path and mode in exhaustive preview mode', () => { + const cases = buildRedirectSmokeCases('preview'); - expect(destinations).toContain('/docs/langgraph/guides/streaming'); - expect(destinations).toContain('/workspace/langgraph/durable-execution'); - expect(destinations).toContain( - '/docs/deep-agents/capabilities/planning' + for (const entry of cockpitManifest) { + expect( + cases.some( + (smokeCase) => + smokeCase.path === entry.legacyPath && + smokeCase.expectedStatus === 308 + ), + `${entry.id} missing default redirect probe` + ).toBe(true); + for (const mode of entry.availableModes) { + expect( + cases.some( + (smokeCase) => + smokeCase.path === + `${entry.legacyPath}?mode=${mode.toLowerCase()}` && + smokeCase.expectedStatus === 308 + ), + `${entry.id} missing ${mode} redirect probe` + ).toBe(true); + } + expect( + cases.some( + (smokeCase) => smokeCase.path === `${entry.legacyPath}?mode=invalid` + ), + `${entry.id} missing invalid-mode probe` + ).toBe(true); + expect( + cases.some( + (smokeCase) => + smokeCase.path === `${entry.legacyPath}?mode=docs&mode=run` + ), + `${entry.id} missing duplicate-mode probe` + ).toBe(true); + } + + const rootLocation = + 'https://threadplane.ai/docs/langgraph/guides/streaming?mode=run'; + for (const path of [ + '/', + '/?mode=docs', + '/?mode=run', + '/?mode=code', + '/?mode=api', + '/?mode=invalid', + '/?mode=docs&mode=run', + '/?return_to=https%3A%2F%2Fattacker.test&utm_source=legacy', + ]) { + expect(cases).toContainEqual( + expect.objectContaining({ + path, + expectedStatus: 308, + expectedLocation: rootLocation, + }) + ); + } + expect( + cases.some((smokeCase) => smokeCase.name.includes('unavailable mode')) + ).toBe(true); + expect( + cases.some( + (smokeCase) => + smokeCase.name.includes('workspace Docs serialization') && + smokeCase.expectedLocation?.includes('?mode=docs') + ) + ).toBe(true); + expect( + cases.some( + (smokeCase) => + smokeCase.name.includes('unrelated query') && + !smokeCase.expectedLocation?.includes('return_to') + ) + ).toBe(true); + expect( + cases.every( + (smokeCase) => + !Object.keys(smokeCase.headers ?? {}).some( + (header) => header.toLowerCase() === 'host' + ) + ) + ).toBe(true); + + expect(cases).toContainEqual( + expect.objectContaining({ + name: 'hostile forwarding headers ignored', + path: '/langgraph/core-capabilities/streaming/overview/python', + headers: { + forwarded: 'host=attacker.test;proto=http', + 'x-forwarded-host': 'attacker.test', + 'x-forwarded-proto': 'http', + referer: 'https://attacker.test/redirect', + }, + expectedStatus: 308, + expectedLocation: + 'https://threadplane.ai/docs/langgraph/guides/streaming?mode=run', + }) ); - expect(destinations).not.toContain('/workspace/deep-agents/overview'); - expect(destinations).toEqual([...destinations].sort()); - expect(new Set(destinations).size).toBe(destinations.length); }); - it('uses a registry-owned legacy route to prove redirects remain disabled', () => { - expect(getRedirectDisabledProbePath()).toBe( - '/langgraph/core-capabilities/streaming/overview/python' + it('sends every malformed request target as an exact 404 probe', () => { + const cases = buildRedirectSmokeCases('preview'); + + expect(RAW_MALFORMED_REQUEST_TARGETS).toEqual( + expect.arrayContaining([ + expect.stringContaining('//'), + expect.stringContaining('\\'), + expect.stringContaining('/./'), + expect.stringContaining('/../'), + expect.stringContaining('%2e'), + expect.stringContaining('%2F'), + ]) ); + for (const path of RAW_MALFORMED_REQUEST_TARGETS) { + expect(cases).toContainEqual( + expect.objectContaining({ + path, + expectedStatus: 404, + raw: true, + }) + ); + } }); - it('formats dry-run output without performing a network request', async () => { + it('keeps production mode representative and includes raw canaries', () => { + const cases = buildRedirectSmokeCases('production'); + + expect(cases.length).toBeLessThan( + buildRedirectSmokeCases('preview').length + ); + for (const label of [ + 'root', + 'Docs-backed', + 'workspace-only', + 'unknown', + 'favicon', + 'raw malformed', + ]) { + expect( + cases.some((smokeCase) => smokeCase.name.includes(label)), + `missing ${label}` + ).toBe(true); + } + }); + + it('uses the injected low-level transport without normalizing request targets', async () => { + const cases = buildRedirectSmokeCases('preview'); + const requestImpl = vi.fn(async (request: RedirectSmokeRequest) => + responseFor(request, cases) + ); + await expect( runDeploySmoke({ - url: 'https://cockpit.threadplane.ai', - expectedTitle: 'Cockpit', - dryRun: true, + url: previewUrl, + mode: 'preview', + requestImpl, }) - ).resolves.toBe('dry-run:https://cockpit.threadplane.ai:Cockpit'); + ).resolves.toBe(`pass:preview:${previewUrl}:${cases.length}`); + + for (const path of RAW_MALFORMED_REQUEST_TARGETS) { + expect(requestImpl).toHaveBeenCalledWith( + expect.objectContaining({ origin: previewUrl, path }) + ); + } }); - it('retries until the deployment responds with the expected title', async () => { - const fetchImpl = vi - .fn() - .mockResolvedValueOnce(new Response('missing', { status: 404, statusText: 'Not Found' })) - .mockResolvedValueOnce( - new Response('CockpitCockpit', { - status: 200, - statusText: 'OK', - }) + it('writes malformed paths unchanged onto the Node HTTP request line', async () => { + const received: string[] = []; + const server = createServer((request, response) => { + received.push(request.url ?? ''); + response.statusCode = 404; + response.end(); + }); + await new Promise((resolvePromise) => + server.listen(0, '127.0.0.1', resolvePromise) + ); + + try { + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Expected a TCP test server address'); + } + const origin = `http://127.0.0.1:${address.port}`; + for (const path of RAW_MALFORMED_REQUEST_TARGETS) { + await requestExactTarget({ origin, path }); + } + expect(received).toEqual(RAW_MALFORMED_REQUEST_TARGETS); + } finally { + await new Promise((resolvePromise, reject) => + server.close((error) => (error ? reject(error) : resolvePromise())) ); + } + }); + + it('reports a deterministic contract mismatch immediately without retrying', async () => { + const requestImpl = vi.fn(async () => ({ status: 200, headers: {} })); + const sleep = vi.fn(); + + await expect( + runDeploySmoke({ + url: previewUrl, + mode: 'preview', + retries: 4, + requestImpl, + sleep, + }) + ).rejects.toThrow(/preview.*root.*expected 308.*received 200/i); + expect(requestImpl).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + }); + + it('retries transport failures and identifies the failing raw case', async () => { + const cases = buildRedirectSmokeCases('production'); + const rawCase = cases.find((smokeCase) => smokeCase.raw); + if (!rawCase) throw new Error('Expected production raw canary'); + + const requestImpl = vi.fn(async (request: RedirectSmokeRequest) => { + if (request.path === rawCase.path) { + throw new Error('socket reset'); + } + return responseFor(request, cases); + }); const sleep = vi.fn().mockResolvedValue(undefined); await expect( runDeploySmoke({ url: 'https://cockpit.threadplane.ai', + mode: 'production', retries: 1, retryDelayMs: 1, - fetchImpl, + requestImpl, sleep, }) - ).resolves.toBe('pass:https://cockpit.threadplane.ai:Cockpit'); - - expect(fetchImpl).toHaveBeenCalledTimes(2); + ).rejects.toThrow( + new RegExp(`production.*${rawCase.name}.*socket reset`, 'i') + ); + expect( + requestImpl.mock.calls.filter( + ([request]) => request.path === rawCase.path + ) + ).toHaveLength(2); expect(sleep).toHaveBeenCalledTimes(1); }); - it('verifies every canonical Website destination and the default-off redirect gate', async () => { - const cockpitUrl = 'https://cockpit.threadplane.ai'; - const websiteUrl = 'https://threadplane.ai'; - const destinations = getRegistryWebsiteDestinations(); - const redirectProbe = getRedirectDisabledProbePath(); - const fetchImpl = vi.fn( - async (input: string | URL | Request, init?: RequestInit) => { - const requestedUrl = String(input); - if (requestedUrl === cockpitUrl) { - return new Response('Cockpit', { status: 200 }); - } - if (requestedUrl === `${cockpitUrl}${redirectProbe}`) { - expect(init?.redirect).toBe('manual'); - return new Response('Cockpit', { status: 200 }); - } - return new Response('Threadplane', { status: 200 }); - } - ) as unknown as typeof fetch; + it('identifies the Vercel Raw Path prerequisite when a raw canary is normalized', async () => { + const cases = buildRedirectSmokeCases('production'); + const requestImpl = vi.fn(async (request: RedirectSmokeRequest) => { + const smokeCase = cases.find( + (candidate) => candidate.path === request.path + ); + if (!smokeCase) throw new Error(`Unexpected request ${request.path}`); + if (smokeCase.raw) return { status: 308, headers: {} }; + return responseFor(request, cases); + }); await expect( runDeploySmoke({ - url: cockpitUrl, - websiteUrl, - fetchImpl, + url: 'https://cockpit.threadplane.ai', + mode: 'production', + requestImpl, }) + ).rejects.toThrow(/WAF Raw Path prerequisite/); + }); + + it('formats dry-run output with the selected mode and case count', async () => { + await expect( + runDeploySmoke({ url: previewUrl, mode: 'preview', dryRun: true }) ).resolves.toBe( - `pass:${cockpitUrl}:Cockpit:website:${destinations.length}:redirects-off` + `dry-run:preview:${previewUrl}:${ + buildRedirectSmokeCases('preview').length + }` ); - - expect(fetchImpl).toHaveBeenCalledTimes(destinations.length + 2); - for (const destination of destinations) { - expect(fetchImpl).toHaveBeenCalledWith(`${websiteUrl}${destination}`); - } - expect(fetchImpl).toHaveBeenCalledWith(`${cockpitUrl}${redirectProbe}`, { - redirect: 'manual', - }); }); }); diff --git a/apps/cockpit/scripts/deploy-smoke.ts b/apps/cockpit/scripts/deploy-smoke.ts index 1e22a4602..b157baacc 100644 --- a/apps/cockpit/scripts/deploy-smoke.ts +++ b/apps/cockpit/scripts/deploy-smoke.ts @@ -1,55 +1,309 @@ +import * as http from 'node:http'; +import * as https from 'node:https'; import { resolve } from 'node:path'; import { cockpitManifest, + getCanonicalWebsiteWorkspaceHref, getWorkspaceDestinationPath, + resolveLegacyPath, + resolveLegacyRequestMode, + type CockpitManifestEntry, + type WorkspaceMode, + type WorkspaceResolution, } from '@threadplane/cockpit-registry'; +export type DeploySmokeMode = 'preview' | 'production'; + +export interface RedirectSmokeRequest { + readonly origin: string; + readonly path: string; + readonly headers?: Readonly>; +} + +export interface RedirectSmokeResponse { + readonly status: number; + readonly headers: Readonly>; +} + +export type RedirectSmokeRequestImpl = ( + request: RedirectSmokeRequest +) => Promise; + +export interface RedirectSmokeCase { + readonly name: string; + readonly path: string; + readonly expectedStatus: 308 | 404; + readonly expectedLocation?: string; + readonly headers?: Readonly>; + readonly raw?: boolean; +} + export interface DeploySmokeOptions { - url: string; - websiteUrl?: string; - expectedTitle?: string; - dryRun?: boolean; - retries?: number; - retryDelayMs?: number; - fetchImpl?: typeof fetch; - sleep?: (delayMs: number) => Promise; + readonly url: string; + readonly mode?: DeploySmokeMode; + readonly dryRun?: boolean; + readonly retries?: number; + readonly retryDelayMs?: number; + readonly requestImpl?: RedirectSmokeRequestImpl; + readonly sleep?: (delayMs: number) => Promise; } -export type ParsedDeploySmokeArgs = DeploySmokeOptions; +export interface ParsedDeploySmokeArgs { + url: string; + mode: DeploySmokeMode; + dryRun: boolean; + retries: number; + retryDelayMs: number; +} -const DEFAULT_EXPECTED_TITLE = 'Cockpit'; +const WEBSITE_ORIGIN = 'https://threadplane.ai'; const DEFAULT_RETRIES = 0; const DEFAULT_RETRY_DELAY_MS = 2000; +const ALL_MODES: readonly WorkspaceMode[] = ['Docs', 'Run', 'Code', 'API']; +const ROOT_STREAMING_LEGACY_PATH = + '/langgraph/core-capabilities/streaming/overview/python'; const defaultSleep = (delayMs: number): Promise => - new Promise((resolvePromise) => { - setTimeout(resolvePromise, delayMs); - }); + new Promise((resolvePromise) => setTimeout(resolvePromise, delayMs)); + +type MappedWorkspaceResolution = Extract< + WorkspaceResolution, + { kind: 'mapped' } +>; + +const rootResolution = (): MappedWorkspaceResolution => { + const resolution = resolveLegacyPath(ROOT_STREAMING_LEGACY_PATH); + if (!resolution || resolution.kind !== 'mapped') { + throw new Error('Redirect smoke requires the registry streaming route'); + } + return resolution; +}; -export const getRegistryWebsiteDestinations = (): string[] => - [ - ...new Set( - cockpitManifest - .filter((entry) => entry.availableModes.length > 0) - .map(getWorkspaceDestinationPath) +const expectedLocation = ( + resolution: WorkspaceResolution, + rawMode: string | string[] | undefined +): string => { + const mode = resolveLegacyRequestMode(rawMode, resolution); + return new URL( + getCanonicalWebsiteWorkspaceHref(resolution, mode), + `${WEBSITE_ORIGIN}/` + ).toString(); +}; + +const redirectCase = ( + name: string, + path: string, + resolution: WorkspaceResolution, + rawMode?: string | string[], + headers?: Readonly> +): RedirectSmokeCase => ({ + name, + path, + expectedStatus: 308, + expectedLocation: expectedLocation(resolution, rawMode), + ...(headers ? { headers } : {}), +}); + +const notFoundCase = ( + name: string, + path: string, + raw = false +): RedirectSmokeCase => ({ name, path, expectedStatus: 404, raw }); + +export const RAW_MALFORMED_REQUEST_TARGETS = [ + `/${ROOT_STREAMING_LEGACY_PATH}`, + ROOT_STREAMING_LEGACY_PATH.replace( + '/core-capabilities/', + '/./core-capabilities/' + ), + ROOT_STREAMING_LEGACY_PATH.replace( + '/core-capabilities/', + '/../core-capabilities/' + ), + ROOT_STREAMING_LEGACY_PATH.replace( + '/core-capabilities/', + '/%2e/core-capabilities/' + ), + ROOT_STREAMING_LEGACY_PATH.replace( + '/core-capabilities/', + '/%2e%2e/core-capabilities/' + ), + ROOT_STREAMING_LEGACY_PATH.replace('/overview/', '/%2Foverview/'), + ROOT_STREAMING_LEGACY_PATH.replace('/overview/', '/%5Coverview/'), + ROOT_STREAMING_LEGACY_PATH.replace('/overview/', '/\\overview/'), +] as const; + +const entryResolution = ( + entry: CockpitManifestEntry +): MappedWorkspaceResolution => { + const resolution = resolveLegacyPath(entry.legacyPath); + if (!resolution || resolution.kind !== 'mapped') { + throw new Error(`Manifest route is not resolvable: ${entry.id}`); + } + return resolution; +}; + +const buildPreviewCases = (): RedirectSmokeCase[] => { + const cases: RedirectSmokeCase[] = []; + const root = rootResolution(); + for (const [name, path] of [ + ['root default redirect', '/'], + ['root Docs ignored', '/?mode=docs'], + ['root Run redirect', '/?mode=run'], + ['root Code ignored', '/?mode=code'], + ['root API ignored', '/?mode=api'], + ['root invalid mode ignored', '/?mode=invalid'], + ['root duplicate modes ignored', '/?mode=docs&mode=run'], + [ + 'root unrelated query stripped', + '/?return_to=https%3A%2F%2Fattacker.test&utm_source=legacy', + ], + ] as const) { + cases.push(redirectCase(name, path, root, 'run')); + } + + for (const entry of cockpitManifest) { + const resolution = entryResolution(entry); + cases.push( + redirectCase(`${entry.id} missing mode`, entry.legacyPath, resolution) + ); + for (const mode of entry.availableModes) { + cases.push( + redirectCase( + `${entry.id} available mode ${mode}`, + `${entry.legacyPath}?mode=${mode.toLowerCase()}`, + resolution, + mode.toLowerCase() + ) + ); + } + for (const mode of ALL_MODES.filter( + (candidate) => !entry.availableModes.includes(candidate) + )) { + cases.push( + redirectCase( + `${entry.id} unavailable mode ${mode}`, + `${entry.legacyPath}?mode=${mode.toLowerCase()}`, + resolution, + mode.toLowerCase() + ) + ); + } + cases.push( + redirectCase( + `${entry.id} invalid mode`, + `${entry.legacyPath}?mode=invalid`, + resolution, + 'invalid' + ), + redirectCase( + `${entry.id} duplicate modes`, + `${entry.legacyPath}?mode=docs&mode=run`, + resolution, + ['docs', 'run'] + ), + redirectCase( + `${entry.id} unrelated query stripping`, + `${entry.legacyPath}?return_to=https%3A%2F%2Fattacker.test&utm_source=legacy`, + resolution + ) + ); + } + + const workspaceOnly = cockpitManifest.find((entry) => + getWorkspaceDestinationPath(entry).startsWith('/workspace/') + ); + if (!workspaceOnly) + throw new Error('Expected a workspace-only manifest entry'); + cases.push( + redirectCase( + 'workspace Docs serialization', + `${workspaceOnly.legacyPath}?mode=docs`, + entryResolution(workspaceOnly), + 'docs' + ) + ); + + cases.push( + notFoundCase('unknown path 404', '/unknown'), + notFoundCase('partial path 404', '/langgraph/core-capabilities/streaming'), + notFoundCase('extra path 404', `${ROOT_STREAMING_LEGACY_PATH}/extra`), + notFoundCase('trailing slash 404', `${ROOT_STREAMING_LEGACY_PATH}/`), + redirectCase( + 'hostile forwarding headers ignored', + ROOT_STREAMING_LEGACY_PATH, + root, + undefined, + { + forwarded: 'host=attacker.test;proto=http', + 'x-forwarded-host': 'attacker.test', + 'x-forwarded-proto': 'http', + referer: 'https://attacker.test/redirect', + } ), - ].sort(); + { + name: 'favicon permanent redirect', + path: '/favicon.ico', + expectedStatus: 308, + expectedLocation: '/icon.svg', + }, + ...RAW_MALFORMED_REQUEST_TARGETS.map((path, index) => + notFoundCase(`raw malformed ${index + 1}: ${path}`, path, true) + ) + ); + return cases; +}; -export const getRedirectDisabledProbePath = (): string => { - const streaming = cockpitManifest.find( - (entry) => entry.product === 'langgraph' && entry.topic === 'streaming' +const buildProductionCases = (): RedirectSmokeCase[] => { + const root = rootResolution(); + const docsBacked = cockpitManifest.find((entry) => + getWorkspaceDestinationPath(entry).startsWith('/docs/') ); - if (!streaming) { + const workspaceOnly = cockpitManifest.find((entry) => + getWorkspaceDestinationPath(entry).startsWith('/workspace/') + ); + if (!docsBacked || !workspaceOnly) { throw new Error( - 'Deploy smoke requires the registry-owned LangGraph streaming route' + 'Redirect smoke requires Docs-backed and workspace-only routes' ); } - return streaming.legacyPath; + return [ + redirectCase('root production redirect', '/', root, 'run'), + redirectCase( + 'Docs-backed production redirect', + docsBacked.legacyPath, + entryResolution(docsBacked) + ), + redirectCase( + 'workspace-only production redirect', + workspaceOnly.legacyPath, + entryResolution(workspaceOnly) + ), + notFoundCase('unknown production 404', '/unknown'), + { + name: 'favicon production redirect', + path: '/favicon.ico', + expectedStatus: 308, + expectedLocation: '/icon.svg', + }, + ...RAW_MALFORMED_REQUEST_TARGETS.slice(0, 3).map((path, index) => + notFoundCase( + `raw malformed production canary ${index + 1}: ${path}`, + path, + true + ) + ), + ]; }; +export const buildRedirectSmokeCases = ( + mode: DeploySmokeMode +): RedirectSmokeCase[] => + mode === 'preview' ? buildPreviewCases() : buildProductionCases(); + export const parseDeploySmokeArgs = (argv: string[]): ParsedDeploySmokeArgs => { const options: ParsedDeploySmokeArgs = { url: 'http://127.0.0.1:3000', - expectedTitle: DEFAULT_EXPECTED_TITLE, + mode: 'preview', dryRun: false, retries: DEFAULT_RETRIES, retryDelayMs: DEFAULT_RETRY_DELAY_MS, @@ -57,134 +311,159 @@ export const parseDeploySmokeArgs = (argv: string[]): ParsedDeploySmokeArgs => { for (let index = 0; index < argv.length; index += 1) { const current = argv[index]; - - if (current === '--url' && argv[index + 1]) { - options.url = argv[index + 1]; + const next = argv[index + 1]; + if (current === '--url' && next) { + options.url = next; index += 1; - continue; - } - - if (current === '--expected-title' && argv[index + 1]) { - options.expectedTitle = argv[index + 1]; + } else if (current === '--mode' && next) { + if (next !== 'preview' && next !== 'production') { + throw new Error('--mode must be preview or production'); + } + options.mode = next; index += 1; - continue; - } - - if (current === '--website-url' && argv[index + 1]) { - options.websiteUrl = argv[index + 1]; + } else if (current === '--dry-run') { + options.dryRun = true; + } else if (current === '--retries' && next) { + options.retries = Number(next); + index += 1; + } else if (current === '--retry-delay-ms' && next) { + options.retryDelayMs = Number(next); index += 1; - continue; } + } + return options; +}; - if (current === '--dry-run') { - options.dryRun = true; - continue; - } +export const requestExactTarget: RedirectSmokeRequestImpl = ({ + origin, + path, + headers, +}) => + new Promise((resolvePromise, reject) => { + const target = new URL(origin); + const requester = + target.protocol === 'https:' ? https.request : http.request; + const request = requester( + { + protocol: target.protocol, + hostname: target.hostname, + port: target.port || undefined, + method: 'GET', + path, + headers, + }, + (response) => { + response.resume(); + response.on('end', () => { + const normalizedHeaders = Object.fromEntries( + Object.entries(response.headers).map(([key, value]) => [ + key.toLowerCase(), + Array.isArray(value) ? value.join(', ') : value, + ]) + ); + resolvePromise({ + status: response.statusCode ?? 0, + headers: normalizedHeaders, + }); + }); + } + ); + request.on('error', reject); + request.end(); + }); - if (current === '--retries' && argv[index + 1]) { - options.retries = Number(argv[index + 1]); - index += 1; - continue; - } +class RedirectContractError extends Error {} - if (current === '--retry-delay-ms' && argv[index + 1]) { - options.retryDelayMs = Number(argv[index + 1]); - index += 1; +const verifyCase = ( + mode: DeploySmokeMode, + smokeCase: RedirectSmokeCase, + response: RedirectSmokeResponse +): void => { + const rawGateHint = smokeCase.raw + ? ' Raw Path rejection failed; verify the Vercel project WAF Raw Path prerequisite before promotion.' + : ''; + if (response.status !== smokeCase.expectedStatus) { + throw new RedirectContractError( + `[${mode}] ${smokeCase.name}: expected ${smokeCase.expectedStatus}, received ${response.status}.${rawGateHint}` + ); + } + const location = response.headers.location; + if (smokeCase.expectedLocation !== undefined) { + if (location !== smokeCase.expectedLocation) { + throw new RedirectContractError( + `[${mode}] ${smokeCase.name}: expected Location ${ + smokeCase.expectedLocation + }, received ${location ?? ''}.${rawGateHint}` + ); } + } else if (location !== undefined) { + throw new RedirectContractError( + `[${mode}] ${smokeCase.name}: expected no Location, received ${location}.${rawGateHint}` + ); } - - return options; }; export const runDeploySmoke = async ({ url, - websiteUrl, - expectedTitle = DEFAULT_EXPECTED_TITLE, + mode = 'preview', dryRun = false, retries = DEFAULT_RETRIES, retryDelayMs = DEFAULT_RETRY_DELAY_MS, - fetchImpl = fetch, + requestImpl = requestExactTarget, sleep = defaultSleep, }: DeploySmokeOptions): Promise => { - if (dryRun) { - return `dry-run:${url}:${expectedTitle}`; + const target = new URL(url); + if (target.pathname !== '/' || target.search || target.hash) { + throw new Error('Deploy smoke --url must be an absolute origin'); } + const origin = target.origin; + const cases = buildRedirectSmokeCases(mode); + if (dryRun) return `dry-run:${mode}:${origin}:${cases.length}`; - let attemptsRemaining = retries + 1; - let lastError: Error | null = null; - - while (attemptsRemaining > 0) { - try { - const response = await fetchImpl(url); - - if (!response.ok) { - throw new Error(`Deploy smoke failed for ${url}: ${response.status} ${response.statusText}`); - } - - const html = await response.text(); - - if (!html.includes(expectedTitle)) { - throw new Error(`Deploy smoke failed for ${url}: missing title ${expectedTitle}`); - } - - if (websiteUrl) { - const destinations = getRegistryWebsiteDestinations(); - for (const destination of destinations) { - const destinationUrl = new URL(destination, websiteUrl).toString(); - const destinationResponse = await fetchImpl(destinationUrl); - if (!destinationResponse.ok) { - throw new Error( - `Deploy smoke failed for ${destinationUrl}: ${destinationResponse.status} ${destinationResponse.statusText}` - ); - } - } - - const redirectProbeUrl = new URL( - getRedirectDisabledProbePath(), - url - ).toString(); - const redirectProbeResponse = await fetchImpl(redirectProbeUrl, { - redirect: 'manual', + for (const smokeCase of cases) { + let attempt = 0; + while (true) { + try { + const response = await requestImpl({ + origin, + path: smokeCase.path, + ...(smokeCase.headers ? { headers: smokeCase.headers } : {}), }); - if ( - !redirectProbeResponse.ok || - (redirectProbeResponse.status >= 300 && - redirectProbeResponse.status < 400) - ) { + verifyCase(mode, smokeCase, response); + break; + } catch (error: unknown) { + if (error instanceof RedirectContractError) throw error; + if (attempt >= retries) { + const message = + error instanceof Error ? error.message : String(error); throw new Error( - `Deploy smoke failed for ${redirectProbeUrl}: legacy redirects must remain disabled before activation` + `[${mode}] ${smokeCase.name}: transport failed: ${message}` ); } - - return `pass:${url}:${expectedTitle}:website:${destinations.length}:redirects-off`; - } - - return `pass:${url}:${expectedTitle}`; - } catch (error: unknown) { - lastError = error instanceof Error ? error : new Error(String(error)); - attemptsRemaining -= 1; - - if (attemptsRemaining === 0) { - throw lastError; + attempt += 1; + await sleep(retryDelayMs); } - - await sleep(retryDelayMs); } } - - throw lastError ?? new Error(`Deploy smoke failed for ${url}`); + return `pass:${mode}:${origin}:${cases.length}`; }; -if (process.argv[1] === resolve(process.cwd(), 'apps/cockpit/scripts/deploy-smoke.ts')) { - const options = parseDeploySmokeArgs(process.argv.slice(2)); - - runDeploySmoke(options) - .then((result) => { - process.stdout.write(`${result}\n`); - }) - .catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`${message}\n`); - process.exitCode = 1; - }); +if ( + process.argv[1] === + resolve(process.cwd(), 'apps/cockpit/scripts/deploy-smoke.ts') +) { + try { + const options = parseDeploySmokeArgs(process.argv.slice(2)); + runDeploySmoke(options) + .then((result) => process.stdout.write(`${result}\n`)) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`${message}\n`); + process.exitCode = 1; + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`${message}\n`); + process.exitCode = 1; + } } diff --git a/apps/cockpit/scripts/serve-example.spec.ts b/apps/cockpit/scripts/serve-example.spec.ts index b70455879..586046454 100644 --- a/apps/cockpit/scripts/serve-example.spec.ts +++ b/apps/cockpit/scripts/serve-example.spec.ts @@ -28,7 +28,12 @@ describe('backendCommand', () => { it('returns null when the capability has no pythonDir', () => { const noPy: Capability = { - id: 'x', product: 'render', topic: 'x', angularProject: 'cockpit-render-x-angular', port: 4499, + id: 'x', + runtimeAdapter: 'none', + product: 'render', + topic: 'x', + angularProject: 'cockpit-render-x-angular', + port: 4499, }; expect(backendCommand(noPy)).toBeNull(); }); diff --git a/apps/cockpit/scripts/vercel-config.spec.ts b/apps/cockpit/scripts/vercel-config.spec.ts new file mode 100644 index 000000000..43c62544d --- /dev/null +++ b/apps/cockpit/scripts/vercel-config.spec.ts @@ -0,0 +1,40 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { RAW_MALFORMED_REQUEST_TARGETS } from './deploy-smoke'; + +const repoRoot = resolve(import.meta.dirname, '../../..'); +const config = JSON.parse( + readFileSync(resolve(repoRoot, 'vercel.cockpit.json'), 'utf8') +) as { + routes?: Array<{ src?: string; status?: number }>; +}; + +const EXPECTED_RAW_REJECTION_PATTERN = + '^(?:.*//.*|.*(?:\\\\|%5[cC]|%2[fF]).*|.*(?:^|/)(?:\\.{1,2}|%2[eE](?:%2[eE])?)(?:/|$).*)$'; + +describe('Cockpit Vercel malformed raw-path rejection', () => { + it('places one rejection-only rule before framework routing', () => { + expect(config.routes?.[0]).toEqual({ + src: EXPECTED_RAW_REJECTION_PATTERN, + status: 404, + }); + expect(config.routes).toHaveLength(1); + }); + + it('targets every raw malformed preview probe without duplicating redirects', () => { + const pattern = new RegExp(EXPECTED_RAW_REJECTION_PATTERN, 'i'); + for (const path of RAW_MALFORMED_REQUEST_TARGETS) { + expect(pattern.test(path), path).toBe(true); + } + for (const path of [ + '/', + '/favicon.ico', + '/langgraph/core-capabilities/streaming/overview/python', + ]) { + expect(pattern.test(path), path).toBe(false); + } + expect(JSON.stringify(config.routes)).not.toContain('threadplane.ai'); + expect(JSON.stringify(config.routes)).not.toContain('mode='); + }); +}); diff --git a/apps/cockpit/src/app/[...slug]/page.spec.tsx b/apps/cockpit/src/app/[...slug]/page.spec.tsx deleted file mode 100644 index fa9dd7915..000000000 --- a/apps/cockpit/src/app/[...slug]/page.spec.tsx +++ /dev/null @@ -1,133 +0,0 @@ -/** @vitest-environment jsdom */ -import { describe, expect, it, vi } from 'vitest'; - -vi.mock('next/navigation', () => ({ - redirect: vi.fn(() => { - throw new Error('redirect() should not be called for a canonical slug'); - }), -})); - -vi.mock('@threadplane/cockpit-shell', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - getContentBundle: vi.fn().mockResolvedValue({ - codeFiles: {}, - promptFiles: {}, - runtimeUrl: null, - docSections: [], - narrativeDocs: [], - }), - }; -}); - -import CockpitRoutePage, { - getCockpitRouteRedirect, - getLegacyRouteRedirect, -} from './page'; -import { getCockpitPageModel } from '../../lib/cockpit-page'; - -const enabledEnv = { - UNIFIED_WORKSPACE_REDIRECTS_ENABLED: 'true', - NEXT_PUBLIC_WEBSITE_ORIGIN: 'https://threadplane.ai', - NODE_ENV: 'production', -}; - -const renderRoute = (slug: string[]) => - CockpitRoutePage({ - params: Promise.resolve({ slug }), - searchParams: Promise.resolve({}), - }); - -describe('CockpitRoutePage', () => { - it('keys the rendered CockpitShell on the canonical path', async () => { - const slug = [ - 'langgraph', - 'core-capabilities', - 'streaming', - 'overview', - 'python', - ]; - const { canonicalPath } = getCockpitPageModel(slug); - - const element = await renderRoute(slug); - - expect(element.key).toBe(canonicalPath); - }); - - it('gives two different capabilities two different keys', async () => { - const streamingSlug = [ - 'langgraph', - 'core-capabilities', - 'streaming', - 'overview', - 'python', - ]; - const persistenceSlug = [ - 'langgraph', - 'core-capabilities', - 'persistence', - 'overview', - 'python', - ]; - - const streamingElement = await renderRoute(streamingSlug); - const persistenceElement = await renderRoute(persistenceSlug); - - expect(streamingElement.key).not.toBe(persistenceElement.key); - expect(streamingElement.key).toBe( - getCockpitPageModel(streamingSlug).canonicalPath - ); - expect(persistenceElement.key).toBe( - getCockpitPageModel(persistenceSlug).canonicalPath - ); - }); -}); - -describe('canonical Cockpit route redirects', () => { - it('preserves a valid mode query that is available on the canonical entry', () => { - expect( - getCockpitRouteRedirect( - [ - 'langgraph', - 'core-capabilities', - 'streaming', - 'overview', - 'python', - 'extra', - ], - 'code' - ) - ).toBe('/langgraph/core-capabilities/streaming/overview/python?mode=code'); - }); - - it('keeps the external adapter disabled by default', () => { - expect( - getLegacyRouteRedirect( - ['langgraph', 'core-capabilities', 'streaming', 'overview', 'python'], - 'run', - {} - ) - ).toBeNull(); - }); - - it('redirects only exact registry legacy routes when enabled', () => { - expect( - getLegacyRouteRedirect( - ['deep-agents', 'core-capabilities', 'planning', 'overview', 'python'], - 'api', - enabledEnv - ) - ).toBe( - 'https://threadplane.ai/docs/deep-agents/capabilities/planning?mode=api' - ); - expect( - getLegacyRouteRedirect( - ['deep-agents', 'core-capabilities', 'planning'], - 'run', - enabledEnv - ) - ).toBeNull(); - }); -}); diff --git a/apps/cockpit/src/app/[...slug]/page.tsx b/apps/cockpit/src/app/[...slug]/page.tsx deleted file mode 100644 index a253b98d6..000000000 --- a/apps/cockpit/src/app/[...slug]/page.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import React from 'react'; -import { redirect } from 'next/navigation'; -import { CockpitShell } from '../../components/cockpit-shell'; -import { getContentBundle } from '@threadplane/cockpit-shell'; -import { - cockpitManifest, - getCanonicalCockpitRedirect, - getCockpitPageModel, - getLegacyWebsiteRedirect, - normalizeRequestedMode, - type UnifiedWorkspaceRedirectEnvironment, -} from '../../lib/cockpit-page'; - -export async function generateStaticParams() { - return cockpitManifest.map((entry) => ({ - slug: [ - entry.product, - entry.section, - entry.topic, - entry.page, - entry.language, - ], - })); -} - -export function getCockpitRouteRedirect( - slug: string[], - mode: string | string[] | undefined -): string | null { - const model = getCockpitPageModel(slug); - const requestedPath = `/${slug.join('/')}`; - return slug.length > 0 && requestedPath !== model.canonicalPath - ? getCanonicalCockpitRedirect(model, mode) - : null; -} - -export function getLegacyRouteRedirect( - slug: string[], - mode: string | string[] | undefined, - environment: UnifiedWorkspaceRedirectEnvironment = process.env -): string | null { - if (slug.length === 0) return null; - return getLegacyWebsiteRedirect(`/${slug.join('/')}`, mode, environment); -} - -export default async function CockpitRoutePage({ - params, - searchParams, -}: { - params: Promise<{ slug?: string[] }>; - searchParams: Promise<{ mode?: string | string[] }>; -}) { - const { slug = [] } = await params; - const { mode } = await searchParams; - const legacyRedirectDestination = getLegacyRouteRedirect(slug, mode); - if (legacyRedirectDestination) { - redirect(legacyRedirectDestination); - } - const model = getCockpitPageModel(slug); - const { resolution, presentation, navigationTree, canonicalPath } = model; - const redirectDestination = getCockpitRouteRedirect(slug, mode); - if (redirectDestination) { - redirect(redirectDestination); - } - - const contentBundle = await getContentBundle(presentation); - - return ( - - ); -} diff --git a/apps/cockpit/src/app/[[...slug]]/route.spec.ts b/apps/cockpit/src/app/[[...slug]]/route.spec.ts new file mode 100644 index 000000000..064c7b8b6 --- /dev/null +++ b/apps/cockpit/src/app/[[...slug]]/route.spec.ts @@ -0,0 +1,193 @@ +import { + cockpitManifest, + getWorkspaceDestinationPath, + type WorkspaceMode, +} from '@threadplane/cockpit-registry'; +import { NextRequest } from 'next/server'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { GET } from './route'; + +const originalOrigin = process.env.COCKPIT_WEBSITE_ORIGIN; +const originalNodeEnvironment = process.env.NODE_ENV; + +const request = (path: string, headers?: HeadersInit) => + new NextRequest(`https://cockpit.threadplane.ai${path}`, { headers }); + +const expectedLocation = ( + entry: (typeof cockpitManifest)[number], + mode: WorkspaceMode +): string => { + const path = getWorkspaceDestinationPath(entry); + const query = + mode === 'Docs' && path.startsWith('/docs') + ? '' + : `?mode=${mode.toLowerCase()}`; + return `https://threadplane.ai${path}${query}`; +}; + +describe('legacy Cockpit redirect route', () => { + beforeEach(() => { + process.env.COCKPIT_WEBSITE_ORIGIN = 'https://threadplane.ai'; + process.env.NODE_ENV = 'production'; + }); + + afterEach(() => { + if (originalOrigin === undefined) { + delete process.env.COCKPIT_WEBSITE_ORIGIN; + } else { + process.env.COCKPIT_WEBSITE_ORIGIN = originalOrigin; + } + if (originalNodeEnvironment === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = originalNodeEnvironment; + } + }); + + it.each([ + '/', + '/?mode=docs', + '/?mode=run', + '/?mode=code', + '/?mode=api', + '/?mode=invalid', + '/?mode=docs&mode=run', + '/?return_to=https%3A%2F%2Fattacker.test&utm_source=old-bookmark', + ])('always redirects root to representative Streaming Run for %s', (path) => { + const response = GET(request(path)); + + expect(response.status).toBe(308); + expect(response.headers.get('location')).toBe( + 'https://threadplane.ai/docs/langgraph/guides/streaming?mode=run' + ); + }); + + it('redirects every exact manifest path permanently', () => { + for (const entry of cockpitManifest) { + const response = GET(request(`${entry.legacyPath}?ignored=1`)); + const defaultMode = entry.availableModes.includes('Run') ? 'Run' : 'Docs'; + + expect(response.status, entry.id).toBe(308); + expect(response.headers.get('location'), entry.id).toBe( + expectedLocation(entry, defaultMode) + ); + expect(response.headers.get('location'), entry.id).not.toContain( + 'ignored' + ); + } + }); + + it('honors every available mode for every manifest path', () => { + for (const entry of cockpitManifest) { + for (const mode of entry.availableModes) { + const response = GET( + request(`${entry.legacyPath}?mode=${mode.toUpperCase()}`) + ); + + expect(response.status, `${entry.id} ${mode}`).toBe(308); + expect(response.headers.get('location'), `${entry.id} ${mode}`).toBe( + expectedLocation(entry, mode) + ); + } + } + }); + + it('reads only mode and strips every unrelated query parameter', () => { + const streaming = cockpitManifest.find( + (entry) => + entry.id === 'langgraph:core-capabilities:streaming:overview:python' + ); + if (!streaming) throw new Error('Expected streaming fixture'); + + const response = GET( + request( + `${streaming.legacyPath}?return_to=https://attacker.test&mode=code&utm_source=x` + ) + ); + + expect(response.status).toBe(308); + expect(response.headers.get('location')).toBe( + 'https://threadplane.ai/docs/langgraph/guides/streaming?mode=code' + ); + }); + + it('treats duplicate mode values as invalid and uses the old default', () => { + const streaming = cockpitManifest.find( + (entry) => + entry.id === 'langgraph:core-capabilities:streaming:overview:python' + ); + if (!streaming) throw new Error('Expected streaming fixture'); + + const response = GET( + request(`${streaming.legacyPath}?mode=docs&mode=code`) + ); + + expect(response.status).toBe(308); + expect(response.headers.get('location')).toBe( + 'https://threadplane.ai/docs/langgraph/guides/streaming?mode=run' + ); + }); + + it('falls back to Docs when a mode is unavailable on a docs-only entry', () => { + const docsOnly = cockpitManifest.find( + (entry) => !entry.availableModes.includes('Run') + ); + if (!docsOnly) throw new Error('Expected docs-only fixture'); + + const response = GET(request(`${docsOnly.legacyPath}?mode=run`)); + + expect(response.status).toBe(308); + expect(response.headers.get('location')).toBe( + expectedLocation(docsOnly, 'Docs') + ); + }); + + it.each([ + '/unknown', + '/langgraph/core-capabilities/streaming', + '/langgraph/core-capabilities/streaming/overview/python/extra', + '/langgraph/core-capabilities/streaming/overview/python/', + '//langgraph/core-capabilities/streaming/overview/python', + '/langgraph/core-capabilities/streaming/%2Foverview/python', + ])('returns a real 404 for %s', (pathname) => { + const response = GET(request(pathname)); + + expect(response.status).toBe(404); + expect(response.headers.get('location')).toBeNull(); + }); + + it('ignores hostile request authorities and forwarding metadata', () => { + const streaming = cockpitManifest.find( + (entry) => + entry.id === 'langgraph:core-capabilities:streaming:overview:python' + ); + if (!streaming) throw new Error('Expected streaming fixture'); + + const response = GET( + request(streaming.legacyPath, { + host: 'attacker.example', + forwarded: 'host=attacker.example;proto=http', + 'x-forwarded-host': 'attacker.example', + 'x-forwarded-proto': 'http', + referer: 'https://attacker.example/redirect', + }) + ); + + expect(response.status).toBe(308); + expect(response.headers.get('location')).toBe( + 'https://threadplane.ai/docs/langgraph/guides/streaming?mode=run' + ); + }); + + it.each([ + 'https://attacker@threadplane.ai', + 'https://threadplane.ai?', + 'https://threadplane.ai#', + 'https://threadplane.ai/%2e', + 'https://threadplane.ai/a/..', + ])('fails closed when the server-only destination origin is %s', (origin) => { + process.env.COCKPIT_WEBSITE_ORIGIN = origin; + + expect(() => GET(request('/'))).toThrow(/COCKPIT_WEBSITE_ORIGIN/); + }); +}); diff --git a/apps/cockpit/src/app/[[...slug]]/route.ts b/apps/cockpit/src/app/[[...slug]]/route.ts new file mode 100644 index 000000000..605f7372b --- /dev/null +++ b/apps/cockpit/src/app/[[...slug]]/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { + getLegacyWebsiteRedirect, + getRootWebsiteRedirect, +} from '../../lib/cockpit-page'; + +export function GET(request: NextRequest): NextResponse { + const pathname = request.nextUrl.pathname; + const destination = + pathname === '/' + ? getRootWebsiteRedirect() + : getLegacyWebsiteRedirect( + pathname, + request.nextUrl.searchParams.getAll('mode') + ); + + return destination + ? NextResponse.redirect(destination, 308) + : new NextResponse(null, { status: 404 }); +} diff --git a/apps/cockpit/src/app/api/theme/route.ts b/apps/cockpit/src/app/api/theme/route.ts deleted file mode 100644 index 881b6676f..000000000 --- a/apps/cockpit/src/app/api/theme/route.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { NextResponse } from 'next/server'; - -const ONE_YEAR_S = 60 * 60 * 24 * 365; - -export async function POST(req: Request) { - let body: unknown; - try { - body = await req.json(); - } catch { - return new NextResponse('invalid json', { status: 400 }); - } - const theme = - body && typeof body === 'object' && 'theme' in body ? (body as { theme: unknown }).theme : null; - if (theme !== 'light' && theme !== 'dark') { - return new NextResponse('bad theme', { status: 400 }); - } - const res = new NextResponse(null, { status: 204 }); - res.cookies.set('theme', theme, { - path: '/', - maxAge: ONE_YEAR_S, - sameSite: 'lax', - httpOnly: false, - }); - return res; -} diff --git a/apps/cockpit/src/app/cockpit.css b/apps/cockpit/src/app/cockpit.css deleted file mode 100644 index fa43b0677..000000000 --- a/apps/cockpit/src/app/cockpit.css +++ /dev/null @@ -1,2 +0,0 @@ -@import "tailwindcss"; -@import "../../../../libs/workspace-react/src/styles/workspace.css"; diff --git a/apps/cockpit/src/app/favicon.ico/route.spec.ts b/apps/cockpit/src/app/favicon.ico/route.spec.ts index 3b57489ac..24ef36b5a 100644 --- a/apps/cockpit/src/app/favicon.ico/route.spec.ts +++ b/apps/cockpit/src/app/favicon.ico/route.spec.ts @@ -1,23 +1,16 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { describe, expect, it, vi } from 'vitest'; - -const capabilityResolver = vi.hoisted(() => vi.fn()); - -vi.mock('../../lib/cockpit-page', () => ({ - getCockpitPageModel: capabilityResolver, -})); +import { describe, expect, it } from 'vitest'; import { GET } from './route'; describe('GET /favicon.ico', () => { - it('redirects permanently to the same-origin SVG without resolving a capability', () => { + it('redirects permanently to the same-origin SVG', () => { const response = GET(new Request('https://cockpit.test/favicon.ico')); expect(response.status).toBe(308); expect(response.headers.get('location')).toBe('/icon.svg'); expect(response.headers.get('cache-control')).toBe('public, max-age=86400'); - expect(capabilityResolver).not.toHaveBeenCalled(); }); it('keeps the redirect relative when the request URL was normalized', () => { diff --git a/apps/cockpit/src/app/layout.tsx b/apps/cockpit/src/app/layout.tsx index c17599eed..6fe3fb7e1 100644 --- a/apps/cockpit/src/app/layout.tsx +++ b/apps/cockpit/src/app/layout.tsx @@ -1,57 +1,13 @@ import type { ReactNode } from 'react'; -import { cookies } from 'next/headers'; -import { cssVars, ThemeProvider } from '@threadplane/ui-react'; -import type { Theme } from '@threadplane/design-tokens'; -import { AnalyticsBootstrap } from '../components/analytics-bootstrap'; -import './cockpit.css'; -import { EB_Garamond, Inter, JetBrains_Mono } from 'next/font/google'; - -const inter = Inter({ subsets: ['latin'], variable: '--font-inter', display: 'swap' }); -const mono = JetBrains_Mono({ subsets: ['latin'], variable: '--font-mono', display: 'swap' }); -const garamond = EB_Garamond({ subsets: ['latin'], weight: ['400', '500', '600'], variable: '--font-garamond', display: 'swap' }); - -export const metadata = { - title: 'Cockpit — Threadplane', - description: 'The live reference app for Threadplane. Real LangGraph + AG-UI agents through the Angular surface you’ll ship.', - openGraph: { - title: 'Cockpit — Threadplane', - description: 'The live reference app for the framework. Real LangGraph + AG-UI agents through the same Angular surface you’ll ship.', - type: 'website', - siteName: 'Cockpit', - }, - twitter: { - card: 'summary_large_image', - title: 'Cockpit — Threadplane', - description: 'The live reference app for the framework. Real LangGraph + AG-UI agents through the Angular surface you’ll ship.', - }, -}; interface RootLayoutProps { children: ReactNode; } -export default async function RootLayout({ children }: RootLayoutProps) { - const cookieStore = await cookies(); - const cookieValue = cookieStore.get('theme')?.value; - const theme: Theme = cookieValue === 'light' ? 'light' : 'dark'; - +export default function RootLayout({ children }: RootLayoutProps) { return ( - - - - {children} - + + {children} ); } diff --git a/apps/cockpit/src/app/opengraph-image.tsx b/apps/cockpit/src/app/opengraph-image.tsx deleted file mode 100644 index 06d3f9cc3..000000000 --- a/apps/cockpit/src/app/opengraph-image.tsx +++ /dev/null @@ -1,169 +0,0 @@ -/** - * Default OpenGraph + Twitter share card for the cockpit reference app. - * - * Renders a 1200×630 PNG at request time via Next.js ImageResponse. - * Per-route overrides can be added by dropping an `opengraph-image.tsx` - * file in any route folder (e.g. per-product or per-topic cards). - * - * Cockpit's chrome is Linear-style devtools (Phase 8 spec) — Inter Bold - * for the headline rather than the marketing site's EB Garamond, so we - * don't need to bundle a serif TTF. - */ -import { ImageResponse } from 'next/og'; -import { darkOverrides } from '@threadplane/design-tokens'; - -export const runtime = 'edge'; -export const alt = 'Cockpit — the live reference app for Threadplane'; -export const size = { width: 1200, height: 630 }; -export const contentType = 'image/png'; - -async function loadFont(family: string, weight: number): Promise { - try { - const css = await fetch( - `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}:wght@${weight}&display=swap`, - { headers: { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36' } }, - ).then((res) => res.text()); - const match = css.match(/src:\s*url\((https?:\/\/[^)]+)\)/); - if (!match) return null; - const fontRes = await fetch(match[1]); - if (!fontRes.ok) return null; - return await fontRes.arrayBuffer(); - } catch { - return null; - } -} - -export default async function OpenGraphImage() { - const [interRegular, interBold, monoBold] = await Promise.all([ - loadFont('Inter', 400), - loadFont('Inter', 600), - loadFont('JetBrains+Mono', 700), - ]); - const fonts = [ - interRegular && { name: 'Inter', data: interRegular, weight: 400 as const, style: 'normal' as const }, - interBold && { name: 'Inter', data: interBold, weight: 700 as const, style: 'normal' as const }, - monoBold && { name: 'JetBrains Mono', data: monoBold, weight: 700 as const, style: 'normal' as const }, - ].filter((f): f is NonNullable => f !== null); - - return new ImageResponse( - ( -
- {/* Eyebrow */} -
- Cockpit · Threadplane -
- - {/* Headline — Inter Bold (cockpit chrome is sans-serif Linear-style) */} -
- The live reference app for the framework. -
- - {/* Subhead */} -
- Real LangGraph and AG-UI agents running through the same Angular surface you'll ship. - Switch between Run · Code · Docs · API for each capability. -
- - {/* Mode pills + cockpit wordmark */} -
-
- {['Run', 'Code', 'Docs', 'API'].map((mode, i) => ( - {mode} - ))} -
-
- 🛩️ - cockpit.threadplane.ai -
-
-
- ), - { - ...size, - fonts, - }, - ); -} - -interface ModePillProps { - active: boolean; - children: React.ReactNode; -} - -/** Mimics the cockpit mode-switcher: rounded pill, accent on the active one. */ -function ModePill({ active, children }: ModePillProps) { - return ( -
- {children} -
- ); -} diff --git a/apps/cockpit/src/app/page.tsx b/apps/cockpit/src/app/page.tsx deleted file mode 100644 index eca0ccf94..000000000 --- a/apps/cockpit/src/app/page.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; -import { redirect } from 'next/navigation'; -import { CockpitShell } from '../components/cockpit-shell'; -import { getContentBundle } from '@threadplane/cockpit-shell'; -import { - getCockpitPageModel, - getRootWebsiteRedirect, - normalizeRequestedMode, -} from '../lib/cockpit-page'; - -export default async function CockpitHomePage({ - searchParams, -}: { - searchParams: Promise<{ mode?: string | string[] }>; -}) { - const { mode } = await searchParams; - const websiteRedirect = getRootWebsiteRedirect(mode); - if (websiteRedirect) { - redirect(websiteRedirect); - } - const { resolution, presentation, navigationTree } = getCockpitPageModel(); - const contentBundle = await getContentBundle(presentation); - - return ( - - ); -} diff --git a/apps/cockpit/src/components/analytics-bootstrap.tsx b/apps/cockpit/src/components/analytics-bootstrap.tsx deleted file mode 100644 index a544c57b9..000000000 --- a/apps/cockpit/src/components/analytics-bootstrap.tsx +++ /dev/null @@ -1,38 +0,0 @@ -// SPDX-License-Identifier: MIT -'use client'; - -import { useEffect } from 'react'; -import posthog from 'posthog-js'; -import { getCockpitSessionId } from '../lib/analytics/distinct-id'; -import { shouldCaptureAnalytics } from '@threadplane/telemetry/browser'; - -/** - * Client-side analytics bootstrap. Initializes posthog-js once per - * client process when env + privacy gates pass. - * - * Mounted from the root layout. Renders nothing. Idempotent — re-renders - * (e.g. fast-refresh) check `__loaded` before re-initializing. - */ -export function AnalyticsBootstrap(): null { - useEffect(() => { - if ((posthog as unknown as { __loaded?: boolean }).__loaded) { - return; - } - const token = process.env.NEXT_PUBLIC_COCKPIT_POSTHOG_TOKEN; - const captureLocal = process.env.NEXT_PUBLIC_COCKPIT_CAPTURE_LOCAL === 'true'; - const host = typeof window === 'undefined' ? undefined : window.location.host; - if (!shouldCaptureAnalytics({ token, captureLocal, host })) { - return; - } - posthog.init(token as string, { - api_host: '/ingest', - ui_host: 'https://us.posthog.com', - persistence: 'memory', - bootstrap: { distinctID: getCockpitSessionId() }, - autocapture: false, - capture_pageview: false, - defaults: '2026-01-30', - }); - }, []); - return null; -} diff --git a/apps/cockpit/src/components/branding/logo.spec.tsx b/apps/cockpit/src/components/branding/logo.spec.tsx deleted file mode 100644 index 77154c3e8..000000000 --- a/apps/cockpit/src/components/branding/logo.spec.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { describe, expect, it } from 'vitest'; -import { Logo } from './logo'; - -describe('Logo', () => { - it('renders the Threadplane wordmark', () => { - const html = renderToStaticMarkup(); - expect(html).toContain('Threadplane'); - }); - - it('exposes a stable data-ui selector', () => { - const html = renderToStaticMarkup(); - expect(html).toContain('data-ui="cockpit-logo"'); - }); -}); diff --git a/apps/cockpit/src/components/branding/logo.tsx b/apps/cockpit/src/components/branding/logo.tsx deleted file mode 100644 index fe39f2d0f..000000000 --- a/apps/cockpit/src/components/branding/logo.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react'; -import type { HTMLAttributes } from 'react'; - -export function Logo({ className, style, ...rest }: HTMLAttributes) { - return ( - - - - Threadplane - - - ); -} diff --git a/apps/cockpit/src/components/cockpit-shell.spec.tsx b/apps/cockpit/src/components/cockpit-shell.spec.tsx deleted file mode 100644 index 163f7ac6b..000000000 --- a/apps/cockpit/src/components/cockpit-shell.spec.tsx +++ /dev/null @@ -1,1219 +0,0 @@ -/** @vitest-environment jsdom */ -import React from 'react'; -import { - act, - fireEvent, - render, - screen, - waitFor, - within, -} from '@testing-library/react'; -import { - CONTROL_PLANE_STORAGE_KEY, - ThemeProvider, -} from '@threadplane/ui-react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { getCockpitPageModel } from '../lib/cockpit-page'; -import type { - UseRuntimeControllerOptions, - WorkspaceProviderProps, - WorkspaceShellProps, -} from '@threadplane/workspace-react'; - -type CockpitSharedShellProps = WorkspaceShellProps & { - modeNavigationLabel?: string; - contextPaneLabel?: string; - mobileDialogLabel?: string; - mobileTitle?: string; -}; - -const operationalMocks = vi.hoisted(() => ({ - controllerInstances: 0, - latestControllerOptions: null as UseRuntimeControllerOptions | null, - activityShouldThrow: false, - latestProviderProps: null as WorkspaceProviderProps | null, - latestShellProps: null as CockpitSharedShellProps | null, - track: vi.fn(), - push: vi.fn(), - replace: vi.fn(), -})); - -vi.mock('next/navigation', () => ({ - useRouter: () => ({ - push: operationalMocks.push, - refresh: vi.fn(), - replace: operationalMocks.replace, - back: vi.fn(), - forward: vi.fn(), - prefetch: vi.fn(), - }), -})); - -vi.mock('../lib/analytics/client', () => ({ track: operationalMocks.track })); - -vi.mock('@threadplane/workspace-react', async (importOriginal) => { - const ReactModule = await import('react'); - const actual = await importOriginal< - typeof import('@threadplane/workspace-react') - >(); - return { - ...actual, - WorkspaceProvider(props: WorkspaceProviderProps) { - operationalMocks.latestProviderProps = props; - return ReactModule.createElement(actual.WorkspaceProvider, props); - }, - WorkspaceShell(props: WorkspaceShellProps) { - operationalMocks.latestShellProps = props; - return ReactModule.createElement(actual.WorkspaceShell, props); - }, - }; -}); - -vi.mock( - '../../../../libs/workspace-react/src/lib/runtime/use-runtime-controller', - async (importOriginal) => { - const ReactModule = await import('react'); - const actual = await importOriginal< - typeof import('../../../../libs/workspace-react/src/lib/runtime/use-runtime-controller') - >(); - return { - ...actual, - useRuntimeController(options: UseRuntimeControllerOptions) { - const mounted = ReactModule.useRef(false); - if (!mounted.current) { - mounted.current = true; - operationalMocks.controllerInstances += 1; - } - ReactModule.useLayoutEffect(() => { - operationalMocks.latestControllerOptions = options; - }, [options]); - return actual.useRuntimeController(options); - }, - }; - } -); - -vi.mock( - '../../../../libs/workspace-react/src/lib/components/control-plane/activity-panel', - async (importOriginal) => { - const ReactModule = await import('react'); - const actual = await importOriginal< - typeof import('../../../../libs/workspace-react/src/lib/components/control-plane/activity-panel') - >(); - return { - ...actual, - ActivityPanel(props: React.ComponentProps) { - if (operationalMocks.activityShouldThrow) { - throw new Error('sensitive activity render failure'); - } - return ReactModule.createElement(actual.ActivityPanel, props); - }, - }; - } -); - -import { CockpitShell } from './cockpit-shell'; - -const model = getCockpitPageModel(); -const persistenceModel = getCockpitPageModel([ - 'langgraph', - 'core-capabilities', - 'persistence', - 'overview', - 'python', -]); -const baseContentBundle = { - codeFiles: {}, - promptFiles: {}, - runtimeUrl: null, - docSections: [], - narrativeDocs: [], -}; - -const seedExpanded = ( - expanded: Record = { - Capability: true, - Runtime: true, - } -) => { - window.localStorage.setItem( - CONTROL_PLANE_STORAGE_KEY, - JSON.stringify({ - version: 1, - docs: { expanded: { Learn: true, Environment: false } }, - cockpit: { expanded }, - }) - ); -}; - -const renderShell = (runtimeUrl: string | null = null) => - render( - - - - ); - -const renderShellFor = (slug: string[]) => { - const pageModel = getCockpitPageModel(slug); - return render( - - - - ); -}; - -// The Run rail item's accessible name carries the live runtime phase -// ('Run, runtime ready'), so these mode assertions match the label prefix -// instead of a name that depends on which phase the fixture happens to be in. -const RUN_RAIL_ITEM = /^Run(,|$)/; - -const openActivity = () => { - fireEvent.click(screen.getByRole('button', { name: /^Activity/ })); - return screen.getByRole('heading', { - name: /Activity(?: unavailable)?/, - }); -}; - -describe('CockpitShell operational composition', () => { - beforeEach(() => { - window.localStorage.clear(); - window.sessionStorage.clear(); - window.history.replaceState({}, '', '/'); - operationalMocks.controllerInstances = 0; - operationalMocks.latestControllerOptions = null; - operationalMocks.activityShouldThrow = false; - operationalMocks.latestProviderProps = null; - operationalMocks.latestShellProps = null; - operationalMocks.track.mockClear(); - operationalMocks.push.mockClear(); - operationalMocks.replace.mockClear(); - document.documentElement.dataset.theme = 'light'; - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({})); - }); - - afterEach(() => { - window.history.replaceState({}, '', '/'); - vi.useRealTimers(); - vi.unstubAllGlobals(); - vi.restoreAllMocks(); - }); - - it('adapts Cockpit route, content, navigation, analytics, session, telemetry, theme, and labels into the shared workspace', () => { - renderShell(); - - expect(operationalMocks.latestProviderProps).toMatchObject({ - contentBundle: baseContentBundle, - routeKind: 'workspace', - routePath: model.canonicalPath, - requestedMode: null, - runtimeTelemetry: { - posthogToken: process.env.NEXT_PUBLIC_COCKPIT_POSTHOG_TOKEN, - ingestHost: process.env.NEXT_PUBLIC_COCKPIT_INGEST_HOST, - }, - }); - expect(operationalMocks.latestProviderProps?.resolution.kind).toBe( - 'mapped' - ); - expect( - operationalMocks.latestProviderProps?.resolution.kind === 'mapped' - ? operationalMocks.latestProviderProps.resolution.identity.id - : null - ).toBe('langgraph:core-capabilities:streaming:overview:python'); - expect(operationalMocks.latestProviderProps?.presentation.kind).toBe( - 'capability' - ); - expect(operationalMocks.latestProviderProps?.getSessionId).toBeTypeOf( - 'function' - ); - expect(operationalMocks.latestProviderProps?.pushIdentity).toBeTypeOf( - 'function' - ); - expect(operationalMocks.latestProviderProps?.pushMode).toBeTypeOf( - 'function' - ); - expect(operationalMocks.latestProviderProps?.replaceMode).toBeTypeOf( - 'function' - ); - expect(operationalMocks.latestProviderProps?.trackNavigation).toBeTypeOf( - 'function' - ); - expect( - operationalMocks.latestProviderProps?.trackNarrativeAction - ).toBeTypeOf('function'); - expect(operationalMocks.latestProviderProps?.trackModeChange).toBeTypeOf( - 'function' - ); - expect(operationalMocks.latestProviderProps?.trackRuntimeAction).toBeTypeOf( - 'function' - ); - expect( - operationalMocks.latestProviderProps?.trackRuntimeTransition - ).toBeTypeOf('function'); - expect(operationalMocks.latestShellProps).toMatchObject({ - navigationTree: model.navigationTree, - ariaLabel: 'Cockpit shell', - modeNavigationLabel: 'Cockpit modes', - contextPaneLabel: 'Cockpit context', - mobileDialogLabel: 'Cockpit control plane', - mobileTitle: 'Cockpit', - }); - expect(operationalMocks.latestShellProps?.themeControl).toBeTruthy(); - }); - - it('uses the truthful workspace route default when no mode query is present', async () => { - renderShell(); - - await waitFor(() => { - expect( - screen - .getByRole('button', { name: RUN_RAIL_ITEM }) - .getAttribute('aria-pressed') - ).toBe('true'); - }); - expect(screen.getByRole('region', { name: 'Run mode' })).toBeTruthy(); - }); - - it('keeps a valid mode query as route state without persisting the mode', async () => { - window.history.replaceState({}, '', '/?mode=code&keep=1'); - renderShell(); - - await waitFor(() => { - expect( - screen - .getByRole('button', { name: 'Code' }) - .getAttribute('aria-pressed') - ).toBe('true'); - }); - expect(window.location.search).toBe('?mode=code&keep=1'); - expect(window.localStorage.getItem(CONTROL_PLANE_STORAGE_KEY)).toBeNull(); - }); - - it('normalizes invalid mode queries to the truthful route default', async () => { - window.history.replaceState({}, '', '/?mode=preview'); - renderShell(); - - await waitFor(() => { - expect( - screen - .getByRole('button', { name: RUN_RAIL_ITEM }) - .getAttribute('aria-pressed') - ).toBe('true'); - }); - expect(operationalMocks.replace).toHaveBeenCalledWith('/?mode=run'); - }); - - it('owns one controller and one Activity store shared by desktop and mobile adapters', async () => { - renderShell(); - await waitFor(() => - expect(screen.getByRole('button', { name: RUN_RAIL_ITEM })).toBeTruthy() - ); - expect(operationalMocks.controllerInstances).toBe(1); - - fireEvent.click(screen.getByRole('button', { name: 'Code' })); - openActivity(); - expect(screen.getAllByText('Mode changed to Code')).toHaveLength(1); - - fireEvent.click(screen.getByRole('button', { name: 'Open navigation' })); - const dialog = screen.getByRole('dialog', { - name: 'Cockpit control plane', - }); - expect(within(dialog).getByText('Mode changed to Code')).toBeTruthy(); - expect(operationalMocks.controllerInstances).toBe(1); - }); - - it('flags an unread runtime problem until Activity is opened, and survives recovery', async () => { - renderShell(); - await waitFor(() => - expect(operationalMocks.latestControllerOptions).not.toBeNull() - ); - - // Routine activity must not light the indicator. - act(() => { - operationalMocks.latestControllerOptions?.onActivity({ - id: 'ready-event', - at: '2026-08-31T17:00:00.000Z', - kind: 'runtime_ready', - capability: 'streaming', - }); - }); - expect(screen.getByRole('button', { name: 'Activity' })).toBeTruthy(); - - act(() => { - operationalMocks.latestControllerOptions?.onActivity({ - id: 'unresponsive-event', - at: '2026-08-31T17:01:00.000Z', - kind: 'runtime_unresponsive', - capability: 'streaming', - }); - }); - expect( - screen.getAllByRole('button', { name: 'Activity, 1 unread problem' }) - ).not.toHaveLength(0); - - // A self-recovering runtime clears the phase but not the unread problem. - act(() => { - operationalMocks.latestControllerOptions?.onActivity({ - id: 'recovered-event', - at: '2026-08-31T17:02:00.000Z', - kind: 'runtime_recovered', - capability: 'streaming', - }); - }); - expect( - screen.getAllByRole('button', { name: 'Activity, 1 unread problem' }) - ).not.toHaveLength(0); - - openActivity(); - expect( - screen.getAllByRole('button', { name: 'Activity' }) - ).not.toHaveLength(0); - - // Clearing the log must reset the marker too. If it did not, the marker - // would stay at N over an empty log and silently swallow the next N - // problems for the rest of the page visit. - fireEvent.click( - screen.getAllByRole('button', { name: 'Activity actions' })[0] - ); - fireEvent.click( - screen.getAllByRole('menuitem', { name: 'Clear session activity' })[0] - ); - act(() => { - operationalMocks.latestControllerOptions?.onActivity({ - id: 'post-clear-event', - at: '2026-08-31T17:03:00.000Z', - kind: 'runtime_unresponsive', - capability: 'streaming', - }); - }); - expect( - screen.getAllByRole('button', { name: 'Activity, 1 unread problem' }) - ).not.toHaveLength(0); - }); - - it('does not reset drawer focus when shared operational state rerenders', async () => { - renderShell(); - await waitFor(() => - expect(operationalMocks.latestControllerOptions).not.toBeNull() - ); - fireEvent.click(screen.getByRole('button', { name: 'Open navigation' })); - const dialog = screen.getByRole('dialog', { - name: 'Cockpit control plane', - }); - const capability = within(dialog).getByRole('button', { - name: 'Capability', - }); - capability.focus(); - - act(() => { - operationalMocks.latestControllerOptions?.onActivity({ - id: 'background-event', - at: '2026-08-31T17:00:00.000Z', - kind: 'runtime_ready', - capability: 'streaming', - }); - }); - - expect(document.activeElement).toBe(capability); - }); - - it('keeps both background siblings inert through the mobile closing transition', async () => { - vi.useFakeTimers(); - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => - window.setTimeout(() => callback(performance.now()), 16) - ); - vi.stubGlobal('cancelAnimationFrame', (handle: number) => - window.clearTimeout(handle) - ); - const rendered = renderShell(); - await act(async () => { - await vi.runAllTimersAsync(); - }); - const shell = screen.getByRole('main', { name: 'Cockpit shell' }); - const desktopNavigation = shell.querySelector( - '[data-cockpit-desktop-navigation]' - ); - const workspace = shell.querySelector('[data-cockpit-workspace]'); - const trigger = shell.querySelector( - '.cockpit-mobile-navigation-trigger' - ); - if (!desktopNavigation || !workspace || !trigger) { - throw new Error('Expected named shell boundaries and mobile trigger'); - } - const inertAtFocusAttempt: boolean[] = []; - const nativeFocus = trigger.focus.bind(trigger); - vi.spyOn(trigger, 'focus').mockImplementation(() => { - inertAtFocusAttempt.push(Boolean(trigger.closest('[inert]'))); - nativeFocus(); - }); - - fireEvent.click(trigger); - - expect(desktopNavigation.hasAttribute('inert')).toBe(true); - expect(desktopNavigation.getAttribute('aria-hidden')).toBe('true'); - expect(workspace.hasAttribute('inert')).toBe(true); - expect(workspace.getAttribute('aria-hidden')).toBe('true'); - expect(trigger.getAttribute('aria-label')).toBe('Open navigation'); - expect(trigger.tabIndex).toBe(-1); - expect( - screen.queryByRole('button', { name: 'Open navigation' }) - ).toBeNull(); - expect( - screen.getAllByRole('button', { name: 'Close navigation', hidden: true }) - ).toHaveLength(1); - - fireEvent.click( - within( - screen.getByRole('dialog', { name: 'Cockpit control plane' }) - ).getByRole('button', { name: 'Close navigation' }) - ); - - expect( - screen - .getByRole('dialog', { name: 'Cockpit control plane' }) - .getAttribute('data-state') - ).toBe('closing'); - expect(desktopNavigation.hasAttribute('inert')).toBe(true); - expect(workspace.hasAttribute('inert')).toBe(true); - expect(trigger.tabIndex).toBe(-1); - - act(() => vi.advanceTimersByTime(149)); - expect(desktopNavigation.hasAttribute('inert')).toBe(true); - expect(workspace.hasAttribute('inert')).toBe(true); - - act(() => vi.advanceTimersByTime(1)); - expect(screen.queryByRole('dialog')).toBeNull(); - expect(desktopNavigation.hasAttribute('inert')).toBe(false); - expect(desktopNavigation.hasAttribute('aria-hidden')).toBe(false); - expect(workspace.hasAttribute('inert')).toBe(false); - expect(workspace.hasAttribute('aria-hidden')).toBe(false); - expect(trigger.tabIndex).toBe(0); - expect(document.activeElement).not.toBe(trigger); - - act(() => vi.advanceTimersByTime(16)); - expect(inertAtFocusAttempt).toEqual([false]); - expect(document.activeElement).toBe(trigger); - - rendered.unmount(); - vi.useRealTimers(); - }); - - it('closes before routing and restores destination-panel focus after navigation exactly once', async () => { - vi.useFakeTimers(); - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => - window.setTimeout(() => callback(performance.now()), 16) - ); - vi.stubGlobal('cancelAnimationFrame', (handle: number) => - window.clearTimeout(handle) - ); - const rendered = renderShell(); - await act(async () => { - await vi.runAllTimersAsync(); - }); - const trigger = screen.getByRole('button', { name: 'Open navigation' }); - const panel = screen.getByRole('heading', { - name: 'LangGraph Streaming Run', - }); - fireEvent.click(trigger); - const overlay = screen.getByRole('dialog', { - name: 'Cockpit control plane', - }); - const workspace = trigger.closest('[data-cockpit-workspace]'); - if (!workspace) throw new Error('Expected the workspace boundary'); - const destination = within(overlay).getByRole('link', { - name: 'Persistence', - }); - const destinationPath = new URL( - destination.getAttribute('href') ?? '', - window.location.href - ).pathname; - const triggerFocus = vi.spyOn(trigger, 'focus'); - const panelFocus = vi.spyOn(panel, 'focus'); - operationalMocks.track.mockClear(); - - expect(fireEvent.click(destination)).toBe(false); - expect(overlay.getAttribute('data-state')).toBe('closing'); - expect(operationalMocks.push).not.toHaveBeenCalled(); - expect(operationalMocks.track).toHaveBeenCalledTimes(1); - expect(operationalMocks.track).toHaveBeenCalledWith( - 'cockpit:recipe_opened', - expect.objectContaining({ capability: 'persistence' }) - ); - - act(() => vi.advanceTimersByTime(150)); - expect(screen.queryByRole('dialog')).toBeNull(); - expect(workspace.hasAttribute('inert')).toBe(false); - expect(operationalMocks.push).not.toHaveBeenCalled(); - - act(() => vi.advanceTimersByTime(16)); - expect(triggerFocus).not.toHaveBeenCalled(); - expect(panelFocus).not.toHaveBeenCalled(); - expect(operationalMocks.push).toHaveBeenCalledTimes(1); - expect(operationalMocks.push).toHaveBeenCalledWith(destinationPath); - - act(() => window.history.replaceState({}, '', destinationPath)); - rendered.rerender( - - - - ); - act(() => vi.advanceTimersByTime(16)); - expect(triggerFocus).not.toHaveBeenCalled(); - expect(panelFocus).toHaveBeenCalledTimes(1); - expect(document.activeElement).toBe(panel); - expect(operationalMocks.push).toHaveBeenCalledTimes(1); - - rendered.unmount(); - vi.useRealTimers(); - }); - - it('focuses the selected mobile destination panel instead of the navigation trigger', () => { - vi.useFakeTimers(); - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => - window.setTimeout(() => callback(performance.now()), 16) - ); - vi.stubGlobal('cancelAnimationFrame', (handle: number) => - window.clearTimeout(handle) - ); - renderShell(); - const trigger = screen.getByRole('button', { name: 'Open navigation' }); - const triggerFocus = vi.spyOn(trigger, 'focus'); - - fireEvent.click(trigger); - fireEvent.click( - within( - screen.getByRole('dialog', { name: 'Cockpit control plane' }) - ).getByRole('button', { name: 'Code' }) - ); - const codePanel = screen.getByRole('heading', { - name: 'LangGraph Streaming Code', - hidden: true, - }); - act(() => vi.advanceTimersByTime(150)); - act(() => vi.advanceTimersByTime(16)); - - expect(triggerFocus).not.toHaveBeenCalled(); - expect(document.activeElement).toBe(codePanel); - }); - - it('uses the Cockpit host adapter for desktop capability navigation', async () => { - const rendered = renderShell(); - await waitFor(() => - expect(screen.getByRole('link', { name: 'Persistence' })).toBeTruthy() - ); - const destination = screen.getByRole('link', { name: 'Persistence' }); - const destinationPath = new URL( - destination.getAttribute('href') ?? '', - window.location.href - ).pathname; - - expect(fireEvent.click(destination)).toBe(false); - - expect(operationalMocks.push).toHaveBeenCalledWith(destinationPath); - expect( - JSON.parse( - window.sessionStorage.getItem( - 'threadplane:cockpit:workspace-panel-focus' - ) ?? '{}' - ) - ).toEqual({ - destination: destinationPath, - requestedAt: expect.any(Number), - }); - rendered.unmount(); - }); - - it('does not focus the mobile trigger on an ordinary shell load', async () => { - const rendered = renderShell(); - await waitFor(() => - expect( - screen.getByRole('button', { name: 'Open navigation' }) - ).toBeTruthy() - ); - const trigger = screen.getByRole('button', { name: 'Open navigation' }); - - expect(document.activeElement).not.toBe(trigger); - rendered.unmount(); - }); - - it('consumes a cross-route focus intent into the active destination panel', () => { - vi.useFakeTimers(); - vi.stubGlobal( - 'matchMedia', - vi.fn().mockReturnValue({ - matches: true, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - }) - ); - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => - window.setTimeout(() => callback(performance.now()), 16) - ); - vi.stubGlobal('cancelAnimationFrame', (handle: number) => - window.clearTimeout(handle) - ); - window.history.replaceState({}, '', persistenceModel.canonicalPath); - window.sessionStorage.setItem( - 'threadplane:cockpit:workspace-panel-focus', - JSON.stringify({ - destination: persistenceModel.canonicalPath, - requestedAt: Date.now(), - }) - ); - const rendered = render( - - - - ); - const trigger = screen.getByRole('button', { - name: 'Open navigation', - hidden: true, - }); - const focus = vi.spyOn(trigger, 'focus'); - const panel = screen.getByRole('heading', { - name: 'LangGraph Persistence Run', - }); - const panelFocus = vi.spyOn(panel, 'focus'); - - act(() => vi.advanceTimersByTime(16)); - - expect(focus).not.toHaveBeenCalled(); - expect(panelFocus).toHaveBeenCalledTimes(1); - expect(document.activeElement).toBe(panel); - rendered.unmount(); - vi.useRealTimers(); - }); - - it('uses a persistent tablet rail while Activity and Settings replace the context surface', () => { - vi.stubGlobal( - 'matchMedia', - vi.fn((query: string) => ({ - matches: - query === '(min-width: 48rem)' || - query === '(min-width: 48rem) and (max-width: 63.999rem)', - media: query, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - addListener: vi.fn(), - removeListener: vi.fn(), - })) - ); - renderShell(); - - const settings = screen.getByRole('button', { name: 'Settings' }); - fireEvent.click(settings); - const surface = screen.getByRole('dialog', { - name: 'Cockpit control plane context', - }); - expect( - within(surface).getByRole('heading', { name: 'Settings' }) - ).toBeTruthy(); - expect( - screen.getByRole('navigation', { name: 'Cockpit modes' }) - ).toBeTruthy(); - - const activity = screen.getByRole('button', { name: 'Activity' }); - fireEvent.click(activity); - expect( - within(surface).getByRole('heading', { name: 'Activity' }) - ).toBeTruthy(); - fireEvent.click( - within(surface).getByRole('button', { name: 'Close Activity' }) - ); - - expect(document.activeElement).toBe(activity); - expect( - within(surface).getByRole('button', { name: 'Capability' }) - ).toBeTruthy(); - }); - - it('closes the tablet context surface and focuses the selected mode panel', () => { - vi.useFakeTimers(); - vi.stubGlobal( - 'matchMedia', - vi.fn((query: string) => ({ - matches: - query === '(min-width: 48rem)' || - query === '(min-width: 48rem) and (max-width: 63.999rem)', - media: query, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - addListener: vi.fn(), - removeListener: vi.fn(), - })) - ); - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => - window.setTimeout(() => callback(performance.now()), 16) - ); - vi.stubGlobal('cancelAnimationFrame', (handle: number) => - window.clearTimeout(handle) - ); - renderShell(); - - fireEvent.click(screen.getByRole('button', { name: 'Open context' })); - expect( - screen.getByRole('dialog', { name: 'Cockpit control plane context' }) - ).toBeTruthy(); - fireEvent.click(screen.getByRole('button', { name: 'Code' })); - const codePanel = screen.getByRole('heading', { - name: 'LangGraph Streaming Code', - hidden: true, - }); - - act(() => vi.advanceTimersByTime(150)); - act(() => vi.advanceTimersByTime(16)); - expect(screen.queryByRole('dialog')).toBeNull(); - expect(document.activeElement).toBe(codePanel); - }); - - it('restores the tablet context trigger after explicit dismissal', () => { - vi.useFakeTimers(); - vi.stubGlobal( - 'matchMedia', - vi.fn((query: string) => ({ - matches: - query === '(min-width: 48rem)' || - query === '(min-width: 48rem) and (max-width: 63.999rem)', - media: query, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - addListener: vi.fn(), - removeListener: vi.fn(), - })) - ); - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => - window.setTimeout(() => callback(performance.now()), 16) - ); - vi.stubGlobal('cancelAnimationFrame', (handle: number) => - window.clearTimeout(handle) - ); - renderShell(); - - const trigger = screen.getByRole('button', { name: 'Open context' }); - fireEvent.click(trigger); - fireEvent.click( - within( - screen.getByRole('dialog', { - name: 'Cockpit control plane context', - }) - ).getByRole('button', { name: 'Close navigation' }) - ); - act(() => vi.advanceTimersByTime(150)); - act(() => vi.advanceTimersByTime(16)); - - expect(document.activeElement).toBe(trigger); - }); - - it('records one fixed Activity event and one existing analytics event only for an actual mode change', async () => { - renderShell(); - await waitFor(() => - expect(screen.getByRole('button', { name: RUN_RAIL_ITEM })).toBeTruthy() - ); - - fireEvent.click(screen.getByRole('button', { name: 'Code' })); - fireEvent.click(screen.getByRole('button', { name: 'Code' })); - openActivity(); - - expect(screen.getAllByText('Mode changed to Code')).toHaveLength(1); - expect(operationalMocks.track).toHaveBeenCalledTimes(1); - expect(operationalMocks.track).toHaveBeenCalledWith( - 'cockpit:mode_switched', - { - capability: 'streaming', - from_mode: 'run', - to_mode: 'code', - } - ); - expect(operationalMocks.push).toHaveBeenCalledTimes(1); - expect(operationalMocks.push).toHaveBeenCalledWith('/?mode=code'); - expect(operationalMocks.replace).not.toHaveBeenCalled(); - }); - - it('keeps the exact Run iframe mounted while Activity and Settings replace only context', async () => { - renderShell('https://runtime.test/path'); - const frame = await screen.findByTitle('LangGraph Streaming live example'); - - openActivity(); - expect(screen.getByTitle('LangGraph Streaming live example')).toBe(frame); - fireEvent.click(screen.getByRole('button', { name: 'Settings' })); - expect(screen.getByTitle('LangGraph Streaming live example')).toBe(frame); - }); - - it('renders non-empty Run, Code, narrative Docs, and API fixtures through the shared panels without remounting Run', async () => { - if (model.presentation.kind !== 'capability') { - throw new Error('Expected the streaming capability presentation'); - } - const codePath = model.presentation.codeAssetPaths[0]; - if (!codePath) throw new Error('Expected a streaming code asset'); - window.history.replaceState({}, '', '/?mode=run'); - - render( - - const adapterFixture = true;', - }, - promptFiles: {}, - runtimeUrl: 'https://runtime.test/parity', - narrativeDocs: [ - { - title: 'Adapter narrative', - html: '

Adapter narrative

Shared Docs fixture.

', - sourceFile: 'adapter.md', - }, - ], - docSections: [ - { - title: 'adapterApi', - signature: 'adapterApi(value: string): boolean', - description: 'Shared API fixture.', - params: [{ name: 'value', description: 'Fixture input.' }], - returns: 'Whether the fixture is active.', - sourceFile: 'adapter.ts', - language: 'typescript', - }, - ], - }} - routePath={model.canonicalPath} - requestedMode="run" - /> -
- ); - - const frame = await screen.findByTitle('LangGraph Streaming live example'); - expect(screen.getByRole('region', { name: 'Run mode' })).toBeTruthy(); - - fireEvent.click(screen.getByRole('button', { name: 'Code' })); - expect(screen.getByRole('region', { name: 'Code mode' })).toBeTruthy(); - expect(screen.getByText('const adapterFixture = true;')).toBeTruthy(); - expect(screen.getByTitle('LangGraph Streaming live example')).toBe(frame); - - fireEvent.click(screen.getByRole('button', { name: 'Docs' })); - expect(screen.getByRole('region', { name: 'Docs mode' })).toBeTruthy(); - expect( - screen.getByRole('heading', { name: 'Adapter narrative' }) - ).toBeTruthy(); - expect(screen.getByText('Shared Docs fixture.')).toBeTruthy(); - expect(screen.getByTitle('LangGraph Streaming live example')).toBe(frame); - - fireEvent.click(screen.getByRole('button', { name: 'API' })); - expect(screen.getByRole('region', { name: 'API mode' })).toBeTruthy(); - expect(screen.getByRole('heading', { name: 'adapterApi' })).toBeTruthy(); - expect(screen.getByText('Shared API fixture.')).toBeTruthy(); - expect(screen.getByTitle('LangGraph Streaming live example')).toBe(frame); - - fireEvent.click(screen.getByRole('button', { name: RUN_RAIL_ITEM })); - expect(screen.getByRole('region', { name: 'Run mode' })).toBeTruthy(); - expect(screen.getByTitle('LangGraph Streaming live example')).toBe(frame); - }); - - it('reloads only the iframe while preserving shell state and session Activity', async () => { - seedExpanded({ Capability: true, Runtime: true }); - renderShell('https://runtime.test/path?secret=hidden'); - const firstFrame = await screen.findByTitle( - 'LangGraph Streaming live example' - ); - const routeBefore = window.location.pathname; - - fireEvent.click(screen.getByRole('button', { name: 'Capability' })); - fireEvent.click(screen.getByRole('button', { name: 'Settings' })); - fireEvent.click( - screen.getByRole('button', { name: 'Switch to dark theme' }) - ); - fireEvent.click(screen.getByRole('button', { name: 'Close Settings' })); - fireEvent.click(screen.getByRole('button', { name: 'Reload runtime' })); - - await waitFor(() => - expect(screen.getByTitle('LangGraph Streaming live example')).not.toBe( - firstFrame - ) - ); - expect( - screen - .getByRole('button', { name: 'Run, runtime starting' }) - .getAttribute('aria-pressed') - ).toBe('true'); - expect(window.location.pathname).toBe(routeBefore); - expect( - screen - .getByRole('button', { name: 'Capability' }) - .getAttribute('aria-expanded') - ).toBe('false'); - expect(document.documentElement.dataset.theme).toBe('dark'); - openActivity(); - expect(screen.getByText('Runtime reload requested')).toBeTruthy(); - }); - - it('copies sanitized diagnostics from the current snapshot and at most 20 safe current events', async () => { - const writeText = vi.fn().mockResolvedValue(undefined); - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: { writeText }, - }); - renderShell('https://runtime.test/path?secret=hidden#fragment'); - await screen.findByTitle('LangGraph Streaming live example'); - - for (let index = 0; index < 22; index += 1) { - fireEvent.click( - screen.getByRole('button', { - name: index % 2 === 0 ? 'Code' : 'Run, runtime starting', - }) - ); - } - fireEvent.click( - screen.getByRole('button', { name: 'More runtime actions' }) - ); - fireEvent.click( - await screen.findByRole('menuitem', { name: 'Copy diagnostics' }) - ); - - await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1)); - const clipboardCall = writeText.mock.calls[0]; - if (!clipboardCall) throw new Error('Expected one clipboard write'); - const diagnostics = JSON.parse(clipboardCall[0]); - expect(diagnostics.runtime).toBe('https://runtime.test/path'); - expect(diagnostics.state).toBe('connecting'); - expect(diagnostics.recentEvents).toHaveLength(20); - expect(JSON.stringify(diagnostics)).not.toMatch( - /secret|nonce|cockpit_did|cockpit_phk|session_id|raw_error/i - ); - expect(operationalMocks.track).toHaveBeenCalledWith( - 'cockpit:runtime_action', - { - capability: 'streaming', - action: 'copy_diagnostics', - state_before: 'connecting', - outcome: 'succeeded', - } - ); - openActivity(); - expect(screen.getByText('Diagnostics copied')).toBeTruthy(); - }); - - it('records a failed diagnostics outcome locally and analytically without false success', async () => { - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) }, - }); - renderShell('https://runtime.test/path'); - await screen.findByTitle('LangGraph Streaming live example'); - - fireEvent.click( - screen.getByRole('button', { name: 'More runtime actions' }) - ); - fireEvent.click( - await screen.findByRole('menuitem', { name: 'Copy diagnostics' }) - ); - await waitFor(() => - expect(operationalMocks.track).toHaveBeenCalledWith( - 'cockpit:runtime_action', - { - capability: 'streaming', - action: 'copy_diagnostics', - state_before: 'connecting', - outcome: 'failed', - } - ) - ); - - openActivity(); - expect(screen.getByText('Diagnostics copy failed')).toBeTruthy(); - expect(screen.queryByText('Diagnostics copied')).toBeNull(); - }); - - it('tracks runtime commands from the shell with the captured state and no runtime details', async () => { - vi.spyOn(window, 'open').mockImplementation(() => { - throw new Error('popup blocked'); - }); - renderShell('https://runtime.test/path?secret=hidden'); - await screen.findByTitle('LangGraph Streaming live example'); - - fireEvent.click(screen.getByRole('button', { name: 'Reload runtime' })); - fireEvent.click(screen.getByRole('button', { name: 'Open runtime' })); - - expect(operationalMocks.track).toHaveBeenCalledWith( - 'cockpit:runtime_action', - { - capability: 'streaming', - action: 'reload', - state_before: 'connecting', - outcome: 'requested', - } - ); - expect(operationalMocks.track).toHaveBeenCalledWith( - 'cockpit:runtime_action', - { - capability: 'streaming', - action: 'open', - state_before: 'reloading', - outcome: 'failed', - } - ); - for (const [, properties] of operationalMocks.track.mock.calls) { - expect(Object.keys(properties)).not.toEqual( - expect.arrayContaining([ - 'url', - 'runtime_url', - 'nonce', - 'raw_error', - 'diagnostics_id', - 'session_id', - ]) - ); - } - }); - - it('tracks one semantic terminal transition while Activity remains controller-owned', async () => { - renderShell(); - await waitFor(() => - expect(operationalMocks.latestControllerOptions).not.toBeNull() - ); - const options = operationalMocks.latestControllerOptions; - if (options === null) - throw new Error('Expected committed controller options'); - - act(() => { - options.onActivity({ - id: 'recovered-event', - at: '2026-08-31T17:00:00.000Z', - kind: 'runtime_recovered', - capability: 'streaming', - }); - options.onTerminalTransition({ - capability: 'streaming', - fromState: 'unresponsive', - toState: 'ready', - transition: 'recovered', - elapsedMs: 25, - }); - }); - - expect(operationalMocks.track).toHaveBeenCalledTimes(1); - expect(operationalMocks.track).toHaveBeenCalledWith( - 'cockpit:runtime_status_changed', - { - capability: 'streaming', - from_state: 'unresponsive', - to_state: 'ready', - transition: 'recovered', - elapsed_ms: 25, - } - ); - openActivity(); - expect(screen.getAllByText('Runtime recovered')).toHaveLength(1); - }); - - it('contains an Activity render failure without replacing or remounting Run and restores focus on dismiss', async () => { - const consoleError = vi - .spyOn(console, 'error') - .mockImplementation(() => undefined); - renderShell('https://runtime.test/path'); - const frame = await screen.findByTitle('LangGraph Streaming live example'); - const activity = screen.getByRole('button', { name: 'Activity' }); - operationalMocks.activityShouldThrow = true; - - fireEvent.click(activity); - - expect( - screen.getByRole('heading', { name: 'Activity unavailable' }) - ).toBeTruthy(); - expect(screen.getByTitle('LangGraph Streaming live example')).toBe(frame); - expect(document.body.textContent).not.toContain( - 'sensitive activity render failure' - ); - fireEvent.click( - screen.getByRole('button', { name: 'Close Activity unavailable' }) - ); - expect(document.activeElement).toBe(activity); - expect(screen.getByTitle('LangGraph Streaming live example')).toBe(frame); - consoleError.mockRestore(); - }); -}); - -describe('CockpitShell documentation link', () => { - beforeEach(() => { - window.localStorage.clear(); - window.history.replaceState({}, '', '/'); - }); - - it('links a capability to its page on the docs site', () => { - renderShellFor([ - 'langgraph', - 'core-capabilities', - 'streaming', - 'overview', - 'python', - ]); - - const link = screen.getByRole('link', { name: /read docs/i }); - expect(link.getAttribute('href')).toBe( - 'https://threadplane.ai/docs/langgraph/guides/streaming' - ); - expect(link.getAttribute('target')).toBe('_blank'); - expect(link.getAttribute('rel')).toBe('noopener noreferrer'); - }); - - it('links a docs-only legacy entry to its published canonical page', () => { - renderShellFor([ - 'langgraph', - 'getting-started', - 'overview', - 'overview', - 'python', - ]); - - expect( - screen.getByRole('link', { name: /read docs/i }).getAttribute('href') - ).toBe( - 'https://threadplane.ai/docs/langgraph/getting-started/introduction' - ); - }); - - it('links a deep-agents capability at the deep-agents docs library', () => { - renderShellFor([ - 'deep-agents', - 'core-capabilities', - 'planning', - 'overview', - 'python', - ]); - - const link = screen.getByRole('link', { name: /read docs/i }); - expect(link.getAttribute('href')).toBe( - 'https://threadplane.ai/docs/deep-agents/capabilities/planning' - ); - }); - -}); diff --git a/apps/cockpit/src/components/cockpit-shell.tsx b/apps/cockpit/src/components/cockpit-shell.tsx deleted file mode 100644 index 103874e89..000000000 --- a/apps/cockpit/src/components/cockpit-shell.tsx +++ /dev/null @@ -1,315 +0,0 @@ -'use client'; - -import React, { useCallback, useEffect, useMemo, useRef } from 'react'; -import { - cockpitManifest, - type CockpitManifestEntry, - type WorkspaceMode, - type WorkspaceResolution, -} from '@threadplane/cockpit-registry'; -import { - toCockpitPath, - type ContentBundle, - type NavigationProduct, - type WorkspacePresentation, -} from '@threadplane/cockpit-shell'; -import { ThemeToggle } from '@threadplane/ui-react'; -import { - WorkspaceProvider, - WorkspaceShell, - resolveDocsUrl, - type RuntimeTerminalTransition, - type TrackModeChange, - type TrackNarrativeAction, - type TrackNavigation, - type TrackRuntimeAction, - type TrackRuntimeTransition, -} from '@threadplane/workspace-react'; -import { BookOpen } from 'lucide-react'; -import { useRouter } from 'next/navigation'; -import { track } from '../lib/analytics/client'; -import { getCockpitSessionId } from '../lib/analytics/distinct-id'; -import type { CockpitRuntimeStatusChangedProps } from '../lib/analytics/events'; - -export interface CockpitShellProps { - readonly navigationTree: NavigationProduct[]; - readonly resolution: WorkspaceResolution; - readonly presentation: WorkspacePresentation; - readonly contentBundle: ContentBundle; - readonly routePath: string; - readonly requestedMode: string | null; -} - -const MODE_ANALYTICS: Record = { - Run: 'run', - Code: 'code', - Docs: 'docs', - API: 'api', -}; - -const WORKSPACE_PANEL_FOCUS_INTENT = - 'threadplane:cockpit:workspace-panel-focus'; -const WORKSPACE_PANEL_FOCUS_MAX_AGE_MS = 10_000; -const RUNTIME_FRAME_TELEMETRY = { - posthogToken: process.env.NEXT_PUBLIC_COCKPIT_POSTHOG_TOKEN, - ingestHost: process.env.NEXT_PUBLIC_COCKPIT_INGEST_HOST, -}; - -function toRuntimeStatusChangedProps( - transition: RuntimeTerminalTransition -): CockpitRuntimeStatusChangedProps { - const common = { - capability: transition.capability, - ...(transition.elapsedMs !== undefined && - Number.isFinite(transition.elapsedMs) - ? { elapsed_ms: transition.elapsedMs } - : {}), - }; - - switch (transition.toState) { - case 'ready': - return transition.fromState === 'unresponsive' || - transition.fromState === 'error' - ? { - ...common, - from_state: transition.fromState, - to_state: 'ready', - transition: 'recovered', - } - : { ...common, from_state: transition.fromState, to_state: 'ready' }; - case 'unresponsive': - return { - ...common, - from_state: transition.fromState, - to_state: 'unresponsive', - }; - case 'error': - return { - ...common, - from_state: transition.fromState, - to_state: 'error', - ...(transition.reasonCode === 'bootstrap_failed' - ? { reason_code: transition.reasonCode } - : {}), - }; - case 'invalid_configuration': - return { - ...common, - from_state: transition.fromState, - to_state: 'invalid_configuration', - ...(transition.reasonCode === 'invalid_runtime_url' - ? { reason_code: transition.reasonCode } - : {}), - }; - } -} - -const trackNavigation: TrackNavigation = ({ - capability, - category, - fromCapability, -}) => { - track('cockpit:recipe_opened', { - capability, - category, - from_capability: fromCapability, - }); -}; - -const trackNarrativeAction: TrackNarrativeAction = ({ - capability, - surface, -}) => { - track('cockpit:code_copied', { capability, surface }); -}; - -const trackModeChange: TrackModeChange = ({ capability, fromMode, toMode }) => { - track('cockpit:mode_switched', { - capability, - from_mode: MODE_ANALYTICS[fromMode], - to_mode: MODE_ANALYTICS[toMode], - }); -}; - -const trackRuntimeAction: TrackRuntimeAction = (event) => { - switch (event.action) { - case 'recheck': - case 'reload': - track('cockpit:runtime_action', { - capability: event.capability, - action: event.action, - state_before: event.stateBefore, - outcome: event.outcome, - }); - break; - case 'open': - track('cockpit:runtime_action', { - capability: event.capability, - action: event.action, - state_before: event.stateBefore, - outcome: event.outcome, - }); - break; - case 'copy_diagnostics': - track('cockpit:runtime_action', { - capability: event.capability, - action: event.action, - state_before: event.stateBefore, - outcome: event.outcome, - }); - break; - } -}; - -const trackRuntimeTransition: TrackRuntimeTransition = (transition) => { - track( - 'cockpit:runtime_status_changed', - toRuntimeStatusChangedProps(transition) - ); -}; - -const modeHref = (mode: WorkspaceMode): string => { - const url = new URL(window.location.href); - url.searchParams.set('mode', mode.toLowerCase()); - return `${url.pathname}${url.search}${url.hash}`; -}; - -export function CockpitShell({ - navigationTree, - resolution, - presentation, - contentBundle, - routePath, - requestedMode, -}: CockpitShellProps) { - const router = useRouter(); - const routerRef = useRef(router); - routerRef.current = router; - - const pushIdentity = useCallback( - ( - href: string, - options?: { - restoreFocus?: 'mobile-navigation-trigger' | 'workspace-panel'; - } - ) => { - if (options?.restoreFocus === 'workspace-panel') { - const currentDestination = `${window.location.pathname}${window.location.search}${window.location.hash}`; - if (href !== currentDestination) { - try { - window.sessionStorage.setItem( - WORKSPACE_PANEL_FOCUS_INTENT, - JSON.stringify({ destination: href, requestedAt: Date.now() }) - ); - } catch { - // Client navigation still works if session storage is unavailable. - } - } - } - routerRef.current.push(href); - }, - [] - ); - const pushMode = useCallback((mode: WorkspaceMode) => { - routerRef.current.push(modeHref(mode)); - }, []); - const replaceMode = useCallback((mode: WorkspaceMode) => { - routerRef.current.replace(modeHref(mode)); - }, []); - const resolveIdentityHref = useCallback( - (entry: CockpitManifestEntry) => toCockpitPath(entry), - [] - ); - - useEffect(() => { - let rawIntent: string | null = null; - try { - rawIntent = window.sessionStorage.getItem(WORKSPACE_PANEL_FOCUS_INTENT); - } catch { - return undefined; - } - if (!rawIntent) return undefined; - - let intent: { destination?: unknown; requestedAt?: unknown }; - try { - intent = JSON.parse(rawIntent) as typeof intent; - } catch { - window.sessionStorage.removeItem(WORKSPACE_PANEL_FOCUS_INTENT); - return undefined; - } - const currentDestination = `${window.location.pathname}${window.location.search}${window.location.hash}`; - const isFresh = - typeof intent.requestedAt === 'number' && - Date.now() - intent.requestedAt <= WORKSPACE_PANEL_FOCUS_MAX_AGE_MS; - if (!isFresh) { - window.sessionStorage.removeItem(WORKSPACE_PANEL_FOCUS_INTENT); - return undefined; - } - if (intent.destination !== currentDestination) return undefined; - - window.sessionStorage.removeItem(WORKSPACE_PANEL_FOCUS_INTENT); - const focusPanel = () => { - const panel = document.querySelector( - '[data-workspace-panel-target]:not([aria-hidden="true"])' - ); - if (!panel?.closest('[inert]')) panel?.focus(); - }; - if (typeof window.requestAnimationFrame === 'function') { - const frame = window.requestAnimationFrame(focusPanel); - return () => window.cancelAnimationFrame(frame); - } - const timer = window.setTimeout(focusPanel, 0); - return () => window.clearTimeout(timer); - }, [routePath]); - - const docsUrl = resolveDocsUrl(presentation.docsPath); - const headerActions = useMemo( - () => - docsUrl ? ( - - - ) : null, - [docsUrl] - ); - - return ( - - } - headerActions={headerActions} - ariaLabel="Cockpit shell" - modeNavigationLabel="Cockpit modes" - contextPaneLabel="Cockpit context" - mobileDialogLabel="Cockpit control plane" - mobileTitle="Cockpit" - /> - - ); -} diff --git a/apps/cockpit/src/components/pane-rendering.spec.tsx b/apps/cockpit/src/components/pane-rendering.spec.tsx deleted file mode 100644 index 450a831f7..000000000 --- a/apps/cockpit/src/components/pane-rendering.spec.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { describe, expect, it } from 'vitest'; -import { CodeMode, CodePane } from '@threadplane/workspace-react'; -import { CockpitShell } from './cockpit-shell'; - -import { getCockpitPageModel } from '../lib/cockpit-page'; - -describe('metadata-driven panes', () => { - it('renders code pane from metadata values', () => { - const html = renderToStaticMarkup( -
- -
- ); - - expect(html).toContain('cockpit/langgraph/streaming/python/src/index.ts'); - }); -}); - -describe('cockpit shell contract', () => { - it('renders a simplified shell with sidebar, run/code modes, and compact header', () => { - const model = getCockpitPageModel(); - const html = renderToStaticMarkup( - - ); - - expect(html).toContain('Cockpit'); - expect(html).toContain('Deep Agents'); - expect(html).toContain('LangGraph'); - expect(html).toContain('Run'); - expect(html).toContain('Code'); - expect(html).toContain('Docs'); - expect(html).not.toContain('Explore the example surface'); - expect(html).not.toContain('Run example'); - expect(html).not.toContain('Open prompt assets'); - expect(html).not.toContain('aria-label="Prompt drawer"'); - }); -}); - -describe('refreshed shell structure', () => { - it('renders the key structure for the full-height shell and file tabs', () => { - const model = getCockpitPageModel(); - const html = renderToStaticMarkup( -
- - -
- ); - - expect(html).toContain('aria-label="Cockpit shell"'); - expect(html).toContain('aria-label="Cockpit modes"'); - expect(html).toContain('aria-label="Cockpit context"'); - expect(html).toContain('aria-label="Code mode"'); - expect(html).toContain('page.tsx'); - expect(html).toContain('index.ts'); - }); -}); diff --git a/apps/cockpit/src/lib/analytics/client.spec.ts b/apps/cockpit/src/lib/analytics/client.spec.ts deleted file mode 100644 index 6061628a6..000000000 --- a/apps/cockpit/src/lib/analytics/client.spec.ts +++ /dev/null @@ -1,153 +0,0 @@ -// SPDX-License-Identifier: MIT -import { describe, test, expect, beforeEach, expectTypeOf, vi } from 'vitest'; -import { track } from './client'; -import type { - CockpitRuntimeActionProps, - CockpitRuntimeStatusChangedProps, -} from './events'; -import type { RuntimePhase } from '../runtime/runtime-state'; - -const mocks = vi.hoisted(() => ({ capture: vi.fn(), __loaded: true })); - -vi.mock('posthog-js', () => ({ - default: { - capture: mocks.capture, - get __loaded() { - return mocks.__loaded; - }, - }, -})); - -describe('track', () => { - beforeEach(() => { - mocks.capture.mockClear(); - mocks.__loaded = true; - }); - - test('fires posthog.capture when loaded', () => { - track('cockpit:recipe_opened', { capability: 'streaming' }); - expect(mocks.capture).toHaveBeenCalledWith('cockpit:recipe_opened', { - capability: 'streaming', - }); - }); - - test('no-ops when posthog not loaded', () => { - mocks.__loaded = false; - track('cockpit:mode_switched', { - capability: 'x', - from_mode: 'run', - to_mode: 'code', - }); - expect(mocks.capture).not.toHaveBeenCalled(); - }); - - test('passes empty properties when not provided', () => { - track('cockpit:code_copied'); - expect(mocks.capture).toHaveBeenCalledWith('cockpit:code_copied', {}); - }); - - test('accepts only the allowlisted operational analytics vocabulary', () => { - expectTypeOf().toEqualTypeOf< - 'recheck' | 'reload' | 'open' | 'copy_diagnostics' - >(); - expectTypeOf< - CockpitRuntimeActionProps['state_before'] - >().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf< - 'requested' | 'succeeded' | 'failed' - >(); - expectTypeOf< - CockpitRuntimeStatusChangedProps['reason_code'] - >().toEqualTypeOf<'bootstrap_failed' | 'invalid_runtime_url' | undefined>(); - - const action: CockpitRuntimeActionProps = { - capability: 'streaming', - action: 'copy_diagnostics', - state_before: 'ready', - outcome: 'succeeded', - }; - const status: CockpitRuntimeStatusChangedProps = { - capability: 'streaming', - from_state: 'unresponsive', - to_state: 'ready', - transition: 'recovered', - elapsed_ms: 25, - }; - - track('cockpit:runtime_action', action); - track('cockpit:runtime_status_changed', status); - - expect(mocks.capture).toHaveBeenNthCalledWith( - 1, - 'cockpit:runtime_action', - action - ); - expect(mocks.capture).toHaveBeenNthCalledWith( - 2, - 'cockpit:runtime_status_changed', - status - ); - const propertyKeys = mocks.capture.mock.calls.flatMap(([, properties]) => - Object.keys(properties) - ); - expect(propertyKeys).not.toEqual( - expect.arrayContaining([ - 'url', - 'runtime_url', - 'nonce', - 'raw_error', - 'diagnostics_id', - 'session_id', - ]) - ); - }); - - test('correlates operational events with their exact property bags', () => { - track('cockpit:recipe_opened'); - track('cockpit:mode_switched', { - capability: 'streaming', - from_mode: 'run', - to_mode: 'code', - }); - - const compileInvalidOperationalCalls = () => { - // @ts-expect-error runtime actions require their property bag - track('cockpit:runtime_action'); - // @ts-expect-error reload cannot report clipboard success - track('cockpit:runtime_action', { - capability: 'streaming', - action: 'reload', - state_before: 'ready', - outcome: 'succeeded', - }); - // @ts-expect-error status events cannot use action properties - track('cockpit:runtime_status_changed', { - capability: 'streaming', - action: 'recheck', - state_before: 'ready', - outcome: 'requested', - }); - // @ts-expect-error recovery is only a terminal failure to ready - track('cockpit:runtime_status_changed', { - capability: 'streaming', - from_state: 'ready', - to_state: 'error', - transition: 'recovered', - }); - // @ts-expect-error terminal recovery origins require recovered transition - track('cockpit:runtime_status_changed', { - capability: 'streaming', - from_state: 'unresponsive', - to_state: 'ready', - }); - // @ts-expect-error invalid URL reasons belong only to invalid configuration - track('cockpit:runtime_status_changed', { - capability: 'streaming', - from_state: 'checking', - to_state: 'error', - reason_code: 'invalid_runtime_url', - }); - }; - expectTypeOf(compileInvalidOperationalCalls).toBeFunction(); - }); -}); diff --git a/apps/cockpit/src/lib/analytics/client.ts b/apps/cockpit/src/lib/analytics/client.ts deleted file mode 100644 index f2d787fad..000000000 --- a/apps/cockpit/src/lib/analytics/client.ts +++ /dev/null @@ -1,38 +0,0 @@ -// SPDX-License-Identifier: MIT -import posthog from 'posthog-js'; -import type { - CockpitLegacyEvent, - CockpitNavigationProps, - CockpitRuntimeActionProps, - CockpitRuntimeStatusChangedProps, - CockpitShellEvent, - CockpitShellProps, -} from './events'; - -export function track( - event: CockpitLegacyEvent, - props?: CockpitNavigationProps -): void; -export function track( - event: 'cockpit:runtime_action', - props: CockpitRuntimeActionProps -): void; -export function track( - event: 'cockpit:runtime_status_changed', - props: CockpitRuntimeStatusChangedProps -): void; -export function track( - event: CockpitShellEvent, - props: CockpitShellProps = {} -): void { - try { - if ( - typeof window !== 'undefined' && - (posthog as unknown as { __loaded?: boolean }).__loaded - ) { - posthog.capture(event, props); - } - } catch { - // silent fail - } -} diff --git a/apps/cockpit/src/lib/analytics/distinct-id.spec.ts b/apps/cockpit/src/lib/analytics/distinct-id.spec.ts deleted file mode 100644 index 55cf2c5f1..000000000 --- a/apps/cockpit/src/lib/analytics/distinct-id.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -// SPDX-License-Identifier: MIT -import { describe, test, expect, beforeEach } from 'vitest'; -import { getCockpitSessionId, _resetCockpitSessionIdForTesting } from './distinct-id'; - -describe('getCockpitSessionId', () => { - beforeEach(() => _resetCockpitSessionIdForTesting()); - - test('returns stable id within process', () => { - expect(getCockpitSessionId()).toBe(getCockpitSessionId()); - }); - - test('id has cockpit_ prefix + uuid shape', () => { - expect(getCockpitSessionId()).toMatch(/^cockpit_[0-9a-f-]{36}$/); - }); -}); diff --git a/apps/cockpit/src/lib/analytics/distinct-id.ts b/apps/cockpit/src/lib/analytics/distinct-id.ts deleted file mode 100644 index 70516e477..000000000 --- a/apps/cockpit/src/lib/analytics/distinct-id.ts +++ /dev/null @@ -1,12 +0,0 @@ -// SPDX-License-Identifier: MIT -let cached: string | null = null; - -export function getCockpitSessionId(): string { - if (!cached) cached = `cockpit_${crypto.randomUUID()}`; - return cached; -} - -// @internal — for tests only -export function _resetCockpitSessionIdForTesting(): void { - cached = null; -} diff --git a/apps/cockpit/src/lib/analytics/events.ts b/apps/cockpit/src/lib/analytics/events.ts deleted file mode 100644 index 2fa99e8e6..000000000 --- a/apps/cockpit/src/lib/analytics/events.ts +++ /dev/null @@ -1,104 +0,0 @@ -// SPDX-License-Identifier: MIT -import type { - RuntimePhase, - RuntimeTerminalPhase, -} from '@threadplane/workspace-react'; - -export type CockpitShellEvent = - | 'cockpit:recipe_opened' - | 'cockpit:mode_switched' - | 'cockpit:code_copied' - | 'cockpit:runtime_action' - | 'cockpit:runtime_status_changed'; - -export type CockpitLegacyEvent = - | 'cockpit:recipe_opened' - | 'cockpit:mode_switched' - | 'cockpit:code_copied'; - -export interface CockpitNavigationProps { - capability?: string; - category?: string; - from_capability?: string; - from_mode?: 'run' | 'code' | 'docs' | 'api'; - to_mode?: 'run' | 'code' | 'docs' | 'api'; - surface?: 'code_mode' | 'docs_code_snippet' | 'agentic_prompt'; - file_path?: string; -} - -interface CockpitRuntimeActionBase { - capability: string; - state_before: RuntimePhase; -} - -export type CockpitRuntimeActionProps = CockpitRuntimeActionBase & - ( - | { action: 'recheck' | 'reload'; outcome: 'requested' } - | { action: 'open'; outcome: 'requested' | 'failed' } - | { - action: 'copy_diagnostics'; - outcome: 'succeeded' | 'failed'; - } - ); - -interface CockpitRuntimeStatusChangedBase { - capability: string; - elapsed_ms?: number; -} - -export type CockpitRuntimeStatusChangedProps = CockpitRuntimeStatusChangedBase & - ( - | { - from_state: 'unresponsive' | 'error'; - to_state: 'ready'; - transition: 'recovered'; - reason_code?: never; - } - | { - from_state: Exclude; - to_state: 'ready'; - transition?: never; - reason_code?: never; - } - | { - from_state: RuntimePhase; - to_state: 'unresponsive'; - transition?: never; - reason_code?: never; - } - | { - from_state: RuntimePhase; - to_state: 'error'; - transition?: never; - reason_code?: 'bootstrap_failed'; - } - | { - from_state: RuntimePhase; - to_state: 'invalid_configuration'; - transition?: never; - reason_code?: 'invalid_runtime_url'; - } - ); - -export interface CockpitShellEventPropsMap { - 'cockpit:recipe_opened': CockpitNavigationProps; - 'cockpit:mode_switched': CockpitNavigationProps; - 'cockpit:code_copied': CockpitNavigationProps; - 'cockpit:runtime_action': CockpitRuntimeActionProps; - 'cockpit:runtime_status_changed': CockpitRuntimeStatusChangedProps; -} - -interface CockpitRuntimeStatusPropertyBag { - from_state: RuntimePhase; - to_state: RuntimeTerminalPhase; - transition?: 'recovered'; - elapsed_ms?: number; - reason_code?: 'bootstrap_failed' | 'invalid_runtime_url'; -} - -/** - * Backwards-compatible client property bag. Operational call sites use the - * required, event-specific interfaces above before passing them to `track`. - */ -export type CockpitShellProps = CockpitNavigationProps & - Partial; diff --git a/apps/cockpit/src/lib/cockpit-page.spec.ts b/apps/cockpit/src/lib/cockpit-page.spec.ts index 9f9e41741..dcf21c361 100644 --- a/apps/cockpit/src/lib/cockpit-page.spec.ts +++ b/apps/cockpit/src/lib/cockpit-page.spec.ts @@ -1,194 +1,320 @@ +import { + cockpitManifest, + getWorkspaceDestinationPath, + type WorkspaceMode, +} from '@threadplane/cockpit-registry'; import { describe, expect, it } from 'vitest'; import { - getCanonicalCockpitRedirect, - getCockpitPageModel, + getCockpitWebsiteOrigin, getLegacyWebsiteRedirect, getRootWebsiteRedirect, - getUnifiedWorkspaceRedirectOrigin, - normalizeRequestedMode, } from './cockpit-page'; -import { cockpitManifest } from '@threadplane/cockpit-registry'; -import { getWorkspaceDestinationPath } from '@threadplane/cockpit-registry'; -const enabledProductionEnv = { - UNIFIED_WORKSPACE_REDIRECTS_ENABLED: 'true', - NEXT_PUBLIC_WEBSITE_ORIGIN: 'https://threadplane.ai', +const productionEnvironment = { + COCKPIT_WEBSITE_ORIGIN: 'https://threadplane.ai', NODE_ENV: 'production', -}; - -describe('Cockpit page query normalization', () => { - it('keeps repeated mode params explicitly invalid for provider normalization', () => { - expect(normalizeRequestedMode(['code', 'docs'])).toBe('code,docs'); - expect(normalizeRequestedMode('code')).toBe('code'); - expect(normalizeRequestedMode(undefined)).toBeNull(); - }); +} as const; - it('preserves only a syntactically valid mode available on the canonical entry', () => { - const model = getCockpitPageModel([ - 'langgraph', - 'core-capabilities', - 'streaming', - 'overview', - 'python', - ]); - expect(getCanonicalCockpitRedirect(model, 'code')).toBe( - `${model.canonicalPath}?mode=code` - ); - expect(getCanonicalCockpitRedirect(model, 'preview')).toBe( - model.canonicalPath - ); - expect(getCanonicalCockpitRedirect(model, ['code', 'docs'])).toBe( - model.canonicalPath - ); +const expectedHref = ( + entry: (typeof cockpitManifest)[number], + mode: WorkspaceMode +): string => { + const path = getWorkspaceDestinationPath(entry); + const query = + mode === 'Docs' && path.startsWith('/docs') + ? '' + : `?mode=${mode.toLowerCase()}`; + return `https://threadplane.ai${path}${query}`; +}; - const docsOnly = getCockpitPageModel([ - 'langgraph', - 'getting-started', - 'overview', - 'overview', - 'python', - ]); - expect(getCanonicalCockpitRedirect(docsOnly, 'run')).toBe( - docsOnly.canonicalPath +describe('Cockpit Website origin validation', () => { + it('accepts only the canonical Website HTTPS origin in production', () => { + expect(getCockpitWebsiteOrigin(productionEnvironment)).toBe( + 'https://threadplane.ai' ); - }); -}); - -describe('unified Website redirect gate', () => { - it('is disabled unless the explicit flag and a valid origin are both present', () => { expect( - getUnifiedWorkspaceRedirectOrigin({ - NEXT_PUBLIC_WEBSITE_ORIGIN: 'https://threadplane.ai', + getCockpitWebsiteOrigin({ + COCKPIT_WEBSITE_ORIGIN: 'https://threadplane.ai/', NODE_ENV: 'production', }) - ).toBeNull(); + ).toBe('https://threadplane.ai'); + }); + + it('accepts explicit HTTP localhost only in development', () => { expect( - getUnifiedWorkspaceRedirectOrigin({ - ...enabledProductionEnv, - NEXT_PUBLIC_WEBSITE_ORIGIN: 'http://threadplane.ai', + getCockpitWebsiteOrigin({ + COCKPIT_WEBSITE_ORIGIN: 'http://localhost/', + NODE_ENV: 'development', }) - ).toBeNull(); + ).toBe('http://localhost'); expect( - getUnifiedWorkspaceRedirectOrigin({ - ...enabledProductionEnv, - NEXT_PUBLIC_WEBSITE_ORIGIN: 'https://threadplane.ai/docs', + getCockpitWebsiteOrigin({ + COCKPIT_WEBSITE_ORIGIN: 'http://localhost:4200/', + NODE_ENV: 'development', }) - ).toBeNull(); - expect(getUnifiedWorkspaceRedirectOrigin(enabledProductionEnv)).toBe( - 'https://threadplane.ai' - ); + ).toBe('http://localhost:4200'); }); - it('allows HTTP localhost only in development', () => { - const localhost = { - UNIFIED_WORKSPACE_REDIRECTS_ENABLED: 'true', - NEXT_PUBLIC_WEBSITE_ORIGIN: 'http://localhost:3000', - }; - expect( - getUnifiedWorkspaceRedirectOrigin({ - ...localhost, + it.each([ + [{ NODE_ENV: 'production' }, 'missing'], + [ + { COCKPIT_WEBSITE_ORIGIN: 'not a URL', NODE_ENV: 'production' }, + 'invalid', + ], + [ + { + COCKPIT_WEBSITE_ORIGIN: 'https://preview.threadplane.ai', + NODE_ENV: 'production', + }, + 'production preview origin', + ], + [ + { + COCKPIT_WEBSITE_ORIGIN: 'https://threadplane.ai:8443', + NODE_ENV: 'production', + }, + 'production non-default port', + ], + [ + { + COCKPIT_WEBSITE_ORIGIN: 'https://preview.threadplane.ai', NODE_ENV: 'development', - }) - ).toBe('http://localhost:3000'); - expect( - getUnifiedWorkspaceRedirectOrigin({ - ...localhost, + }, + 'development preview HTTPS origin', + ], + [ + { + COCKPIT_WEBSITE_ORIGIN: 'https://user:secret@threadplane.ai', NODE_ENV: 'production', - }) - ).toBeNull(); + }, + 'credentials', + ], + [ + { + COCKPIT_WEBSITE_ORIGIN: 'https://threadplane.ai/docs', + NODE_ENV: 'production', + }, + 'non-root path', + ], + [ + { + COCKPIT_WEBSITE_ORIGIN: 'https://threadplane.ai?next=/docs', + NODE_ENV: 'production', + }, + 'query', + ], + [ + { + COCKPIT_WEBSITE_ORIGIN: 'https://threadplane.ai#fragment', + NODE_ENV: 'production', + }, + 'fragment', + ], + [ + { + COCKPIT_WEBSITE_ORIGIN: 'https://threadplane.ai?', + NODE_ENV: 'production', + }, + 'empty query delimiter', + ], + [ + { + COCKPIT_WEBSITE_ORIGIN: 'https://threadplane.ai#', + NODE_ENV: 'production', + }, + 'empty fragment delimiter', + ], + [ + { + COCKPIT_WEBSITE_ORIGIN: ' https://threadplane.ai', + NODE_ENV: 'production', + }, + 'leading whitespace', + ], + [ + { + COCKPIT_WEBSITE_ORIGIN: 'https://threadplane.ai\n', + NODE_ENV: 'production', + }, + 'trailing whitespace', + ], + [ + { + COCKPIT_WEBSITE_ORIGIN: 'https://threadplane.ai/%2e', + NODE_ENV: 'production', + }, + 'encoded dot path', + ], + [ + { + COCKPIT_WEBSITE_ORIGIN: 'https://threadplane.ai/a/..', + NODE_ENV: 'production', + }, + 'normalized dot path', + ], + [ + { + COCKPIT_WEBSITE_ORIGIN: 'HTTPS://THREADPLANE.AI', + NODE_ENV: 'production', + }, + 'case-normalized origin', + ], + [ + { + COCKPIT_WEBSITE_ORIGIN: 'https://threadplane.ai:443', + NODE_ENV: 'production', + }, + 'normalized default port', + ], + [ + { + COCKPIT_WEBSITE_ORIGIN: 'http://threadplane.ai', + NODE_ENV: 'production', + }, + 'production HTTP', + ], + [ + { + COCKPIT_WEBSITE_ORIGIN: 'http://127.0.0.1:4200', + NODE_ENV: 'development', + }, + 'non-localhost development HTTP', + ], + [ + { + COCKPIT_WEBSITE_ORIGIN: 'http://localhost.evil.test:4200', + NODE_ENV: 'development', + }, + 'lookalike localhost', + ], + [ + { + COCKPIT_WEBSITE_ORIGIN: 'javascript:alert(1)', + NODE_ENV: 'development', + }, + 'unsafe protocol', + ], + [ + { + COCKPIT_WEBSITE_ORIGIN: 'file:///tmp/threadplane', + NODE_ENV: 'development', + }, + 'file protocol', + ], + ] as const)('rejects %s origin configuration', (environment) => { + expect(() => getCockpitWebsiteOrigin(environment)).toThrow( + /COCKPIT_WEBSITE_ORIGIN/ + ); }); }); describe('registry-derived legacy Website redirects', () => { - it('maps every legacy path to its registry-owned Website destination', () => { + it('maps every exact manifest path using its old Cockpit default mode', () => { for (const entry of cockpitManifest) { + const defaultMode = entry.availableModes.includes('Run') ? 'Run' : 'Docs'; expect( - getLegacyWebsiteRedirect( - entry.legacyPath, - undefined, - enabledProductionEnv - ) - ).toBe( - `https://threadplane.ai${getWorkspaceDestinationPath(entry)}` - ); + getLegacyWebsiteRedirect(entry.legacyPath, [], productionEnvironment), + entry.id + ).toBe(expectedHref(entry, defaultMode)); } }); - it('preserves only a single valid mode available at the destination', () => { - const streaming = cockpitManifest.find( - (entry) => - entry.id === 'langgraph:core-capabilities:streaming:overview:python' + it('honors every single available mode case-insensitively', () => { + for (const entry of cockpitManifest) { + for (const mode of entry.availableModes) { + expect( + getLegacyWebsiteRedirect( + entry.legacyPath, + [mode.toUpperCase()], + productionEnvironment + ), + `${entry.id} ${mode}` + ).toBe(expectedHref(entry, mode)); + } + } + }); + + it('uses the old default for invalid, duplicate, or unavailable modes', () => { + const runnable = cockpitManifest.find((entry) => + entry.availableModes.includes('Run') ); - const overview = cockpitManifest.find( - (entry) => - entry.id === 'langgraph:getting-started:overview:overview:python' + const docsOnly = cockpitManifest.find( + (entry) => !entry.availableModes.includes('Run') ); - if (!streaming || !overview) throw new Error('Expected fixture entries'); + if (!runnable || !docsOnly) throw new Error('Expected manifest fixtures'); expect( getLegacyWebsiteRedirect( - streaming.legacyPath, - 'code', - enabledProductionEnv + runnable.legacyPath, + ['preview'], + productionEnvironment ) - ).toBe('https://threadplane.ai/docs/langgraph/guides/streaming?mode=code'); + ).toBe(expectedHref(runnable, 'Run')); expect( getLegacyWebsiteRedirect( - streaming.legacyPath, - ['code', 'run'], - enabledProductionEnv + runnable.legacyPath, + ['docs', 'code'], + productionEnvironment ) - ).toBe('https://threadplane.ai/docs/langgraph/guides/streaming'); - expect( - getLegacyWebsiteRedirect(overview.legacyPath, 'run', enabledProductionEnv) - ).toBe( - 'https://threadplane.ai/docs/langgraph/getting-started/introduction' - ); + ).toBe(expectedHref(runnable, 'Run')); expect( getLegacyWebsiteRedirect( - streaming.legacyPath, - 'preview', - enabledProductionEnv + docsOnly.legacyPath, + ['run'], + productionEnvironment ) - ).toBe('https://threadplane.ai/docs/langgraph/guides/streaming'); + ).toBe(expectedHref(docsOnly, 'Docs')); }); - it('preserves secondary capability identity and its available modes', () => { - const jsonRender = cockpitManifest.find( - (entry) => - entry.id === 'ag-ui:core-capabilities:json-render:overview:python' + it('serializes Docs mode truthfully for docs and workspace destinations', () => { + const docsDestination = cockpitManifest.find((entry) => + getWorkspaceDestinationPath(entry).startsWith('/docs/') + ); + const workspaceDestination = cockpitManifest.find((entry) => + getWorkspaceDestinationPath(entry).startsWith('/workspace/') ); - if (!jsonRender) throw new Error('Expected AG-UI JSON Render fixture'); + if (!docsDestination || !workspaceDestination) { + throw new Error('Expected docs and workspace fixtures'); + } - expect(jsonRender.availableModes).toContain('Run'); expect( getLegacyWebsiteRedirect( - jsonRender.legacyPath, - 'run', - enabledProductionEnv + docsDestination.legacyPath, + ['docs'], + productionEnvironment ) - ).toBe('https://threadplane.ai/workspace/ag-ui/json-render?mode=run'); + ).toBe(expectedHref(docsDestination, 'Docs')); expect( getLegacyWebsiteRedirect( - jsonRender.legacyPath, - 'docs', - enabledProductionEnv + workspaceDestination.legacyPath, + ['docs'], + productionEnvironment ) - ).toBe('https://threadplane.ai/workspace/ag-ui/json-render?mode=docs'); + ).toBe(expectedHref(workspaceDestination, 'Docs')); + expect(expectedHref(workspaceDestination, 'Docs')).toContain('?mode=docs'); }); - it('does not redirect invalid or unmapped legacy paths', () => { - expect( - getLegacyWebsiteRedirect( - '/langgraph/core-capabilities/not-real/overview/python', - 'run', - enabledProductionEnv - ) - ).toBeNull(); + it('returns null for unknown, partial, extra, malformed, and trailing paths', () => { + const exact = cockpitManifest[0].legacyPath; + const partial = exact.split('/').slice(0, -1).join('/'); + + for (const pathname of [ + '/not-a-capability', + partial, + `${exact}/extra`, + `${exact}/`, + exact.replace('/', '//'), + exact.replace('/overview/', '/%2Foverview/'), + ]) { + expect( + getLegacyWebsiteRedirect(pathname, [], productionEnvironment), + pathname + ).toBeNull(); + } }); - it('redirects the Cockpit root through its default registry identity', () => { - expect(getRootWebsiteRedirect('run', enabledProductionEnv)).toBe( + it('redirects root to the representative streaming Run surface', () => { + expect(getRootWebsiteRedirect(productionEnvironment)).toBe( 'https://threadplane.ai/docs/langgraph/guides/streaming?mode=run' ); }); diff --git a/apps/cockpit/src/lib/cockpit-page.ts b/apps/cockpit/src/lib/cockpit-page.ts index d9b2b7dc6..8f820c1a3 100644 --- a/apps/cockpit/src/lib/cockpit-page.ts +++ b/apps/cockpit/src/lib/cockpit-page.ts @@ -1,174 +1,98 @@ import { - cockpitManifest, - getWorkspaceDestinationPath, + getCanonicalWebsiteWorkspaceHref, resolveLegacyPath, - toWorkspaceIdentity, - type CockpitProduct, - type CockpitSection, - type CockpitPageId, - type CockpitLanguage, - type WorkspaceMode, + resolveLegacyRequestMode, type WorkspaceResolution, } from '@threadplane/cockpit-registry'; -import { - buildNavigationTree, - getWorkspacePresentation, - resolveCockpitEntry, - toCockpitPath, - type NavigationProduct, - type WorkspacePresentation, -} from '@threadplane/cockpit-shell'; - -export { cockpitManifest }; - -export interface CockpitPageModel { - entry: ReturnType; - resolution: WorkspaceResolution; - presentation: WorkspacePresentation; - navigationTree: NavigationProduct[]; - canonicalPath: string; -} -const DEFAULT_COCKPIT_SLUG = [ - 'langgraph', - 'core-capabilities', - 'streaming', - 'overview', - 'python', -] as const; +const ROOT_STREAMING_LEGACY_PATH = + '/langgraph/core-capabilities/streaming/overview/python'; -const QUERY_MODES: Record = { - docs: 'Docs', - run: 'Run', - code: 'Code', - api: 'API', -}; - -export interface UnifiedWorkspaceRedirectEnvironment { - readonly UNIFIED_WORKSPACE_REDIRECTS_ENABLED?: string; - readonly NEXT_PUBLIC_WEBSITE_ORIGIN?: string; +export interface CockpitRedirectEnvironment { + readonly COCKPIT_WEBSITE_ORIGIN?: string; readonly NODE_ENV?: string; } -export function getUnifiedWorkspaceRedirectOrigin( - environment: UnifiedWorkspaceRedirectEnvironment = process.env -): string | null { - if (environment.UNIFIED_WORKSPACE_REDIRECTS_ENABLED !== 'true') return null; - const rawOrigin = environment.NEXT_PUBLIC_WEBSITE_ORIGIN; - if (!rawOrigin) return null; +export function getCockpitWebsiteOrigin( + environment: CockpitRedirectEnvironment = process.env +): string { + const rawOrigin = environment.COCKPIT_WEBSITE_ORIGIN; - try { - const url = new URL(rawOrigin); - if ( - url.username || - url.password || - url.pathname !== '/' || - url.search || - url.hash - ) { - return null; - } + if (!rawOrigin) { + throw new Error('COCKPIT_WEBSITE_ORIGIN must be configured'); + } - const secure = url.protocol === 'https:'; - const developmentLocalhost = - environment.NODE_ENV === 'development' && - url.protocol === 'http:' && - url.hostname === 'localhost'; - return secure || developmentLocalhost ? url.origin : null; + let url: URL; + try { + url = new URL(rawOrigin); } catch { - return null; + throw new Error('COCKPIT_WEBSITE_ORIGIN must be a valid absolute origin'); + } + + const hasCanonicalOriginForm = + rawOrigin === url.origin || rawOrigin === `${url.origin}/`; + const hasOnlyOrigin = + !url.username && + !url.password && + url.pathname === '/' && + !url.search && + !url.hash; + const canonicalWebsiteOrigin = url.origin === 'https://threadplane.ai'; + const developmentLocalhost = + environment.NODE_ENV === 'development' && + url.protocol === 'http:' && + url.hostname === 'localhost'; + + if ( + !hasCanonicalOriginForm || + !hasOnlyOrigin || + (!canonicalWebsiteOrigin && !developmentLocalhost) + ) { + throw new Error( + 'COCKPIT_WEBSITE_ORIGIN must be https://threadplane.ai, or HTTP localhost in development' + ); } + + return url.origin; } -const appendAvailableMode = ( - destinationPath: string, - mode: string | string[] | undefined, - availableModes: readonly WorkspaceMode[] -): string => { - if (typeof mode !== 'string') return destinationPath; - const parsed = QUERY_MODES[mode.toLowerCase()]; - if (!parsed || !availableModes.includes(parsed)) return destinationPath; - return `${destinationPath}?mode=${parsed.toLowerCase()}`; +const normalizeRequestedMode = ( + modeValues: readonly string[] +): string | string[] | undefined => { + if (modeValues.length === 0) return undefined; + return modeValues.length === 1 ? modeValues[0] : [...modeValues]; }; const toWebsiteRedirect = ( - origin: string, resolution: WorkspaceResolution, - mode: string | string[] | undefined -): string | null => { - if (resolution.kind !== 'mapped') return null; - const destinationPath = getWorkspaceDestinationPath(resolution.identity); - return new URL( - appendAvailableMode( - destinationPath, - mode, - resolution.identity.availableModes - ), - `${origin}/` - ).toString(); + modeValues: readonly string[], + environment: CockpitRedirectEnvironment +): string => { + const mode = resolveLegacyRequestMode( + normalizeRequestedMode(modeValues), + resolution + ); + const href = getCanonicalWebsiteWorkspaceHref(resolution, mode); + return new URL(href, `${getCockpitWebsiteOrigin(environment)}/`).toString(); }; export function getLegacyWebsiteRedirect( legacyPath: string, - mode: string | string[] | undefined, - environment: UnifiedWorkspaceRedirectEnvironment = process.env + modeValues: readonly string[], + environment: CockpitRedirectEnvironment = process.env ): string | null { - const origin = getUnifiedWorkspaceRedirectOrigin(environment); - if (!origin) return null; const resolution = resolveLegacyPath(legacyPath); - return resolution ? toWebsiteRedirect(origin, resolution, mode) : null; + return resolution + ? toWebsiteRedirect(resolution, modeValues, environment) + : null; } export function getRootWebsiteRedirect( - mode: string | string[] | undefined, - environment: UnifiedWorkspaceRedirectEnvironment = process.env -): string | null { - const origin = getUnifiedWorkspaceRedirectOrigin(environment); - if (!origin) return null; - return toWebsiteRedirect(origin, getCockpitPageModel().resolution, mode); -} - -export function normalizeRequestedMode( - mode: string | string[] | undefined -): string | null { - return Array.isArray(mode) ? mode.join(',') : mode ?? null; -} - -export function getCanonicalCockpitRedirect( - model: CockpitPageModel, - mode: string | string[] | undefined + environment: CockpitRedirectEnvironment = process.env ): string { - if (typeof mode !== 'string') return model.canonicalPath; - const parsed = QUERY_MODES[mode.toLowerCase()]; - if ( - !parsed || - model.resolution.kind !== 'mapped' || - !model.resolution.identity.availableModes.includes(parsed) - ) { - return model.canonicalPath; + const resolution = resolveLegacyPath(ROOT_STREAMING_LEGACY_PATH); + if (!resolution) { + throw new Error('The Cockpit root streaming capability is not registered'); } - return `${model.canonicalPath}?mode=${parsed.toLowerCase()}`; -} - -export function getCockpitPageModel(slug: string[] = []): CockpitPageModel { - const resolvedEntry = resolveCockpitEntry({ - manifest: cockpitManifest, - product: (slug[0] ?? DEFAULT_COCKPIT_SLUG[0]) as CockpitProduct, - section: (slug[1] ?? DEFAULT_COCKPIT_SLUG[1]) as CockpitSection, - topic: slug[2] ?? DEFAULT_COCKPIT_SLUG[2], - page: (slug[3] ?? DEFAULT_COCKPIT_SLUG[3]) as CockpitPageId, - language: (slug[4] ?? DEFAULT_COCKPIT_SLUG[4]) as CockpitLanguage, - }); - const resolution: WorkspaceResolution = { - kind: 'mapped', - identity: toWorkspaceIdentity(resolvedEntry), - }; - - return { - entry: resolvedEntry, - resolution, - presentation: getWorkspacePresentation(resolution), - navigationTree: buildNavigationTree(cockpitManifest), - canonicalPath: toCockpitPath(resolvedEntry), - }; + return toWebsiteRedirect(resolution, ['run'], environment); } diff --git a/apps/cockpit/src/lib/utils.ts b/apps/cockpit/src/lib/utils.ts deleted file mode 100644 index 3cf73c02f..000000000 --- a/apps/cockpit/src/lib/utils.ts +++ /dev/null @@ -1 +0,0 @@ -export { cn } from '@threadplane/ui-react'; diff --git a/apps/cockpit/test-setup.ts b/apps/cockpit/test-setup.ts deleted file mode 100644 index ef231d7c9..000000000 --- a/apps/cockpit/test-setup.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { vi } from 'vitest'; - -// jsdom doesn't implement CSS.escape; polyfill it for components that use -// CSS.escape() in event handlers (e.g. code-mode copy button). -if (typeof globalThis.CSS === 'undefined') { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (globalThis as any).CSS = {}; -} -if (typeof globalThis.CSS.escape !== 'function') { - globalThis.CSS.escape = (value: string): string => - String(value).replace(/[^a-zA-Z0-9_-]/g, (ch) => `\\${ch}`); -} - -// next/navigation's useRouter throws "invariant expected app router to be -// mounted" when rendered outside an AppRouterContext (e.g. via -// renderToStaticMarkup). Provide a no-op mock so components that call -// useRouter (e.g. in the sidebar) render in tests. -vi.mock('next/navigation', () => ({ - useRouter: () => ({ - refresh: () => undefined, - push: () => undefined, - replace: () => undefined, - back: () => undefined, - forward: () => undefined, - prefetch: () => undefined, - }), -})); diff --git a/apps/cockpit/tsconfig.json b/apps/cockpit/tsconfig.json index e36918031..de56f8ec4 100644 --- a/apps/cockpit/tsconfig.json +++ b/apps/cockpit/tsconfig.json @@ -12,34 +12,11 @@ "baseUrl": ".", "paths": { "@/*": ["./src/*"], - "@threadplane/design-tokens": ["../../libs/design-tokens/src/index.ts"], - "@threadplane/ui-react": ["../../libs/ui-react/src/index.ts"], "@threadplane/cockpit-registry": [ "../../libs/cockpit-registry/src/index.ts" - ], - "@threadplane/cockpit-shell": ["../../libs/cockpit-shell/src/index.ts"], - "@threadplane/workspace-react": [ - "../../libs/workspace-react/src/index.ts" - ], - "@threadplane/cockpit-runtime-bridge": [ - "../../libs/cockpit-runtime-bridge/src/index.ts" - ], - "@threadplane/telemetry/shared": [ - "../../libs/telemetry/src/shared/public-api.ts" - ], - "@threadplane/telemetry/browser": [ - "../../libs/telemetry/src/browser/public-api.ts" ] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"], - "references": [ - { - "path": "../../libs/design-tokens" - }, - { - "path": "../../libs/telemetry" - } - ] + "exclude": ["node_modules"] } diff --git a/apps/cockpit/vite.config.mts b/apps/cockpit/vite.config.mts index d91a2f2c5..21ba49539 100644 --- a/apps/cockpit/vite.config.mts +++ b/apps/cockpit/vite.config.mts @@ -13,7 +13,7 @@ export default defineConfig({ // with ERR_MODULE_NOT_FOUND on a `/@fs/...` path under `nx test cockpit`. server: { fs: { allow: [resolve(__dirname, '../..')] } }, test: { - environment: 'jsdom', + environment: 'node', globals: true, include: [ 'src/**/*.spec.ts', @@ -33,6 +33,5 @@ export default defineConfig({ // day it lands instead of joining the unrun pile. '../../cockpit/*/footprint.spec.ts', ], - setupFiles: ['./test-setup.ts'], }, }); diff --git a/apps/website/content/blog/2026-05-28-human-in-the-loop-langgraph-agents-in-angular.mdx b/apps/website/content/blog/2026-05-28-human-in-the-loop-langgraph-agents-in-angular.mdx index 613184e8a..8670f6186 100644 --- a/apps/website/content/blog/2026-05-28-human-in-the-loop-langgraph-agents-in-angular.mdx +++ b/apps/website/content/blog/2026-05-28-human-in-the-loop-langgraph-agents-in-angular.mdx @@ -11,13 +11,13 @@ This is how to pause a LangGraph agent in Angular for human approval before it r The example is a refund agent: it drafts a refund, then stops and asks an operator to approve, edit, or cancel before any charge is reversed. -Everything below is running code from the cockpit example at `cockpit/langgraph/interrupts`. +Everything below is running code from the workspace example at `cockpit/langgraph/interrupts`. Clone the repo, run `nx serve cockpit-langgraph-interrupts-angular`, and follow along.
- - The refund agent running in the cockpit. Walk the approve / edit / cancel flow yourself. + + The refund agent running in the docs workspace. Walk the approve / edit / cancel flow yourself. The exact graph.py and Angular component from this post. @@ -54,7 +54,7 @@ That lets you reuse one approval dialog across multiple agents.
The refund agent's welcome screen with two suggestion chips: 'Refund a duplicate charge' and 'Refund a chargeback.' -
The cockpit refund example.
+
The live refund workspace.
## Scaffold diff --git a/apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx b/apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx index 0d2dd6018..aee20a4cf 100644 --- a/apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx +++ b/apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx @@ -11,13 +11,13 @@ This is how to pause an AG-UI agent in Angular for human approval before it runs The example is the same refund agent from [Human-in-the-Loop LangGraph Agents in Angular](/blog/2026-05-28-human-in-the-loop-langgraph-agents-in-angular) — wired through the AG-UI adapter instead. The Angular component is byte-identical except the import. -Everything below is running code from the cockpit example at `cockpit/ag-ui/interrupts`. +Everything below is running code from the workspace example at `cockpit/ag-ui/interrupts`. Clone the repo, run `nx serve cockpit-ag-ui-interrupts-angular`, and follow along.
- - The refund agent running in the cockpit. Walk the approve / edit / cancel flow yourself. + + The refund agent running in the docs workspace. Walk the approve / edit / cancel flow yourself. The exact graph.py, server.py, and Angular component from this post. @@ -99,8 +99,8 @@ On the LangGraph adapter, the same `submit({ resume })` becomes a native `Client Different wire, same Angular surface.
- The cockpit ag-ui/interrupts welcome screen showing two suggestion chips: 'Refund a duplicate charge' and 'Refund a chargeback.' -
The cockpit refund example on the AG-UI adapter.
+ The Threadplane docs workspace showing the AG-UI interrupts welcome screen with two suggestion chips: 'Refund a duplicate charge' and 'Refund a chargeback.' +
The live refund workspace on the AG-UI adapter.
## Scaffold diff --git a/apps/website/content/docs/deep-agents/getting-started/introduction.mdx b/apps/website/content/docs/deep-agents/getting-started/introduction.mdx index 5ec0811f8..30dfb40ea 100644 --- a/apps/website/content/docs/deep-agents/getting-started/introduction.mdx +++ b/apps/website/content/docs/deep-agents/getting-started/introduction.mdx @@ -75,7 +75,7 @@ That last row is a real constraint of the framework as it stands, not an oversig ## The demos -Each capability page describes a standalone example that runs the real framework against a real model. The examples live under [`cockpit/deep-agents`](https://github.com/cacheplane/angular-agent-framework/tree/main/cockpit/deep-agents) and are hosted on the [Threadplane cockpit](https://cockpit.threadplane.ai), which shows the Angular source, the Python graph, and the system prompt beside the running demo. +Each capability page describes a standalone example that runs the real framework against a real model. The examples live under [`cockpit/deep-agents`](https://github.com/cacheplane/angular-agent-framework/tree/main/cockpit/deep-agents) and run in the [Threadplane docs workspace](/docs/deep-agents/capabilities/planning?mode=run), which shows the Angular source, the Python graph, and the system prompt beside the running demo. All five share one scenario — an aviation dispatch desk with a handful of airport lookup tools — so the difference between two pages is the capability under test and nothing else. diff --git a/apps/website/content/docs/langgraph/api/api-docs.json b/apps/website/content/docs/langgraph/api/api-docs.json index 3230198e4..23a2b53f0 100644 --- a/apps/website/content/docs/langgraph/api/api-docs.json +++ b/apps/website/content/docs/langgraph/api/api-docs.json @@ -1667,6 +1667,12 @@ "kind": "interface", "description": "Tuning options for the underlying LangGraph SDK `Client` constructed by the\ndefault FetchStreamTransport. Ignored when a custom `transport` is\nsupplied (the transport owns its own client).", "properties": [ + { + "name": "apiKey", + "type": "string | null", + "description": "API key passed directly to the LangGraph SDK client. `null` preserves the\nSDK's explicit no-key behavior; omission leaves the SDK default unchanged.", + "optional": true + }, { "name": "maxRetries", "type": "number", diff --git a/apps/website/e2e/custom-runtime-bfcache.spec.ts b/apps/website/e2e/custom-runtime-bfcache.spec.ts new file mode 100644 index 000000000..9810ecd82 --- /dev/null +++ b/apps/website/e2e/custom-runtime-bfcache.spec.ts @@ -0,0 +1,104 @@ +import { expect, test, type Page } from '@playwright/test'; + +const LANGGRAPH_PATH = '/docs/langgraph/guides/streaming'; +const FIXTURE_ORIGIN = 'http://127.0.0.1:4399'; +const FIXTURE_KEY = 'test-key-redact-me'; + +const desktopControlPlane = (page: Page) => + page.locator('[data-cockpit-desktop-navigation]'); + +async function openSettings(page: Page): Promise { + await desktopControlPlane(page) + .getByRole('button', { name: 'Settings', exact: true }) + .click(); + await expect(page.locator('[data-runtime-target-settings]')).toBeVisible(); +} + +test('clears its memory-only target on an actual persisted BFCache restore', async ({ + page, + request, +}) => { + test.setTimeout(60_000); + await request.delete(`${FIXTURE_ORIGIN}/__bfcache`); + await page.addInitScript( + (fixture) => { + addEventListener('pageshow', (event) => { + if (event.persisted) { + setTimeout(() => { + const settings = document.querySelector( + '[data-runtime-target-settings]' + ); + const targetKind = + settings?.getAttribute('data-runtime-target-kind') === 'shared' + ? 'shared' + : 'other'; + const sensitiveState = [ + location.href, + JSON.stringify(history.state), + JSON.stringify(localStorage), + JSON.stringify(sessionStorage), + document.cookie, + document.documentElement.outerHTML, + ].join('\n'); + const privacy = sensitiveState.includes(fixture.key) + ? 'dirty' + : 'clean'; + void fetch(`${fixture.origin}/__bfcache/${targetKind}/${privacy}`, { + mode: 'no-cors', + cache: 'no-store', + }); + }, 0); + } + }); + }, + { origin: FIXTURE_ORIGIN, key: FIXTURE_KEY } + ); + // Stay in Docs mode so no runtime iframe or pending child navigation can + // make the top-level page ineligible for BFCache. The provider and Settings + // lifecycle are mounted identically in Docs and Run. + await page.goto(LANGGRAPH_PATH); + await openSettings(page); + const settings = page.locator('[data-runtime-target-settings]'); + await settings.getByRole('radio', { name: 'Custom LangSmith' }).check(); + await settings + .locator('input[name="rtu"]') + .fill(`${FIXTURE_ORIGIN}/case/bfcache/langgraph/success`); + await settings.locator('input[name="rts"]').fill(FIXTURE_KEY); + await settings.getByRole('button', { name: 'Use custom target' }).click(); + await expect(settings).toHaveAttribute( + 'data-runtime-target-kind', + 'langsmith' + ); + const runtimeFrame = page.locator( + 'iframe[title="LangGraph Streaming live example"]' + ); + await expect(runtimeFrame).toHaveAttribute( + 'src', + /^http:\/\/localhost:4300\// + ); + await expect( + desktopControlPlane(page).getByRole('button', { + name: /^Run, runtime ready$/, + }) + ).toBeVisible({ timeout: 15_000 }); + // The lifecycle under test is the top-level memory provider. Detach the + // already-proven child after its real handshake so Chromium does not reject + // the top page merely because an embedded frame is still navigating. + await runtimeFrame.evaluate((frame) => frame.remove()); + + // A cross-site top-level navigation forces Chromium to create a distinct + // browsing instance; same-site Next routes are intentionally not BFCache + // candidates (`BrowsingInstanceNotSwapped`). + await page.goto('http://localhost:4300/'); + // Playwright intentionally does not support BFCache restores because they + // have no network navigation event. Trigger the browser history operation + // natively and verify the persisted pageshow beacon out-of-band instead of + // asking Playwright to synchronize to the restored page. + await page.evaluate(() => history.back()); + await expect + .poll(async () => { + const response = await request.get(`${FIXTURE_ORIGIN}/__bfcache`); + return response.json(); + }) + .toEqual({ persisted: true, targetKind: 'shared', privacy: 'clean' }); +}); diff --git a/apps/website/e2e/custom-runtime-targets.spec.ts b/apps/website/e2e/custom-runtime-targets.spec.ts new file mode 100644 index 000000000..613ba88a7 --- /dev/null +++ b/apps/website/e2e/custom-runtime-targets.spec.ts @@ -0,0 +1,628 @@ +import { + expect, + test, + type FrameLocator, + type Page, + type TestInfo, +} from '@playwright/test'; +import { renderRuntimeBridgeFrame } from './fixtures/runtime-bridge-frame'; + +const LANGGRAPH_PATH = '/docs/langgraph/guides/streaming'; +const AG_UI_PATH = '/docs/ag-ui/reference/event-mapping'; +const CHAT_THREADS_PATH = '/docs/chat/guides/thread-routing'; +const FIXTURE_ORIGIN = 'http://127.0.0.1:4399'; +const FIXTURE_KEY = 'test-key-redact-me'; +const POISON_MARKER = `${FIXTURE_KEY}-poison-body`; + +const fixtureCaseId = (testInfo: TestInfo): string => + `case-${testInfo.workerIndex}-${testInfo.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '')}`.slice(0, 80); + +const fixtureRuntimeUrl = (testInfo: TestInfo, path: string): string => + `${FIXTURE_ORIGIN}/case/${fixtureCaseId(testInfo)}/${path}`; + +const fixtureRequestLogUrl = (testInfo: TestInfo): string => + `${FIXTURE_ORIGIN}/__requests/${fixtureCaseId(testInfo)}`; + +function assertSensitiveValuesAbsent( + label: string, + values: readonly unknown[], + sensitiveValues: readonly string[] +): void { + const contaminated = values.some((value) => { + let serialized: string; + try { + serialized = + typeof value === 'string' ? value : JSON.stringify(value) ?? ''; + } catch { + serialized = String(value); + } + return sensitiveValues.some( + (sensitive) => sensitive.length > 0 && serialized.includes(sensitive) + ); + }); + expect(contaminated, `${label} contained prohibited runtime data`).toBe( + false + ); +} + +function observePrivacyChannels(page: Page): { + readonly browserMessages: string[]; + readonly outboundPayloads: string[]; +} { + const browserMessages: string[] = []; + const outboundPayloads: string[] = []; + page.on('console', (message) => browserMessages.push(message.text())); + page.on('pageerror', (error) => browserMessages.push(error.message)); + page.on('request', (request) => { + const url = new URL(request.url()); + const analyticsPath = + url.pathname === '/api/ingest' || + url.pathname === '/ingest' || + url.pathname.startsWith('/ingest/'); + const threadplaneBound = + url.hostname === 'threadplane.ai' || + url.hostname.endsWith('.threadplane.ai'); + if (!analyticsPath && !threadplaneBound) return; + const payload = request.postData(); + if (payload !== null) outboundPayloads.push(payload); + }); + return { browserMessages, outboundPayloads }; +} + +const desktopControlPlane = (page: Page) => + page.locator('[data-cockpit-desktop-navigation]'); + +const runtimeFrame = (page: Page, title: string): FrameLocator => + page.frameLocator(`iframe[title="${title} live example"]`); + +async function openSettings(page: Page): Promise { + const settings = desktopControlPlane(page).getByRole('button', { + name: 'Settings', + exact: true, + }); + await settings.click(); + await expect(page.locator('[data-runtime-target-settings]')).toBeVisible(); +} + +async function applyCustomTarget( + page: Page, + adapter: 'AG-UI' | 'LangSmith', + endpoint: string, + apiKey?: string +): Promise { + await openSettings(page); + const settings = page.locator('[data-runtime-target-settings]'); + await settings.getByRole('radio', { name: `Custom ${adapter}` }).check(); + await settings.locator('input[name="rtu"]').fill(endpoint); + if (apiKey) await settings.locator('input[name="rts"]').fill(apiKey); + await settings.getByRole('button', { name: 'Use custom target' }).click(); + await expect(settings).toHaveAttribute( + 'data-runtime-target-kind', + adapter === 'AG-UI' ? 'ag-ui' : 'langsmith' + ); + if (apiKey) + await expect(settings.locator('input[name="rts"]')).toHaveValue(''); + await desktopControlPlane(page) + .getByRole('button', { name: 'Settings', exact: true }) + .click(); + await expect(page.locator('[data-runtime-target-settings]')).toBeHidden(); +} + +async function sendMessage( + frame: FrameLocator, + message: string +): Promise { + await frame.getByRole('textbox', { name: 'Type a message' }).fill(message); + await frame.getByRole('button', { name: 'Send message' }).click(); +} + +async function assertMemoryPrivacy(page: Page): Promise { + const topState = await page.evaluate( + (secret) => ({ + url: location.href, + state: JSON.stringify(history.state), + local: JSON.stringify(localStorage), + session: JSON.stringify(sessionStorage), + cookies: document.cookie, + html: document.documentElement.outerHTML, + secretPresent: document.documentElement.textContent?.includes(secret), + }), + FIXTURE_KEY + ); + assertSensitiveValuesAbsent( + 'top-level memory privacy state', + Object.values(topState), + [FIXTURE_KEY] + ); + + for (const frame of page + .frames() + .filter((candidate) => candidate !== page.mainFrame())) { + const childState = await frame.evaluate(() => ({ + url: location.href, + local: JSON.stringify(localStorage), + session: JSON.stringify(sessionStorage), + cookies: document.cookie, + html: document.documentElement.outerHTML, + })); + assertSensitiveValuesAbsent( + 'child memory privacy state', + Object.values(childState), + [FIXTURE_KEY] + ); + } +} + +async function assertOperationalPrivacy( + page: Page, + endpoint: string, + privacyChannels: ReturnType +): Promise { + const runtimeSection = desktopControlPlane(page).locator( + '[data-runtime-section]' + ); + await runtimeSection + .getByRole('button', { name: 'More runtime actions' }) + .click(); + await page.getByRole('menuitem', { name: 'Copy diagnostics' }).click(); + await expect(runtimeSection.getByRole('status')).toContainText( + 'Diagnostics copied.' + ); + const diagnosticsText = await page.evaluate(() => + navigator.clipboard.readText() + ); + assertSensitiveValuesAbsent('copied diagnostics', [diagnosticsText], [ + FIXTURE_KEY, + POISON_MARKER, + endpoint, + ]); + let diagnostics: unknown; + try { + diagnostics = JSON.parse(diagnosticsText); + } catch { + throw new Error('Copied diagnostics were not valid JSON'); + } + expect( + typeof diagnostics === 'object' && + diagnostics !== null && + (diagnostics as Record)['targetKind'] === 'langsmith', + 'Copied diagnostics did not retain the sanitized target kind' + ).toBe(true); + + await desktopControlPlane(page) + .getByRole('button', { name: 'Activity', exact: true }) + .click(); + const activity = page.locator('[data-control-plane-utility-panel]'); + await expect( + activity.getByText('Runtime ready', { exact: true }).first() + ).toBeVisible(); + await expect( + activity.getByText('Diagnostics copied', { exact: true }).first() + ).toBeVisible(); + assertSensitiveValuesAbsent( + 'Activity', + [await activity.textContent()], + [FIXTURE_KEY, POISON_MARKER, endpoint] + ); + + await page.waitForTimeout(100); + assertSensitiveValuesAbsent( + 'browser console and page errors', + privacyChannels.browserMessages, + [FIXTURE_KEY, POISON_MARKER, endpoint] + ); + assertSensitiveValuesAbsent( + 'analytics and Threadplane-bound request payloads', + privacyChannels.outboundPayloads, + [FIXTURE_KEY, POISON_MARKER, endpoint] + ); +} + +test.describe('custom runtime targets', () => { + test.beforeEach(async ({ page, request }, testInfo) => { + test.setTimeout(60_000); + await page.addInitScript(() => { + const hideDevelopmentIndicator = () => { + document + .querySelectorAll('nextjs-portal') + .forEach((portal) => { + portal.style.setProperty('display', 'none', 'important'); + }); + }; + addEventListener('DOMContentLoaded', () => { + hideDevelopmentIndicator(); + new MutationObserver(hideDevelopmentIndicator).observe( + document.documentElement, + { + childList: true, + subtree: true, + } + ); + }); + }); + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']); + await request.delete(fixtureRequestLogUrl(testInfo)); + }); + + test('isolates fixture request evidence across concurrent case IDs', async ({ + request, + }) => { + await Promise.all([ + request.post(`${FIXTURE_ORIGIN}/case/isolation-a/ag-ui/success`, { + headers: { Origin: 'http://localhost:4321' }, + data: {}, + }), + request.post(`${FIXTURE_ORIGIN}/case/isolation-b/ag-ui/success`, { + headers: { Origin: 'http://localhost:4300' }, + data: {}, + }), + ]); + + const [caseA, caseB] = await Promise.all([ + request.get(`${FIXTURE_ORIGIN}/__requests/isolation-a`), + request.get(`${FIXTURE_ORIGIN}/__requests/isolation-b`), + ]); + expect(await caseA.json()).toEqual([ + expect.objectContaining({ origin: 'http://localhost:4321' }), + ]); + expect(await caseB.json()).toEqual([ + expect.objectContaining({ origin: 'http://localhost:4300' }), + ]); + }); + + test('streams through the real AG-UI app with an exact iframe Origin', async ({ + page, + request, + }, testInfo) => { + await page.goto(`${AG_UI_PATH}?mode=run`); + await applyCustomTarget( + page, + 'AG-UI', + fixtureRuntimeUrl(testInfo, 'ag-ui/success') + ); + + const frame = runtimeFrame(page, 'AG-UI Streaming'); + await sendMessage(frame, 'Use the custom AG-UI runtime'); + await expect(frame.getByText('Custom AG-UI success')).toBeVisible(); + + const records = (await ( + await request.get(fixtureRequestLogUrl(testInfo)) + ).json()) as Array<{ + origin: string | null; + headerNames: string[]; + keyMatched: boolean; + }>; + expect( + records.some((record) => record.origin === 'http://localhost:4321') + ).toBe(true); + expect( + records.some((record) => record.headerNames.includes('content-type')) + ).toBe(true); + assertSensitiveValuesAbsent('AG-UI request evidence', records, [ + FIXTURE_KEY, + ]); + }); + + test('streams through the real LangGraph app with preflight and a sanitized key header', async ({ + page, + request, + }, testInfo) => { + const endpoint = fixtureRuntimeUrl(testInfo, 'langgraph/success'); + const privacyChannels = observePrivacyChannels(page); + await page.goto(`${LANGGRAPH_PATH}?mode=run`); + await applyCustomTarget( + page, + 'LangSmith', + endpoint, + FIXTURE_KEY + ); + + const frame = runtimeFrame(page, 'LangGraph Streaming'); + await sendMessage(frame, 'Use the custom LangSmith runtime'); + await expect(frame.getByText('Custom LangSmith success')).toBeVisible(); + + const records = (await ( + await request.get(fixtureRequestLogUrl(testInfo)) + ).json()) as Array<{ + origin: string | null; + headerNames: string[]; + keyMatched: boolean; + }>; + expect( + records.some((record) => record.origin === 'http://localhost:4300') + ).toBe(true); + expect( + records.some((record) => + record.headerNames.includes('access-control-request-method') + ) + ).toBe(true); + expect( + records.some( + (record) => + record.headerNames.includes('x-api-key') && record.keyMatched + ) + ).toBe(true); + assertSensitiveValuesAbsent('LangGraph request evidence', records, [ + FIXTURE_KEY, + ]); + await assertOperationalPrivacy(page, endpoint, privacyChannels); + await assertMemoryPrivacy(page); + }); + + test('rejects a wrong LangGraph key without retaining its value', async ({ + page, + request, + }, testInfo) => { + await page.goto(`${LANGGRAPH_PATH}?mode=run`); + await applyCustomTarget( + page, + 'LangSmith', + fixtureRuntimeUrl(testInfo, 'langgraph/success'), + 'test-key-deliberately-wrong' + ); + await sendMessage(runtimeFrame(page, 'LangGraph Streaming'), 'Reject me'); + + await expect(page.getByText('Unauthorized', { exact: true })).toBeVisible({ + timeout: 15_000, + }); + const records = (await ( + await request.get(fixtureRequestLogUrl(testInfo)) + ).json()) as Array<{ + origin: string | null; + headerNames: string[]; + keyMatched: boolean; + }>; + expect( + records.some( + (record) => + record.headerNames.includes('x-api-key') && !record.keyMatched + ) + ).toBe(true); + assertSensitiveValuesAbsent('wrong-key request evidence', records, [ + 'test-key-deliberately-wrong', + ]); + }); + + for (const failure of ['unauthorized', 'forbidden'] as const) { + test(`maps a real ${failure} response to Unauthorized without exposing its poison body`, async ({ + page, + }, testInfo) => { + const observed: string[] = []; + page.on('console', (message) => observed.push(message.text())); + page.on('pageerror', (error) => observed.push(error.message)); + await page.goto(`${LANGGRAPH_PATH}?mode=run`); + await applyCustomTarget( + page, + 'LangSmith', + fixtureRuntimeUrl(testInfo, `langgraph/${failure}`), + FIXTURE_KEY + ); + await sendMessage( + runtimeFrame(page, 'LangGraph Streaming'), + 'Fail safely' + ); + + await expect(page.getByText('Unauthorized', { exact: true })).toBeVisible( + { + timeout: 15_000, + } + ); + assertSensitiveValuesAbsent('failure browser logs', observed, [ + POISON_MARKER, + FIXTURE_KEY, + ]); + await assertMemoryPrivacy(page); + }); + } + + test('maps the real Chat Threads immediate refresh failure to Unauthorized', async ({ + page, + request, + }, testInfo) => { + await page.goto(`${CHAT_THREADS_PATH}?mode=run`); + const initialFrame = page.locator( + 'iframe[title="Chat Threads live example"]' + ); + await expect( + runtimeFrame(page, 'Chat Threads').getByText( + 'How can I help?', + { exact: true } + ) + ).toBeVisible({ timeout: 15_000 }); + await initialFrame.evaluate((element) => + element.setAttribute('data-shared-generation', 'true') + ); + await applyCustomTarget( + page, + 'LangSmith', + fixtureRuntimeUrl(testInfo, 'langgraph/unauthorized'), + FIXTURE_KEY + ); + await expect( + page.locator('iframe[data-shared-generation="true"]') + ).toHaveCount(0); + + await expect + .poll(async () => { + const records = (await ( + await request.get(fixtureRequestLogUrl(testInfo)) + ).json()) as Array<{ + origin: string | null; + headerNames: string[]; + keyMatched: boolean; + }>; + return records + .map( + (record) => + `${record.origin ?? 'none'}:${record.keyMatched}:${record.headerNames.join(',')}` + ) + .join('|'); + }, { timeout: 15_000 }) + .toContain('http://localhost:4506:true:'); + + await expect(page.getByText('Unauthorized', { exact: true })).toBeVisible({ + timeout: 15_000, + }); + await assertMemoryPrivacy(page); + }); + + test('maps a real failed CORS preflight to Network blocked', async ({ + page, + }, testInfo) => { + await page.goto(`${AG_UI_PATH}?mode=run`); + await applyCustomTarget( + page, + 'AG-UI', + fixtureRuntimeUrl(testInfo, 'ag-ui/cors') + ); + await sendMessage( + runtimeFrame(page, 'AG-UI Streaming'), + 'Block this request' + ); + await expect( + page.getByText('Network blocked', { exact: true }) + ).toBeVisible({ + timeout: 15_000, + }); + }); + + test('uses the synthetic bridge only for a transport handshake fault', async ({ + page, + }) => { + await page.route('http://localhost:4300/**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'text/html', + headers: { 'Referrer-Policy': 'origin' }, + body: renderRuntimeBridgeFrame('child-ready-loss'), + }); + }); + await page.goto(`${LANGGRAPH_PATH}?mode=run`); + await expect( + page.getByText('Incompatible runtime', { exact: true }) + ).toBeVisible({ + timeout: 12_000, + }); + }); + + test('replaces the iframe generation and ignores a late response from the old target', async ({ + page, + }, testInfo) => { + await page.goto(`${AG_UI_PATH}?mode=run`); + await applyCustomTarget( + page, + 'AG-UI', + fixtureRuntimeUrl(testInfo, 'ag-ui/delayed-unauthorized') + ); + const firstFrame = page.locator( + 'iframe[title="AG-UI Streaming live example"]' + ); + await firstFrame.evaluate((element) => + element.setAttribute('data-old-generation', 'true') + ); + await sendMessage( + runtimeFrame(page, 'AG-UI Streaming'), + 'Start old generation' + ); + + await applyCustomTarget( + page, + 'AG-UI', + fixtureRuntimeUrl(testInfo, 'ag-ui/success') + ); + await expect( + page.locator('iframe[data-old-generation="true"]') + ).toHaveCount(0); + await sendMessage(runtimeFrame(page, 'AG-UI Streaming'), 'Use replacement'); + await expect( + runtimeFrame(page, 'AG-UI Streaming').getByText('Custom AG-UI success') + ).toBeVisible(); + await page.waitForTimeout(2_200); + await expect(page.getByText('Unauthorized', { exact: true })).toHaveCount( + 0 + ); + }); + + test('keeps both adapter slots across in-shell navigation, supports explicit clear, then clears on reload', async ({ + page, + }, testInfo) => { + await page.goto(`${LANGGRAPH_PATH}?mode=run`); + await applyCustomTarget( + page, + 'LangSmith', + fixtureRuntimeUrl(testInfo, 'langgraph/success'), + FIXTURE_KEY + ); + await page + .getByRole('link', { name: 'AG-UI Streaming', exact: true }) + .click(); + await expect(page).toHaveURL( + new RegExp(`${AG_UI_PATH.replaceAll('/', '\\/')}`) + ); + await applyCustomTarget( + page, + 'AG-UI', + fixtureRuntimeUrl(testInfo, 'ag-ui/success') + ); + + await page.goBack(); + await expect(page).toHaveURL( + new RegExp(`${LANGGRAPH_PATH.replaceAll('/', '\\/')}`) + ); + await openSettings(page); + await expect(page.locator('[data-runtime-target-settings]')).toContainText( + fixtureRuntimeUrl(testInfo, 'langgraph/success') + ); + await page + .locator('[data-runtime-target-settings]') + .getByRole('button', { name: 'Use shared development' }) + .click(); + await expect( + page.locator('[data-runtime-target-settings]') + ).toHaveAttribute('data-runtime-target-kind', 'shared'); + await desktopControlPlane(page) + .getByRole('button', { name: 'Settings', exact: true }) + .click(); + await page + .getByRole('link', { name: 'AG-UI Streaming', exact: true }) + .click(); + await openSettings(page); + await expect(page.locator('[data-runtime-target-settings]')).toContainText( + fixtureRuntimeUrl(testInfo, 'ag-ui/success') + ); + await page.reload(); + await openSettings(page); + await expect( + page.locator('[data-runtime-target-settings]') + ).toHaveAttribute('data-runtime-target-kind', 'shared'); + await expect( + page.locator('[data-runtime-target-settings]') + ).not.toContainText(FIXTURE_ORIGIN); + await assertMemoryPrivacy(page); + }); + + test('clears a custom target after a full top-level navigation away and back', async ({ + page, + }, testInfo) => { + await page.goto(`${AG_UI_PATH}?mode=run`); + await applyCustomTarget( + page, + 'AG-UI', + fixtureRuntimeUrl(testInfo, 'ag-ui/success') + ); + + await page.goto('http://localhost:4300/'); + await page.goto(`${AG_UI_PATH}?mode=run`); + await openSettings(page); + await expect( + page.locator('[data-runtime-target-settings]') + ).toHaveAttribute('data-runtime-target-kind', 'shared'); + await expect( + page.locator('[data-runtime-target-settings]') + ).not.toContainText(FIXTURE_ORIGIN); + await assertMemoryPrivacy(page); + }); +}); diff --git a/apps/website/e2e/docs-shell.spec.ts b/apps/website/e2e/docs-shell.spec.ts index b0fcd811d..d026230bc 100644 --- a/apps/website/e2e/docs-shell.spec.ts +++ b/apps/website/e2e/docs-shell.spec.ts @@ -1,6 +1,13 @@ import { test, expect } from '@playwright/test'; const ARTICLE = '/docs/langgraph/getting-started/introduction'; +const PAGE_ACTION_LABELS = [ + 'On this page', + 'Copy page as Markdown', + 'Open in ChatGPT', + 'View as Markdown', + 'Edit on GitHub', +]; async function expectWorkspaceReady(page: import('@playwright/test').Page) { await expect(page.locator('[data-workspace-shell]')).toHaveAttribute( @@ -115,3 +122,202 @@ test.describe('docs shell layout', () => { expect(header).toBe(prevNext); }); }); + +test.describe('Page actions', () => { + for (const width of [320, 768, 1024, 1440]) { + test(`stays aligned and contained at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + await page.goto(ARTICLE); + await expectWorkspaceReady(page); + + const header = page.locator('.docs-page-header'); + const trigger = page.getByRole('button', { name: 'Page actions' }); + await expect(header).toBeVisible(); + await expect(trigger).toBeVisible(); + + const [headerBox, triggerBox] = await Promise.all([ + header.boundingBox(), + trigger.boundingBox(), + ]); + expect(headerBox).not.toBeNull(); + expect(triggerBox).not.toBeNull(); + if (!headerBox || !triggerBox) throw new Error('Expected Page actions geometry'); + + expect(triggerBox.width).toBeGreaterThanOrEqual(44); + expect(triggerBox.height).toBeGreaterThanOrEqual(44); + expect(triggerBox.x).toBeGreaterThanOrEqual(headerBox.x); + expect(triggerBox.x + triggerBox.width).toBeLessThanOrEqual( + headerBox.x + headerBox.width, + ); + expect( + Math.abs( + triggerBox.x + triggerBox.width - + (headerBox.x + headerBox.width), + ), + ).toBeLessThanOrEqual(1); + expect(triggerBox.y).toBeGreaterThanOrEqual(headerBox.y); + expect(triggerBox.y + triggerBox.height).toBeLessThanOrEqual( + headerBox.y + headerBox.height, + ); + expect( + Math.abs( + triggerBox.y + triggerBox.height / 2 - + (headerBox.y + headerBox.height / 2), + ), + ).toBeLessThanOrEqual(1); + + await trigger.click(); + const menu = page.getByRole('menu'); + await expect(menu).toBeVisible(); + await expect + .poll(() => + menu + .getByRole('menuitem') + .evaluateAll((items) => items.map((item) => item.textContent?.trim())), + ) + .toEqual(PAGE_ACTION_LABELS); + + const menuBox = await menu.boundingBox(); + expect(menuBox).not.toBeNull(); + if (!menuBox) throw new Error('Expected open Page actions menu geometry'); + const viewport = await page.evaluate(() => ({ + height: window.visualViewport?.height ?? window.innerHeight, + width: window.visualViewport?.width ?? window.innerWidth, + x: window.visualViewport?.offsetLeft ?? 0, + y: window.visualViewport?.offsetTop ?? 0, + })); + const safeInset = 8; + expect(menuBox.x).toBeGreaterThanOrEqual(viewport.x + safeInset); + expect(menuBox.y).toBeGreaterThanOrEqual(viewport.y + safeInset); + expect(menuBox.x + menuBox.width).toBeLessThanOrEqual( + viewport.x + viewport.width - safeInset, + ); + expect(menuBox.y + menuBox.height).toBeLessThanOrEqual( + viewport.y + viewport.height - safeInset, + ); + + const documentGeometry = await page.evaluate(() => { + const articleScroller = document.querySelector('.docs-workspace-article'); + if (!(articleScroller instanceof HTMLElement)) { + throw new Error('Expected docs article scroller'); + } + return { + articleClientWidth: articleScroller.clientWidth, + articleScrollWidth: articleScroller.scrollWidth, + rootClientWidth: document.documentElement.clientWidth, + rootScrollWidth: document.documentElement.scrollWidth, + }; + }); + expect(documentGeometry.articleScrollWidth).toBeLessThanOrEqual( + documentGeometry.articleClientWidth, + ); + expect(documentGeometry.rootScrollWidth).toBeLessThanOrEqual( + documentGeometry.rootClientWidth, + ); + }); + } + + test('reveals its tooltip for fine-pointer hover and keyboard focus', async ({ + page, + }) => { + await page.setViewportSize({ width: 1024, height: 900 }); + await page.goto(ARTICLE); + await expectWorkspaceReady(page); + expect(await page.evaluate(() => matchMedia('(pointer: fine)').matches)).toBe(true); + + const trigger = page.getByRole('button', { name: 'Page actions' }); + const tooltip = page.getByRole('tooltip', { name: 'Page actions' }); + await expect(tooltip).toBeHidden(); + + await trigger.hover(); + await expect(tooltip).toBeVisible(); + await page.mouse.move(1, 1); + await expect(tooltip).toBeHidden(); + + await trigger.focus(); + await page.keyboard.press('Shift+Tab'); + await page.keyboard.press('Tab'); + await expect(trigger).toBeFocused(); + expect(await trigger.evaluate((element) => element.matches(':focus-visible'))).toBe( + true, + ); + await expect(tooltip).toBeVisible(); + + await page.keyboard.press('Enter'); + await expect(page.getByRole('menu')).toBeVisible(); + await expect(page.getByRole('tooltip')).toHaveCount(0); + + await page.keyboard.press('Escape'); + await expect(page.getByRole('menu')).toHaveCount(0); + await expect(trigger).toBeFocused(); + await expect(tooltip).toBeVisible(); + }); + + test('keeps a visible system-color trigger boundary and focus indicator', async ({ + page, + }) => { + await page.emulateMedia({ forcedColors: 'active' }); + await page.setViewportSize({ width: 1024, height: 900 }); + await page.goto(ARTICLE); + await expectWorkspaceReady(page); + + const trigger = page.getByRole('button', { name: 'Page actions' }); + await trigger.focus(); + await expect(page.getByRole('tooltip', { name: 'Page actions' })).toBeVisible(); + const styles = await trigger.evaluate((element) => { + const reference = document.createElement('div'); + reference.style.color = 'CanvasText'; + reference.style.backgroundColor = 'Canvas'; + reference.style.outline = '2px solid Highlight'; + reference.style.forcedColorAdjust = 'none'; + document.body.append(reference); + + const style = getComputedStyle(element); + const referenceStyle = getComputedStyle(reference); + const result = { + system: { + canvas: referenceStyle.backgroundColor, + canvasText: referenceStyle.color, + highlight: referenceStyle.outlineColor, + }, + trigger: { + backgroundColor: style.backgroundColor, + borderColor: style.borderTopColor, + borderStyle: style.borderTopStyle, + borderWidth: style.borderTopWidth, + outlineColor: style.outlineColor, + outlineStyle: style.outlineStyle, + outlineWidth: style.outlineWidth, + }, + }; + reference.remove(); + return result; + }); + + expect(styles.trigger.backgroundColor).toBe(styles.system.canvas); + expect(styles.trigger.borderColor).toBe(styles.system.canvasText); + expect(styles.trigger.borderColor).not.toBe(styles.trigger.backgroundColor); + expect(styles.trigger.borderStyle).not.toBe('none'); + expect(Number.parseFloat(styles.trigger.borderWidth)).toBeGreaterThan(0); + expect(styles.trigger.outlineColor).toBe(styles.system.highlight); + expect(styles.trigger.outlineStyle).not.toBe('none'); + expect(Number.parseFloat(styles.trigger.outlineWidth)).toBeGreaterThan(0); + }); + + test('removes the tooltip transition when reduced motion is requested', async ({ + page, + }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.setViewportSize({ width: 1024, height: 900 }); + await page.goto(ARTICLE); + await expectWorkspaceReady(page); + + const trigger = page.getByRole('button', { name: 'Page actions' }); + await trigger.focus(); + const tooltip = page.getByRole('tooltip', { name: 'Page actions' }); + await expect(tooltip).toBeVisible(); + expect( + await tooltip.evaluate((element) => getComputedStyle(element).transitionDuration), + ).toBe('0s'); + }); +}); diff --git a/apps/website/e2e/fixtures/custom-runtime-server.ts b/apps/website/e2e/fixtures/custom-runtime-server.ts new file mode 100644 index 000000000..e99f74974 --- /dev/null +++ b/apps/website/e2e/fixtures/custom-runtime-server.ts @@ -0,0 +1,290 @@ +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from 'node:http'; +import { timingSafeEqual } from 'node:crypto'; +import { + renderRuntimeBridgeFrame, + type RuntimeBridgeFault, +} from './runtime-bridge-frame'; + +const HOST = '127.0.0.1'; +const PORT = 4399; +const POISON = 'test-key-redact-me-poison-body'; +const FIXTURE_KEY_BYTES = Buffer.from('test-key-redact-me'); +const allowedRuntimeOrigins = new Set([ + 'http://localhost:4300', + 'http://localhost:4321', + 'http://localhost:4506', +]); + +interface SanitizedRequestRecord { + readonly origin: string | null; + readonly headerNames: readonly string[]; + readonly keyMatched: boolean; +} + +const requestsByCase = new Map(); +let bfcacheObservation: { + persisted: boolean; + targetKind: 'shared' | 'other'; + privacy: 'clean' | 'dirty'; +} | null = null; + +function sanitizedHeaderNames(request: IncomingMessage): readonly string[] { + return Object.freeze( + Object.keys(request.headers) + .map((name) => name.toLowerCase()) + .filter((name) => /^[a-z0-9-]{1,64}$/.test(name)) + .sort() + ); +} + +function requestKeyMatched(request: IncomingMessage): boolean { + const candidate = request.headers['x-api-key']; + if (typeof candidate !== 'string') return false; + const candidateBytes = Buffer.from(candidate); + if (candidateBytes.length !== FIXTURE_KEY_BYTES.length) return false; + return timingSafeEqual(candidateBytes, FIXTURE_KEY_BYTES); +} + +function recordRequest( + request: IncomingMessage, + keyMatched: boolean, + caseId: string +): void { + const requests = requestsByCase.get(caseId) ?? []; + requests.push( + Object.freeze({ + origin: + typeof request.headers.origin === 'string' + ? request.headers.origin + : null, + headerNames: sanitizedHeaderNames(request), + keyMatched, + }) + ); + while (requests.length > 100) requests.shift(); + requestsByCase.set(caseId, requests); +} + +function runtimeCase( + url: URL +): { readonly caseId: string; readonly pathname: string } | null { + const match = url.pathname.match( + /^\/case\/([a-z0-9-]{1,80})(\/(?:ag-ui|langgraph)\/.*)$/ + ); + return match === null ? null : { caseId: match[1], pathname: match[2] }; +} + +function applyCors( + request: IncomingMessage, + response: ServerResponse +): boolean { + const origin = request.headers.origin; + if (typeof origin !== 'string' || !allowedRuntimeOrigins.has(origin)) { + return false; + } + response.setHeader('Access-Control-Allow-Origin', origin); + response.setHeader( + 'Access-Control-Allow-Methods', + 'GET, POST, PATCH, OPTIONS' + ); + response.setHeader( + 'Access-Control-Allow-Headers', + 'content-type, x-api-key, authorization, last-event-id' + ); + response.setHeader('Access-Control-Expose-Headers', 'content-location'); + response.setHeader('Vary', 'Origin'); + return true; +} + +function writeJson( + response: ServerResponse, + status: number, + value: unknown +): void { + response.writeHead(status, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + response.end(JSON.stringify(value)); +} + +function writePoisonFailure(response: ServerResponse, status: 401 | 403): void { + writeJson(response, status, { error: POISON, authorization: POISON }); +} + +function writeAgUiSuccess(response: ServerResponse): void { + const runId = 'fixture-run'; + const messageId = 'fixture-assistant'; + const events = [ + { type: 'RUN_STARTED', threadId: 'fixture-thread', runId }, + { + type: 'TEXT_MESSAGE_START', + messageId, + role: 'assistant', + }, + { + type: 'TEXT_MESSAGE_CONTENT', + messageId, + delta: 'Custom AG-UI success', + }, + { type: 'TEXT_MESSAGE_END', messageId }, + { type: 'RUN_FINISHED', threadId: 'fixture-thread', runId }, + ]; + response.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-store', + Connection: 'keep-alive', + }); + for (const event of events) + response.write(`data: ${JSON.stringify(event)}\n\n`); + response.end(); +} + +function writeLangGraphSuccess(response: ServerResponse): void { + const message = { + id: 'fixture-langgraph-assistant', + type: 'ai', + content: 'Custom LangSmith success', + }; + response.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-store', + Connection: 'keep-alive', + 'Content-Location': '/threads/fixture-thread/runs/fixture-run', + }); + response.write( + `event: messages\ndata: ${JSON.stringify([ + message, + { langgraph_node: 'fixture' }, + ])}\n\n` + ); + response.write( + `event: values\ndata: ${JSON.stringify({ messages: [message] })}\n\n` + ); + response.end(); +} + +function behavior( + pathname: string +): + | 'success' + | 'unauthorized' + | 'forbidden' + | 'cors' + | 'delayed' + | 'delayed-unauthorized' { + if (pathname.includes('/delayed-unauthorized')) return 'delayed-unauthorized'; + if (pathname.includes('/unauthorized')) return 'unauthorized'; + if (pathname.includes('/forbidden')) return 'forbidden'; + if (pathname.includes('/cors')) return 'cors'; + if (pathname.includes('/delayed')) return 'delayed'; + return 'success'; +} + +const server = createServer((request, response) => { + const url = new URL(request.url ?? '/', `http://${HOST}:${PORT}`); + if (url.pathname === '/health') return writeJson(response, 200, { ok: true }); + const requestLogMatch = url.pathname.match( + /^\/__requests\/([a-z0-9-]{1,80})$/ + ); + if (requestLogMatch && request.method === 'GET') { + return writeJson(response, 200, requestsByCase.get(requestLogMatch[1]) ?? []); + } + if (requestLogMatch && request.method === 'DELETE') { + requestsByCase.delete(requestLogMatch[1]); + response.writeHead(204, { 'Cache-Control': 'no-store' }); + return response.end(); + } + if (url.pathname === '/__bfcache' && request.method === 'GET') { + return writeJson(response, 200, bfcacheObservation); + } + if (url.pathname === '/__bfcache' && request.method === 'DELETE') { + bfcacheObservation = null; + response.writeHead(204, { 'Cache-Control': 'no-store' }); + return response.end(); + } + const bfcacheMatch = url.pathname.match( + /^\/__bfcache\/(shared|other)\/(clean|dirty)$/ + ); + if (bfcacheMatch && request.method === 'GET') { + bfcacheObservation = { + persisted: true, + targetKind: bfcacheMatch[1] === 'shared' ? 'shared' : 'other', + privacy: bfcacheMatch[2] === 'clean' ? 'clean' : 'dirty', + }; + response.writeHead(204, { + 'Cache-Control': 'no-store', + 'Access-Control-Allow-Origin': '*', + }); + return response.end(); + } + if (url.pathname.startsWith('/bridge/')) { + const fault = url.pathname.slice('/bridge/'.length) as RuntimeBridgeFault; + response.writeHead(200, { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store', + 'Referrer-Policy': 'origin', + }); + return response.end(renderRuntimeBridgeFrame(fault)); + } + + const selectedCase = runtimeCase(url); + if (selectedCase === null) + return writeJson(response, 400, { error: 'case_required' }); + const keyMatched = requestKeyMatched(request); + recordRequest(request, keyMatched, selectedCase.caseId); + const selectedBehavior = behavior(selectedCase.pathname); + if (request.method === 'OPTIONS') { + if (selectedBehavior !== 'cors') applyCors(request, response); + response.writeHead(204, { 'Cache-Control': 'no-store' }); + return response.end(); + } + if (selectedBehavior !== 'cors') applyCors(request, response); + request.resume(); + + if ( + selectedCase.pathname.startsWith('/langgraph/') && + request.method !== 'OPTIONS' && + !keyMatched + ) { + return writePoisonFailure(response, 401); + } + + const respond = () => { + if (selectedBehavior === 'delayed-unauthorized') { + return writePoisonFailure(response, 401); + } + if (selectedBehavior === 'unauthorized') + return writePoisonFailure(response, 401); + if (selectedBehavior === 'forbidden') + return writePoisonFailure(response, 403); + if (selectedBehavior === 'cors') return writePoisonFailure(response, 403); + if (selectedCase.pathname.startsWith('/ag-ui/')) + return writeAgUiSuccess(response); + if (selectedCase.pathname.endsWith('/threads')) { + return writeJson(response, 200, { thread_id: 'fixture-thread' }); + } + if (selectedCase.pathname.includes('/runs/stream')) + return writeLangGraphSuccess(response); + if (selectedCase.pathname.endsWith('/history')) + return writeJson(response, 200, []); + return writeJson(response, 404, { error: 'fixture_not_found' }); + }; + + if ( + selectedBehavior === 'delayed' || + selectedBehavior === 'delayed-unauthorized' + ) { + setTimeout(respond, 2_000); + } else respond(); +}); + +server.listen(PORT, HOST); + +for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.once(signal, () => server.close(() => process.exit(0))); +} diff --git a/apps/website/e2e/fixtures/runtime-bridge-frame.ts b/apps/website/e2e/fixtures/runtime-bridge-frame.ts new file mode 100644 index 000000000..f13979663 --- /dev/null +++ b/apps/website/e2e/fixtures/runtime-bridge-frame.ts @@ -0,0 +1,41 @@ +export type RuntimeBridgeFault = + | 'none' + | 'child-ready-loss' + | 'configured-ack-loss' + | 'wrong-version'; + +export function renderRuntimeBridgeFrame( + fault: RuntimeBridgeFault = 'none' +): string { + const encodedFault = JSON.stringify(fault); + return ` +Runtime bridge fixture +

Runtime bridge fixture

`; +} diff --git a/apps/cockpit/e2e/production-smoke.spec.ts b/apps/website/e2e/platform-production-smoke.spec.ts similarity index 61% rename from apps/cockpit/e2e/production-smoke.spec.ts rename to apps/website/e2e/platform-production-smoke.spec.ts index 84080a1bc..da9799740 100644 --- a/apps/cockpit/e2e/production-smoke.spec.ts +++ b/apps/website/e2e/platform-production-smoke.spec.ts @@ -1,30 +1,56 @@ import { expect, test } from '@playwright/test'; -import { capabilities } from '../scripts/capability-registry'; +import { readFileSync } from 'node:fs'; +import { validateRuntimeParentOrigins } from '@threadplane/cockpit-runtime-bridge'; import { - getRedirectDisabledProbePath, - getRegistryWebsiteDestinations, -} from '../scripts/deploy-smoke'; + cockpitManifest, + getCanonicalWebsiteWorkspaceHref, + getWorkspaceDestinationPath, + resolveLegacyPath, + resolveLegacyRequestMode, +} from '@threadplane/cockpit-registry'; /** - * Production smoke test: verifies the deployed cockpit shell and deployed - * example apps are reachable after production deploy. + * Production platform smoke: verifies the Website, legacy Cockpit redirects, + * deployed examples, canonical demo, and shared runtimes as one product. * * Requires: - * BASE_URL - e.g. https://cockpit.threadplane.ai + * COCKPIT_URL - e.g. https://cockpit.threadplane.ai * EXAMPLES_URL - e.g. https://examples.threadplane.ai * OPENAI_API_KEY - optional; enables the single live-provider canary * * Run: - * BASE_URL=https://cockpit.threadplane.ai \ + * PRODUCTION_SMOKE=true COCKPIT_URL=https://cockpit.threadplane.ai \ * EXAMPLES_URL=https://examples.threadplane.ai \ - * npx playwright test apps/cockpit/e2e/production-smoke.spec.ts + * npx playwright test apps/website/e2e/platform-production-smoke.spec.ts */ -const COCKPIT_URL = process.env['BASE_URL'] ?? 'https://cockpit.threadplane.ai'; +const COCKPIT_URL = + process.env['COCKPIT_URL'] ?? 'https://cockpit.threadplane.ai'; const EXAMPLES_URL = process.env['EXAMPLES_URL'] ?? 'https://examples.threadplane.ai'; const DEMO_URL = process.env['DEMO_URL'] ?? 'https://demo.threadplane.ai'; const WEBSITE_URL = process.env['WEBSITE_URL'] ?? 'https://threadplane.ai'; +const runtimeParentOriginSource = JSON.parse( + readFileSync( + new URL('../../../runtime-parent-origins.json', import.meta.url), + 'utf8' + ) +) as { readonly baseOrigins?: unknown }; +const runtimeParentPreviewOrigins = ( + process.env['RUNTIME_PARENT_PREVIEW_ORIGINS'] ?? '' +) + .split(/\r?\n/) + .filter(Boolean); +const baseRuntimeParentOrigins = validateRuntimeParentOrigins( + runtimeParentOriginSource.baseOrigins +); +const expectedRuntimeParentOrigins = validateRuntimeParentOrigins([ + ...(baseRuntimeParentOrigins ?? []), + ...runtimeParentPreviewOrigins, +]); +if (baseRuntimeParentOrigins === null || expectedRuntimeParentOrigins === null) { + throw new Error('Invalid runtime parent origin smoke policy'); +} const CHAT_CAPABILITIES = [ 'langgraph/streaming', @@ -84,13 +110,75 @@ const RENDER_CAPABILITIES = [ * runtime but are not yet in the examples route table, so they have no * /ag-ui// URL to assert against. */ -const AG_UI_TOPICS = capabilities - .filter((c) => c.product === 'ag-ui' && c.pythonDir) - .map((c) => c.topic) - .sort(); +const AG_UI_TOPICS = [ + ...new Set( + cockpitManifest + .filter( + (entry) => entry.product === 'ag-ui' && entry.runtimeAdapter === 'ag-ui' + ) + .map((entry) => entry.topic) + ), +].sort(); const SEND_RECEIVE_TIMEOUT_MS = 30_000; -const WEBSITE_DESTINATIONS = getRegistryWebsiteDestinations(); +const WEBSITE_DESTINATIONS = [ + ...new Set(cockpitManifest.map(getWorkspaceDestinationPath)), +].sort(); + +const expectedRedirect = (legacyPath: string): string => { + const resolution = resolveLegacyPath(legacyPath); + if (!resolution) throw new Error(`Expected registry path ${legacyPath}`); + const mode = resolveLegacyRequestMode(undefined, resolution); + return new URL( + getCanonicalWebsiteWorkspaceHref(resolution, mode), + `${WEBSITE_URL}/` + ).toString(); +}; + +const docsBacked = cockpitManifest.find((entry) => + getWorkspaceDestinationPath(entry).startsWith('/docs/') +); +const workspaceOnly = cockpitManifest.find((entry) => + getWorkspaceDestinationPath(entry).startsWith('/workspace/') +); +if (!docsBacked || !workspaceOnly) { + throw new Error('Production smoke requires Docs-backed and workspace routes'); +} + +const COCKPIT_REDIRECT_CASES = [ + { + name: 'root production redirect', + path: '/', + status: 308, + location: expectedRedirect( + '/langgraph/core-capabilities/streaming/overview/python' + ), + }, + { + name: 'Docs-backed production redirect', + path: docsBacked.legacyPath, + status: 308, + location: expectedRedirect(docsBacked.legacyPath), + }, + { + name: 'workspace-only production redirect', + path: workspaceOnly.legacyPath, + status: 308, + location: expectedRedirect(workspaceOnly.legacyPath), + }, + { + name: 'unknown production 404', + path: '/unknown', + status: 404, + location: undefined, + }, + { + name: 'favicon production redirect', + path: '/favicon.ico', + status: 308, + location: '/icon.svg', + }, +] as const; test.describe('Production: registry-owned Website destinations load', () => { for (const destination of WEBSITE_DESTINATIONS) { @@ -144,68 +232,81 @@ test.describe('Production: render example apps load', () => { } }); -test.describe('Production: cockpit shell loads', () => { - test('cockpit loads with sidebar navigation', async ({ page }) => { - await page.goto(COCKPIT_URL, { timeout: 15_000 }); - await expect( - page.getByRole('navigation', { name: 'Cockpit navigation' }) - ).toBeVisible(); +test.describe('Production: legacy Cockpit redirect service', () => { + for (const smokeCase of COCKPIT_REDIRECT_CASES) { + test(smokeCase.name, async ({ request }) => { + const response = await request.get(`${COCKPIT_URL}${smokeCase.path}`, { + maxRedirects: 0, + }); - const links = await page.locator('nav a').allTextContents(); - const overviewLinks = links.filter((text) => - text.toLowerCase().includes('overview') - ); - expect(overviewLinks).toHaveLength(0); - }); + expect(response.status()).toBe(smokeCase.status); + expect(response.headers()['location']).toBe(smokeCase.location); + }); + } +}); - test('representative runtime reports Ready after Recheck and records Activity', async ({ - page, +test.describe('Production: unified runtime embedding policy', () => { + test('assembled children ship the exact parent/referrer policy without an X-Frame-Options conflict', async ({ + request, }) => { - test.setTimeout(60_000); - const runtimeRoute = `${COCKPIT_URL}/langgraph/core-capabilities/streaming/overview/python`; - await page.goto(runtimeRoute, { timeout: 15_000 }); - - const ready = page.getByText('Ready', { exact: true }); - await expect(ready).toBeVisible({ timeout: 15_000 }); + const response = await request.get(`${EXAMPLES_URL}/langgraph/streaming/`); + const headers = response.headers(); + const policy = headers['content-security-policy']; + const frameAncestors = policy + ?.split(';') + .find((directive) => directive.trim().startsWith('frame-ancestors')); - await page.getByRole('button', { name: 'Activity' }).click(); - const checkEvents = page.locator( - '[data-activity-kind="runtime_check_requested"]' + expect(response.status()).toBe(200); + const actualFrameAncestors = frameAncestors + ?.trim() + .split(/\s+/) + .slice(1); + const validatedFrameAncestors = validateRuntimeParentOrigins( + actualFrameAncestors ); - const readyEvents = page.locator('[data-activity-kind="runtime_ready"]'); - await expect(checkEvents).toHaveCount(1); - await expect(readyEvents).toHaveCount(1); - await page.getByRole('button', { name: 'Close Activity' }).click(); - - const checkedAt = page.locator('[data-runtime-checked-at]'); - const checkedAtBefore = await checkedAt.textContent(); - await page.getByRole('button', { name: 'Recheck' }).click(); - await expect - .poll(() => checkedAt.textContent(), { timeout: 15_000 }) - .not.toBe(checkedAtBefore); - await expect(ready).toBeVisible({ timeout: 15_000 }); - - await page.getByRole('button', { name: 'Activity' }).click(); - await expect(checkEvents).toHaveCount(2); - await expect(readyEvents).toHaveCount(2); - }); - - test('favicon resolves after redirects', async ({ request }) => { - const response = await request.get(`${COCKPIT_URL}/favicon.ico`); - - expect(response.status()).toBeLessThan(400); + expect(validatedFrameAncestors).not.toBeNull(); + if (runtimeParentPreviewOrigins.length > 0) { + expect(validatedFrameAncestors).toEqual(expectedRuntimeParentOrigins); + } else { + for (const origin of baseRuntimeParentOrigins) { + expect(validatedFrameAncestors).toContain(origin); + } + } + expect(policy).toContain( + "connect-src 'self' https: http://localhost:* http://127.0.0.1:* http://[::1]:*" + ); + expect(frameAncestors).not.toContain('*'); + expect(frameAncestors).not.toContain('cockpit.threadplane.ai'); + expect(headers['referrer-policy']).toBe('origin'); + expect(headers['x-frame-options']).toBeUndefined(); }); - test('legacy workspace redirects remain disabled before opt-in activation', async ({ - request, + test('production begins Shared-only and sends only the Website origin as iframe referrer', async ({ + page, }) => { - const response = await request.get( - new URL(getRedirectDisabledProbePath(), COCKPIT_URL).toString(), - { maxRedirects: 0 } - ); + let iframeReferrer: string | undefined; + page.on('request', (request) => { + if (request.resourceType() !== 'document') return; + if (!request.url().startsWith(`${EXAMPLES_URL}/langgraph/streaming`)) { + return; + } + iframeReferrer = request.headers()['referer']; + }); - expect(response.status()).toBe(200); - expect(response.headers()['location']).toBeUndefined(); + await page.goto(`${WEBSITE_URL}/docs/langgraph/guides/streaming?mode=run`); + const controls = page.locator('[data-cockpit-desktop-navigation]'); + await controls + .getByRole('button', { name: 'Settings', exact: true }) + .click(); + await expect( + page.locator('[data-runtime-target-settings]') + ).toHaveAttribute('data-runtime-target-kind', 'shared'); + await expect( + page.locator('[data-runtime-target-settings]') + ).not.toContainText('Custom target active'); + await expect + .poll(() => iframeReferrer) + .toBe(new URL(WEBSITE_URL).origin + '/'); }); }); @@ -272,7 +373,9 @@ test.describe('Production: canonical demo sends runtime telemetry', () => { }); test.describe('AG-UI Railway runtime', () => { - const RAILWAY_URL = process.env['AG_UI_RAILWAY_URL'] ?? 'https://ag-ui-dev-production.up.railway.app'; + const RAILWAY_URL = + process.env['AG_UI_RAILWAY_URL'] ?? + 'https://ag-ui-dev-production.up.railway.app'; test('healthcheck /ok responds 200', async ({ request }) => { const res = await request.get(`${RAILWAY_URL}/ok`); @@ -340,7 +443,10 @@ test.describe('examples langgraph proxy hardening', () => { test('rejects a forbidden Origin with 403', async ({ request }) => { const res = await request.post(streamPath(), { - headers: { Origin: 'https://evil.example.com', 'content-type': 'application/json' }, + headers: { + Origin: 'https://evil.example.com', + 'content-type': 'application/json', + }, data: runBody, }); expect(res.status()).toBe(403); @@ -365,9 +471,14 @@ test.describe('AG-UI demo (ag-ui.threadplane.ai)', () => { expect(res?.status()).toBeLessThan(400); }); - test('forbidden origin to /agent is rejected with 403', async ({ request }) => { + test('forbidden origin to /agent is rejected with 403', async ({ + request, + }) => { const res = await request.post(`${DEMO}/agent`, { - headers: { Origin: 'https://evil.example.com', 'content-type': 'application/json' }, + headers: { + Origin: 'https://evil.example.com', + 'content-type': 'application/json', + }, data: {}, }); expect(res.status()).toBe(403); diff --git a/apps/website/e2e/workspace-shell.spec.ts b/apps/website/e2e/workspace-shell.spec.ts index 0f5a3cd4f..839bef19f 100644 --- a/apps/website/e2e/workspace-shell.spec.ts +++ b/apps/website/e2e/workspace-shell.spec.ts @@ -8,6 +8,13 @@ const workspaceOnlyPath = '/workspace/langgraph/durable-execution'; const deepAgentsDocsPath = '/docs/deep-agents/capabilities/planning'; const RUN_RAIL_ITEM = /^Run(?:,|$)/; +declare global { + interface Window { + __websiteAboutBlankMounted?: boolean; + __websiteRuntimePhases?: string[]; + } +} + const modeButton = (page: Page, mode: 'Docs' | 'Run' | 'Code' | 'API') => page.locator('[data-cockpit-desktop-navigation]').getByRole('button', { name: mode === 'Run' ? RUN_RAIL_ITEM : mode, @@ -42,6 +49,75 @@ async function markRuntimeFrame(frame: Locator) { return frame.getAttribute('data-e2e-runtime-frame'); } +async function installRuntimeObservation(page: Page) { + await page.addInitScript(() => { + window.__websiteAboutBlankMounted = false; + window.__websiteRuntimePhases = []; + + const recordPhase = (phase: string | null) => { + if (phase && !window.__websiteRuntimePhases?.includes(phase)) { + window.__websiteRuntimePhases?.push(phase); + } + }; + const inspectElement = (element: Element) => { + const frames = element.matches('iframe') + ? [element] + : Array.from(element.querySelectorAll('iframe')); + for (const frame of frames) { + if (frame.getAttribute('src') === 'about:blank') { + window.__websiteAboutBlankMounted = true; + } + } + const statuses = element.matches('[data-runtime-phase]') + ? [element] + : Array.from(element.querySelectorAll('[data-runtime-phase]')); + for (const status of statuses) { + recordPhase(status.getAttribute('data-runtime-phase')); + } + }; + const inspectCurrent = () => { + for (const element of document.querySelectorAll( + 'iframe, [data-runtime-phase]' + )) { + inspectElement(element); + } + }; + + new MutationObserver((records) => { + for (const record of records) { + if (record.type === 'attributes' && record.target instanceof Element) { + if ( + record.attributeName === 'src' && + record.target.matches('iframe') && + record.oldValue === 'about:blank' + ) { + window.__websiteAboutBlankMounted = true; + } + if (record.attributeName === 'data-runtime-phase') { + recordPhase(record.oldValue); + } + inspectElement(record.target); + } else if (record.type === 'childList') { + for (const node of record.addedNodes) { + if (node instanceof Element) inspectElement(node); + } + } + } + inspectCurrent(); + }).observe(document, { + attributes: true, + attributeOldValue: true, + attributeFilter: ['src', 'data-runtime-phase'], + childList: true, + subtree: true, + }); + inspectCurrent(); + document.addEventListener('DOMContentLoaded', inspectCurrent, { + once: true, + }); + }); +} + test.describe('workspace shell', () => { test.describe.configure({ mode: 'serial' }); @@ -100,6 +176,71 @@ test.describe('workspace shell', () => { } }); + test('runtime observer captures transient blank and unresponsive mutations', async ({ + page, + }) => { + await installRuntimeObservation(page); + await page.goto(streamingDocsPath); + + await page.evaluate(() => { + const frame = document.createElement('iframe'); + frame.src = 'about:blank'; + document.body.append(frame); + frame.src = 'https://runtime.test/ready'; + + const status = document.createElement('span'); + status.setAttribute('data-runtime-phase', 'ready'); + document.body.append(status); + status.setAttribute('data-runtime-phase', 'unresponsive'); + status.setAttribute('data-runtime-phase', 'ready'); + }); + + await expect + .poll(() => page.evaluate(() => window.__websiteAboutBlankMounted)) + .toBe(true); + await expect + .poll(() => page.evaluate(() => window.__websiteRuntimePhases)) + .toContain('unresponsive'); + }); + + test('reports Ready and Recheck activity without blank or unresponsive runtime phases', async ({ + page, + }) => { + await installRuntimeObservation(page); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto(`${streamingDocsPath}?mode=run`); + await expectMode(page, 'Run'); + await expect(page.getByText('Ready', { exact: true })).toBeVisible(); + expect(await page.evaluate(() => window.__websiteAboutBlankMounted)).toBe( + false + ); + expect( + await page.evaluate(() => window.__websiteRuntimePhases) + ).not.toContain('unresponsive'); + + await page.getByRole('button', { name: 'Activity', exact: true }).click(); + await expect( + page.locator('[data-activity-kind="runtime_check_requested"]') + ).toHaveCount(1); + await expect( + page.locator('[data-activity-kind="runtime_ready"]') + ).toHaveCount(1); + await page.getByRole('button', { name: 'Close Activity' }).click(); + + await page.getByRole('button', { name: 'Recheck' }).click(); + await expect(page.getByText('Ready', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Activity', exact: true }).click(); + await expect( + page.locator('[data-activity-kind="runtime_check_requested"]') + ).toHaveCount(2); + await expect( + page.locator('[data-activity-kind="runtime_ready"]') + ).toHaveCount(2); + expect( + await page.evaluate(() => window.__websiteRuntimePhases) + ).not.toContain('unresponsive'); + }); + test('restores mode and capability navigation through Back and Forward', async ({ page, }) => { @@ -190,6 +331,40 @@ test.describe('workspace shell', () => { } }); + for (const path of ['/docs', '/docs/choosing-an-adapter']) { + test(`docs-only ${path} keeps operational modes focusable and local`, async ({ + page, + }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto(path); + + const controlPlane = page.locator('[data-docs-control-plane]'); + await expect(controlPlane).toBeVisible(); + for (const mode of ['Run', 'Code', 'API'] as const) { + const control = controlPlane.getByRole('button', { + name: mode, + exact: true, + }); + await expect(control).toHaveAttribute('aria-disabled', 'true'); + await expect(control).toHaveAccessibleDescription( + new RegExp( + `${mode} is unavailable because this page has no workspace capability`, + 'i' + ) + ); + await expect(control).not.toHaveAttribute('href', /.+/); + await expect(control).not.toHaveAttribute('target', /.+/); + await control.focus(); + await expect(control).toBeFocused(); + await control.click({ force: true }); + await expect(page).toHaveURL(path); + } + await expect( + controlPlane.getByRole('button', { name: 'Search docs' }) + ).toBeVisible(); + }); + } + test('uses workspace fallbacks only when a shared Docs path would lose identity', async ({ page, }) => { @@ -233,6 +408,87 @@ test.describe('workspace shell', () => { ).toBeHidden(); }); + for (const viewport of [ + { width: 1440, height: 900, surface: 'wide desktop' }, + { width: 1024, height: 900, surface: 'desktop breakpoint' }, + { width: 768, height: 900, surface: 'tablet breakpoint' }, + { width: 320, height: 844, surface: 'compact mobile' }, + ] as const) { + test(`${viewport.surface} keeps workspace controls reachable without overflow`, async ({ + page, + }) => { + await page.setViewportSize(viewport); + await page.goto(`${streamingDocsPath}?mode=run`); + await expectMode(page, 'Run'); + await expectNoHorizontalOverflow(page, `Website at ${viewport.width}px`); + + const desktopNavigation = page.locator( + '[data-cockpit-desktop-navigation]' + ); + const mobileTrigger = page.getByRole('button', { + name: 'Open navigation', + }); + if (viewport.width >= 1024) { + await expect(desktopNavigation).toBeVisible(); + await expect(mobileTrigger).toBeHidden(); + await expect( + page.getByRole('button', { name: 'Runtime', exact: true }) + ).toBeVisible(); + await expect( + desktopNavigation.getByRole('button', { + name: RUN_RAIL_ITEM, + }) + ).toBeVisible(); + await desktopNavigation + .getByRole('button', { name: 'Activity' }) + .click(); + await expect( + page.getByRole('heading', { name: 'Activity' }) + ).toBeVisible(); + } else if (viewport.width === 768) { + await expect(desktopNavigation).toBeVisible(); + await expect(mobileTrigger).toBeHidden(); + const contextTrigger = page.getByRole('button', { + name: 'Open context', + }); + await expect(contextTrigger).toBeVisible(); + await contextTrigger.click(); + const dialog = page.getByRole('dialog', { + name: 'Documentation control plane context', + }); + await expect( + dialog.getByRole('button', { name: 'Runtime', exact: true }) + ).toBeVisible(); + await desktopNavigation + .getByRole('button', { name: 'Activity' }) + .click(); + await expect( + dialog.getByRole('heading', { name: 'Activity' }) + ).toBeVisible(); + } else { + await expect(desktopNavigation).toBeHidden(); + await expect(mobileTrigger).toBeVisible(); + const triggerBox = await mobileTrigger.boundingBox(); + expect(triggerBox?.width).toBeGreaterThanOrEqual(44); + expect(triggerBox?.height).toBeGreaterThanOrEqual(44); + await mobileTrigger.click(); + const dialog = page.getByRole('dialog', { + name: 'Documentation control plane', + }); + await expect( + dialog.getByRole('button', { name: RUN_RAIL_ITEM }) + ).toBeVisible(); + await expect( + dialog.getByRole('button', { name: 'Runtime', exact: true }) + ).toBeVisible(); + await dialog.getByRole('button', { name: 'Activity' }).click(); + await expect( + dialog.getByRole('heading', { name: 'Activity' }) + ).toBeVisible(); + } + }); + } + test('uses tablet disclosure, focuses destinations, and restores utility focus', async ({ page, }) => { @@ -419,7 +675,16 @@ test.describe('workspace shell', () => { await expect( page.getByRole('dialog', { name: 'Search documentation' }) ).toBeVisible(); + await expect( + page.getByRole('combobox', { name: 'Search documentation...' }) + ).toBeFocused(); await page.keyboard.press('Escape'); + await expect( + page.getByRole('dialog', { name: 'Search documentation' }) + ).toHaveCount(0); + await expect( + page.getByRole('button', { name: 'Open navigation' }) + ).toBeFocused(); } }); @@ -443,14 +708,31 @@ test.describe('workspace shell', () => { expect(Number.parseFloat(styles.borderWidth)).toBeGreaterThan(0); expect(styles.outlineStyle).not.toBe('none'); expect(Number.parseFloat(styles.outlineWidth)).toBeGreaterThan(0); + + await run.click(); + await expect(page.getByText('Ready', { exact: true })).toBeVisible(); + const runtime = page.getByRole('button', { name: 'Runtime', exact: true }); + await runtime.focus(); + const runtimeStyles = await runtime.evaluate((element) => { + const style = getComputedStyle(element); + return { + borderWidth: style.borderTopWidth, + outlineStyle: style.outlineStyle, + outlineWidth: style.outlineWidth, + }; + }); + expect(Number.parseFloat(runtimeStyles.borderWidth)).toBeGreaterThan(0); + expect(runtimeStyles.outlineStyle).not.toBe('none'); + expect(Number.parseFloat(runtimeStyles.outlineWidth)).toBeGreaterThan(0); }); test('removes mobile control-plane motion when reduced motion is requested', async ({ page, }) => { await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.route('http://localhost:4300/**', (request) => request.abort()); await page.setViewportSize({ width: 390, height: 844 }); - await page.goto(streamingDocsPath); + await page.goto(`${streamingDocsPath}?mode=run`); await page.getByRole('button', { name: 'Open navigation' }).click(); const dialog = page.getByRole('dialog', { @@ -470,6 +752,13 @@ test.describe('workspace shell', () => { panelTransition: panelStyle?.transitionDuration, }; }); + const loader = dialog.locator('.cockpit-runtime-status-loader'); + await expect(loader).toBeVisible(); + expect( + await loader.evaluate( + (element) => getComputedStyle(element).animationName + ) + ).toBe('none'); expect(motion).toEqual({ overlayAnimation: 'none', overlayTransition: '0s', diff --git a/apps/website/playwright.config.ts b/apps/website/playwright.config.ts index 7e7d834b3..e3f0b2b11 100644 --- a/apps/website/playwright.config.ts +++ b/apps/website/playwright.config.ts @@ -1,47 +1,113 @@ import { defineConfig, devices } from '@playwright/test'; -const localHost = '127.0.0.1'; -const localPort = process.env['WEBSITE_E2E_PORT'] ?? '4308'; -const localURL = `http://${localHost}:${localPort}`; -const runtimeURL = 'http://localhost:4300'; -const baseURL = process.env['BASE_URL'] ?? localURL; -const shouldStartLocalServer = !process.env['BASE_URL']; -const reuseExistingServer = - process.env['PLAYWRIGHT_REUSE_EXISTING_SERVER'] === 'true'; +type WebsitePlaywrightEnvironment = Readonly< + Record +>; -export default defineConfig({ - testDir: './e2e', - fullyParallel: true, - // Match the cockpit configs: 2 retries on CI to absorb transient Next.js - // dev-server startup flake; 0 locally for fast feedback. - retries: process.env['CI'] ? 2 : 0, - use: { - baseURL, - }, - // Declare chromium as the only browser project — see the matching comment - // in apps/cockpit/playwright.config.ts. Suppresses the misleading - // "missing system dependencies" warning for webkit/firefox. - projects: [ - { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, +export const createWebsitePlaywrightConfig = ( + environment: WebsitePlaywrightEnvironment = process.env +) => { + const localHost = '127.0.0.1'; + const localPort = environment['WEBSITE_E2E_PORT'] ?? '4308'; + const localURL = `http://${localHost}:${localPort}`; + const runtimeURL = 'http://localhost:4300'; + const productionSmoke = environment['PRODUCTION_SMOKE'] === 'true'; + const bfcacheRuntimeTest = environment['CUSTOM_RUNTIME_BFCACHE'] === 'true'; + const baseURL = environment['BASE_URL'] ?? localURL; + const shouldStartLocalServer = !productionSmoke && !environment['BASE_URL']; + const reuseExistingServer = + environment['PLAYWRIGHT_REUSE_EXISTING_SERVER'] === 'true'; + + return defineConfig({ + testDir: './e2e', + testMatch: bfcacheRuntimeTest + ? '**/custom-runtime-bfcache.spec.ts' + : undefined, + testIgnore: productionSmoke + ? undefined + : bfcacheRuntimeTest + ? '**/platform-production-smoke.spec.ts' + : [ + '**/platform-production-smoke.spec.ts', + '**/custom-runtime-bfcache.spec.ts', + ], + fullyParallel: true, + // Match the cockpit configs: 2 retries on CI to absorb transient Next.js + // dev-server startup flake; 0 locally for fast feedback. + retries: environment['CI'] ? 2 : 0, + use: { + baseURL, + // Custom-target coverage carries an obvious fixture key. Keep browser + // artifacts disabled so request headers and page state are never retained. + trace: 'off', + video: 'off', }, - ], - webServer: shouldStartLocalServer - ? [ - { - command: `NEXT_PUBLIC_COCKPIT_RUNTIME_BASE_URL='' npx next dev apps/website --hostname ${localHost} --port ${localPort}`, - cwd: '../..', - url: localURL, - reuseExistingServer, - }, - { - command: - 'npx nx run cockpit-langgraph-streaming-angular:serve:cockpit --port 4300', - cwd: '../..', - url: runtimeURL, - reuseExistingServer, + // Declare chromium as the only browser project. This suppresses the + // misleading "missing system dependencies" warning for webkit/firefox. + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + ...(bfcacheRuntimeTest + ? { + launchOptions: { + channel: 'chromium', + ignoreDefaultArgs: ['--disable-back-forward-cache'], + args: ['--enable-features=BackForwardCache'], + }, + } + : {}), }, - ] - : undefined, -}); + }, + ], + webServer: shouldStartLocalServer + ? [ + { + command: bfcacheRuntimeTest + ? `NEXT_PUBLIC_COCKPIT_RUNTIME_BASE_URL='' npx nx build website --configuration=production --skip-nx-cache && npx nx serve website --configuration=production --port=${localPort} --skip-nx-cache` + : `NEXT_PUBLIC_COCKPIT_RUNTIME_BASE_URL='' npx next dev apps/website --hostname ${localHost} --port ${localPort}`, + cwd: '../..', + url: localURL, + reuseExistingServer, + timeout: bfcacheRuntimeTest ? 180_000 : 60_000, + }, + { + command: bfcacheRuntimeTest + ? 'npx nx build cockpit-langgraph-streaming-angular --configuration=cockpit --skip-nx-cache && npx http-server dist/cockpit/langgraph/streaming/angular -p 4300 -c-1' + : 'npx nx run cockpit-langgraph-streaming-angular:serve:cockpit --port 4300', + cwd: '../..', + url: runtimeURL, + reuseExistingServer, + }, + ...(!bfcacheRuntimeTest + ? [ + { + command: + 'npx nx run cockpit-ag-ui-streaming-angular:serve:cockpit --port 4321', + cwd: '../..', + url: 'http://localhost:4321', + reuseExistingServer, + }, + { + command: + 'npx nx run cockpit-chat-threads-angular:serve:cockpit --port 4506', + cwd: '../..', + url: 'http://localhost:4506', + reuseExistingServer, + }, + ] + : []), + { + command: + 'npx tsx apps/website/e2e/fixtures/custom-runtime-server.ts', + cwd: '../..', + url: 'http://127.0.0.1:4399/health', + reuseExistingServer, + }, + ] + : undefined, + }); +}; + +export default createWebsitePlaywrightConfig(); diff --git a/apps/website/project.json b/apps/website/project.json index 3352773ec..65e8f259f 100644 --- a/apps/website/project.json +++ b/apps/website/project.json @@ -5,19 +5,15 @@ "projectType": "application", "implicitDependencies": [ "workspace-react", - "cockpit-langgraph-streaming-angular" - ], - "tags": [ - "scope:website", - "scope:website-e2e", - "type:app" + "cockpit-langgraph-streaming-angular", + "cockpit-ag-ui-streaming-angular", + "cockpit-chat-threads-angular" ], + "tags": ["scope:website", "scope:website-e2e", "type:app"], "targets": { "build": { "executor": "@nx/next:build", - "outputs": [ - "{options.outputPath}" - ], + "outputs": ["{options.outputPath}"], "defaultConfiguration": "production", "options": { "outputPath": "dist/apps/website" @@ -30,11 +26,7 @@ "outputPath": "dist/apps/website" } }, - "inputs": [ - "default", - "deploymentConfig", - "^default" - ] + "inputs": ["default", "deploymentConfig", "^default"] }, "serve": { "executor": "@nx/next:server", @@ -63,9 +55,7 @@ }, "lint": { "executor": "@nx/eslint:lint", - "outputs": [ - "{options.outputFile}" - ] + "outputs": ["{options.outputFile}"] }, "test": { "executor": "@nx/vitest:test", @@ -81,8 +71,6 @@ } }, "namedInputs": { - "deploymentConfig": [ - "{workspaceRoot}/vercel.json" - ] + "deploymentConfig": ["{workspaceRoot}/vercel.json"] } } diff --git a/apps/website/scripts/capture-screenshots.spec.ts b/apps/website/scripts/capture-screenshots.spec.ts new file mode 100644 index 000000000..62ca5477e --- /dev/null +++ b/apps/website/scripts/capture-screenshots.spec.ts @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT +import { pathToFileURL } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { + CAPTURE_TARGETS, + DEFAULT_WEBSITE_URL, + WORKSPACE_CONTENT_SELECTOR, + WORKSPACE_READY_SELECTOR, + isMainModule, + modeButtonName, + parseCaptureArgs, + workspaceModeSelector, +} from './capture-screenshots'; + +describe('Website workspace screenshot capture', () => { + it('defaults to the canonical streaming Run route', () => { + expect(DEFAULT_WEBSITE_URL).toBe( + 'https://threadplane.ai/docs/langgraph/guides/streaming?mode=run' + ); + expect(parseCaptureArgs([])).toEqual({ + url: DEFAULT_WEBSITE_URL, + keepPng: false, + }); + }); + + it('parses CLI overrides without changing existing flag behavior', () => { + expect( + parseCaptureArgs([ + '--keep-png', + '--url', + 'http://localhost:3000/docs/langgraph/guides/streaming?mode=run', + ]) + ).toEqual({ + url: 'http://localhost:3000/docs/langgraph/guides/streaming?mode=run', + keepPng: true, + }); + }); + + it('uses executable Website workspace selectors and mode matchers', () => { + expect(WORKSPACE_READY_SELECTOR).toBe( + '[data-workspace-shell][data-hydrated="true"]' + ); + expect(WORKSPACE_CONTENT_SELECTOR).toBe('[data-cockpit-workspace]'); + expect(workspaceModeSelector('Code')).toBe( + '[data-workspace-shell][data-workspace-mode="Code"]' + ); + + const runName = modeButtonName('Run'); + expect(runName.test('Run')).toBe(true); + expect(runName.test('Run, Ready')).toBe(true); + expect(runName.test('Runtime')).toBe(false); + }); + + it('preserves all four modes and output basenames', () => { + expect(CAPTURE_TARGETS.map(({ name, mode }) => ({ name, mode }))).toEqual([ + { name: 'cockpit-run', mode: 'Run' }, + { name: 'cockpit-code', mode: 'Code' }, + { name: 'cockpit-docs', mode: 'Docs' }, + { name: 'cockpit-api', mode: 'API' }, + ]); + }); + + it('does not treat an imported module as the CLI entrypoint', () => { + const entry = '/tmp/capture-screenshots.ts'; + expect(isMainModule(pathToFileURL(entry).href, entry)).toBe(true); + expect(isMainModule('file:///tmp/importer.ts', entry)).toBe(false); + expect(isMainModule('file:///tmp/importer.ts', undefined)).toBe(false); + }); +}); diff --git a/apps/website/scripts/capture-screenshots.ts b/apps/website/scripts/capture-screenshots.ts index b630bfd43..09d46c2e4 100644 --- a/apps/website/scripts/capture-screenshots.ts +++ b/apps/website/scripts/capture-screenshots.ts @@ -1,16 +1,16 @@ /** - * Capture product screenshots from the live cockpit demo + * Capture product screenshots from the live Website workspace * for use in the marketing site's BrowserFrame placeholders. * - * Captures cockpit.threadplane.ai in each of its 4 modes (Run, Code, - * Docs, API) at 2× DPR, then crops the cockpit content well, optimizes + * Captures the streaming docs workspace in each of its 4 modes (Run, Code, + * Docs, API) at 2× DPR, then crops the workspace content well, optimizes * to WebP, and writes to apps/website/public/screenshots/. * * Usage: - * pnpm tsx apps/website/scripts/capture-screenshots.ts + * npx tsx apps/website/scripts/capture-screenshots.ts * * Optional flags: - * --url Override the cockpit URL (default cockpit.threadplane.ai) + * --url Override the Website workspace URL * --keep-png Keep the intermediate PNG files (for debugging) * * The script is idempotent — it overwrites existing files in @@ -20,20 +20,24 @@ */ import { chromium, type Page } from 'playwright'; import sharp from 'sharp'; -import { mkdir, writeFile, unlink } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; +import { mkdir, unlink } from 'node:fs/promises'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { existsSync } from 'node:fs'; -const DEFAULT_COCKPIT_URL = - 'https://cockpit.threadplane.ai/langgraph/core-capabilities/streaming/overview/python'; +export const DEFAULT_WEBSITE_URL = + 'https://threadplane.ai/docs/langgraph/guides/streaming?mode=run'; +export const WORKSPACE_READY_SELECTOR = + '[data-workspace-shell][data-hydrated="true"]'; +export const WORKSPACE_CONTENT_SELECTOR = '[data-cockpit-workspace]'; -interface CaptureTarget { +export interface CaptureTarget { /** Output filename (without extension). */ name: string; - /** Cockpit mode to switch to before capturing. */ + /** Workspace mode to switch to before capturing. */ mode: 'Run' | 'Code' | 'Docs' | 'API'; /** - * Selector for the element to capture. If omitted, captures the cockpit + * Selector for the element to capture. If omitted, captures the workspace * content section (everything except the sidebar) at full size. */ selector?: string; @@ -41,7 +45,7 @@ interface CaptureTarget { settleMs?: number; } -const TARGETS: CaptureTarget[] = [ +export const CAPTURE_TARGETS: readonly CaptureTarget[] = [ // Hero collage back frame + Stream FeatureBlock + Pilot "Build" block. // The "Run" mode shows the live chat surface — captures real product UI. { name: 'cockpit-run', mode: 'Run', settleMs: 4000 }, @@ -56,14 +60,15 @@ const TARGETS: CaptureTarget[] = [ { name: 'cockpit-api', mode: 'API', settleMs: 1500 }, ]; -interface Args { +export interface CaptureArgs { url: string; keepPng: boolean; } -function parseArgs(): Args { - const args = process.argv.slice(2); - const out: Args = { url: DEFAULT_COCKPIT_URL, keepPng: false }; +export function parseCaptureArgs( + args: readonly string[] = process.argv.slice(2) +): CaptureArgs { + const out: CaptureArgs = { url: DEFAULT_WEBSITE_URL, keepPng: false }; for (let i = 0; i < args.length; i++) { if (args[i] === '--url' && i + 1 < args.length) { out.url = args[++i]; @@ -74,16 +79,35 @@ function parseArgs(): Args { return out; } +export function modeButtonName(mode: CaptureTarget['mode']): RegExp { + return new RegExp(`^${mode}(?:,|$)`); +} + +export function workspaceModeSelector(mode: CaptureTarget['mode']): string { + return `[data-workspace-shell][data-workspace-mode="${mode}"]`; +} + +export function isMainModule( + metaUrl: string, + argvEntry: string | undefined = process.argv[1] +): boolean { + return Boolean(argvEntry && metaUrl === pathToFileURL(argvEntry).href); +} + async function ensureDir(path: string): Promise { if (!existsSync(path)) await mkdir(path, { recursive: true }); } async function switchMode(page: Page, mode: CaptureTarget['mode']): Promise { - // Cockpit's ModeSwitcher renders buttons with the mode name as text. - // Target the exact button by accessible name. - const button = page.getByRole('button', { name: mode, exact: true }); + // The Website control plane exposes each mode as an accessible button. + const button = page.getByRole('button', { + name: modeButtonName(mode), + }); await button.waitFor({ state: 'visible', timeout: 10_000 }); await button.click(); + await page + .locator(workspaceModeSelector(mode)) + .waitFor({ state: 'visible', timeout: 15_000 }); } async function captureOne( @@ -99,11 +123,11 @@ async function captureOne( console.log(` → waiting ${settle}ms for content to settle`); await page.waitForTimeout(settle); - // Capture the cockpit content section (not the sidebar — we want the + // Capture the Website workspace content section (not the sidebar — we want the // mode content visible at the top, not the sidebar nav). const locator = target.selector ? page.locator(target.selector) - : page.locator('main[aria-label="Cockpit shell"] section').first(); + : page.locator(WORKSPACE_CONTENT_SELECTOR); const pngPath = join(outputDir, `${target.name}.png`); const webpPath = join(outputDir, `${target.name}.webp`); @@ -123,11 +147,11 @@ async function captureOne( } async function main(): Promise { - const args = parseArgs(); + const args = parseCaptureArgs(); const outputDir = join(process.cwd(), 'apps/website/public/screenshots'); await ensureDir(outputDir); - console.log(`Capturing cockpit screenshots from: ${args.url}`); + console.log(`Capturing Website workspace screenshots from: ${args.url}`); console.log(`Output: ${outputDir}\n`); const browser = await chromium.launch({ headless: true }); @@ -138,14 +162,17 @@ async function main(): Promise { const page = await context.newPage(); try { - console.log(`Loading cockpit at ${args.url}`); - await page.goto(args.url, { waitUntil: 'networkidle', timeout: 30_000 }); + console.log(`Loading Website workspace at ${args.url}`); + await page.goto(args.url, { waitUntil: 'domcontentloaded', timeout: 30_000 }); - // Wait for cockpit shell to hydrate. - await page.waitForSelector('[data-hydrated="true"]', { timeout: 15_000 }); - console.log('Cockpit hydrated ✓\n'); + // Wait for the Website workspace shell to hydrate. + await page.waitForSelector( + WORKSPACE_READY_SELECTOR, + { timeout: 15_000 } + ); + console.log('Website workspace hydrated ✓\n'); - for (const target of TARGETS) { + for (const target of CAPTURE_TARGETS) { console.log(`Capturing: ${target.name}`); await captureOne(page, target, outputDir, args.keepPng); console.log(''); @@ -157,7 +184,9 @@ async function main(): Promise { } } -main().catch((err) => { - console.error('Capture failed:', err); - process.exit(1); -}); +if (isMainModule(import.meta.url)) { + void main().catch((err) => { + console.error('Capture failed:', err); + process.exit(1); + }); +} diff --git a/apps/website/scripts/generate-api-docs.ts b/apps/website/scripts/generate-api-docs.ts index 1daf94140..15e23f969 100644 --- a/apps/website/scripts/generate-api-docs.ts +++ b/apps/website/scripts/generate-api-docs.ts @@ -41,6 +41,16 @@ function extractExamples(comment: any): string[] { .map((t: any) => t.content.map((c: any) => c.text ?? '').join('').trim()); } +function isInternalReflection(ref: any): boolean { + if (typeof ref?.name === 'string' && ref.name.startsWith('ɵ')) return true; + const modifierTags = ref?.comment?.modifierTags; + return ( + modifierTags !== undefined && + typeof modifierTags.has === 'function' && + modifierTags.has('@internal') + ); +} + /** Renders a single parameter for a signature string, preserving rest (`...`) syntax. */ function paramToSigString(p: any): string { return `${p.flags?.isRest ? '...' : ''}${p.name}: ${extractType(p.type)}`; @@ -74,15 +84,18 @@ function extractType(typeObj: any): string { function extractParams(sig: any): ApiParam[] { if (!sig?.parameters) return []; - return sig.parameters.map((p: any) => ({ - name: `${p.flags?.isRest ? '...' : ''}${p.name}`, - type: extractType(p.type), - description: extractDescription(p.comment), - // A parameter is optional if explicitly marked `?` OR it has a default - // value (e.g. `opts: MockAgentOptions = {}`) — TypeDoc only sets the - // `isOptional` flag for the former, so check `defaultValue` for the latter. - optional: (p.flags?.isOptional ?? false) || p.defaultValue !== undefined, - })); + return sig.parameters + .filter((p: any) => !isInternalReflection(p)) + .map((p: any) => ({ + name: `${p.flags?.isRest ? '...' : ''}${p.name}`, + type: extractType(p.type), + description: extractDescription(p.comment), + // A parameter is optional if explicitly marked `?` OR it has a default + // value (e.g. `opts: MockAgentOptions = {}`) — TypeDoc only sets the + // `isOptional` flag for the former, so check `defaultValue` for the latter. + optional: + (p.flags?.isOptional ?? false) || p.defaultValue !== undefined, + })); } function reflectionToEntry(ref: any): ApiDocEntry | null { @@ -105,10 +118,16 @@ function reflectionToEntry(ref: any): ApiDocEntry | null { if (kind === ReflectionKind.Class) { const props = (ref.children ?? []) - .filter((c: any) => c.kind === ReflectionKind.Property) + .filter( + (c: any) => + c.kind === ReflectionKind.Property && !isInternalReflection(c) + ) .map((c: any) => ({ name: c.name, type: extractType(c.type), description: extractDescription(c.comment), optional: c.flags?.isOptional })); const methods = (ref.children ?? []) - .filter((c: any) => c.kind === ReflectionKind.Method) + .filter( + (c: any) => + c.kind === ReflectionKind.Method && !isInternalReflection(c) + ) .map((c: any) => { const sig = c.signatures?.[0]; return { name: c.name, signature: signatureToString(c.name, sig), description: extractDescription(c.comment) || extractDescription(sig?.comment), params: extractParams(sig) }; @@ -128,7 +147,10 @@ function reflectionToEntry(ref: any): ApiDocEntry | null { if (kind === ReflectionKind.Interface) { const children = ref.children ?? []; const props = children - .filter((c: any) => c.kind !== ReflectionKind.Method) + .filter( + (c: any) => + c.kind !== ReflectionKind.Method && !isInternalReflection(c) + ) .map((c: any) => ({ name: c.name, type: extractType(c.type), @@ -136,7 +158,10 @@ function reflectionToEntry(ref: any): ApiDocEntry | null { optional: c.flags?.isOptional, })); const methods = children - .filter((c: any) => c.kind === ReflectionKind.Method) + .filter( + (c: any) => + c.kind === ReflectionKind.Method && !isInternalReflection(c) + ) .map((c: any) => { const sig = c.signatures?.[0]; return { @@ -162,6 +187,7 @@ function reflectionToEntry(ref: any): ApiDocEntry | null { function collectApiEntries(reflections: any[]): ApiDocEntry[] { return reflections.flatMap((ref) => { + if (isInternalReflection(ref)) return []; const entry = reflectionToEntry(ref); if (entry) return [entry]; return collectApiEntries(ref.children ?? []); @@ -236,8 +262,31 @@ function findPackageRoot(entryPoint: string): string { return path.dirname(path.dirname(entryPoint)); } +function selectedLibrarySlugs(args: readonly string[]): ReadonlySet | null { + const flags = args.filter((arg) => arg.startsWith('--libraries=')); + if (flags.length === 0) return null; + if (flags.length !== 1) throw new Error('Pass --libraries at most once'); + const selected = new Set( + flags[0] + .slice('--libraries='.length) + .split(',') + .map((slug) => slug.trim()) + .filter(Boolean) + ); + const known = new Set(LIBRARIES.map((library) => library.docSlug)); + const unknown = [...selected].filter((slug) => !known.has(slug)); + if (selected.size === 0 || unknown.length > 0) { + throw new Error( + `Unknown or empty API-doc library selection: ${unknown.join(', ')}` + ); + } + return selected; +} + async function main() { + const selected = selectedLibrarySlugs(process.argv.slice(2)); for (const cfg of LIBRARIES) { + if (selected !== null && !selected.has(cfg.docSlug)) continue; await generateForLibrary(cfg); } } diff --git a/apps/website/src/app/chat/page.spec.tsx b/apps/website/src/app/chat/page.spec.tsx index 8aa4cc70e..0832e7a6c 100644 --- a/apps/website/src/app/chat/page.spec.tsx +++ b/apps/website/src/app/chat/page.spec.tsx @@ -30,4 +30,16 @@ describe('ChatPage', () => { ); expect(cta?.getAttribute('data-surface')).toBe('dark'); }); + + it('opens generative UI in the same-origin Website workspace', async () => { + const ui = await ChatPage(); + render(ui); + + const link = screen.getByRole('link', { name: 'See it live →' }); + expect(link.getAttribute('href')).toBe( + '/docs/chat/guides/generative-ui?mode=run' + ); + expect(link.getAttribute('target')).toBeNull(); + expect(link.getAttribute('rel')).toBeNull(); + }); }); diff --git a/apps/website/src/app/chat/page.tsx b/apps/website/src/app/chat/page.tsx index d13b98c10..f057c7de8 100644 --- a/apps/website/src/app/chat/page.tsx +++ b/apps/website/src/app/chat/page.tsx @@ -40,7 +40,7 @@ export default async function ChatPage() {

-
diff --git a/apps/website/src/app/layout.tsx b/apps/website/src/app/layout.tsx index dd412c7a1..86f698e87 100644 --- a/apps/website/src/app/layout.tsx +++ b/apps/website/src/app/layout.tsx @@ -5,7 +5,7 @@ import './global.css'; import { Nav } from '../components/shared/Nav'; import { SiteFooter } from '../components/shared/SiteFooter'; import { AnnouncementToast } from '../components/shared/AnnouncementToast'; -import { WebsiteWorkspaceLayout } from '../components/workspace/WebsiteWorkspace'; +import { WebsiteWorkspaceRoot } from '../components/workspace/WebsiteWorkspace'; import { JsonLd } from '../components/shared/JsonLd'; import { rootJsonLd } from '../lib/structured-data'; import { @@ -79,7 +79,7 @@ export default function RootLayout({