Skip to content

Commit 941296e

Browse files
committed
make it easier to run dev script
1 parent 36149f7 commit 941296e

File tree

3 files changed

+313
-0
lines changed

3 files changed

+313
-0
lines changed

epicshop/run.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// This script spawns the `dev:mcp` script of the app ID and defaults to the playground
2+
3+
import { getApps, isPlaygroundApp } from '@epic-web/workshop-utils/apps.server'
4+
import { execa } from 'execa'
5+
6+
async function main() {
7+
const apps = await getApps()
8+
9+
const appParts = process.argv[2]
10+
let selectedApp = null
11+
if (appParts) {
12+
let [givenExercise, givenStep, givenType = 'solution'] = appParts.split('.')
13+
selectedApp = apps.find((app) => {
14+
return (
15+
app.exerciseNumber === Number(givenExercise) &&
16+
app.stepNumber === Number(givenStep) &&
17+
app.type.includes(givenType)
18+
)
19+
})
20+
} else {
21+
selectedApp = apps.find(isPlaygroundApp)
22+
}
23+
if (!selectedApp) {
24+
console.error('No app found')
25+
return
26+
}
27+
28+
console.error('Running MCP server for', selectedApp.relativePath)
29+
30+
await execa('npm', ['--prefix', selectedApp.fullPath, 'run', 'dev:mcp'], {
31+
cwd: selectedApp.fullPath,
32+
stdio: 'inherit',
33+
env: {
34+
...process.env,
35+
PORT: selectedApp.dev.portNumber,
36+
},
37+
})
38+
}
39+
40+
await main()

