Skip to content

Commit cf32e4e

Browse files
Allow filtering app dev logs by type
1 parent ec6b98a commit cf32e4e

8 files changed

Lines changed: 279 additions & 36 deletions

File tree

‎.changeset/filter-app-dev-logs.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@shopify/app': patch
3+
'@shopify/cli': patch
4+
'@shopify/cli-kit': patch
5+
---
6+
7+
Add an interactive log filter to `shopify app dev`.

‎packages/app/src/cli/services/dev/ui/components/DevSessionUI.test.tsx‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import React from 'react'
1212
import {beforeEach, describe, expect, test, vi} from 'vitest'
1313
import {unstyled} from '@shopify/cli-kit/node/output'
1414
import {openURL} from '@shopify/cli-kit/node/system'
15+
import {useConcurrentOutputContext} from '@shopify/cli-kit/node/ui/components'
1516
import {Writable} from 'stream'
1617

1718
vi.mock('@shopify/cli-kit/node/system', async () => {
@@ -176,6 +177,95 @@ describe('DevSessionUI', () => {
176177
renderInstance.unmount()
177178
})
178179

180+
test('cycles through log prefixes and only renders output for the selected prefix', async () => {
181+
let processesStartedResolve: () => void
182+
const processesStarted = new Promise<void>((resolve) => {
183+
processesStartedResolve = resolve
184+
})
185+
let releaseProcesses = () => {}
186+
const processesReleased = new Promise<void>((resolve) => {
187+
releaseProcesses = resolve
188+
})
189+
let startedProcessCount = 0
190+
const processStarted = () => {
191+
startedProcessCount++
192+
if (startedProcessCount === 3) processesStartedResolve()
193+
}
194+
const appPreviewProcess = {
195+
prefix: 'app-preview',
196+
action: async (stdout: Writable) => {
197+
stdout.write('app preview message')
198+
useConcurrentOutputContext({outputPrefix: 'app_home'}, () => {
199+
stdout.write('app home message')
200+
})
201+
processStarted()
202+
await processesReleased
203+
},
204+
}
205+
const webProcess = {
206+
prefix: 'React Router',
207+
action: async (stdout: Writable) => {
208+
stdout.write('react router message')
209+
processStarted()
210+
await processesReleased
211+
},
212+
}
213+
const graphiqlProcess = {
214+
prefix: 'graphiql',
215+
action: async (stdout: Writable) => {
216+
stdout.write('graphiql message')
217+
processStarted()
218+
await processesReleased
219+
},
220+
}
221+
222+
const renderInstance = render(
223+
<DevSessionUI
224+
processes={[appPreviewProcess, webProcess, graphiqlProcess]}
225+
abortController={new AbortController()}
226+
devSessionStatusManager={devSessionStatusManager}
227+
shopFqdn="mystore.myshopify.com"
228+
onAbort={onAbort}
229+
/>,
230+
)
231+
await processesStarted
232+
await waitForContent(renderInstance, 'app home message')
233+
234+
let output = unstyled(renderInstance.lastFrame()!)
235+
expect(output).toContain('(f) Filter logs: all')
236+
expect(output).toContain('app preview message')
237+
expect(output).toContain('react router message')
238+
expect(output).toContain('app home message')
239+
expect(output).toContain('graphiql message')
240+
241+
await sendInputAndWait(renderInstance, 10, 'f')
242+
output = unstyled(renderInstance.lastFrame()!)
243+
expect(output).toContain('(f) Filter logs: app-previ')
244+
expect(output).toContain('app preview message')
245+
expect(output).not.toContain('react router message')
246+
expect(output).not.toContain('app home message')
247+
expect(output).not.toContain('graphiql message')
248+
249+
await sendInputAndWait(renderInstance, 10, 'f', 'f', 'f')
250+
output = unstyled(renderInstance.lastFrame()!)
251+
expect(output).toContain('(f) Filter logs: app_home')
252+
expect(output).not.toContain('app preview message')
253+
expect(output).not.toContain('react router message')
254+
expect(output).toContain('app home message')
255+
expect(output).not.toContain('graphiql message')
256+
257+
await sendInputAndWait(renderInstance, 10, 'f')
258+
output = unstyled(renderInstance.lastFrame()!)
259+
expect(output).toContain('(f) Filter logs: all')
260+
expect(output).toContain('app preview message')
261+
expect(output).toContain('react router message')
262+
expect(output).toContain('app home message')
263+
expect(output).toContain('graphiql message')
264+
265+
releaseProcesses()
266+
renderInstance.unmount()
267+
})
268+
179269
test('opens the previewURL when p is pressed', async () => {
180270
// When
181271
const renderInstance = render(

‎packages/app/src/cli/services/dev/ui/components/DevSessionUI.tsx‎

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {buildDevConsoleURL} from '../../../../utilities/app/app-url.js'
66
import {OutputProcess} from '@shopify/cli-kit/node/output'
77
import {Alert, ConcurrentOutput, Link, LoadingIndicator, TabularData} from '@shopify/cli-kit/node/ui/components'
88
import {useAbortSignal} from '@shopify/cli-kit/node/ui/hooks'
9-
import React, {FunctionComponent, useEffect, useMemo, useState} from 'react'
9+
import React, {FunctionComponent, useCallback, useEffect, useMemo, useRef, useState} from 'react'
1010
import {AbortController, AbortSignal} from '@shopify/cli-kit/node/abort'
1111
import {Box, MouseProvider, Text, useInput, useStdin} from '@shopify/cli-kit/node/ink'
1212
import {handleCtrlC} from '@shopify/cli-kit/node/ui'
@@ -62,6 +62,30 @@ const DevSessionUI: FunctionComponent<DevSesionUIProps> = ({
6262
const [error, setError] = useState<string | undefined>(undefined)
6363
const [status, setStatus] = useState<DevSessionStatus>(devSessionStatusManager.status)
6464
const [shouldShowPersistentDevInfo, setShouldShowPersistentDevInfo] = useState<boolean>(false)
65+
const [availableLogPrefixes, setAvailableLogPrefixes] = useState<string[]>(() => [
66+
...new Set(processes.map(({prefix}) => prefix)),
67+
])
68+
const availableLogPrefixesRef = useRef(new Set(availableLogPrefixes))
69+
const [selectedLogPrefix, setSelectedLogPrefix] = useState<string | undefined>()
70+
71+
const addAvailableLogPrefix = useCallback((prefix: string) => {
72+
if (availableLogPrefixesRef.current.has(prefix)) return
73+
74+
availableLogPrefixesRef.current.add(prefix)
75+
setAvailableLogPrefixes((currentPrefixes) => [...currentPrefixes, prefix])
76+
}, [])
77+
78+
const filterOutputByPrefix = useCallback(
79+
(prefix: string) => selectedLogPrefix === undefined || prefix === selectedLogPrefix,
80+
[selectedLogPrefix],
81+
)
82+
83+
const selectNextLogPrefix = () => {
84+
setSelectedLogPrefix((currentPrefix) => {
85+
const currentPrefixIndex = currentPrefix === undefined ? -1 : availableLogPrefixes.indexOf(currentPrefix)
86+
return availableLogPrefixes[currentPrefixIndex + 1]
87+
})
88+
}
6589

6690
// eslint-disable-next-line @typescript-eslint/no-explicit-any
6791
const {isAborted} = useAbortSignal(abortController.signal, async (err: any) => {
@@ -101,6 +125,10 @@ const DevSessionUI: FunctionComponent<DevSesionUIProps> = ({
101125
}
102126
}, [])
103127

128+
useEffect(() => {
129+
processes.forEach(({prefix}) => addAvailableLogPrefix(prefix))
130+
}, [addAvailableLogPrefix, processes])
131+
104132
useInput(
105133
(input, key) => {
106134
handleCtrlC(input, key, () => abortController.abort())
@@ -234,6 +262,13 @@ const DevSessionUI: FunctionComponent<DevSesionUIProps> = ({
234262
</Box>
235263
),
236264
},
265+
// eslint-disable-next-line id-length
266+
f: {
267+
label: `Filter logs: ${selectedLogPrefix ?? 'all'}`,
268+
action: async () => {
269+
selectNextLogPrefix()
270+
},
271+
},
237272
q: {
238273
label: 'Quit',
239274
action: async () => {
@@ -250,6 +285,8 @@ const DevSessionUI: FunctionComponent<DevSesionUIProps> = ({
250285
abortSignal={abortController.signal}
251286
keepRunningAfterProcessesResolve={true}
252287
useAlternativeColorPalette={true}
288+
outputFilter={canUseShortcuts ? filterOutputByPrefix : undefined}
289+
onOutputPrefix={canUseShortcuts ? addAvailableLogPrefix : undefined}
253290
/>
254291
{shouldShowPersistentDevInfo && (
255292
<Box marginTop={1} flexDirection="column">
@@ -295,8 +332,8 @@ const DevSessionUI: FunctionComponent<DevSesionUIProps> = ({
295332
</>
296333
)
297334

298-
return canUseShortcuts && !isAborted ? (
299-
<MouseProvider allowTerminalScrolling trackMouseMovement={false}>
335+
return canUseShortcuts ? (
336+
<MouseProvider allowTerminalScrolling isActive={!isAborted} trackMouseMovement={false}>
300337
{content}
301338
</MouseProvider>
302339
) : (

‎packages/app/src/cli/services/dev/ui/components/TabPanel.test.tsx‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,16 @@ describe('TabPanel', () => {
157157
renderInstance.unmount()
158158
})
159159

160+
test('executes tab action when action tab is clicked', async () => {
161+
const renderInstance = render(<TabPanel tabs={sampleTabs} initialActiveTab="a" />)
162+
163+
await waitForInputsToBeReady()
164+
await sendInputAndWait(renderInstance, 10, ...mouseClick(100, 2))
165+
166+
expect(mockAction).toHaveBeenCalledOnce()
167+
renderInstance.unmount()
168+
})
169+
160170
test('executes shortcut action when shortcut key is pressed', async () => {
161171
const renderInstance = render(<TabPanel tabs={sampleTabs} initialActiveTab="a" />)
162172

‎packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.test.tsx‎

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {AbortController, AbortSignal} from '../../../../public/node/abort.js'
44
import {unstyled} from '../../../../public/node/output.js'
55

66
import React from 'react'
7-
import {describe, expect, test} from 'vitest'
7+
import {describe, expect, test, vi} from 'vitest'
88

99
import {Writable} from 'stream'
1010

@@ -177,6 +177,58 @@ describe('ConcurrentOutput', () => {
177177
gate.resolve()
178178
})
179179

180+
test('filters existing output by prefix without restarting processes', async () => {
181+
const outputSync = new Synchronizer()
182+
const gate = new Synchronizer()
183+
const abortSignal = new AbortController().signal
184+
const observedPrefixes: string[] = []
185+
const backendAction = vi.fn(async (stdout: Writable) => {
186+
stdout.write('backend message')
187+
await gate.promise
188+
})
189+
const frontendAction = vi.fn(async (stdout: Writable) => {
190+
useConcurrentOutputContext({outputPrefix: 'custom-frontend'}, () => {
191+
stdout.write('frontend message')
192+
})
193+
outputSync.resolve()
194+
await gate.promise
195+
})
196+
const processes = [
197+
{prefix: 'backend', action: backendAction},
198+
{prefix: 'frontend', action: frontendAction},
199+
]
200+
201+
const renderInstance = render(
202+
<ConcurrentOutput
203+
processes={processes}
204+
abortSignal={abortSignal}
205+
outputFilter={() => true}
206+
onOutputPrefix={(prefix) => observedPrefixes.push(prefix)}
207+
/>,
208+
)
209+
await outputSync.promise
210+
await waitForContent(renderInstance, 'frontend message')
211+
212+
renderInstance.rerender(
213+
<ConcurrentOutput
214+
processes={processes}
215+
abortSignal={abortSignal}
216+
outputFilter={(prefix) => prefix === 'backend'}
217+
onOutputPrefix={(prefix) => observedPrefixes.push(prefix)}
218+
/>,
219+
)
220+
await waitForContent(renderInstance, 'backend message')
221+
222+
const output = unstyled(renderInstance.lastFrame()!)
223+
expect(output).toContain('backend message')
224+
expect(output).not.toContain('frontend message')
225+
expect(observedPrefixes).toEqual(['backend', 'custom-frontend'])
226+
expect(backendAction).toHaveBeenCalledOnce()
227+
expect(frontendAction).toHaveBeenCalledOnce()
228+
229+
gate.resolve()
230+
})
231+
180232
test('renders prefix column width based on prefixColumnSize', async () => {
181233
// Given
182234
const processSync1 = new Synchronizer()

‎packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.tsx‎

Lines changed: 38 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {OutputProcess} from '../../../../public/node/output.js'
22
import {AbortSignal} from '../../../../public/node/abort.js'
33
import {useComplete} from '../../ui.js'
4-
import React, {FunctionComponent, useCallback, useEffect, useMemo, useState} from 'react'
4+
import React, {FunctionComponent, useCallback, useEffect, useMemo, useRef, useState} from 'react'
55
import {Box, Static, Text, TextProps} from 'ink'
66
import figures from 'figures'
77
import stripAnsi from 'strip-ansi'
@@ -16,12 +16,17 @@ export interface ConcurrentOutputProps {
1616
showTimestamps?: boolean
1717
keepRunningAfterProcessesResolve?: boolean
1818
useAlternativeColorPalette?: boolean
19+
/** Filters both existing and future output by its displayed prefix. */
20+
outputFilter?: (prefix: string) => boolean
21+
/** Called when output is received, including output with a contextual prefix. */
22+
onOutputPrefix?: (prefix: string) => void
1923
}
2024

2125
interface Chunk {
2226
color: TextProps['color']
2327
prefix: string
2428
lines: string[]
29+
timestamp: string
2530
}
2631

2732
function addLeadingZero(number: number) {
@@ -91,9 +96,13 @@ const ConcurrentOutput: FunctionComponent<ConcurrentOutputProps> = ({
9196
showTimestamps = true,
9297
keepRunningAfterProcessesResolve = false,
9398
useAlternativeColorPalette = false,
99+
outputFilter,
100+
onOutputPrefix,
94101
}) => {
95102
const [processOutput, setProcessOutput] = useState<Chunk[]>([])
96103
const [completionResult, setCompletionResult] = useState<{error?: Error} | null>(null)
104+
const onOutputPrefixRef = useRef(onOutputPrefix)
105+
onOutputPrefixRef.current = onOutputPrefix
97106
const complete = useComplete()
98107
const concurrentColors: TextProps['color'][] = useMemo(
99108
() =>
@@ -144,12 +153,14 @@ const ConcurrentOutput: FunctionComponent<ConcurrentOutputProps> = ({
144153
const index = addPrefix(prefix, prefixes)
145154

146155
const lines = shouldStripAnsi ? stripAnsi(log).split(/\n/) : log.split(/\n/)
156+
onOutputPrefixRef.current?.(prefix)
147157
setProcessOutput((previousProcessOutput) => [
148158
...previousProcessOutput,
149159
{
150160
color: lineColor(index),
151161
prefix,
152162
lines,
163+
timestamp: currentTime(),
153164
},
154165
])
155166
next()
@@ -203,31 +214,32 @@ const ConcurrentOutput: FunctionComponent<ConcurrentOutputProps> = ({
203214

204215
const {lineVertical} = figures
205216

206-
return (
207-
<Static items={processOutput}>
208-
{(chunk, index) => {
209-
return (
210-
<Box flexDirection="column" key={index}>
211-
{chunk.lines.map((line, index) => (
212-
<Box key={index} flexDirection="row">
213-
<Text>
214-
{showTimestamps ? (
215-
<Text>
216-
{currentTime()} {lineVertical}{' '}
217-
</Text>
218-
) : null}
219-
<Text color={chunk.color}>{formatPrefix(chunk.prefix)}</Text>
220-
<Text>
221-
{' '}
222-
{lineVertical} {line}
223-
</Text>
224-
</Text>
225-
</Box>
226-
))}
227-
</Box>
228-
)
229-
}}
230-
</Static>
217+
const renderChunk = (chunk: Chunk, index: number) => (
218+
<Box flexDirection="column" key={index}>
219+
{chunk.lines.map((line, index) => (
220+
<Box key={index} flexDirection="row">
221+
<Text>
222+
{showTimestamps ? (
223+
<Text>
224+
{chunk.timestamp} {lineVertical}{' '}
225+
</Text>
226+
) : null}
227+
<Text color={chunk.color}>{formatPrefix(chunk.prefix)}</Text>
228+
<Text>
229+
{' '}
230+
{lineVertical} {line}
231+
</Text>
232+
</Text>
233+
</Box>
234+
))}
235+
</Box>
231236
)
237+
238+
if (outputFilter) {
239+
// Ink's Static output is immutable once written, so filterable output must remain in the live render tree.
240+
return <Box flexDirection="column">{processOutput.filter(({prefix}) => outputFilter(prefix)).map(renderChunk)}</Box>
241+
}
242+
243+
return <Static items={processOutput}>{renderChunk}</Static>
232244
}
233245
export {ConcurrentOutput, ConcurrentOutputContext, useConcurrentOutputContext}

0 commit comments

Comments
 (0)