CoverImageCachedMount.test.tsx 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. /**
  2. * The printer-card thumbnail has to survive an image the browser already has (#2826).
  3. *
  4. * The URL is cache-busted on the print *name*, which does not change while a
  5. * print runs. So navigating away from the printers page and back re-mounts
  6. * with a byte-identical `src`, which the browser serves from its in-memory
  7. * cache -- no network request at all, which is why the reporter's Network
  8. * panel was empty while the thumbnail sat on the placeholder.
  9. *
  10. * `loaded` used to be settable only by `onLoad`, while a mount effect reset it
  11. * to false unconditionally. For a cache hit those two are racing tasks with no
  12. * ordering between them, and when `load` won, the effect undid it -- and never
  13. * ran again, because the URL does not change again during the print. That is
  14. * why it reproduced 100% for the reporter and not at all on the maintainer's
  15. * machine.
  16. *
  17. * jsdom never loads images or fires `load`, so the race itself cannot be
  18. * staged here. What these tests pin is the invariant that makes the race
  19. * unwinnable either way: the component must read the element's own state
  20. * instead of assuming nothing has loaded yet.
  21. */
  22. import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
  23. import { render, waitFor } from '@testing-library/react';
  24. import { CoverImage } from '../../pages/PrintersPage';
  25. vi.mock('../../hooks/useCameraStreamToken', async () => {
  26. const actual = await vi.importActual<Record<string, unknown>>('../../hooks/useCameraStreamToken');
  27. return { ...actual, withStreamToken: (u: string) => u };
  28. });
  29. /** Present an <img> the way a memory-cache hit does: already complete. */
  30. function stubAlreadyComplete(naturalWidth = 640) {
  31. Object.defineProperty(HTMLImageElement.prototype, 'complete', {
  32. get: () => true,
  33. configurable: true,
  34. });
  35. Object.defineProperty(HTMLImageElement.prototype, 'naturalWidth', {
  36. get: () => naturalWidth,
  37. configurable: true,
  38. });
  39. }
  40. function restoreImg() {
  41. // @ts-expect-error removing the test-only prototype overrides
  42. delete HTMLImageElement.prototype.complete;
  43. // @ts-expect-error removing the test-only prototype overrides
  44. delete HTMLImageElement.prototype.naturalWidth;
  45. }
  46. const URL_ = '/api/v1/printers/1/cover';
  47. describe('CoverImage with an image the browser already has', () => {
  48. afterEach(restoreImg);
  49. it('shows it instead of the placeholder', async () => {
  50. stubAlreadyComplete();
  51. const { container } = render(<CoverImage url={URL_} printName="Benchy" />);
  52. await waitFor(() => {
  53. expect(container.querySelector('img')!.className).toContain('block');
  54. });
  55. expect(container.querySelector('img')!.className).not.toContain('hidden');
  56. });
  57. it('treats it as loaded across a remount with the same print', async () => {
  58. stubAlreadyComplete();
  59. // First visit: warms the cache in a real browser.
  60. const first = render(<CoverImage url={URL_} printName="Benchy" />);
  61. await waitFor(() => expect(first.container.querySelector('img')!.className).toContain('block'));
  62. first.unmount();
  63. // Navigating back. Same print name means a byte-identical URL, so nothing
  64. // is fetched -- this is the mount that used to come back blank.
  65. const second = render(<CoverImage url={URL_} printName="Benchy" />);
  66. await waitFor(() => {
  67. expect(second.container.querySelector('img')!.className).toContain('block');
  68. });
  69. });
  70. it('makes it clickable, not just visible', async () => {
  71. // `loaded` also gates the click-to-enlarge overlay, so a stuck `false`
  72. // left the thumbnail inert as well as invisible.
  73. stubAlreadyComplete();
  74. const { container } = render(<CoverImage url={URL_} printName="Benchy" />);
  75. await waitFor(() => {
  76. expect(container.querySelector('div')!.className).toContain('cursor-pointer');
  77. });
  78. });
  79. });
  80. describe('CoverImage when nothing is cached', () => {
  81. beforeEach(restoreImg);
  82. it('waits behind the placeholder until the image arrives', () => {
  83. // jsdom leaves `complete` false and never fires `load`, which is exactly
  84. // the cold-cache state: the placeholder is correct until it resolves.
  85. const { container } = render(<CoverImage url={URL_} printName="Benchy" />);
  86. expect(container.querySelector('img')!.className).toContain('hidden');
  87. });
  88. it('still reveals the image when onLoad fires', async () => {
  89. const { container } = render(<CoverImage url={URL_} printName="Benchy" />);
  90. const img = container.querySelector('img')!;
  91. img.dispatchEvent(new Event('load'));
  92. await waitFor(() => expect(img.className).toContain('block'));
  93. });
  94. it('falls back to the placeholder when the image fails', async () => {
  95. const { container } = render(<CoverImage url={URL_} printName="Benchy" />);
  96. container.querySelector('img')!.dispatchEvent(new Event('error'));
  97. await waitFor(() => expect(container.querySelector('img')).toBeNull());
  98. });
  99. it('shows the placeholder when there is no cover at all', () => {
  100. const { container } = render(<CoverImage url={null} printName="Benchy" />);
  101. expect(container.querySelector('img')).toBeNull();
  102. });
  103. });
  104. describe('CoverImage when the print changes', () => {
  105. afterEach(restoreImg);
  106. it('re-evaluates rather than carrying the previous print forward', async () => {
  107. // The reset-on-change behaviour the effect was added for in the first
  108. // place still has to hold: a new print name is a new URL, and a fresh
  109. // element that is not yet complete must go back behind the placeholder.
  110. stubAlreadyComplete();
  111. const { container, rerender } = render(<CoverImage url={URL_} printName="Benchy" />);
  112. await waitFor(() => expect(container.querySelector('img')!.className).toContain('block'));
  113. restoreImg();
  114. rerender(<CoverImage url={URL_} printName="Something Else" />);
  115. await waitFor(() => {
  116. expect(container.querySelector('img')!.className).toContain('hidden');
  117. });
  118. });
  119. it('keeps the cache-buster tied to the print name', () => {
  120. stubAlreadyComplete();
  121. const { container, rerender } = render(<CoverImage url={URL_} printName="Benchy" />);
  122. const first = container.querySelector('img')!.getAttribute('src');
  123. rerender(<CoverImage url={URL_} printName="Other" />);
  124. const second = container.querySelector('img')!.getAttribute('src');
  125. expect(first).toContain('v=Benchy');
  126. expect(second).toContain('v=Other');
  127. expect(first).not.toEqual(second);
  128. });
  129. it('reuses the URL for the same print, which is what makes the cache hit', () => {
  130. // Pinning the precondition, not an incidental detail: if this ever became
  131. // unique per mount the bug would vanish and so would the caching, and the
  132. // tests above would silently stop covering anything.
  133. stubAlreadyComplete();
  134. const a = render(<CoverImage url={URL_} printName="Benchy" />);
  135. const first = a.container.querySelector('img')!.getAttribute('src');
  136. a.unmount();
  137. const b = render(<CoverImage url={URL_} printName="Benchy" />);
  138. const second = b.container.querySelector('img')!.getAttribute('src');
  139. expect(second).toEqual(first);
  140. });
  141. });
  142. describe('CoverImage with a broken cached image', () => {
  143. afterEach(restoreImg);
  144. it('does not treat a zero-width complete image as loaded', async () => {
  145. // `complete` is also true for an image that failed. Width is what
  146. // separates "decoded and ready" from "finished, with nothing to show".
  147. stubAlreadyComplete(0);
  148. const { container } = render(<CoverImage url={URL_} printName="Benchy" />);
  149. await waitFor(() => expect(container.querySelector('img')).not.toBeNull());
  150. expect(container.querySelector('img')!.className).toContain('hidden');
  151. });
  152. });