Skip to content

Commit 97da1b4

Browse files
authored
Merge pull request #6798 from Shopify/jf-audit-proto
Add `doctor` command
2 parents 962d1f5 + 5597fcc commit 97da1b4

File tree

17 files changed

+1658
-3
lines changed

17 files changed

+1658
-3
lines changed

.eslintrc.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ module.exports = {
2323
'**/public/node/cli.ts',
2424
'**/public/node/dot-env.ts',
2525
'**/public/node/error-handler.ts',
26+
'**/public/node/doctor/**/*',
2627
'**/public/node/framework.ts',
2728
'**/public/node/fs.ts',
2829
'**/public/node/github.ts',

packages/cli-kit/src/private/node/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const environmentVariables = {
1616
alwaysLogAnalytics: 'SHOPIFY_CLI_ALWAYS_LOG_ANALYTICS',
1717
alwaysLogMetrics: 'SHOPIFY_CLI_ALWAYS_LOG_METRICS',
1818
deviceAuth: 'SHOPIFY_CLI_DEVICE_AUTH',
19+
doctor: 'SHOPIFY_CLI_DOCTOR',
1920
enableCliRedirect: 'SHOPIFY_CLI_ENABLE_CLI_REDIRECT',
2021
env: 'SHOPIFY_CLI_ENV',
2122
firstPartyDev: 'SHOPIFY_CLI_1P_DEV',

packages/cli-kit/src/public/node/context/local.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,16 @@ export function firstPartyDev(env = process.env): boolean {
112112
return isTruthy(env[environmentVariables.firstPartyDev])
113113
}
114114

115+
/**
116+
* Returns true if the CLI can run the "doctor-release" command.
117+
*
118+
* @param env - The environment variables from the environment of the current process.
119+
* @returns True if the CLI can run the "doctor-release" command.
120+
*/
121+
export function canRunDoctorRelease(env = process.env): boolean {
122+
return isTruthy(env[environmentVariables.doctor])
123+
}
124+
115125
/**
116126
* Return gitpodURL if we are running in gitpod.
117127
* Https://www.gitpod.io/docs/environment-variables#default-environment-variables.
Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
import {DoctorSuite} from './framework.js'
2+
import {describe, expect, test, vi, beforeEach} from 'vitest'
3+
import type {DoctorContext} from './types.js'
4+
5+
vi.mock('../fs.js')
6+
vi.mock('../system.js')
7+
8+
/**
9+
* Creates a minimal DoctorContext for testing
10+
*/
11+
function createTestContext(overrides?: Partial<DoctorContext>): DoctorContext {
12+
return {
13+
workingDirectory: '/test/dir',
14+
data: {},
15+
...overrides,
16+
}
17+
}
18+
19+
/**
20+
* Concrete test suite for testing DoctorSuite behavior
21+
*/
22+
class TestSuite extends DoctorSuite {
23+
static description = 'Test suite for framework testing'
24+
25+
private readonly testDefinitions: {name: string; fn: () => Promise<void>}[] = []
26+
27+
addTest(name: string, fn: () => Promise<void>): void {
28+
this.testDefinitions.push({name, fn})
29+
}
30+
31+
// Expose protected methods for testing
32+
public exposeAssert(condition: boolean, message: string): void {
33+
this.assert(condition, message)
34+
}
35+
36+
public exposeAssertEqual<T>(actual: T, expected: T, message: string): void {
37+
this.assertEqual(actual, expected, message)
38+
}
39+
40+
protected tests(): void {
41+
for (const def of this.testDefinitions) {
42+
this.test(def.name, def.fn)
43+
}
44+
}
45+
}
46+
47+
describe('DoctorSuite', () => {
48+
let suite: TestSuite
49+
let context: DoctorContext
50+
51+
beforeEach(() => {
52+
suite = new TestSuite()
53+
context = createTestContext()
54+
})
55+
56+
describe('test registration', () => {
57+
test('registers tests via test() method', async () => {
58+
// Given
59+
suite.addTest('first test', async () => {})
60+
suite.addTest('second test', async () => {})
61+
62+
// When
63+
const results = await suite.runSuite(context)
64+
65+
// Then
66+
expect(results).toHaveLength(2)
67+
expect(results[0]!.name).toBe('first test')
68+
expect(results[1]!.name).toBe('second test')
69+
})
70+
71+
test('runs tests in registration order', async () => {
72+
// Given
73+
const order: string[] = []
74+
suite.addTest('A', async () => {
75+
order.push('A')
76+
})
77+
suite.addTest('B', async () => {
78+
order.push('B')
79+
})
80+
suite.addTest('C', async () => {
81+
order.push('C')
82+
})
83+
84+
// When
85+
await suite.runSuite(context)
86+
87+
// Then
88+
expect(order).toEqual(['A', 'B', 'C'])
89+
})
90+
91+
test('returns empty results when no tests registered', async () => {
92+
// Given - no tests added
93+
94+
// When
95+
const results = await suite.runSuite(context)
96+
97+
// Then
98+
expect(results).toHaveLength(0)
99+
})
100+
})
101+
102+
describe('assertion tracking', () => {
103+
test('collects assertions from test', async () => {
104+
// Given
105+
suite.addTest('with assertions', async () => {
106+
suite.exposeAssert(true, 'first assertion')
107+
suite.exposeAssert(true, 'second assertion')
108+
})
109+
110+
// When
111+
const results = await suite.runSuite(context)
112+
113+
// Then
114+
expect(results[0]!.assertions).toHaveLength(2)
115+
expect(results[0]!.assertions[0]!.description).toBe('first assertion')
116+
expect(results[0]!.assertions[1]!.description).toBe('second assertion')
117+
})
118+
119+
test('resets assertions between tests', async () => {
120+
// Given
121+
suite.addTest('test1', async () => {
122+
suite.exposeAssert(true, 'assertion from test1')
123+
})
124+
suite.addTest('test2', async () => {
125+
suite.exposeAssert(true, 'assertion from test2')
126+
})
127+
128+
// When
129+
const results = await suite.runSuite(context)
130+
131+
// Then
132+
expect(results[0]!.assertions).toHaveLength(1)
133+
expect(results[0]!.assertions[0]!.description).toBe('assertion from test1')
134+
expect(results[1]!.assertions).toHaveLength(1)
135+
expect(results[1]!.assertions[0]!.description).toBe('assertion from test2')
136+
})
137+
138+
test('tracks assertion pass/fail status', async () => {
139+
// Given
140+
suite.addTest('mixed assertions', async () => {
141+
suite.exposeAssert(true, 'passing')
142+
suite.exposeAssert(false, 'failing')
143+
})
144+
145+
// When
146+
const results = await suite.runSuite(context)
147+
148+
// Then
149+
expect(results[0]!.assertions[0]!.passed).toBe(true)
150+
expect(results[0]!.assertions[1]!.passed).toBe(false)
151+
})
152+
})
153+
154+
describe('pass/fail determination', () => {
155+
test('marks test as passed when all assertions pass', async () => {
156+
// Given
157+
suite.addTest('all pass', async () => {
158+
suite.exposeAssert(true, 'pass1')
159+
suite.exposeAssert(true, 'pass2')
160+
})
161+
162+
// When
163+
const results = await suite.runSuite(context)
164+
165+
// Then
166+
expect(results[0]!.status).toBe('passed')
167+
})
168+
169+
test('marks test as failed when any assertion fails', async () => {
170+
// Given
171+
suite.addTest('one fails', async () => {
172+
suite.exposeAssert(true, 'pass')
173+
suite.exposeAssert(false, 'fail')
174+
suite.exposeAssert(true, 'pass again')
175+
})
176+
177+
// When
178+
const results = await suite.runSuite(context)
179+
180+
// Then
181+
expect(results[0]!.status).toBe('failed')
182+
})
183+
184+
test('marks test as passed with no assertions', async () => {
185+
// Given
186+
suite.addTest('no assertions', async () => {})
187+
188+
// When
189+
const results = await suite.runSuite(context)
190+
191+
// Then
192+
expect(results[0]!.status).toBe('passed')
193+
})
194+
})
195+
196+
describe('error handling', () => {
197+
test('marks test as failed when test throws Error', async () => {
198+
// Given
199+
suite.addTest('throws error', async () => {
200+
throw new Error('Test error')
201+
})
202+
203+
// When
204+
const results = await suite.runSuite(context)
205+
206+
// Then
207+
expect(results[0]!.status).toBe('failed')
208+
expect(results[0]!.error).toBeInstanceOf(Error)
209+
expect(results[0]!.error!.message).toBe('Test error')
210+
})
211+
212+
test('converts non-Error throws to Error', async () => {
213+
// Given
214+
suite.addTest('throws string', async () => {
215+
throw 'string error'
216+
})
217+
218+
// When
219+
const results = await suite.runSuite(context)
220+
221+
// Then
222+
expect(results[0]!.status).toBe('failed')
223+
expect(results[0]!.error).toBeInstanceOf(Error)
224+
expect(results[0]!.error!.message).toBe('string error')
225+
})
226+
227+
test('continues running other tests after error', async () => {
228+
// Given
229+
suite.addTest('throws', async () => {
230+
throw new Error('boom')
231+
})
232+
suite.addTest('succeeds', async () => {
233+
suite.exposeAssert(true, 'ok')
234+
})
235+
236+
// When
237+
const results = await suite.runSuite(context)
238+
239+
// Then
240+
expect(results).toHaveLength(2)
241+
expect(results[0]!.status).toBe('failed')
242+
expect(results[1]!.status).toBe('passed')
243+
})
244+
245+
test('preserves assertions collected before error', async () => {
246+
// Given
247+
suite.addTest('throws after assertion', async () => {
248+
suite.exposeAssert(true, 'before error')
249+
throw new Error('after assertion')
250+
})
251+
252+
// When
253+
const results = await suite.runSuite(context)
254+
255+
// Then
256+
expect(results[0]!.assertions).toHaveLength(1)
257+
expect(results[0]!.assertions[0]!.description).toBe('before error')
258+
})
259+
})
260+
261+
describe('duration tracking', () => {
262+
test('records duration for each test', async () => {
263+
// Given
264+
suite.addTest('quick test', async () => {})
265+
266+
// When
267+
const results = await suite.runSuite(context)
268+
269+
// Then
270+
expect(results[0]!.duration).toBeGreaterThanOrEqual(0)
271+
expect(typeof results[0]!.duration).toBe('number')
272+
})
273+
})
274+
275+
describe('assertEqual', () => {
276+
test('passes when values are equal', async () => {
277+
// Given
278+
suite.addTest('equal values', async () => {
279+
suite.exposeAssertEqual(42, 42, 'numbers match')
280+
})
281+
282+
// When
283+
const results = await suite.runSuite(context)
284+
285+
// Then
286+
expect(results[0]!.assertions[0]!.passed).toBe(true)
287+
expect(results[0]!.assertions[0]!.expected).toBe('42')
288+
expect(results[0]!.assertions[0]!.actual).toBe('42')
289+
})
290+
291+
test('fails when values are not equal', async () => {
292+
// Given
293+
suite.addTest('unequal values', async () => {
294+
suite.exposeAssertEqual('foo', 'bar', 'strings should match')
295+
})
296+
297+
// When
298+
const results = await suite.runSuite(context)
299+
300+
// Then
301+
expect(results[0]!.assertions[0]!.passed).toBe(false)
302+
expect(results[0]!.assertions[0]!.expected).toBe('bar')
303+
expect(results[0]!.assertions[0]!.actual).toBe('foo')
304+
})
305+
})
306+
307+
describe('suite reusability', () => {
308+
test('can run suite multiple times with fresh state', async () => {
309+
// Given
310+
let runCount = 0
311+
suite.addTest('increments', async () => {
312+
runCount++
313+
suite.exposeAssert(true, `run ${runCount}`)
314+
})
315+
316+
// When
317+
const results1 = await suite.runSuite(context)
318+
const results2 = await suite.runSuite(context)
319+
320+
// Then
321+
expect(results1[0]!.assertions[0]!.description).toBe('run 1')
322+
expect(results2[0]!.assertions[0]!.description).toBe('run 2')
323+
})
324+
})
325+
})

0 commit comments

Comments
 (0)