epicshop/test.js

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
import path from 'node:path'
2+
import { performance } from 'perf_hooks'
3+
import { fileURLToPath } from 'url'
4+
import {
5+
getApps,
6+
getAppDisplayName,
7+
} from '@epic-web/workshop-utils/apps.server'
8+
import enquirer from 'enquirer'
9+
import { execa } from 'execa'
10+
import { matchSorter } from 'match-sorter'
11+
import pLimit from 'p-limit'
12+
13+
const { prompt } = enquirer
14+
15+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
16+
17+
function captureOutput() {
18+
const output = []
19+
return {
20+
write: (chunk, streamType) => {
21+
output.push({ chunk: chunk.toString(), streamType })
22+
},
23+
replay: () => {
24+
for (const { chunk, streamType } of output) {
25+
if (streamType === 'stderr') {
26+
process.stderr.write(chunk)
27+
} else {
28+
process.stdout.write(chunk)
29+
}
30+
}
31+
},
32+
hasOutput: () => output.length > 0,
33+
}
34+
}
35+
36+
function printTestSummary(results) {
37+
const label = '--- Test Summary ---'
38+
console.log(`\n${label}`)
39+
for (const [appPath, { result, duration }] of results) {
40+
let emoji
41+
switch (result) {
42+
case 'Passed':
43+
emoji = '✅'
44+
break
45+
case 'Failed':
46+
emoji = '❌'
47+
break
48+
case 'Error':
49+
emoji = '💥'
50+
break
51+
case 'Incomplete':
52+
emoji = '⏳'
53+
break
54+
default:
55+
emoji = '❓'
56+
}
57+
console.log(`${emoji} ${appPath} (${duration.toFixed(2)}s)`)
58+
}
59+
console.log(`${'-'.repeat(label.length)}\n`)
60+
}
61+
62+
async function main() {
63+
const allApps = await getApps()
64+
65+
let selectedApps
66+
let additionalArgs = []
67+
68+
// Parse command-line arguments
69+
const argIndex = process.argv.indexOf('--')
70+
if (argIndex !== -1) {
71+
additionalArgs = process.argv.slice(argIndex + 1)
72+
process.argv = process.argv.slice(0, argIndex)
73+
}
74+
75+
if (process.argv[2]) {
76+
const patterns = process.argv[2].toLowerCase().split(',')
77+
selectedApps = allApps.filter((app) => {
78+
const { exerciseNumber, stepNumber, type } = app
79+
80+
return patterns.some((pattern) => {
81+
let [patternExercise = '*', patternStep = '*', patternType = '*'] =
82+
pattern.split('.')
83+
84+
patternExercise ||= '*'
85+
patternStep ||= '*'
86+
patternType ||= '*'
87+
88+
return (
89+
(patternExercise === '*' ||
90+
exerciseNumber === Number(patternExercise)) &&
91+
(patternStep === '*' || stepNumber === Number(patternStep)) &&
92+
(patternType === '*' || type.includes(patternType))
93+
)
94+
})
95+
})
96+
} else {
97+
const displayNameMap = new Map(
98+
allApps.map((app) => [getAppDisplayName(app, allApps), app]),
99+
)
100+
const choices = displayNameMap.keys()
101+
102+
const response = await prompt({
103+
type: 'autocomplete',
104+
name: 'appDisplayNames',
105+
message: 'Select apps to test:',
106+
choices: ['All', ...choices],
107+
multiple: true,
108+
suggest: (input, choices) => {
109+
return matchSorter(choices, input, { keys: ['name'] })
110+
},
111+
})
112+
113+
selectedApps = response.appDisplayNames.includes('All')
114+
? allApps
115+
: response.appDisplayNames.map((appDisplayName) =>
116+
displayNameMap.get(appDisplayName),
117+
)
118+
119+
// Update this block to use process.argv
120+
const appPattern =
121+
selectedApps.length === allApps.length
122+
? '*'
123+
: selectedApps
124+
.map((app) => `${app.exerciseNumber}.${app.stepNumber}.${app.type}`)
125+
.join(',')
126+
const additionalArgsString =
127+
additionalArgs.length > 0 ? ` -- ${additionalArgs.join(' ')}` : ''
128+
console.log(`\nℹ️ To skip the prompt next time, use this command:`)
129+
console.log(`npm test -- ${appPattern}${additionalArgsString}\n`)
130+
}
131+
132+
if (selectedApps.length === 0) {
133+
console.log('⚠️ No apps selected. Exiting.')
134+
return
135+
}
136+
137+
if (selectedApps.length === 1) {
138+
const app = selectedApps[0]
139+
console.log(`🚀 Running tests for ${app.relativePath}\n\n`)
140+
const startTime = performance.now()
141+
try {
142+
await execa('npm', ['run', 'test', '--silent', '--', ...additionalArgs], {
143+
cwd: app.fullPath,
144+
stdio: 'inherit',
145+
env: {
146+
...process.env,
147+
PORT: app.dev.portNumber,
148+
},
149+
})
150+
const duration = (performance.now() - startTime) / 1000
151+
console.log(
152+
`✅ Finished tests for ${app.relativePath} (${duration.toFixed(2)}s)`,
153+
)
154+
} catch {
155+
const duration = (performance.now() - startTime) / 1000
156+
console.error(
157+
`❌ Tests failed for ${app.relativePath} (${duration.toFixed(2)}s)`,
158+
)
159+
process.exit(1)
160+
}
161+
} else {
162+
const limit = pLimit(1)
163+
let hasFailures = false
164+
const runningProcesses = new Map()
165+
let isShuttingDown = false
166+
const results = new Map()
167+
168+
const shutdownHandler = () => {
169+
if (isShuttingDown) return
170+
isShuttingDown = true
171+
console.log('\nGracefully shutting down. Please wait...')
172+
console.log('Outputting results of running tests:')
173+
for (const [app, output] of runningProcesses.entries()) {
174+
if (output.hasOutput()) {
175+
console.log(`\nPartial results for ${app.relativePath}:\n\n`)
176+
output.replay()
177+
console.log('\n\n')
178+
} else {
179+
console.log(`ℹ️ No output captured for ${app.relativePath}`)
180+
}
181+
// Set result for incomplete tests
182+
if (!results.has(app.relativePath)) {
183+
results.set(app.relativePath, 'Incomplete')
184+
}
185+
}
186+
printTestSummary(results)
187+
// Allow some time for output to be written before exiting
188+
setTimeout(() => process.exit(1), 100)
189+
}
190+
191+
process.on('SIGINT', shutdownHandler)
192+
process.on('SIGTERM', shutdownHandler)
193+
194+
const tasks = selectedApps.map((app) =>
195+
limit(async () => {
196+
if (isShuttingDown) return
197+
console.log(`🚀 Starting tests for ${app.relativePath}`)
198+
const output = captureOutput()
199+
runningProcesses.set(app, output)
200+
const startTime = performance.now()
201+
try {
202+
const subprocess = execa(
203+
'npm',
204+
['run', 'test', '--silent', '--', ...additionalArgs],
205+
{
206+
cwd: path.join(__dirname, '..', app.relativePath),
207+
reject: false,
208+
env: {
209+
...process.env,
210+
PORT: app.dev.portNumber,
211+
},
212+
},
213+
)
214+
215+
subprocess.stdout.on('data', (chunk) => output.write(chunk, 'stdout'))
216+
subprocess.stderr.on('data', (chunk) => output.write(chunk, 'stderr'))
217+
218+
const { exitCode } = await subprocess
219+
const duration = (performance.now() - startTime) / 1000
220+
221+
runningProcesses.delete(app)
222+
223+
if (exitCode !== 0) {
224+
hasFailures = true
225+
console.error(
226+
`\n❌ Tests failed for ${app.relativePath} (${duration.toFixed(2)}s):\n\n`,
227+
)
228+
output.replay()
229+
console.log('\n\n')
230+
results.set(app.relativePath, { result: 'Failed', duration })
231+
// Set result for incomplete tests
232+
if (!results.has(app.relativePath)) {
233+
results.set(app.relativePath, 'Incomplete')
234+
}
235+
} else {
236+
console.log(
237+
`✅ Finished tests for ${app.relativePath} (${duration.toFixed(2)}s)`,
238+
)
239+
results.set(app.relativePath, { result: 'Passed', duration })
240+
}
241+
} catch (error) {
242+
const duration = (performance.now() - startTime) / 1000
243+
runningProcesses.delete(app)
244+
hasFailures = true
245+
console.error(
246+
`\n❌ An error occurred while running tests for ${app.relativePath} (${duration.toFixed(2)}s):\n\n`,
247+
)
248+
console.error(error.message)
249+
output.replay()
250+
console.log('\n\n')
251+
results.set(app.relativePath, { result: 'Error', duration })
252+
}
253+
}),
254+
)
255+
256+
await Promise.all(tasks)
257+
258+
// Print summary output
259+
printTestSummary(results)
260+
261+
if (hasFailures) {
262+
process.exit(1)
263+
}
264+
}
265+
}
266+
267+
main().catch((error) => {
268+
if (error) {
269+
console.error('❌ An error occurred:', error)
270+
}
271+
setTimeout(() => process.exit(1), 100)
272+
})

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"postinstall": "cd ./epicshop && npm install",
1717
"start": "npx --prefix ./epicshop epicshop start",
1818
"dev": "npx --prefix ./epicshop epicshop start",
19+
"dev:mcp": "node ./epicshop/run.js",
1920
"inspect": "npx @modelcontextprotocol/inspector",
2021
"test": "npm run test --silent --prefix playground",
2122
"test:e2e": "npm run test:e2e --silent --prefix playground",

0 commit comments

Comments
 (0)