mirror of
https://github.com/simstudioai/sim.git
synced 2026-08-30 17:05:18 +08:00
fix(csv): neutralize formula-leading exports (#6993)
This commit is contained in:
@@ -10,8 +10,8 @@ import {
|
||||
queryAuditLogs,
|
||||
} from '@/lib/audit-logs/query'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { formatCsvValue, toCsvRow } from '@/lib/core/utils/csv'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { formatCsvValue, toCsvRow } from '@/lib/table/export-format'
|
||||
import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth'
|
||||
import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format'
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { and, desc, eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency'
|
||||
import { neutralizeCsvFormula } from '@/lib/core/utils/csv'
|
||||
import { formatCsvValue, toCsvRow } from '@/lib/core/utils/csv'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store'
|
||||
import { buildFilterConditions, LogFilterParamsSchema } from '@/lib/logs/filters'
|
||||
@@ -17,15 +17,6 @@ const logger = createLogger('LogsExportAPI')
|
||||
|
||||
export const revalidate = 0
|
||||
|
||||
function escapeCsv(value: any): string {
|
||||
if (value === null || value === undefined) return ''
|
||||
const str = typeof value === 'string' ? neutralizeCsvFormula(value) : String(value)
|
||||
if (/[",\n]/.test(str)) {
|
||||
return `"${str.replace(/"/g, '""')}"`
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
@@ -61,7 +52,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
? and(workspaceCondition, filterConditions)
|
||||
: workspaceCondition
|
||||
|
||||
const header = [
|
||||
const header = toCsvRow([
|
||||
'startedAt',
|
||||
'level',
|
||||
'workflow',
|
||||
@@ -72,7 +63,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
'executionId',
|
||||
'message',
|
||||
'traceSpans',
|
||||
].join(',')
|
||||
])
|
||||
|
||||
const access = await checkWorkspaceAccess(params.workspaceId, userId)
|
||||
if (!access.hasAccess) {
|
||||
@@ -147,18 +138,18 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
error: getErrorMessage(rowError),
|
||||
})
|
||||
}
|
||||
const line = [
|
||||
escapeCsv(r.startedAt?.toISOString?.() || r.startedAt),
|
||||
escapeCsv(r.level),
|
||||
escapeCsv(r.workflowName),
|
||||
escapeCsv(r.trigger),
|
||||
escapeCsv(r.totalDurationMs ?? ''),
|
||||
escapeCsv(r.costTotal ?? ''),
|
||||
escapeCsv(r.workflowId ?? ''),
|
||||
escapeCsv(r.executionId ?? ''),
|
||||
escapeCsv(message),
|
||||
escapeCsv(tracesJson),
|
||||
].join(',')
|
||||
const line = toCsvRow([
|
||||
formatCsvValue(r.startedAt?.toISOString?.() || r.startedAt),
|
||||
formatCsvValue(r.level),
|
||||
formatCsvValue(r.workflowName),
|
||||
formatCsvValue(r.trigger),
|
||||
formatCsvValue(r.totalDurationMs ?? ''),
|
||||
formatCsvValue(r.costTotal ?? ''),
|
||||
formatCsvValue(r.workflowId ?? ''),
|
||||
formatCsvValue(r.executionId ?? ''),
|
||||
formatCsvValue(message),
|
||||
formatCsvValue(tracesJson),
|
||||
])
|
||||
controller.enqueue(encoder.encode(`${line}\n`))
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,8 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import {
|
||||
createTableExportStream,
|
||||
exportContentType,
|
||||
sanitizeExportFilename,
|
||||
} from '@/lib/table/export-stream'
|
||||
import { sanitizeExportFilename } from '@/lib/table/export-format'
|
||||
import { createTableExportStream, exportContentType } from '@/lib/table/export-stream'
|
||||
import { accessError, checkAccess } from '@/app/api/table/utils'
|
||||
|
||||
interface RouteParams {
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
toBillingUsageLogSource,
|
||||
toInternalUsageLogSources,
|
||||
} from '@/lib/billing/usage-sources'
|
||||
import { formatCsvValue, toCsvRow } from '@/lib/core/utils/csv'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { formatCsvValue, toCsvRow } from '@/lib/table/export-format'
|
||||
import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared'
|
||||
|
||||
const logger = createLogger('UsageLogsExportAPI')
|
||||
|
||||
@@ -75,6 +75,15 @@ describe('serializeOutputForFile (csv)', () => {
|
||||
expect(serializeOutputForFile(output, 'csv')).toBe('name,age\nAlice,30\nBob,40')
|
||||
})
|
||||
|
||||
it('neutralizes formula-leading values in generated CSV', () => {
|
||||
const output = {
|
||||
result: [{ value: '=1+1' }],
|
||||
stdout: '',
|
||||
}
|
||||
|
||||
expect(serializeOutputForFile(output, 'csv')).toBe("value\n'=1+1")
|
||||
})
|
||||
|
||||
it('returns the raw string when the non-envelope output is already a CSV string', () => {
|
||||
expect(serializeOutputForFile('a,b\n1,2', 'csv')).toBe('a,b\n1,2')
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/r
|
||||
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
|
||||
import { decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils'
|
||||
import { writeCopilotWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer'
|
||||
import { formatCsvValue, toCsvRow } from '@/lib/core/utils/csv'
|
||||
import {
|
||||
createWorkspaceFileSecretProvenanceFromRegistry,
|
||||
type WorkspaceFileSecretProvenance,
|
||||
@@ -99,15 +100,6 @@ export function extractTabularData(output: unknown): Record<string, unknown>[] |
|
||||
return null
|
||||
}
|
||||
|
||||
export function escapeCsvValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return ''
|
||||
const str = typeof value === 'object' ? JSON.stringify(value) : String(value)
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
||||
return `"${str.replace(/"/g, '""')}"`
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
export function normalizeOutputWorkspaceFileName(outputPath: string): string {
|
||||
const segments = decodeVfsPathSegments(outputPath.trim().replace(/^\/+|\/+$/g, ''))
|
||||
const fileName = segments.at(-1)
|
||||
@@ -180,7 +172,7 @@ function convertRowsToCsvWithProvenance(
|
||||
}
|
||||
}
|
||||
const serializeCell = (sourceValue: unknown): string => {
|
||||
const persistedValue = escapeCsvValue(sourceValue)
|
||||
const persistedValue = formatCsvValue(sourceValue)
|
||||
const serializedSource =
|
||||
sourceValue === null || sourceValue === undefined
|
||||
? ''
|
||||
@@ -223,9 +215,9 @@ function convertRowsToCsvWithProvenance(
|
||||
return persistedValue
|
||||
}
|
||||
|
||||
const lines = [headers.map(serializeCell).join(',')]
|
||||
const lines = [toCsvRow(headers.map(serializeCell))]
|
||||
for (const row of rows) {
|
||||
lines.push(headers.map((header) => serializeCell(row[header])).join(','))
|
||||
lines.push(toCsvRow(headers.map((header) => serializeCell(row[header]))))
|
||||
}
|
||||
return {
|
||||
content: lines.join('\n'),
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@/lib/copilot/tools/secret-mount-materializer.server'
|
||||
import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils'
|
||||
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { neutralizeCsvFormula, toCsvRow } from '@/lib/core/utils/csv'
|
||||
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
|
||||
import type { PrivateSecretProvenanceBundleV1 } from '@/lib/execution/model-input-provenance'
|
||||
import {
|
||||
@@ -22,7 +23,7 @@ import { MAX_PLAN_REQUIRED } from '@/lib/execution/remote-sandbox/workspace-sand
|
||||
import { recordSecretUsage } from '@/lib/secrets/usage/record'
|
||||
import { getColumnId } from '@/lib/table/column-keys'
|
||||
import { TABLE_LIMITS } from '@/lib/table/constants'
|
||||
import { formatCsvCell, neutralizeCsvFormula, toCsvRow } from '@/lib/table/export-format'
|
||||
import { formatCsvCell } from '@/lib/table/export-format'
|
||||
import {
|
||||
isTableSnapshotSafeForModelMount,
|
||||
loadTableRowSecretProvenance,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatCsvValue, neutralizeCsvFormula, toCsvRow } from '@/lib/core/utils/csv'
|
||||
|
||||
describe('CSV formatting', () => {
|
||||
it.each(['=1+1', '+1+1', '-1+1', '@SUM(A1)', '\t=1+1', '\r=1+1'])(
|
||||
'neutralizes formula-leading text: %j',
|
||||
(value) => {
|
||||
expect(neutralizeCsvFormula(value)).toBe(`'${value}`)
|
||||
expect(formatCsvValue(value)).toBe(`'${value}`)
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves non-string primitives', () => {
|
||||
expect(formatCsvValue(-42)).toBe('-42')
|
||||
expect(formatCsvValue(true)).toBe('true')
|
||||
})
|
||||
|
||||
it('uses the provided object serializer', () => {
|
||||
expect(formatCsvValue({ value: 'x' }, () => 'serialized')).toBe('serialized')
|
||||
})
|
||||
|
||||
it('handles objects that serialize to undefined', () => {
|
||||
expect(formatCsvValue({ toJSON: () => undefined })).toBe('')
|
||||
})
|
||||
|
||||
it('escapes quotes, commas, and record separators', () => {
|
||||
expect(toCsvRow(['plain', 'with,comma', 'with"quote', 'with\nnewline', 'with\rreturn'])).toBe(
|
||||
'plain,"with,comma","with""quote","with\nnewline","with\rreturn"'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -5,3 +5,22 @@
|
||||
export function neutralizeCsvFormula(value: string): string {
|
||||
return /^[=+\-@\t\r]/.test(value) ? `'${value}` : value
|
||||
}
|
||||
|
||||
export function formatCsvValue(
|
||||
value: unknown,
|
||||
serializeObject: (value: object) => string | undefined = JSON.stringify
|
||||
): string {
|
||||
if (value === null || value === undefined) return ''
|
||||
if (value instanceof Date) return value.toISOString()
|
||||
if (typeof value === 'object') return serializeObject(value) ?? ''
|
||||
if (typeof value === 'string') return neutralizeCsvFormula(value)
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export function toCsvRow(values: string[]): string {
|
||||
return values.map(escapeCsvField).join(',')
|
||||
}
|
||||
|
||||
function escapeCsvField(field: string): string {
|
||||
return /[",\n\r]/.test(field) ? `"${field.replace(/"/g, '""')}"` : field
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* byte-identical files.
|
||||
*/
|
||||
|
||||
import { formatCsvValue, neutralizeCsvFormula } from '@/lib/core/utils/csv'
|
||||
import { columnTypeOf } from '@/lib/table/column-types'
|
||||
import { selectValueToNames } from '@/lib/table/select-values'
|
||||
import type { ColumnDefinition } from '@/lib/table/types'
|
||||
@@ -19,26 +20,6 @@ export function sanitizeExportFilename(name: string): string {
|
||||
return cleaned || 'table'
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefixes a single quote to values starting with a spreadsheet formula trigger
|
||||
* (`=`, `+`, `-`, `@`, tab, CR), neutralizing CSV injection in Excel/Sheets.
|
||||
*/
|
||||
export function neutralizeCsvFormula(value: string): string {
|
||||
return /^[=+\-@\t\r]/.test(value) ? `'${value}` : value
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a cell for CSV. Only string cells are formula-neutralized; numbers,
|
||||
* booleans, dates, and JSON objects can never form a trigger and pass through verbatim.
|
||||
*/
|
||||
export function formatCsvValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return ''
|
||||
if (value instanceof Date) return value.toISOString()
|
||||
if (typeof value === 'object') return JSON.stringify(value)
|
||||
if (typeof value === 'string') return neutralizeCsvFormula(value)
|
||||
return String(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes one cell for CSV, resolving `select` option ids to their names
|
||||
* (comma-joined for multi) so the file shows the enum label, not the id.
|
||||
@@ -51,14 +32,3 @@ export function formatCsvCell(column: ColumnDefinition, value: unknown): string
|
||||
}
|
||||
return formatCsvValue(value)
|
||||
}
|
||||
|
||||
export function toCsvRow(values: string[]): string {
|
||||
return values.map(escapeCsvField).join(',')
|
||||
}
|
||||
|
||||
function escapeCsvField(field: string): string {
|
||||
if (/[",\n\r]/.test(field)) {
|
||||
return `"${field.replace(/"/g, '""')}"`
|
||||
}
|
||||
return field
|
||||
}
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { neutralizeCsvFormula, toCsvRow } from '@/lib/core/utils/csv'
|
||||
import { namedRowMapper } from '@/lib/table/cell-format'
|
||||
import { getColumnId } from '@/lib/table/column-keys'
|
||||
import { appendTableEvent } from '@/lib/table/events'
|
||||
import {
|
||||
formatCsvCell,
|
||||
neutralizeCsvFormula,
|
||||
sanitizeExportFilename,
|
||||
toCsvRow,
|
||||
} from '@/lib/table/export-format'
|
||||
import { formatCsvCell, sanitizeExportFilename } from '@/lib/table/export-format'
|
||||
import {
|
||||
markJobFailedInWorkspace,
|
||||
markJobReadyInWorkspace,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { neutralizeCsvFormula } from '@/lib/core/utils/csv'
|
||||
import { neutralizeCsvFormula, toCsvRow } from '@/lib/core/utils/csv'
|
||||
import { namedRowMapper } from '@/lib/table/cell-format'
|
||||
import { getColumnId } from '@/lib/table/column-keys'
|
||||
import { formatCsvCell } from '@/lib/table/export-format'
|
||||
@@ -10,19 +10,6 @@ const logger = createLogger('TableExportStream')
|
||||
|
||||
const EXPORT_BATCH_SIZE = 1000
|
||||
|
||||
export function sanitizeExportFilename(name: string): string {
|
||||
const cleaned = name.replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_+|_+$/g, '')
|
||||
return cleaned || 'table'
|
||||
}
|
||||
|
||||
function escapeCsvField(field: string): string {
|
||||
return /[",\n\r]/.test(field) ? `"${field.replace(/"/g, '""')}"` : field
|
||||
}
|
||||
|
||||
function toCsvRow(values: string[]): string {
|
||||
return values.map(escapeCsvField).join(',')
|
||||
}
|
||||
|
||||
export function exportContentType(format: TableExportFormat): string {
|
||||
return format === 'csv' ? 'text/csv; charset=utf-8' : 'application/json'
|
||||
}
|
||||
|
||||
@@ -16,8 +16,9 @@ import { db } from '@sim/db'
|
||||
import { userTableDefinitions } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { neutralizeCsvFormula, toCsvRow } from '@/lib/core/utils/csv'
|
||||
import { getColumnId } from '@/lib/table/column-keys'
|
||||
import { formatCsvCell, neutralizeCsvFormula, toCsvRow } from '@/lib/table/export-format'
|
||||
import { formatCsvCell } from '@/lib/table/export-format'
|
||||
import { selectExportRowPage } from '@/lib/table/jobs/service'
|
||||
import type { TableDefinition } from '@/lib/table/types'
|
||||
import { createMultipartUpload, deleteFile, headObject } from '@/lib/uploads/core/storage-service'
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockSaveBlob } = vi.hoisted(() => ({
|
||||
mockSaveBlob: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/client/download', () => ({
|
||||
saveBlob: mockSaveBlob,
|
||||
}))
|
||||
|
||||
vi.hoisted(() => {
|
||||
const legacyNewestFirst = {
|
||||
state: {
|
||||
@@ -30,6 +38,15 @@ vi.hoisted(() => {
|
||||
|
||||
import { useChatStore } from '@/stores/chat/store'
|
||||
|
||||
function readBlob(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.addEventListener('load', () => resolve(String(reader.result)))
|
||||
reader.addEventListener('error', () => reject(reader.error))
|
||||
reader.readAsText(blob)
|
||||
})
|
||||
}
|
||||
|
||||
describe('chat store message ordering', () => {
|
||||
it('migrates v0 persisted messages from newest-first to insertion order', () => {
|
||||
const messages = useChatStore.getState().messages
|
||||
@@ -38,6 +55,7 @@ describe('chat store message ordering', () => {
|
||||
|
||||
describe('addMessage', () => {
|
||||
beforeEach(() => {
|
||||
mockSaveBlob.mockClear()
|
||||
useChatStore.setState({ messages: [] })
|
||||
})
|
||||
|
||||
@@ -65,4 +83,30 @@ describe('chat store message ordering', () => {
|
||||
expect(messages[messages.length - 1].content).toBe('m54')
|
||||
})
|
||||
})
|
||||
|
||||
describe('exportChatCSV', () => {
|
||||
beforeEach(() => {
|
||||
mockSaveBlob.mockClear()
|
||||
useChatStore.setState({
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-formula',
|
||||
content: '=1+1',
|
||||
workflowId: 'wf-1',
|
||||
type: 'workflow',
|
||||
timestamp: '2026-08-22T12:00:00.000Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('neutralizes formula-leading message content', async () => {
|
||||
useChatStore.getState().exportChatCSV('wf-1')
|
||||
|
||||
expect(mockSaveBlob).toHaveBeenCalledOnce()
|
||||
const [blob, filename] = mockSaveBlob.mock.calls[0] as [Blob, string]
|
||||
expect(filename).toMatch(/^chat-wf-1-.*\.csv$/)
|
||||
await expect(readBlob(blob)).resolves.toContain("workflow,'=1+1")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,8 @@ import { generateId } from '@sim/utils/id'
|
||||
import { truncate } from '@sim/utils/string'
|
||||
import { create } from 'zustand'
|
||||
import { devtools, persist } from 'zustand/middleware'
|
||||
import { formatCsvValue, toCsvRow } from '@/lib/core/utils/csv'
|
||||
import { saveBlob } from '@/lib/uploads/client/download'
|
||||
import type { ChatMessage, ChatState } from './types'
|
||||
import { MAX_CHAT_HEIGHT, MAX_CHAT_WIDTH, MIN_CHAT_HEIGHT, MIN_CHAT_WIDTH } from './utils'
|
||||
|
||||
@@ -99,41 +101,16 @@ export const useChatStore = create<ChatState>()(
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely stringify and escape CSV values
|
||||
*/
|
||||
const formatCSVValue = (value: any): string => {
|
||||
if (value === null || value === undefined) {
|
||||
return ''
|
||||
}
|
||||
|
||||
let stringValue = typeof value === 'object' ? JSON.stringify(value) : String(value)
|
||||
|
||||
// Truncate very long strings
|
||||
stringValue = truncate(stringValue, 2000)
|
||||
|
||||
// Escape quotes and wrap in quotes if contains special characters
|
||||
if (
|
||||
stringValue.includes('"') ||
|
||||
stringValue.includes(',') ||
|
||||
stringValue.includes('\n')
|
||||
) {
|
||||
stringValue = `"${stringValue.replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
return stringValue
|
||||
}
|
||||
|
||||
const headers = ['timestamp', 'type', 'content']
|
||||
|
||||
const csvRows = [
|
||||
headers.join(','),
|
||||
toCsvRow(headers),
|
||||
...messages.map((message: ChatMessage) =>
|
||||
[
|
||||
formatCSVValue(message.timestamp),
|
||||
formatCSVValue(message.type),
|
||||
formatCSVValue(message.content),
|
||||
].join(',')
|
||||
toCsvRow([
|
||||
formatCsvValue(message.timestamp),
|
||||
formatCsvValue(message.type),
|
||||
truncate(formatCsvValue(message.content), 2000),
|
||||
])
|
||||
),
|
||||
]
|
||||
|
||||
@@ -143,19 +120,7 @@ export const useChatStore = create<ChatState>()(
|
||||
const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
||||
const filename = `chat-${workflowId}-${timestamp}.csv`
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||
const link = document.createElement('a')
|
||||
|
||||
if (link.download !== undefined) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
link.setAttribute('href', url)
|
||||
link.setAttribute('download', filename)
|
||||
link.style.visibility = 'hidden'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
saveBlob(new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }), filename)
|
||||
},
|
||||
|
||||
setSelectedWorkflowOutput: (workflowId, outputIds) => {
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockSaveBlob } = vi.hoisted(() => ({
|
||||
mockSaveBlob: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/client/download', () => ({
|
||||
saveBlob: mockSaveBlob,
|
||||
}))
|
||||
|
||||
vi.unmock('@/stores/terminal')
|
||||
vi.unmock('@/stores/terminal/console/store')
|
||||
|
||||
@@ -10,6 +18,7 @@ import { useTerminalConsoleStore } from '@/stores/terminal/console/store'
|
||||
|
||||
describe('terminal console store', () => {
|
||||
beforeEach(() => {
|
||||
mockSaveBlob.mockClear()
|
||||
useTerminalConsoleStore.setState({
|
||||
workflowEntries: {},
|
||||
entryIdsByBlockExecution: {},
|
||||
@@ -19,6 +28,25 @@ describe('terminal console store', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('neutralizes formula-leading text in CSV exports', async () => {
|
||||
useTerminalConsoleStore.getState().addConsole({
|
||||
workflowId: 'wf-1',
|
||||
blockId: 'block-1',
|
||||
blockName: 'Function',
|
||||
blockType: 'function',
|
||||
executionId: 'exec-1',
|
||||
executionOrder: 1,
|
||||
error: '=1+1',
|
||||
})
|
||||
|
||||
useTerminalConsoleStore.getState().exportConsoleCSV('wf-1')
|
||||
|
||||
expect(mockSaveBlob).toHaveBeenCalledOnce()
|
||||
const [blob, filename] = mockSaveBlob.mock.calls[0] as [Blob, string]
|
||||
expect(filename).toMatch(/^terminal-console-wf-1-.*\.csv$/)
|
||||
await expect(blob.text()).resolves.toContain(",'=1+1,")
|
||||
})
|
||||
|
||||
it('normalizes oversized payloads when adding console entries', () => {
|
||||
useTerminalConsoleStore.getState().addConsole({
|
||||
workflowId: 'wf-1',
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
} from '@/components/agent-stream/tool-call-lifecycle'
|
||||
import { isChatEnabled } from '@/lib/core/config/env-flags'
|
||||
import { redactApiKeys } from '@/lib/core/security/redaction'
|
||||
import { formatCsvValue, toCsvRow } from '@/lib/core/utils/csv'
|
||||
import { sendMothershipMessage } from '@/lib/mothership/events'
|
||||
import { saveBlob } from '@/lib/uploads/client/download'
|
||||
import { getQueryClient } from '@/app/_shell/providers/query-provider'
|
||||
import type { NormalizedBlockOutput } from '@/executor/types'
|
||||
import { type GeneralSettings, generalSettingsKeys } from '@/hooks/queries/general-settings'
|
||||
@@ -431,20 +433,6 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
|
||||
return
|
||||
}
|
||||
|
||||
const formatCSVValue = (value: any): string => {
|
||||
if (value === null || value === undefined) {
|
||||
return ''
|
||||
}
|
||||
|
||||
let stringValue = typeof value === 'object' ? safeConsoleStringify(value) : String(value)
|
||||
|
||||
if (stringValue.includes('"') || stringValue.includes(',') || stringValue.includes('\n')) {
|
||||
stringValue = `"${stringValue.replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
return stringValue
|
||||
}
|
||||
|
||||
const headers = [
|
||||
'timestamp',
|
||||
'blockName',
|
||||
@@ -458,23 +446,24 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
|
||||
'error',
|
||||
'warning',
|
||||
]
|
||||
const serializeValue = (value: unknown) => formatCsvValue(value, safeConsoleStringify)
|
||||
|
||||
const csvRows = [
|
||||
headers.join(','),
|
||||
toCsvRow(headers),
|
||||
...entries.map((entry) =>
|
||||
[
|
||||
formatCSVValue(entry.timestamp),
|
||||
formatCSVValue(entry.blockName),
|
||||
formatCSVValue(entry.blockType),
|
||||
formatCSVValue(entry.startedAt),
|
||||
formatCSVValue(entry.endedAt),
|
||||
formatCSVValue(entry.durationMs),
|
||||
formatCSVValue(entry.success),
|
||||
formatCSVValue(entry.input),
|
||||
formatCSVValue(entry.output),
|
||||
formatCSVValue(entry.error),
|
||||
formatCSVValue(entry.warning),
|
||||
].join(',')
|
||||
toCsvRow([
|
||||
serializeValue(entry.timestamp),
|
||||
serializeValue(entry.blockName),
|
||||
serializeValue(entry.blockType),
|
||||
serializeValue(entry.startedAt),
|
||||
serializeValue(entry.endedAt),
|
||||
serializeValue(entry.durationMs),
|
||||
serializeValue(entry.success),
|
||||
serializeValue(entry.input),
|
||||
serializeValue(entry.output),
|
||||
serializeValue(entry.error),
|
||||
serializeValue(entry.warning),
|
||||
])
|
||||
),
|
||||
]
|
||||
|
||||
@@ -482,19 +471,7 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
||||
const filename = `terminal-console-${workflowId}-${timestamp}.csv`
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||
const link = document.createElement('a')
|
||||
|
||||
if (link.download !== undefined) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
link.setAttribute('href', url)
|
||||
link.setAttribute('download', filename)
|
||||
link.style.visibility = 'hidden'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
saveBlob(new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }), filename)
|
||||
},
|
||||
|
||||
getWorkflowEntries: (workflowId) => {
|
||||
|
||||
Reference in New Issue
Block a user