From 87ae9df0429aff30d8de6fb807a97d1b349c2747 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Mon, 10 Nov 2025 10:47:48 -0500 Subject: [PATCH 1/5] refactor: dont crash trigger on cloud tests job (#1726) * refactor: dont crash trigger on cloud tests job * chore: fix types --- .../cloud-tests/actions/connect-cloud.ts | 15 +- .../components/CloudConnectionCard.tsx | 23 +- .../cloud-tests/components/EmptyState.tsx | 14 +- .../cloud-tests/components/ResultsView.tsx | 66 +++++- .../cloud-tests/components/TestsLayout.tsx | 218 ++++++++++++++---- .../app/(app)/[orgId]/cloud-tests/types.ts | 17 ++ .../integration/run-integration-tests.ts | 71 ++++-- 7 files changed, 345 insertions(+), 79 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/cloud-tests/types.ts diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/connect-cloud.ts b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/connect-cloud.ts index 878377adc..0174f9ace 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/connect-cloud.ts +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/connect-cloud.ts @@ -4,7 +4,7 @@ import { encrypt } from '@/lib/encryption'; import { getIntegrationHandler } from '@comp/integrations'; import { db } from '@db'; import { revalidatePath } from 'next/cache'; -import { headers } from 'next/headers'; +import { cookies, headers } from 'next/headers'; import { z } from 'zod'; import { authActionClient } from '../../../../../actions/safe-action'; import { runTests } from './run-tests'; @@ -100,7 +100,11 @@ export const connectCloudAction = authActionClient } // Trigger immediate scan - await runTests(); + const runResult = await runTests(); + + if (runResult.success && runResult.publicAccessToken) { + (await cookies()).set('publicAccessToken', runResult.publicAccessToken); + } // Revalidate the path const headersList = await headers(); @@ -110,6 +114,13 @@ export const connectCloudAction = authActionClient return { success: true, + trigger: runResult.success + ? { + taskId: runResult.taskId ?? undefined, + publicAccessToken: runResult.publicAccessToken ?? undefined, + } + : undefined, + runErrors: runResult.success ? undefined : (runResult.errors ?? undefined), }; } catch (error) { console.error('Failed to connect cloud provider:', error); diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudConnectionCard.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudConnectionCard.tsx index fb44d3d06..479770906 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudConnectionCard.tsx +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudConnectionCard.tsx @@ -18,6 +18,11 @@ interface CloudField { type?: string; } +type TriggerInfo = { + taskId?: string; + publicAccessToken?: string; +}; + interface CloudConnectionCardProps { cloudProvider: 'aws' | 'gcp' | 'azure'; name: string; @@ -27,7 +32,7 @@ interface CloudConnectionCardProps { guideUrl?: string; color?: string; logoUrl?: string; - onSuccess?: () => void; + onSuccess?: (trigger?: TriggerInfo) => void; } export function CloudConnectionCard({ @@ -83,7 +88,11 @@ export function CloudConnectionCard({ if (result?.data?.success) { toast.success(`${name} connected! Running initial scan...`); setCredentials({}); - onSuccess?.(); + onSuccess?.(result.data?.trigger); + + if (result.data?.runErrors && result.data.runErrors.length > 0) { + toast.error(result.data.runErrors[0] || 'Initial scan reported an issue'); + } } else { toast.error(result?.data?.error || 'Failed to connect'); } @@ -99,13 +108,11 @@ export function CloudConnectionCard({
-
+
{logoUrl && ( - {`${shortName} + {`${shortName} )}
diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/EmptyState.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/EmptyState.tsx index b47a93d72..ca8988a78 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/EmptyState.tsx +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/EmptyState.tsx @@ -117,12 +117,18 @@ const PROVIDER_FIELDS: Record<'aws' | 'gcp' | 'azure', ProviderFieldWithOptions[ ], }; +type TriggerInfo = { + taskId?: string; + publicAccessToken?: string; +}; + interface EmptyStateProps { onBack?: () => void; connectedProviders?: string[]; + onConnected?: (trigger?: TriggerInfo) => void; } -export function EmptyState({ onBack, connectedProviders = [] }: EmptyStateProps = {}) { +export function EmptyState({ onBack, connectedProviders = [], onConnected }: EmptyStateProps = {}) { const [step, setStep] = useState('choose'); const [selectedProvider, setSelectedProvider] = useState(null); const [credentials, setCredentials] = useState>({}); @@ -224,6 +230,12 @@ export function EmptyState({ onBack, connectedProviders = [] }: EmptyStateProps if (result?.data?.success) { setStep('success'); + if (result.data?.trigger) { + onConnected?.(result.data.trigger); + } + if (result.data?.runErrors && result.data.runErrors.length > 0) { + toast.error(result.data.runErrors[0] || 'Initial scan reported an issue'); + } // If user already has clouds, automatically return to results after 2 seconds if (onBack) { setTimeout(() => { diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ResultsView.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ResultsView.tsx index d7a68a281..21c09a317 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ResultsView.tsx +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ResultsView.tsx @@ -3,8 +3,9 @@ import { Button } from '@comp/ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select'; import { useRealtimeRun } from '@trigger.dev/react-hooks'; -import { CheckCircle2, Info, Loader2, RefreshCw, X } from 'lucide-react'; +import { AlertTriangle, CheckCircle2, Info, Loader2, RefreshCw, X } from 'lucide-react'; import { useEffect, useState } from 'react'; +import type { IntegrationRunOutput } from '../types'; import { FindingsTable } from './FindingsTable'; interface Finding { @@ -23,6 +24,7 @@ interface ResultsViewProps { scanAccessToken: string | null; onRunScan: () => Promise; isScanning: boolean; + runOutput: IntegrationRunOutput | null; } const severityOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 }; @@ -50,6 +52,7 @@ export function ResultsView({ scanAccessToken, onRunScan, isScanning, + runOutput, }: ResultsViewProps) { // Track scan status with Trigger.dev hooks const { run } = useRealtimeRun(scanTaskId || '', { @@ -64,17 +67,28 @@ export function ResultsView({ const [selectedSeverity, setSelectedSeverity] = useState('all'); const [showSuccessBanner, setShowSuccessBanner] = useState(false); const [showErrorBanner, setShowErrorBanner] = useState(false); + const [showOutputErrorBanner, setShowOutputErrorBanner] = useState(false); - // Show success banner when scan completes, auto-hide after 5 seconds + const runOutputError = runOutput && !runOutput.success ? runOutput : null; + const scanSucceeded = scanCompleted && !runOutputError; + const outputErrorMessages = + runOutputError?.errors && runOutputError.errors.length > 0 + ? runOutputError.errors + : runOutputError?.failedIntegrations?.map( + (integration) => `${integration.name}: ${integration.error}`, + ) ?? []; + + // Show success banner when scan completes successfully, auto-hide after 5 seconds useEffect(() => { - if (scanCompleted) { + if (scanSucceeded) { setShowSuccessBanner(true); const timer = setTimeout(() => { setShowSuccessBanner(false); }, 5000); return () => clearTimeout(timer); } - }, [scanCompleted]); + setShowSuccessBanner(false); + }, [scanSucceeded]); // Auto-dismiss error banner after 30 seconds useEffect(() => { @@ -87,6 +101,16 @@ export function ResultsView({ } }, [scanFailed]); + // Show output error banner when run completes with errors + useEffect(() => { + if (runOutputError) { + setShowOutputErrorBanner(true); + setShowSuccessBanner(false); + } else { + setShowOutputErrorBanner(false); + } + }, [runOutputError]); + // Get unique statuses and severities const uniqueStatuses = Array.from( new Set(findings.map((f) => f.status).filter(Boolean) as string[]), @@ -146,8 +170,40 @@ export function ResultsView({
)} + {/* Output error banner when run reports errors but job didn't crash */} + {showOutputErrorBanner && runOutputError && !isScanning && ( +
+ +
+

Scan completed with errors

+
    + {outputErrorMessages.slice(0, 5).map((message, index) => ( +
  • • {message}
  • + ))} + {outputErrorMessages.length === 0 && ( +
  • Encountered an unknown error while processing integration results.
  • + )} +
+ {runOutputError.failedIntegrations && runOutputError.failedIntegrations.length > 0 && ( +

+ {runOutputError.failedIntegrations.length} integration + {runOutputError.failedIntegrations.length === 1 ? '' : 's'} returned errors. +

+ )} +
+ +
+ )} + {/* Propagation delay info banner - only when scan succeeds but returns empty output */} - {scanCompleted && findings.length === 0 && !isScanning && !scanFailed && ( + {scanCompleted && findings.length === 0 && !isScanning && !scanFailed && !runOutputError && (
diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/TestsLayout.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/TestsLayout.tsx index 7928547de..636575fb6 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/TestsLayout.tsx +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/TestsLayout.tsx @@ -7,8 +7,10 @@ import { useRealtimeRun } from '@trigger.dev/react-hooks'; import { Plus, Settings } from 'lucide-react'; import { useEffect, useState } from 'react'; import { toast } from 'sonner'; +import type { Fetcher } from 'swr'; import useSWR from 'swr'; import { runTests } from '../actions/run-tests'; +import type { IntegrationRunOutput } from '../types'; import { ChatPlaceholder } from './ChatPlaceholder'; import { CloudSettingsModal } from './CloudSettingsModal'; import { EmptyState } from './EmptyState'; @@ -32,22 +34,107 @@ interface TestsLayoutProps { initialProviders: Integration[]; } -const fetcher = async (url: string) => { - const res = await fetch(url); - if (!res.ok) throw new Error('Failed to fetch'); - return res.json(); +type SupportedProviderId = 'aws' | 'gcp' | 'azure'; +type SupportedIntegration = Integration & { integrationId: SupportedProviderId }; +type TriggerInfo = { + taskId?: string; + publicAccessToken?: string; }; +type ActiveScanStatus = + | 'QUEUED' + | 'EXECUTING' + | 'WAITING_FOR_DEPLOY' + | 'REATTEMPTING' + | 'WAITING_FOR_CLAIM' + | 'WAITING_FOR_START' + | 'PENDING'; + +const SUPPORTED_PROVIDER_IDS: readonly SupportedProviderId[] = ['aws', 'gcp', 'azure']; +const SUPPORTED_PROVIDER_ID_SET: ReadonlySet = new Set(SUPPORTED_PROVIDER_IDS); +const ACTIVE_SCAN_STATUSES: ReadonlySet = new Set([ + 'QUEUED', + 'EXECUTING', + 'WAITING_FOR_DEPLOY', + 'REATTEMPTING', + 'WAITING_FOR_CLAIM', + 'WAITING_FOR_START', + 'PENDING', +]); + +const isSupportedProviderId = (integrationId: string): integrationId is SupportedProviderId => + SUPPORTED_PROVIDER_ID_SET.has(integrationId as SupportedProviderId); + +const isActiveScanStatus = (status: string | null | undefined): status is ActiveScanStatus => + Boolean(status) && ACTIVE_SCAN_STATUSES.has(status as ActiveScanStatus); + +const isIntegrationRunOutput = (value: unknown): value is IntegrationRunOutput => { + if (typeof value !== 'object' || value === null) { + return false; + } + + const candidate = value as { + success?: unknown; + errors?: unknown; + failedIntegrations?: unknown; + }; + + if (typeof candidate.success !== 'boolean') { + return false; + } + + if ( + candidate.errors !== undefined && + (!Array.isArray(candidate.errors) || candidate.errors.some((item) => typeof item !== 'string')) + ) { + return false; + } + + if (candidate.failedIntegrations !== undefined) { + if (!Array.isArray(candidate.failedIntegrations)) { + return false; + } + + for (const item of candidate.failedIntegrations) { + if ( + typeof item !== 'object' || + item === null || + typeof (item as { id?: unknown }).id !== 'string' || + typeof (item as { integrationId?: unknown }).integrationId !== 'string' || + typeof (item as { name?: unknown }).name !== 'string' || + typeof (item as { error?: unknown }).error !== 'string' + ) { + return false; + } + } + } + + return true; +}; + +async function fetchJson(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error('Failed to fetch'); + } + + return (await response.json()) as T; +} export function TestsLayout({ initialFindings, initialProviders }: TestsLayoutProps) { const [scanTaskId, setScanTaskId] = useState(null); const [scanAccessToken, setScanAccessToken] = useState(null); const [showSettings, setShowSettings] = useState(false); const [viewingResults, setViewingResults] = useState(true); + const [isScanManuallyPending, setIsScanManuallyPending] = useState(false); + const [lastRunOutput, setLastRunOutput] = useState(null); // Use SWR for real-time updates + const findingsFetcher: Fetcher = (url) => fetchJson(url); + const providersFetcher: Fetcher = (url) => fetchJson(url); + const { data: findings = initialFindings, mutate: mutateFindings } = useSWR( '/api/cloud-tests/findings', - fetcher, + findingsFetcher, { fallbackData: initialFindings, refreshInterval: 5000, // Refresh every 5 seconds when scanning @@ -57,15 +144,15 @@ export function TestsLayout({ initialFindings, initialProviders }: TestsLayoutPr const { data: providers = initialProviders, mutate: mutateProviders } = useSWR( '/api/cloud-tests/providers', - fetcher, + providersFetcher, { fallbackData: initialProviders, revalidateOnFocus: true, }, ); - const connectedProviders = (providers || []).filter((p) => - ['aws', 'gcp', 'azure'].includes(p.integrationId), + const connectedProviders = providers.filter((provider): provider is SupportedIntegration => + isSupportedProviderId(provider.integrationId), ); // Track scan run status with access token @@ -74,22 +161,58 @@ export function TestsLayout({ initialFindings, initialProviders }: TestsLayoutPr accessToken: scanAccessToken || undefined, }); + const isScanActive = + Boolean(scanTaskId) && + (isScanManuallyPending || !scanRun || isActiveScanStatus(scanRun.status)); + // Auto-refresh findings when scan completes useEffect(() => { - if (scanRun?.status === 'COMPLETED') { + if (!scanRun) { + return; + } + + let pendingTimeout: ReturnType | undefined; + + if (scanRun.status === 'COMPLETED') { + const runOutput = isIntegrationRunOutput(scanRun.output) ? scanRun.output : undefined; + mutateFindings(); - toast.success('Scan completed! Results updated.'); - } else if (scanRun?.status === 'FAILED' || scanRun?.status === 'CRASHED') { + setLastRunOutput(runOutput ?? null); + + if (runOutput && !runOutput.success) { + const errorMessage = + runOutput.errors?.[0] ?? + runOutput.failedIntegrations?.[0]?.error ?? + 'Scan completed with errors'; + + toast.error(errorMessage); + } else { + toast.success('Scan completed! Results updated.'); + } + } else if (scanRun.status === 'FAILED' || scanRun.status === 'CRASHED') { + setLastRunOutput(null); toast.error('Scan failed. Please try again.'); } - }, [scanRun?.status, mutateFindings]); + if (scanRun.status && !isActiveScanStatus(scanRun.status)) { + pendingTimeout = setTimeout(() => { + setIsScanManuallyPending(false); + }, 300); + } + return () => { + if (pendingTimeout) { + clearTimeout(pendingTimeout); + } + }; + }, [scanRun, mutateFindings]); const handleRunScan = async (): Promise => { try { + setLastRunOutput(null); const result = await runTests(); if (result.success && result.taskId && result.publicAccessToken) { setScanTaskId(result.taskId); setScanAccessToken(result.publicAccessToken); + setIsScanManuallyPending(true); toast.success('Scan started! Checking your cloud infrastructure...'); return result.taskId; } else { @@ -109,33 +232,44 @@ export function TestsLayout({ initialFindings, initialProviders }: TestsLayoutPr setViewingResults(true); }; + const handleCloudConnected = (trigger?: TriggerInfo) => { + mutateProviders(); + mutateFindings(); + setLastRunOutput(null); + + if (trigger?.taskId && trigger?.publicAccessToken) { + setScanTaskId(trigger.taskId); + setScanAccessToken(trigger.publicAccessToken); + setIsScanManuallyPending(true); + } + + setViewingResults(true); + }; + // First-time user: No clouds connected OR user wants to add a cloud if (connectedProviders.length === 0 || !viewingResults) { return ( 0 ? () => setViewingResults(true) : undefined} connectedProviders={connectedProviders.map((p) => p.integrationId)} + onConnected={handleCloudConnected} /> ); } // Group findings by cloud provider - const findingsByProvider = (findings || []).reduce( - (acc, finding) => { - const provider = finding.integration.integrationId; - if (!acc[provider]) { - acc[provider] = []; - } - acc[provider].push(finding); - return acc; - }, - {} as Record, - ); + const findingsByProvider = findings.reduce>((acc, finding) => { + const providerId = finding.integration.integrationId; + const bucket = acc[providerId] ?? []; + bucket.push(finding); + acc[providerId] = bucket; + return acc; + }, {}); // Single cloud user (primary use case) if (connectedProviders.length === 1) { const provider = connectedProviders[0]; - const providerFindings = findingsByProvider[provider.integrationId] || []; + const providerFindings = findingsByProvider[provider.integrationId] ?? []; return (
@@ -154,7 +288,7 @@ export function TestsLayout({ initialFindings, initialProviders }: TestsLayoutPr )}
- {connectedProviders.length < 3 && ( + {connectedProviders.length < SUPPORTED_PROVIDER_IDS.length && (
{/*
@@ -194,7 +322,7 @@ export function TestsLayout({ initialFindings, initialProviders }: TestsLayoutPr open={showSettings} onOpenChange={setShowSettings} connectedProviders={connectedProviders.map((p) => ({ - id: p.integrationId as 'aws' | 'gcp' | 'azure', + id: p.integrationId, name: p.name, fields: getProviderFields(p.integrationId), }))} @@ -205,7 +333,7 @@ export function TestsLayout({ initialFindings, initialProviders }: TestsLayoutPr } // Multi-cloud user (<5%) - const defaultTab = connectedProviders[0]?.integrationId || 'aws'; + const defaultTab = connectedProviders[0]?.integrationId ?? SUPPORTED_PROVIDER_IDS[0]; return (
@@ -223,7 +351,7 @@ export function TestsLayout({ initialFindings, initialProviders }: TestsLayoutPr )}
- {connectedProviders.length < 3 && ( + {connectedProviders.length < SUPPORTED_PROVIDER_IDS.length && (
@@ -286,7 +408,7 @@ export function TestsLayout({ initialFindings, initialProviders }: TestsLayoutPr open={showSettings} onOpenChange={setShowSettings} connectedProviders={connectedProviders.map((p) => ({ - id: p.integrationId as 'aws' | 'gcp' | 'azure', + id: p.integrationId, name: p.name, fields: getProviderFields(p.integrationId), }))} @@ -297,7 +419,9 @@ export function TestsLayout({ initialFindings, initialProviders }: TestsLayoutPr } // Helper function to get provider fields for settings modal -function getProviderFields(providerId: string) { +type ProviderField = { id: string; label: string }; + +function getProviderFields(providerId: SupportedProviderId): ProviderField[] { switch (providerId) { case 'aws': return [ @@ -317,7 +441,5 @@ function getProviderFields(providerId: string) { { id: 'AZURE_CLIENT_SECRET', label: 'Client Secret' }, { id: 'AZURE_SUBSCRIPTION_ID', label: 'Subscription ID' }, ]; - default: - return []; } } diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/types.ts b/apps/app/src/app/(app)/[orgId]/cloud-tests/types.ts new file mode 100644 index 000000000..345c3af4e --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/types.ts @@ -0,0 +1,17 @@ +export type FailedIntegration = { + id: string; + integrationId: string; + name: string; + error: string; +}; + +export type IntegrationRunOutput = { + success: boolean; + organizationId: string; + integrationsCount: number; + batchHandleId?: string; + errors?: string[]; + failedIntegrations?: FailedIntegration[]; +}; + + diff --git a/apps/app/src/jobs/tasks/integration/run-integration-tests.ts b/apps/app/src/jobs/tasks/integration/run-integration-tests.ts index 989061546..0e0784c25 100644 --- a/apps/app/src/jobs/tasks/integration/run-integration-tests.ts +++ b/apps/app/src/jobs/tasks/integration/run-integration-tests.ts @@ -40,7 +40,9 @@ export const runIntegrationTests = task({ }; } - logger.info(`Found ${integrations.length} integrations to test for organization: ${organizationId}`); + logger.info( + `Found ${integrations.length} integrations to test for organization: ${organizationId}`, + ); const batchItems = integrations.map((integration) => ({ payload: { @@ -58,22 +60,51 @@ export const runIntegrationTests = task({ try { const batchHandle = await sendIntegrationResults.batchTriggerAndWait(batchItems); - // Check if any child runs failed - const failedRuns = batchHandle.runs.filter((run) => !run.ok); + const failedIntegrations: Array<{ + id: string; + integrationId: string; + name: string; + error: string; + }> = []; - if (failedRuns.length > 0) { - const errorMessages = failedRuns - .map((run) => { - const errorMsg = run.error instanceof Error ? run.error.message : String(run.error); - return errorMsg; - }) - .join('; '); + batchHandle.runs.forEach((run, index) => { + if (run.ok) { + return; + } - logger.error(`Integration tests failed for organization ${organizationId}: ${errorMessages}`); - throw new Error(errorMessages); + const integration = integrations[index]; + const errorValue = run.error; + const errorMessage = + errorValue instanceof Error ? errorValue.message : String(errorValue ?? 'Unknown error'); + + failedIntegrations.push({ + id: integration.id, + integrationId: integration.integrationId, + name: integration.name, + error: errorMessage, + }); + }); + + if (failedIntegrations.length > 0) { + const errorMessages = failedIntegrations.map(({ error }) => error).join('; '); + + logger.warn( + `Integration tests completed with errors for organization ${organizationId}: ${errorMessages}`, + ); + + return { + success: false, + organizationId, + integrationsCount: integrations.length, + batchHandleId: batchHandle.id, + errors: failedIntegrations.map(({ error }) => error), + failedIntegrations, + }; } - logger.info(`Successfully completed batch integration tests for organization: ${organizationId}`); + logger.info( + `Successfully completed batch integration tests for organization: ${organizationId}`, + ); return { success: true, @@ -82,8 +113,18 @@ export const runIntegrationTests = task({ batchHandleId: batchHandle.id, }; } catch (error) { - logger.error(`Failed to run integration tests for organization ${organizationId}: ${error}`); - throw error; + const errorMessage = error instanceof Error ? error.message : String(error); + + logger.error( + `Failed to run integration tests for organization ${organizationId}: ${errorMessage}`, + ); + + return { + success: false, + organizationId, + integrationsCount: integrations.length, + errors: [errorMessage], + }; } }, }); From 8d90d7ff1c0a9444a07b083d267e477bc3d2d27e Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Mon, 10 Nov 2025 14:36:54 -0500 Subject: [PATCH 2/5] Mariano/trigger errors 2 (#1729) * chore: add forced fail prop for testing --------- Signed-off-by: Mariano Fuentes --- .../integration/run-integration-tests.ts | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/apps/app/src/jobs/tasks/integration/run-integration-tests.ts b/apps/app/src/jobs/tasks/integration/run-integration-tests.ts index 0e0784c25..1b5fcecc8 100644 --- a/apps/app/src/jobs/tasks/integration/run-integration-tests.ts +++ b/apps/app/src/jobs/tasks/integration/run-integration-tests.ts @@ -4,8 +4,8 @@ import { sendIntegrationResults } from './integration-results'; export const runIntegrationTests = task({ id: 'run-integration-tests', - run: async (payload: { organizationId: string }) => { - const { organizationId } = payload; + run: async (payload: { organizationId: string; forceFailure?: boolean }) => { + const { organizationId, forceFailure = false } = payload; logger.info(`Running integration tests for organization: ${organizationId}`); @@ -44,6 +44,32 @@ export const runIntegrationTests = task({ `Found ${integrations.length} integrations to test for organization: ${organizationId}`, ); + if (forceFailure) { + await new Promise((resolve) => setTimeout(resolve, 5000)); + + const forcedFailureMessage = + 'Test failure: intentionally failing integration job for UI verification'; + + const forcedFailedIntegrations = integrations.map((integration) => ({ + id: integration.id, + integrationId: integration.integrationId, + name: integration.name, + error: forcedFailureMessage, + })); + + logger.warn( + `Force-failing integration tests for organization ${organizationId}: ${forcedFailureMessage}`, + ); + + return { + success: false, + organizationId, + integrationsCount: integrations.length, + errors: [forcedFailureMessage], + failedIntegrations: forcedFailedIntegrations, + }; + } + const batchItems = integrations.map((integration) => ({ payload: { integration: { From fb3902b051096aa524353e2ab9499a87cd0d886c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 10 Nov 2025 18:53:44 -0500 Subject: [PATCH 3/5] [dev] [Marfuen] mariano/trigger-errors-3 (#1730) * chore: fix types --------- Co-authored-by: Mariano Fuentes --- .cursor/mcp.json | 12 ++ .../cloud-tests/components/ResultsView.tsx | 39 ++++-- .../cloud-tests/components/TestsLayout.tsx | 132 +++++++++++------- .../app/(app)/[orgId]/cloud-tests/status.ts | 59 ++++++++ 4 files changed, 174 insertions(+), 68 deletions(-) create mode 100644 .cursor/mcp.json create mode 100644 apps/app/src/app/(app)/[orgId]/cloud-tests/status.ts diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 000000000..43e26b028 --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "trigger": { + "command": "npx", + "args": [ + "trigger.dev@latest", + "mcp", + "--dev-only" + ] + } + } +} \ No newline at end of file diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ResultsView.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ResultsView.tsx index 21c09a317..133d9d93c 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ResultsView.tsx +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ResultsView.tsx @@ -5,6 +5,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { useRealtimeRun } from '@trigger.dev/react-hooks'; import { AlertTriangle, CheckCircle2, Info, Loader2, RefreshCw, X } from 'lucide-react'; import { useEffect, useState } from 'react'; +import { isFailureRunStatus, isSuccessfulRunStatus, isTerminalRunStatus } from '../status'; import type { IntegrationRunOutput } from '../types'; import { FindingsTable } from './FindingsTable'; @@ -60,9 +61,7 @@ export function ResultsView({ accessToken: scanAccessToken || undefined, }); - const scanCompleted = run?.status === 'COMPLETED'; - const scanFailed = - run?.status === 'FAILED' || run?.status === 'CRASHED' || run?.status === 'SYSTEM_FAILURE'; + const runStatus = run?.status; const [selectedStatus, setSelectedStatus] = useState('all'); const [selectedSeverity, setSelectedSeverity] = useState('all'); const [showSuccessBanner, setShowSuccessBanner] = useState(false); @@ -70,13 +69,15 @@ export function ResultsView({ const [showOutputErrorBanner, setShowOutputErrorBanner] = useState(false); const runOutputError = runOutput && !runOutput.success ? runOutput : null; - const scanSucceeded = scanCompleted && !runOutputError; + const isRunTerminal = isTerminalRunStatus(runStatus); + const scanSucceeded = isRunTerminal && !runOutputError && isSuccessfulRunStatus(runStatus); + const runHasHardFailure = isRunTerminal && (isFailureRunStatus(runStatus) || Boolean(run?.error)); const outputErrorMessages = runOutputError?.errors && runOutputError.errors.length > 0 ? runOutputError.errors - : runOutputError?.failedIntegrations?.map( + : (runOutputError?.failedIntegrations?.map( (integration) => `${integration.name}: ${integration.error}`, - ) ?? []; + ) ?? []); // Show success banner when scan completes successfully, auto-hide after 5 seconds useEffect(() => { @@ -92,14 +93,15 @@ export function ResultsView({ // Auto-dismiss error banner after 30 seconds useEffect(() => { - if (scanFailed) { + if (runHasHardFailure) { setShowErrorBanner(true); const timer = setTimeout(() => { setShowErrorBanner(false); }, 30000); return () => clearTimeout(timer); } - }, [scanFailed]); + setShowErrorBanner(false); + }, [runHasHardFailure]); // Show output error banner when run completes with errors useEffect(() => { @@ -152,7 +154,7 @@ export function ResultsView({
)} - {showSuccessBanner && scanCompleted && !isScanning && ( + {showSuccessBanner && scanSucceeded && !isScanning && (
@@ -203,25 +205,34 @@ export function ResultsView({ )} {/* Propagation delay info banner - only when scan succeeds but returns empty output */} - {scanCompleted && findings.length === 0 && !isScanning && !scanFailed && !runOutputError && ( + {scanSucceeded && findings.length === 0 && !isScanning && !runOutputError && (
-

Initial scan complete

+

+ Initial scan complete +

- Security findings may take 24-48 hours to appear after enabling cloud security services. Check back later. + Security findings may take 24-48 hours to appear after enabling cloud security + services. Check back later.

)} - {showErrorBanner && scanFailed && !isScanning && ( + {showErrorBanner && runHasHardFailure && !isScanning && (

Scan failed

- {extractCleanErrorMessage(run?.error?.message || 'An error occurred during the scan. Please try again.')} + {extractCleanErrorMessage( + (typeof run?.error === 'string' && run.error) || + (run?.error && typeof run.error === 'object' && 'message' in run.error + ? String((run.error as { message?: unknown }).message) + : undefined) || + 'An error occurred during the scan. Please try again.', + )}

@@ -245,28 +256,81 @@ export function DeviceAgentAccordionItem({ {fleetPolicies.length > 0 ? ( - fleetPolicies.map((policy) => ( -
-

{policy.name}

- {policy.response === 'pass' ? ( -
- - Pass -
- ) : ( -
- - Fail + <> + {fleetPolicies.map((policy) => ( +
+

{policy.name}

+ {policy.response === 'pass' ? ( +
+ + Pass +
+ ) : ( +
+ + Fail +
+ )} +
+ ))} + {isMacOS && ( +
+
+

{mdmEnabledStatus.name}

+ {mdmEnabledStatus.response === 'fail' && host?.id && ( + + + + + + +

+ There are additional steps required to enable MDM. Please check{' '} + + this documentation + + . +

+
+
+
+ )}
- )} -
- )) + {mdmEnabledStatus.response === 'pass' ? ( +
+ + Pass +
+ ) : ( +
+ + Fail +
+ )} +
+ )} + ) : (

No policies configured for this device. diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/types/index.ts b/apps/portal/src/app/(app)/(home)/[orgId]/types/index.ts index 4a8541205..ea337dc9b 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/types/index.ts +++ b/apps/portal/src/app/(app)/(home)/[orgId]/types/index.ts @@ -66,7 +66,7 @@ export interface Host { gigs_total_disk_space: number; disk_encryption_enabled: boolean; issues: object; - mdm: object; + mdm: MDM; refetch_critical_queries_until: string | null; last_restarted_at: string; policies: FleetPolicy[]; @@ -80,3 +80,12 @@ export interface Host { display_text: string; display_name: string; } + +export type MDM = { + connected_to_fleet: boolean; + dep_profile_error: boolean; + encryption_key_available: boolean; + enrollment_status: "Off" | "On"; + name?: string; + server_url?: string; +}; \ No newline at end of file From 0d34e8b97ae20c3880918d0419a96599a0e1b618 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 09:26:36 -0500 Subject: [PATCH 5/5] [dev] [Marfuen] mariano/trigger-errors-3 (#1731) * refactor: dont crash trigger on cloud tests job * chore: fix types * chore: add forced fail prop for testing * chore: fix types * refactor(cloud-tests): integrate trigger token creation and update session handling * refactor(cloud-tests): remove debug console logs from TestsLayout * chore: remove leftover code --------- Co-authored-by: Mariano Fuentes --- .../actions/create-trigger-token.ts | 44 +++ .../cloud-tests/components/ResultsView.tsx | 211 ++++--------- .../cloud-tests/components/TestsLayout.tsx | 296 +++++------------- .../app/(app)/[orgId]/cloud-tests/page.tsx | 19 +- .../app/(app)/[orgId]/cloud-tests/status.ts | 33 +- 5 files changed, 225 insertions(+), 378 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/cloud-tests/actions/create-trigger-token.ts diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/create-trigger-token.ts b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/create-trigger-token.ts new file mode 100644 index 000000000..6e1dc60db --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/create-trigger-token.ts @@ -0,0 +1,44 @@ +'use server'; + +import { auth as betterAuth } from '@/utils/auth'; +import { auth } from '@trigger.dev/sdk'; +import { headers } from 'next/headers'; + +export const createTriggerToken = async () => { + const session = await betterAuth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + return { + success: false, + error: 'Unauthorized', + }; + } + + const orgId = session.session?.activeOrganizationId; + if (!orgId) { + return { + success: false, + error: 'No active organization', + }; + } + + try { + const token = await auth.createTriggerPublicToken('run-integration-tests', { + multipleUse: true, + expirationTime: '1hr', + }); + + return { + success: true, + token, + }; + } catch (error) { + console.error('Error creating trigger token:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to create trigger token', + }; + } +}; diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ResultsView.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ResultsView.tsx index 133d9d93c..d8a5c301e 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ResultsView.tsx +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ResultsView.tsx @@ -2,11 +2,9 @@ import { Button } from '@comp/ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select'; -import { useRealtimeRun } from '@trigger.dev/react-hooks'; +import type { AnyRealtimeRun } from '@trigger.dev/sdk'; import { AlertTriangle, CheckCircle2, Info, Loader2, RefreshCw, X } from 'lucide-react'; -import { useEffect, useState } from 'react'; -import { isFailureRunStatus, isSuccessfulRunStatus, isTerminalRunStatus } from '../status'; -import type { IntegrationRunOutput } from '../types'; +import { useMemo, useState } from 'react'; import { FindingsTable } from './FindingsTable'; interface Finding { @@ -21,99 +19,39 @@ interface Finding { interface ResultsViewProps { findings: Finding[]; - scanTaskId: string | null; - scanAccessToken: string | null; onRunScan: () => Promise; isScanning: boolean; - runOutput: IntegrationRunOutput | null; + run: AnyRealtimeRun | undefined; } const severityOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 }; -// Helper function to extract clean error messages from cloud provider errors -function extractCleanErrorMessage(errorMessage: string): string { - try { - // Try to parse as JSON (GCP returns JSON blob) - const parsed = JSON.parse(errorMessage); - - // GCP error structure: { error: { message: "actual message" } } - if (parsed.error?.message) { - return parsed.error.message; - } - } catch { - // Not JSON, return original - } - - return errorMessage; -} - -export function ResultsView({ - findings, - scanTaskId, - scanAccessToken, - onRunScan, - isScanning, - runOutput, -}: ResultsViewProps) { - // Track scan status with Trigger.dev hooks - const { run } = useRealtimeRun(scanTaskId || '', { - enabled: !!scanTaskId && !!scanAccessToken, - accessToken: scanAccessToken || undefined, - }); - - const runStatus = run?.status; +export function ResultsView({ findings, onRunScan, isScanning, run }: ResultsViewProps) { const [selectedStatus, setSelectedStatus] = useState('all'); const [selectedSeverity, setSelectedSeverity] = useState('all'); - const [showSuccessBanner, setShowSuccessBanner] = useState(false); - const [showErrorBanner, setShowErrorBanner] = useState(false); - const [showOutputErrorBanner, setShowOutputErrorBanner] = useState(false); - - const runOutputError = runOutput && !runOutput.success ? runOutput : null; - const isRunTerminal = isTerminalRunStatus(runStatus); - const scanSucceeded = isRunTerminal && !runOutputError && isSuccessfulRunStatus(runStatus); - const runHasHardFailure = isRunTerminal && (isFailureRunStatus(runStatus) || Boolean(run?.error)); - const outputErrorMessages = - runOutputError?.errors && runOutputError.errors.length > 0 - ? runOutputError.errors - : (runOutputError?.failedIntegrations?.map( - (integration) => `${integration.name}: ${integration.error}`, - ) ?? []); - - // Show success banner when scan completes successfully, auto-hide after 5 seconds - useEffect(() => { - if (scanSucceeded) { - setShowSuccessBanner(true); - const timer = setTimeout(() => { - setShowSuccessBanner(false); - }, 5000); - return () => clearTimeout(timer); - } - setShowSuccessBanner(false); - }, [scanSucceeded]); - - // Auto-dismiss error banner after 30 seconds - useEffect(() => { - if (runHasHardFailure) { - setShowErrorBanner(true); - const timer = setTimeout(() => { - setShowErrorBanner(false); - }, 30000); - return () => clearTimeout(timer); - } - setShowErrorBanner(false); - }, [runHasHardFailure]); - // Show output error banner when run completes with errors - useEffect(() => { - if (runOutputError) { - setShowOutputErrorBanner(true); - setShowSuccessBanner(false); - } else { - setShowOutputErrorBanner(false); - } - }, [runOutputError]); + const isCompleted = run?.status === 'COMPLETED'; + const isFailed = + run?.status === 'FAILED' || + run?.status === 'CRASHED' || + run?.status === 'SYSTEM_FAILURE' || + run?.status === 'TIMED_OUT' || + run?.status === 'CANCELED'; + + const runOutput = + run?.output && typeof run.output === 'object' && 'success' in run.output + ? (run.output as { + success: boolean; + errors?: string[]; + failedIntegrations?: Array<{ name: string; error: string }>; + }) + : null; + + const hasOutputErrors = runOutput && !runOutput.success; + const outputErrorMessages = hasOutputErrors + ? (runOutput.errors ?? runOutput.failedIntegrations?.map((i) => `${i.name}: ${i.error}`) ?? []) + : []; - // Get unique statuses and severities const uniqueStatuses = Array.from( new Set(findings.map((f) => f.status).filter(Boolean) as string[]), ); @@ -121,27 +59,28 @@ export function ResultsView({ new Set(findings.map((f) => f.severity).filter(Boolean) as string[]), ); - // Filter findings const filteredFindings = findings.filter((finding) => { const matchesStatus = selectedStatus === 'all' || finding.status === selectedStatus; const matchesSeverity = selectedSeverity === 'all' || finding.severity === selectedSeverity; return matchesStatus && matchesSeverity; }); - // Sort findings by severity (always) - const sortedFindings = [...filteredFindings].sort((a, b) => { - const severityA = a.severity - ? (severityOrder[a.severity.toLowerCase() as keyof typeof severityOrder] ?? 999) - : 999; - const severityB = b.severity - ? (severityOrder[b.severity.toLowerCase() as keyof typeof severityOrder] ?? 999) - : 999; - return severityA - severityB; - }); + const sortedFindings = useMemo( + () => + [...filteredFindings].sort((a, b) => { + const severityA = a.severity + ? (severityOrder[a.severity.toLowerCase() as keyof typeof severityOrder] ?? 999) + : 999; + const severityB = b.severity + ? (severityOrder[b.severity.toLowerCase() as keyof typeof severityOrder] ?? 999) + : 999; + return severityA - severityB; + }), + [filteredFindings], + ); return (

- {/* Scan Status Banner */} {isScanning && (
@@ -154,99 +93,62 @@ export function ResultsView({
)} - {showSuccessBanner && scanSucceeded && !isScanning && ( + {isCompleted && !isScanning && !hasOutputErrors && (

Scan completed

Results updated successfully

-
)} - {/* Output error banner when run reports errors but job didn't crash */} - {showOutputErrorBanner && runOutputError && !isScanning && ( + {hasOutputErrors && !isScanning && (

Scan completed with errors

    {outputErrorMessages.slice(0, 5).map((message, index) => ( -
  • • {message}
  • +
  • • {message}
  • ))} {outputErrorMessages.length === 0 && (
  • Encountered an unknown error while processing integration results.
  • )}
- {runOutputError.failedIntegrations && runOutputError.failedIntegrations.length > 0 && ( -

- {runOutputError.failedIntegrations.length} integration - {runOutputError.failedIntegrations.length === 1 ? '' : 's'} returned errors. -

- )}
-
)} - {/* Propagation delay info banner - only when scan succeeds but returns empty output */} - {scanSucceeded && findings.length === 0 && !isScanning && !runOutputError && ( -
- + {isFailed && !isScanning && ( +
+
-

- Initial scan complete -

+

Scan failed

- Security findings may take 24-48 hours to appear after enabling cloud security - services. Check back later. + {typeof run?.error === 'object' && run.error && 'message' in run.error + ? String(run.error.message) + : 'An error occurred during the scan. Please try again.'}

)} - {showErrorBanner && runHasHardFailure && !isScanning && ( -
- + {isCompleted && findings.length === 0 && !isScanning && !hasOutputErrors && ( +
+
-

Scan failed

+

+ Initial scan complete +

- {extractCleanErrorMessage( - (typeof run?.error === 'string' && run.error) || - (run?.error && typeof run.error === 'object' && 'message' in run.error - ? String((run.error as { message?: unknown }).message) - : undefined) || - 'An error occurred during the scan. Please try again.', - )} + Security findings may take 24-48 hours to appear after enabling cloud security + services. Check back later.

-
)} - {/* Filters and Run Scan Button */}
{findings.length > 0 ? (
@@ -294,7 +196,6 @@ export function ResultsView({
- {/* Results Table */} {sortedFindings.length > 0 ? ( ) : findings.length > 0 ? ( diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/TestsLayout.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/TestsLayout.tsx index b7965f9e7..70ff646b7 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/TestsLayout.tsx +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/TestsLayout.tsx @@ -1,24 +1,15 @@ 'use client'; +import type { runIntegrationTests } from '@/jobs/tasks/integration/run-integration-tests'; import { Button } from '@comp/ui/button'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@comp/ui/tabs'; import { Integration } from '@db'; -import { useRealtimeRun } from '@trigger.dev/react-hooks'; +import { useRealtimeTaskTrigger } from '@trigger.dev/react-hooks'; import { Plus, Settings } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import { toast } from 'sonner'; -import type { Fetcher } from 'swr'; import useSWR from 'swr'; -import { runTests } from '../actions/run-tests'; -import { - isActiveRunStatus, - isFailureRunStatus, - isRunStatus, - isSuccessfulRunStatus, - isTerminalRunStatus, -} from '../status'; import type { IntegrationRunOutput } from '../types'; -import { ChatPlaceholder } from './ChatPlaceholder'; import { CloudSettingsModal } from './CloudSettingsModal'; import { EmptyState } from './EmptyState'; import { ResultsView } from './ResultsView'; @@ -39,155 +30,94 @@ interface Finding { interface TestsLayoutProps { initialFindings: Finding[]; initialProviders: Integration[]; + triggerToken: string; + orgId: string; } type SupportedProviderId = 'aws' | 'gcp' | 'azure'; type SupportedIntegration = Integration & { integrationId: SupportedProviderId }; -type TriggerInfo = { - taskId?: string; - publicAccessToken?: string; -}; const SUPPORTED_PROVIDER_IDS: readonly SupportedProviderId[] = ['aws', 'gcp', 'azure']; -const SUPPORTED_PROVIDER_ID_SET: ReadonlySet = new Set(SUPPORTED_PROVIDER_IDS); -const isSupportedProviderId = (integrationId: string): integrationId is SupportedProviderId => - SUPPORTED_PROVIDER_ID_SET.has(integrationId as SupportedProviderId); +const isSupportedProviderId = (id: string): id is SupportedProviderId => + SUPPORTED_PROVIDER_IDS.includes(id as SupportedProviderId); const isIntegrationRunOutput = (value: unknown): value is IntegrationRunOutput => { if (typeof value !== 'object' || value === null) { return false; } - - const candidate = value as { - success?: unknown; - errors?: unknown; - failedIntegrations?: unknown; - }; - - if (typeof candidate.success !== 'boolean') { - return false; - } - - if ( - candidate.errors !== undefined && - (!Array.isArray(candidate.errors) || candidate.errors.some((item) => typeof item !== 'string')) - ) { - return false; - } - - if (candidate.failedIntegrations !== undefined) { - if (!Array.isArray(candidate.failedIntegrations)) { - return false; - } - - for (const item of candidate.failedIntegrations) { - if ( - typeof item !== 'object' || - item === null || - typeof (item as { id?: unknown }).id !== 'string' || - typeof (item as { integrationId?: unknown }).integrationId !== 'string' || - typeof (item as { name?: unknown }).name !== 'string' || - typeof (item as { error?: unknown }).error !== 'string' - ) { - return false; - } - } - } - - return true; + return typeof (value as { success?: unknown }).success === 'boolean'; }; -async function fetchJson(url: string): Promise { - const response = await fetch(url); - if (!response.ok) { - throw new Error('Failed to fetch'); - } - - return (await response.json()) as T; -} - -export function TestsLayout({ initialFindings, initialProviders }: TestsLayoutProps) { - const [scanTaskId, setScanTaskId] = useState(null); - const [scanAccessToken, setScanAccessToken] = useState(null); +export function TestsLayout({ + initialFindings, + initialProviders, + triggerToken, + orgId, +}: TestsLayoutProps) { const [showSettings, setShowSettings] = useState(false); const [viewingResults, setViewingResults] = useState(true); - const [isAwaitingRunStart, setIsAwaitingRunStart] = useState(false); - const [lastRunOutput, setLastRunOutput] = useState(null); - const lastHandledRunIdRef = useRef(null); - const lastHandledStatusRef = useRef(null); - - // Use SWR for real-time updates - const findingsFetcher: Fetcher = (url) => fetchJson(url); - const providersFetcher: Fetcher = (url) => fetchJson(url); const { data: findings = initialFindings, mutate: mutateFindings } = useSWR( '/api/cloud-tests/findings', - findingsFetcher, + async (url) => { + const res = await fetch(url); + if (!res.ok) throw new Error('Failed to fetch'); + return res.json(); + }, { fallbackData: initialFindings, - refreshInterval: 5000, // Refresh every 5 seconds when scanning + refreshInterval: 5000, revalidateOnFocus: true, }, ); const { data: providers = initialProviders, mutate: mutateProviders } = useSWR( '/api/cloud-tests/providers', - providersFetcher, + async (url) => { + const res = await fetch(url); + if (!res.ok) throw new Error('Failed to fetch'); + return res.json(); + }, { fallbackData: initialProviders, revalidateOnFocus: true, }, ); - const connectedProviders = providers.filter((provider): provider is SupportedIntegration => - isSupportedProviderId(provider.integrationId), + const connectedProviders = providers.filter((p): p is SupportedIntegration => + isSupportedProviderId(p.integrationId), ); - // Track scan run status with access token - const { run: scanRun } = useRealtimeRun(scanTaskId || '', { - enabled: !!scanTaskId && !!scanAccessToken, - accessToken: scanAccessToken || undefined, - }); - - const isScanActive = - Boolean(scanTaskId) && (isAwaitingRunStart || isActiveRunStatus(scanRun?.status)); - - // Sync job completion state with realtime run events - useEffect(() => { - if (!scanRun) { - return; - } - - setIsAwaitingRunStart(false); - - const status = scanRun.status; + const { submit, run, error, isLoading } = useRealtimeTaskTrigger( + 'run-integration-tests', + { + accessToken: triggerToken, + }, + ); - if (!isRunStatus(status)) { - return; - } + const isCompleted = run?.status === 'COMPLETED'; + const isFailed = + run?.status === 'FAILED' || + run?.status === 'CRASHED' || + run?.status === 'SYSTEM_FAILURE' || + run?.status === 'TIMED_OUT' || + run?.status === 'CANCELED' || + run?.status === 'EXPIRED'; - if (scanRun.id !== lastHandledRunIdRef.current) { - lastHandledRunIdRef.current = scanRun.id; - lastHandledStatusRef.current = null; - } + const isTerminal = isCompleted || isFailed; + const isScanning = Boolean(run && !isTerminal) || isLoading; - if (!isTerminalRunStatus(status)) { - lastHandledStatusRef.current = status; - return; - } + const runOutput = isCompleted && isIntegrationRunOutput(run?.output) ? run.output : null; - if (lastHandledStatusRef.current === status) { + useEffect(() => { + if (!run || !isTerminal) { return; } - lastHandledStatusRef.current = status; - - const runOutput = isIntegrationRunOutput(scanRun.output) ? scanRun.output : null; - setLastRunOutput(runOutput); void mutateFindings(); - if (runOutput && runOutput.success === false) { + if (runOutput && !runOutput.success) { const errorMessage = runOutput.errors?.[0] ?? runOutput.failedIntegrations?.[0]?.error ?? @@ -196,53 +126,34 @@ export function TestsLayout({ initialFindings, initialProviders }: TestsLayoutPr return; } - if (isFailureRunStatus(status) || scanRun.error) { + if (isFailed || run.error) { const errorMessage = - (typeof scanRun.error === 'string' && scanRun.error) || - (scanRun.error && - typeof scanRun.error === 'object' && - 'message' in scanRun.error && - scanRun.error.message - ? String((scanRun.error as { message?: unknown }).message) - : undefined) || - 'Scan failed. Please try again.'; + typeof run.error === 'object' && run.error && 'message' in run.error + ? String(run.error.message) + : typeof run.error === 'string' + ? run.error + : 'Scan failed. Please try again.'; toast.error(errorMessage); return; } - if (isSuccessfulRunStatus(status)) { + if (isCompleted) { toast.success('Scan completed! Results updated.'); - } else { - toast.message('Scan completed.'); } - }, [scanRun, mutateFindings]); + }, [run, isTerminal, isFailed, isCompleted, runOutput, mutateFindings]); - useEffect(() => { - if (!scanTaskId) { - setIsAwaitingRunStart(false); - lastHandledRunIdRef.current = null; - lastHandledStatusRef.current = null; + const handleRunScan = async (): Promise => { + if (!orgId) { + toast.error('No active organization'); + return null; } - }, [scanTaskId]); - const handleRunScan = async (): Promise => { try { - setLastRunOutput(null); - const result = await runTests(); - if (result.success && result.taskId && result.publicAccessToken) { - setScanTaskId(result.taskId); - setScanAccessToken(result.publicAccessToken); - setIsAwaitingRunStart(true); - lastHandledRunIdRef.current = null; - lastHandledStatusRef.current = null; - toast.message('Scan started. Checking your cloud infrastructure...'); - return result.taskId; - } else { - toast.error(result.errors?.[0] || 'Failed to start scan'); - return null; - } + await submit({ organizationId: orgId }); + toast.message('Scan started. Checking your cloud infrastructure...'); + return run?.id || null; } catch (error) { - console.error(error); + console.error('🚀 Submit error:', error); toast.error('Failed to start scan. Please try again.'); return null; } @@ -254,23 +165,18 @@ export function TestsLayout({ initialFindings, initialProviders }: TestsLayoutPr setViewingResults(true); }; - const handleCloudConnected = (trigger?: TriggerInfo) => { + const handleCloudConnected = async () => { mutateProviders(); mutateFindings(); - setLastRunOutput(null); - - if (trigger?.taskId && trigger?.publicAccessToken) { - setScanTaskId(trigger.taskId); - setScanAccessToken(trigger.publicAccessToken); - setIsAwaitingRunStart(true); - lastHandledRunIdRef.current = null; - lastHandledStatusRef.current = null; + + if (orgId) { + await submit({ organizationId: orgId }); + toast.message('Scan started. Checking your cloud infrastructure...'); } setViewingResults(true); }; - // First-time user: No clouds connected OR user wants to add a cloud if (connectedProviders.length === 0 || !viewingResults) { return ( >((acc, finding) => { - const providerId = finding.integration.integrationId; - const bucket = acc[providerId] ?? []; + const bucket = acc[finding.integration.integrationId] ?? []; bucket.push(finding); - acc[providerId] = bucket; + acc[finding.integration.integrationId] = bucket; return acc; }, {}); - // Single cloud user (primary use case) if (connectedProviders.length === 1) { const provider = connectedProviders[0]; const providerFindings = findingsByProvider[provider.integrationId] ?? []; return (
- {/* Header */}

Cloud Security Tests

@@ -324,24 +226,13 @@ export function TestsLayout({ initialFindings, initialProviders }: TestsLayoutPr
- {/* Split-screen layout: Results (70%) + Chat placeholder (30%) */} -
-
- -
- {/*
- -
*/} -
+ - {/* Settings Modal */} - {/* Header */}

Cloud Security Tests

@@ -387,7 +276,6 @@ export function TestsLayout({ initialFindings, initialProviders }: TestsLayoutPr
- {/* Tabs for multiple clouds */} {connectedProviders.map((provider) => ( @@ -398,7 +286,7 @@ export function TestsLayout({ initialFindings, initialProviders }: TestsLayoutPr {connectedProviders.map((provider) => { - const providerFindings = findingsByProvider[provider.integrationId] || []; + const providerFindings = findingsByProvider[provider.integrationId] ?? []; return ( - {/* Split-screen layout */} -
-
- -
-
- -
-
+
); })}
- {/* Settings Modal */} }) { const { orgId } = await params; - const session = await auth.api.getSession({ + const session = await betterAuth.api.getSession({ headers: await headers(), }); @@ -59,5 +60,17 @@ export default async function CloudTestsPage({ params }: { params: Promise<{ org }, })) || []; - return ; + const triggerToken = await auth.createTriggerPublicToken('run-integration-tests', { + multipleUse: true, + expirationTime: '1hr', + }); + + return ( + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/status.ts b/apps/app/src/app/(app)/[orgId]/cloud-tests/status.ts index 3b93b4c92..1f763722b 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/status.ts +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/status.ts @@ -1,22 +1,35 @@ -import type { AnyRealtimeRun } from '@trigger.dev/sdk'; +export type RunStatus = + | 'WAITING_FOR_DEPLOY' + | 'QUEUED' + | 'EXECUTING' + | 'REATTEMPTING' + | 'FROZEN' + | 'COMPLETED' + | 'CANCELED' + | 'FAILED' + | 'CRASHED' + | 'INTERRUPTED' + | 'SYSTEM_FAILURE' + | 'DELAYED' + | 'EXPIRED' + | 'TIMED_OUT'; -export type RunStatus = AnyRealtimeRun['status']; - -const RUN_STATUS_VALUES_INTERNAL = [ - 'PENDING_VERSION', +const RUN_STATUS_VALUES_INTERNAL: readonly RunStatus[] = [ + 'WAITING_FOR_DEPLOY', 'QUEUED', - 'DEQUEUED', 'EXECUTING', - 'WAITING', + 'REATTEMPTING', + 'FROZEN', 'COMPLETED', 'CANCELED', 'FAILED', 'CRASHED', + 'INTERRUPTED', 'SYSTEM_FAILURE', 'DELAYED', 'EXPIRED', 'TIMED_OUT', -] as const satisfies readonly RunStatus[]; +]; export const RUN_STATUS_VALUES = RUN_STATUS_VALUES_INTERNAL; @@ -28,7 +41,7 @@ const TERMINAL_STATUS_SET: ReadonlySet = new Set([ 'FAILED', 'CRASHED', 'SYSTEM_FAILURE', - 'DELAYED', + 'INTERRUPTED', 'EXPIRED', 'TIMED_OUT', ]); @@ -41,6 +54,8 @@ const FAILURE_STATUS_SET: ReadonlySet = new Set([ 'SYSTEM_FAILURE', 'TIMED_OUT', 'EXPIRED', + 'INTERRUPTED', + 'CANCELED', ]); export const isRunStatus = (status: unknown): status is RunStatus =>