@hoardodile/sdk-react 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,118 @@
1
+ import { act } from "react"
2
+ import { createRoot, type Root } from "react-dom/client"
3
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"
4
+ import { StubPluginAPIProvider } from "./fixtures.tsx"
5
+ import { useExtractProgress } from "./use-extract-progress.ts"
6
+
7
+ function jsonResponse(payload: unknown): Response {
8
+ return { json: async () => payload } as Response
9
+ }
10
+
11
+ function renderHarness(progressUrl: () => string = () => "/progress") {
12
+ const container = document.createElement("div")
13
+ document.body.appendChild(container)
14
+ let root: Root | undefined
15
+ act(() => {
16
+ root = createRoot(container)
17
+ root.render(
18
+ <StubPluginAPIProvider api={{ extractProgressUrl: progressUrl }}>
19
+ <ProgressProbe />
20
+ </StubPluginAPIProvider>,
21
+ )
22
+ })
23
+ return { container, root }
24
+ }
25
+
26
+ function ProgressProbe() {
27
+ const progress = useExtractProgress()
28
+ return <div data-testid="state">{JSON.stringify(progress)}</div>
29
+ }
30
+
31
+ function stateOf(container: HTMLElement): Record<string, unknown> {
32
+ return JSON.parse(
33
+ container.querySelector("[data-testid='state']")!.textContent!,
34
+ )
35
+ }
36
+
37
+ describe("useExtractProgress", () => {
38
+ beforeEach(() => {
39
+ vi.useFakeTimers()
40
+ })
41
+
42
+ afterEach(() => {
43
+ vi.useRealTimers()
44
+ vi.unstubAllGlobals()
45
+ document.body.innerHTML = ""
46
+ })
47
+
48
+ test("starts idle and stays idle while the host reports nothing", async () => {
49
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse(null))
50
+ vi.stubGlobal("fetch", fetchMock)
51
+
52
+ const { container, root } = renderHarness()
53
+ await act(async () => {})
54
+ expect(stateOf(container)).toEqual({ state: "idle" })
55
+
56
+ await act(async () => {
57
+ vi.advanceTimersByTime(1000)
58
+ })
59
+ expect(stateOf(container)).toEqual({ state: "idle" })
60
+ root?.unmount()
61
+ })
62
+
63
+ test("reports in-flight materialization", async () => {
64
+ const fetchMock = vi
65
+ .fn()
66
+ .mockResolvedValue(jsonResponse({ done: 3, total: 10 }))
67
+ vi.stubGlobal("fetch", fetchMock)
68
+
69
+ const { container, root } = renderHarness()
70
+ await act(async () => {})
71
+ expect(stateOf(container)).toEqual({
72
+ state: "extracting",
73
+ done: 3,
74
+ total: 10,
75
+ })
76
+
77
+ await act(async () => {
78
+ vi.advanceTimersByTime(600)
79
+ })
80
+ expect(stateOf(container)).toEqual({
81
+ state: "extracting",
82
+ done: 3,
83
+ total: 10,
84
+ })
85
+ root?.unmount()
86
+ })
87
+
88
+ test("turns done once progress was seen and the record went idle", async () => {
89
+ const fetchMock = vi
90
+ .fn()
91
+ .mockResolvedValueOnce(jsonResponse({ done: 5, total: 10 }))
92
+ .mockResolvedValue(jsonResponse(null))
93
+ vi.stubGlobal("fetch", fetchMock)
94
+
95
+ const { container, root } = renderHarness()
96
+ await act(async () => {})
97
+ expect(stateOf(container)).toEqual({
98
+ state: "extracting",
99
+ done: 5,
100
+ total: 10,
101
+ })
102
+
103
+ await act(async () => {
104
+ vi.advanceTimersByTime(300)
105
+ })
106
+ expect(stateOf(container)).toEqual({ state: "done" })
107
+ root?.unmount()
108
+ })
109
+
110
+ test("treats a failed poll as idle", async () => {
111
+ vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network")))
112
+
113
+ const { container, root } = renderHarness()
114
+ await act(async () => {})
115
+ expect(stateOf(container)).toEqual({ state: "idle" })
116
+ root?.unmount()
117
+ })
118
+ })
@@ -0,0 +1,76 @@
1
+ import { useEffect, useRef, useState } from "react"
2
+ import { usePluginAPI } from "./context.tsx"
3
+
4
+ /**
5
+ * Polling interval for {@link useExtractProgress}. The host's progress
6
+ * record lives in memory with a short TTL, so the poll must be tight
7
+ * enough to catch a row before it expires.
8
+ */
9
+ const POLL_INTERVAL_MS = 300
10
+
11
+ /**
12
+ * Materialization progress of the plugin's `extractArchive` hook:
13
+ * `"extracting"` while the host reports in-flight work, `"done"` once
14
+ * progress was seen and the record went idle again, `"idle"` when no
15
+ * extraction has ever been observed (or a poll failed — the host may
16
+ * not be serving yet).
17
+ */
18
+ export type ExtractProgressState =
19
+ | { readonly state: "idle" }
20
+ | {
21
+ readonly state: "extracting"
22
+ readonly done: number
23
+ readonly total: number
24
+ }
25
+ | { readonly state: "done" }
26
+
27
+ /**
28
+ * Reactive materialization progress for the current resource. Polls
29
+ * `api.extractProgressUrl()` and tracks the seen-progress transition:
30
+ * a plugin that called `extractArchive` can show "extracting" while the
31
+ * host materializes, then switch to "done" when the record expires.
32
+ */
33
+ export function useExtractProgress(): ExtractProgressState {
34
+ const api = usePluginAPI()
35
+ const [state, setState] = useState<ExtractProgressState>({ state: "idle" })
36
+ const seenProgress = useRef(false)
37
+
38
+ useEffect(
39
+ function pollProgress() {
40
+ let cancelled = false
41
+ seenProgress.current = false
42
+ setState({ state: "idle" })
43
+
44
+ async function poll(): Promise<void> {
45
+ let payload: unknown
46
+ try {
47
+ const response = await fetch(api.extractProgressUrl())
48
+ payload = await response.json()
49
+ } catch {
50
+ // A failed poll (host not serving yet) is treated as idle.
51
+ payload = null
52
+ }
53
+ if (cancelled) return
54
+ if (payload !== null && typeof payload === "object") {
55
+ const { done, total } = payload as Record<string, unknown>
56
+ if (typeof done === "number" && typeof total === "number") {
57
+ seenProgress.current = true
58
+ setState({ state: "extracting", done, total })
59
+ return
60
+ }
61
+ }
62
+ setState(seenProgress.current ? { state: "done" } : { state: "idle" })
63
+ }
64
+
65
+ void poll()
66
+ const timer = setInterval(() => void poll(), POLL_INTERVAL_MS)
67
+ return () => {
68
+ cancelled = true
69
+ clearInterval(timer)
70
+ }
71
+ },
72
+ [api],
73
+ )
74
+
75
+ return state
76
+ }