Skip to content

Commit 817bf4b

Browse files
authored
Merge pull request #5992 from Shopify/henry-eh-develop-logs
[Feature] Display UI Extension Logs from Extensibility Host
2 parents 867e7e8 + 6127a14 commit 817bf4b

File tree

4 files changed

+219
-3
lines changed

4 files changed

+219
-3
lines changed

.changeset/chatty-rats-pull.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@shopify/ui-extensions-server-kit': minor
3+
'@shopify/app': minor
4+
---
5+
6+
Add support for displaying UI Extension dev logs

packages/app/src/cli/services/dev/extension/websocket/handlers.test.ts

Lines changed: 143 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,24 @@ import {
22
getConnectionDoneHandler,
33
getOnMessageHandler,
44
getPayloadUpdateHandler,
5+
handleLogEvent,
6+
parseLogMessage,
57
websocketUpgradeHandler,
68
} from './handlers.js'
79
import {SetupWebSocketConnectionOptions} from './models.js'
810
import {ExtensionsEndpointPayload} from '../payload/models.js'
9-
import {vi, describe, test, expect} from 'vitest'
11+
import {vi, describe, test, expect, Mock} from 'vitest'
12+
import {useConcurrentOutputContext} from '@shopify/cli-kit/node/ui/components'
1013
import WebSocket, {RawData, WebSocketServer} from 'ws'
1114
import {IncomingMessage} from 'h3'
15+
import colors from '@shopify/cli-kit/node/colors'
16+
import {outputContent, outputToken} from '@shopify/cli-kit/node/output'
1217
import {Duplex} from 'stream'
1318

19+
vi.mock('@shopify/cli-kit/node/ui/components', () => ({
20+
useConcurrentOutputContext: vi.fn(),
21+
}))
22+
1423
function getMockRequest() {
1524
const request = {
1625
url: '/extensions',
@@ -57,6 +66,9 @@ function getMockSetupWebSocketConnectionOptions() {
5766
updateExtensions: vi.fn(),
5867
},
5968
manifestVersion: '3',
69+
stdout: {
70+
write: vi.fn(),
71+
},
6072
} as unknown as SetupWebSocketConnectionOptions
6173
}
6274

