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 (
+
+
+
+ |
+ Message
+ |
+
+ Source
+ |
+
+ Time
+ |
+
+
+
+ {logs.map(({ source, entry }, index) => (
+
+
+
+ {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"
>
-
+
-
+