1import { Fragment, useEffect, useRef, useState, type ReactNode } from "react";
2import { Line, sleep, TermChrome, useSpinner } from "./_terminal";
3
4/* ============================================================
5 Combined Dev + Test terminal. One TermChrome, two narratives.
6 Loops:
7 DEV — alchemy dev (boot → ready → edit → reload → request)
8 TEST — bun test (deploy → tests → destroy)
9 The badge + title swap when the mode flips, reinforcing
10 "same primitive, two modes" without doubling vertical space.
11 ============================================================ */
12
13type Mode = "dev" | "test";
14
15interface DevStep {
16 kind: "boot" | "ready" | "edit" | "reload" | "diff" | "wire" | "request";
17 label?: string;
18 detail?: string;
19 ms: number;
20}
21
22const DEV_STEPS: DevStep[] = [
23 { kind: "boot", label: "Photos", detail: "Cloudflare.R2.Bucket", ms: 600 },
24 {
25 kind: "boot",
26 label: "Sessions",
27 detail: "Cloudflare.KV.Namespace",
28 ms: 500,
29 },
30 {
31 kind: "boot",
32 label: "Api",
33 detail: "Cloudflare.Worker · local → workerd",
34 ms: 700,
35 },
36 { kind: "ready", ms: 700 },
37 { kind: "edit", label: "src/Api.ts", ms: 900 },
38 { kind: "reload", detail: "38ms", ms: 600 },
39 { kind: "request", label: "GET /object/hello.txt", detail: "200", ms: 900 },
40 { kind: "edit", label: "alchemy.run.ts", ms: 900 },
41 { kind: "diff", label: "Queue", detail: "Cloudflare.Queues.Queue", ms: 700 },
42 { kind: "wire", label: "Api → Queue.bind", ms: 700 },
43 { kind: "ready", ms: 1000 },
44];
45
46const TEST_STAGE = "pr-1729";
47
48interface BaseTestStep {
49 id: string;
50 label: string;
51 runMs: number;
52}
53interface PhaseTestStep extends BaseTestStep {
54 kind: "phase";
55 detail: string;
56 durSec: string;
57 url?: string;
58}
59interface UnitTestStep extends BaseTestStep {
60 kind: "test";
61 durMs: string;
62}
63type TestStep = PhaseTestStep | UnitTestStep;
64type RunningTest = TestStep & { status: "running" | "done" };
65
66const TEST_STEPS: TestStep[] = [
67 {
68 id: "deploy",
69 kind: "phase",
70 label: "deploy",
71 detail: "3 resources",
72 runMs: 1000,
73 durSec: "4.2",
74 url: `https://api.${TEST_STAGE}.workers.dev`,
75 },
76 {
77 id: "t1",
78 kind: "test",
79 label: "PUT + GET round-trips through R2",
80 runMs: 800,
81 durMs: "312",
82 },
83 {
84 id: "t2",
85 kind: "test",
86 label: "Room DO preserves state across requests",
87 runMs: 700,
88 durMs: "184",
89 },
90 {
91 id: "destroy",
92 kind: "phase",
93 label: "destroy",
94 detail: "3 resources",
95 runMs: 800,
96 durSec: "1.8",
97 },
98];
99
100interface LogLine {
101 text: ReactNode;
102 key: string;
103}
104
105let _id = 0;
106
107const DEV_ACCENT = "var(--alc-accent-bright)";
108const TEST_ACCENT = "var(--alc-success)";
109
110export default function DevTestTerminal() {
111 const [mode, setMode] = useState<Mode>("dev");
112
113 // Dev state
114 const [devLines, setDevLines] = useState<LogLine[]>([]);
115 const [devBusy, setDevBusy] = useState(false);
116
117 // Test state
118 const [cmd, setCmd] = useState("");
119 const [caret, setCaret] = useState(false);
120 const [testSteps, setTestSteps] = useState<RunningTest[]>([]);
121 const [summary, setSummary] = useState<{
122 tests: number;
123 secs: string;
124 } | null>(null);
125
126 const cancelRef = useRef(false);
127
128 useEffect(() => {
129 cancelRef.current = false;
130 const aborted = () => cancelRef.current;
131
132 const pushDev = (text: ReactNode) =>
133 setDevLines((ls) => [...ls, { text, key: `${++_id}` }].slice(-13));
134 const resetDev = () => setDevLines([]);
135
136 const typeCmd = async (text: string) => {
137 setCmd("");
138 setCaret(true);
139 for (let i = 1; i <= text.length; i++) {
140 if (aborted()) return;
141 setCmd(text.slice(0, i));
142 await sleep(34 + Math.random() * 22);
143 }
144 await sleep(160);
145 setCaret(false);
146 };
147
148 const runDev = async () => {
149 resetDev();
150 pushDev(
151 <>
152 <span style={{ color: "var(--alc-code-comment)" }}>$ </span>
153 <span>alchemy dev</span>
154 </>,
155 );
156 await sleep(280);
157 for (const step of DEV_STEPS) {
158 if (aborted()) return;
159 if (step.kind === "boot") {
160 setDevBusy(true);
161 await sleep(step.ms);
162 setDevBusy(false);
163 pushDev(
164 <>
165 <span style={{ color: "var(--alc-success)" }}>✓ </span>
166 <span style={{ color: "var(--alc-fg-invert)", fontWeight: 600 }}>
167 {step.label}
168 </span>
169 <span
170 style={{ color: "var(--alc-code-comment)" }}
171 >{` (${step.detail})`}</span>
172 <span style={{ color: "var(--alc-success)" }}> created</span>
173 </>,
174 );
175 } else if (step.kind === "ready") {
176 await sleep(step.ms);
177 pushDev(
178 <>
179 <span style={{ color: "var(--alc-code-comment)" }}> → </span>
180 <span style={{ color: "var(--alc-accent-bright)" }}>
181 http://localhost:1337
182 </span>
183 </>,
184 );
185 pushDev(
186 <span style={{ color: "var(--alc-code-comment)" }}>
187 Watching for changes…
188 </span>,
189 );
190 } else if (step.kind === "edit") {
191 await sleep(step.ms);
192 pushDev(
193 <>
194 <span style={{ color: "var(--alc-warn)" }}>↻ </span>
195 <span style={{ color: "var(--alc-code-comment)" }}>
196 {step.label} changed
197 </span>
198 </>,
199 );
200 } else if (step.kind === "reload") {
201 setDevBusy(true);
202 await sleep(step.ms);
203 setDevBusy(false);
204 pushDev(
205 <>
206 <span style={{ color: "var(--alc-success)" }}>✓ </span>
207 <span style={{ color: "var(--alc-fg-invert)", fontWeight: 600 }}>
208 Api
209 </span>
210 <span
211 style={{ color: "var(--alc-code-comment)" }}
212 >{` reloaded in `}</span>
213 <span style={{ color: "var(--alc-fg-invert)", fontWeight: 600 }}>
214 {step.detail}
215 </span>
216 </>,
217 );
218 } else if (step.kind === "diff") {
219 await sleep(step.ms);
220 pushDev(
221 <>
222 <span style={{ color: "var(--alc-success)" }}>+ </span>
223 <span style={{ color: "var(--alc-fg-invert)", fontWeight: 600 }}>
224 {step.label}
225 </span>
226 <span
227 style={{ color: "var(--alc-code-comment)" }}
228 >{` (${step.detail})`}</span>
229 <span style={{ color: "var(--alc-success)" }}> created</span>
230 </>,
231 );
232 } else if (step.kind === "wire") {
233 await sleep(step.ms);
234 pushDev(
235 <>
236 <span style={{ color: "var(--alc-warn)" }}>~ </span>
237 <span style={{ color: "var(--alc-code-comment)" }}>wired </span>
238 <span style={{ color: "var(--alc-code-type)" }}>
239 {step.label}
240 </span>
241 </>,
242 );
243 } else if (step.kind === "request") {
244 await sleep(step.ms);
245 pushDev(
246 <>
247 <span
248 style={{ color: "var(--alc-code-comment)" }}
249 >{`[${new Date().toLocaleTimeString().slice(0, 8)}] `}</span>
250 <span style={{ color: "var(--alc-fg-invert)" }}>
251 {step.label}
252 </span>
253 <span
254 style={{ color: "var(--alc-success)" }}
255 >{` ${step.detail}`}</span>
256 </>,
257 );
258 }
259 }
260 };
261
262 const runTest = async () => {
263 setTestSteps([]);
264 setSummary(null);
265 setCmd("");
266 await sleep(200);
267 await typeCmd("bun test");
268 if (aborted()) return;
269 await sleep(260);
270
271 const t0 = Date.now();
272 for (const s of TEST_STEPS) {
273 if (aborted()) return;
274 setTestSteps((arr) => [...arr, { ...s, status: "running" }]);
275 await sleep(s.runMs);
276 if (aborted()) return;
277 setTestSteps((arr) =>
278 arr.map((r) => (r.id === s.id ? { ...r, status: "done" } : r)),
279 );
280 await sleep(140);
281 }
282 if (aborted()) return;
283 const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
284 setSummary({ tests: 2, secs: elapsed });
285 };
286
287 const loop = async () => {
288 while (!aborted()) {
289 setMode("dev");
290 await runDev();
291 if (aborted()) return;
292 await sleep(1600);
293
294 setMode("test");
295 await runTest();
296 if (aborted()) return;
297 await sleep(2400);
298 }
299 };
300
301 loop();
302 return () => {
303 cancelRef.current = true;
304 };
305 }, []);
306
307 const devSpinner = useSpinner(devBusy);
308 const anyTestRunning = testSteps.some((s) => s.status === "running");
309 const testSpinner = useSpinner(anyTestRunning);
310
311 const accent = mode === "dev" ? DEV_ACCENT : TEST_ACCENT;
312 const title = mode === "dev" ? "~/my-app" : `ci · ${TEST_STAGE}`;
313 const badge = mode === "dev" ? "DEV" : "TEST";
314
315 return (
316 <TermChrome title={title} badge={badge} badgeColor={accent} maxLines={13}>
317 {mode === "dev" ? (
318 <>
319 {devLines.map((l) => (
320 <Line key={l.key}>{l.text}</Line>
321 ))}
322 {devBusy && (
323 <Line>
324 <span style={{ color: "var(--alc-code-comment)" }}>
325 {devSpinner}{" "}
326 </span>
327 </Line>
328 )}
329 </>
330 ) : (
331 <>
332 <Line>
333 <span style={{ color: accent }}>$ </span>
334 {cmd}
335 {caret && <span style={{ color: "var(--alc-fg-invert)" }}>▍</span>}
336 </Line>
337 {testSteps.length > 0 && (
338 <>
339 <Line> </Line>
340 {testSteps.map((s, i, arr) =>
341 renderTestStep(s, i, arr, accent, testSpinner),
342 )}
343 </>
344 )}
345 {summary && (
346 <>
347 <Line> </Line>
348 <Line>
349 <span
350 style={{
351 background: accent,
352 color: "var(--alc-bg-code)",
353 fontWeight: 700,
354 padding: "0 8px",
355 marginRight: 8,
356 }}
357 >
358 {" PASS "}
359 </span>
360 <span>{summary.tests} tests · </span>
361 <span
362 style={{ color: "var(--alc-fg-invert)", fontWeight: 600 }}
363 >
364 {summary.secs}s
365 </span>
366 </Line>
367 </>
368 )}
369 </>
370 )}
371 </TermChrome>
372 );
373}
374
375function renderTestStep(
376 s: RunningTest,
377 idx: number,
378 arr: RunningTest[],
379 accent: string,
380 spinner: string,
381) {
382 const isRunning = s.status === "running";
383 const icon = isRunning ? spinner : "✓";
384 const iconColor = isRunning ? "var(--alc-code-type)" : accent;
385
386 if (s.kind === "phase") {
387 return (
388 <Fragment key={s.id}>
389 <Line>
390 <span
391 style={{
392 color: iconColor,
393 width: "1.2em",
394 display: "inline-block",
395 }}
396 >
397 {icon}
398 </span>
399 <span style={{ color: "var(--alc-fg-invert)", fontWeight: 600 }}>
400 {s.label}
401 </span>
402 {!isRunning ? (
403 <span style={{ color: "var(--alc-code-comment)" }}>
404 {` (${s.detail} · ${s.durSec}s)`}
405 </span>
406 ) : (
407 <span
408 style={{ color: "var(--alc-code-comment)" }}
409 >{` (${s.detail})`}</span>
410 )}
411 </Line>
412 {s.url && !isRunning && (
413 <Line>
414 <span style={{ color: "var(--alc-code-comment)" }}>{" → "}</span>
415 <span style={{ color: "var(--alc-code-comment)" }}>{s.url}</span>
416 </Line>
417 )}
418 {!isRunning && idx < arr.length - 1 && <Line> </Line>}
419 </Fragment>
420 );
421 }
422 return (
423 <Line key={s.id}>
424 <span
425 style={{ color: iconColor, width: "1.2em", display: "inline-block" }}
426 >
427 {icon}
428 </span>
429 <span style={{ color: "var(--alc-fg-invert)" }}>{s.label}</span>
430 {!isRunning && (
431 <span
432 style={{ color: "var(--alc-code-comment)" }}
433 >{` (${s.durMs}ms)`}</span>
434 )}
435 </Line>
436 );
437}