1// @vitest-environment jsdom
2
3import { act } from "react";
4import { createRoot, type Root } from "react-dom/client";
5import { AsyncResult, Atom } from "effect/reactivity";
6import * as Stream from "effect/Stream";
7import {
8 EnvironmentId,
9 ThreadId,
10 type ScriptProgressEvent,
11 type ScriptProgressStream,
12} from "@t3tools/contracts";
13import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
14
15import { appAtomRegistry } from "../../rpc/atomRegistry";
16import { ScriptProgress } from "./ScriptProgress";
17
18type ProgressResult = AsyncResult.AsyncResult<{
19 events: ReadonlyArray<ScriptProgressEvent>;
20 streams: ReadonlyArray<ScriptProgressStream>;
21}>;
22
23const state = vi.hoisted(() => ({ atom: undefined as unknown }));
24vi.mock("../../state/scriptProgress", () => ({
25 scriptProgressEnvironment: { watch: () => state.atom },
26}));
27
28let root: Root;
29let container: HTMLDivElement;
30let atom: Atom.Writable<ProgressResult, ProgressResult>;
31const descriptor: ScriptProgressStream = {
32 id: "script",
33 pid: 42,
34 label: "render.ts",
35 status: "connected",
36 summary: [{ text: "Rendering", value: 4, total: 10, detail: "4/10" }],
37};
38
39beforeEach(async () => {
40 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
41 const initial: ProgressResult = AsyncResult.success({
42 events: [{ type: "snapshot", streams: [] }],
43 streams: [],
44 });
45 atom = Atom.make<ProgressResult>(initial);
46 state.atom = atom;
47 container = document.createElement("div");
48 document.body.append(container);
49 root = createRoot(container);
50 await act(async () => {
51 root.render(
52 <ScriptProgress environmentId={EnvironmentId.make("test")} threadId={ThreadId.make("one")} />,
53 );
54 });
55});
56
57afterEach(async () => {
58 await act(async () => root.unmount());
59 container.remove();
60 vi.unstubAllGlobals();
61});
62
63async function emit(event: ScriptProgressEvent) {
64 await act(async () => {
65 appAtomRegistry.set(atom, AsyncResult.success({ events: [event], streams: [] }));
66 });
67}
68
69function progressbar() {
70 const tree = container.querySelector("[data-progress-motion]");
71 return tree?.shadowRoot?.querySelector('[role="progressbar"]');
72}
73
74it("renders actual progress values and resets to zero without resetting the tree", async () => {
75 expect(container.querySelector("section")?.hidden).toBe(true);
76 await emit({ type: "snapshot", streams: [descriptor] });
77 await emit({
78 type: "progress",
79 id: descriptor.id,
80 event: [-1, { k: 1, t: "Rendering", v: 4, e: 10 }, [1]],
81 summary: descriptor.summary,
82 });
83 await vi.waitFor(() => expect(progressbar()?.getAttribute("aria-valuenow")).toBe("4"));
84 expect(progressbar()?.getAttribute("aria-valuemax")).toBe("10");
85 expect(container.querySelector("section")?.hidden).toBe(false);
86
87 await emit({
88 type: "progress",
89 id: descriptor.id,
90 event: [0, { k: 1, v: 0 }],
91 summary: [{ text: "Rendering", value: 0, total: 10, detail: "0/10" }],
92 });
93 await vi.waitFor(() => expect(progressbar()?.getAttribute("aria-valuenow")).toBe("0"));
94});
95
96it("removes the old chat's decoder and DOM before displaying another chat", async () => {
97 await emit({ type: "snapshot", streams: [descriptor] });
98 await emit({
99 type: "progress",
100 id: descriptor.id,
101 event: [-1, { k: 1, t: "Rendering", v: 4, e: 10 }, [1]],
102 summary: descriptor.summary,
103 });
104 await vi.waitFor(() => expect(progressbar()).toBeTruthy());
105 await act(async () => {
106 root.render(
107 <ScriptProgress environmentId={EnvironmentId.make("test")} threadId={ThreadId.make("two")} />,
108 );
109 });
110 expect(progressbar()).toBeUndefined();
111 expect(container.querySelector("section")?.hidden).toBe(true);
112});
113
114it("requests a fresh tree when returning to a chat whose live atom cached a delta", async () => {
115 let connections = 0;
116 const snapshot: ScriptProgressEvent = { type: "snapshot", streams: [descriptor] };
117 const update: ScriptProgressEvent = {
118 type: "progress",
119 id: descriptor.id,
120 event: [-1, { k: 1, t: "Rendering", v: 4, e: 10 }, [1]],
121 summary: descriptor.summary,
122 };
123 state.atom = Atom.make(
124 Stream.suspend(() => {
125 connections++;
126 return Stream.concat(
127 Stream.fromArray([{ events: [snapshot, update], streams: [descriptor] }]),
128 Stream.never,
129 );
130 }),
131 ).pipe(Atom.keepAlive);
132
133 await act(async () => {
134 root.render(
135 <ScriptProgress
136 environmentId={EnvironmentId.make("test")}
137 threadId={ThreadId.make("cached")}
138 />,
139 );
140 });
141 await vi.waitFor(() => expect(progressbar()?.getAttribute("aria-valuenow")).toBe("4"));
142 const initialConnections = connections;
143 await act(async () => root.unmount());
144 root = createRoot(container);
145 await act(async () => {
146 root.render(
147 <ScriptProgress
148 environmentId={EnvironmentId.make("test")}
149 threadId={ThreadId.make("cached")}
150 />,
151 );
152 });
153
154 await vi.waitFor(() => expect(progressbar()?.getAttribute("aria-valuenow")).toBe("4"));
155 expect(connections).toBeGreaterThan(initialConnections);
156});
157
158it("hides the panel when a passive grouping node has no visible tasks left", async () => {
159 await emit({ type: "snapshot", streams: [descriptor] });
160 await emit({
161 type: "progress",
162 id: descriptor.id,
163 event: [-1, { k: 1, t: "Group", p: true, c: [2] }, { k: 2, t: "Compiling", v: 1, e: 4 }, [1]],
164 summary: descriptor.summary,
165 });
166 await vi.waitFor(() => expect(container.querySelector("section")?.hidden).toBe(false));
167 await emit({
168 type: "progress",
169 id: descriptor.id,
170 event: [1, 2, { k: 1, c: [] }],
171 summary: [],
172 });
173 await vi.waitFor(() => expect(container.querySelector("section")?.hidden).toBe(true));
174});
175
176it("hides an empty disconnected script while another script is running", async () => {
177 const empty = { ...descriptor, id: "empty", status: "disconnected" as const, summary: [] };
178 await emit({ type: "snapshot", streams: [empty, descriptor] });
179 await emit({ type: "progress", id: empty.id, event: [-1, []], summary: [] });
180 await emit({
181 type: "progress",
182 id: descriptor.id,
183 event: [-1, { k: 1, t: "Rendering", v: 4, e: 10 }, [1]],
184 summary: descriptor.summary,
185 });
186
187 const section = container.querySelector("section");
188 await vi.waitFor(() => expect(section?.hidden).toBe(false));
189 expect(section?.children[0]?.getAttribute("hidden")).toBe("");
190 expect(section?.children[1]?.hasAttribute("hidden")).toBe(false);
191});