@learnpack/learnpack 5.0.361 → 5.0.363

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.
Files changed (41) hide show
  1. package/lib/commands/serve.js +44 -7
  2. package/lib/creatorDist/assets/{index-B7s5Nmq8.css → index-Cco3XhDR.css} +96 -0
  3. package/lib/creatorDist/assets/{index-CqVEKiJR.js → index-o3Cw0bsF.js} +69682 -66808
  4. package/lib/creatorDist/index.html +2 -2
  5. package/lib/utils/api.d.ts +0 -9
  6. package/lib/utils/api.js +1 -65
  7. package/lib/utils/lessonRecovery.d.ts +18 -0
  8. package/lib/utils/lessonRecovery.js +31 -0
  9. package/package.json +1 -1
  10. package/src/commands/serve.ts +65 -18
  11. package/src/creator/package-lock.json +297 -3
  12. package/src/creator/package.json +1 -0
  13. package/src/creator/src/App.test.tsx +124 -1
  14. package/src/creator/src/App.tsx +52 -36
  15. package/src/creator/src/components/FileCard.tsx +63 -19
  16. package/src/creator/src/components/FileReadListener.test.tsx +128 -0
  17. package/src/creator/src/components/FileReadListener.tsx +75 -0
  18. package/src/creator/src/components/FileReviewModal.test.tsx +193 -0
  19. package/src/creator/src/components/FileReviewModal.tsx +128 -0
  20. package/src/creator/src/components/FileUploader.test.tsx +229 -0
  21. package/src/creator/src/components/FileUploader.tsx +212 -58
  22. package/src/creator/src/components/GenerationErrorCard.tsx +57 -0
  23. package/src/creator/src/components/MarkdownRenderer.tsx +4 -2
  24. package/src/creator/src/components/syllabus/SyllabusEditor.tsx +6 -9
  25. package/src/creator/src/index.css +45 -0
  26. package/src/creator/src/locales/en.json +27 -5
  27. package/src/creator/src/locales/es.json +27 -5
  28. package/src/creator/src/main.tsx +2 -0
  29. package/src/creator/src/utils/completionResult.test.ts +61 -0
  30. package/src/creator/src/utils/completionResult.ts +61 -2
  31. package/src/creator/src/utils/courseLanguage.test.ts +6 -0
  32. package/src/creator/src/utils/creationRequest.test.ts +53 -18
  33. package/src/creator/src/utils/creationRequest.ts +17 -12
  34. package/src/creator/src/utils/fileRead.ts +60 -0
  35. package/src/creator/src/utils/lib.ts +0 -5
  36. package/src/creator/src/utils/store.ts +0 -10
  37. package/src/creatorDist/assets/{index-B7s5Nmq8.css → index-Cco3XhDR.css} +96 -0
  38. package/src/creatorDist/assets/{index-CqVEKiJR.js → index-o3Cw0bsF.js} +69682 -66808
  39. package/src/creatorDist/index.html +2 -2
  40. package/src/utils/api.ts +0 -83
  41. package/src/utils/lessonRecovery.ts +32 -0
@@ -24,7 +24,6 @@ vi.mock("./utils/lib", async (importOriginal) => ({
24
24
  ...(await importOriginal<typeof import("./utils/lib")>()),
25
25
  isValidPublicToken: vi.fn(async () => true),
26
26
  isValidRigoToken: vi.fn(async () => false),
27
- getTechnologies: vi.fn(async () => []),
28
27
  loginWithToken: vi.fn(),
29
28
  }));
30
29
  let deliverNotification: ((payload: unknown) => void) | null = null;
@@ -34,6 +33,12 @@ vi.mock("./components/NotificationListener", () => ({
34
33
  return null;
35
34
  },
36
35
  }));
36
+ let expireWatchdog: (() => void) | null = null;
37
+ vi.mock("./utils/useCompletionWatchdog", () => ({
38
+ useCompletionWatchdog: (_id: string, onTimeout: () => void) => {
39
+ expireWatchdog = onTimeout;
40
+ },
41
+ }));
37
42
  vi.mock("./components/TurnstileChallenge", () => ({ default: () => null }));
38
43
  vi.mock("./components/TurnstileModal", () => ({ default: () => null }));
39
44
 
