| 1 | // @vitest-environment jsdom |
| 2 | |
| 3 | import { act } from "react"; |
| 4 | import { createRoot, type Root } from "react-dom/client"; |
| 5 | import { AsyncResult, Atom } from "effect/reactivity"; |
| 6 | import * as Stream from "effect/Stream"; |
| 7 | import { |
| 8 | EnvironmentId, |
| 9 | ThreadId, |
| 10 | type ScriptProgressEvent, |
| 11 | type ScriptProgressStream, |
| 12 | } from "@t3tools/contracts"; |
| 13 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; |
| 14 | |
| 15 | import { appAtomRegistry } from "../../rpc/atomRegistry"; |
| 16 | import { ScriptProgress } from "./ScriptProgress"; |
| 17 | |
| 18 | type ProgressResult = AsyncResult.AsyncResult<{ |
| 19 | events: ReadonlyArray<ScriptProgressEvent>; |
| 20 | streams: ReadonlyArray<ScriptProgressStream>; |
| 21 | }>; |
| 22 | |
| 23 | const state = vi.hoisted(() => ({ atom: undefined as unknown })); |
| 24 | vi.mock("../../state/scriptProgress", () => ({ |
| 25 | scriptProgressEnvironment: { watch: () => state.atom }, |
| 26 | })); |
| 27 | |
| 28 | let root: Root; |
| 29 | let container: HTMLDivElement; |
| 30 | let atom: Atom.Writable<ProgressResult, ProgressResult>; |
| 31 | const 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 | |
| 39 | beforeEach(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 | |
| 57 | afterEach(async () => { |
| 58 | await act(async () => root.unmount()); |
| 59 | container.remove(); |
| 60 | vi.unstubAllGlobals(); |
| 61 | }); |
| 62 | |
| 63 | async function emit(event: ScriptProgressEvent) { |
| 64 | await act(async () => { |
| 65 | appAtomRegistry.set(atom, AsyncResult.success({ events: [event], streams: [] })); |
| 66 | }); |
| 67 | } |
| 68 | |
| 69 | function progressbar() { |
| 70 | const tree = container.querySelector("[data-progress-motion]"); |
| 71 | return tree?.shadowRoot?.querySelector('[role="progressbar"]'); |
| 72 | } |
| 73 | |
| 74 | it("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 | |
| 96 | it("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 | |
| 114 | it("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 | |
| 158 | it("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 | |
| 176 | it("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 | }); |