1---
2/**
3 * The compare page's testing loop: one deploy → test → destroy → edit loop
4 * whose nodes and arcs restyle per tool to show exactly where each one's
5 * loop breaks. Alchemy closes it; SST leaves deploy/destroy to the CLI;
6 * Pulumi leaves the test harness to you; Terraform can't test the app.
7 */
8import { highlightTS } from "../marketing/highlightTS";
9
10type State = "ok" | "diy" | "broken";
11type Tool = {
12 id: string;
13 label: string;
14 // deploy, test, destroy, edit
15 nodes: [State, State, State, State];
16 // arc i runs from node i to node i + 1
17 arcs: [State, State, State, State];
18 times: [string, string, string, string];
19 total: string;
20 caption: string;
21 file: string;
22 code: string;
23 lang?: "hcl";
24};
25
26const TOOLS: Tool[] = [
27 {
28 id: "alchemy",
29 label: "Alchemy",
30 nodes: ["ok", "ok", "ok", "ok"],
31 arcs: ["ok", "ok", "ok", "ok"],
32 times: ["~12s", "<1s", "~4s", ""],
33 total: "~17s",
34 caption: "per loop",
35 file: "test/api.test.ts",
36 code: `const stack = beforeAll(deploy(Stack));
37afterAll(destroy(Stack));
38
39test("stores a session", Effect.gen(function* () {
40 const { url } = yield* stack;
41 const res = yield* HttpClient.post(\`\${url}/session\`);
42 expect(res.status).toBe(200);
43}));`,
44 },
45 {
46 id: "sst",
47 label: "SST",
48 nodes: ["diy", "ok", "diy", "ok"],
49 arcs: ["diy", "diy", "diy", "diy"],
50 times: ["by hand", "", "by hand", ""],
51 total: "manual",
52 caption: "outside the test",
53 file: "terminal",
54 code: `# 1. deploy the stage yourself
55sst deploy --stage test
56# 2. run tests with linked resources
57sst shell --stage test -- vitest run
58# 3. tear it down yourself, even on failure
59sst remove --stage test`,
60 },
61 {
62 id: "pulumi",
63 label: "Pulumi",
64 nodes: ["ok", "diy", "ok", "ok"],
65 arcs: ["diy", "diy", "ok", "ok"],
66 times: ["", "", "", ""],
67 total: "DIY",
68 caption: "build the harness",
69 file: "test/harness.ts",
70 code: `const stack = await LocalWorkspace.createOrSelectStack({
71 stackName: "test", projectName: "api", program,
72});
73const { outputs } = await stack.up();
74// pass outputs to your tests yourself…
75await runTests(outputs.url.value);
76// …and clean up yourself, even when they fail
77await stack.destroy();`,
78 },
79 {
80 id: "terraform",
81 label: "Terraform",
82 nodes: ["ok", "broken", "ok", "ok"],
83 arcs: ["ok", "broken", "ok", "ok"],
84 times: ["", "", "", ""],
85 total: "infra only",
86 caption: "app never tested",
87 file: "tests/api.tftest.hcl",
88 lang: "hcl",
89 code: `run "creates_table" {
90 command = apply
91 assert {
92 condition = (
93 aws_dynamodb_table.sessions.billing_mode == "PAY_PER_REQUEST"
94 )
95 error_message = "wrong billing mode"
96 }
97}
98# asserts on resource attributes only;
99# nothing here calls your running app`,
100 },
101];
102
103const c = 150;
104const r = 108;
105const pt = (deg: number) => {
106 const t = (deg * Math.PI) / 180;
107 return [+(c + r * Math.cos(t)).toFixed(1), +(c + r * Math.sin(t)).toFixed(1)] as const;
108};
109const DEGS = [-90, 0, 90, 180];
110const STEPS = ["deploy", "test", "destroy", "edit"];
111const NODES = DEGS.map(pt);
112const ARCS = DEGS.map((d) => {
113 const [x0, y0] = pt(d + 24);
114 const [x1, y1] = pt(d + 66);
115 return `M ${x0} ${y0} A ${r} ${r} 0 0 1 ${x1} ${y1}`;
116});
117
118const esc = (t: string) => t.replace(/&/g, "&amp;").replace(/</g, "&lt;");
119const hcl = (t: string) =>
120 t
121 .split("\n")
122 .map((line) =>
123 /^\s*#/.test(line)
124 ? `<span class="alc-tok-c">${esc(line)}</span>`
125 : esc(line)
126 .replace(/("[^"]*")/g, '<span class="alc-tok-s">$1</span>')
127 .replace(/^(\s*)(run|assert)\b/, '$1<span class="alc-tok-k">$2</span>'),
128 )
129 .join("\n");
130const shell = (t: string) =>
131 t
132 .split("\n")
133 .map((line) =>
134 /^\s*#/.test(line) ? `<span class="alc-tok-c">${esc(line)}</span>` : esc(line),
135 )
136 .join("\n");
137const render = (t: Tool) =>
138 t.lang === "hcl" ? hcl(t.code) : t.file === "terminal" ? shell(t.code) : highlightTS(t.code);
139
140const verb = (s: State) =>
141 s === "ok" ? "works" : s === "diy" ? "is yours to build" : "can't test the app";
142const aria = (t: Tool) =>
143 `${t.label}: ${STEPS.map((s, i) => `${s} ${verb(t.nodes[i]!)}`).join(", ")}.`;
144---
145
146<div class="tl" data-tool="alchemy">
147 <fieldset class="tl__modes">
148 <legend class="tl__sr">Compare the testing loop</legend>
149 {
150 TOOLS.map((t, i) => (
151 <label>
152 <input type="radio" name="tl-tool" value={t.id} checked={i === 0} />
153 <span data-copy>{t.label}</span>
154 </label>
155 ))
156 }
157 </fieldset>
158
159 <div class="tl__stage">
160 <figure class="tl__loop">
161 <svg
162 viewBox="0 0 300 300"
163 role="img"
164 aria-label={aria(TOOLS[0]!)}
165 data-aria={JSON.stringify(Object.fromEntries(TOOLS.map((t) => [t.id, aria(t)])))}
166 >
167 <defs>
168 {["ok", "diy", "broken"].map((s) => (
169 <marker
170 id={`tl-arrow-${s}`}
171 class={`tl__arrow is-${s}`}
172 viewBox="0 0 10 10"
173 refX="7"
174 refY="5"
175 markerWidth="7"
176 markerHeight="7"
177 orient="auto-start-reverse"
178 >
179 <path d="M0 1 L9 5 L0 9 z" />
180 </marker>
181 ))}
182 </defs>
183 <circle class="tl__track" cx={c} cy={c} r={r} />
184 {
185 ARCS.map((d, i) => (
186 <g class="tl__arc" data-i={i}>
187 {["ok", "diy", "broken"].map((s) => (
188 <path class={`is-${s}`} d={d} marker-end={s === "broken" ? undefined : `url(#tl-arrow-${s})`} />
189 ))}
190 </g>
191 ))
192 }
193 {
194 NODES.map(([x, y], i) => (
195 <g class="tl__node" data-i={i} transform={`translate(${x} ${y})`}>
196 <circle r="30" />
197 <text y="-2" text-anchor="middle" class="tl__label">{STEPS[i]}</text>
198 {TOOLS.map((t) => (
199 <text y="13" text-anchor="middle" class="tl__time" data-for={t.id}>
200 {t.times[i]}
201 </text>
202 ))}
203 </g>
204 ))
205 }
206 {
207 TOOLS.map((t) => (
208 <g class="tl__center" data-for={t.id}>
209 <text x={c} y={c - 4} text-anchor="middle" class="tl__total">{t.total}</text>
210 <text x={c} y={c + 16} text-anchor="middle" class="tl__caption">{t.caption}</text>
211 </g>
212 ))
213 }
214 </svg>
215 </figure>
216
217 <div class="tl__side">
218 {
219 TOOLS.map((t) => (
220 <div class="tl__panel" data-for={t.id}>
221 <div class="alc-code-block alc-code-block--compact">
222 <div class="alc-code-block__header">
223 <span class="alc-code-block__filename">{t.file}</span>
224 </div>
225 <pre class="alc-code-block__pre" tabindex="0" aria-label={`${t.label}: ${t.file}`} set:html={render(t)} />
226 </div>
227 </div>
228 ))
229 }
230 </div>
231 </div>
232
233 <div class="tl__notes" aria-live="polite">
234 <div class="tl__note" data-for="alchemy">
235 <h3 data-copy>One test file closes the loop</h3>
236 <p data-copy>
237 <code>beforeAll(deploy(Stack))</code> stands up real infrastructure,
238 the test calls it, and <code>afterAll(destroy(Stack))</code> tears it
239 down, even when a test fails.
240 </p>
241 </div>
242 <div class="tl__note" data-for="sst">
243 <h3 data-copy>Tests can't deploy &amp; destroy</h3>
244 <p data-copy>
245 <code>sst shell</code> can give tests access to resources via link,
246 but deploying and tearing down the stack still need to be done outside
247 of the test runner.
248 </p>
249 </div>
250 <div class="tl__note" data-for="pulumi">
251 <h3 data-copy>Make your own test runner</h3>
252 <p data-copy>
253 Pulumi exposes and automation api that can deploy and destroy, but
254 passing outputs to tests and cleaning up properly after failures means
255 you need to write your own testing harness.
256 </p>
257 </div>
258 <div class="tl__note" data-for="terraform">
259 <h3 data-copy>Tests must be entirely decoupled</h3>
260 <p data-copy>
261 Terraform test can assert what resources are deployed, but it doesn't
262 verify whats running in those resources is correct
263 </p>
264 </div>
265 </div>
266
267 <ul class="tl__legend" aria-label="Legend">
268 <li><span class="is-ok" aria-hidden="true"></span>part of the loop</li>
269 <li><span class="is-diy" aria-hidden="true"></span>possible, you build it</li>
270 <li><span class="is-broken" aria-hidden="true"></span>can't do what the loop needs</li>
271 </ul>
272</div>
273
274<script>
275 // The loop is one SVG: switching tools restyles its nodes and arcs (CSS
276 // transitions on data-* state) and swaps the side panel and note.
277 type State = "ok" | "diy" | "broken";
278 const STATES: Record<string, { nodes: State[]; arcs: State[] }> = {
279 alchemy: { nodes: ["ok", "ok", "ok", "ok"], arcs: ["ok", "ok", "ok", "ok"] },
280 sst: { nodes: ["diy", "ok", "diy", "ok"], arcs: ["diy", "diy", "diy", "diy"] },
281 pulumi: { nodes: ["ok", "diy", "ok", "ok"], arcs: ["diy", "diy", "ok", "ok"] },
282 terraform: { nodes: ["ok", "broken", "ok", "ok"], arcs: ["ok", "broken", "ok", "ok"] },
283 };
284 for (const root of document.querySelectorAll<HTMLElement>(".tl")) {
285 const svg = root.querySelector("svg")!;
286 const aria = JSON.parse(svg.dataset.aria!) as Record<string, string>;
287 const apply = (tool: string) => {
288 const s = STATES[tool]!;
289 root.dataset.tool = tool;
290 root.querySelectorAll<SVGGElement>(".tl__node").forEach((n, i) => {
291 n.dataset.state = s.nodes[i];
292 n.toggleAttribute("data-timed", !!n.querySelector(`[data-for="${tool}"]`)?.textContent?.trim());
293 });
294 root.querySelectorAll<SVGGElement>(".tl__arc").forEach((a, i) => (a.dataset.state = s.arcs[i]));
295 svg.setAttribute("aria-label", aria[tool]!);
296 };
297 root.addEventListener("change", (e) => apply((e.target as HTMLInputElement).value));
298 apply("alchemy");
299 }
300</script>
301
302<style>
303 .tl {
304 --ok: var(--alc-accent);
305 --diy: var(--alc-warn);
306 --broken: var(--alc-danger);
307 }
308 .tl__sr {
309 position: absolute;
310 width: 1px;
311 height: 1px;
312 overflow: hidden;
313 clip-path: inset(50%);
314 white-space: nowrap;
315 }
316
317 /* ---------- picker ---------- */
318 .tl__modes {
319 display: flex;
320 flex-wrap: wrap;
321 gap: 8px;
322 margin: 0 0 12px;
323 padding: 0;
324 border: 0;
325 }
326 .tl__modes label {
327 position: relative;
328 cursor: pointer;
329 }
330 .tl__modes input {
331 position: absolute;
332 inset: 0;
333 margin: 0;
334 opacity: 0;
335 cursor: pointer;
336 }
337 .tl__modes span {
338 display: inline-block;
339 padding: 6px 14px;
340 border: 1px dashed var(--alc-hairline-3);
341 border-radius: 999px;
342 font-size: 13px;
343 color: var(--alc-fg-2);
344 transition:
345 border-color 200ms ease,
346 background 200ms ease,
347 color 200ms ease;
348 }
349 .tl__modes label:hover span {
350 color: var(--alc-fg-1);
351 }
352 .tl__modes input:checked + span {
353 border-style: solid;
354 border-color: var(--alc-accent);
355 background: color-mix(in srgb, var(--alc-accent) 12%, transparent);
356 color: var(--alc-fg-1);
357 }
358 .tl__modes input:focus-visible + span {
359 outline: 2px solid var(--alc-accent);
360 outline-offset: 2px;
361 }
362
363 /* ---------- layout ---------- */
364 .tl__stage {
365 display: grid;
366 grid-template-columns: minmax(0, 0.85fr) minmax(0, 1fr);
367 gap: 24px;
368 align-items: center;
369 }
370 .tl__loop {
371 margin: 0;
372 text-align: center;
373 }
374 .tl__loop svg {
375 width: 100%;
376 max-width: 340px;
377 }
378 /* Panels stack in one grid cell; the active one is shown. */
379 .tl__side,
380 .tl__notes {
381 display: grid;
382 }
383 .tl__panel,
384 .tl__note {
385 grid-area: 1 / 1;
386 min-width: 0;
387 opacity: 0;
388 visibility: hidden;
389 transition:
390 opacity 220ms ease,
391 visibility 0s linear 220ms;
392 }
393 .tl[data-tool="alchemy"] [data-for="alchemy"],
394 .tl[data-tool="sst"] [data-for="sst"],
395 .tl[data-tool="pulumi"] [data-for="pulumi"],
396 .tl[data-tool="terraform"] [data-for="terraform"] {
397 opacity: 1;
398 visibility: visible;
399 transition: opacity 320ms ease 100ms;
400 }
401
402 /* ---------- loop ---------- */
403 .tl__track {
404 fill: none;
405 stroke: var(--alc-hairline);
406 stroke-width: 1;
407 }
408 /* Each arc has one path per state; the current state's path shows. */
409 .tl__arc path {
410 fill: none;
411 stroke-width: 2;
412 opacity: 0;
413 transition: opacity 320ms ease;
414 }
415 .tl__arc[data-state="ok"] .is-ok,
416 .tl__arc[data-state="diy"] .is-diy,
417 .tl__arc[data-state="broken"] .is-broken {
418 opacity: 1;
419 }
420 .tl__arc .is-ok {
421 stroke: var(--ok);
422 stroke-dasharray: 5 6;
423 animation: tl-flow 1.4s linear infinite;
424 }
425 .tl__arc .is-diy {
426 stroke: var(--diy);
427 stroke-dasharray: 5 6;
428 }
429 .tl__arc .is-broken {
430 stroke: var(--broken);
431 stroke-dasharray: 1 8;
432 stroke-linecap: round;
433 }
434 .tl__arrow.is-ok path {
435 fill: var(--ok);
436 }
437 .tl__arrow.is-diy path {
438 fill: var(--diy);
439 }
440 @keyframes tl-flow {
441 to {
442 stroke-dashoffset: -22;
443 }
444 }
445 .tl__node circle {
446 fill: var(--alc-bg);
447 stroke: color-mix(in srgb, var(--ok) 70%, transparent);
448 stroke-width: 1.5;
449 transition:
450 stroke 320ms ease,
451 fill 320ms ease,
452 stroke-dasharray 320ms ease;
453 }
454 .tl__node[data-state="diy"] circle {
455 stroke: var(--diy);
456 stroke-dasharray: 5 4;
457 }
458 .tl__node[data-state="broken"] circle {
459 stroke: var(--broken);
460 fill: color-mix(in srgb, var(--broken) 16%, var(--alc-bg));
461 }
462 .tl__label {
463 font: 600 13px var(--alc-font-sans);
464 fill: var(--alc-fg-1);
465 transform: translateY(6px);
466 transition: transform 320ms ease;
467 }
468 .tl__node[data-timed] .tl__label {
469 transform: none;
470 }
471 .tl__time {
472 font: 10.5px var(--alc-font-mono);
473 fill: var(--alc-fg-3);
474 opacity: 0;
475 transition: opacity 320ms ease;
476 }
477 .tl__node[data-state="diy"] .tl__time {
478 fill: var(--diy);
479 }
480 .tl__node[data-state="broken"] .tl__time {
481 fill: var(--broken);
482 }
483 .tl__center {
484 opacity: 0;
485 transition: opacity 320ms ease;
486 }
487 .tl__total {
488 font: 500 30px var(--alc-font-serif);
489 fill: var(--alc-fg-1);
490 }
491 .tl__caption {
492 font: 12px var(--alc-font-sans);
493 fill: var(--alc-fg-3);
494 }
495 .tl[data-tool="alchemy"] [data-for="alchemy"],
496 .tl[data-tool="sst"] [data-for="sst"],
497 .tl[data-tool="pulumi"] [data-for="pulumi"],
498 .tl[data-tool="terraform"] [data-for="terraform"] {
499 opacity: 1;
500 }
501
502 /* ---------- note ---------- */
503 .tl__notes {
504 margin-top: 20px;
505 }
506 .tl__note h3 {
507 margin: 0 0 6px;
508 font-family: var(--alc-font-serif);
509 font-weight: 500;
510 font-size: 22px;
511 line-height: 1.25;
512 color: var(--alc-fg-1);
513 }
514 .tl__note p {
515 max-width: 680px;
516 margin: 0;
517 font-size: 15px;
518 line-height: 1.6;
519 color: var(--alc-fg-2);
520 }
521 .tl__note code {
522 font-family: var(--alc-font-mono);
523 font-size: 0.92em;
524 color: var(--alc-fg-1);
525 }
526
527 /* ---------- legend ---------- */
528 .tl__legend {
529 display: flex;
530 flex-wrap: wrap;
531 gap: 8px 18px;
532 margin: 16px 0 0;
533 padding: 0;
534 list-style: none;
535 font-size: 13px;
536 color: var(--alc-fg-3);
537 }
538 .tl__legend li {
539 display: inline-flex;
540 align-items: center;
541 gap: 8px;
542 }
543 .tl__legend span {
544 width: 12px;
545 height: 12px;
546 border-radius: 50%;
547 }
548 .tl__legend .is-ok {
549 background: color-mix(in srgb, var(--ok) 45%, transparent);
550 border: 1.5px solid var(--ok);
551 }
552 .tl__legend .is-diy {
553 border: 1.5px dashed var(--diy);
554 }
555 .tl__legend .is-broken {
556 background: color-mix(in srgb, var(--broken) 25%, transparent);
557 border: 1.5px solid var(--broken);
558 }
559
560 @media (max-width: 900px) {
561 .tl__stage {
562 grid-template-columns: minmax(0, 1fr);
563 }
564 }
565 @media (prefers-reduced-motion: reduce) {
566 .tl__arc .is-ok {
567 animation: none;
568 }
569 }
570</style>