1import { Fragment, useEffect, useRef, useState } from "react";
2import {
3 Line,
4 prefersReducedMotion,
5 sleep,
6 TermChrome,
7 useSpinner,
8} from "./_terminal";
9
10const TEST_STAGE = "pr-1729";
11
12interface BaseStep {
13 id: string;
14 label: string;
15 runMs: number;
16}
17interface PhaseStep extends BaseStep {
18 kind: "phase";
19 detail: string;
20 durSec: string;
21 url?: string;
22}
23interface TestStep extends BaseStep {
24 kind: "test";
25 durMs: string;
26}
27type Step = PhaseStep | TestStep;
28type RunningStep = Step & { status: "running" | "done" };
29
30const TEST_STEPS: Step[] = [
31 {
32 id: "deploy",
33 kind: "phase",
34 label: "deploy",
35 detail: "3 resources",
36 runMs: 1100,
37 durSec: "4.2",
38 url: `https://api.${TEST_STAGE}.workers.dev`,
39 },
40 {
41 id: "t1",
42 kind: "test",
43 label: "PUT + GET round-trips through R2",
44 runMs: 850,
45 durMs: "312",
46 },
47 {
48 id: "t2",
49 kind: "test",
50 label: "Room DO preserves state across requests",
51 runMs: 700,
52 durMs: "184",
53 },
54 {
55 id: "destroy",
56 kind: "phase",
57 label: "destroy",
58 detail: "3 resources",
59 runMs: 900,
60 durSec: "1.8",
61 },
62];
63
64export default function TestTerminal({
65 title = `CI · ${TEST_STAGE}`,
66}: {
67 title?: string;
68}) {
69 const [cmd, setCmd] = useState("");
70 const [caret, setCaret] = useState(false);
71 const [steps, setSteps] = useState<RunningStep[]>([]);
72 const [summary, setSummary] = useState<{
73 tests: number;
74 secs: string;
75 } | null>(null);
76
77 const cancelRef = useRef(false);
78
79 useEffect(() => {
80 cancelRef.current = false;
81 const aborted = () => cancelRef.current;
82
83 const typeCmd = async (text: string) => {
84 setCmd("");
85 setCaret(true);
86 for (let i = 1; i <= text.length; i++) {
87 if (aborted()) return;
88 setCmd(text.slice(0, i));
89 await sleep(36 + Math.random() * 24);
90 }
91 await sleep(180);
92 setCaret(false);
93 };
94
95 const run = async () => {
96 while (!aborted()) {
97 setSteps([]);
98 setSummary(null);
99 await typeCmd("pnpm test");
100 if (aborted()) return;
101 await sleep(280);
102
103 const t0 = Date.now();
104 for (const s of TEST_STEPS) {
105 if (aborted()) return;
106 setSteps((arr) => [...arr, { ...s, status: "running" }]);
107 await sleep(s.runMs);
108 if (aborted()) return;
109 setSteps((arr) =>
110 arr.map((r) => (r.id === s.id ? { ...r, status: "done" } : r)),
111 );
112 await sleep(160);
113 }
114 if (aborted()) return;
115 // Reduced motion skips the waits, so report the scripted total.
116 const ms = prefersReducedMotion()
117 ? TEST_STEPS.reduce((sum, step) => sum + step.runMs + 160, 0)
118 : Date.now() - t0;
119 const elapsed = (ms / 1000).toFixed(1);
120 setSummary({ tests: 2, secs: elapsed });
121 await sleep(2800);
122 }
123 };
124
125 run();
126 return () => {
127 cancelRef.current = true;
128 };
129 }, []);
130
131 const anyRunning = steps.some((s) => s.status === "running");
132 const spinner = useSpinner(anyRunning);
133 const accent = "var(--alc-accent-bright)";
134
135 const renderStep = (s: RunningStep, idx: number, arr: RunningStep[]) => {
136 const isRunning = s.status === "running";
137 const icon = isRunning ? spinner : "✓";
138 const iconColor = isRunning ? "var(--alc-code-type)" : accent;
139
140 if (s.kind === "phase") {
141 return (
142 <Fragment key={s.id}>
143 <Line>
144 <span
145 style={{
146 color: iconColor,
147 width: "1.2em",
148 display: "inline-block",
149 }}
150 >
151 {icon}
152 </span>
153 <span style={{ color: "var(--alc-fg-invert)", fontWeight: 600 }}>
154 {s.label}
155 </span>
156 {!isRunning ? (
157 <span style={{ color: "var(--alc-code-comment)" }}>
158 {` (${s.detail} · ${s.durSec}s)`}
159 </span>
160 ) : (
161 <span
162 style={{ color: "var(--alc-code-comment)" }}
163 >{` (${s.detail})`}</span>
164 )}
165 </Line>
166 {s.url && !isRunning && (
167 <Line>
168 <span style={{ color: "var(--alc-code-comment)" }}>{" → "}</span>
169 <span style={{ color: "var(--alc-code-comment)" }}>{s.url}</span>
170 </Line>
171 )}
172 {!isRunning && idx < arr.length - 1 && <Line> </Line>}
173 </Fragment>
174 );
175 }
176 return (
177 <Line key={s.id}>
178 <span
179 style={{ color: iconColor, width: "1.2em", display: "inline-block" }}
180 >
181 {icon}
182 </span>
183 <span style={{ color: "var(--alc-fg-invert)" }}>{s.label}</span>
184 {!isRunning && (
185 <span
186 style={{ color: "var(--alc-code-comment)" }}
187 >{` (${s.durMs}ms)`}</span>
188 )}
189 </Line>
190 );
191 };
192
193 return (
194 <TermChrome title={title} badge="TEST" badgeColor={accent} maxLines={10}>
195 <Line>
196 <span style={{ color: accent }}>$ </span>
197 {cmd}
198 {caret && <span style={{ color: "var(--alc-fg-invert)" }}>▍</span>}
199 </Line>
200 {steps.length > 0 && (
201 <>
202 <Line> </Line>
203 {steps.map((s, i, arr) => renderStep(s, i, arr))}
204 </>
205 )}
206 {summary && (
207 <>
208 <Line> </Line>
209 <Line>
210 <span
211 style={{
212 background: accent,
213 color: "var(--alc-bg-code)",
214 fontWeight: 700,
215 padding: "0 8px",
216 marginRight: 8,
217 }}
218 >
219 {" PASS "}
220 </span>
221 <span>{summary.tests} tests · </span>
222 <span style={{ color: "var(--alc-fg-invert)", fontWeight: 600 }}>
223 {summary.secs}s
224 </span>
225 </Line>
226 </>
227 )}
228 </TermChrome>
229 );
230}