@@ -183,4 +195,134 @@ describe('getOnMessageHandler()', () => {
183195
}) as unknown as RawData
184196
wss.clients.forEach((ws) => expect(ws.send).toHaveBeenCalledWith(outgoingMessage))
185197
})
198+
199+
test('on an incoming log event calls handleLogMessage and does not notify clients', () => {
200+
const wss = getMockWebsocketServer()
201+
const options = getMockSetupWebSocketConnectionOptions()
202+
const data = JSON.stringify({
203+
event: 'log',
204+
data: {
205+
type: 'info',
206+
message: 'Test log message',
207+
extensionName: 'test-extension',
208+
},
209+
}) as unknown as RawData
210+
211+
getOnMessageHandler(wss, options)(data)
212+
213+
// Verify useConcurrentOutputContext (any therefore handleLogMessage) was called with correct parameters
214+
expect(useConcurrentOutputContext).toHaveBeenCalledWith(
215+
{outputPrefix: 'test-extension', stripAnsi: false},
216+
expect.any(Function),
217+
)
218+
219+
// Verify no client messages were sent since this was a log event
220+
wss.clients.forEach((ws) => expect(ws.send).not.toHaveBeenCalled())
221+
})
222+
})
223+
224+
describe('parseLogMessage()', () => {
225+
test('parses and formats JSON array of strings', () => {
226+
const message = JSON.stringify(['Hello', 'world', 'test'], null, 2)
227+
const result = parseLogMessage(message)
228+
expect(result).toBe('Hello world test')
229+
})
230+
231+
test('parses and formats JSON array with mixed types', () => {
232+
const message = JSON.stringify(['String', 42, true, null], null, 2)
233+
const result = parseLogMessage(message)
234+
expect(result).toBe('String 42 true null')
235+
})
236+
237+
test('parses and formats JSON array with objects', () => {
238+
const object = {user: 'john', age: 30}
239+
const message = JSON.stringify(['Message:', object], null, 2)
240+
const result = parseLogMessage(message)
241+
expect(result).toBe(outputContent`Message: ${outputToken.json(object)}`.value)
242+
})
243+
244+
test('returns original message when JSON parsing fails', () => {
245+
const invalidJson = 'This is not JSON'
246+
const result = parseLogMessage(invalidJson)
247+
expect(result).toBe('This is not JSON')
248+
})
249+
250+
test('returns original message for JSON that is not an array', () => {
251+
const malformedJson = '{"invalid": json}'
252+
const result = parseLogMessage(malformedJson)
253+
expect(result).toBe('{"invalid": json}')
254+
})
255+
})
256+
257+
describe('handleLogEvent()', () => {
258+
// Helper function to abstract the common expect pattern
259+
function expectLogMessageOutput(
260+
extensionName: string,
261+
expectedOutput: string,
262+
options: SetupWebSocketConnectionOptions,
263+
) {
264+
expect(useConcurrentOutputContext).toHaveBeenCalledWith(
265+
{outputPrefix: extensionName, stripAnsi: false},
266+
expect.any(Function),
267+
)
268+
const contextCallback = (useConcurrentOutputContext as Mock).mock.calls[0]?.[1]
269+
contextCallback()
270+
271+
expect(options.stdout.write).toHaveBeenCalledWith(expectedOutput)
272+
}
273+
274+
test('outputs info level log message with correct formatting', () => {
275+
const options = getMockSetupWebSocketConnectionOptions()
276+
const eventData = {
277+
type: 'info',
278+
message: 'Test info message',
279+
extensionName: 'test-extension',
280+
}
281+
handleLogEvent(eventData, options)
282+
283+
expectLogMessageOutput('test-extension', `Test info message`, options)
284+
})
285+
286+
test('outputs log message with parsed JSON array', () => {
287+
const options = getMockSetupWebSocketConnectionOptions()
288+
const message = JSON.stringify(['Hello', 'world', {user: 'test'}], null, 2)
289+
const eventData = {
290+
type: 'info',
291+
message,
292+
extensionName: 'test-extension',
293+
}
294+
295+
handleLogEvent(eventData, options)
296+
297+
expectLogMessageOutput(
298+
'test-extension',
299+
outputContent`Hello world ${outputToken.json({user: 'test'})}`.value,
300+
options,
301+
)
302+
})
303+
304+
test('outputs error level log message with error formatting', () => {
305+
const options = getMockSetupWebSocketConnectionOptions()
306+
const eventData = {
307+
type: 'error',
308+
message: 'Test error message',
309+
extensionName: 'error-extension',
310+
}
311+
312+
handleLogEvent(eventData, options)
313+
314+
expectLogMessageOutput('error-extension', `${colors.bold.redBright('ERROR')}: Test error message`, options)
315+
})
316+
317+
test('handles unknown log type without erroring', () => {
318+
const options = getMockSetupWebSocketConnectionOptions()
319+
const eventData = {
320+
type: 'unknown-type',
321+
message: 'Message with unknown log type',
322+
extensionName: 'test-extension',
323+
}
324+
handleLogEvent(eventData, options)
325+
326+
expectLogMessageOutput('test-extension', 'UNKNOWN-TYPE: Message with unknown log type', options)
327+
})
186328
})

packages/app/src/cli/services/dev/extension/websocket/handlers.ts

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import {
22
EventType,
33
IncomingDispatchMessage,
4+
LogPayload,
45
OutgoingDispatchMessage,
56
OutgoingMessage,
67
SetupWebSocketConnectionOptions,
78
} from './models.js'
89
import {RawData, WebSocket, WebSocketServer} from 'ws'
910
import {outputDebug, outputContent, outputToken} from '@shopify/cli-kit/node/output'
11+
import {useConcurrentOutputContext} from '@shopify/cli-kit/node/ui/components'
1012
import {IncomingMessage} from 'http'
1113
import {Duplex} from 'stream'
1214

