cursedbelt 4.1.1 → 4.4.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/components/Autocomplete.d.ts.map +1 -1
- package/dist/react/components/Autocomplete.js +1 -1
- package/dist/react/components/Autocomplete.js.map +1 -1
- package/dist/react/components/ComboboxField.d.ts.map +1 -1
- package/dist/react/components/ComboboxField.js +1 -1
- package/dist/react/components/ComboboxField.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 +21 -3
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +178 -17
- 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/analytics.css +1 -1
- package/dist/styles-areas/data-table.css +1 -1
- package/dist/styles-areas/fields.css +1 -1
- package/dist/styles-areas/media-gallery.css +1 -1
- package/dist/styles-areas/media.css +1 -1
- package/dist/styles-areas/wizard.css +1 -1
- package/package.json +51 -6
- package/scripts/demoServer.ts +211 -0
- package/scripts/gate.ts +101 -0
- package/scripts/guardrailsEnforce.spec.ts +313 -1
- package/scripts/guardrailsEnforce.ts +283 -26
- package/scripts/paths.ts +1 -1
- package/src/demoFixture.spec.ts +54 -10
- package/src/demoStaticServer.spec.ts +169 -0
- package/src/publishShape.spec.ts +79 -0
- package/src/react/components/Autocomplete.tsx +11 -1
- package/src/react/components/ComboboxField.spec.tsx +12 -0
- package/src/react/components/ComboboxField.tsx +11 -1
- 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 +311 -0
- package/src/react/media-gallery/MediaGallery.tsx +382 -54
- 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/analytics.css +1 -1
- package/src/styles-areas/data-table.css +1 -1
- package/src/styles-areas/fields.css +1 -1
- package/src/styles-areas/media-gallery.css +1 -1
- package/src/styles-areas/media.css +1 -1
- package/src/styles-areas/wizard.css +1 -1
- package/src/testFilesRunInParallel.spec.ts +3 -3
- package/src/typecheckCachesAreSeparate.spec.ts +2 -2
- package/src/verifyGraph.spec.ts +75 -108
- package/scripts/verify.ts +0 -296
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 🔴 The demo's static server, and specifically the fault that used to end the gate.
|
|
3
|
+
*
|
|
4
|
+
* `bun run verify` went red about one run in four on an unmodified tree (task 388,
|
|
5
|
+
* 2026-09-18): `vite preview` — connect on Bun's `node:http` shim — threw
|
|
6
|
+
* `ERR_STREAM_WRITE_AFTER_END` out of `advanceResponsePipeline` when a Playwright worker
|
|
7
|
+
* hung up on a response that was still being written, and the throw landed after the
|
|
8
|
+
* handler had returned, where no middleware could catch it. The process exited 1, the
|
|
9
|
+
* `webServer` never came ready, and 138 innocent specs went red 120 seconds later.
|
|
10
|
+
*
|
|
11
|
+
* So the first assertion here is the one that matters: abort requests mid-flight and prove
|
|
12
|
+
* the server is still serving afterwards. Everything else in this file is the ordinary
|
|
13
|
+
* contract that makes the fixture pages reachable at all.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, it } from 'bun:test';
|
|
16
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { join, resolve } from 'node:path';
|
|
19
|
+
import { DEMO_HOST, DEMO_URL } from '../demo/devServer';
|
|
20
|
+
import { isPeerHangup, resolveRequestPath, startDemoServer } from '../scripts/demoServer';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A throwaway `dist` shaped like the one `bun run demo:build` writes. `$TMPDIR` is already
|
|
24
|
+
* redirected into this repo's scratch root by `scripts/testTmpRoot.ts` in the preload.
|
|
25
|
+
*/
|
|
26
|
+
function fixtureRoot(): string {
|
|
27
|
+
const root = mkdtempSync(join(tmpdir(), 'demo-server-'));
|
|
28
|
+
mkdirSync(resolve(root, 'assets'), { recursive: true });
|
|
29
|
+
writeFileSync(resolve(root, 'index.html'), '<!doctype html><title>kitchen sink</title>');
|
|
30
|
+
writeFileSync(resolve(root, 'table-layout.html'), '<!doctype html><title>table layout</title>');
|
|
31
|
+
// Big enough that an abort lands mid-body rather than after the whole thing arrived.
|
|
32
|
+
writeFileSync(resolve(root, 'assets', 'app.js'), `export const pad = '${'x'.repeat(4_000_000)}';\n`);
|
|
33
|
+
writeFileSync(resolve(root, 'assets', 'app.css'), ':root { color-scheme: dark; }\n');
|
|
34
|
+
return root;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Runs `body` against a server on an ephemeral port, and always stops it. */
|
|
38
|
+
async function withServer(body: (base: string) => Promise<void>): Promise<void> {
|
|
39
|
+
const server = startDemoServer({ root: fixtureRoot(), port: 0, hostname: '127.0.0.1' });
|
|
40
|
+
try {
|
|
41
|
+
await body(`http://127.0.0.1:${server.port}`);
|
|
42
|
+
} finally {
|
|
43
|
+
await server.stop(true);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('🔴 a client that hangs up is a non-event', () => {
|
|
48
|
+
it('keeps serving after a burst of aborted requests', async () => {
|
|
49
|
+
await withServer(async (base) => {
|
|
50
|
+
expect((await fetch(`${base}/`)).status, 'the server must be up before we abort at it').toBe(200);
|
|
51
|
+
|
|
52
|
+
// Eight, because that is how many Playwright workers this repo runs, and each one
|
|
53
|
+
// abandons in-flight subresources on every navigation.
|
|
54
|
+
for (let i = 0; i < 8; i++) {
|
|
55
|
+
const control = new AbortController();
|
|
56
|
+
const inFlight = fetch(`${base}/assets/app.js`, { signal: control.signal });
|
|
57
|
+
// Abort on the next turn of the loop: late enough that the response is being
|
|
58
|
+
// written, early enough that the body has not finished.
|
|
59
|
+
queueMicrotask(() => control.abort());
|
|
60
|
+
await inFlight.then(
|
|
61
|
+
async (response) => {
|
|
62
|
+
// If it won the race, drain it and abort during the read instead.
|
|
63
|
+
control.abort();
|
|
64
|
+
await response.text().catch(() => undefined);
|
|
65
|
+
},
|
|
66
|
+
() => undefined,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The whole point. Under `vite preview` the process was gone by now.
|
|
71
|
+
const after = await fetch(`${base}/`);
|
|
72
|
+
expect(after.status, 'the server died on a client hang-up — the exact task-388 bug').toBe(200);
|
|
73
|
+
expect(await after.text()).toInclude('kitchen sink');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('names the peer faults it tolerates, and nothing else', () => {
|
|
78
|
+
// 🔴 A blanket handler would keep a broken server alive and let the gate measure a
|
|
79
|
+
// corpse that answers 200. The list is closed; this is the negative control.
|
|
80
|
+
expect(isPeerHangup({ code: 'ERR_STREAM_WRITE_AFTER_END' })).toBe(true);
|
|
81
|
+
expect(isPeerHangup({ code: 'ECONNRESET' })).toBe(true);
|
|
82
|
+
expect(isPeerHangup({ code: 'EPIPE' })).toBe(true);
|
|
83
|
+
expect(isPeerHangup(Object.assign(new Error('aborted'), { name: 'AbortError' }))).toBe(true);
|
|
84
|
+
expect(isPeerHangup(new TypeError('x is not a function'))).toBe(false);
|
|
85
|
+
expect(isPeerHangup({ code: 'ENOSPC' })).toBe(false);
|
|
86
|
+
expect(isPeerHangup(null)).toBe(false);
|
|
87
|
+
expect(isPeerHangup('ECONNRESET')).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('the pages the specs navigate to', () => {
|
|
92
|
+
it('serves the fixture pages, with the right content type and no caching', async () => {
|
|
93
|
+
await withServer(async (base) => {
|
|
94
|
+
const index = await fetch(`${base}/`);
|
|
95
|
+
expect(index.status).toBe(200);
|
|
96
|
+
expect(index.headers.get('content-type')).toInclude('text/html');
|
|
97
|
+
// 🔴 A cached bundle is a gate reading pre-checkout code, which is the trap this
|
|
98
|
+
// whole change exists to close.
|
|
99
|
+
expect(index.headers.get('cache-control')).toInclude('no-store');
|
|
100
|
+
|
|
101
|
+
const page = await fetch(`${base}/table-layout.html`);
|
|
102
|
+
expect(page.status).toBe(200);
|
|
103
|
+
expect(await page.text()).toInclude('table layout');
|
|
104
|
+
|
|
105
|
+
const css = await fetch(`${base}/assets/app.css`);
|
|
106
|
+
expect(css.status).toBe(200);
|
|
107
|
+
expect(css.headers.get('content-type')).toInclude('text/css');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('404s what it does not have, rather than answering with index.html', async () => {
|
|
112
|
+
// A SPA fallback here would make `demoFixture.spec.ts`'s "every page the specs
|
|
113
|
+
// navigate to is real" assertion unfalsifiable at runtime: a typo'd fixture URL
|
|
114
|
+
// would load the kitchen sink and fail on a missing selector instead.
|
|
115
|
+
await withServer(async (base) => {
|
|
116
|
+
expect((await fetch(`${base}/no-such-page.html`)).status).toBe(404);
|
|
117
|
+
expect((await fetch(`${base}/assets/`)).status, 'no directory listings').toBe(404);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('what it refuses to serve', () => {
|
|
123
|
+
const root = '/tmp/demo-dist';
|
|
124
|
+
|
|
125
|
+
it('resolves the shapes the fixture actually uses', () => {
|
|
126
|
+
expect(resolveRequestPath(root, '/../../etc/passwd')).toBeNull();
|
|
127
|
+
expect(resolveRequestPath(root, '/%2e%2e/%2e%2e/etc/passwd')).toBeNull();
|
|
128
|
+
expect(resolveRequestPath(root, '/%zz')).toBeNull();
|
|
129
|
+
expect(resolveRequestPath(root, '/nope.html')).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('cannot be walked out of its root even through an encoded traversal', async () => {
|
|
133
|
+
// The pure check above says `null`; this says the socket agrees, because a server
|
|
134
|
+
// that resolved correctly and then read the file anyway would pass the first.
|
|
135
|
+
await withServer(async (base) => {
|
|
136
|
+
for (const path of ['/../package.json', '/%2e%2e/package.json', '/..%2fpackage.json']) {
|
|
137
|
+
const response = await fetch(`${base}${path}`);
|
|
138
|
+
expect(await response.text(), `${path} escaped the demo dist`).not.toInclude('cursedbelt');
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('the port', () => {
|
|
145
|
+
it('🔴 binds one literal address, so a second server cannot hide beside it', async () => {
|
|
146
|
+
// Measured 2026-09-18: with `hostname: 'localhost'` two processes bound 4318 at once,
|
|
147
|
+
// one on `[::1]` and one on `127.0.0.1`, and NEITHER raised EADDRINUSE — `localhost`
|
|
148
|
+
// resolves to both, so they were never competing for the same socket. The gate would
|
|
149
|
+
// then test whichever one the client happened to reach.
|
|
150
|
+
const server = startDemoServer({ root: fixtureRoot(), port: 0 });
|
|
151
|
+
try {
|
|
152
|
+
expect(server.hostname, 'a resolvable NAME here is two sockets pretending to be one').toBe(DEMO_HOST);
|
|
153
|
+
expect(DEMO_URL).toInclude(DEMO_HOST);
|
|
154
|
+
} finally {
|
|
155
|
+
await server.stop(true);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('🔴 refuses a taken port instead of walking to the next one', async () => {
|
|
160
|
+
// The same rule `strictPort: true` gave the vite servers: a fixture that quietly
|
|
161
|
+
// moves to 4319 is a suite that times out against the port it was pointed at.
|
|
162
|
+
const first = startDemoServer({ root: fixtureRoot(), port: 0, hostname: '127.0.0.1' });
|
|
163
|
+
try {
|
|
164
|
+
expect(() => startDemoServer({ root: fixtureRoot(), port: first.port, hostname: '127.0.0.1' })).toThrow();
|
|
165
|
+
} finally {
|
|
166
|
+
await first.stop(true);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
});
|
package/src/publishShape.spec.ts
CHANGED
|
@@ -52,6 +52,31 @@ const typesThatAreNotDeclarations = (exports: Record<string, unknown>): string[]
|
|
|
52
52
|
.filter(([, target]) => !target.endsWith('.d.ts'))
|
|
53
53
|
.map(([subpath, target]) => `${subpath} → ${target}`);
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Every `[subpath, condition, target]` this manifest resolves OUTSIDE `dist/`.
|
|
57
|
+
*
|
|
58
|
+
* `./dist/` is deliberately excluded: those are `scripts/checkDistExports.ts`' job, and it
|
|
59
|
+
* has to be a build step because `dist/` is the one directory no spec here may open
|
|
60
|
+
* (`src/verifyGraph.spec.ts` asserts it, because `build` runs beside `test`). Everything
|
|
61
|
+
* else is a file that is already on disk when the tests run, so it belongs here.
|
|
62
|
+
*
|
|
63
|
+
* A string-valued export (`'./styles.css': './src/styles.css'`) is one target under the
|
|
64
|
+
* implicit `default` condition — 48 of this manifest's entries are that shape.
|
|
65
|
+
*/
|
|
66
|
+
const targetsOutsideDist = (exports: Record<string, unknown>): [string, string, string][] => {
|
|
67
|
+
const out: [string, string, string][] = [];
|
|
68
|
+
for (const [subpath, entry] of Object.entries(exports)) {
|
|
69
|
+
const conditions: Record<string, unknown> =
|
|
70
|
+
typeof entry === 'string' ? { default: entry } : typeof entry === 'object' && entry !== null ? (entry as Record<string, unknown>) : {};
|
|
71
|
+
for (const [condition, target] of Object.entries(conditions)) {
|
|
72
|
+
if (typeof target === 'string' && target.startsWith('./') && !target.startsWith('./dist/')) {
|
|
73
|
+
out.push([subpath, condition, target]);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
};
|
|
79
|
+
|
|
55
80
|
describe('publish shape', () => {
|
|
56
81
|
it('ships every directory its exports resolve into', () => {
|
|
57
82
|
const files = (pkg as { files?: string[] }).files ?? [];
|
|
@@ -147,7 +172,61 @@ describe('publish shape', () => {
|
|
|
147
172
|
expect(typesThatAreNotDeclarations({ './a': { types: './dist/a.js' } })).toHaveLength(1);
|
|
148
173
|
});
|
|
149
174
|
|
|
175
|
+
it('🔴 points every non-dist export condition at a file that EXISTS', async () => {
|
|
176
|
+
// ── The third of three ways this manifest can lie, and the only one nothing watched ──
|
|
177
|
+
// `publishShape` above proves an export's ROOT directory is in `files`;
|
|
178
|
+
// `scripts/checkDistExports.ts` proves every `./dist/` target was emitted. Between
|
|
179
|
+
// them sits the condition a consumer actually RUNS — `bun`/`source`/`default`, which
|
|
180
|
+
// for this package is raw `./src/**` and `./scripts/guardrailsEnforce.ts` — and
|
|
181
|
+
// nothing proved those files are there. `files` ships whole directories, so renaming
|
|
182
|
+
// or moving one leaves the root shipped, the tarball valid, and the target dangling.
|
|
183
|
+
//
|
|
184
|
+
// It is not hypothetical and it is not ours to survive: since task 202 folded
|
|
185
|
+
// `--baseline` into the scanner, SIX apps run
|
|
186
|
+
// `bun node_modules/cursedbelt/scripts/guardrailsEnforce.ts …` inside their own
|
|
187
|
+
// `verify`. That path and this manifest's `./guardrails` conditions name the same
|
|
188
|
+
// file, so the day it moves, six repos go red in a file none of them wrote — the
|
|
189
|
+
// exact three-repos-away failure that tasks 172 and 219 each cost a session to find.
|
|
190
|
+
//
|
|
191
|
+
// The 48 string-valued CSS entries are the sharper half: they are GENERATED
|
|
192
|
+
// (`bun run styles:areas`) and have no second condition to fall through to, so a
|
|
193
|
+
// generator that stops emitting one is an unresolvable import for every consumer
|
|
194
|
+
// with nothing red on this side at all.
|
|
195
|
+
const repo = `${import.meta.dir}/..`;
|
|
196
|
+
const missing: string[] = [];
|
|
197
|
+
for (const [subpath, condition, target] of targetsOutsideDist(pkg.exports as unknown as Record<string, unknown>)) {
|
|
198
|
+
// 🔴 Bun.file, NOT node:fs — the preload virtualizes node:fs and an existsSync
|
|
199
|
+
// here would answer from MOCK data. Same trap as the header of this file.
|
|
200
|
+
if (!(await Bun.file(`${repo}/${target.slice(2)}`).exists())) missing.push(`${subpath} [${condition}] → ${target}`);
|
|
201
|
+
}
|
|
202
|
+
expect(
|
|
203
|
+
missing,
|
|
204
|
+
'these export conditions name a file that is not on disk. A consumer does not get a\n' +
|
|
205
|
+
'helpful error — the resolver falls through to whatever condition is left, or fails\n' +
|
|
206
|
+
'outright for the CSS entries, which have none:\n ' +
|
|
207
|
+
missing.join('\n '),
|
|
208
|
+
).toEqual([]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('catches a dangling non-dist target, and leaves dist to the build step', () => {
|
|
212
|
+
// The negative control for the rule above. Without it, `targetsOutsideDist` could
|
|
213
|
+
// quietly stop matching and the assertion would be green against nothing.
|
|
214
|
+
const found = targetsOutsideDist({
|
|
215
|
+
'./guardrails': { types: './dist/scripts/guardrailsEnforce.d.ts', bun: './scripts/moved.ts' },
|
|
216
|
+
'./styles.css': './src/styles.css',
|
|
217
|
+
'./react': { types: './dist/react/index.d.ts', import: './dist/react/index.js' },
|
|
218
|
+
});
|
|
219
|
+
// The `./dist/` conditions are checkDistExports' to police, so neither appears here.
|
|
220
|
+
expect(found).toEqual([
|
|
221
|
+
['./guardrails', 'bun', './scripts/moved.ts'],
|
|
222
|
+
['./styles.css', 'default', './src/styles.css'],
|
|
223
|
+
]);
|
|
224
|
+
});
|
|
225
|
+
|
|
150
226
|
it('reads the real manifest, so the rule cannot pass vacuously', () => {
|
|
227
|
+
// The scan above must see the whole non-dist surface, not a handful of it: 97 targets
|
|
228
|
+
// across the `bun`/`source`/`default` conditions and the 48 bare CSS entries.
|
|
229
|
+
expect(targetsOutsideDist(pkg.exports as unknown as Record<string, unknown>).length).toBeGreaterThan(90);
|
|
151
230
|
// `types` is what every conditional export in this package leads with; if the scan
|
|
152
231
|
// ever returns nothing, both assertions above are green about a manifest they never
|
|
153
232
|
// looked at.
|
|
@@ -138,7 +138,17 @@ export function AutocompleteField<S = string>({
|
|
|
138
138
|
</Autocomplete.InputGroup>
|
|
139
139
|
|
|
140
140
|
<Autocomplete.Portal>
|
|
141
|
-
|
|
141
|
+
{/* 🔴 The z-index goes on the POSITIONER, not only the popup. Base UI's positioner is
|
|
142
|
+
the element that is `position: absolute` (with a transform); the popup inside it is
|
|
143
|
+
`position: static`, so the popup's own `z-[var(--z-popover)]` does nothing and the
|
|
144
|
+
list painted UNDER any sibling with a z-index — measured 2026-09-22 in the
|
|
145
|
+
binary-server inspector, a sticky table header (z-index 1) covering the second option
|
|
146
|
+
of an open MultiSelectField. */}
|
|
147
|
+
<Autocomplete.Positioner
|
|
148
|
+
sideOffset={4}
|
|
149
|
+
collisionPadding={FLOATING_COLLISION_PADDING}
|
|
150
|
+
className='z-[var(--z-popover)]'
|
|
151
|
+
>
|
|
142
152
|
<Autocomplete.Popup
|
|
143
153
|
className={cn(
|
|
144
154
|
FLOATING_CONTENT_BASE,
|
|
@@ -53,6 +53,18 @@ describe('ComboboxField (single)', () => {
|
|
|
53
53
|
expect(getByText('Banana')).not.toBeNull();
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
+
test('🔴 the POSITIONER carries the popover z-index — the popup is static inside it', () => {
|
|
57
|
+
const { container, getByRole } = render(<Controlled />);
|
|
58
|
+
const trigger = container.querySelector('[aria-label="Toggle options"]') as HTMLElement;
|
|
59
|
+
act(() => {
|
|
60
|
+
fireEvent.click(trigger);
|
|
61
|
+
});
|
|
62
|
+
const popup = getByRole('listbox').closest('[data-side]') as HTMLElement;
|
|
63
|
+
const positioner = popup.parentElement?.closest('[data-side]') as HTMLElement;
|
|
64
|
+
expect(positioner).not.toBeNull();
|
|
65
|
+
expect(positioner.className).toContain('z-[var(--z-popover)]');
|
|
66
|
+
});
|
|
67
|
+
|
|
56
68
|
test('disabled prevents the trigger from responding', () => {
|
|
57
69
|
const onChange = mock(() => {});
|
|
58
70
|
const { container } = render(
|
|
@@ -291,7 +291,17 @@ export function ComboboxField(props: ComboboxFieldProps) {
|
|
|
291
291
|
</Combobox.InputGroup>
|
|
292
292
|
|
|
293
293
|
<Combobox.Portal>
|
|
294
|
-
|
|
294
|
+
{/* 🔴 The z-index goes on the POSITIONER, not only the popup. Base UI's positioner is
|
|
295
|
+
the element that is `position: absolute` (with a transform); the popup inside it is
|
|
296
|
+
`position: static`, so the popup's own `z-[var(--z-popover)]` does nothing and the
|
|
297
|
+
list painted UNDER any sibling with a z-index — measured 2026-09-22 in the
|
|
298
|
+
binary-server inspector, a sticky table header (z-index 1) covering the second option
|
|
299
|
+
of an open MultiSelectField. */}
|
|
300
|
+
<Combobox.Positioner
|
|
301
|
+
sideOffset={4}
|
|
302
|
+
collisionPadding={FLOATING_COLLISION_PADDING}
|
|
303
|
+
className='z-[var(--z-popover)]'
|
|
304
|
+
>
|
|
295
305
|
<Combobox.Popup
|
|
296
306
|
className={cn(
|
|
297
307
|
FLOATING_CONTENT_BASE,
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import type { PlayableVideo } from 'cursedbelt-core/media';
|
|
3
2
|
import { Button } from '../components/Button';
|
|
4
3
|
import { IconButton } from '../components/IconButton';
|
|
5
4
|
import { useHlsSource } from './hlsSource';
|
|
6
|
-
import { DEFAULT_VIDEO_ASPECT_RATIO, ProcessingIndicator } from './VideoPlayer';
|
|
5
|
+
import { DEFAULT_VIDEO_ASPECT_RATIO, type PlayerVideo, ProcessingIndicator } from './VideoPlayer';
|
|
7
6
|
|
|
8
7
|
// ── The green-frame-immune fallback player ───────────────────────────────────────
|
|
9
8
|
// On some GPUs Chrome composites a hardware-decoded <video> as a solid GREEN frame when it's
|
|
@@ -12,7 +11,7 @@ import { DEFAULT_VIDEO_ASPECT_RATIO, ProcessingIndicator } from './VideoPlayer';
|
|
|
12
11
|
// reintroduced it). The decode is correct — drawing the video to a <canvas> yields right pixels.
|
|
13
12
|
// So this keeps a HIDDEN <video> as the decode/audio/seek engine and paints each presented frame
|
|
14
13
|
// to a <canvas> (composited as an ordinary texture, immune to the bug). It's a drop-in swap for
|
|
15
|
-
// the Vidstack <VideoPlayer> (same
|
|
14
|
+
// the Vidstack <VideoPlayer> (same PlayerVideo + props); switch to it if you see green frames.
|
|
16
15
|
// Ported from ca's CanvasVideoPlayer (Tailwind, themed off cursedbelt's --accent token). The
|
|
17
16
|
// source attach — native HLS / hls.js / progressive — is `hlsSource.ts`, shared with the
|
|
18
17
|
// gallery's native stage.
|
|
@@ -26,7 +25,8 @@ function fmtTime(s: number): string {
|
|
|
26
25
|
}
|
|
27
26
|
|
|
28
27
|
export interface CanvasVideoPlayerProps {
|
|
29
|
-
|
|
28
|
+
/** Same clip shape as {@link VideoPlayer} — see {@link PlayerVideo}. */
|
|
29
|
+
video: PlayerVideo;
|
|
30
30
|
autoPlay?: boolean;
|
|
31
31
|
muted?: boolean;
|
|
32
32
|
className?: string;
|
|
@@ -204,7 +204,8 @@ export function CanvasVideoPlayer({
|
|
|
204
204
|
};
|
|
205
205
|
|
|
206
206
|
const status = video.processingStatus;
|
|
207
|
-
|
|
207
|
+
// An unreachable ladder is its own reason to draw — see `VideoPlayer`'s copy of this line.
|
|
208
|
+
const showProcessing = (status != null && status !== 'ready') || video.playbackDegraded === true;
|
|
208
209
|
|
|
209
210
|
return (
|
|
210
211
|
// Same intrinsic-sizing contract as <VideoPlayer>: aspect-ratio keeps the surface visible
|
|
@@ -234,7 +235,11 @@ export function CanvasVideoPlayer({
|
|
|
234
235
|
/>
|
|
235
236
|
|
|
236
237
|
{showProcessing ? (
|
|
237
|
-
<ProcessingIndicator
|
|
238
|
+
<ProcessingIndicator
|
|
239
|
+
status={status}
|
|
240
|
+
progress={video.processingProgress}
|
|
241
|
+
playbackDegraded={video.playbackDegraded}
|
|
242
|
+
/>
|
|
238
243
|
) : null}
|
|
239
244
|
|
|
240
245
|
{/* Controls */}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { resolve } from 'node:path';
|
|
4
|
-
import
|
|
4
|
+
import type React from 'react';
|
|
5
|
+
import { cleanup, fireEvent, render } from '@testing-library/react';
|
|
5
6
|
import { FrameGrabber } from './FrameGrabber';
|
|
6
7
|
|
|
7
8
|
afterEach(cleanup);
|
|
@@ -173,3 +174,58 @@ describe('FrameGrabber — the exit is named in the dialog itself', () => {
|
|
|
173
174
|
expect(text, 'nothing tells the reader how to leave without saving').toContain('escape');
|
|
174
175
|
});
|
|
175
176
|
});
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 🔴 The dialog SAYS when it has no film, and keeps its hands off the save buttons.
|
|
180
|
+
*
|
|
181
|
+
* The turn dialog's report (`ideas/collections-09-14.md`: *"When I click the turn the video
|
|
182
|
+
* button I see a black screen"*) turned out to be true of four siblings, this one included: it
|
|
183
|
+
* was mounted `src={item.streamUrl ?? item.url ?? ''}` off the raw row, and an item is allowed
|
|
184
|
+
* to carry neither field (`resolveMedia` is the documented path for a signed URL). Here the
|
|
185
|
+
* consequence is a step beyond a bad preview: every output of this dialog is pixels read back
|
|
186
|
+
* off the `<video>`, so a black frame came with two live-looking save buttons that could only
|
|
187
|
+
* fail at `toBlob` — after somebody had scrubbed for their moment.
|
|
188
|
+
*/
|
|
189
|
+
describe('FrameGrabber — when it cannot show the film', () => {
|
|
190
|
+
const openWith = (props: Partial<React.ComponentProps<typeof FrameGrabber>>) =>
|
|
191
|
+
render(
|
|
192
|
+
<FrameGrabber
|
|
193
|
+
src=''
|
|
194
|
+
name='wedding.mp4'
|
|
195
|
+
onClose={() => {}}
|
|
196
|
+
onSaveThumbnail={() => {}}
|
|
197
|
+
onSaveSnapshot={() => {}}
|
|
198
|
+
{...props}
|
|
199
|
+
/>,
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
test('🔴 no source at all is a stated reason, not a black box', () => {
|
|
203
|
+
const { getByRole } = openWith({});
|
|
204
|
+
expect(body().querySelector('video')).toBeNull();
|
|
205
|
+
expect(getByRole('status').textContent).toContain('no video source');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('🔴 the save button is HELD, because there is nothing to read a frame from', () => {
|
|
209
|
+
const { getByRole } = openWith({});
|
|
210
|
+
const keep = getByRole('button', { name: /thumbnail/i }) as HTMLButtonElement;
|
|
211
|
+
// DISABLED and still on screen, which is the `DESIGN.md` rule for a control that cannot
|
|
212
|
+
// work here: hiding it would make "this film has not arrived" look like "this app cannot
|
|
213
|
+
// choose a thumbnail", and the reason is already stated on the frame beside it.
|
|
214
|
+
expect(keep.disabled).toBe(true);
|
|
215
|
+
expect(body().contains(keep)).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('🔴 a source that fails to load says so too', () => {
|
|
219
|
+
const { getByRole, queryByRole } = openWith({ src: '/films/gone.mp4' });
|
|
220
|
+
const film = body().querySelector('video');
|
|
221
|
+
expect(film).not.toBeNull();
|
|
222
|
+
expect(queryByRole('status')).toBeNull();
|
|
223
|
+
fireEvent.error(film as HTMLVideoElement);
|
|
224
|
+
expect(getByRole('status').textContent).toContain('could not be loaded');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('a source still being resolved reads as "on its way", not as a failure', () => {
|
|
228
|
+
const { getByRole } = openWith({ src: '', resolving: true });
|
|
229
|
+
expect(getByRole('status').textContent).toContain('Finding this film');
|
|
230
|
+
});
|
|
231
|
+
});
|