diff --git a/resources/checks_snippet.js b/resources/checks_snippet.js index 0fd65ed5..db512e04 100644 --- a/resources/checks_snippet.js +++ b/resources/checks_snippet.js @@ -1,13 +1,26 @@ export function handleSummary(data) { const checks = [] + + function traverseGroup(group) { + if (group.checks) { + group.checks.forEach((check) => { + checks.push(check) + }) + } + if (group.groups) { + group.groups.forEach((subGroup) => { + traverseGroup(subGroup) + }) + } + } + data.root_group.checks.forEach((check) => { checks.push(check) }) data.root_group.groups.forEach((group) => { - group.checks.forEach((check) => { - checks.push(check) - }) + traverseGroup(group) }) + return { stdout: JSON.stringify(checks), } diff --git a/src/preload.ts b/src/preload.ts index ddd30c50..e4be4c9e 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -1,5 +1,5 @@ import { ipcRenderer, contextBridge, IpcRendererEvent } from 'electron' -import { ProxyData, K6Log, FolderContent } from './types' +import { ProxyData, K6Log, FolderContent, K6Check } from './types' import { HarFile } from './types/har' import { GeneratorFile } from './types/generator' import { AddToastPayload } from './types/toast' @@ -71,6 +71,9 @@ const script = { onScriptStopped: (callback: () => void) => { return createListener('script:stopped', callback) }, + onScriptCheck: (callback: (data: K6Check[]) => void) => { + return createListener('script:check', callback) + }, } as const const har = { diff --git a/src/views/Validator/CheckRow.tsx b/src/views/Validator/CheckRow.tsx new file mode 100644 index 00000000..26d39b57 --- /dev/null +++ b/src/views/Validator/CheckRow.tsx @@ -0,0 +1,25 @@ +import { K6Check } from '@/types' +import { Cross2Icon, CheckIcon } from '@radix-ui/react-icons' +import { Table } from '@radix-ui/themes' +import { hasFailures, getPassPercentage } from './ChecksSection.utils' + +export function CheckRow({ check }: { check: K6Check }) { + return ( + + + {hasFailures(check) && ( + + )} + {!hasFailures(check) && ( + + )} + + {check.name} + + {getPassPercentage(check).toFixed(2)}% + + {check.passes} + {check.fails} + + ) +} diff --git a/src/views/Validator/ChecksSection.tsx b/src/views/Validator/ChecksSection.tsx new file mode 100644 index 00000000..71ccbff2 --- /dev/null +++ b/src/views/Validator/ChecksSection.tsx @@ -0,0 +1,82 @@ +import { CollapsibleSection } from '@/components/CollapsibleSection' +import { K6Check } from '@/types' +import { css } from '@emotion/react' +import { InfoCircledIcon } from '@radix-ui/react-icons' +import { Box, Callout, ScrollArea, Table } from '@radix-ui/themes' +import { groupChecksByPath } from './ChecksSection.utils' +import { CheckRow } from './CheckRow' + +interface ChecksSectionProps { + checks: K6Check[] + isRunning: boolean +} + +export function ChecksSection({ checks, isRunning }: ChecksSectionProps) { + if (!checks.length || isRunning) { + return + } + + const groupedChecks = groupChecksByPath(checks) + + return ( + + + {Object.entries(groupedChecks).map(([key, checks]) => ( + + + + + + Name + + Success rate + + + Success count + + + Fail count + + + + + + {checks.map((check) => ( + + ))} + + + + } + key={key} + defaultOpen + > + + {key} ({checks.length}) + + + ))} + + + ) +} + +function NoChecksMessage() { + return ( + + + + + + Your checks will appear here. + + + ) +} diff --git a/src/views/Validator/ChecksSection.utils.test.ts b/src/views/Validator/ChecksSection.utils.test.ts new file mode 100644 index 00000000..f183129d --- /dev/null +++ b/src/views/Validator/ChecksSection.utils.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' + +import { + getPassPercentage, + groupChecksByPath, + hasFailures, +} from './ChecksSection.utils' +import { K6Check } from '@/types' + +function buildCheck(data?: Partial) { + return { + id: self.crypto.randomUUID(), + name: 'test check', + passes: 0, + fails: 0, + path: '', + ...data, + } +} + +describe('Checks Section - utils', () => { + describe('getPassPercentage', () => { + it('should return the percentage of passes in a check', () => { + const check = buildCheck({ passes: 30, fails: 20 }) + const actual = getPassPercentage(check) + const expected = 60 + expect(actual).toEqual(expected) + }) + + it('should return 0 if passes and fails are zero', () => { + const check = buildCheck({ passes: 0, fails: 0 }) + const actual = getPassPercentage(check) + const expected = 0 + expect(actual).toEqual(expected) + }) + }) + + describe('groupChecksByPath', () => { + it('should group checks by path', () => { + const check1 = buildCheck({ path: '::check1' }) + const check2 = buildCheck({ path: '::check2' }) + const check3 = buildCheck({ path: '::group1::check3' }) + const check4 = buildCheck({ path: '::group2::check4' }) + const check5 = buildCheck({ path: '::group2::subgroup::check5' }) + + const groupedChecks = groupChecksByPath([ + check1, + check2, + check3, + check4, + check5, + ]) + + const expected = { + default: [check1, check2], + group1: [check3], + group2: [check4], + 'group2::subgroup': [check5], + } + + expect(groupedChecks).toMatchObject(expected) + }) + }) + + describe('hasFailures', () => { + it('should return true when the check has fails', () => { + const check = buildCheck({ fails: 1 }) + expect(hasFailures(check)).toBeTruthy() + }) + + it('should return false when the check has no fails', () => { + const check = buildCheck({ fails: 0 }) + expect(hasFailures(check)).toBeFalsy() + }) + }) +}) diff --git a/src/views/Validator/ChecksSection.utils.ts b/src/views/Validator/ChecksSection.utils.ts new file mode 100644 index 00000000..64126213 --- /dev/null +++ b/src/views/Validator/ChecksSection.utils.ts @@ -0,0 +1,37 @@ +import { K6Check } from '@/types' + +export function groupChecksByPath(checks: K6Check[]) { + const result: Record = { + default: [], + } + + checks.forEach((item) => { + const paths = item.path.split('::').filter(Boolean) + + if (paths.length === 1) { + result['default']?.push(item) + } else { + const pathName = paths.slice(0, -1).join('::') + + if (result[pathName]) { + result[pathName].push(item) + } else { + result[pathName] = [item] + } + } + }) + + return result +} + +export function hasFailures(check: K6Check) { + return check.fails > 0 +} + +export function getPassPercentage(check: K6Check) { + const total = check.passes + check.fails + if (total === 0) { + return 0 + } + return (check.passes / total) * 100 +} diff --git a/src/views/Validator/Validator.tsx b/src/views/Validator/Validator.tsx index 842cd362..70ee4c93 100644 --- a/src/views/Validator/Validator.tsx +++ b/src/views/Validator/Validator.tsx @@ -6,7 +6,7 @@ import { Allotment } from 'allotment' import { useListenProxyData } from '@/hooks/useListenProxyData' import { useSetWindowTitle } from '@/hooks/useSetWindowTitle' -import { K6Log, ProxyData } from '@/types' +import { K6Check, K6Log, ProxyData } from '@/types' import { LogsSection } from './LogsSection' import { ValidatorControls } from './ValidatorControls' import { View } from '@/components/Layout/View' @@ -15,6 +15,7 @@ import { ReadOnlyEditor } from '@/components/Monaco/ReadOnlyEditor' import { getRoutePath } from '@/routeMap' import { Details } from '@/components/WebLogView/Details' import { useScriptPath } from './Validator.hooks' +import { ChecksSection } from './ChecksSection' export function Validator() { const [selectedRequest, setSelectedRequest] = useState(null) @@ -23,6 +24,7 @@ export function Validator() { const { scriptPath, isExternal } = useScriptPath() const [isRunning, setIsRunning] = useState(false) const [logs, setLogs] = useState([]) + const [checks, setChecks] = useState([]) const navigate = useNavigate() const { proxyData, resetProxyData } = useListenProxyData() @@ -87,6 +89,12 @@ export function Validator() { }) }, []) + useEffect(() => { + return window.studio.script.onScriptCheck((checks) => { + setChecks(checks) + }) + }, []) + useEffect(() => { // Reset requests and logs when script changes resetProxyData() @@ -148,6 +156,9 @@ export function Validator() { Logs ({logs.length}) + + Checks ({checks.length}) + Script @@ -160,6 +171,15 @@ export function Validator() { > + + +