Skip to content

Commit 2f165b5

Browse files
authored
Merge pull request #5320 from Shopify/01-30-create_a_new_ui_component_for_devsessions
Create a new UI component for DevSessions
2 parents f6d9546 + 008b94c commit 2f165b5

File tree

3 files changed

+538
-1
lines changed

3 files changed

+538
-1
lines changed

packages/app/src/cli/services/dev/processes/dev-session-status-manager.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ import {EventEmitter} from 'events'
44
export interface DevSessionStatus {
55
isReady: boolean
66
previewURL?: string
7+
graphiqlURL?: string
78
}
89

9-
class DevSessionStatusManager extends EventEmitter {
10+
export class DevSessionStatusManager extends EventEmitter {
1011
private currentStatus: DevSessionStatus = {
1112
isReady: false,
1213
previewURL: undefined,
14+
graphiqlURL: undefined,
1315
}
1416

1517
updateStatus(status: Partial<DevSessionStatus>) {
@@ -29,6 +31,7 @@ class DevSessionStatusManager extends EventEmitter {
2931
this.currentStatus = {
3032
isReady: false,
3133
previewURL: undefined,
34+
graphiqlURL: undefined,
3235
}
3336
}
3437
}
Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
import {DevSessionUI} from './DevSessionUI.js'
2+
import {DevSessionStatus, DevSessionStatusManager} from '../../processes/dev-session-status-manager.js'
3+
import {
4+
getLastFrameAfterUnmount,
5+
render,
6+
sendInputAndWait,
7+
waitForContent,
8+
waitForInputsToBeReady,
9+
} from '@shopify/cli-kit/node/testing/ui'
10+
import {AbortController} from '@shopify/cli-kit/node/abort'
11+
import React from 'react'
12+
import {beforeEach, describe, expect, test, vi} from 'vitest'
13+
import {unstyled} from '@shopify/cli-kit/node/output'
14+
import {openURL} from '@shopify/cli-kit/node/system'
15+
import {Writable} from 'stream'
16+
17+
vi.mock('@shopify/cli-kit/node/system')
18+
vi.mock('@shopify/cli-kit/node/context/local')
19+
vi.mock('@shopify/cli-kit/node/tree-kill')
20+
21+
let devSessionStatusManager: DevSessionStatusManager
22+
23+
const initialStatus: DevSessionStatus = {
24+
isReady: true,
25+
previewURL: 'https://shopify.com',
26+
graphiqlURL: 'https://graphiql.shopify.com',
27+
}
28+
29+
const onAbort = vi.fn()
30+
31+
describe('DevSessionUI', () => {
32+
beforeEach(() => {
33+
devSessionStatusManager = new DevSessionStatusManager()
34+
devSessionStatusManager.reset()
35+
devSessionStatusManager.updateStatus(initialStatus)
36+
})
37+
38+
test('renders a stream of concurrent outputs from sub-processes, shortcuts and URLs', async () => {
39+
// Given
40+
let backendPromiseResolve: () => void
41+
let frontendPromiseResolve: () => void
42+
43+
const backendPromise = new Promise<void>(function (resolve, _reject) {
44+
backendPromiseResolve = resolve
45+
})
46+
47+
const frontendPromise = new Promise<void>(function (resolve, _reject) {
48+
frontendPromiseResolve = resolve
49+
})
50+
51+
const backendProcess = {
52+
prefix: 'backend',
53+
action: async (stdout: Writable, _stderr: Writable) => {
54+
stdout.write('first backend message')
55+
stdout.write('second backend message')
56+
stdout.write('third backend message')
57+
58+
backendPromiseResolve()
59+
},
60+
}
61+
62+
const frontendProcess = {
63+
prefix: 'frontend',
64+
action: async (stdout: Writable, _stderr: Writable) => {
65+
await backendPromise
66+
67+
stdout.write('first frontend message')
68+
stdout.write('second frontend message')
69+
stdout.write('third frontend message')
70+
71+
frontendPromiseResolve()
72+
},
73+
}
74+
75+
// When
76+
const renderInstance = render(
77+
<DevSessionUI
78+
processes={[backendProcess, frontendProcess]}
79+
abortController={new AbortController()}
80+
devSessionStatusManager={devSessionStatusManager}
81+
onAbort={onAbort}
82+
/>,
83+
)
84+
85+
await frontendPromise
86+
87+
// Then
88+
expect(unstyled(renderInstance.lastFrame()!).replace(/\d/g, '0')).toMatchInlineSnapshot(`
89+
"00:00:00 │ backend │ first backend message
90+
00:00:00 │ backend │ second backend message
91+
00:00:00 │ backend │ third backend message
92+
00:00:00 │ frontend │ first frontend message
93+
00:00:00 │ frontend │ second frontend message
94+
00:00:00 │ frontend │ third frontend message
95+
96+
────────────────────────────────────────────────────────────────────────────────────────────────────
97+
98+
› Press g │ open GraphiQL (Admin API) in your browser
99+
› Press p │ preview in your browser
100+
› Press q │ quit
101+
102+
Preview URL: https://shopify.com
103+
GraphiQL URL: https://graphiql.shopify.com
104+
"
105+
`)
106+
107+
renderInstance.unmount()
108+
})
109+
110+
test('opens the previewURL when p is pressed', async () => {
111+
// When
112+
const renderInstance = render(
113+
<DevSessionUI
114+
processes={[]}
115+
abortController={new AbortController()}
116+
devSessionStatusManager={devSessionStatusManager}
117+
onAbort={onAbort}
118+
/>,
119+
)
120+
121+
await waitForInputsToBeReady()
122+
await sendInputAndWait(renderInstance, 100, 'p')
123+
124+
// Then
125+
expect(vi.mocked(openURL)).toHaveBeenNthCalledWith(1, 'https://shopify.com')
126+
127+
renderInstance.unmount()
128+
})
129+
130+
test('opens the graphiqlURL when g is pressed', async () => {
131+
// When
132+
const renderInstance = render(
133+
<DevSessionUI
134+
processes={[]}
135+
abortController={new AbortController()}
136+
devSessionStatusManager={devSessionStatusManager}
137+
onAbort={onAbort}
138+
/>,
139+
)
140+
141+
await waitForInputsToBeReady()
142+
await sendInputAndWait(renderInstance, 100, 'g')
143+
144+
// Then
145+
expect(vi.mocked(openURL)).toHaveBeenNthCalledWith(1, 'https://graphiql.shopify.com')
146+
147+
renderInstance.unmount()
148+
})
149+
150+
test('quits when q is pressed', async () => {
151+
// Given
152+
const abortController = new AbortController()
153+
const abort = vi.spyOn(abortController, 'abort')
154+
155+
// When
156+
const renderInstance = render(
157+
<DevSessionUI
158+
processes={[]}
159+
abortController={abortController}
160+
devSessionStatusManager={devSessionStatusManager}
161+
onAbort={onAbort}
162+
/>,
163+
)
164+
165+
const promise = renderInstance.waitUntilExit()
166+
167+
await waitForInputsToBeReady()
168+
renderInstance.stdin.write('q')
169+
170+
await promise
171+
172+
// Then
173+
expect(abort).toHaveBeenCalledOnce()
174+
175+
renderInstance.unmount()
176+
})
177+
178+
test('shows shutting down message when aborted', async () => {
179+
// Given
180+
const abortController = new AbortController()
181+
182+
// When
183+
const renderInstance = render(
184+
<DevSessionUI
185+
processes={[]}
186+
abortController={abortController}
187+
devSessionStatusManager={devSessionStatusManager}
188+
onAbort={onAbort}
189+
/>,
190+
)
191+
192+
const promise = renderInstance.waitUntilExit()
193+
194+
abortController.abort()
195+
196+
expect(unstyled(renderInstance.lastFrame()!).replace(/\d/g, '0')).toContain('Shutting down dev ...')
197+
198+
await promise
199+
200+
expect(unstyled(getLastFrameAfterUnmount(renderInstance)!).replace(/\d/g, '0')).toMatchInlineSnapshot(`
201+
""
202+
`)
203+
204+
// unmount so that polling is cleared after every test
205+
renderInstance.unmount()
206+
})
207+
208+
test('shows error shutting down message when aborted with error', async () => {
209+
// Given
210+
const abortController = new AbortController()
211+
212+
const backendProcess: any = {
213+
prefix: 'backend',
214+
action: async (stdout: Writable, _stderr: Writable, _signal: AbortSignal) => {
215+
stdout.write('first backend message')
216+
stdout.write('second backend message')
217+
stdout.write('third backend message')
218+
219+
// await promise that never resolves
220+
await new Promise(() => {})
221+
},
222+
}
223+
224+
// When
225+
const renderInstance = render(
226+
<DevSessionUI
227+
processes={[backendProcess]}
228+
abortController={abortController}
229+
devSessionStatusManager={devSessionStatusManager}
230+
onAbort={onAbort}
231+
/>,
232+
)
233+
234+
const promise = renderInstance.waitUntilExit()
235+
236+
abortController.abort('something went wrong')
237+
238+
expect(unstyled(renderInstance.lastFrame()!).replace(/\d/g, '0')).toMatchInlineSnapshot(`
239+
"00:00:00 │ backend │ first backend message
240+
00:00:00 │ backend │ second backend message
241+
00:00:00 │ backend │ third backend message
242+
243+
────────────────────────────────────────────────────────────────────────────────────────────────────
244+
245+
› Press g │ open GraphiQL (Admin API) in your browser
246+
› Press p │ preview in your browser
247+
› Press q │ quit
248+
249+
Shutting down dev because of an error ...
250+
"
251+
`)
252+
253+
await promise
254+
255+
expect(unstyled(getLastFrameAfterUnmount(renderInstance)!).replace(/\d/g, '0')).toMatchInlineSnapshot(`
256+
"00:00:00 │ backend │ first backend message
257+
00:00:00 │ backend │ second backend message
258+
00:00:00 │ backend │ third backend message
259+
"
260+
`)
261+
262+
// unmount so that polling is cleared after every test
263+
renderInstance.unmount()
264+
})
265+
266+
test('updates UI when status changes through devSessionStatusManager', async () => {
267+
// Given
268+
devSessionStatusManager.reset()
269+
270+
// When
271+
const renderInstance = render(
272+
<DevSessionUI
273+
processes={[]}
274+
abortController={new AbortController()}
275+
devSessionStatusManager={devSessionStatusManager}
276+
onAbort={onAbort}
277+
/>,
278+
)
279+
280+
await waitForInputsToBeReady()
281+
282+
// Initial state
283+
expect(unstyled(renderInstance.lastFrame()!)).not.toContain('preview in your browser')
284+
285+
// When status updates
286+
devSessionStatusManager.updateStatus({
287+
isReady: true,
288+
previewURL: 'https://new-preview-url.shopify.com',
289+
graphiqlURL: 'https://new-graphiql.shopify.com',
290+
})
291+
292+
await waitForContent(renderInstance, 'preview in your browser')
293+
294+
// Then
295+
expect(unstyled(renderInstance.lastFrame()!)).toContain('Preview URL: https://new-preview-url.shopify.com')
296+
expect(unstyled(renderInstance.lastFrame()!)).toContain('GraphiQL URL: https://new-graphiql.shopify.com')
297+
renderInstance.unmount()
298+
})
299+
300+
test('updates UI when devSessionEnabled changes from false to true', async () => {
301+
// Given
302+
devSessionStatusManager.updateStatus({isReady: false})
303+
304+
const renderInstance = render(
305+
<DevSessionUI
306+
processes={[]}
307+
abortController={new AbortController()}
308+
devSessionStatusManager={devSessionStatusManager}
309+
onAbort={onAbort}
310+
/>,
311+
)
312+
313+
await waitForInputsToBeReady()
314+
315+
// Then
316+
expect(unstyled(renderInstance.lastFrame()!)).not.toContain('Press p')
317+
expect(unstyled(renderInstance.lastFrame()!)).not.toContain('Press g')
318+
expect(unstyled(renderInstance.lastFrame()!)).not.toContain('Preview URL')
319+
expect(unstyled(renderInstance.lastFrame()!)).not.toContain('GraphiQL URL')
320+
321+
// When
322+
devSessionStatusManager.updateStatus({isReady: true})
323+
324+
await waitForInputsToBeReady()
325+
326+
// Then
327+
expect(unstyled(renderInstance.lastFrame()!)).toContain('Press p')
328+
expect(unstyled(renderInstance.lastFrame()!)).toContain('Press g')
329+
expect(unstyled(renderInstance.lastFrame()!)).toContain('Preview URL: https://shopify.com')
330+
expect(unstyled(renderInstance.lastFrame()!)).toContain('GraphiQL URL: https://graphiql.shopify.com')
331+
renderInstance.unmount()
332+
})
333+
334+
test('handles process errors by aborting', async () => {
335+
// Given
336+
const abortController = new AbortController()
337+
const abort = vi.spyOn(abortController, 'abort')
338+
const errorProcess = {
339+
prefix: 'error',
340+
action: async () => {
341+
throw new Error('Test error')
342+
},
343+
}
344+
345+
// When
346+
const renderInstance = render(
347+
<DevSessionUI
348+
processes={[errorProcess]}
349+
abortController={abortController}
350+
devSessionStatusManager={devSessionStatusManager}
351+
onAbort={onAbort}
352+
/>,
353+
)
354+
355+
await expect(renderInstance.waitUntilExit()).rejects.toThrow('Test error')
356+
357+
// Then
358+
expect(abort).toHaveBeenCalledWith(new Error('Test error'))
359+
360+
renderInstance.unmount()
361+
})
362+
})

0 commit comments

Comments
 (0)