diff --git a/src/components/AutoScrollArea.tsx b/src/components/AutoScrollArea.tsx index 71f4bc3ef..ddf4123a1 100644 --- a/src/components/AutoScrollArea.tsx +++ b/src/components/AutoScrollArea.tsx @@ -1,3 +1,4 @@ +import { css } from '@emotion/react' import { ScrollArea, ScrollAreaProps } from '@radix-ui/themes' import { ReactNode, UIEvent, useRef } from 'react' @@ -50,7 +51,14 @@ export function AutoScrollArea({ return ( -
{children}
+
+ {children} +
) } diff --git a/src/components/Validator/ExecutionDetails.tsx b/src/components/Validator/ExecutionDetails.tsx index 21f488107..fc6091032 100644 --- a/src/components/Validator/ExecutionDetails.tsx +++ b/src/components/Validator/ExecutionDetails.tsx @@ -8,7 +8,7 @@ import { Check, LogEntry } from '@/schemas/k6' import { ReadOnlyEditor } from '../Monaco/ReadOnlyEditor' import { ChecksSection } from './ChecksSection' -import { LogsSection } from './LogsSection' +import { LogsSection, useConsoleFilter } from './LogsSection' interface ExecutionDetailsProps { isRunning: boolean @@ -27,6 +27,10 @@ export function ExecutionDetails({ script !== undefined ? 'script' : 'logs' ) + const consoleFilter = useConsoleFilter({ + browser: false, + }) + const handleTabChange = (value: string) => { if (value !== 'logs' && value !== 'checks' && value !== 'script') { return @@ -74,7 +78,7 @@ export function ExecutionDetails({ min-height: 0; `} > - + {script !== undefined && ( = { - info: 'green', - debug: 'blue', - warning: 'orange', - error: 'red', -} - -const headerStyles = css` - position: sticky; - top: 0; - --table-row-background-color: var(--color-background); -` - -interface LogsSectionProps { - logs: LogEntry[] - autoScroll: boolean -} - -export function LogsSection({ logs, autoScroll }: LogsSectionProps) { - const ref = useAutoScroll(logs, autoScroll) - - // Radix UI's Table component wraps the table element in a ScrollArea but in - // order to autoscroll we need to get a ref to the table element. Ideally Radix - // would provide a way to do this, but instead we have to get the ref to an - // inner element and find the table element from there. - const setTableRef = useCallback( - (element: HTMLTableSectionElement | null) => { - if (element === null) { - return - } - - const table = findTableElement(element) - - if (table === null) { - return - } - - ref.current = table - }, - [ref] - ) - - return ( - - - - - Time - - - Message - - - - - {logs.map((log, index) => ( - - - {log.time} - - -
-                {log.msg}
-              
-
-
- ))} -
-
- ) -} diff --git a/src/components/Validator/LogsSection/LogFilter.tsx b/src/components/Validator/LogsSection/LogFilter.tsx new file mode 100644 index 000000000..52745fff3 --- /dev/null +++ b/src/components/Validator/LogsSection/LogFilter.tsx @@ -0,0 +1,137 @@ +import { css } from '@emotion/react' +import * as ToggleGroup from '@radix-ui/react-toggle-group' +import { Separator } from '@radix-ui/themes' + +import { ConsoleFilter, SourcesOptions } from './types' + +function isLogSource(value: string) { + return value === 'browser' || value === 'runtime' || value === 'script' +} + +function isLogLevel(value: string) { + return ( + value === 'debug' || + value === 'info' || + value === 'warning' || + value === 'error' + ) +} + +const toggleGroupStyles = css` + display: flex; + gap: var(--space-1); +` + +const toggleItemStyles = css` + box-sizing: border-box; + padding: var(--space-1) var(--space-2); + font-size: var(--font-size-2); + border: none; + border-radius: var(--radius-2); + cursor: pointer; + color: var(--gray-11); + background-color: transparent; + + &[data-state='on'] { + background-color: var(--gray-a4); + } +` + +interface LogFilterProps { + sources: SourcesOptions + filter: ConsoleFilter + onChange: (filter: ConsoleFilter) => void +} + +export function LogFilter({ sources, filter, onChange }: LogFilterProps) { + const handleLogLevelsChange = (values: string[]) => { + onChange({ + ...filter, + levels: values.filter(isLogLevel), + }) + } + + const handleLogSourcesChange = (values: string[]) => { + onChange({ + ...filter, + sources: values.filter(isLogSource), + }) + } + + return ( + <> + + + Debug + + + Info + + + Warning + + + Error + + + + + {sources.browser && ( + + Browser + + )} + {sources.script && ( + + Script + + )} + {sources.runtime && ( + + Runtime + + )} + + + ) +} diff --git a/src/components/Validator/LogsSection/LogsSection.tsx b/src/components/Validator/LogsSection/LogsSection.tsx new file mode 100644 index 000000000..f8c28434a --- /dev/null +++ b/src/components/Validator/LogsSection/LogsSection.tsx @@ -0,0 +1,147 @@ +import { css } from '@emotion/react' +import { Flex, Text } from '@radix-ui/themes' +import { ReactNode, useMemo, useState } from 'react' + +import { AutoScrollArea } from '@/components/AutoScrollArea' +import { LogEntry } from '@/schemas/k6' + +import { LogFilter } from './LogFilter' +import { withSource } from './LogsSection.utils' +import { LogsTable } from './LogsTable' +import { ConsoleFilter, LogSource, SourcesOptions } from './types' + +const ALL_LOG_LEVELS: Array = [ + 'info', + 'debug', + 'warning', + 'error', +] + +const ALL_LOG_SOURCES: Array = ['browser', 'runtime', 'script'] + +interface AvailableSources { + browser?: boolean + runtime?: boolean + script?: boolean +} + +export function useConsoleFilter({ + browser = true, + runtime = true, + script = true, +}: AvailableSources = {}) { + function filterSources(source: LogSource) { + return ( + (source === 'browser' && browser) || + (source === 'runtime' && runtime) || + (source === 'script' && script) + ) + } + + const [filter, setFilter] = useState({ + levels: ALL_LOG_LEVELS, + sources: ALL_LOG_SOURCES.filter(filterSources), + }) + + return { + sources: { + browser, + runtime, + script, + }, + filter, + onFilterChange: (filter: ConsoleFilter) => { + setFilter({ + ...filter, + sources: filter.sources.filter(filterSources), + }) + }, + } +} + +function LogMessage({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ) +} + +interface LogsContentProps { + filter: ConsoleFilter + logs: LogEntry[] +} + +function LogsContent({ filter, logs }: LogsContentProps) { + const filteredLogs = useMemo(() => { + return logs.map(withSource).filter((log) => { + return ( + filter.levels.includes(log.entry.level) && + filter.sources.includes(log.source) + ) + }) + }, [logs, filter]) + + if (logs.length === 0) { + return No logs available. + } + + if (filteredLogs.length === 0) { + return No logs match the filter. + } + + return +} + +interface LogsSectionProps { + sources: SourcesOptions + filter: ConsoleFilter + logs: LogEntry[] + autoScroll: boolean + onFilterChange: (filter: ConsoleFilter) => void +} + +export function LogsSection({ + sources, + filter, + logs, + autoScroll, + onFilterChange, +}: LogsSectionProps) { + return ( + + +
+ Filters: +
+ +
+ + + +
+ ) +} diff --git a/src/components/Validator/LogsSection/LogsSection.utils.ts b/src/components/Validator/LogsSection/LogsSection.utils.ts new file mode 100644 index 000000000..9f441e115 --- /dev/null +++ b/src/components/Validator/LogsSection/LogsSection.utils.ts @@ -0,0 +1,39 @@ +import { LogEntry } from '@/schemas/k6' + +import { LogEntryWithSource } from './types' + +export function formatTime(time: string) { + const date = new Date(time) + + return date.toLocaleTimeString(navigator.language, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) +} + +/** + * LogEntry has a source property but it's not really reliable and doesn't let us + * distinguish between logs from the browser module and logs from the actual browser, + * so we use our own source mappings. + */ +export function getSource(entry: LogEntry) { + if (entry.process === 'browser') { + return 'browser' + } + + // Console is ambiguous in this context because it could be referring to the console + // API in k6 or in the browser. To avoid confusion we re-map it to "script". + if (entry.source === 'console') { + return 'script' + } + + return 'runtime' +} + +export function withSource(entry: LogEntry): LogEntryWithSource { + return { + source: getSource(entry), + entry, + } +} diff --git a/src/components/Validator/LogsSection/LogsTable.tsx b/src/components/Validator/LogsSection/LogsTable.tsx new file mode 100644 index 000000000..977a355a6 --- /dev/null +++ b/src/components/Validator/LogsSection/LogsTable.tsx @@ -0,0 +1,106 @@ +import { css } from '@emotion/react' +import { Text, VisuallyHidden } from '@radix-ui/themes' + +import { LogEntry } from '@/schemas/k6' + +import { formatTime } from './LogsSection.utils' +import { LogEntryWithSource } from './types' + +const colors: Record = { + info: 'green', + debug: 'blue', + warning: 'yellow', + error: 'red', +} + +interface LogsTableProps { + logs: LogEntryWithSource[] +} + +export function LogsTable({ logs }: LogsTableProps) { + return ( + + + + + + + + + + {logs.map(({ source, entry }, index) => ( + + + + + + + + + + ))} + +
+ Message + + Source + + Time +
+
+                {entry.msg}
+              
+
[{source}]{formatTime(entry.time)}
+ ) +} diff --git a/src/components/Validator/LogsSection/index.tsx b/src/components/Validator/LogsSection/index.tsx new file mode 100644 index 000000000..1dc86d692 --- /dev/null +++ b/src/components/Validator/LogsSection/index.tsx @@ -0,0 +1 @@ +export * from './LogsSection' diff --git a/src/components/Validator/LogsSection/types.ts b/src/components/Validator/LogsSection/types.ts new file mode 100644 index 000000000..d0a265c66 --- /dev/null +++ b/src/components/Validator/LogsSection/types.ts @@ -0,0 +1,19 @@ +import { LogEntry } from '@/schemas/k6' + +export type LogSource = 'browser' | 'runtime' | 'script' + +export interface SourcesOptions { + browser: boolean + runtime: boolean + script: boolean +} + +export interface ConsoleFilter { + levels: Array + sources: Array +} + +export interface LogEntryWithSource { + source: LogSource + entry: LogEntry +} diff --git a/src/hooks/useAutoScroll.ts b/src/hooks/useAutoScroll.ts index 3e5fc221e..1d81d7fe6 100644 --- a/src/hooks/useAutoScroll.ts +++ b/src/hooks/useAutoScroll.ts @@ -1,9 +1,9 @@ -import { MutableRefObject, useEffect, useRef } from 'react' +import { RefObject, useEffect, useRef } from 'react' export function useAutoScroll( items: unknown, enabled = true -): MutableRefObject { +): RefObject { const bottomRef = useRef(null) useEffect(() => { diff --git a/src/main/runner/shims/browser/proxies/page.ts b/src/main/runner/shims/browser/proxies/page.ts index 484946a7e..1f3e33a96 100644 --- a/src/main/runner/shims/browser/proxies/page.ts +++ b/src/main/runner/shims/browser/proxies/page.ts @@ -5,10 +5,10 @@ import { createSingleEntryGuard, ProxyOptions, trackLog } from '../utils' import { locatorProxy } from './locator' import { isLocatorMethod } from './utils' -const isPageInstrumented = createSingleEntryGuard() +const shouldInstrument = createSingleEntryGuard() export function pageProxy(target: Page): ProxyOptions { - if (!isPageInstrumented(target)) { + if (shouldInstrument(target)) { target.on('console', (msg) => { const type = msg.type() @@ -27,6 +27,7 @@ export function pageProxy(target: Page): ProxyOptions { msg: msg.text(), time: new Date().toISOString(), source: 'browser', + process: 'browser', }) }) } diff --git a/src/schemas/k6.ts b/src/schemas/k6.ts index f0250bba5..c0df1717a 100644 --- a/src/schemas/k6.ts +++ b/src/schemas/k6.ts @@ -6,6 +6,7 @@ export const LogEntrySchema = z.object({ source: z.string().optional(), time: z.string(), error: z.string().optional(), + process: z.union([z.literal('k6'), z.literal('browser')]).default('k6'), }) export const CheckSchema = z.object({ diff --git a/src/test/factories/k6Log.ts b/src/test/factories/k6Log.ts index 5901c2c54..2630c717b 100644 --- a/src/test/factories/k6Log.ts +++ b/src/test/factories/k6Log.ts @@ -7,6 +7,7 @@ export function createK6Log(log?: Partial): LogEntry { level: 'info', source: 'source', time: '00:00:00', + process: 'k6', ...log, } } diff --git a/src/views/BrowserTestEditor/BrowserTestEditor.tsx b/src/views/BrowserTestEditor/BrowserTestEditor.tsx index 2f3d2436a..fd51d3c90 100644 --- a/src/views/BrowserTestEditor/BrowserTestEditor.tsx +++ b/src/views/BrowserTestEditor/BrowserTestEditor.tsx @@ -5,7 +5,10 @@ import { useNavigate } from 'react-router-dom' import { FileNameHeader } from '@/components/FileNameHeader' import { View } from '@/components/Layout/View' import { ReadOnlyEditor } from '@/components/Monaco/ReadOnlyEditor' -import { LogsSection } from '@/components/Validator/LogsSection' +import { + LogsSection, + useConsoleFilter, +} from '@/components/Validator/LogsSection' import { Group, Panel, Separator } from '@/components/primitives/ResizablePanel' import { routeMap } from '@/routeMap' import { BrowserTestFile } from '@/schemas/browserTest/v1' @@ -36,6 +39,8 @@ function BrowserTestEditorView({ file, data }: BrowserTestEditorViewProps) { const { mutateAsync: saveBrowserTest } = useSaveBrowserTest(file.fileName) + const consoleFilter = useConsoleFilter() + const test = useBrowserTestState(data) const preview = useBrowserScriptPreview(test.actions) @@ -162,7 +167,11 @@ function BrowserTestEditorView({ file, data }: BrowserTestEditorViewProps) { `} value="console" > - +
- +