Skip to content

Commit 7bf723e

Browse files
feat: EpicVideo component automation (#574)
* add admin set-videos command for product video sync Co-authored-by: me <me@kentcdodds.com> * clean up set-videos test global unstub handling Co-authored-by: me <me@kentcdodds.com> * refactor admin tests to using disposables Co-authored-by: me <me@kentcdodds.com> * rename using bindings to satisfy lint Co-authored-by: me <me@kentcdodds.com> * add dry-run mode for admin set-videos Co-authored-by: me <me@kentcdodds.com> * print set-videos failures when not silent Co-authored-by: me <me@kentcdodds.com> * enable automatic vitest mock and global cleanup Co-authored-by: me <me@kentcdodds.com> * expand set-videos mismatch diagnostics Co-authored-by: me <me@kentcdodds.com> * map set-videos by lesson slots not file count Co-authored-by: me <me@kentcdodds.com> * extract shared admin workshop utilities Co-authored-by: me <me@kentcdodds.com> * chore: format admin command files Co-authored-by: me <me@kentcdodds.com> * Extract formatProductLessonUrl utility Co-authored-by: me <me@kentcdodds.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent d46de8f commit 7bf723e

9 files changed

Lines changed: 1439 additions & 305 deletions

File tree

docs/launch.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ To validate launch readiness locally, run:
2121

2222
`npx epicshop admin launch-readiness`
2323

24+
To auto-set workshop videos from the current product lesson order, run:
25+
26+
`npx epicshop admin set-videos`
27+
28+
This command only inserts/updates the `EpicVideo` directly below the file title,
29+
and leaves any additional `EpicVideo` components in the file unchanged. Use
30+
`--dry-run` to preview what would change without writing files.
31+
2432
You must also add the `product.slug` and `product.host` to the `epicshop`
2533
section in `package.json`:
2634

packages/workshop-cli/src/cli.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -979,7 +979,7 @@ const cli = yargs(args)
979979
.positional('subcommand', {
980980
describe: 'Admin subcommand',
981981
type: 'string',
982-
choices: ['launch-readiness'],
982+
choices: ['launch-readiness', 'set-videos'],
983983
})
984984
.option('workshop-dir', {
985985
alias: 'w',
@@ -1005,10 +1005,24 @@ const cli = yargs(args)
10051005
'Skip checking that EpicVideo urls return 200 to HEAD (network required)',
10061006
default: false,
10071007
})
1008+
.option('dry-run', {
1009+
type: 'boolean',
1010+
description:
1011+
'Preview set-videos changes without writing files (set-videos only)',
1012+
default: false,
1013+
})
10081014
.example(
10091015
'$0 admin launch-readiness',
10101016
'Check workshop launch readiness (hidden command)',
10111017
)
1018+
.example(
1019+
'$0 admin set-videos',
1020+
'Set top EpicVideo embeds from product lesson order (hidden command)',
1021+
)
1022+
.example(
1023+
'$0 admin set-videos --dry-run',
1024+
'Preview top EpicVideo changes without writing files',
1025+
)
10121026
},
10131027
async (
10141028
argv: ArgumentsCamelCase<{
@@ -1017,6 +1031,7 @@ const cli = yargs(args)
10171031
silent?: boolean
10181032
skipRemote?: boolean
10191033
skipHead?: boolean
1034+
dryRun?: boolean
10201035
}>,
10211036
) => {
10221037
const { findWorkshopRoot } = await import('./commands/workshops.js')
@@ -1049,6 +1064,15 @@ const cli = yargs(args)
10491064
if (!result.success) process.exit(1)
10501065
break
10511066
}
1067+
case 'set-videos': {
1068+
const { setVideos } = await import('./commands/admin.js')
1069+
const result = await setVideos({
1070+
silent: argv.silent,
1071+
dryRun: argv.dryRun,
1072+
})
1073+
if (!result.success) process.exit(1)
1074+
break
1075+
}
10521076
default: {
10531077
console.error(
10541078
chalk.red(`❌ Unknown admin subcommand: ${argv.subcommand}`),

packages/workshop-cli/src/commands/admin.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,8 @@ export {
33
type LaunchReadinessOptions,
44
type LaunchReadinessResult,
55
} from './admin/launch-readiness.js'
6+
export {
7+
setVideos,
8+
type SetVideosOptions,
9+
type SetVideosResult,
10+
} from './admin/set-videos.js'

packages/workshop-cli/src/commands/admin/launch-readiness.test.ts

Lines changed: 97 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import fs from 'node:fs/promises'
22
import os from 'node:os'
33
import path from 'node:path'
44

5-
import { afterEach, expect, test, vi } from 'vitest'
5+
import { expect, test, vi } from 'vitest'
66

77
vi.mock('@epic-web/workshop-utils/compile-mdx.server', async () => {
88
const fs = await import('node:fs/promises')
@@ -84,56 +84,49 @@ async function createWorkshopFixture({
8484
`# Step Solution\n\n<EpicVideo url="https://${productHost}/workshops/${productSlug}/step-solution" />\n`,
8585
)
8686

87-
return root
87+
return {
88+
root,
89+
async [Symbol.asyncDispose]() {
90+
await fs.rm(root, { recursive: true, force: true })
91+
},
92+
}
8893
}
8994

90-
afterEach(async () => {
91-
vi.unstubAllGlobals()
92-
})
93-
9495
test('passes with configured product + videos (skip remote)', async () => {
95-
const workshopRoot = await createWorkshopFixture()
96-
97-
try {
98-
await expect(
99-
launchReadiness({
100-
workshopRoot,
101-
silent: true,
102-
skipRemote: true,
103-
skipHead: true,
104-
}),
105-
).resolves.toEqual(expect.objectContaining({ success: true }))
106-
} finally {
107-
await fs.rm(workshopRoot, { recursive: true, force: true })
108-
}
96+
await using workshop = await createWorkshopFixture()
97+
98+
await expect(
99+
launchReadiness({
100+
workshopRoot: workshop.root,
101+
silent: true,
102+
skipRemote: true,
103+
skipHead: true,
104+
}),
105+
).resolves.toEqual(expect.objectContaining({ success: true }))
109106
})
110107

111108
test('fails when epicshop.product.slug missing', async () => {
112-
const workshopRoot = await createWorkshopFixture({
109+
await using workshop = await createWorkshopFixture({
113110
includeProductSlug: false,
114111
})
115112

116-
try {
117-
await expect(
118-
launchReadiness({
119-
workshopRoot,
120-
silent: true,
121-
skipRemote: true,
122-
skipHead: true,
123-
}),
124-
).resolves.toEqual(expect.objectContaining({ success: false }))
125-
} finally {
126-
await fs.rm(workshopRoot, { recursive: true, force: true })
127-
}
113+
await expect(
114+
launchReadiness({
115+
workshopRoot: workshop.root,
116+
silent: true,
117+
skipRemote: true,
118+
skipHead: true,
119+
}),
120+
).resolves.toEqual(expect.objectContaining({ success: false }))
128121
})
129122

130123
test('fails when a required MDX file has no EpicVideo embed (and prints helpful path)', async () => {
131-
const workshopRoot = await createWorkshopFixture()
124+
await using workshop = await createWorkshopFixture()
132125

133126
// Remove the EpicVideo embed from the step problem README.
134127
await writeFile(
135128
path.join(
136-
workshopRoot,
129+
workshop.root,
137130
'exercises',
138131
'01.first-exercise',
139132
'01.problem',
@@ -144,30 +137,26 @@ test('fails when a required MDX file has no EpicVideo embed (and prints helpful
144137

145138
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
146139

147-
try {
148-
const result = await launchReadiness({
149-
workshopRoot,
150-
silent: false,
151-
skipRemote: true,
152-
skipHead: true,
153-
})
154-
155-
expect(result.success).toBe(false)
156-
const output = logSpy.mock.calls.map((c) => c.join(' ')).join('\n')
157-
expect(output).toContain('No <EpicVideo url="..."> embed found')
158-
expect(output).toContain(
159-
'exercises/01.first-exercise/01.problem/README.mdx',
160-
)
161-
} finally {
162-
logSpy.mockRestore()
163-
await fs.rm(workshopRoot, { recursive: true, force: true })
164-
}
140+
const result = await launchReadiness({
141+
workshopRoot: workshop.root,
142+
silent: false,
143+
skipRemote: true,
144+
skipHead: true,
145+
})
146+
147+
expect(result.success).toBe(false)
148+
const output = logSpy.mock.calls.map((c) => c.join(' ')).join('\n')
149+
expect(output).toContain('No <EpicVideo url="..."> embed found')
150+
expect(output).toContain('exercises/01.first-exercise/01.problem/README.mdx')
165151
})
166152

167153
test('remote lesson check fails when product lesson slug not represented locally', async () => {
168154
const productHost = 'www.epicweb.dev'
169155
const productSlug = 'test-workshop'
170-
const workshopRoot = await createWorkshopFixture({ productHost, productSlug })
156+
await using workshop = await createWorkshopFixture({
157+
productHost,
158+
productSlug,
159+
})
171160

172161
vi.stubGlobal(
173162
'fetch',
@@ -191,35 +180,33 @@ test('remote lesson check fails when product lesson slug not represented locally
191180

192181
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
193182

194-
try {
195-
const result = await launchReadiness({
196-
workshopRoot,
197-
silent: false,
198-
skipRemote: false,
199-
skipHead: true,
200-
})
201-
202-
expect(result.success).toBe(false)
203-
const output = logSpy.mock.calls.map((c) => c.join(' ')).join('\n')
204-
expect(output).toContain('Missing videos in workshop for product lessons:')
205-
expect(output).toContain('missing-lesson')
206-
expect(output).toContain(
207-
`https://${productHost}/workshops/${productSlug}/functions-section/missing-lesson`,
208-
)
209-
} finally {
210-
logSpy.mockRestore()
211-
await fs.rm(workshopRoot, { recursive: true, force: true })
212-
}
183+
const result = await launchReadiness({
184+
workshopRoot: workshop.root,
185+
silent: false,
186+
skipRemote: false,
187+
skipHead: true,
188+
})
189+
190+
expect(result.success).toBe(false)
191+
const output = logSpy.mock.calls.map((c) => c.join(' ')).join('\n')
192+
expect(output).toContain('Missing videos in workshop for product lessons:')
193+
expect(output).toContain('missing-lesson')
194+
expect(output).toContain(
195+
`https://${productHost}/workshops/${productSlug}/functions-section/missing-lesson`,
196+
)
213197
})
214198

215199
test('warns about extra embeds only for configured workshop (includes offending url + file)', async () => {
216200
const productHost = 'www.epicweb.dev'
217201
const productSlug = 'test-workshop'
218-
const workshopRoot = await createWorkshopFixture({ productHost, productSlug })
202+
await using workshop = await createWorkshopFixture({
203+
productHost,
204+
productSlug,
205+
})
219206

220207
// Add an extra embed for this workshop (should warn) and one outside /workshops (should not).
221208
const exerciseIntroPath = path.join(
222-
workshopRoot,
209+
workshop.root,
223210
'exercises',
224211
'01.first-exercise',
225212
'README.mdx',
@@ -255,56 +242,46 @@ test('warns about extra embeds only for configured workshop (includes offending
255242

256243
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
257244

258-
try {
259-
const result = await launchReadiness({
260-
workshopRoot,
261-
silent: false,
262-
skipRemote: false,
263-
skipHead: true,
264-
})
265-
266-
expect(result.success).toBe(true)
267-
const output = logSpy.mock.calls.map((c) => c.join(' ')).join('\n')
268-
expect(output).toContain(
269-
`EpicVideo embed not present in the product lesson list: https://${productHost}/workshops/${productSlug}/extra-lesson`,
270-
)
271-
expect(output).toContain('exercises/01.first-exercise/README.mdx')
272-
expect(output).not.toContain('some-post')
273-
} finally {
274-
logSpy.mockRestore()
275-
await fs.rm(workshopRoot, { recursive: true, force: true })
276-
}
245+
const result = await launchReadiness({
246+
workshopRoot: workshop.root,
247+
silent: false,
248+
skipRemote: false,
249+
skipHead: true,
250+
})
251+
252+
expect(result.success).toBe(true)
253+
const output = logSpy.mock.calls.map((c) => c.join(' ')).join('\n')
254+
expect(output).toContain(
255+
`EpicVideo embed not present in the product lesson list: https://${productHost}/workshops/${productSlug}/extra-lesson`,
256+
)
257+
expect(output).toContain('exercises/01.first-exercise/README.mdx')
258+
expect(output).not.toContain('some-post')
277259
})
278260

279261
test('fails when a required FINISHED.mdx is too short', async () => {
280-
const workshopRoot = await createWorkshopFixture()
262+
await using workshop = await createWorkshopFixture()
281263

282264
await writeFile(
283-
path.join(workshopRoot, 'exercises', '01.first-exercise', 'FINISHED.mdx'),
265+
path.join(workshop.root, 'exercises', '01.first-exercise', 'FINISHED.mdx'),
284266
`Short.\n`,
285267
)
286268

287269
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
288270

289-
try {
290-
const result = await launchReadiness({
291-
workshopRoot,
292-
silent: false,
293-
skipRemote: true,
294-
skipHead: true,
295-
})
296-
expect(result.success).toBe(false)
297-
const output = logSpy.mock.calls.map((c) => c.join(' ')).join('\n')
298-
expect(output).toContain('File content too short')
299-
expect(output).toContain('exercises/01.first-exercise/FINISHED.mdx')
300-
} finally {
301-
logSpy.mockRestore()
302-
await fs.rm(workshopRoot, { recursive: true, force: true })
303-
}
271+
const result = await launchReadiness({
272+
workshopRoot: workshop.root,
273+
silent: false,
274+
skipRemote: true,
275+
skipHead: true,
276+
})
277+
expect(result.success).toBe(false)
278+
const output = logSpy.mock.calls.map((c) => c.join(' ')).join('\n')
279+
expect(output).toContain('File content too short')
280+
expect(output).toContain('exercises/01.first-exercise/FINISHED.mdx')
304281
})
305282

306283
test('fails when an EpicVideo url does not return 200 to HEAD', async () => {
307-
const workshopRoot = await createWorkshopFixture()
284+
await using workshop = await createWorkshopFixture()
308285

309286
vi.stubGlobal(
310287
'fetch',
@@ -317,16 +294,12 @@ test('fails when an EpicVideo url does not return 200 to HEAD', async () => {
317294
}),
318295
)
319296

320-
try {
321-
await expect(
322-
launchReadiness({
323-
workshopRoot,
324-
silent: true,
325-
skipRemote: true,
326-
skipHead: false,
327-
}),
328-
).resolves.toEqual(expect.objectContaining({ success: false }))
329-
} finally {
330-
await fs.rm(workshopRoot, { recursive: true, force: true })
331-
}
297+
await expect(
298+
launchReadiness({
299+
workshopRoot: workshop.root,
300+
silent: true,
301+
skipRemote: true,
302+
skipHead: false,
303+
}),
304+
).resolves.toEqual(expect.objectContaining({ success: false }))
332305
})

0 commit comments

Comments
 (0)