Skip to content

Commit 7e5a349

Browse files
authored
Merge pull request #4142 from sdornan/claude/scan-page-missing-active-display-30873c
fix(v2): show a running scan on pages loaded mid-scan
2 parents 0f9d2fc + 23d0042 commit 7e5a349

2 files changed

Lines changed: 289 additions & 2 deletions

File tree

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import { mount } from "@vue/test-utils";
2+
import { createPinia, setActivePinia } from "pinia";
3+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4+
import { defineComponent, reactive } from "vue";
5+
import type { ScanStats } from "@/__generated__";
6+
import taskApi from "@/services/api/task";
7+
import storeScanning from "@/stores/scanning";
8+
import { installScanLifecycle } from "./index";
9+
10+
// Minimal socket stand-in: records handlers so tests can fire events, and
11+
// stays "connected" so `useSocketEvent` never tries to dial out.
12+
const handlers = new Map<string, (payload: unknown) => void>();
13+
vi.mock("@/services/socket", () => ({
14+
default: {
15+
connected: true,
16+
connect: vi.fn(),
17+
on: (event: string, handler: (payload: unknown) => void) => {
18+
handlers.set(event, handler);
19+
},
20+
off: vi.fn(),
21+
},
22+
}));
23+
24+
vi.mock("@/services/api/task", () => ({
25+
default: { getTaskStatus: vi.fn() },
26+
}));
27+
28+
// Platform lookups are incidental here: `getPlatform` for platforms the
29+
// scanning store doesn't know yet, `getPlatforms` for the post-scan reconcile.
30+
vi.mock("@/services/api/platform", () => ({
31+
default: {
32+
getPlatform: vi.fn(() => Promise.resolve({ data: { id: 1 } })),
33+
getPlatforms: vi.fn(() => Promise.resolve({ data: [] })),
34+
},
35+
}));
36+
37+
// Reactive so the composable's `watch` on `authStore.user` fires when the
38+
// user arrives after install, which is the real flow: AppLayout installs
39+
// while /users/me is still in flight.
40+
const authState = reactive({
41+
user: { id: 1, oauth_scopes: ["tasks.run"] } as {
42+
id: number;
43+
oauth_scopes: string[];
44+
} | null,
45+
});
46+
vi.mock("@/stores/auth", () => ({
47+
default: () => authState,
48+
}));
49+
50+
const getTaskStatus = vi.mocked(taskApi.getTaskStatus);
51+
52+
/** Drain pending microtasks so the reconcile's promise chain has settled. */
53+
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0));
54+
55+
function makeStats(overrides: Partial<ScanStats> = {}): ScanStats {
56+
return {
57+
total_platforms: 0,
58+
total_roms: 0,
59+
scanned_platforms: 0,
60+
new_platforms: 0,
61+
identified_platforms: 0,
62+
scanned_roms: 0,
63+
new_roms: 0,
64+
identified_roms: 0,
65+
scanned_firmware: 0,
66+
new_firmware: 0,
67+
...overrides,
68+
};
69+
}
70+
71+
function runningScanTask(stats: ScanStats | null) {
72+
return {
73+
task_name: "scan_platforms",
74+
task_id: "job-1",
75+
status: "started",
76+
task_type: "scan",
77+
created_at: null,
78+
enqueued_at: null,
79+
started_at: null,
80+
ended_at: null,
81+
meta: { scan_stats: stats },
82+
};
83+
}
84+
85+
// The lifecycle uses `inject` and `onScopeDispose`, so it needs a host
86+
// component instance. Tracked so `afterEach` can unmount it: the auth state
87+
// is reactive and shared, so a leaked host would keep watching it and
88+
// reconcile again during later tests.
89+
let host: ReturnType<typeof mount> | null = null;
90+
91+
function install() {
92+
host = mount(
93+
defineComponent({
94+
setup() {
95+
installScanLifecycle();
96+
return () => null;
97+
},
98+
}),
99+
);
100+
}
101+
102+
function fire(event: string, payload: unknown) {
103+
handlers.get(event)?.(payload);
104+
}
105+
106+
describe("installScanLifecycle", () => {
107+
beforeEach(() => {
108+
setActivePinia(createPinia());
109+
handlers.clear();
110+
getTaskStatus.mockReset();
111+
getTaskStatus.mockResolvedValue({ data: [] } as never);
112+
authState.user = { id: 1, oauth_scopes: ["tasks.run"] };
113+
});
114+
115+
afterEach(() => {
116+
host?.unmount();
117+
host = null;
118+
});
119+
120+
it("treats a stats event as proof a scan is running", () => {
121+
install();
122+
const scanning = storeScanning();
123+
expect(scanning.scanning).toBe(false);
124+
125+
fire("scan:update_stats", makeStats({ scanned_roms: 12 }));
126+
127+
expect(scanning.scanning).toBe(true);
128+
expect(scanning.scanStats.scanned_roms).toBe(12);
129+
});
130+
131+
it("reconciles with a running scan job on install", async () => {
132+
getTaskStatus.mockResolvedValue({
133+
data: [runningScanTask(makeStats({ scanned_roms: 40, total_roms: 100 }))],
134+
} as never);
135+
136+
install();
137+
await flushPromises();
138+
139+
const scanning = storeScanning();
140+
expect(scanning.scanning).toBe(true);
141+
expect(scanning.scanStats.scanned_roms).toBe(40);
142+
expect(scanning.scanStats.total_roms).toBe(100);
143+
});
144+
145+
it("reconciles once the user arrives after install", async () => {
146+
// The real flow: AppLayout installs while /users/me is still in flight,
147+
// so `user` is null at install and the watch has to catch the arrival.
148+
authState.user = null;
149+
getTaskStatus.mockResolvedValue({
150+
data: [runningScanTask(makeStats({ scanned_roms: 7 }))],
151+
} as never);
152+
153+
install();
154+
await flushPromises();
155+
expect(getTaskStatus).not.toHaveBeenCalled();
156+
157+
authState.user = { id: 1, oauth_scopes: ["tasks.run"] };
158+
await flushPromises();
159+
160+
expect(getTaskStatus).toHaveBeenCalledTimes(1);
161+
const scanning = storeScanning();
162+
expect(scanning.scanning).toBe(true);
163+
expect(scanning.scanStats.scanned_roms).toBe(7);
164+
});
165+
166+
it("stays idle when no scan job is running", async () => {
167+
getTaskStatus.mockResolvedValue({
168+
data: [{ ...runningScanTask(makeStats()), status: "finished" }],
169+
} as never);
170+
171+
install();
172+
await flushPromises();
173+
174+
expect(storeScanning().scanning).toBe(false);
175+
});
176+
177+
it("skips the reconcile without the tasks.run scope", async () => {
178+
authState.user = { id: 1, oauth_scopes: ["platforms.write"] };
179+
180+
install();
181+
await flushPromises();
182+
183+
expect(getTaskStatus).not.toHaveBeenCalled();
184+
expect(storeScanning().scanning).toBe(false);
185+
});
186+
187+
it("does not resurrect a scan that ended while the reconcile was in flight", async () => {
188+
let resolveStatus!: (value: unknown) => void;
189+
getTaskStatus.mockReturnValue(
190+
new Promise((resolve) => {
191+
resolveStatus = resolve;
192+
}) as never,
193+
);
194+
195+
install();
196+
fire("scan:done", makeStats({ scanned_roms: 100 }));
197+
resolveStatus({ data: [runningScanTask(makeStats({ scanned_roms: 40 }))] });
198+
await flushPromises();
199+
200+
const scanning = storeScanning();
201+
expect(scanning.scanning).toBe(false);
202+
expect(scanning.scanStats.scanned_roms).toBe(100);
203+
});
204+
205+
it("lets live stats win over the job's snapshot", async () => {
206+
let resolveStatus!: (value: unknown) => void;
207+
getTaskStatus.mockReturnValue(
208+
new Promise((resolve) => {
209+
resolveStatus = resolve;
210+
}) as never,
211+
);
212+
213+
install();
214+
fire("scan:update_stats", makeStats({ scanned_roms: 90 }));
215+
resolveStatus({ data: [runningScanTask(makeStats({ scanned_roms: 40 }))] });
216+
await flushPromises();
217+
218+
expect(storeScanning().scanStats.scanned_roms).toBe(90);
219+
});
220+
});

