cursedbelt 4.1.0 → 4.3.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.
- package/dist/react/master-lock/MasterLockDialog.d.ts.map +1 -1
- package/dist/react/master-lock/MasterLockDialog.js +25 -5
- package/dist/react/master-lock/MasterLockDialog.js.map +1 -1
- package/dist/react/master-lock/MasterLockProvider.d.ts +19 -0
- package/dist/react/master-lock/MasterLockProvider.d.ts.map +1 -1
- package/dist/react/master-lock/MasterLockProvider.js +19 -0
- package/dist/react/master-lock/MasterLockProvider.js.map +1 -1
- package/dist/react/media/CanvasVideoPlayer.d.ts +3 -2
- package/dist/react/media/CanvasVideoPlayer.d.ts.map +1 -1
- package/dist/react/media/CanvasVideoPlayer.js +4 -3
- package/dist/react/media/CanvasVideoPlayer.js.map +1 -1
- package/dist/react/media/FrameGrabber.d.ts +16 -2
- package/dist/react/media/FrameGrabber.d.ts.map +1 -1
- package/dist/react/media/FrameGrabber.js +68 -43
- package/dist/react/media/FrameGrabber.js.map +1 -1
- package/dist/react/media/PictureEditor.d.ts +17 -2
- package/dist/react/media/PictureEditor.d.ts.map +1 -1
- package/dist/react/media/PictureEditor.js +33 -7
- package/dist/react/media/PictureEditor.js.map +1 -1
- package/dist/react/media/VideoChapterEditor.d.ts +17 -2
- package/dist/react/media/VideoChapterEditor.d.ts.map +1 -1
- package/dist/react/media/VideoChapterEditor.js +21 -3
- package/dist/react/media/VideoChapterEditor.js.map +1 -1
- package/dist/react/media/VideoCutEditor.d.ts +16 -2
- package/dist/react/media/VideoCutEditor.d.ts.map +1 -1
- package/dist/react/media/VideoCutEditor.js +21 -3
- package/dist/react/media/VideoCutEditor.js.map +1 -1
- package/dist/react/media/VideoPlayer.d.ts +37 -9
- package/dist/react/media/VideoPlayer.d.ts.map +1 -1
- package/dist/react/media/VideoPlayer.js +130 -39
- package/dist/react/media/VideoPlayer.js.map +1 -1
- package/dist/react/media/hlsSource.d.ts +24 -3
- package/dist/react/media/hlsSource.d.ts.map +1 -1
- package/dist/react/media/hlsSource.js +109 -8
- package/dist/react/media/hlsSource.js.map +1 -1
- package/dist/react/media/pictureEditor.css +2 -0
- package/dist/react/media/sourceFault.d.ts +146 -0
- package/dist/react/media/sourceFault.d.ts.map +1 -0
- package/dist/react/media/sourceFault.js +139 -0
- package/dist/react/media/sourceFault.js.map +1 -0
- package/dist/react/media/videoChapterEditor.css +19 -0
- package/dist/react/media/videoCutEditor.css +22 -0
- package/dist/react/media-gallery/MediaGallery.d.ts +12 -1
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +166 -13
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/react/media-gallery/galleryItemMedia.d.ts +4 -0
- package/dist/react/media-gallery/galleryItemMedia.d.ts.map +1 -1
- package/dist/react/media-gallery/galleryItemMedia.js +27 -3
- package/dist/react/media-gallery/galleryItemMedia.js.map +1 -1
- package/dist/react/media-gallery/mediaGalleryModel.d.ts.map +1 -1
- package/dist/react/media-gallery/mediaGalleryModel.js +5 -0
- package/dist/react/media-gallery/mediaGalleryModel.js.map +1 -1
- package/dist/react/media-gallery/types.d.ts +23 -0
- package/dist/react/media-gallery/types.d.ts.map +1 -1
- package/dist/scripts/guardrailsEnforce.d.ts +1 -0
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
- package/dist/styles-areas/master-lock.css +1 -1
- package/dist/styles-areas/media-gallery.css +1 -1
- package/dist/styles-areas/media.css +1 -1
- package/package.json +3 -2
- package/scripts/guardrailsEnforce.spec.ts +244 -1
- package/scripts/guardrailsEnforce.ts +224 -7
- package/src/iframesMakeNoRealRequest.spec.ts +92 -0
- package/src/react/components/IsolatedFrame.spec.tsx +27 -9
- package/src/react/master-lock/MasterLockDialog.tsx +24 -5
- package/src/react/master-lock/MasterLockProvider.spec.tsx +39 -0
- package/src/react/master-lock/MasterLockProvider.tsx +19 -0
- package/src/react/media/CanvasVideoPlayer.tsx +11 -6
- package/src/react/media/FrameGrabber.spec.tsx +57 -1
- package/src/react/media/FrameGrabber.tsx +138 -84
- package/src/react/media/PictureEditor.spec.tsx +90 -0
- package/src/react/media/PictureEditor.tsx +78 -23
- package/src/react/media/VideoChapterEditor.spec.tsx +86 -0
- package/src/react/media/VideoChapterEditor.tsx +64 -18
- package/src/react/media/VideoCutEditor.spec.tsx +44 -0
- package/src/react/media/VideoCutEditor.tsx +63 -18
- package/src/react/media/VideoPlayer.spec.tsx +101 -0
- package/src/react/media/VideoPlayer.tsx +221 -73
- package/src/react/media/hlsSource.answer.spec.ts +255 -0
- package/src/react/media/hlsSource.ts +136 -8
- package/src/react/media/pictureEditor.css +2 -0
- package/src/react/media/sourceFault.spec.tsx +224 -0
- package/src/react/media/sourceFault.tsx +220 -0
- package/src/react/media/videoChapterEditor.css +19 -0
- package/src/react/media/videoCutEditor.css +22 -0
- package/src/react/media-gallery/MediaGallery.spec.tsx +288 -0
- package/src/react/media-gallery/MediaGallery.tsx +356 -45
- package/src/react/media-gallery/galleryItemMedia.tsx +27 -4
- package/src/react/media-gallery/mediaGalleryModel.spec.ts +36 -0
- package/src/react/media-gallery/mediaGalleryModel.ts +4 -0
- package/src/react/media-gallery/types.ts +23 -0
- package/src/styles-areas/master-lock.css +1 -1
- package/src/styles-areas/media-gallery.css +1 -1
- package/src/styles-areas/media.css +1 -1
|
@@ -17,6 +17,25 @@
|
|
|
17
17
|
border-radius: var(--radius-md, 8px);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/* Why there is no film, said IN the player's own place — the sibling of `.cbvc-nofilm` in
|
|
21
|
+
`videoCutEditor.css`, which documents both halves of the reasoning (why the panel wears the
|
|
22
|
+
video's own class, and why the turner's white-on-black note cannot be reused on a theme
|
|
23
|
+
surface). Restated here for the same reason every `.cbch-*` rule is a sibling of a `.cbvc-*`
|
|
24
|
+
one: these two sheets ride their own lazily-imported chunks and share nothing. */
|
|
25
|
+
.cbch-nofilm {
|
|
26
|
+
display: flex;
|
|
27
|
+
align-items: center;
|
|
28
|
+
justify-content: center;
|
|
29
|
+
min-block-size: 8rem;
|
|
30
|
+
margin: 0;
|
|
31
|
+
padding: 1.5rem;
|
|
32
|
+
text-align: center;
|
|
33
|
+
text-wrap: balance;
|
|
34
|
+
font-size: 13px;
|
|
35
|
+
line-height: 1.5;
|
|
36
|
+
color: var(--muted-foreground, var(--foreground));
|
|
37
|
+
}
|
|
38
|
+
|
|
20
39
|
/* The one authoring row: name the section, then mark it. In that order, because the button
|
|
21
40
|
is disabled until there is a name — see the component. */
|
|
22
41
|
.cbch-add {
|
|
@@ -17,6 +17,28 @@
|
|
|
17
17
|
border-radius: var(--radius-md, 8px);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/* Why there is no film, said IN the player's own place — the owner's *"I see a black screen"*,
|
|
21
|
+
which he could only report as a black screen because the dialog said nothing. It carries
|
|
22
|
+
`.cbvc-video` as well, so the panel is the same width, ground and corner as the element it
|
|
23
|
+
stands in for and the dialog does not jump when a source resolves.
|
|
24
|
+
|
|
25
|
+
🔴 Not `.cbpe-frame-note`, which the turner and the grabber share: that note is white on the
|
|
26
|
+
true black of a photograph's frame, and this sheet's ground is a theme surface — reusing it
|
|
27
|
+
here would be invisible text in the light theme. */
|
|
28
|
+
.cbvc-nofilm {
|
|
29
|
+
display: flex;
|
|
30
|
+
align-items: center;
|
|
31
|
+
justify-content: center;
|
|
32
|
+
min-block-size: 8rem;
|
|
33
|
+
margin: 0;
|
|
34
|
+
padding: 1.5rem;
|
|
35
|
+
text-align: center;
|
|
36
|
+
text-wrap: balance;
|
|
37
|
+
font-size: 13px;
|
|
38
|
+
line-height: 1.5;
|
|
39
|
+
color: var(--muted-foreground, var(--foreground));
|
|
40
|
+
}
|
|
41
|
+
|
|
20
42
|
.cbvc-marks {
|
|
21
43
|
display: flex;
|
|
22
44
|
align-items: center;
|
|
@@ -567,6 +567,103 @@ describe('MediaGallery — turning a video', () => {
|
|
|
567
567
|
expect(preview?.getAttribute('poster')).toBe('/signed/poster2');
|
|
568
568
|
});
|
|
569
569
|
|
|
570
|
+
/**
|
|
571
|
+
* ── 🔴 The other four dialogs read the raw row too, and the same black screen came out ──
|
|
572
|
+
*
|
|
573
|
+
* The fix above was scoped to the turn dialog; the cause is shared by every capability
|
|
574
|
+
* dialog the gallery mounts. Each of the four below was written as
|
|
575
|
+
* `src={item.streamUrl ?? item.url ?? ''}` (or `item.url ?? ''` for the picture editor) and
|
|
576
|
+
* each therefore opened on nothing at all for an app whose URLs are minted on demand — the
|
|
577
|
+
* app that reported the original bug. They are asserted one dialog per test, by the source
|
|
578
|
+
* that ARRIVES at the element, because that is the fact the report was about.
|
|
579
|
+
*
|
|
580
|
+
* 🔴 Every assertion goes through `baseElement`: these are portals, so nothing they render
|
|
581
|
+
* is under the render root and a `container` query would find null and pass for the wrong
|
|
582
|
+
* reason.
|
|
583
|
+
*/
|
|
584
|
+
const SIGNED_FILM: MediaGalleryItem[] = [
|
|
585
|
+
{
|
|
586
|
+
id: 'vid3',
|
|
587
|
+
name: 'signed.mp4',
|
|
588
|
+
mime: 'video/mp4',
|
|
589
|
+
createdAt: 10,
|
|
590
|
+
durationSeconds: 90,
|
|
591
|
+
// No `url` and no `streamUrl`: the app mints them when something needs them.
|
|
592
|
+
resolveMedia: async () => ({ streamUrl: '/signed/vid3', posterUrl: '/signed/poster3' }),
|
|
593
|
+
},
|
|
594
|
+
];
|
|
595
|
+
|
|
596
|
+
test('🔴 the TRIM dialog is handed the RESOLVED source, not the raw row', async () => {
|
|
597
|
+
const view = render(<MediaGallery items={SIGNED_FILM} onTrimVideo={() => {}} />);
|
|
598
|
+
await waitFor(() => expect(view.queryByRole('button', { name: 'Open signed.mp4' })).not.toBeNull());
|
|
599
|
+
fireEvent.click(view.getByRole('button', { name: 'Open signed.mp4' }));
|
|
600
|
+
fireEvent.click(view.getByRole('button', { name: 'Trim' }));
|
|
601
|
+
await waitFor(() => expect(view.queryByText('Trim signed.mp4')).not.toBeNull());
|
|
602
|
+
const film = await waitFor(() => {
|
|
603
|
+
const el = view.baseElement.querySelector<HTMLVideoElement>('video.cbvc-video');
|
|
604
|
+
expect(el, 'the trim dialog rendered no film to mark up').not.toBeNull();
|
|
605
|
+
return el as HTMLVideoElement;
|
|
606
|
+
});
|
|
607
|
+
expect(film.getAttribute('src')).toBe('/signed/vid3');
|
|
608
|
+
expect(film.getAttribute('poster')).toBe('/signed/poster3');
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
test('🔴 the SECTIONS dialog is handed the RESOLVED source, not the raw row', async () => {
|
|
612
|
+
const view = render(<MediaGallery items={SIGNED_FILM} onSaveChapters={() => {}} />);
|
|
613
|
+
await waitFor(() => expect(view.queryByRole('button', { name: 'Open signed.mp4' })).not.toBeNull());
|
|
614
|
+
fireEvent.click(view.getByRole('button', { name: 'Open signed.mp4' }));
|
|
615
|
+
fireEvent.click(view.getByRole('button', { name: 'Sections' }));
|
|
616
|
+
await waitFor(() => expect(view.queryByText('Sections of signed.mp4')).not.toBeNull());
|
|
617
|
+
const film = await waitFor(() => {
|
|
618
|
+
const el = view.baseElement.querySelector<HTMLVideoElement>('video.cbch-video');
|
|
619
|
+
expect(el, 'the sections dialog rendered no film to scrub').not.toBeNull();
|
|
620
|
+
return el as HTMLVideoElement;
|
|
621
|
+
});
|
|
622
|
+
expect(film.getAttribute('src')).toBe('/signed/vid3');
|
|
623
|
+
expect(film.getAttribute('poster')).toBe('/signed/poster3');
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
test('🔴 the FRAME GRABBER is handed the RESOLVED source, not the raw row', async () => {
|
|
627
|
+
const view = render(<MediaGallery items={SIGNED_FILM} onSaveThumbnail={() => {}} />);
|
|
628
|
+
await waitFor(() => expect(view.queryByRole('button', { name: 'Open signed.mp4' })).not.toBeNull());
|
|
629
|
+
fireEvent.click(view.getByRole('button', { name: 'Open signed.mp4' }));
|
|
630
|
+
// "Choose" rather than "Change": the RAW row carries no poster. The dialog is told about
|
|
631
|
+
// the resolved one, which is the fact the button's own wording cannot see.
|
|
632
|
+
fireEvent.click(view.getByRole('button', { name: 'Choose thumbnail' }));
|
|
633
|
+
await waitFor(() => expect(view.queryByText('Pick a moment in signed.mp4')).not.toBeNull());
|
|
634
|
+
const film = await waitFor(() => {
|
|
635
|
+
const el = view.baseElement.querySelector<HTMLVideoElement>('.cbpe-frame video');
|
|
636
|
+
expect(el, 'the frame grabber rendered no film to take a frame from').not.toBeNull();
|
|
637
|
+
return el as HTMLVideoElement;
|
|
638
|
+
});
|
|
639
|
+
expect(film.getAttribute('src')).toBe('/signed/vid3');
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
test('🔴 the PICTURE EDITOR is handed the RESOLVED source, not the raw row', async () => {
|
|
643
|
+
const signed: MediaGalleryItem[] = [
|
|
644
|
+
{
|
|
645
|
+
id: 'img9',
|
|
646
|
+
name: 'signed.jpg',
|
|
647
|
+
mime: 'image/jpeg',
|
|
648
|
+
createdAt: 10,
|
|
649
|
+
resolveMedia: async () => ({ url: '/signed/img9' }),
|
|
650
|
+
},
|
|
651
|
+
];
|
|
652
|
+
const view = render(<MediaGallery items={signed} onSaveEdit={() => {}} />);
|
|
653
|
+
await waitFor(() => expect(view.queryByRole('button', { name: 'Open signed.jpg' })).not.toBeNull());
|
|
654
|
+
fireEvent.click(view.getByRole('button', { name: 'Open signed.jpg' }));
|
|
655
|
+
fireEvent.click(view.getByRole('button', { name: 'Edit picture' }));
|
|
656
|
+
await waitFor(() => expect(view.queryByText('Edit picture')).not.toBeNull());
|
|
657
|
+
const picture = await waitFor(() => {
|
|
658
|
+
const el = view.baseElement.querySelector<HTMLImageElement>('.cbpe-frame img');
|
|
659
|
+
// 🔴 The worst of the five to get wrong: this dialog BAKES pixels, so an empty frame is
|
|
660
|
+
// not a bad preview, it is a blank picture about to be written over a photograph.
|
|
661
|
+
expect(el, 'the picture editor rendered no picture to edit').not.toBeNull();
|
|
662
|
+
return el as HTMLImageElement;
|
|
663
|
+
});
|
|
664
|
+
expect(picture.getAttribute('src')).toBe('/signed/img9');
|
|
665
|
+
});
|
|
666
|
+
|
|
570
667
|
test('🔴 a PICTURE never gets the video control — it already has the fuller editor', () => {
|
|
571
668
|
const picture: MediaGalleryItem[] = [
|
|
572
669
|
{ id: 'img1', name: 'beach.jpg', url: '/files/img1', createdAt: 1 },
|
|
@@ -1271,6 +1368,30 @@ describe('🔴 the stage hands the player every transport the app resolved', ()
|
|
|
1271
1368
|
false,
|
|
1272
1369
|
);
|
|
1273
1370
|
});
|
|
1371
|
+
|
|
1372
|
+
/*
|
|
1373
|
+
* 🔴 The FIFTH field down this road, and the first one that is not a URL — the app's own
|
|
1374
|
+
* answer to "was this play a fallback, or is there simply no ladder?". It fails the same way
|
|
1375
|
+
* as the four before it: not destructured in the app's `resolveMedia`, or not listed here,
|
|
1376
|
+
* and the badge is dead with every gate green. The ordinary ladder-less video must stay
|
|
1377
|
+
* silent, which is the second assertion — a badge that appears on every unoptimized clip
|
|
1378
|
+
* stops meaning anything.
|
|
1379
|
+
*/
|
|
1380
|
+
test('🔴 an unreachable ladder reaches the player as `playbackDegraded`', () => {
|
|
1381
|
+
const video = stagePlayableVideo(item, { playbackDegraded: true }, '/files/v1.web.mp4');
|
|
1382
|
+
expect(video.playbackDegraded).toBe(true);
|
|
1383
|
+
// It never stands in for the ladder or for a processing fault — those are other sentences.
|
|
1384
|
+
expect('hlsUrl' in video).toBe(false);
|
|
1385
|
+
expect('processingStatus' in video).toBe(false);
|
|
1386
|
+
});
|
|
1387
|
+
|
|
1388
|
+
test('a video that simply has NO ladder leaves the field absent, so the badge stays quiet', () => {
|
|
1389
|
+
expect('playbackDegraded' in stagePlayableVideo(item, {}, '/files/v1.web.mp4')).toBe(false);
|
|
1390
|
+
expect(
|
|
1391
|
+
'playbackDegraded' in
|
|
1392
|
+
stagePlayableVideo(item, { playbackDegraded: false }, '/files/v1.web.mp4'),
|
|
1393
|
+
).toBe(false);
|
|
1394
|
+
});
|
|
1274
1395
|
});
|
|
1275
1396
|
|
|
1276
1397
|
/**
|
|
@@ -2446,3 +2567,170 @@ describe('MediaGallery — tags and descriptions', () => {
|
|
|
2446
2567
|
expect(line?.textContent).toContain('holiday');
|
|
2447
2568
|
});
|
|
2448
2569
|
});
|
|
2570
|
+
|
|
2571
|
+
/**
|
|
2572
|
+
* 🔴 A 404 AND A HANG MUST NOT LOOK THE SAME — the owner's report, 2026-09-17.
|
|
2573
|
+
*
|
|
2574
|
+
* *"make a 404 stop the media player spinner so I don't think it's going to eventually load."*
|
|
2575
|
+
* `itm_cbaebe887d3b48dab2c4` in his catalogue has no ladder, so the very first request its
|
|
2576
|
+
* stage made was answered honestly and immediately — `404`, `{"error":"not_found"}`, 0.2 s —
|
|
2577
|
+
* and the spinner then ran until he gave up. A timeout may keep spinning, because something
|
|
2578
|
+
* might still arrive. An answer may not, because nothing will.
|
|
2579
|
+
*
|
|
2580
|
+
* happy-dom can see all of this: both surfaces below are a plain `<video>` and a plain `<img>`,
|
|
2581
|
+
* the probe is a `fetch` a test can install, and what is under test is which element is in the
|
|
2582
|
+
* DOM and which sentence is on screen. (The real `fetch` is restored after each test — the unit
|
|
2583
|
+
* suite may not put a request on a real socket; see `iframesMakeNoRealRequest.spec.ts`.)
|
|
2584
|
+
*/
|
|
2585
|
+
describe('🔴 a source that ANSWERED stops the stage spinning and says why', () => {
|
|
2586
|
+
const realFetch = globalThis.fetch;
|
|
2587
|
+
afterEach(() => {
|
|
2588
|
+
globalThis.fetch = realFetch;
|
|
2589
|
+
});
|
|
2590
|
+
|
|
2591
|
+
/** Answer every probe with one status, and count how many were made. */
|
|
2592
|
+
function probesAnswer(...statuses: number[]): { count: () => number } {
|
|
2593
|
+
let n = 0;
|
|
2594
|
+
globalThis.fetch = (() => {
|
|
2595
|
+
const status = statuses[Math.min(n, statuses.length - 1)] ?? 404;
|
|
2596
|
+
n += 1;
|
|
2597
|
+
return Promise.resolve(new Response(null, { status }));
|
|
2598
|
+
}) as unknown as typeof fetch;
|
|
2599
|
+
return { count: () => n };
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
const GONE_VIDEO = 'This video is no longer on the media store (HTTP 404) — nothing is still loading.';
|
|
2603
|
+
const GONE_PICTURE =
|
|
2604
|
+
'This picture is no longer on the media store (HTTP 404) — nothing is still loading.';
|
|
2605
|
+
const STALE_PICTURE = "This picture's link has expired (HTTP 403) — reopen it for a fresh one.";
|
|
2606
|
+
|
|
2607
|
+
test('🔴 the native stage: a 404 source replaces the `<video>` with the reason', async () => {
|
|
2608
|
+
probesAnswer(404);
|
|
2609
|
+
const items: MediaGalleryItem[] = [
|
|
2610
|
+
{ id: 'vid1', name: 'clip.mp4', streamUrl: '/files/vid1.web.mp4', createdAt: 20 },
|
|
2611
|
+
];
|
|
2612
|
+
const { container, getByRole, getByText } = render(
|
|
2613
|
+
<MediaGallery items={items} nativeVideoStage />,
|
|
2614
|
+
);
|
|
2615
|
+
fireEvent.click(getByRole('button', { name: 'Open clip.mp4' }));
|
|
2616
|
+
const video = container.querySelector<HTMLVideoElement>('.cbmg-stage-video');
|
|
2617
|
+
expect(video).not.toBeNull();
|
|
2618
|
+
|
|
2619
|
+
fireEvent.error(video as HTMLVideoElement);
|
|
2620
|
+
|
|
2621
|
+
await waitFor(() => expect(getByText(GONE_VIDEO)).not.toBeNull());
|
|
2622
|
+
// 🔴 The element is GONE, not merely covered: a `<video>` still in the tree is a browser
|
|
2623
|
+
// spinner still on the picture, which is the entire complaint.
|
|
2624
|
+
expect(container.querySelector('.cbmg-stage-video')).toBeNull();
|
|
2625
|
+
});
|
|
2626
|
+
|
|
2627
|
+
test('🔴 the picture stage: a 404 image replaces the torn-page glyph with the reason', async () => {
|
|
2628
|
+
probesAnswer(404);
|
|
2629
|
+
const items: MediaGalleryItem[] = [
|
|
2630
|
+
{ id: 'img1', name: 'beach.jpg', url: '/files/img1', createdAt: 30 },
|
|
2631
|
+
];
|
|
2632
|
+
const { container, getByRole, getByText } = render(<MediaGallery items={items} />);
|
|
2633
|
+
fireEvent.click(getByRole('button', { name: 'Open beach.jpg' }));
|
|
2634
|
+
const img = container.querySelector<HTMLImageElement>('.cbgd-stage-img img');
|
|
2635
|
+
expect(img).not.toBeNull();
|
|
2636
|
+
|
|
2637
|
+
fireEvent.error(img as HTMLImageElement);
|
|
2638
|
+
|
|
2639
|
+
await waitFor(() => expect(getByText(GONE_PICTURE)).not.toBeNull());
|
|
2640
|
+
expect(container.querySelector('.cbgd-stage-img')).toBeNull();
|
|
2641
|
+
});
|
|
2642
|
+
|
|
2643
|
+
/*
|
|
2644
|
+
* 🔴 A 403 is the one fault that comes back on its own. Every signed URL in this fleet
|
|
2645
|
+
* carries `exp = iat + 2 × window`, so a page left open across a window boundary meets a
|
|
2646
|
+
* stale token legitimately — and the app can simply mint another. Saying "gone" there would
|
|
2647
|
+
* be a false and permanent-sounding sentence about a file that is still sitting in the store.
|
|
2648
|
+
*/
|
|
2649
|
+
test('🔴 a 403 re-asks the app for a fresh URL instead of stating anything', async () => {
|
|
2650
|
+
probesAnswer(403);
|
|
2651
|
+
let minted = 0;
|
|
2652
|
+
const items: MediaGalleryItem[] = [
|
|
2653
|
+
{
|
|
2654
|
+
id: 'img1',
|
|
2655
|
+
name: 'beach.jpg',
|
|
2656
|
+
createdAt: 30,
|
|
2657
|
+
resolveMedia: async () => {
|
|
2658
|
+
minted += 1;
|
|
2659
|
+
return { url: `/files/img1?sig=${minted}` };
|
|
2660
|
+
},
|
|
2661
|
+
},
|
|
2662
|
+
];
|
|
2663
|
+
const { container, getByRole, queryByText } = render(<MediaGallery items={items} />);
|
|
2664
|
+
fireEvent.click(getByRole('button', { name: 'Open beach.jpg' }));
|
|
2665
|
+
await waitFor(() =>
|
|
2666
|
+
expect(container.querySelector<HTMLImageElement>('.cbgd-stage-img img')?.src).toContain(
|
|
2667
|
+
'sig=1',
|
|
2668
|
+
),
|
|
2669
|
+
);
|
|
2670
|
+
|
|
2671
|
+
fireEvent.error(container.querySelector('.cbgd-stage-img img') as HTMLImageElement);
|
|
2672
|
+
|
|
2673
|
+
// A second signature, drawn as a picture again — not a sentence.
|
|
2674
|
+
await waitFor(() =>
|
|
2675
|
+
expect(container.querySelector<HTMLImageElement>('.cbgd-stage-img img')?.src).toContain(
|
|
2676
|
+
'sig=2',
|
|
2677
|
+
),
|
|
2678
|
+
);
|
|
2679
|
+
expect(minted).toBe(2);
|
|
2680
|
+
expect(queryByText(STALE_PICTURE)).toBeNull();
|
|
2681
|
+
});
|
|
2682
|
+
|
|
2683
|
+
/*
|
|
2684
|
+
* 🔴 And the re-ask is BOUNDED at one. An unbounded retry against a URL that is not going to
|
|
2685
|
+
* come back fresh is the same spinner with more requests in it — the failure `RECOVERY_BUDGET`
|
|
2686
|
+
* bounds one layer down, for the same reason.
|
|
2687
|
+
*/
|
|
2688
|
+
test('🔴 a second 403 states the reason rather than re-asking for ever', async () => {
|
|
2689
|
+
probesAnswer(403);
|
|
2690
|
+
let minted = 0;
|
|
2691
|
+
const items: MediaGalleryItem[] = [
|
|
2692
|
+
{
|
|
2693
|
+
id: 'img1',
|
|
2694
|
+
name: 'beach.jpg',
|
|
2695
|
+
createdAt: 30,
|
|
2696
|
+
resolveMedia: async () => {
|
|
2697
|
+
minted += 1;
|
|
2698
|
+
return { url: `/files/img1?sig=${minted}` };
|
|
2699
|
+
},
|
|
2700
|
+
},
|
|
2701
|
+
];
|
|
2702
|
+
const { container, getByRole, getByText } = render(<MediaGallery items={items} />);
|
|
2703
|
+
fireEvent.click(getByRole('button', { name: 'Open beach.jpg' }));
|
|
2704
|
+
await waitFor(() =>
|
|
2705
|
+
expect(container.querySelector('.cbgd-stage-img img')).not.toBeNull(),
|
|
2706
|
+
);
|
|
2707
|
+
|
|
2708
|
+
fireEvent.error(container.querySelector('.cbgd-stage-img img') as HTMLImageElement);
|
|
2709
|
+
await waitFor(() =>
|
|
2710
|
+
expect(container.querySelector<HTMLImageElement>('.cbgd-stage-img img')?.src).toContain(
|
|
2711
|
+
'sig=2',
|
|
2712
|
+
),
|
|
2713
|
+
);
|
|
2714
|
+
fireEvent.error(container.querySelector('.cbgd-stage-img img') as HTMLImageElement);
|
|
2715
|
+
|
|
2716
|
+
await waitFor(() => expect(getByText(STALE_PICTURE)).not.toBeNull());
|
|
2717
|
+
expect(minted).toBe(2);
|
|
2718
|
+
});
|
|
2719
|
+
|
|
2720
|
+
/*
|
|
2721
|
+
* 🔴 The guard on all of it: nothing here may fire while a source is merely SLOW. A stage
|
|
2722
|
+
* that has not been told anything is a stage that keeps waiting, which is correct — the
|
|
2723
|
+
* fix is about answers, and an element that has said nothing has not answered.
|
|
2724
|
+
*/
|
|
2725
|
+
test('🔴 a stage nobody has answered is still loading, and still says so', async () => {
|
|
2726
|
+
const { count } = probesAnswer(404);
|
|
2727
|
+
const items: MediaGalleryItem[] = [
|
|
2728
|
+
{ id: 'img1', name: 'beach.jpg', createdAt: 30, resolveMedia: async () => null },
|
|
2729
|
+
];
|
|
2730
|
+
const { getByText } = render(<MediaGallery items={items} />);
|
|
2731
|
+
fireEvent.click(getByText('beach.jpg'));
|
|
2732
|
+
await waitFor(() => expect(getByText('Image unavailable.')).not.toBeNull());
|
|
2733
|
+
// And no probe was made: the price of this feature on a working gallery is zero requests.
|
|
2734
|
+
expect(count()).toBe(0);
|
|
2735
|
+
});
|
|
2736
|
+
});
|