@@ -296,6 +301,124 @@ describe("language URL param", () => {
296
301
  });
297
302
  });
298
303
 
304
+ describe("when generation fails", () => {
305
+ const TITLE = "We could not generate your course";
306
+ const REJECTED = {
307
+ id: 231,
308
+ status: "ERROR",
309
+ status_text: "The selected model openai/gpt-5.6-luna doesn't support structured outputs",
310
+ parsed: null,
311
+ };
312
+ const UNEXPLAINED = { id: 232, status: "ERROR", status_text: "boom", parsed: null };
313
+
314
+ const generate = async () => {
315
+ deliverNotification = null;
316
+ finishWizard();
317
+ await waitFor(() => expect(deliverNotification).not.toBeNull());
318
+ };
319
+
320
+ const fail = async (payload: unknown) => {
321
+ await generate();
322
+ act(() => deliverNotification!(payload));
323
+ };
324
+
325
+ it("stays on screen with its reference instead of returning to the wizard", async () => {
326
+ await setUp({});
327
+ await fail(UNEXPLAINED);
328
+
329
+ expect(screen.getByRole("alert").textContent).toContain(TITLE);
330
+ expect(screen.getByText("Reference code: 232")).toBeTruthy();
331
+ expect(screen.getByText("Your answers and files are still saved.")).toBeTruthy();
332
+ expect(screen.queryByText("Any materials to get this course started?")).toBeNull();
333
+ });
334
+
335
+ it("retries with one click and no more questions", async () => {
336
+ await setUp({});
337
+ await fail(UNEXPLAINED);
338
+
339
+ deliverNotification = null;
340
+ fireEvent.click(screen.getByText("Try again"));
341
+
342
+ await waitFor(() => expect(publicInteractiveCreation).toHaveBeenCalledTimes(2));
343
+ await waitFor(() => expect(deliverNotification).not.toBeNull());
344
+ expect(screen.queryByRole("alert")).toBeNull();
345
+ });
346
+
347
+ it("sends to support a failure a retry cannot fix", async () => {
348
+ await setUp({});
349
+ await fail(REJECTED);
350
+
351
+ expect(
352
+ screen.getByText("Please contact support and share the reference code.")
353
+ ).toBeTruthy();
354
+ expect(screen.getByText("Reference code: 231")).toBeTruthy();
355
+ expect(screen.queryByText("Try again")).toBeNull();
356
+ expect(screen.getByText("Review my answers")).toBeTruthy();
357
+ });
358
+
359
+ it("sends to support an unknown failure that repeats", async () => {
360
+ await setUp({});
361
+ await fail(UNEXPLAINED);
362
+ expect(screen.getByText("Try again")).toBeTruthy();
363
+
364
+ deliverNotification = null;
365
+ fireEvent.click(screen.getByText("Try again"));
366
+ await waitFor(() => expect(deliverNotification).not.toBeNull());
367
+ act(() => deliverNotification!(UNEXPLAINED));
368
+
369
+ expect(screen.queryByText("Try again")).toBeNull();
370
+ expect(
371
+ screen.getByText("Please contact support and share the reference code.")
372
+ ).toBeTruthy();
373
+ });
374
+
375
+ it("names the job even when its notification never arrives", async () => {
376
+ vi.mocked(publicInteractiveCreation).mockResolvedValueOnce({
377
+ res: { id: 77 },
378
+ notificationId: "job-1",
379
+ });
380
+ await setUp({});
381
+ await generate();
382
+
383
+ act(() => expireWatchdog!());
384
+
385
+ expect(
386
+ screen.getByText("This is taking longer than expected, so we stopped waiting. Please try again.")
387
+ ).toBeTruthy();
388
+ expect(screen.getByText("Reference code: 77")).toBeTruthy();
389
+ });
390
+
391
+ it("shows no reference when the request never created a job", async () => {
392
+ vi.mocked(publicInteractiveCreation).mockResolvedValueOnce(null);
393
+ await setUp({});
394
+ finishWizard();
395
+
396
+ await waitFor(() => expect(screen.getByRole("alert")).toBeTruthy());
397
+ expect(screen.queryByText(/Reference code/)).toBeNull();
398
+ });
399
+
400
+ it("reviews the materials when there are files, keeping them", async () => {
401
+ await setUp({ form: { hasContentIndex: true }, files: [file(ENGLISH_SEED)] });
402
+ await fail(REJECTED);
403
+
404
+ fireEvent.click(screen.getByText("Review my answers"));
405
+
406
+ expect(screen.queryByRole("alert")).toBeNull();
407
+ expect(useStore.getState().formState.currentStep).toBe("contentIndex");
408
+ expect(useStore.getState().uploadedFiles).toHaveLength(1);
409
+ });
410
+
411
+ it("asks about materials again when there were none", async () => {
412
+ await setUp({});
413
+ await fail(REJECTED);
414
+
415
+ fireEvent.click(screen.getByText("Review my answers"));
416
+
417
+ expect(useStore.getState().formState.currentStep).toBe("hasContentIndex");
418
+ expect(screen.getByText("Any materials to get this course started?")).toBeTruthy();
419
+ });
420
+ });
421
+
299
422
  describe("description language modal", () => {
300
423
  const typeDescriptionAndGoNext = (description: string) => {
301
424
  fireEvent.change(screen.getByPlaceholderText(i18n.t("stepWizard.descriptionPlaceholder")), {
@@ -13,7 +13,6 @@ import {
13
13
  loginWithToken,
14
14
  parseLesson,
15
15
  fixTitleLength,
16
- getTechnologies,
17
16
  detectLanguage,
18
17
  isValidPublicToken,
19
18
  } from "./utils/lib"
@@ -29,15 +28,16 @@ import { possiblePurposes, PurposeSelector } from "./components/PurposeSelector"
29
28
  import { useTranslation } from "react-i18next"
30
29
  import NotificationListener from "./components/NotificationListener"
31
30
  import {
32
- COMPLETION_ERROR_I18N_KEY,
33
31
  interpretCompletionPayload,
34
32
  TCompletionErrorCode,
33
+ TGenerationFailure,
35
34
  } from "./utils/completionResult"
36
35
  import { useCompletionWatchdog } from "./utils/useCompletionWatchdog"
37
36
  import { slugify } from "./utils/creatorUtils"
38
37
  import TurnstileModal from "./components/TurnstileModal"
39
38
  import { TMessage } from "./components/Message"
40
39
  import { Lesson } from "./components/LessonItem"
40
+ import GenerationErrorCard from "./components/GenerationErrorCard"
41
41
  import LanguageDetectionModal from "./components/LanguageDetectionModal"
42
42
  import CourseLanguagePicker, {
43
43
  TCourseLanguageChoice,
@@ -68,15 +68,11 @@ function App() {
68
68
  resetFormState,
69
69
  cleanAll,
70
70
  setMessages,
71
- technologies,
72
- setTechnologies,
73
71
  } = useStore(
74
72
  useShallow((state) => ({
75
73
  formState: state.formState,
76
74
  setFormState: state.setFormState,
77
75
  setAuth: state.setAuth,
78
- technologies: state.technologies,
79
- setTechnologies: state.setTechnologies,
80
76
  push: state.push,
81
77
  history: state.history,
82
78
  cleanHistory: state.cleanHistory,
@@ -90,6 +86,8 @@ function App() {
90
86
  )
91
87
 
92
88
  const [notificationId, setNotificationId] = useState<string>("")
89
+ const [generationFailure, setGenerationFailure] =
90
+ useState<TGenerationFailure | null>(null)
93
91
  const [showTurnstileModal, setShowTurnstileModal] = useState(false)
94
92
  const [showLanguageDetectionModal, setShowLanguageDetectionModal] = useState(false)
95
93
  const [detectedLanguage, setDetectedLanguage] = useState<string>("")
@@ -98,6 +96,9 @@ function App() {
98
96
  // video transcript), for detecting the course language. Uploaded files are
99
97
  // read from the store instead.
100
98
  const seedTextRef = useRef<string | null>(null)
99
+ // Refs, because a retry clears the failure on screen but must not forget them.
100
+ const failedAttemptsRef = useRef(0)
101
+ const jobReferenceRef = useRef("")
101
102
  // Course language settled when generation is fired, reused when the result
102
103
  // arrives so the stored language matches the one that was sent.
103
104
  const courseLanguageRef = useRef<TCourseLanguage | null>(null)
@@ -116,7 +117,6 @@ function App() {
116
117
  useEffect(() => {
117
118
  verifyToken()
118
119
  checkQueryParams()
119
- checkTechs()
120
120
  }, [])
121
121
 
122
122
  useCompletionWatchdog(notificationId, () => {
@@ -266,6 +266,7 @@ function App() {
266
266
  }
267
267
 
268
268
  const handleCreateTutorial = async () => {
269
+ jobReferenceRef.current = ""
269
270
  try {
270
271
  const decision = decideLanguageForGeneration()
271
272
  if (decision.kind === "ask") {
@@ -308,19 +309,10 @@ function App() {
308
309
  return
309
310
  }
310
311
 
311
- let techs = technologies.filter(
312
- (t) => t.lang === (courseLanguage ?? formState.language)
313
- )
314
-
315
- if (techs.length === 0) {
316
- techs = technologies.filter((t) => t.lang === "en")
317
- }
318
-
319
312
  const res = await publicInteractiveCreation(
320
313
  {
321
314
  courseInfo: buildCourseInfo({
322
315
  formState,
323
- techs,
324
316
  uploadedFiles,
325
317
  language: courseLanguage,
326
318
  }),
@@ -330,48 +322,74 @@ function App() {
330
322
  formState.purpose || "learnpack-lesson-writer",
331
323
  tokenType === "rigo" ? false : true
332
324
  )
325
+ if (!res) {
326
+ handleCompletionFailure("UNKNOWN", "The completion request failed")
327
+ return
328
+ }
329
+
330
+ jobReferenceRef.current = String(res.res?.id ?? "")
333
331
  setNotificationId(res.notificationId)
334
332
  } catch (error) {
335
333
  console.error(error, "ERROR CREATING TUTORIAL")
336
- toast.error("Something went wrong. Please try again.")
337
- setFormState({
338
- isCompleted: false,
339
- currentStep: "hasContentIndex",
340
- })
334
+ handleCompletionFailure("UNKNOWN", String(error))
341
335
  }
342
336
  }
343
337
 
344
338
  /**
345
- * Leaves the loading screen and puts the user back on the last wizard step.
339
+ * Leaves the loading screen for the error screen, which stays until the user
340
+ * acts on it.
346
341
  *
347
342
  * Their answers stay in the store, so retrying costs them nothing. The raw
348
- * detail goes to the console rather than the toast: it carries provider
343
+ * detail goes to the console rather than the screen: it carries provider
349
344
  * internals, such as the Rigobot team id, that mean nothing to the user.
350
345
  * @param code - The failure to report.
351
346
  * @param detail - The underlying message, for the console.
347
+ * @param reference - The job the failure came from, when the failure names it.
352
348
  */
353
349
  const handleCompletionFailure = (
354
350
  code: TCompletionErrorCode,
355
- detail: string
351
+ detail: string,
352
+ reference = ""
356
353
  ) => {
357
354
  console.error("COURSE GENERATION FAILED", code, detail)
358
- toast.error(t(COMPLETION_ERROR_I18N_KEY[code]))
355
+ failedAttemptsRef.current += 1
356
+ setGenerationFailure({
357
+ code,
358
+ reference: reference || jobReferenceRef.current,
359
+ attempts: failedAttemptsRef.current,
360
+ })
359
361
  setNotificationId("")
362
+
363
+ const { formState: current, uploadedFiles: files } = useStore.getState()
360
364
  setFormState({
361
365
  isCompleted: false,
362
- currentStep: "hasContentIndex",
366
+ currentStep:
367
+ files.length > 0 || current.contentIndex.length > 0
368
+ ? "contentIndex"
369
+ : "hasContentIndex",
363
370
  })
364
371
  }
365
372
 
373
+ const handleRetryGeneration = () => {
374
+ setGenerationFailure(null)
375
+ setFormState({ isCompleted: true })
376
+ }
377
+
378
+ const handleReviewAnswers = () => {
379
+ failedAttemptsRef.current = 0
380
+ setGenerationFailure(null)
381
+ }
382
+
366
383
  const handleNotification = (payload: unknown) => {
367
384
  try {
368
385
  const result = interpretCompletionPayload(payload)
369
386
 
370
387
  if (!result.ok) {
371
- handleCompletionFailure(result.code, result.detail)
388
+ handleCompletionFailure(result.code, result.detail, result.reference)
372
389
  return
373
390
  }
374
391
 
392
+ failedAttemptsRef.current = 0
375
393
  const { parsed } = result
376
394
  // parseLesson returns null for a step that does not match the expected
377
395
  // "01.0 - Title [TYPE: description]" format. Those nulls used to slip into
@@ -461,14 +479,6 @@ function App() {
461
479
  }
462
480
  }
463
481
 
464
- const checkTechs = async () => {
465
- if (technologies.length === 0) {
466
- const technologies = await getTechnologies()
467
- console.log("TECHNOLOGIES", technologies)
468
- setTechnologies(technologies)
469
- }
470
- }
471
-
472
482
  const handleLanguageSwitch = () => {
473
483
  setFormState({
474
484
  language: detectedLanguage,
@@ -749,7 +759,13 @@ function App() {
749
759
  }}
750
760
  />
751
761
  )}
752
- {formState.isCompleted && history.length === 0 ? (
762
+ {generationFailure ? (
763
+ <GenerationErrorCard
764
+ failure={generationFailure}
765
+ onRetry={handleRetryGeneration}
766
+ onReview={handleReviewAnswers}
767
+ />
768
+ ) : formState.isCompleted && history.length === 0 ? (
753
769
  <>
754
770
  <Loader
755
771
  text={t("loader.text")}
@@ -1,4 +1,9 @@
1
+ import { useEffect, useRef } from "react"
2
+ import toast from "react-hot-toast"
3
+ import { useTranslation } from "react-i18next"
1
4
  import { SVGS } from "../assets/svgs"
5
+ import { readNotices } from "../utils/fileRead"
6
+ import { FileReviewButton } from "./FileReviewModal"
2
7
  import { ParsedFile } from "./FileUploader"
3
8
  import {
4
9
  Tooltip,
@@ -12,23 +17,62 @@ export const FileCard = ({
12
17
  }: {
13
18
  file: ParsedFile
14
19
  handleRemove: () => void
15
- }) => (
16
- <div className="flex items-center justify-between bg-white shadow-sm p-2 rounded-lg w-[100px]">
17
- <Tooltip>
18
- <TooltipTrigger asChild>
19
- <span className="text-xs text-gray-800 truncate">
20
- {file.name}
21
- </span>
22
- </TooltipTrigger>
23
- <TooltipContent>
24
- <p>{file.name}</p>
25
- </TooltipContent>
26
- </Tooltip>
27
- <button
28
- onClick={handleRemove}
29
- className="p-1 text-red-500 hover:text-red-700 transition-colors cursor-pointer "
20
+ }) => {
21
+ const { t } = useTranslation()
22
+ const notices = readNotices(file, t)
23
+ const isReading = useRef(file.status === "PROCESSING")
24
+
25
+ // The sidebar never asks before reading, so a read that needs attention
26
+ // speaks up once when it ends. Reloading the page must not repeat it.
27
+ useEffect(() => {
28
+ if (!isReading.current || file.status === "PROCESSING") return
29
+ isReading.current = false
30
+
31
+ if (file.status === "ERROR") {
32
+ toast.error(t("uploader.files.readFailed"))
33
+ return
34
+ }
35
+
36
+ readNotices(file, t).forEach((notice) => toast(notice, { icon: "⚠️" }))
37
+ }, [file, t])
38
+
39
+ return (
40
+ <div
41
+ className={
42
+ "flex items-center justify-between gap-1 shadow-sm p-2 rounded-lg w-[100px] " +
43
+ (file.status === "ERROR"
44
+ ? "bg-red-100"
45
+ : notices.length > 0
46
+ ? "bg-amber-100"
47
+ : "bg-white")
48
+ }
30
49
  >
31
- {SVGS.trash}
32
- </button>
33
- </div>
34
- )
50
+ {file.status === "PROCESSING" && (
51
+ <div className="shrink-0 w-3 h-3 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
52
+ )}
53
+ <Tooltip>
54
+ <TooltipTrigger asChild>
55
+ <span className="text-xs text-gray-800 truncate">
56
+ {file.status === "SUCCESS" ? (
57
+ <FileReviewButton file={file} label={file.name} />
58
+ ) : (
59
+ file.name
60
+ )}
61
+ </span>
62
+ </TooltipTrigger>
63
+ <TooltipContent>
64
+ <p>{file.name}</p>
65
+ {notices.map((notice) => (
66
+ <p key={notice}>{notice}</p>
67
+ ))}
68
+ </TooltipContent>
69
+ </Tooltip>
70
+ <button
71
+ onClick={handleRemove}
72
+ className="p-1 text-red-500 hover:text-red-700 transition-colors cursor-pointer "
73
+ >
74
+ {SVGS.trash}
75
+ </button>
76
+ </div>
77
+ )
78
+ }
@@ -0,0 +1,128 @@
1
+ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
2
+ import { act, cleanup, render } from "@testing-library/react";
3
+ import FileReadListener, { FILE_READ_TIMEOUT_MS } from "./FileReadListener";
4
+ import useStore from "../utils/store";
5
+ import type { ParsedFile } from "./FileUploader";
6
+
7
+ const sockets = vi.hoisted(() => ({
8
+ listeners: new Map<string, (data: unknown) => void>(),
9
+ }));
10
+
11
+ vi.mock("../utils/socket", () => ({
12
+ default: class {
13
+ subscribe(id: string, callback: (data: unknown) => void) {
14
+ sockets.listeners.set(id, callback);
15
+ return () => sockets.listeners.delete(id);
16
+ }
17
+ },
18
+ }));
19
+
20
+ const reading = (name: string): ParsedFile => ({
21
+ name,
22
+ text: "",
23
+ status: "PROCESSING",
24
+ notificationId: `read-${name}`,
25
+ });
26
+
27
+ const answer = (name: string, data: unknown) =>
28
+ act(() => sockets.listeners.get(`read-${name}`)?.(data));
29
+
30
+ const file = (name: string) =>
31
+ useStore.getState().uploadedFiles.find((f) => f.name === name);
32
+
33
+ beforeEach(() => {
34
+ sockets.listeners.clear();
35
+ useStore.getState().setUploadedFiles([]);
36
+ });
37
+
38
+ afterEach(() => {
39
+ cleanup();
40
+ vi.useRealTimers();
41
+ });
42
+
43
+ describe("FileReadListener", () => {
44
+ it("stores the text and the report of a finished read", () => {
45
+ useStore.getState().setUploadedFiles([reading("a.pdf")]);
46
+ render(<FileReadListener />);
47
+
48
+ const report = {
49
+ total_pages: 1,
50
+ ai_pages: [1],
51
+ unread_pages: [],
52
+ failed_pages: [],
53
+ };
54
+ answer("a.pdf", { text: "\n[Page 1]\nHello", report });
55
+
56
+ expect(file("a.pdf")).toMatchObject({
57
+ status: "SUCCESS",
58
+ text: "\n[Page 1]\nHello",
59
+ report,
60
+ });
61
+ });
62
+
63
+ it("keeps both answers when two files finish in turn", () => {
64
+ useStore.getState().setUploadedFiles([reading("a.pdf"), reading("b.pdf")]);
65
+ render(<FileReadListener />);
66
+
67
+ answer("a.pdf", { text: "first" });
68
+ answer("b.pdf", { text: "second" });
69
+
70
+ expect(file("a.pdf")).toMatchObject({ status: "SUCCESS", text: "first" });
71
+ expect(file("b.pdf")).toMatchObject({ status: "SUCCESS", text: "second" });
72
+ });
73
+
74
+ it("hears files added from anywhere, without an upload card on screen", () => {
75
+ render(<FileReadListener />);
76
+
77
+ act(() => useStore.getState().setUploadedFiles([reading("sidebar.pdf")]));
78
+ answer("sidebar.pdf", { text: "Read from the sidebar" });
79
+
80
+ expect(file("sidebar.pdf")?.text).toBe("Read from the sidebar");
81
+ });
82
+
83
+ it("does not touch a newer file that took the name of an old read", () => {
84
+ useStore.getState().setUploadedFiles([reading("a.pdf")]);
85
+ render(<FileReadListener />);
86
+ const oldAnswer = sockets.listeners.get("read-a.pdf");
87
+
88
+ act(() =>
89
+ useStore.getState().setUploadedFiles([
90
+ { ...reading("a.pdf"), notificationId: "read-a.pdf-new" },
91
+ ])
92
+ );
93
+ act(() => oldAnswer?.({ text: "old version" }));
94
+
95
+ expect(file("a.pdf")).toMatchObject({ status: "PROCESSING", text: "" });
96
+ });
97
+
98
+ it("marks the file as failed when the read comes back empty", () => {
99
+ useStore.getState().setUploadedFiles([reading("a.pdf")]);
100
+ render(<FileReadListener />);
101
+
102
+ answer("a.pdf", { text: "", status: "ERROR", error_code: "read_failed" });
103
+
104
+ expect(file("a.pdf")?.status).toBe("ERROR");
105
+ });
106
+
107
+ it("gives up on a read that never answers", () => {
108
+ vi.useFakeTimers();
109
+ useStore.getState().setUploadedFiles([reading("a.pdf")]);
110
+ render(<FileReadListener />);
111
+
112
+ act(() => {
113
+ vi.advanceTimersByTime(FILE_READ_TIMEOUT_MS);
114
+ });
115
+
116
+ expect(file("a.pdf")?.status).toBe("ERROR");
117
+ expect(sockets.listeners.size).toBe(0);
118
+ });
119
+
120
+ it("stops listening once the file is removed", () => {
121
+ useStore.getState().setUploadedFiles([reading("a.pdf")]);
122
+ render(<FileReadListener />);
123
+
124
+ act(() => useStore.getState().setUploadedFiles([]));
125
+
126
+ expect(sockets.listeners.size).toBe(0);
127
+ });
128
+ });
@@ -0,0 +1,75 @@
1
+ import { useEffect } from "react"
2
+ import useStore from "../utils/store"
3
+ import CreatorSocket from "../utils/socket"
4
+ import type { ParsedFile } from "./FileUploader"
5
+
6
+ export const FILE_READ_TIMEOUT_MS = 5 * 60 * 1000
7
+
8
+ const socketClient = new CreatorSocket("")
9
+
10
+ // Reads the store at call time: this runs long after the subscription was
11
+ // created, and two files can finish while the same list is on screen.
12
+ const updateFile = (notificationId: string, changes: Partial<ParsedFile>) => {
13
+ const { uploadedFiles, setUploadedFiles } = useStore.getState()
14
+ setUploadedFiles(
15
+ uploadedFiles.map((file) =>
16
+ file.notificationId === notificationId ? { ...file, ...changes } : file
17
+ )
18
+ )
19
+ }
20
+
21
+ const FileReadSubscription = ({
22
+ notificationId,
23
+ }: {
24
+ notificationId: string
25
+ }) => {
26
+ useEffect(() => {
27
+ const timeout = setTimeout(() => {
28
+ updateFile(notificationId, { status: "ERROR" })
29
+ }, FILE_READ_TIMEOUT_MS)
30
+
31
+ const onAnswer = (data: Pick<ParsedFile, "text" | "report">) => {
32
+ updateFile(
33
+ notificationId,
34
+ data.text
35
+ ? { status: "SUCCESS", text: data.text, report: data.report }
36
+ : { status: "ERROR" }
37
+ )
38
+ }
39
+
40
+ const release = socketClient.subscribe(notificationId, onAnswer)
41
+
42
+ return () => {
43
+ clearTimeout(timeout)
44
+ release()
45
+ }
46
+ }, [notificationId])
47
+
48
+ return null
49
+ }
50
+
51
+ /**
52
+ * Waits for the rigobot answer of every file being read.
53
+ *
54
+ * Lives above the routes because files are attached from two places and the
55
+ * user can leave either one while a read is running: a listener inside a card
56
+ * would die with the card, and the sidebar has no such card at all.
57
+ */
58
+ const FileReadListener = () => {
59
+ const uploadedFiles = useStore((state) => state.uploadedFiles)
60
+
61
+ return (
62
+ <>
63
+ {uploadedFiles
64
+ .filter((file) => file.status === "PROCESSING" && file.notificationId)
65
+ .map((file) => (
66
+ <FileReadSubscription
67
+ key={file.notificationId}
68
+ notificationId={file.notificationId}
69
+ />
70
+ ))}
71
+ </>
72
+ )
73
+ }
74
+
75
+ export default FileReadListener