frontend/src/v2/composables/useScanLifecycle/index.ts

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,20 @@
1818
// * `scan:done_ko` — scan errored; surface the message as a
1919
// snackbar and flip `scanning` off.
2020
//
21+
// Events alone can't tell a tab that loads mid-scan what's going on, so
22+
// install also reconciles against the running RQ job — see
23+
// `reconcileWithRunningScan` below.
24+
//
2125
// `useSocketEvent` is the typed subscription wrapper that auto-cleans up
2226
// on unmount; since AppLayout never unmounts during normal use the
2327
// listeners effectively live for the session.
2428
import { debounce } from "lodash";
2529
import type { Emitter } from "mitt";
26-
import { inject } from "vue";
27-
import type { ScanStats } from "@/__generated__";
30+
import { inject, watch } from "vue";
31+
import type { ScanStats, ScanTaskStatusResponse } from "@/__generated__";
2832
import platformApi from "@/services/api/platform";
33+
import taskApi from "@/services/api/task";
34+
import storeAuth from "@/stores/auth";
2935
import storePlatforms from "@/stores/platforms";
3036
import storeRoms, { type SimpleRom } from "@/stores/roms";
3137
import storeScanning, { type ScanningPlatform } from "@/stores/scanning";
@@ -38,6 +44,7 @@ export function installScanLifecycle() {
3844
const romsStore = storeRoms();
3945
const platformsStore = storePlatforms();
4046
const galleryRomsStore = storeGalleryRoms();
47+
const authStore = storeAuth();
4148
const emitter = inject<Emitter<Events>>("emitter");
4249

4350
useSocketEvent<ScanningPlatform>(
@@ -162,11 +169,18 @@ export function installScanLifecycle() {
162169
processRomUpdates();
163170
});
164171

172+
// Stats are the only event a scan emits continuously: `scanning_platform`
173+
// fires once per platform, and `scanning_rom` only for ROMs the scan
174+
// actually adds, so an update scan over a settled library can go a long
175+
// while emitting nothing else. Flipping `scanning` here is what lets a tab
176+
// that missed the start of the scan catch up on the next tick.
165177
useSocketEvent<ScanStats>("scan:update_stats", (stats) => {
178+
scanningStore.setScanning(true);
166179
scanningStore.setScanStats(stats);
167180
});
168181

169182
useSocketEvent<ScanStats>("scan:done", (stats) => {
183+
markScanEnded();
170184
scanningStore.setScanStats(stats);
171185
scanningStore.setScanning(false);
172186
// Reconcile against the backend once the scan settles: pick up anything
@@ -181,6 +195,7 @@ export function installScanLifecycle() {
181195
});
182196

183197
useSocketEvent<string>("scan:done_ko", (msg) => {
198+
markScanEnded();
184199
scanningStore.setScanning(false);
185200
emitter?.emit("snackbarShow", {
186201
msg: `Scan failed: ${msg}`,
@@ -189,4 +204,56 @@ export function installScanLifecycle() {
189204
timeout: 6000,
190205
});
191206
});
207+
208+
// Reconcile with the scan the server is actually running. Without this a
209+
// tab that loads mid-scan (refresh, second tab, another device) shows the
210+
// /scan empty state and an armed "Start scan" button until an event lands.
211+
//
212+
// `/tasks/status` needs `tasks.run`, the same scope the `scan` socket
213+
// handler gates on, so anyone who could have started this scan can read it
214+
// back. Users without it stay purely event-driven.
215+
let sawScanEnd = false;
216+
function markScanEnded() {
217+
sawScanEnd = true;
218+
}
219+
220+
// Reconciles once per eligible user: collapsing the source to an id keeps
221+
// unrelated profile updates from re-firing it, and re-arms if the scope
222+
// shows up later.
223+
watch(
224+
() =>
225+
authStore.user?.oauth_scopes.includes("tasks.run")
226+
? authStore.user.id
227+
: null,
228+
(userId) => {
229+
if (userId === null) return;
230+
sawScanEnd = false;
231+
reconcileWithRunningScan();
232+
},
233+
{ immediate: true },
234+
);
235+
236+
function reconcileWithRunningScan() {
237+
taskApi
238+
.getTaskStatus()
239+
.then(({ data }) => {
240+
// A terminal event that landed while the request was in flight means
241+
// the job we asked about is already over; don't resurrect it. Same for
242+
// stats already streaming in, which are fresher than the job's meta.
243+
if (sawScanEnd || scanningStore.scanning) return;
244+
const running = data.find(
245+
(task): task is ScanTaskStatusResponse =>
246+
task.task_type === "scan" && task.status === "started",
247+
);
248+
if (!running) return;
249+
scanningStore.setScanning(true);
250+
// The per-platform live log only ever lived in the originating tab's
251+
// memory, so the panel list fills in from the next platform onward.
252+
// `meta` is typed as required but this is a JSON boundary: a missing
253+
// snapshot just means no counters yet, not "no scan".
254+
if (running.meta?.scan_stats)
255+
scanningStore.setScanStats(running.meta.scan_stats);
256+
})
257+
.catch((error) => console.error(error));
258+
}
192259
}

0 commit comments

Comments
 (0)