cursedbelt 5.3.0 → 5.3.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt",
3
- "version": "5.3.0",
3
+ "version": "5.3.1",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The React design system of the cursedbelt split — components, styles, theme. cursedbelt-core below it; server tier in cursedbelt-server.",
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
2
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
4
+ import { dirname, join } from 'node:path';
5
5
  import {
6
6
  cascadeFaults,
7
7
  checkAreaStyles,
@@ -12,6 +12,7 @@ import {
12
12
  onlyInMergeTables,
13
13
  readBeltSheets,
14
14
  readThemeVariables,
15
+ scanLeaks,
15
16
  themeVariablesIn,
16
17
  tokensInJs,
17
18
  undefinedThemeVariables,
@@ -19,6 +20,12 @@ import {
19
20
  } from './checkAreaStyles';
20
21
  import { compileStylesheet } from './generateUtilityStyles';
21
22
 
23
+ /**
24
+ * The CLI also runs the scan-leak check over its cwd. These fixtures live in a temp dir — which this
25
+ * repo's test preload puts under `.agent.noindex/` — so tell git the temp dir is in NO work tree.
26
+ */
27
+ const noRepoEnv = (dir: string) => ({ ...process.env, GIT_CEILING_DIRECTORIES: dirname(dir) });
28
+
22
29
  const BELT = {
23
30
  union: `@layer utilities { .flex { display: flex } .rounded-full { border-radius: 9999px } .hover\\:bg-muted { &:hover { @media (hover: hover) { background: var(--muted) } } } .recharts-tip { opacity: .5; } }`,
24
31
  areas: {
@@ -86,7 +93,7 @@ describe('checkAreaStyles', () => {
86
93
  const dir = mkdtempSync(join(tmpdir(), 'belt-area-check-'));
87
94
  try {
88
95
  const run = () =>
89
- Bun.spawnSync(['bun', join(import.meta.dir, 'checkAreaStyles.ts'), dir], { stdout: 'pipe', stderr: 'pipe' });
96
+ Bun.spawnSync(['bun', join(import.meta.dir, 'checkAreaStyles.ts'), dir], { cwd: dir, env: noRepoEnv(dir), stdout: 'pipe', stderr: 'pipe' });
90
97
  expect(run().exitCode).toBe(2);
91
98
  const { union } = readBeltSheets();
92
99
  // `truncate` is a core utility in every build of the union.
@@ -222,7 +229,7 @@ describe('🔴 the ORDER check — a base utility after a variant that sets the
222
229
  mkdirSync(join(dir, 'assets'));
223
230
  writeFileSync(join(dir, 'assets', 'index.js'), 'const c="truncate";');
224
231
  writeFileSync(join(dir, 'assets', 'index.css'), '@layer utilities{@media(min-width:40rem){.sm\\:flex{display:flex}}.truncate{overflow:hidden}.hidden{display:none}}');
225
- const r = Bun.spawnSync(['bun', join(import.meta.dir, 'checkAreaStyles.ts'), dir], { stdout: 'pipe', stderr: 'pipe' });
232
+ const r = Bun.spawnSync(['bun', join(import.meta.dir, 'checkAreaStyles.ts'), dir], { cwd: dir, env: noRepoEnv(dir), stdout: 'pipe', stderr: 'pipe' });
226
233
  expect(r.exitCode).toBe(1);
227
234
  expect(r.stderr.toString()).toContain('.hidden (later) overrides .sm:flex');
228
235
  } finally {
@@ -284,7 +291,7 @@ describe('🔴 the THEME VARIABLE check — a `var()` of a theme variable no bui
284
291
  mkdirSync(join(dir, 'assets'));
285
292
  writeFileSync(join(dir, 'assets', 'index.js'), `const c="truncate";${STATION_JS}`);
286
293
  writeFileSync(join(dir, 'assets', 'index.css'), '@layer theme{:root{--color-muted:var(--muted);--color-accent:var(--accent)}}@layer utilities{.truncate{overflow:hidden}}');
287
- const r = Bun.spawnSync(['bun', join(import.meta.dir, 'checkAreaStyles.ts'), dir], { stdout: 'pipe', stderr: 'pipe' });
294
+ const r = Bun.spawnSync(['bun', join(import.meta.dir, 'checkAreaStyles.ts'), dir], { cwd: dir, env: noRepoEnv(dir), stdout: 'pipe', stderr: 'pipe' });
288
295
  expect(r.exitCode).toBe(1);
289
296
  expect(r.stderr.toString()).toContain('--color-card');
290
297
  expect(r.stderr.toString()).not.toContain('--color-muted');
@@ -293,3 +300,116 @@ describe('🔴 the THEME VARIABLE check — a `var()` of a theme variable no bui
293
300
  }
294
301
  });
295
302
  });
303
+
304
+ describe('🔴 the SCAN-LEAK check — a scanned file ignored only by the global gitignore (5.3.1, family 0a298a5)', () => {
305
+ /** A git repo whose "machine-global" excludes file is a temp file — the real ~/.gitignore_global is never read. */
306
+ function fixture() {
307
+ const base = mkdtempSync(join(tmpdir(), 'belt-scan-leak-'));
308
+ const repo = join(base, 'app');
309
+ mkdirSync(repo);
310
+ const globalIgnore = join(base, 'gitignore_global');
311
+ writeFileSync(globalIgnore, '.DS_Store\n.agent.noindex/\n*.agent.*\nnode_modules\n');
312
+ const gitconfig = join(base, 'gitconfig');
313
+ writeFileSync(gitconfig, `[core]\n\texcludesFile = ${globalIgnore}\n`);
314
+ const env = { ...process.env, GIT_CONFIG_GLOBAL: gitconfig, GIT_CONFIG_NOSYSTEM: '1' };
315
+ expect(Bun.spawnSync(['git', 'init', '-q', repo], { env }).exitCode).toBe(0);
316
+ writeFileSync(join(repo, '.gitignore'), 'dist/\n');
317
+ mkdirSync(join(repo, 'src'));
318
+ writeFileSync(join(repo, 'src', 'App.tsx'), 'export const A = () => <div className="flex" />;');
319
+ // The scratch that made family's "core only" build look complete.
320
+ mkdirSync(join(repo, '.agent.noindex'));
321
+ writeFileSync(join(repo, '.agent.noindex', 'compare-styles.agent.ts'), 'const c = "truncate rounded-full grid-cols-3";');
322
+ writeFileSync(join(repo, 'src', 'probe.agent.tsx'), '<p className="sr-only" />');
323
+ // Globally ignored but nothing an extractor reads: binary, and inside node_modules.
324
+ writeFileSync(join(repo, '.DS_Store'), Buffer.from([0, 0, 0, 1, 66, 117, 100, 49, 0]));
325
+ mkdirSync(join(repo, 'node_modules', 'x'), { recursive: true });
326
+ writeFileSync(join(repo, 'node_modules', 'x', 'index.js'), 'const c = "flex";');
327
+ // Ignored by the repo itself — Tailwind skips it too.
328
+ mkdirSync(join(repo, 'dist'));
329
+ writeFileSync(join(repo, 'dist', 'index.js'), 'const c="truncate";');
330
+ return { base, repo, env, globalIgnore };
331
+ }
332
+
333
+ test('RED: each globally-ignored TEXT path is named with the file and pattern that matched', () => {
334
+ const { base, repo, env, globalIgnore } = fixture();
335
+ try {
336
+ const leaks = scanLeaks(repo, env);
337
+ expect(leaks?.map((l) => l.path)).toEqual(['.agent.noindex/', 'src/probe.agent.tsx']);
338
+ // `*.agent.*` (line 3) matches `.agent.noindex/` too, and the LAST match in a file decides.
339
+ expect(leaks?.[0]?.source).toBe(`${globalIgnore}:3`);
340
+ expect(leaks?.[0]?.textFiles).toBe(1);
341
+ expect(leaks?.[1]?.pattern).toBe('*.agent.*');
342
+ // From a SUBDIRECTORY root, only what is under it.
343
+ expect(scanLeaks(join(repo, 'src'), env)?.map((l) => l.path)).toEqual(['probe.agent.tsx']);
344
+ } finally {
345
+ rmSync(base, { recursive: true, force: true });
346
+ }
347
+ });
348
+
349
+ test("GREEN once the repo's OWN .gitignore names them — the global file still matches, but no longer decides", () => {
350
+ const { base, repo, env } = fixture();
351
+ try {
352
+ writeFileSync(join(repo, '.gitignore'), 'dist/\n.agent.noindex/\n*.agent.*\n');
353
+ expect(scanLeaks(repo, env)).toEqual([]);
354
+ } finally {
355
+ rmSync(base, { recursive: true, force: true });
356
+ }
357
+ });
358
+
359
+ test('.git/info/exclude is not the repo\'s .gitignore either — RED', () => {
360
+ const { base, repo, env } = fixture();
361
+ try {
362
+ writeFileSync(join(repo, '.gitignore'), 'dist/\n.agent.noindex/\n*.agent.*\n');
363
+ writeFileSync(join(repo, 'notes.md'), 'bg-primary');
364
+ writeFileSync(join(repo, '.git', 'info', 'exclude'), 'notes.md\n');
365
+ expect(scanLeaks(repo, env)?.map((l) => [l.path, l.source])).toEqual([['notes.md', '.git/info/exclude:1']]);
366
+ } finally {
367
+ rmSync(base, { recursive: true, force: true });
368
+ }
369
+ });
370
+
371
+ test('an app root that is itself git-ignored cannot be measured — it throws (the CLI exits 2)', () => {
372
+ const { base, repo, env } = fixture();
373
+ try {
374
+ expect(() => scanLeaks(join(repo, '.agent.noindex'), env)).toThrow(/itself git-ignored/);
375
+ } finally {
376
+ rmSync(base, { recursive: true, force: true });
377
+ }
378
+ });
379
+
380
+ test('outside any git work tree there is nothing to disagree about — null, not []', () => {
381
+ const dir = mkdtempSync(join(tmpdir(), 'belt-scan-leak-nogit-'));
382
+ try {
383
+ expect(scanLeaks(dir, noRepoEnv(dir))).toBeNull();
384
+ } finally {
385
+ rmSync(dir, { recursive: true, force: true });
386
+ }
387
+ });
388
+
389
+ test('the CLI exits 1 on a leak even when every class is styled, and 0 once the repo ignores it', () => {
390
+ const { base, repo, env } = fixture();
391
+ try {
392
+ writeFileSync(join(repo, 'dist', 'index.css'), '.truncate{overflow:hidden}');
393
+ const run = () =>
394
+ Bun.spawnSync(['bun', join(import.meta.dir, 'checkAreaStyles.ts'), 'dist'], { cwd: repo, env, stdout: 'pipe', stderr: 'pipe' });
395
+ const red = run();
396
+ expect(red.exitCode).toBe(1);
397
+ expect(red.stderr.toString()).toContain('.agent.noindex/');
398
+ expect(red.stderr.toString()).toContain('src/probe.agent.tsx');
399
+ // --root reads the same repo from anywhere.
400
+ const elsewhere = Bun.spawnSync(['bun', join(import.meta.dir, 'checkAreaStyles.ts'), join(repo, 'dist'), '--root', repo], {
401
+ cwd: base,
402
+ env,
403
+ stdout: 'pipe',
404
+ stderr: 'pipe',
405
+ });
406
+ expect(elsewhere.exitCode).toBe(1);
407
+ writeFileSync(join(repo, '.gitignore'), 'dist/\n.agent.noindex/\n*.agent.*\n');
408
+ const green = run();
409
+ expect(green.stderr.toString()).toBe('');
410
+ expect(green.exitCode).toBe(0);
411
+ } finally {
412
+ rmSync(base, { recursive: true, force: true });
413
+ }
414
+ });
415
+ });
@@ -35,15 +35,27 @@
35
35
  * component that reads one by name from a style prop is invisible to the class check above — the
36
36
  * hole station's frozen DataTable column fell through (F4, 2026-09-23). Exit 1 as well.
37
37
  *
38
- * Usage, from an app, after its build:
39
- * bun node_modules/cursedbelt/scripts/checkAreaStyles.ts dist
38
+ * ── …and whether the build could have SEEN files the repo does not own (5.3.1) ───────────
39
+ * {@link scanLeaks}: Tailwind v4's automatic source detection honours the repo's OWN `.gitignore`
40
+ * files but NOT the machine-global `core.excludesFile`. So agent scratch — `.agent.noindex/`,
41
+ * `*.agent.*`, ignored only globally — is scanned, adds its classes to the build, and every check
42
+ * above passes on a stylesheet a clean checkout cannot reproduce. Measured in apps/family
43
+ * (0a298a5, 2026-09-23): its first "core only" adoption was green, and a clean build was 65
44
+ * cursedbelt classes short. Any text file under the app root that git ignores through something
45
+ * other than a repo `.gitignore` (`git check-ignore -v` names the file that matched) is exit 1;
46
+ * the fix is the line in the repo's own `.gitignore`, which Tailwind does read.
47
+ *
48
+ * Usage, from an app ROOT (the directory Tailwind scans), after its build:
49
+ * bun node_modules/cursedbelt/scripts/checkAreaStyles.ts dist [--root <app root, default cwd>]
40
50
  * Exit 0 = complete, 1 = a class is unstyled (listed with its area), a base utility follows a
41
- * variant, or a theme variable read is declared nowhere; 2 = could not measure
51
+ * variant, a theme variable read is declared nowhere, or a scanned file is ignored only
52
+ * outside the repo's own `.gitignore`; 2 = could not measure
42
53
  * (no JS or no CSS under the directory — a check that measured nothing has not passed).
43
54
  */
44
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
55
+ import { spawnSync } from 'node:child_process';
56
+ import { closeSync, existsSync, lstatSync, openSync, readdirSync, readFileSync, readSync, statSync } from 'node:fs';
45
57
  import { createRequire } from 'node:module';
46
- import { basename, dirname, join, resolve } from 'node:path';
58
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
47
59
  import { fileURLToPath } from 'node:url';
48
60
 
49
61
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -354,6 +366,105 @@ export function readThemeVariables(src: string | undefined = SRC): Set<string> {
354
366
  return out;
355
367
  }
356
368
 
369
+ // ── the SCAN-LEAK check (5.3.1) ───────────────────────────────────────────────────────
370
+
371
+ export interface ScanLeak {
372
+ /** The ignored path, relative to the app root (`.agent.noindex/`, `notes.agent.md`). */
373
+ path: string;
374
+ /** The ignore file that matched — never a repo `.gitignore` — and its line and pattern. */
375
+ source: string;
376
+ pattern: string;
377
+ /** How many TEXT files it holds (what Tailwind's extractor reads). */
378
+ textFiles: number;
379
+ }
380
+
381
+ /** Tailwind never scans these; a leak inside them cannot reach the build. */
382
+ const NEVER_SCANNED = new Set(['node_modules', '.git']);
383
+
384
+ /** Text = no NUL byte in the first 8 KB — the test git and Tailwind's extractor both effectively apply. */
385
+ function isTextFile(file: string): boolean {
386
+ const fd = openSync(file, 'r');
387
+ try {
388
+ const buf = Buffer.alloc(8192);
389
+ const n = readSync(fd, buf, 0, buf.length, 0);
390
+ return !buf.subarray(0, n).includes(0);
391
+ } finally {
392
+ closeSync(fd);
393
+ }
394
+ }
395
+
396
+ /** Text files at or under `path`, not following symlinks, skipping node_modules/.git; stops counting at `cap`. */
397
+ function countTextFiles(path: string, cap = 1000): number {
398
+ let st: ReturnType<typeof lstatSync>;
399
+ try {
400
+ st = lstatSync(path);
401
+ } catch {
402
+ return 0;
403
+ }
404
+ if (st.isSymbolicLink()) return 0;
405
+ if (st.isFile()) return st.size > 0 && isTextFile(path) ? 1 : 0;
406
+ if (!st.isDirectory()) return 0;
407
+ let n = 0;
408
+ for (const entry of readdirSync(path)) {
409
+ if (NEVER_SCANNED.has(entry)) continue;
410
+ n += countTextFiles(join(path, entry), cap - n);
411
+ if (n >= cap) break;
412
+ }
413
+ return n;
414
+ }
415
+
416
+ function git(root: string, args: string[], env: NodeJS.ProcessEnv | undefined, input?: string) {
417
+ return spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', input, env: env ?? process.env, maxBuffer: 64 * 1024 * 1024 });
418
+ }
419
+
420
+ /**
421
+ * 🔴 Every path under `root` that exists, would be scanned (a text file, or a directory holding
422
+ * one, outside node_modules/.git), and is ignored by git ONLY through something that is not one of
423
+ * the repo's own `.gitignore` files — `core.excludesFile` (the machine-global one) or
424
+ * `.git/info/exclude`. Tailwind v4 reads neither, so each is a source of classes a clean checkout
425
+ * does not have. `git check-ignore -v` reports the DECIDING pattern, and a repo `.gitignore` outranks
426
+ * both, so a path the repo ignores itself is never reported however the global file also matches.
427
+ *
428
+ * Returns `null` when `root` is not inside a git work tree: then no gitignore of any kind applies, to
429
+ * git or to Tailwind, and there is nothing for the two to disagree about. `env` is for tests (a
430
+ * hermetic `GIT_CONFIG_GLOBAL`).
431
+ */
432
+ export function scanLeaks(root: string, env?: NodeJS.ProcessEnv): ScanLeak[] | null {
433
+ const top = git(root, ['rev-parse', '--show-toplevel'], env);
434
+ if (top.status !== 0) return null;
435
+ const topLevel = top.stdout.trim();
436
+ const rootRel = relative(topLevel, resolve(root));
437
+ if (rootRel && git(root, ['check-ignore', '-q', '--', `${resolve(root)}/`], env).status === 0) {
438
+ // git cannot list "the ignored files under an ignored directory", and which of them Tailwind reads
439
+ // is then a question about ITS walker. An app root is never inside an ignored directory; refuse.
440
+ throw new Error(`${root} is itself git-ignored in ${topLevel} — run from the app's root`);
441
+ }
442
+ const listed = git(root, ['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z', '--', '.'], env);
443
+ if (listed.status !== 0) throw new Error(`git ls-files failed in ${root}: ${listed.stderr.trim()}`);
444
+ const paths = listed.stdout
445
+ .split('\0')
446
+ .filter((p) => p.length > 0)
447
+ .filter((p) => !p.split('/').some((seg) => NEVER_SCANNED.has(seg)));
448
+ if (paths.length === 0) return [];
449
+ const matched = git(root, ['check-ignore', '-v', '-z', '--stdin'], env, `${paths.join('\0')}\0`);
450
+ // Exit 1 = none of them ignored (cannot happen for ls-files -i output, but is not an error).
451
+ if (matched.status !== 0 && matched.status !== 1) throw new Error(`git check-ignore failed in ${root}: ${matched.stderr.trim()}`);
452
+ const fields = matched.stdout.split('\0');
453
+ const leaks: ScanLeak[] = [];
454
+ for (let i = 0; i + 3 < fields.length; i += 4) {
455
+ const [source, line, pattern, path] = fields.slice(i, i + 4) as [string, string, string, string];
456
+ if (!source) continue;
457
+ const abs = isAbsolute(source) ? source : resolve(root, source);
458
+ const inRepo = !relative(topLevel, abs).startsWith('..') && !isAbsolute(relative(topLevel, abs));
459
+ const inGitDir = relative(topLevel, abs).split(sep)[0] === '.git';
460
+ if (basename(abs) === '.gitignore' && inRepo && !inGitDir) continue; // the repo's own — Tailwind reads it
461
+ const textFiles = countTextFiles(join(root, path));
462
+ if (textFiles === 0) continue; // .DS_Store, a screenshot: nothing an extractor could read
463
+ leaks.push({ path, source: `${source}:${line}`, pattern, textFiles });
464
+ }
465
+ return leaks.sort((a, b) => a.path.localeCompare(b.path));
466
+ }
467
+
357
468
  function filesUnder(dir: string, ext: string, out: string[] = []): string[] {
358
469
  for (const entry of readdirSync(dir)) {
359
470
  if (entry === 'node_modules') continue;
@@ -428,7 +539,11 @@ export function checkDist(distDir: string): AreaCheckResult {
428
539
  }
429
540
 
430
541
  if (import.meta.main) {
431
- const dir = resolve(process.argv[2] ?? 'dist');
542
+ const args = process.argv.slice(2);
543
+ const rootAt = args.indexOf('--root');
544
+ const root = resolve(rootAt >= 0 ? (args[rootAt + 1] ?? '.') : '.');
545
+ if (rootAt >= 0) args.splice(rootAt, 2);
546
+ const dir = resolve(args[0] ?? 'dist');
432
547
  if (!existsSync(dir)) {
433
548
  console.error(`✗ ${dir} does not exist — build the app first. A check that measured nothing has not passed.`);
434
549
  process.exit(2);
@@ -438,6 +553,22 @@ if (import.meta.main) {
438
553
  console.error(`✗ ${dir} holds ${result.jsFiles} .js and ${result.cssFiles} .css file(s) — nothing to compare.`);
439
554
  process.exit(2);
440
555
  }
556
+ let leaks: ScanLeak[];
557
+ try {
558
+ leaks = scanLeaks(root) ?? [];
559
+ } catch (e) {
560
+ console.error(`✗ could not tell which files Tailwind scanned: ${(e as Error).message}`);
561
+ process.exit(2);
562
+ }
563
+ if (leaks.length > 0) {
564
+ console.error(
565
+ `✗ ${leaks.length} path(s) under ${root} are ignored by git only OUTSIDE the repo's own .gitignore — Tailwind v4 scans them anyway,`,
566
+ );
567
+ console.error(' so their classes are in this build and a clean checkout\'s build would be short of them (apps/family 0a298a5: 65 classes):');
568
+ for (const l of leaks.slice(0, 12)) console.error(` ${l.path} (${l.textFiles} text file(s); ${l.source} \`${l.pattern}\`)`);
569
+ if (leaks.length > 12) console.error(` … +${leaks.length - 12} more`);
570
+ console.error(" Add each pattern to the repo's own .gitignore (e.g. `.agent.noindex/` and `*.agent.*`), rebuild, and check again.");
571
+ }
441
572
  if (result.outOfOrder.length > 0) {
442
573
  console.error(`✗ ${result.outOfOrder.length} base utilit(y|ies) come AFTER a variant that sets the same property, so the base wins at every width:`);
443
574
  for (const f of result.outOfOrder.slice(0, 12)) console.error(` .${f.base} (later) overrides .${f.variant} — ${f.properties.join(', ')}`);
@@ -456,10 +587,10 @@ if (import.meta.main) {
456
587
  console.error(' in a file the app\'s Tailwind pass scans.');
457
588
  }
458
589
  if (result.missing.length === 0) {
459
- if (result.outOfOrder.length > 0 || result.undefinedVariables.length > 0) process.exit(1);
590
+ if (result.outOfOrder.length > 0 || result.undefinedVariables.length > 0 || leaks.length > 0) process.exit(1);
460
591
  console.log(
461
592
  `✓ belt area styles complete: all ${result.referenced} cursedbelt classes the build references are styled, ` +
462
- `no base utility follows a variant, and every theme variable read is declared (${result.jsFiles} js, ${result.cssFiles} css).`,
593
+ `no base utility follows a variant, every theme variable read is declared, and nothing scanned is ignored only globally (${result.jsFiles} js, ${result.cssFiles} css).`,
463
594
  );
464
595
  process.exit(0);
465
596
  }
@@ -58,8 +58,11 @@ describe('attachHlsSource — every road ends at something playable', () => {
58
58
  // master it cannot parse is a permanent spinner, which is worse than the slow copy.
59
59
  const el = videoEl(false);
60
60
  const detach = attachHlsSource(el, { hlsUrl: LADDER, streamUrl: REMUX });
61
- await Promise.resolve();
62
- await new Promise((r) => setTimeout(r, 0));
61
+ // Poll, not one tick: the fallback lands after the dynamic `import('hls.js')` settles, and on a
62
+ // cold module cache (publish's `gate --force`, 2026-09-23) that took longer than one macrotask.
63
+ for (const deadline = Date.now() + 5000; el.src === '' && Date.now() < deadline; ) {
64
+ await new Promise((r) => setTimeout(r, 5));
65
+ }
63
66
  expect(el.src).toBe(REMUX);
64
67
  detach();
65
68
  });