| 1 | import * as NodeModule from "node:module"; |
| 2 | import { act, createElement, type ReactNode } from "react"; |
| 3 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; |
| 4 | |
| 5 | import { useTerminalLifecycle } from "./useTerminalLifecycle"; |
| 6 | |
| 7 | const { createRoot } = NodeModule.createRequire(import.meta.url)("react-dom/client") as { |
| 8 | createRoot(container: Element): { |
| 9 | render(children: ReactNode): void; |
| 10 | unmount(): void; |
| 11 | }; |
| 12 | }; |
| 13 | |
| 14 | type Input = Parameters<typeof useTerminalLifecycle>[0]; |
| 15 | let root: ReturnType<typeof createRoot>; |
| 16 | const reopen = vi.fn<Input["reopen"]>(); |
| 17 | const onRunning = vi.fn<Input["onRunning"]>(); |
| 18 | const onExit = vi.fn<Input["onExit"]>(); |
| 19 | |
| 20 | function LifecycleProbe(input: Input) { |
| 21 | useTerminalLifecycle(input); |
| 22 | return null; |
| 23 | } |
| 24 | |
| 25 | function input(changes: Partial<Input> = {}): Input { |
| 26 | return { |
| 27 | terminalKey: "environment:thread:term-1", |
| 28 | canOperate: true, |
| 29 | observing: false, |
| 30 | attached: true, |
| 31 | terminal: { status: "running", version: 1 }, |
| 32 | reopen, |
| 33 | onRunning, |
| 34 | onExit, |
| 35 | ...changes, |
| 36 | }; |
| 37 | } |
| 38 | |
| 39 | function render(value: Input) { |
| 40 | return act(() => root.render(createElement(LifecycleProbe, value))); |
| 41 | } |
| 42 | |
| 43 | beforeEach(() => { |
| 44 | reopen.mockReset().mockResolvedValue(true); |
| 45 | onRunning.mockClear(); |
| 46 | onExit.mockClear(); |
| 47 | const document = { |
| 48 | nodeType: 9, |
| 49 | addEventListener() {}, |
| 50 | removeEventListener() {}, |
| 51 | }; |
| 52 | const container = { |
| 53 | nodeType: 1, |
| 54 | tagName: "DIV", |
| 55 | namespaceURI: "http://www.w3.org/1999/xhtml", |
| 56 | ownerDocument: document, |
| 57 | addEventListener() {}, |
| 58 | removeEventListener() {}, |
| 59 | }; |
| 60 | vi.stubGlobal("document", document); |
| 61 | vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget }); |
| 62 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); |
| 63 | root = createRoot(container as unknown as HTMLElement); |
| 64 | }); |
| 65 | |
| 66 | afterEach(async () => { |
| 67 | await act(() => root.unmount()); |
| 68 | vi.unstubAllGlobals(); |
| 69 | }); |
| 70 | |
| 71 | it.each(["running", "closed", "exited"] as const)( |
| 72 | "waits for the replacement attachment snapshot before handling %s", |
| 73 | async (status) => { |
| 74 | await render(input()); |
| 75 | await render(input({ terminal: { status: "closed", version: 0 } })); |
| 76 | expect(onExit).not.toHaveBeenCalled(); |
| 77 | expect(reopen).not.toHaveBeenCalled(); |
| 78 | |
| 79 | await render(input({ terminal: { status, version: 1 } })); |
| 80 | expect(onExit).toHaveBeenCalledTimes(status === "running" ? 0 : 1); |
| 81 | expect(reopen).not.toHaveBeenCalled(); |
| 82 | }, |
| 83 | ); |
| 84 | |
| 85 | it("retains an exited observer's history after gaining operate", async () => { |
| 86 | await render(input({ canOperate: false, terminal: { status: "exited", version: 1 } })); |
| 87 | await render(input({ terminal: { status: "closed", version: 0 } })); |
| 88 | await render(input({ terminal: { status: "exited", version: 1 } })); |
| 89 | expect(reopen).not.toHaveBeenCalled(); |
| 90 | expect(onExit).not.toHaveBeenCalled(); |
| 91 | }); |
| 92 | |
| 93 | it.each(["closed", "exited"] as const)( |
| 94 | "keeps a pending observer passive when its first writable snapshot is %s", |
| 95 | async (status) => { |
| 96 | await render( |
| 97 | input({ |
| 98 | canOperate: false, |
| 99 | observing: true, |
| 100 | terminal: { status, version: 0 }, |
| 101 | }), |
| 102 | ); |
| 103 | await render(input({ terminal: { status: "closed", version: 0 } })); |
| 104 | await render(input({ terminal: { status, version: 1 } })); |
| 105 | expect(reopen).not.toHaveBeenCalled(); |
| 106 | expect(onExit).not.toHaveBeenCalled(); |
| 107 | expect(onRunning).not.toHaveBeenCalled(); |
| 108 | }, |
| 109 | ); |
| 110 | |
| 111 | it("reopens an explicit operator visit after permissions finish loading", async () => { |
| 112 | await render( |
| 113 | input({ |
| 114 | canOperate: false, |
| 115 | observing: false, |
| 116 | terminal: { status: "closed", version: 0 }, |
| 117 | }), |
| 118 | ); |
| 119 | await render(input({ terminal: { status: "closed", version: 0 } })); |
| 120 | await render(input({ terminal: { status: "exited", version: 1 } })); |
| 121 | expect(reopen).toHaveBeenCalledOnce(); |
| 122 | expect(onExit).not.toHaveBeenCalled(); |
| 123 | }); |
| 124 | |
| 125 | it("does not respawn a terminal that exited after operate was revoked", async () => { |
| 126 | await render(input()); |
| 127 | await render(input({ canOperate: false })); |
| 128 | await render(input({ canOperate: false, terminal: { status: "exited", version: 2 } })); |
| 129 | await render(input({ terminal: { status: "exited", version: 1 } })); |
| 130 | expect(reopen).not.toHaveBeenCalled(); |
| 131 | expect(onExit).not.toHaveBeenCalled(); |
| 132 | }); |
| 133 | |
| 134 | it("remembers revocation before the observer has received a snapshot", async () => { |
| 135 | await render(input()); |
| 136 | await render(input({ canOperate: false, terminal: { status: "closed", version: 0 } })); |
| 137 | await render(input({ terminal: { status: "exited", version: 1 } })); |
| 138 | expect(reopen).not.toHaveBeenCalled(); |
| 139 | expect(onExit).not.toHaveBeenCalled(); |
| 140 | }); |
| 141 | |
| 142 | it("cleans up an actual exit after an observed running terminal becomes writable", async () => { |
| 143 | await render(input({ canOperate: false })); |
| 144 | await render(input()); |
| 145 | await render(input({ terminal: { status: "exited", version: 2 } })); |
| 146 | await render(input({ terminal: { status: "closed", version: 3 } })); |
| 147 | expect(onExit).toHaveBeenCalledOnce(); |
| 148 | expect(reopen).not.toHaveBeenCalled(); |
| 149 | }); |
| 150 | |
| 151 | it("reopens an ended terminal on an explicit new route visit", async () => { |
| 152 | await render(input({ canOperate: false, terminal: { status: "exited", version: 1 } })); |
| 153 | await render(input({ terminalKey: "environment:thread:term-2" })); |
| 154 | await render(input({ terminal: { status: "exited", version: 1 } })); |
| 155 | expect(reopen).toHaveBeenCalledOnce(); |
| 156 | |
| 157 | await render(input({ terminal: { status: "exited", version: 2 } })); |
| 158 | expect(reopen).toHaveBeenCalledOnce(); |
| 159 | await render(input({ terminal: { status: "running", version: 3 } })); |
| 160 | await render(input({ terminal: { status: "exited", version: 4 } })); |
| 161 | expect(onExit).toHaveBeenCalledOnce(); |
| 162 | }); |
| 163 | |
| 164 | it("keeps a detached terminal from being mistaken for a live exit", async () => { |
| 165 | await render(input()); |
| 166 | await render(input({ attached: false, terminal: { status: "closed", version: 0 } })); |
| 167 | await render(input({ terminal: { status: "exited", version: 1 } })); |
| 168 | expect(onExit).not.toHaveBeenCalled(); |
| 169 | expect(reopen).toHaveBeenCalledOnce(); |
| 170 | }); |
| 171 | |
| 172 | it("allows a later snapshot to retry an unsuccessful explicit reopen", async () => { |
| 173 | reopen.mockResolvedValueOnce(false); |
| 174 | await render(input({ terminal: { status: "exited", version: 1 } })); |
| 175 | await render(input({ terminal: { status: "exited", version: 2 } })); |
| 176 | expect(reopen).toHaveBeenCalledTimes(2); |
| 177 | }); |