Skip to content

Commit a698694

Browse files
authored
Merge pull request #388 from os2display/feature/spinner-on-retrieving-bind-key
Added spinner on retrieving bind key in the client
2 parents fb6b1b2 + f157e98 commit a698694

4 files changed

Lines changed: 319 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ All notable changes to this project will be documented in this file.
3636
- Removed fixture length check from test.
3737
- Fixed video overflow.
3838
- Added vitest for frontend unit tests.
39+
- Added spinner when retrieving bind key.
3940
- Added BRND to feed source admin dropdown.
4041
- Upgraded to PHP 8.4.
4142
- Changed default CLIENT_PULL_STRATEGY_INTERVAL value to 10 minutes.

assets/client/app.jsx

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ function App({ preview, previewId }) {
2828
const [bindKey, setBindKey] = useState(null);
2929
const [displayFallback, setDisplayFallback] = useState(true);
3030
const [debug, setDebug] = useState(false);
31+
const [retrievingBindKey, setRetrievingBindKey] = useState(true);
3132

3233
const checkLoginTimeoutRef = useRef(null);
3334
const contentServiceRef = useRef(null);
@@ -71,6 +72,7 @@ function App({ preview, previewId }) {
7172

7273
setBindKey(null);
7374
setRunning(true);
75+
setRetrievingBindKey(false);
7476

7577
contentServiceRef.current = new ContentService();
7678

@@ -119,17 +121,31 @@ function App({ preview, previewId }) {
119121
tokenService
120122
.checkLogin()
121123
.then((data) => {
122-
if (data.status === constants.LOGIN_STATUS_READY) {
123-
startContent(data.screenId);
124-
} else if (data.status === constants.LOGIN_STATUS_AWAITING_BIND_KEY) {
125-
if (data?.bindKey) {
126-
setBindKey(data.bindKey);
127-
}
128-
129-
restartLoginTimeout();
124+
switch (data.status) {
125+
case constants.LOGIN_STATUS_READY:
126+
setRetrievingBindKey(false);
127+
startContent(data.screenId);
128+
break;
129+
case constants.LOGIN_STATUS_AWAITING_BIND_KEY:
130+
setRetrievingBindKey(false);
131+
132+
if (data?.bindKey) {
133+
setBindKey(data.bindKey);
134+
}
135+
136+
restartLoginTimeout();
137+
break;
138+
case constants.LOGIN_STATUS_UNKNOWN:
139+
default:
140+
// retrievingBindKey intentionally not reset — restartLoginTimeout
141+
// retries checkLogin, so we are still retrieving.
142+
restartLoginTimeout();
143+
break;
130144
}
131145
})
132146
.catch(() => {
147+
// retrievingBindKey intentionally not reset — restartLoginTimeout
148+
// retries checkLogin, so we are still retrieving.
133149
restartLoginTimeout();
134150
});
135151
}
@@ -162,7 +178,9 @@ function App({ preview, previewId }) {
162178
}
163179

164180
setScreen(null);
181+
setBindKey(null);
165182
setRunning(false);
183+
setRetrievingBindKey(true);
166184

167185
tokenService.stopRefreshing();
168186

@@ -285,6 +303,11 @@ function App({ preview, previewId }) {
285303
{displayFallback && !bindKey && (
286304
<div className="fallback" style={fallbackStyle} />
287305
)}
306+
{!preview && retrievingBindKey && !bindKey && (
307+
<div className="retrieving-bind-key-container">
308+
<div className="retrieving-bind-key-spinner" />
309+
</div>
310+
)}
288311
</div>
289312
);
290313
}

assets/client/app.scss

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,27 @@ body {
5252
}
5353
}
5454

55+
.retrieving-bind-key-container {
56+
position: absolute;
57+
bottom: 10px;
58+
right: 10px;
59+
60+
.retrieving-bind-key-spinner {
61+
width: 2rem;
62+
height: 2rem;
63+
border: 0.25em solid rgba(255, 255, 255, 0.25);
64+
border-top-color: white;
65+
border-radius: 50%;
66+
animation: bind-key-spin 0.75s linear infinite;
67+
}
68+
}
69+
70+
@keyframes bind-key-spin {
71+
to {
72+
transform: rotate(360deg);
73+
}
74+
}
75+
5576
.ql-editor {
5677
min-height: 200px;
5778
}
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import { render, act, screen, cleanup } from "@testing-library/react";
3+
4+
const { mockCheckLogin, mockRefreshToken } = vi.hoisted(() => ({
5+
mockCheckLogin: vi.fn(),
6+
mockRefreshToken: vi.fn(),
7+
}));
8+
9+
vi.mock("../../client/logger/logger", () => ({
10+
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
11+
}));
12+
13+
vi.mock("../../client/util/app-storage", () => ({
14+
default: {
15+
getToken: vi.fn(() => null),
16+
getRefreshToken: vi.fn(() => null),
17+
getScreenId: vi.fn(() => null),
18+
getTokenExpire: vi.fn(() => null),
19+
getTokenIssueAt: vi.fn(() => null),
20+
getFallbackImageUrl: vi.fn(() => null),
21+
setToken: vi.fn(),
22+
setRefreshToken: vi.fn(),
23+
setScreenId: vi.fn(),
24+
setTenant: vi.fn(),
25+
setPreviousBoot: vi.fn(),
26+
clearToken: vi.fn(),
27+
clearRefreshToken: vi.fn(),
28+
clearScreenId: vi.fn(),
29+
clearTenant: vi.fn(),
30+
clearFallbackImageUrl: vi.fn(),
31+
clearAppStorage: vi.fn(),
32+
},
33+
}));
34+
35+
vi.mock("../../client/service/token-service", () => ({
36+
default: {
37+
checkLogin: mockCheckLogin,
38+
refreshToken: mockRefreshToken,
39+
startRefreshing: vi.fn(),
40+
stopRefreshing: vi.fn(),
41+
checkToken: vi.fn(),
42+
},
43+
}));
44+
45+
vi.mock("../../client/service/status-service", () => ({
46+
default: {
47+
setStatus: vi.fn(),
48+
setError: vi.fn(),
49+
setStatusInUrl: vi.fn(),
50+
error: null,
51+
},
52+
}));
53+
54+
vi.mock("../../client/service/release-service", () => ({
55+
default: {
56+
checkForNewRelease: vi.fn(() => Promise.resolve()),
57+
setPreviousBootInUrl: vi.fn(),
58+
startReleaseCheck: vi.fn(),
59+
stopReleaseCheck: vi.fn(),
60+
setScreenIdInUrl: vi.fn(),
61+
},
62+
}));
63+
64+
vi.mock("../../client/service/tenant-service", () => ({
65+
default: { loadTenantConfig: vi.fn() },
66+
}));
67+
68+
vi.mock("../../client/util/client-config-loader.js", () => ({
69+
default: {
70+
loadConfig: vi.fn(() => Promise.resolve({ loginCheckTimeout: 50 })),
71+
},
72+
}));
73+
74+
vi.mock("../../client/service/content-service", () => {
75+
return {
76+
default: vi.fn().mockImplementation(function () {
77+
this.start = vi.fn();
78+
this.stop = vi.fn();
79+
}),
80+
};
81+
});
82+
83+
vi.mock("../../client/app.scss", () => ({}));
84+
vi.mock("../../client/assets/fallback.png", () => ({ default: "" }));
85+
vi.mock("../../client/components/screen.jsx", () => ({
86+
default: () => <div data-testid="screen" />,
87+
}));
88+
89+
import App from "../../client/app.jsx";
90+
91+
/**
92+
* Flush microtasks, advance timers, flush again.
93+
* restartLoginTimeout creates setTimeout inside a .then() on loadConfig —
94+
* microtasks must resolve first for the timer to exist.
95+
*/
96+
async function tick(ms) {
97+
await act(async () => {});
98+
await act(async () => {
99+
vi.advanceTimersByTime(ms);
100+
});
101+
await act(async () => {});
102+
}
103+
104+
describe("checkLogin retries on unknown status", () => {
105+
beforeEach(() => {
106+
vi.useFakeTimers();
107+
mockCheckLogin.mockReset();
108+
mockRefreshToken.mockReset();
109+
});
110+
111+
afterEach(() => {
112+
cleanup();
113+
vi.useRealTimers();
114+
});
115+
116+
it("continues login polling when checkLogin returns unknown status", async () => {
117+
// The initial checkLogin (on mount) returns unknown status.
118+
// Without the fix, the polling loop stops dead — no restartLoginTimeout.
119+
// With the fix, unknown triggers restartLoginTimeout and the second
120+
// call returns awaitingBindKey, showing the bind key.
121+
mockCheckLogin
122+
.mockResolvedValueOnce({ status: "unknown" })
123+
.mockResolvedValueOnce({ status: "awaitingBindKey", bindKey: "BIND42" })
124+
.mockResolvedValue({ status: "awaitingBindKey", bindKey: "BIND42" });
125+
126+
const { container } = render(<App preview={null} previewId={null} />);
127+
128+
// Mount: releaseService.checkForNewRelease resolves → checkLogin (call 1)
129+
// → unknown status → with fix: restartLoginTimeout → 50ms timer.
130+
await tick(0);
131+
132+
// Bind key should NOT be visible yet (unknown status, no bind key set).
133+
expect(screen.queryByText("BIND42")).not.toBeInTheDocument();
134+
// Spinner should still be visible — we are still retrieving.
135+
expect(
136+
container.querySelector(".retrieving-bind-key-spinner"),
137+
).toBeInTheDocument();
138+
139+
// Advance past loginCheckTimeout to fire the retry timer.
140+
// Call 2 → awaitingBindKey "BIND42".
141+
await tick(60);
142+
143+
// With the fix, the login loop retried and the bind key is now shown.
144+
expect(screen.getByText("BIND42")).toBeInTheDocument();
145+
// Spinner gone — bind key received.
146+
expect(
147+
container.querySelector(".retrieving-bind-key-spinner"),
148+
).not.toBeInTheDocument();
149+
});
150+
});
151+
152+
describe("reauthenticateHandler shows spinner while retrieving bind key", () => {
153+
beforeEach(() => {
154+
vi.useFakeTimers();
155+
mockCheckLogin.mockReset();
156+
mockRefreshToken.mockReset();
157+
});
158+
159+
afterEach(() => {
160+
cleanup();
161+
vi.useRealTimers();
162+
});
163+
164+
it("recovers from reauth failure and shows new bind key", async () => {
165+
// Call 1 (mount): normal login with bind key.
166+
// Call 2 (reauth → checkLogin): returns new bind key after refresh
167+
// token failure triggers full re-login.
168+
mockCheckLogin
169+
.mockResolvedValueOnce({ status: "awaitingBindKey", bindKey: "INIT01" })
170+
.mockResolvedValue({ status: "awaitingBindKey", bindKey: "REAUTH01" });
171+
172+
// refreshToken will fail when reauthenticate fires.
173+
mockRefreshToken.mockRejectedValue(new Error("token expired"));
174+
175+
const { container } = render(<App preview={null} previewId={null} />);
176+
177+
// Let mount complete.
178+
await tick(0);
179+
expect(screen.getByText("INIT01")).toBeInTheDocument();
180+
// Spinner hidden — bind key is shown.
181+
expect(
182+
container.querySelector(".retrieving-bind-key-spinner"),
183+
).not.toBeInTheDocument();
184+
185+
// Fire reauthenticate event — refreshToken rejects → catch block
186+
// clears screen/bindKey, sets retrievingBindKey, calls checkLogin
187+
// which resolves with new bind key.
188+
await act(async () => {
189+
document.dispatchEvent(new Event("reauthenticate"));
190+
});
191+
await tick(60);
192+
193+
// Old bind key gone, new bind key visible — proves the full reauth
194+
// recovery flow works: clear state → checkLogin → new bind key.
195+
expect(screen.queryByText("INIT01")).not.toBeInTheDocument();
196+
expect(screen.getByText("REAUTH01")).toBeInTheDocument();
197+
// Spinner gone after recovery.
198+
expect(
199+
container.querySelector(".retrieving-bind-key-spinner"),
200+
).not.toBeInTheDocument();
201+
});
202+
});
203+
204+
describe("spinner persists during checkLogin retry on failure", () => {
205+
beforeEach(() => {
206+
vi.useFakeTimers();
207+
mockCheckLogin.mockReset();
208+
mockRefreshToken.mockReset();
209+
});
210+
211+
afterEach(() => {
212+
cleanup();
213+
vi.useRealTimers();
214+
});
215+
216+
it("keeps spinner visible when checkLogin rejects", async () => {
217+
// First call rejects (network error), retry succeeds with bind key.
218+
mockCheckLogin
219+
.mockRejectedValueOnce(new Error("Network error"))
220+
.mockResolvedValueOnce({ status: "awaitingBindKey", bindKey: "RETRY01" })
221+
.mockResolvedValue({ status: "awaitingBindKey", bindKey: "RETRY01" });
222+
223+
const { container } = render(<App preview={null} previewId={null} />);
224+
225+
await tick(0);
226+
227+
// checkLogin rejected → catch → restartLoginTimeout.
228+
// retrievingBindKey was never reset, so spinner is still visible.
229+
expect(
230+
container.querySelector(".retrieving-bind-key-spinner"),
231+
).toBeInTheDocument();
232+
233+
// Advance past the retry timeout.
234+
await tick(60);
235+
236+
// Retry succeeded with bind key — spinner gone.
237+
expect(
238+
container.querySelector(".retrieving-bind-key-spinner"),
239+
).not.toBeInTheDocument();
240+
expect(screen.getByText("RETRY01")).toBeInTheDocument();
241+
});
242+
});
243+
244+
describe("spinner not shown in preview mode", () => {
245+
beforeEach(() => {
246+
vi.useFakeTimers();
247+
mockCheckLogin.mockReset();
248+
mockRefreshToken.mockReset();
249+
});
250+
251+
afterEach(() => {
252+
cleanup();
253+
vi.useRealTimers();
254+
});
255+
256+
it("does not show spinner when preview is set", async () => {
257+
const { container } = render(<App preview="slide" previewId="some-id" />);
258+
259+
await tick(0);
260+
261+
// Even though retrievingBindKey starts true, preview mode suppresses it.
262+
expect(
263+
container.querySelector(".retrieving-bind-key-spinner"),
264+
).not.toBeInTheDocument();
265+
});
266+
});

0 commit comments

Comments
 (0)