@@ -37,6 +39,63 @@ export function getConnectionDoneHandler(wss: WebSocketServer, options: SetupWeb
3739
}
3840
}
3941

42+
export function parseLogMessage(message: string): string {
43+
try {
44+
const parsed = JSON.parse(message)
45+
46+
// it is expected that the message is an array of console arguments
47+
if (!Array.isArray(parsed)) {
48+
return message
49+
}
50+
51+
const formatted = parsed
52+
.map((arg) => {
53+
if (typeof arg === 'object' && arg !== null) {
54+
return outputToken.json(arg).output()
55+
} else {
56+
return String(arg)
57+
}
58+
})
59+
.join(' ')
60+
61+
return outputContent`${formatted}`.value
62+
} catch (error) {
63+
// If parsing fails, return the original message
64+
if (error instanceof SyntaxError) {
65+
return message
66+
}
67+
throw error
68+
}
69+
}
70+
71+
const consoleTypeColors = {
72+
debug: (text: string) => outputToken.gray(text),
73+
warn: (text: string) => outputToken.yellow(text),
74+
error: (text: string) => outputToken.errorText(text),
75+
} as const
76+
77+
function getOutput({type, message}: LogPayload) {
78+
const formattedMessage = parseLogMessage(message)
79+
80+
switch (type) {
81+
case 'debug':
82+
case 'warn':
83+
case 'error':
84+
return outputContent`${consoleTypeColors[type](type.toUpperCase())}: ${formattedMessage}`.value
85+
case 'log':
86+
case 'info':
87+
return formattedMessage
88+
default:
89+
return `${type.toUpperCase()}: ${formattedMessage}`
90+
}
91+
}
92+
93+
export function handleLogEvent(eventData: LogPayload, options: SetupWebSocketConnectionOptions) {
94+
useConcurrentOutputContext({outputPrefix: eventData.extensionName, stripAnsi: false}, () => {
95+
options.stdout.write(getOutput(eventData))
96+
})
97+
}
98+
4099
export function getOnMessageHandler(wss: WebSocketServer, options: SetupWebSocketConnectionOptions) {
41100
return (data: RawData) => {
42101
// eslint-disable-next-line @typescript-eslint/no-base-to-string
@@ -50,7 +109,7 @@ ${outputToken.json(eventData)}
50109
options.stdout,
51110
)
52111

53-
if (eventType === 'update') {
112+
if (eventType === EventType.Update) {
54113
const payloadStoreApiKey = options.payloadStore.getRawPayload().app.apiKey
55114
const eventAppApiKey = eventData.app?.apiKey
56115

@@ -68,10 +127,12 @@ ${outputToken.json(eventData)}
68127
if (eventData.extensions) {
69128
options.payloadStore.updateExtensions(eventData.extensions)
70129
}
71-
} else if (eventType === 'dispatch') {
130+
} else if (eventType === EventType.Dispatch) {
72131
const outGoingMessage = getOutgoingDispatchMessage(jsonData, options)
73132

74133
notifyClients(wss, outGoingMessage, options)
134+
} else if (eventType === EventType.Log) {
135+
handleLogEvent(eventData, options)
75136
}
76137
}
77138
}

packages/app/src/cli/services/dev/extension/websocket/models.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {Server} from 'http'
44
export enum EventType {
55
Update = 'update',
66
Dispatch = 'dispatch',
7+
Log = 'log',
78
}
89

910
type DataType = 'focus' | 'unfocus'
@@ -42,3 +43,9 @@ export interface OutgoingMessage {
4243
version: string
4344
data: {[key: string]: unknown}
4445
}
46+
47+
export interface LogPayload {
48+
type: string
49+
message: string
50+
extensionName: string
51+
}

0 commit comments

Comments
 (0)