Skip to content

Commit

Permalink
feat: Test hog functions (#23017)
Browse files Browse the repository at this point in the history
  • Loading branch information
benjackwhite authored Jun 20, 2024
1 parent 106ba92 commit bf9c242
Show file tree
Hide file tree
Showing 36 changed files with 1,564 additions and 354 deletions.
13 changes: 12 additions & 1 deletion frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { ActivityLogItem } from 'lib/components/ActivityLog/humanizeActivity'
import { apiStatusLogic } from 'lib/logic/apiStatusLogic'
import { objectClean, toParams } from 'lib/utils'
import posthog from 'posthog-js'
import { LogEntry } from 'scenes/pipeline/pipelineNodeLogsLogic'
import { SavedSessionRecordingPlaylistsResult } from 'scenes/session-recordings/saved-playlists/savedSessionRecordingPlaylistsLogic'

import { getCurrentExporterData } from '~/exporter/exporterViewLogic'
Expand Down Expand Up @@ -50,6 +49,7 @@ import {
InsightModel,
IntegrationType,
ListOrganizationMembersParams,
LogEntry,
MediaUploadResponse,
NewEarlyAccessFeatureType,
NotebookListItemType,
Expand Down Expand Up @@ -1685,6 +1685,17 @@ const api = {
async listIcons(params: { query?: string } = {}): Promise<HogFunctionIconResponse[]> {
return await new ApiRequest().hogFunctions().withAction('icons').withQueryString(params).get()
},

async createTestInvocation(
id: HogFunctionType['id'],
data: {
configuration: Partial<HogFunctionType>
mock_async_functions: boolean
event: any
}
): Promise<any> {
return await new ApiRequest().hogFunction(id).withAction('invocations').create({ data })
},
},

annotations: {
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/lib/components/CodeEditors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export function CodeEditor({ options, onMount, ...editorProps }: CodeEditorProps
}

export function CodeEditorResizeable({
height: defaultHeight = 200,
height: defaultHeight,
minHeight = '5rem',
maxHeight = '90vh',
...props
Expand All @@ -84,7 +84,7 @@ export function CodeEditorResizeable({
maxHeight?: string | number
}): JSX.Element {
const [height, setHeight] = useState(defaultHeight)
const [manualHeight, setManualHeight] = useState<number>()
const [manualHeight, setManualHeight] = useState<number | undefined>(defaultHeight)

const ref = useRef<HTMLDivElement | null>(null)

Expand Down
188 changes: 188 additions & 0 deletions frontend/src/scenes/pipeline/hogfunctions/HogFunctionTest.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { TZLabel } from '@posthog/apps-common'
import { IconInfo, IconX } from '@posthog/icons'
import { LemonButton, LemonLabel, LemonSwitch, LemonTable, LemonTag, Tooltip } from '@posthog/lemon-ui'
import clsx from 'clsx'
import { useActions, useValues } from 'kea'
import { Form } from 'kea-forms'
import { CodeEditorResizeable } from 'lib/components/CodeEditors'
import { LemonField } from 'lib/lemon-ui/LemonField'

import { hogFunctionTestLogic, HogFunctionTestLogicProps } from './hogFunctionTestLogic'

const HogFunctionTestEditor = ({
value,
onChange,
}: {
value: string
onChange?: (value?: string) => void
}): JSX.Element => {
return (
<CodeEditorResizeable
language="json"
value={value}
height={300}
onChange={onChange}
options={{
lineNumbers: 'off',
minimap: {
enabled: false,
},
quickSuggestions: {
other: true,
strings: true,
},
suggest: {
showWords: false,
showFields: false,
showKeywords: false,
},
scrollbar: {
vertical: 'hidden',
verticalScrollbarSize: 0,
},
}}
/>
)
}

export function HogFunctionTestPlaceholder(): JSX.Element {
return (
<div className="border bg-accent-3000 rounded p-3 space-y-2">
<h2 className="flex-1 m-0">Testing</h2>
<p>Save your configuration to enable testing</p>
</div>
)
}

export function HogFunctionTest(props: HogFunctionTestLogicProps): JSX.Element {
const { isTestInvocationSubmitting, testResult, expanded } = useValues(hogFunctionTestLogic(props))
const { submitTestInvocation, setTestResult, toggleExpanded } = useActions(hogFunctionTestLogic(props))

return (
<Form logic={hogFunctionTestLogic} props={props} formKey="testInvocation" enableFormOnSubmit>
<div className={clsx('border bg-bg-light rounded p-3 space-y-2', expanded && 'min-h-120')}>
<div className="flex items-center gap-2 justify-end">
{!expanded ? (
<LemonButton className="flex-1" onClick={() => toggleExpanded()}>
<h2 className="m-0">Testing</h2>
</LemonButton>
) : (
<h2 className="flex-1 m-0">Testing</h2>
)}

{expanded && (
<>
{testResult ? (
<LemonButton
type="primary"
onClick={() => setTestResult(null)}
loading={isTestInvocationSubmitting}
>
Clear test result
</LemonButton>
) : (
<>
<LemonField name="mock_async_functions">
{({ value, onChange }) => (
<LemonSwitch
bordered
onChange={onChange}
checked={value}
label={
<Tooltip
title={
<>
When selected, async functions such as `fetch` will not
actually be called but instead will be mocked out with
the fetch content logged instead
</>
}
>
<span className="flex gap-2">
Mock out async functions
<IconInfo className="text-lg" />
</span>
</Tooltip>
}
/>
)}
</LemonField>
<LemonButton
type="primary"
onClick={submitTestInvocation}
loading={isTestInvocationSubmitting}
>
Test function
</LemonButton>
</>
)}

<LemonButton icon={<IconX />} onClick={() => toggleExpanded()} tooltip="Hide testing" />
</>
)}
</div>

{expanded && (
<>
{testResult ? (
<div className="space-y-2">
<div className="flex justify-between items-center">
<LemonLabel>Test invocation result </LemonLabel>
<LemonTag type={testResult.status === 'success' ? 'success' : 'danger'}>
{testResult.status}
</LemonTag>
</div>

<LemonTable
dataSource={testResult.logs ?? []}
columns={[
{
title: 'Timestamp',
key: 'timestamp',
dataIndex: 'timestamp',
render: (timestamp) => <TZLabel time={timestamp as string} />,
width: 0,
},
{
width: 100,
title: 'Level',
key: 'level',
dataIndex: 'level',
},
{
title: 'Message',
key: 'message',
dataIndex: 'message',
render: (message) => <code className="whitespace-pre-wrap">{message}</code>,
},
]}
className="ph-no-capture"
rowKey="timestamp"
pagination={{ pageSize: 200, hideOnSinglePage: true }}
/>
</div>
) : (
<div className="space-y-2">
<LemonField name="globals" label="Test invocation context">
{({ value, onChange }) => (
<>
<div className="flex items-start justify-end">
<p className="flex-1">
The globals object is the context in which your function will be
tested. It should contain all the data that your function will need
to run
</p>
</div>

<HogFunctionTestEditor value={value} onChange={onChange} />
</>
)}
</LemonField>
</div>
)}
</>
)}
</div>
</Form>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { EntityTypes } from '~/types'

import { HogFunctionIconEditable } from './HogFunctionIcon'
import { HogFunctionInputWithSchema } from './HogFunctionInputs'
import { HogFunctionTest, HogFunctionTestPlaceholder } from './HogFunctionTest'
import { pipelineHogFunctionConfigurationLogic } from './pipelineHogFunctionConfigurationLogic'

export function PipelineHogFunctionConfiguration({
Expand Down Expand Up @@ -343,6 +344,8 @@ export function PipelineHogFunctionConfiguration({
)}
</div>
</div>

{id ? <HogFunctionTest id={id} /> : <HogFunctionTestPlaceholder />}
<div className="flex gap-2 justify-end">{saveButtons}</div>
</div>
</div>
Expand Down
95 changes: 95 additions & 0 deletions frontend/src/scenes/pipeline/hogfunctions/hogFunctionTestLogic.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { lemonToast } from '@posthog/lemon-ui'
import { actions, afterMount, connect, kea, key, path, props, reducers } from 'kea'
import { forms } from 'kea-forms'
import api from 'lib/api'
import { tryJsonParse } from 'lib/utils'

import { LogEntry } from '~/types'

import type { hogFunctionTestLogicType } from './hogFunctionTestLogicType'
import { pipelineHogFunctionConfigurationLogic, sanitizeConfiguration } from './pipelineHogFunctionConfigurationLogic'
import { createExampleEvent } from './utils/event-conversion'

export interface HogFunctionTestLogicProps {
id: string
}

export type HogFunctionTestInvocationForm = {
globals: string // HogFunctionInvocationGlobals
mock_async_functions: boolean
}

export type HogFunctionTestInvocationResult = {
status: 'success' | 'error'
logs: LogEntry[]
}

export const hogFunctionTestLogic = kea<hogFunctionTestLogicType>([
props({} as HogFunctionTestLogicProps),
key((props) => props.id),
path((id) => ['scenes', 'pipeline', 'hogfunctions', 'hogFunctionTestLogic', id]),
connect((props: HogFunctionTestLogicProps) => ({
values: [pipelineHogFunctionConfigurationLogic({ id: props.id }), ['configuration', 'configurationHasErrors']],
actions: [pipelineHogFunctionConfigurationLogic({ id: props.id }), ['touchConfigurationField']],
})),
actions({
setTestResult: (result: HogFunctionTestInvocationResult | null) => ({ result }),
toggleExpanded: (expanded?: boolean) => ({ expanded }),
}),
reducers({
expanded: [
false as boolean,
{
toggleExpanded: (_, { expanded }) => (expanded === undefined ? !_ : expanded),
},
],

testResult: [
null as HogFunctionTestInvocationResult | null,
{
setTestResult: (_, { result }) => result,
},
],
}),
forms(({ props, actions, values }) => ({
testInvocation: {
defaults: {
mock_async_functions: true,
} as HogFunctionTestInvocationForm,
alwaysShowErrors: true,
errors: ({ globals }) => {
return {
globals: !globals ? 'Required' : tryJsonParse(globals) ? undefined : 'Invalid JSON',
}
},
submit: async (data) => {
// Submit the test invocation
// Set the response somewhere

if (values.configurationHasErrors) {
lemonToast.error('Please fix the configuration errors before testing.')
// TODO: How to get the form to show errors without submitting?
return
}

const event = tryJsonParse(data.globals)
const configuration = sanitizeConfiguration(values.configuration)

try {
const res = await api.hogFunctions.createTestInvocation(props.id, {
event,
mock_async_functions: data.mock_async_functions,
configuration,
})

actions.setTestResult(res)
} catch (e) {
lemonToast.error(`An unexpected serror occurred while trying to testing the function. ${e}`)
}
},
},
})),
afterMount(({ actions }) => {
actions.setTestInvocationValue('globals', JSON.stringify(createExampleEvent(), null, 2))
}),
])
Loading

0 comments on commit bf9c242

Please sign in to comment.