sibujs 4.4.0 → 4.6.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.
Files changed (112) hide show
  1. package/README.md +11 -0
  2. package/dist/browser.cjs +339 -130
  3. package/dist/browser.d.cts +61 -16
  4. package/dist/browser.d.ts +61 -16
  5. package/dist/browser.js +8 -6
  6. package/dist/build.cjs +273 -82
  7. package/dist/build.js +15 -16
  8. package/dist/cdn.dev.global.js +12 -12
  9. package/dist/cdn.full.dev.global.js +11 -11
  10. package/dist/cdn.full.global.js +10 -10
  11. package/dist/cdn.global.js +12 -12
  12. package/dist/{chunk-IXKSNWV5.js → chunk-2INLLLMZ.js} +1 -1
  13. package/dist/chunk-3ISI6ACU.js +44 -0
  14. package/dist/{chunk-VCTAEPSB.js → chunk-47M47FOM.js} +11 -3
  15. package/dist/chunk-5HZXGZ6T.js +24 -0
  16. package/dist/{chunk-KKLW7YWL.js → chunk-5MT6SJ3P.js} +275 -144
  17. package/dist/{chunk-3BTTCZ5J.js → chunk-5Q4R7HCL.js} +42 -4
  18. package/dist/{chunk-ADM46X22.js → chunk-7GQCWFOE.js} +157 -61
  19. package/dist/{chunk-4Z3SPQ67.js → chunk-7NCARGJW.js} +16 -8
  20. package/dist/{chunk-5DXA2J44.js → chunk-7XHATCIH.js} +5 -2
  21. package/dist/{chunk-LQFGQNMV.js → chunk-AIF3Z2T7.js} +302 -39
  22. package/dist/{chunk-UCALUKB5.js → chunk-BAAG6ZTI.js} +98 -29
  23. package/dist/{chunk-NVNJH22U.js → chunk-D33YTSX5.js} +2 -2
  24. package/dist/{chunk-M2F7TZHH.js → chunk-DKGBBKOF.js} +136 -80
  25. package/dist/{chunk-NIOYEGBQ.js → chunk-HURREPU2.js} +27 -13
  26. package/dist/{chunk-HFCOH2GN.js → chunk-HYXDKS4N.js} +153 -110
  27. package/dist/chunk-J6FW5TV6.js +233 -0
  28. package/dist/{chunk-36S2YPP4.js → chunk-JSXPZCET.js} +1 -1
  29. package/dist/chunk-NYNYSPK7.js +318 -0
  30. package/dist/{chunk-HQSEH5F6.js → chunk-ORMZXBKQ.js} +108 -45
  31. package/dist/{chunk-LYVUX7NT.js → chunk-OXUY2A6L.js} +287 -130
  32. package/dist/{chunk-6GSIXTWX.js → chunk-PK6FK2G2.js} +187 -66
  33. package/dist/{chunk-W2EQ7X2L.js → chunk-QE4TTDU3.js} +47 -9
  34. package/dist/{chunk-XLO7SLIX.js → chunk-SXXVZMKZ.js} +262 -70
  35. package/dist/{chunk-R25EFXXC.js → chunk-TUCPL2HB.js} +3 -3
  36. package/dist/{chunk-RR7M3FHM.js → chunk-UZQ6ALFS.js} +3 -3
  37. package/dist/{chunk-PNIRUQ4C.js → chunk-VVWPJ543.js} +6 -8
  38. package/dist/{chunk-7GIHSAWB.js → chunk-XC4MEKGA.js} +3 -3
  39. package/dist/{chunk-SKPTERHP.js → chunk-XRRZKZYX.js} +55 -31
  40. package/dist/{contracts-DBdg9J_a.d.cts → contracts-DRIuclVT.d.cts} +10 -2
  41. package/dist/{contracts-DBdg9J_a.d.ts → contracts-DRIuclVT.d.ts} +10 -2
  42. package/dist/{customElement-CNZxEB9G.d.ts → customElement-MmInOW1U.d.cts} +59 -10
  43. package/dist/{customElement-CNZxEB9G.d.cts → customElement-MmInOW1U.d.ts} +59 -10
  44. package/dist/data.cjs +293 -125
  45. package/dist/data.d.cts +47 -9
  46. package/dist/data.d.ts +47 -9
  47. package/dist/data.js +12 -9
  48. package/dist/devtools.cjs +121 -58
  49. package/dist/devtools.js +7 -8
  50. package/dist/dispose-GEIG2KOF.js +28 -0
  51. package/dist/ecosystem.cjs +391 -112
  52. package/dist/ecosystem.d.cts +31 -7
  53. package/dist/ecosystem.d.ts +31 -7
  54. package/dist/ecosystem.js +12 -12
  55. package/dist/extras.cjs +2250 -879
  56. package/dist/extras.d.cts +10 -9
  57. package/dist/extras.d.ts +10 -9
  58. package/dist/extras.js +39 -28
  59. package/dist/index.cjs +273 -82
  60. package/dist/index.d.cts +44 -154
  61. package/dist/index.d.ts +44 -154
  62. package/dist/index.js +24 -27
  63. package/dist/motion.cjs +137 -42
  64. package/dist/motion.js +5 -5
  65. package/dist/patterns.cjs +397 -48
  66. package/dist/patterns.d.cts +32 -9
  67. package/dist/patterns.d.ts +32 -9
  68. package/dist/patterns.js +8 -8
  69. package/dist/performance.cjs +338 -217
  70. package/dist/performance.d.cts +2 -2
  71. package/dist/performance.d.ts +2 -2
  72. package/dist/performance.js +8 -9
  73. package/dist/plugin-DVgSnTfK.d.cts +112 -0
  74. package/dist/plugin-DVgSnTfK.d.ts +112 -0
  75. package/dist/plugins.cjs +587 -203
  76. package/dist/plugins.d.cts +3 -3
  77. package/dist/plugins.d.ts +3 -3
  78. package/dist/plugins.js +35 -23
  79. package/dist/signal-EotCj4hS.d.cts +110 -0
  80. package/dist/signal-EotCj4hS.d.ts +110 -0
  81. package/dist/{ssr-BiPRdZ6n.d.cts → ssr-Bli9XRW5.d.cts} +5 -0
  82. package/dist/{ssr-BiPRdZ6n.d.ts → ssr-Bli9XRW5.d.ts} +5 -0
  83. package/dist/{ssr-Y7XOEPEN.js → ssr-XOTUASDO.js} +4 -5
  84. package/dist/ssr.cjs +214 -73
  85. package/dist/ssr.d.cts +9 -3
  86. package/dist/ssr.d.ts +9 -3
  87. package/dist/ssr.js +11 -12
  88. package/dist/{startup-BMpaiMhP.d.ts → startup-BLfSeL15.d.cts} +73 -22
  89. package/dist/{startup-BMpaiMhP.d.cts → startup-BLfSeL15.d.ts} +73 -22
  90. package/dist/tagFactory-DFstCLQV.d.cts +117 -0
  91. package/dist/tagFactory-DkaNVUNV.d.ts +117 -0
  92. package/dist/testing.cjs +2481 -2153
  93. package/dist/testing.d.cts +56 -5
  94. package/dist/testing.d.ts +56 -5
  95. package/dist/testing.js +580 -307
  96. package/dist/ui.cjs +798 -328
  97. package/dist/ui.d.cts +40 -7
  98. package/dist/ui.d.ts +40 -7
  99. package/dist/ui.js +151 -56
  100. package/dist/widgets.cjs +319 -285
  101. package/dist/widgets.js +9 -10
  102. package/package.json +2 -2
  103. package/dist/chunk-2WLZ6757.js +0 -149
  104. package/dist/chunk-7NBDXVHS.js +0 -60
  105. package/dist/chunk-CCSJMTRN.js +0 -15
  106. package/dist/chunk-QKRPLZ2V.js +0 -108
  107. package/dist/chunk-WWV3SJ3L.js +0 -131
  108. package/dist/dispose-46BOMMQJ.js +0 -19
  109. package/dist/plugin-D30wlGW5.d.cts +0 -71
  110. package/dist/plugin-D30wlGW5.d.ts +0 -71
  111. package/dist/tagFactory-Bzupt4Pj.d.cts +0 -55
  112. package/dist/tagFactory-Bzupt4Pj.d.ts +0 -55
package/dist/testing.cjs CHANGED
@@ -45,6 +45,7 @@ __export(testing_exports, {
45
45
  createTimerMock: () => createTimerMock,
46
46
  createUniversalAdapter: () => createUniversalAdapter,
47
47
  createVisualSuite: () => createVisualSuite,
48
+ enableListenerTracking: () => enableListenerTracking,
48
49
  findByRole: () => findByRole,
49
50
  findByTestId: () => findByTestId,
50
51
  findByText: () => findByText,
@@ -52,6 +53,8 @@ __export(testing_exports, {
52
53
  matchSnapshot: () => matchSnapshot,
53
54
  mockRouter: () => mockRouter,
54
55
  mockStore: () => mockStore,
56
+ queryAllByAttribute: () => queryAllByAttribute,
57
+ queryByAttribute: () => queryByAttribute,
55
58
  queryByLabel: () => queryByLabel,
56
59
  queryByRole: () => queryByRole,
57
60
  queryByTestId: () => queryByTestId,
@@ -181,6 +184,23 @@ function reportDrainRunaway(label, executed, remaining) {
181
184
  );
182
185
  }
183
186
  var activeBindingCount = 0;
187
+ function resolveCapture(capture) {
188
+ let frame = capture;
189
+ while (frame?.closed) frame = frame.forwardTo;
190
+ return frame ?? null;
191
+ }
192
+ var registrationCaptures = [];
193
+ function currentDisposerCapture() {
194
+ return resolveCapture(registrationCaptures[registrationCaptures.length - 1]);
195
+ }
196
+ function beginDisposerCapture(capture) {
197
+ if (capture === null && registrationCaptures.length === 0) return false;
198
+ registrationCaptures.push(resolveCapture(capture));
199
+ return true;
200
+ }
201
+ function endDisposerCapture() {
202
+ registrationCaptures.pop();
203
+ }
184
204
  function dispose(node) {
185
205
  const stack = [node];
186
206
  const order = [];
@@ -193,33 +213,34 @@ function dispose(node) {
193
213
  }
194
214
  }
195
215
  for (let i = order.length - 1; i >= 0; i--) {
196
- const current = order[i];
197
- if (elementDisposers.has(current)) {
198
- let executed = 0;
199
- let runaway = false;
200
- while (!runaway) {
201
- const pending = elementDisposers.get(current);
202
- if (!pending || pending.length === 0) break;
203
- const snapshot = pending.slice();
204
- elementDisposers.delete(current);
205
- if (DEV) activeBindingCount -= snapshot.length;
206
- for (let i2 = 0; i2 < snapshot.length; i2++) {
207
- if (executed >= MAX_DRAIN_TEARDOWNS) {
208
- const rest = snapshot.slice(i2);
209
- const added = elementDisposers.get(current);
210
- elementDisposers.set(current, added ? rest.concat(added) : rest);
211
- if (DEV) activeBindingCount += rest.length;
212
- reportDrainRunaway("dispose", executed, rest.length + (added?.length ?? 0));
213
- runaway = true;
214
- break;
215
- }
216
- executed++;
217
- try {
218
- snapshot[i2]();
219
- } catch (err) {
220
- reportError(err, { phase: "cleanup", name: "disposer" });
221
- }
222
- }
216
+ disposeNodeOwn(order[i]);
217
+ }
218
+ }
219
+ function disposeNodeOwn(node) {
220
+ if (!elementDisposers.has(node)) return;
221
+ let executed = 0;
222
+ let runaway = false;
223
+ while (!runaway) {
224
+ const pending = elementDisposers.get(node);
225
+ if (!pending || pending.length === 0) break;
226
+ const snapshot = pending.slice();
227
+ elementDisposers.delete(node);
228
+ if (DEV) activeBindingCount -= snapshot.length;
229
+ for (let i = 0; i < snapshot.length; i++) {
230
+ if (executed >= MAX_DRAIN_TEARDOWNS) {
231
+ const rest = snapshot.slice(i);
232
+ const added = elementDisposers.get(node);
233
+ elementDisposers.set(node, added ? rest.concat(added) : rest);
234
+ if (DEV) activeBindingCount += rest.length;
235
+ reportDrainRunaway("dispose", executed, rest.length + (added?.length ?? 0));
236
+ runaway = true;
237
+ break;
238
+ }
239
+ executed++;
240
+ try {
241
+ snapshot[i]();
242
+ } catch (err) {
243
+ reportError(err, { phase: "cleanup", name: "disposer" });
223
244
  }
224
245
  }
225
246
  }
@@ -242,2262 +263,2524 @@ function globalSingleton(key, create) {
242
263
  return g[key] ?? (g[key] = create());
243
264
  }
244
265
 
245
- // src/testing/a11y.ts
246
- function escSel(value) {
247
- return value.replace(/["\\]/g, "\\$&");
248
- }
249
- var VALID_ARIA_ROLES = /* @__PURE__ */ new Set([
250
- "alert",
251
- "alertdialog",
252
- "application",
253
- "article",
254
- "banner",
255
- "button",
256
- "cell",
257
- "checkbox",
258
- "columnheader",
259
- "combobox",
260
- "complementary",
261
- "contentinfo",
262
- "definition",
263
- "dialog",
264
- "directory",
265
- "document",
266
- "feed",
267
- "figure",
268
- "form",
269
- "grid",
270
- "gridcell",
271
- "group",
272
- "heading",
273
- "img",
274
- "link",
275
- "list",
276
- "listbox",
277
- "listitem",
278
- "log",
279
- "main",
280
- "marquee",
281
- "math",
282
- "menu",
283
- "menubar",
284
- "menuitem",
285
- "menuitemcheckbox",
286
- "menuitemradio",
287
- "meter",
288
- "navigation",
289
- "none",
290
- "note",
291
- "option",
292
- "presentation",
293
- "progressbar",
294
- "radio",
295
- "radiogroup",
296
- "region",
297
- "row",
298
- "rowgroup",
299
- "rowheader",
300
- "scrollbar",
301
- "search",
302
- "searchbox",
303
- "separator",
304
- "slider",
305
- "spinbutton",
306
- "status",
307
- "switch",
308
- "tab",
309
- "table",
310
- "tablist",
311
- "tabpanel",
312
- "term",
313
- "textbox",
314
- "timer",
315
- "toolbar",
316
- "tooltip",
317
- "tree",
318
- "treegrid",
319
- "treeitem"
320
- ]);
321
- var LANDMARK_ROLES = /* @__PURE__ */ new Set([
322
- "banner",
323
- "complementary",
324
- "contentinfo",
325
- "form",
326
- "main",
327
- "navigation",
328
- "region",
329
- "search"
330
- ]);
331
- var LANDMARK_ELEMENTS = {
332
- header: "banner",
333
- footer: "contentinfo",
334
- main: "main",
335
- nav: "navigation",
336
- aside: "complementary",
337
- form: "form",
338
- section: "region"
339
- };
340
- var REQUIRED_ARIA_PROPS = {
341
- checkbox: ["aria-checked"],
342
- combobox: ["aria-expanded"],
343
- heading: ["aria-level"],
344
- meter: ["aria-valuenow"],
345
- option: ["aria-selected"],
346
- radio: ["aria-checked"],
347
- scrollbar: ["aria-controls", "aria-valuenow"],
348
- separator: [],
349
- // only required when focusable
350
- slider: ["aria-valuenow"],
351
- spinbutton: ["aria-valuenow"],
352
- switch: ["aria-checked"]
353
- };
354
- var VALID_ARIA_ATTRIBUTES = /* @__PURE__ */ new Set([
355
- "aria-activedescendant",
356
- "aria-atomic",
357
- "aria-autocomplete",
358
- "aria-busy",
359
- "aria-checked",
360
- "aria-colcount",
361
- "aria-colindex",
362
- "aria-colspan",
363
- "aria-controls",
364
- "aria-current",
365
- "aria-describedby",
366
- "aria-details",
367
- "aria-disabled",
368
- "aria-dropeffect",
369
- "aria-errormessage",
370
- "aria-expanded",
371
- "aria-flowto",
372
- "aria-grabbed",
373
- "aria-haspopup",
374
- "aria-hidden",
375
- "aria-invalid",
376
- "aria-keyshortcuts",
377
- "aria-label",
378
- "aria-labelledby",
379
- "aria-level",
380
- "aria-live",
381
- "aria-modal",
382
- "aria-multiline",
383
- "aria-multiselectable",
384
- "aria-orientation",
385
- "aria-owns",
386
- "aria-placeholder",
387
- "aria-posinset",
388
- "aria-pressed",
389
- "aria-readonly",
390
- "aria-relevant",
391
- "aria-required",
392
- "aria-roledescription",
393
- "aria-rowcount",
394
- "aria-rowindex",
395
- "aria-rowspan",
396
- "aria-selected",
397
- "aria-setsize",
398
- "aria-sort",
399
- "aria-valuemax",
400
- "aria-valuemin",
401
- "aria-valuenow",
402
- "aria-valuetext"
403
- ]);
404
- function getSelector(el) {
405
- if (el.id) return `#${el.id}`;
406
- const tag = el.tagName.toLowerCase();
407
- const classes = el.className && typeof el.className === "string" ? `.${el.className.trim().split(/\s+/).join(".")}` : "";
408
- const role = el.getAttribute("role");
409
- const roleStr = role ? `[role="${role}"]` : "";
410
- return `${tag}${classes}${roleStr}`;
266
+ // src/reactivity/track-core.ts
267
+ var POOL_MAX = 4096;
268
+ var nodePool = [];
269
+ function createNode() {
270
+ return {
271
+ sig: null,
272
+ sub: null,
273
+ epoch: 0,
274
+ depVersion: 0,
275
+ sigPrev: null,
276
+ sigNext: null,
277
+ subPrev: null,
278
+ subNext: null,
279
+ prevActive: null
280
+ };
411
281
  }
412
- function hasAccessibleName(el) {
413
- if (el.getAttribute("aria-label")?.trim()) return true;
414
- const labelledBy = el.getAttribute("aria-labelledby");
415
- if (labelledBy) {
416
- const doc = el.ownerDocument;
417
- const ids = labelledBy.split(/\s+/);
418
- for (const id of ids) {
419
- const ref = doc.getElementById(id);
420
- if (ref?.textContent?.trim()) return true;
421
- }
282
+ function allocNode(sig, sub, epoch) {
283
+ const n = nodePool.pop();
284
+ if (n) {
285
+ n.sig = sig;
286
+ n.sub = sub;
287
+ n.epoch = epoch;
288
+ return n;
422
289
  }
423
- if (el.getAttribute("title")?.trim()) return true;
424
- if (el.textContent?.trim()) return true;
425
- return false;
290
+ const fresh = createNode();
291
+ fresh.sig = sig;
292
+ fresh.sub = sub;
293
+ fresh.epoch = epoch;
294
+ return fresh;
426
295
  }
427
- function isNativeInteractive(el) {
428
- const tag = el.tagName.toLowerCase();
429
- if (["button", "select", "textarea"].includes(tag)) return true;
430
- if (tag === "input" && el.getAttribute("type") !== "hidden") return true;
431
- if (tag === "a" && el.hasAttribute("href")) return true;
432
- if (tag === "area" && el.hasAttribute("href")) return true;
433
- return false;
296
+ function freeNode(node) {
297
+ node.sig = null;
298
+ node.sub = null;
299
+ node.sigPrev = null;
300
+ node.sigNext = null;
301
+ node.subPrev = null;
302
+ node.subNext = null;
303
+ node.prevActive = null;
304
+ if (nodePool.length < POOL_MAX) nodePool.push(node);
434
305
  }
435
- function checkImageAlt(root) {
436
- const violations = [];
437
- const images = root.querySelectorAll("img");
438
- for (const img of Array.from(images)) {
439
- if (!img.hasAttribute("alt")) {
440
- violations.push({
441
- rule: "image-alt",
442
- level: "error",
443
- message: 'Image element must have an alt attribute. Use alt="" for decorative images.',
444
- element: img,
445
- selector: getSelector(img)
446
- });
447
- } else if (img.getAttribute("alt") === "" && !img.getAttribute("role")) {
448
- violations.push({
449
- rule: "image-alt-empty",
450
- level: "info",
451
- message: "Image has empty alt text. Ensure this is a decorative image; otherwise provide meaningful alt text.",
452
- element: img,
453
- selector: getSelector(img)
454
- });
455
- }
456
- }
457
- const roleImgs = root.querySelectorAll('[role="img"]');
458
- for (const el of Array.from(roleImgs)) {
459
- if (!hasAccessibleName(el)) {
460
- violations.push({
461
- rule: "image-alt",
462
- level: "error",
463
- message: 'Element with role="img" must have an accessible name (aria-label or aria-labelledby).',
464
- element: el,
465
- selector: getSelector(el)
466
- });
467
- }
306
+ function linkSignal(sig, node) {
307
+ const oldHead = sig.subsHead ?? null;
308
+ node.sigPrev = null;
309
+ node.sigNext = oldHead;
310
+ if (oldHead) oldHead.sigPrev = node;
311
+ else sig.subsTail = node;
312
+ sig.subsHead = node;
313
+ sig.__sc = (sig.__sc ?? 0) + 1;
314
+ }
315
+ function unlinkSignal(node) {
316
+ const sig = node.sig;
317
+ if (!sig) return;
318
+ const prev = node.sigPrev;
319
+ const next = node.sigNext;
320
+ if (prev) prev.sigNext = next;
321
+ else sig.subsHead = next;
322
+ if (next) next.sigPrev = prev;
323
+ else sig.subsTail = prev;
324
+ sig.__sc = (sig.__sc ?? 1) - 1;
325
+ if (sig.__activeNode === node) sig.__activeNode = node.prevActive;
326
+ if (sig.__sc === 0) {
327
+ sig.subsHead = null;
328
+ sig.subsTail = null;
468
329
  }
469
- return violations;
470
330
  }
471
- function checkFormLabels(root) {
472
- const violations = [];
473
- const inputs = root.querySelectorAll("input, select, textarea");
474
- for (const input of Array.from(inputs)) {
475
- if (input.getAttribute("type") === "hidden") continue;
476
- const inputType = input.getAttribute("type");
477
- if (inputType === "submit" || inputType === "reset" || inputType === "button") continue;
478
- const hasLabel = checkInputHasLabel(input, root);
479
- if (!hasLabel) {
480
- violations.push({
481
- rule: "form-label",
482
- level: "error",
483
- message: `Form ${input.tagName.toLowerCase()} element must have an associated label. Use <label for="id">, aria-label, aria-labelledby, or wrap in a <label>.`,
484
- element: input,
485
- selector: getSelector(input)
486
- });
487
- }
331
+ function linkSub(sub, node) {
332
+ const oldTail = sub.depsTail ?? null;
333
+ node.subPrev = oldTail;
334
+ node.subNext = null;
335
+ if (oldTail) oldTail.subNext = node;
336
+ else sub.depsHead = node;
337
+ sub.depsTail = node;
338
+ }
339
+ function unlinkSub(node) {
340
+ const sub = node.sub;
341
+ if (!sub) return;
342
+ const prev = node.subPrev;
343
+ const next = node.subNext;
344
+ if (prev) prev.subNext = next;
345
+ else sub.depsHead = next;
346
+ if (next) next.subPrev = prev;
347
+ else sub.depsTail = prev;
348
+ }
349
+ var currentSubscriber = null;
350
+ var suspendSavedSub = null;
351
+ var notifyDepth = 0;
352
+ var pendingQueue = [];
353
+ var pendingSet = /* @__PURE__ */ new Set();
354
+ var propagateStack = [];
355
+ function safeInvoke(sub) {
356
+ try {
357
+ sub();
358
+ } catch (err) {
359
+ const s = sub;
360
+ reportError(err, { phase: s._errorPhase ?? "effect", name: s.__name, node: s._errorNode });
488
361
  }
489
- return violations;
490
362
  }
491
- function checkInputHasLabel(input, root) {
492
- if (input.getAttribute("aria-label")?.trim()) return true;
493
- const labelledBy = input.getAttribute("aria-labelledby");
494
- if (labelledBy) {
495
- const doc = input.ownerDocument;
496
- const ids = labelledBy.split(/\s+/);
497
- for (const id2 of ids) {
498
- const ref = doc.getElementById(id2);
499
- if (ref?.textContent?.trim()) return true;
363
+ function depsChanged(sub) {
364
+ let node = sub.depsHead ?? null;
365
+ if (node === null) return true;
366
+ while (node !== null) {
367
+ const sig = node.sig;
368
+ if (sig === null) return true;
369
+ if (sig._d === true && sig._validate !== void 0) {
370
+ try {
371
+ sig._validate();
372
+ } catch {
373
+ return true;
374
+ }
500
375
  }
376
+ if (node.depVersion !== sig.__v) return true;
377
+ node = node.subNext;
501
378
  }
502
- if (input.getAttribute("title")?.trim()) return true;
503
- const id = input.getAttribute("id");
504
- if (id) {
505
- const label = root.querySelector(`label[for="${escSel(id)}"]`);
506
- if (label?.textContent?.trim()) return true;
379
+ return false;
380
+ }
381
+ var suspendDepth = 0;
382
+ var trackingSuspended = false;
383
+ function suspendTracking() {
384
+ if (suspendDepth === 0) {
385
+ suspendSavedSub = currentSubscriber;
386
+ currentSubscriber = null;
387
+ trackingSuspended = true;
507
388
  }
508
- let parent = input.parentElement;
509
- while (parent && parent !== root) {
510
- if (parent.tagName.toLowerCase() === "label") return true;
511
- parent = parent.parentElement;
389
+ suspendDepth++;
390
+ }
391
+ function resumeTracking() {
392
+ suspendDepth--;
393
+ if (suspendDepth === 0) {
394
+ currentSubscriber = suspendSavedSub;
395
+ suspendSavedSub = null;
396
+ trackingSuspended = false;
512
397
  }
513
- return false;
514
398
  }
515
- function checkHeadingHierarchy(root) {
516
- const violations = [];
517
- const headings = root.querySelectorAll("h1, h2, h3, h4, h5, h6");
518
- if (headings.length === 0) return violations;
519
- let previousLevel = 0;
520
- for (const heading of Array.from(headings)) {
521
- const currentLevel = parseInt(heading.tagName[1], 10);
522
- if (previousLevel === 0 && currentLevel !== 1) {
523
- violations.push({
524
- rule: "heading-order",
525
- level: "warning",
526
- message: `Heading hierarchy should start with h1. Found ${heading.tagName.toLowerCase()} as the first heading.`,
527
- element: heading,
528
- selector: getSelector(heading)
529
- });
530
- }
531
- if (previousLevel > 0 && currentLevel > previousLevel + 1) {
532
- violations.push({
533
- rule: "heading-order",
534
- level: "error",
535
- message: `Heading level skipped: ${heading.tagName.toLowerCase()} follows h${previousLevel}. Headings should not skip levels.`,
536
- element: heading,
537
- selector: getSelector(heading)
538
- });
539
- }
540
- if (!heading.textContent?.trim()) {
541
- violations.push({
542
- rule: "heading-empty",
543
- level: "error",
544
- message: `Heading ${heading.tagName.toLowerCase()} is empty. Headings must have discernible text.`,
545
- element: heading,
546
- selector: getSelector(heading)
547
- });
548
- }
549
- previousLevel = currentLevel;
399
+ function isTrackingSuspended() {
400
+ return trackingSuspended;
401
+ }
402
+ function untracked(fn) {
403
+ suspendTracking();
404
+ try {
405
+ return fn();
406
+ } finally {
407
+ resumeTracking();
550
408
  }
551
- return violations;
552
409
  }
553
- function checkColorContrast(root) {
554
- const violations = [];
555
- const roledElements = root.querySelectorAll("[role]");
556
- for (const el of Array.from(roledElements)) {
557
- const role = el.getAttribute("role");
558
- if (!role) continue;
559
- if (role === "presentation" || role === "none") continue;
560
- const nameRequiredRoles = /* @__PURE__ */ new Set([
561
- "alert",
562
- "alertdialog",
563
- "button",
564
- "checkbox",
565
- "combobox",
566
- "dialog",
567
- "link",
568
- "menuitem",
569
- "menuitemcheckbox",
570
- "menuitemradio",
571
- "option",
572
- "progressbar",
573
- "radio",
574
- "slider",
575
- "spinbutton",
576
- "switch",
577
- "tab",
578
- "textbox",
579
- "treeitem"
580
- ]);
581
- if (nameRequiredRoles.has(role) && !hasAccessibleName(el)) {
582
- violations.push({
583
- rule: "accessible-name",
584
- level: "error",
585
- message: `Element with role="${role}" must have an accessible name (via aria-label, aria-labelledby, or text content).`,
586
- element: el,
587
- selector: getSelector(el)
588
- });
589
- }
410
+ var subscriberEpochCounter = 0;
411
+ function retrack(effectFn, subscriber) {
412
+ const prev = currentSubscriber;
413
+ let savedDepth = 0;
414
+ let savedSuspendSub = null;
415
+ if (trackingSuspended) {
416
+ savedDepth = suspendDepth;
417
+ savedSuspendSub = suspendSavedSub;
418
+ suspendDepth = 0;
419
+ suspendSavedSub = null;
420
+ trackingSuspended = false;
590
421
  }
591
- const inlineStyled = root.querySelectorAll("[style]");
592
- for (const el of Array.from(inlineStyled)) {
593
- const style = el.getAttribute("style") || "";
594
- const hasColor = /(?:^|;)\s*color\s*:/i.test(style);
595
- const hasBg = /(?:^|;)\s*background(?:-color)?\s*:/i.test(style);
596
- if (hasColor && !hasBg && el.textContent?.trim()) {
597
- violations.push({
598
- rule: "color-contrast",
599
- level: "warning",
600
- message: "Element sets text color but not background color. Ensure sufficient contrast with the inherited background.",
601
- element: el,
602
- selector: getSelector(el)
603
- });
422
+ currentSubscriber = subscriber;
423
+ const sub = subscriber;
424
+ if (sub._cap === void 0) sub._cap = currentDisposerCapture();
425
+ const epoch = ++subscriberEpochCounter;
426
+ sub._epoch = epoch;
427
+ sub._structDirty = false;
428
+ for (let n = sub.depsHead ?? null; n !== null; n = n.subNext) {
429
+ const sig = n.sig;
430
+ n.prevActive = sig.__activeNode ?? null;
431
+ sig.__activeNode = n;
432
+ }
433
+ try {
434
+ effectFn();
435
+ } finally {
436
+ currentSubscriber = prev;
437
+ if (savedDepth !== 0) {
438
+ suspendDepth = savedDepth;
439
+ suspendSavedSub = savedSuspendSub;
440
+ trackingSuspended = true;
604
441
  }
605
- if (hasBg && !hasColor && el.textContent?.trim()) {
606
- violations.push({
607
- rule: "color-contrast",
608
- level: "warning",
609
- message: "Element sets background color but not text color. Ensure sufficient contrast with the inherited text color.",
610
- element: el,
611
- selector: getSelector(el)
612
- });
442
+ let node = sub.depsHead ?? null;
443
+ while (node !== null) {
444
+ const next = node.subNext;
445
+ const sig = node.sig;
446
+ sig.__activeNode = node.prevActive;
447
+ node.prevActive = null;
448
+ if (node.epoch !== epoch) {
449
+ unlinkSub(node);
450
+ unlinkSignal(node);
451
+ freeNode(node);
452
+ }
453
+ node = next;
613
454
  }
614
455
  }
615
- return violations;
616
456
  }
617
- function checkKeyboardAccess(root) {
618
- const violations = [];
619
- const clickElements = root.querySelectorAll("[onclick]");
620
- for (const el of Array.from(clickElements)) {
621
- if (isNativeInteractive(el)) continue;
622
- const hasTabindex = el.hasAttribute("tabindex");
623
- const hasRole = el.hasAttribute("role");
624
- const hasKeyHandler = el.hasAttribute("onkeydown") || el.hasAttribute("onkeyup") || el.hasAttribute("onkeypress");
625
- if (!hasTabindex) {
626
- violations.push({
627
- rule: "keyboard-access",
628
- level: "error",
629
- message: "Interactive element with onclick must have tabindex to be keyboard accessible.",
630
- element: el,
631
- selector: getSelector(el)
632
- });
457
+ function track(effectFn, subscriber) {
458
+ if (!subscriber) return reactiveBinding(effectFn);
459
+ cleanup(subscriber);
460
+ const prev = currentSubscriber;
461
+ let savedDepth = 0;
462
+ let savedSuspendSub = null;
463
+ if (trackingSuspended) {
464
+ savedDepth = suspendDepth;
465
+ savedSuspendSub = suspendSavedSub;
466
+ suspendDepth = 0;
467
+ suspendSavedSub = null;
468
+ trackingSuspended = false;
469
+ }
470
+ currentSubscriber = subscriber;
471
+ try {
472
+ effectFn();
473
+ } finally {
474
+ currentSubscriber = prev;
475
+ if (savedDepth !== 0) {
476
+ suspendDepth = savedDepth;
477
+ suspendSavedSub = savedSuspendSub;
478
+ trackingSuspended = true;
633
479
  }
634
- if (!hasRole) {
635
- violations.push({
636
- rule: "keyboard-access",
637
- level: "warning",
638
- message: 'Interactive element with onclick should have an explicit role attribute (e.g., role="button").',
639
- element: el,
640
- selector: getSelector(el)
641
- });
480
+ const sub2 = subscriber;
481
+ for (let n = sub2.depsHead ?? null; n !== null; n = n.subNext) {
482
+ const sig = n.sig;
483
+ sig.__activeNode = n.prevActive;
484
+ n.prevActive = null;
642
485
  }
643
- if (!hasKeyHandler) {
644
- violations.push({
645
- rule: "keyboard-access",
646
- level: "error",
647
- message: "Interactive element with onclick must also have a keyboard event handler (onkeydown or onkeyup) for keyboard accessibility.",
648
- element: el,
649
- selector: getSelector(el)
650
- });
486
+ }
487
+ const sub = subscriber;
488
+ return sub._dispose ?? (sub._dispose = () => cleanup(subscriber));
489
+ }
490
+ function reactiveBinding(commit, ownerNode) {
491
+ const run = () => {
492
+ const s = subscriber;
493
+ if (s._disposed || s._reentrant) return;
494
+ s._reentrant = true;
495
+ try {
496
+ retrack(commit, subscriber);
497
+ } finally {
498
+ s._reentrant = false;
651
499
  }
500
+ };
501
+ const subscriber = run;
502
+ subscriber.depsHead = null;
503
+ subscriber.depsTail = null;
504
+ subscriber._epoch = 0;
505
+ subscriber._structDirty = false;
506
+ subscriber._hasComputedDep = false;
507
+ subscriber._runEpoch = 0;
508
+ subscriber._runs = 0;
509
+ subscriber._reentrant = false;
510
+ subscriber._disposed = false;
511
+ subscriber._errorPhase = "binding";
512
+ subscriber._errorNode = ownerNode;
513
+ try {
514
+ run();
515
+ } catch (err) {
516
+ subscriber._disposed = true;
517
+ subscriber._errorNode = void 0;
518
+ cleanup(subscriber);
519
+ throw err;
652
520
  }
653
- const interactiveRoles = root.querySelectorAll(
654
- '[role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"]'
521
+ return subscriber._dispose ?? (subscriber._dispose = () => {
522
+ subscriber._disposed = true;
523
+ subscriber._errorNode = void 0;
524
+ cleanup(subscriber);
525
+ });
526
+ }
527
+ function recordDependency(signal) {
528
+ if (!currentSubscriber) return;
529
+ const sub = currentSubscriber;
530
+ const sig = signal;
531
+ const epoch = sub._epoch ?? 0;
532
+ const active = sig.__activeNode ?? null;
533
+ if (active !== null && active.sub === sub) {
534
+ active.epoch = epoch;
535
+ active.depVersion = sig.__v ?? Number.NaN;
536
+ return;
537
+ }
538
+ if (sig._validate !== void 0) sub._hasComputedDep = true;
539
+ const node = allocNode(signal, sub, epoch);
540
+ node.depVersion = sig.__v ?? Number.NaN;
541
+ node.prevActive = active;
542
+ sig.__activeNode = node;
543
+ linkSub(sub, node);
544
+ linkSignal(sig, node);
545
+ sub._structDirty = true;
546
+ }
547
+ function cleanup(subscriber) {
548
+ const sub = subscriber;
549
+ let node = sub.depsHead ?? null;
550
+ sub.depsHead = null;
551
+ sub.depsTail = null;
552
+ while (node) {
553
+ const next = node.subNext;
554
+ unlinkSignal(node);
555
+ freeNode(node);
556
+ node = next;
557
+ }
558
+ }
559
+ var maxSubscriberRepeats = 1e3;
560
+ var maxDrainIterations = 1e6;
561
+ var drainEpoch = 0;
562
+ var quarantined = /* @__PURE__ */ new Set();
563
+ function setMaxSubscriberRepeats(n) {
564
+ const prev = maxSubscriberRepeats;
565
+ if (Number.isFinite(n) && n > 0) maxSubscriberRepeats = Math.floor(n);
566
+ return prev;
567
+ }
568
+ function setMaxDrainIterations(n) {
569
+ const prev = maxDrainIterations;
570
+ if (Number.isFinite(n) && n > 0) maxDrainIterations = Math.floor(n);
571
+ return prev;
572
+ }
573
+ function tickRepeat(sub) {
574
+ const s = sub;
575
+ if (s._runEpoch !== drainEpoch) {
576
+ s._runEpoch = drainEpoch;
577
+ s._runs = 1;
578
+ return false;
579
+ }
580
+ s._runs = (s._runs ?? 0) + 1;
581
+ return s._runs > maxSubscriberRepeats;
582
+ }
583
+ function cycleError(sub) {
584
+ const name = sub.__name ?? "<unnamed>";
585
+ reportError(
586
+ new Error(
587
+ `subscriber "${name}" fired more than ${maxSubscriberRepeats} times \u2014 likely a write-reads-self cycle between effects/signals. This subscriber is quarantined for the rest of this update; other pending work still runs.`
588
+ ),
589
+ { phase: "scheduler", name }
655
590
  );
656
- for (const el of Array.from(interactiveRoles)) {
657
- if (isNativeInteractive(el)) continue;
658
- if (!el.hasAttribute("tabindex")) {
659
- violations.push({
660
- rule: "keyboard-access",
661
- level: "error",
662
- message: `Element with role="${el.getAttribute("role")}" must have tabindex to be keyboard accessible.`,
663
- element: el,
664
- selector: getSelector(el)
665
- });
591
+ }
592
+ function absoluteDrainError() {
593
+ reportError(
594
+ new Error(
595
+ `Notification drain exceeded ${maxDrainIterations} iterations \u2014 absolute safety net tripped. Breaking to prevent infinite loop.`
596
+ ),
597
+ { phase: "scheduler" }
598
+ );
599
+ }
600
+ function drainQueue() {
601
+ let i = 0;
602
+ while (i < pendingQueue.length) {
603
+ if (i >= maxDrainIterations) {
604
+ absoluteDrainError();
605
+ break;
606
+ }
607
+ const sub = pendingQueue[i++];
608
+ pendingSet.delete(sub);
609
+ if (quarantined.size > 0 && quarantined.has(sub)) continue;
610
+ if (sub._hasComputedDep === true && !depsChanged(sub)) continue;
611
+ if (tickRepeat(sub)) {
612
+ cycleError(sub);
613
+ quarantined.add(sub);
614
+ continue;
615
+ }
616
+ const pushed = beginDisposerCapture(sub._cap ?? null);
617
+ try {
618
+ safeInvoke(sub);
619
+ } finally {
620
+ if (pushed) endDisposerCapture();
666
621
  }
667
622
  }
668
- return violations;
669
623
  }
670
- function checkAriaAttributes(root) {
671
- const violations = [];
672
- const roledElements = root.querySelectorAll("[role]");
673
- for (const el of Array.from(roledElements)) {
674
- const role = el.getAttribute("role");
675
- if (!role) continue;
676
- if (!VALID_ARIA_ROLES.has(role)) {
677
- violations.push({
678
- rule: "aria-valid-role",
679
- level: "error",
680
- message: `Invalid ARIA role "${role}". Must be a valid WAI-ARIA role.`,
681
- element: el,
682
- selector: getSelector(el)
683
- });
624
+ function drainNotificationQueue() {
625
+ if (notifyDepth > 0) return;
626
+ notifyDepth++;
627
+ drainEpoch++;
628
+ try {
629
+ drainQueue();
630
+ } finally {
631
+ notifyDepth--;
632
+ if (notifyDepth === 0) {
633
+ pendingQueue.length = 0;
634
+ pendingSet.clear();
635
+ if (quarantined.size > 0) quarantined.clear();
684
636
  }
685
- if (REQUIRED_ARIA_PROPS[role]) {
686
- for (const requiredProp of REQUIRED_ARIA_PROPS[role]) {
687
- if (!el.hasAttribute(requiredProp)) {
688
- violations.push({
689
- rule: "aria-required-attr",
690
- level: "error",
691
- message: `Element with role="${role}" requires the ${requiredProp} attribute.`,
692
- element: el,
693
- selector: getSelector(el)
694
- });
637
+ }
638
+ }
639
+ function propagateDirty(sub) {
640
+ sub();
641
+ const rootSig = sub._sig;
642
+ if (!rootSig) return;
643
+ const stack = propagateStack;
644
+ const baseLen = stack.length;
645
+ stack.push(rootSig);
646
+ while (stack.length > baseLen) {
647
+ const sig = stack.pop();
648
+ let node = sig.subsHead ?? null;
649
+ while (node) {
650
+ const s = node.sub;
651
+ if (s) {
652
+ if (s._c) {
653
+ const nSig = s._sig;
654
+ if (nSig) {
655
+ if (!nSig._d || nSig._f === true) {
656
+ nSig._d = true;
657
+ nSig._f = false;
658
+ stack.push(nSig);
659
+ }
660
+ } else {
661
+ s();
662
+ }
663
+ } else if (!pendingSet.has(s)) {
664
+ pendingSet.add(s);
665
+ pendingQueue.push(s);
695
666
  }
696
667
  }
668
+ node = node.sigNext;
697
669
  }
698
670
  }
699
- const allElements = root.querySelectorAll("*");
700
- for (const el of Array.from(allElements)) {
701
- for (const attr of Array.from(el.attributes)) {
702
- if (!attr.name.startsWith("aria-")) continue;
703
- if (!VALID_ARIA_ATTRIBUTES.has(attr.name)) {
704
- violations.push({
705
- rule: "aria-valid-attr",
706
- level: "error",
707
- message: `Invalid ARIA attribute "${attr.name}". Must be a valid WAI-ARIA attribute.`,
708
- element: el,
709
- selector: getSelector(el)
710
- });
671
+ }
672
+ function queueSignalNotification(signal) {
673
+ const sig = signal;
674
+ let node = sig.subsHead ?? null;
675
+ while (node) {
676
+ const s = node.sub;
677
+ if (s) {
678
+ if (s._c) {
679
+ propagateDirty(s);
680
+ } else if (!pendingSet.has(s)) {
681
+ pendingSet.add(s);
682
+ pendingQueue.push(s);
711
683
  }
712
- if (attr.name === "aria-labelledby" || attr.name === "aria-describedby" || attr.name === "aria-controls" || attr.name === "aria-owns") {
713
- const ids = attr.value.split(/\s+/).filter(Boolean);
714
- const doc = el.ownerDocument;
715
- for (const id of ids) {
716
- if (!doc.getElementById(id)) {
717
- violations.push({
718
- rule: "aria-valid-attr-value",
719
- level: "error",
720
- message: `${attr.name} references id "${id}" which does not exist in the document.`,
721
- element: el,
722
- selector: getSelector(el)
723
- });
724
- }
684
+ }
685
+ node = node.sigNext;
686
+ }
687
+ }
688
+ function notifySubscribers(signal) {
689
+ const sig = signal;
690
+ const head = sig.subsHead;
691
+ if (!head) return;
692
+ if (notifyDepth > 0) {
693
+ let node = head;
694
+ while (node) {
695
+ const s = node.sub;
696
+ if (s) {
697
+ if (s._c) {
698
+ propagateDirty(s);
699
+ } else if (!pendingSet.has(s)) {
700
+ pendingSet.add(s);
701
+ pendingQueue.push(s);
725
702
  }
726
703
  }
727
- const booleanAttrs = /* @__PURE__ */ new Set([
728
- "aria-checked",
729
- "aria-disabled",
730
- "aria-expanded",
731
- "aria-hidden",
732
- "aria-pressed",
733
- "aria-required",
734
- "aria-selected",
735
- "aria-busy",
736
- "aria-modal",
737
- "aria-multiline",
738
- "aria-multiselectable",
739
- "aria-readonly"
740
- ]);
741
- if (booleanAttrs.has(attr.name)) {
742
- const validValues = ["true", "false"];
743
- if (attr.name === "aria-checked" || attr.name === "aria-pressed") {
744
- validValues.push("mixed");
745
- }
746
- if (!validValues.includes(attr.value)) {
747
- violations.push({
748
- rule: "aria-valid-attr-value",
749
- level: "error",
750
- message: `${attr.name} has invalid value "${attr.value}". Expected one of: ${validValues.join(", ")}.`,
751
- element: el,
752
- selector: getSelector(el)
753
- });
704
+ node = node.sigNext;
705
+ }
706
+ return;
707
+ }
708
+ notifyDepth++;
709
+ drainEpoch++;
710
+ try {
711
+ let node = head;
712
+ while (node) {
713
+ const s = node.sub;
714
+ if (s) {
715
+ if (s._c) {
716
+ propagateDirty(s);
717
+ } else if (!pendingSet.has(s)) {
718
+ pendingSet.add(s);
719
+ pendingQueue.push(s);
754
720
  }
755
721
  }
722
+ node = node.sigNext;
756
723
  }
757
- }
758
- for (const attr of Array.from(root.attributes)) {
759
- if (!attr.name.startsWith("aria-")) continue;
760
- if (!VALID_ARIA_ATTRIBUTES.has(attr.name)) {
761
- violations.push({
762
- rule: "aria-valid-attr",
763
- level: "error",
764
- message: `Invalid ARIA attribute "${attr.name}" on root element. Must be a valid WAI-ARIA attribute.`,
765
- element: root,
766
- selector: getSelector(root)
767
- });
724
+ drainQueue();
725
+ } finally {
726
+ notifyDepth--;
727
+ if (notifyDepth === 0) {
728
+ pendingQueue.length = 0;
729
+ pendingSet.clear();
730
+ if (quarantined.size > 0) quarantined.clear();
768
731
  }
769
732
  }
770
- return violations;
771
733
  }
772
- function checkLandmarks(root) {
773
- const violations = [];
774
- const foundLandmarks = /* @__PURE__ */ new Set();
775
- for (const role of LANDMARK_ROLES) {
776
- const elements = root.querySelectorAll(`[role="${role}"]`);
777
- if (elements.length > 0) {
778
- foundLandmarks.add(role);
779
- }
780
- }
781
- for (const [tag, role] of Object.entries(LANDMARK_ELEMENTS)) {
782
- const elements = root.querySelectorAll(tag);
783
- if (elements.length > 0) {
784
- foundLandmarks.add(role);
785
- }
786
- }
787
- if (!foundLandmarks.has("main")) {
788
- violations.push({
789
- rule: "landmark-main",
790
- level: "warning",
791
- message: 'Page should have a main landmark (<main> element or role="main").',
792
- selector: getSelector(root)
793
- });
794
- }
795
- const mainElements = root.querySelectorAll('main, [role="main"]');
796
- if (mainElements.length > 1) {
797
- violations.push({
798
- rule: "landmark-unique",
799
- level: "error",
800
- message: `Multiple main landmarks found (${mainElements.length}). A page should have at most one main landmark.`,
801
- selector: getSelector(root)
802
- });
734
+ function getSubscriberCount(signal) {
735
+ return signal.__sc ?? 0;
736
+ }
737
+ function getSubscriberDeps(subscriber) {
738
+ const sub = subscriber;
739
+ const out = [];
740
+ let node = sub.depsHead ?? null;
741
+ while (node) {
742
+ if (node.sig) out.push(node.sig);
743
+ node = node.subNext;
803
744
  }
804
- const bannerElements = root.querySelectorAll('header, [role="banner"]');
805
- if (bannerElements.length > 1) {
806
- violations.push({
807
- rule: "landmark-unique",
808
- level: "warning",
809
- message: `Multiple banner landmarks found (${bannerElements.length}). Consider using a single banner landmark.`,
810
- selector: getSelector(root)
811
- });
745
+ return out;
746
+ }
747
+ function forEachSubscriber(signal, visit) {
748
+ let node = signal.subsHead ?? null;
749
+ while (node) {
750
+ const s = node.sub;
751
+ if (s) visit(s);
752
+ node = node.sigNext;
812
753
  }
813
- const contentinfoElements = root.querySelectorAll('footer, [role="contentinfo"]');
814
- if (contentinfoElements.length > 1) {
815
- violations.push({
816
- rule: "landmark-unique",
817
- level: "warning",
818
- message: `Multiple contentinfo landmarks found (${contentinfoElements.length}). Consider using a single contentinfo landmark.`,
819
- selector: getSelector(root)
820
- });
754
+ }
755
+
756
+ // src/reactivity/track.ts
757
+ var _runtimeVersion = true ? "4.6.0" : "dev";
758
+ var REGISTRY_KEY = /* @__PURE__ */ Symbol.for("sibujs.reactive.v1");
759
+ function resolveReactiveApi() {
760
+ const g = globalThis;
761
+ const existing = g[REGISTRY_KEY];
762
+ if (existing) {
763
+ if (DEV && !existing.__dupWarned) {
764
+ existing.__dupWarned = true;
765
+ devWarn(
766
+ `Multiple instances of the reactive runtime detected on this page (active: ${existing.version}, duplicate: ${_runtimeVersion}). Reactivity is NOT broken and no bundler configuration is required: every copy routes through the first one via a global registry, so signal writes still reach subscribers registered by any copy. This is only a SIZE issue \u2014 the duplicate copy's bytes are downloaded and parsed for nothing. If you want them back, de-duplicate sibujs in your bundler (Vite: resolve.dedupe: ['sibujs']). Mismatched VERSIONS above are worth fixing regardless, since only the first one loaded is used.`
767
+ );
768
+ }
769
+ return existing;
821
770
  }
822
- const navElements = root.querySelectorAll('nav, [role="navigation"]');
823
- if (navElements.length > 1) {
824
- for (const nav of Array.from(navElements)) {
825
- if (!nav.getAttribute("aria-label") && !nav.getAttribute("aria-labelledby")) {
826
- violations.push({
827
- rule: "landmark-label",
828
- level: "warning",
829
- message: "When multiple navigation landmarks exist, each should have a unique label (aria-label or aria-labelledby).",
830
- element: nav,
831
- selector: getSelector(nav)
832
- });
771
+ const local = {
772
+ suspendTracking,
773
+ resumeTracking,
774
+ isTrackingSuspended,
775
+ untracked,
776
+ retrack,
777
+ track,
778
+ reactiveBinding,
779
+ recordDependency,
780
+ cleanup,
781
+ setMaxSubscriberRepeats,
782
+ setMaxDrainIterations,
783
+ drainNotificationQueue,
784
+ queueSignalNotification,
785
+ notifySubscribers,
786
+ getSubscriberCount,
787
+ getSubscriberDeps,
788
+ forEachSubscriber,
789
+ version: _runtimeVersion
790
+ };
791
+ g[REGISTRY_KEY] = local;
792
+ return local;
793
+ }
794
+ var API = resolveReactiveApi();
795
+ var suspendTracking2 = API.suspendTracking;
796
+ var resumeTracking2 = API.resumeTracking;
797
+ var isTrackingSuspended2 = API.isTrackingSuspended;
798
+ var untracked2 = API.untracked;
799
+ var retrack2 = API.retrack;
800
+ var track2 = API.track;
801
+ var reactiveBinding2 = API.reactiveBinding;
802
+ var recordDependency2 = API.recordDependency;
803
+ var cleanup2 = API.cleanup;
804
+ var setMaxSubscriberRepeats2 = API.setMaxSubscriberRepeats;
805
+ var setMaxDrainIterations2 = API.setMaxDrainIterations;
806
+ var drainNotificationQueue2 = API.drainNotificationQueue;
807
+ var queueSignalNotification2 = API.queueSignalNotification;
808
+ var notifySubscribers2 = API.notifySubscribers;
809
+ var getSubscriberCount2 = API.getSubscriberCount;
810
+ var getSubscriberDeps2 = API.getSubscriberDeps;
811
+ var forEachSubscriber2 = API.forEachSubscriber;
812
+
813
+ // src/core/ssr-context.ts
814
+ var SSR_KEY = /* @__PURE__ */ Symbol.for("sibujs.ssr.v1");
815
+ function detectSSRShared() {
816
+ let detected = null;
817
+ try {
818
+ if (typeof process !== "undefined" && process.versions && process.versions.node) {
819
+ let mod = null;
820
+ const getBuiltin = process.getBuiltinModule;
821
+ if (typeof getBuiltin === "function") {
822
+ mod = getBuiltin("node:async_hooks");
823
+ } else if (typeof require === "function") {
824
+ mod = require("async_hooks");
833
825
  }
826
+ if (mod) detected = new mod.AsyncLocalStorage();
834
827
  }
828
+ } catch {
829
+ detected = null;
835
830
  }
836
- return violations;
831
+ return {
832
+ als: detected,
833
+ fallbackStore: { ssr: false, suspenseIdCounter: 0, locale: void 0 },
834
+ fallbackDepth: 0
835
+ };
837
836
  }
838
- function checkLinksAndButtons(root) {
839
- const violations = [];
840
- const links = root.querySelectorAll("a[href]");
841
- for (const link of Array.from(links)) {
842
- if (!hasAccessibleName(link)) {
843
- violations.push({
844
- rule: "link-name",
845
- level: "error",
846
- message: "Link must have discernible text. Add text content, aria-label, or aria-labelledby.",
847
- element: link,
848
- selector: getSelector(link)
849
- });
850
- }
851
- if (link.getAttribute("target") === "_blank") {
852
- violations.push({
853
- rule: "link-new-window",
854
- level: "warning",
855
- message: 'Link opens in a new window/tab (target="_blank"). Consider warning users or adding an icon to indicate this.',
856
- element: link,
857
- selector: getSelector(link)
858
- });
859
- const rel = link.getAttribute("rel") || "";
860
- if (!rel.includes("noopener") && !rel.includes("noreferrer")) {
861
- violations.push({
862
- rule: "link-noopener",
863
- level: "warning",
864
- message: 'Link with target="_blank" should include rel="noopener" or rel="noreferrer" for security.',
865
- element: link,
866
- selector: getSelector(link)
867
- });
868
- }
869
- }
870
- const linkText = link.textContent?.trim().toLowerCase() || "";
871
- const genericTexts = ["click here", "here", "read more", "more", "learn more", "link"];
872
- if (genericTexts.includes(linkText)) {
873
- violations.push({
874
- rule: "link-purpose",
875
- level: "warning",
876
- message: `Link text "${link.textContent?.trim()}" is generic. Use descriptive text that explains the link's purpose.`,
877
- element: link,
878
- selector: getSelector(link)
879
- });
880
- }
837
+ var _shared = globalThis[SSR_KEY] ?? (globalThis[SSR_KEY] = detectSSRShared());
838
+ var als = _shared.als;
839
+ var fallbackStore = _shared.fallbackStore;
840
+ _shared.fallbackDepth ?? (_shared.fallbackDepth = 0);
841
+ function getSSRStore() {
842
+ if (als) {
843
+ const s = als.getStore();
844
+ if (s) return s;
881
845
  }
882
- const buttons = root.querySelectorAll('button, [role="button"]');
883
- for (const button of Array.from(buttons)) {
884
- if (!hasAccessibleName(button)) {
885
- violations.push({
886
- rule: "button-name",
887
- level: "error",
888
- message: "Button must have an accessible name. Add text content, aria-label, or aria-labelledby.",
889
- element: button,
890
- selector: getSelector(button)
891
- });
846
+ return fallbackStore;
847
+ }
848
+ function isSSR() {
849
+ return getSSRStore().ssr;
850
+ }
851
+
852
+ // src/core/signals/effect.ts
853
+ var _g = globalThis;
854
+ var MAX_RERUNS = 100;
855
+ function flushUserCleanups(ctx) {
856
+ const list = ctx.userCleanups;
857
+ if (list.length === 0) return;
858
+ ctx.userCleanups = [];
859
+ for (let i = list.length - 1; i >= 0; i--) {
860
+ try {
861
+ list[i]();
862
+ } catch (err) {
863
+ reportError(err, { phase: "cleanup" });
892
864
  }
893
865
  }
894
- const inputButtons = root.querySelectorAll('input[type="submit"], input[type="reset"], input[type="button"]');
895
- for (const input of Array.from(inputButtons)) {
896
- const value = input.getAttribute("value");
897
- if (!value?.trim() && !input.getAttribute("aria-label") && !input.getAttribute("aria-labelledby")) {
898
- violations.push({
899
- rule: "button-name",
900
- level: "error",
901
- message: `Input button (type="${input.getAttribute("type")}") must have a value, aria-label, or aria-labelledby.`,
902
- element: input,
903
- selector: getSelector(input)
904
- });
905
- }
866
+ }
867
+ function drainReruns(ctx) {
868
+ let reruns = 1;
869
+ do {
870
+ ctx.rerunPending = false;
871
+ if (ctx.userCleanups.length > 0) flushUserCleanups(ctx);
872
+ retrack2(ctx.bodyFn, ctx.subscriber);
873
+ } while (ctx.rerunPending && ++reruns <= MAX_RERUNS);
874
+ if (ctx.rerunPending) {
875
+ ctx.rerunPending = false;
876
+ reportError(
877
+ new Error(
878
+ `effect re-requested itself ${MAX_RERUNS}+ times \u2014 likely a write-reads-self cycle. Breaking to prevent infinite loop.`
879
+ ),
880
+ { phase: "effect" }
881
+ );
906
882
  }
907
- return violations;
908
883
  }
909
- function checkListSemantics(root) {
910
- const violations = [];
911
- const lists = root.querySelectorAll("ul, ol");
912
- for (const list of Array.from(lists)) {
913
- const children = Array.from(list.children);
914
- for (const child of children) {
915
- const tag = child.tagName.toLowerCase();
916
- if (tag !== "li" && tag !== "script" && tag !== "template") {
917
- violations.push({
918
- rule: "list-semantics",
919
- level: "error",
920
- message: `Direct children of <${list.tagName.toLowerCase()}> must be <li> elements. Found <${tag}>.`,
921
- element: child,
922
- selector: getSelector(child)
923
- });
924
- }
925
- }
926
- if (children.length === 0) {
927
- violations.push({
928
- rule: "list-empty",
929
- level: "info",
930
- message: `Empty <${list.tagName.toLowerCase()}> element found. Consider removing or adding items.`,
931
- element: list,
932
- selector: getSelector(list)
933
- });
884
+ function disposeEffect(ctx) {
885
+ if (ctx.disposed) return;
886
+ ctx.disposed = true;
887
+ const h = _g.__SIBU_DEVTOOLS_GLOBAL_HOOK__;
888
+ if (h) {
889
+ try {
890
+ h.emit("effect:destroy", { effectFn: ctx.fn });
891
+ } catch {
934
892
  }
935
893
  }
936
- const dlLists = root.querySelectorAll("dl");
937
- for (const dl of Array.from(dlLists)) {
938
- const children = Array.from(dl.children);
939
- for (const child of children) {
940
- const tag = child.tagName.toLowerCase();
941
- if (tag !== "dt" && tag !== "dd" && tag !== "div" && tag !== "script" && tag !== "template") {
942
- violations.push({
943
- rule: "list-semantics",
944
- level: "error",
945
- message: `Direct children of <dl> must be <dt>, <dd>, or <div> elements. Found <${tag}>.`,
946
- element: child,
947
- selector: getSelector(child)
948
- });
949
- }
894
+ try {
895
+ if (ctx.userCleanups.length > 0) flushUserCleanups(ctx);
896
+ } catch (err) {
897
+ if (typeof console !== "undefined") {
898
+ console.warn("[SibuJS effect] onCleanup threw during dispose:", err);
950
899
  }
951
900
  }
952
- const roleLists = root.querySelectorAll('[role="list"]');
953
- for (const list of Array.from(roleLists)) {
954
- const children = Array.from(list.children);
955
- const hasListItems = children.some(
956
- (child) => child.getAttribute("role") === "listitem" || child.tagName.toLowerCase() === "li"
957
- );
958
- if (children.length > 0 && !hasListItems) {
959
- violations.push({
960
- rule: "list-semantics",
961
- level: "warning",
962
- message: 'Element with role="list" should contain children with role="listitem".',
963
- element: list,
964
- selector: getSelector(list)
965
- });
901
+ try {
902
+ cleanup2(ctx.subscriber);
903
+ } catch (err) {
904
+ if (typeof console !== "undefined") {
905
+ console.warn("[SibuJS effect] dispose threw:", err);
966
906
  }
967
907
  }
968
- return violations;
969
908
  }
970
- function checkTabOrder(root) {
971
- const violations = [];
972
- const tabbable = root.querySelectorAll("[tabindex]");
973
- for (const el of Array.from(tabbable)) {
974
- const tabindex = parseInt(el.getAttribute("tabindex") ?? "", 10);
975
- if (!Number.isNaN(tabindex) && tabindex > 0) {
976
- violations.push({
977
- rule: "tabindex-positive",
978
- level: "warning",
979
- message: `Element has tabindex="${tabindex}". Positive tabindex values create a confusing tab order. Use tabindex="0" instead and rely on DOM order.`,
980
- element: el,
981
- selector: getSelector(el)
982
- });
909
+ function effect(effectFn, options) {
910
+ devAssert(typeof effectFn === "function", "effect: argument must be a function.");
911
+ if (isSSR()) return () => {
912
+ };
913
+ const ctx = {
914
+ fn: effectFn,
915
+ onError: options?.onError,
916
+ userCleanups: [],
917
+ running: false,
918
+ rerunPending: false,
919
+ disposed: false,
920
+ onCleanup: null,
921
+ subscriber: null,
922
+ bodyFn: null
923
+ };
924
+ ctx.onCleanup = (fn) => {
925
+ ctx.userCleanups.push(fn);
926
+ };
927
+ const onErrorCaptured = ctx.onError;
928
+ ctx.bodyFn = onErrorCaptured ? () => {
929
+ try {
930
+ ctx.fn(ctx.onCleanup);
931
+ } catch (err) {
932
+ onErrorCaptured(err);
983
933
  }
984
- }
985
- const focusTraps = root.querySelectorAll('[data-sibu-focus-trap], [aria-modal="true"]');
986
- for (const trap of Array.from(focusTraps)) {
987
- const role = trap.getAttribute("role");
988
- if (role === "dialog" || role === "alertdialog" || trap.hasAttribute("aria-modal")) {
989
- const hasCloseButton = trap.querySelector(
990
- 'button[aria-label*="close" i], button[aria-label*="dismiss" i], [data-dismiss], [data-close]'
991
- );
992
- if (!hasCloseButton) {
993
- violations.push({
994
- rule: "focus-trap-escape",
995
- level: "warning",
996
- message: "Modal/dialog with focus trap should have a visible close button. Users must be able to dismiss the dialog.",
997
- element: trap,
998
- selector: getSelector(trap)
999
- });
1000
- }
934
+ } : () => {
935
+ ctx.fn(ctx.onCleanup);
936
+ };
937
+ const sub = (() => {
938
+ if (ctx.disposed) return;
939
+ if (ctx.running) {
940
+ ctx.rerunPending = true;
941
+ return;
1001
942
  }
1002
- const focusable = trap.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
1003
- if (focusable.length === 0) {
1004
- violations.push({
1005
- rule: "focus-trap-empty",
1006
- level: "error",
1007
- message: "Focus trap contains no focusable elements. Users will be trapped with no way to interact.",
1008
- element: trap,
1009
- selector: getSelector(trap)
1010
- });
1011
- }
1012
- }
1013
- return violations;
1014
- }
1015
- var CHECK_FUNCTIONS = {
1016
- images: checkImageAlt,
1017
- forms: checkFormLabels,
1018
- headings: checkHeadingHierarchy,
1019
- contrast: checkColorContrast,
1020
- keyboard: checkKeyboardAccess,
1021
- aria: checkAriaAttributes,
1022
- landmarks: checkLandmarks,
1023
- links: checkLinksAndButtons,
1024
- lists: checkListSemantics,
1025
- tabOrder: checkTabOrder
1026
- };
1027
- var ALL_CHECKS = [
1028
- "images",
1029
- "forms",
1030
- "headings",
1031
- "contrast",
1032
- "keyboard",
1033
- "aria",
1034
- "landmarks",
1035
- "links",
1036
- "lists",
1037
- "tabOrder"
1038
- ];
1039
- function checkA11y(root, options) {
1040
- const checksToRun = options?.checks || ALL_CHECKS;
1041
- const minLevel = options?.level || "info";
1042
- const levelPriority = {
1043
- error: 2,
1044
- warning: 1,
1045
- info: 0
1046
- };
1047
- const minPriority = levelPriority[minLevel];
1048
- const allViolations = [];
1049
- for (const check of checksToRun) {
1050
- const fn = CHECK_FUNCTIONS[check];
1051
- if (fn) {
1052
- const results = fn(root);
1053
- allViolations.push(...results);
943
+ ctx.running = true;
944
+ try {
945
+ ctx.rerunPending = false;
946
+ if (ctx.userCleanups.length > 0) flushUserCleanups(ctx);
947
+ retrack2(ctx.bodyFn, sub);
948
+ if (ctx.rerunPending) drainReruns(ctx);
949
+ } finally {
950
+ ctx.running = false;
951
+ ctx.rerunPending = false;
1054
952
  }
953
+ });
954
+ sub.depsHead = null;
955
+ sub.depsTail = null;
956
+ sub._epoch = 0;
957
+ sub._structDirty = false;
958
+ sub._hasComputedDep = false;
959
+ sub._errorPhase = "effect";
960
+ sub._runEpoch = 0;
961
+ sub._runs = 0;
962
+ ctx.subscriber = sub;
963
+ ctx.running = true;
964
+ try {
965
+ retrack2(ctx.bodyFn, ctx.subscriber);
966
+ if (ctx.rerunPending) drainReruns(ctx);
967
+ } finally {
968
+ ctx.running = false;
969
+ ctx.rerunPending = false;
1055
970
  }
1056
- const filtered = allViolations.filter((v) => levelPriority[v.level] >= minPriority);
1057
- const violations = filtered.filter((v) => v.level === "error");
1058
- const warnings = filtered.filter((v) => v.level === "warning");
1059
- const info = filtered.filter((v) => v.level === "info");
1060
- const parts = [];
1061
- if (violations.length > 0) parts.push(`${violations.length} error(s)`);
1062
- if (warnings.length > 0) parts.push(`${warnings.length} warning(s)`);
1063
- if (info.length > 0) parts.push(`${info.length} info`);
1064
- const passed = violations.length === 0;
1065
- const summary = passed ? parts.length > 0 ? `Passed with ${parts.join(", ")}.` : "Passed with no issues." : `Failed with ${parts.join(", ")}.`;
1066
- return { passed, violations, warnings, info, summary };
971
+ const hook = _g.__SIBU_DEVTOOLS_GLOBAL_HOOK__;
972
+ if (hook) hook.emit("effect:create", { effectFn });
973
+ return () => disposeEffect(ctx);
1067
974
  }
1068
- function assertA11y(root, options) {
1069
- const result = checkA11y(root, options);
1070
- if (!result.passed) {
1071
- const lines = [`Accessibility check failed: ${result.summary}`, ""];
1072
- if (result.violations.length > 0) {
1073
- lines.push("ERRORS:");
1074
- for (const v of result.violations) {
1075
- lines.push(` [${v.rule}] ${v.message}${v.selector ? ` (${v.selector})` : ""}`);
1076
- }
1077
- lines.push("");
975
+
976
+ // src/testing/queries.ts
977
+ function queryByText(container, text) {
978
+ const walk = (node) => {
979
+ if (node.childNodes.length === 1 && node.childNodes[0].nodeType === 3) {
980
+ if (node.textContent?.includes(text)) return node;
1078
981
  }
1079
- if (result.warnings.length > 0) {
1080
- lines.push("WARNINGS:");
1081
- for (const w of result.warnings) {
1082
- lines.push(` [${w.rule}] ${w.message}${w.selector ? ` (${w.selector})` : ""}`);
1083
- }
1084
- lines.push("");
982
+ for (const child of Array.from(node.children)) {
983
+ const found = walk(child);
984
+ if (found) return found;
1085
985
  }
1086
- throw new Error(lines.join("\n"));
986
+ return null;
987
+ };
988
+ return walk(container);
989
+ }
990
+ function queryByAttribute(container, attribute, value) {
991
+ for (const el of Array.from(container.querySelectorAll(`[${attribute}]`))) {
992
+ if (el.getAttribute(attribute) === value) return el;
1087
993
  }
994
+ return null;
1088
995
  }
1089
-
1090
- // src/testing/adapters.ts
1091
- function escSel2(value) {
1092
- return value.replace(/["\\]/g, "\\$&");
996
+ function queryAllByAttribute(container, attribute, value) {
997
+ return Array.from(container.querySelectorAll(`[${attribute}]`)).filter(
998
+ (el) => el.getAttribute(attribute) === value
999
+ );
1093
1000
  }
1094
- function createJestAdapter() {
1095
- let container = null;
1096
- return {
1097
- /** Call in beforeEach to set up DOM container */
1098
- setup() {
1099
- container = document.createElement("div");
1100
- container.setAttribute("data-testenv", "jest");
1101
- document.body.appendChild(container);
1102
- return container;
1103
- },
1104
- /** Call in afterEach to clean up */
1105
- teardown() {
1106
- if (container) {
1107
- container.innerHTML = "";
1108
- if (container.parentNode) {
1109
- container.parentNode.removeChild(container);
1110
- }
1111
- container = null;
1112
- }
1113
- },
1114
- /** Render a component into the test container */
1115
- render(component) {
1116
- if (!container) {
1117
- container = document.createElement("div");
1118
- container.setAttribute("data-testenv", "jest");
1119
- document.body.appendChild(container);
1120
- }
1121
- const element = typeof component === "function" ? component() : component;
1122
- container.appendChild(element);
1123
- return { element, container };
1124
- },
1125
- /** Custom Jest matchers */
1126
- matchers: {
1127
- /** Check if element has specific text content */
1128
- toHaveTextContent(element, expected) {
1129
- const actual = element.textContent || "";
1130
- const pass = actual.includes(expected);
1131
- return {
1132
- pass,
1133
- message: () => pass ? `Expected element not to have text content "${expected}", but found "${actual}"` : `Expected element to have text content "${expected}", but found "${actual}"`
1134
- };
1135
- },
1136
- /** Check if element has specific attribute */
1137
- toHaveAttribute(element, attr, value) {
1138
- const hasAttr = element.hasAttribute(attr);
1139
- const attrValue = element.getAttribute(attr);
1140
- const pass = value !== void 0 ? hasAttr && attrValue === value : hasAttr;
1141
- return {
1142
- pass,
1143
- message: () => {
1144
- if (value !== void 0) {
1145
- return pass ? `Expected element not to have attribute "${attr}" with value "${value}", but it does` : `Expected element to have attribute "${attr}" with value "${value}", but got "${attrValue}"`;
1146
- }
1147
- return pass ? `Expected element not to have attribute "${attr}", but it does` : `Expected element to have attribute "${attr}", but it does not`;
1148
- }
1149
- };
1150
- },
1151
- /** Check if element has specific class */
1152
- toHaveClass(element, className) {
1153
- const pass = element.classList.contains(className);
1154
- return {
1155
- pass,
1156
- message: () => pass ? `Expected element not to have class "${className}", but it does` : `Expected element to have class "${className}", but it has "${element.className}"`
1157
- };
1158
- },
1159
- /** Check if element is visible (no display:none, visibility:hidden, hidden attr) */
1160
- toBeVisible(element) {
1161
- const htmlEl = element;
1162
- const isHiddenAttr = element.hasAttribute("hidden");
1163
- const style = htmlEl.style;
1164
- const isDisplayNone = style?.display === "none";
1165
- const isVisibilityHidden = style?.visibility === "hidden";
1166
- const isOpacityZero = style?.opacity === "0";
1167
- const pass = !isHiddenAttr && !isDisplayNone && !isVisibilityHidden && !isOpacityZero;
1168
- return {
1169
- pass,
1170
- message: () => {
1171
- if (pass) {
1172
- return "Expected element to be hidden, but it is visible";
1173
- }
1174
- const reasons = [];
1175
- if (isHiddenAttr) reasons.push('has "hidden" attribute');
1176
- if (isDisplayNone) reasons.push("has display:none");
1177
- if (isVisibilityHidden) reasons.push("has visibility:hidden");
1178
- if (isOpacityZero) reasons.push("has opacity:0");
1179
- return `Expected element to be visible, but it ${reasons.join(" and ")}`;
1180
- }
1181
- };
1182
- },
1183
- /** Check if element is disabled */
1184
- toBeDisabled(element) {
1185
- const isDisabledProp = element.disabled === true;
1186
- const isDisabledAttr = element.hasAttribute("disabled");
1187
- const isAriaDisabled = element.getAttribute("aria-disabled") === "true";
1188
- const pass = isDisabledProp || isDisabledAttr || isAriaDisabled;
1189
- return {
1190
- pass,
1191
- message: () => pass ? "Expected element not to be disabled, but it is" : "Expected element to be disabled, but it is not"
1192
- };
1193
- },
1194
- /** Check if element has focus */
1195
- toHaveFocus(element) {
1196
- const pass = document.activeElement === element;
1197
- return {
1198
- pass,
1199
- message: () => pass ? "Expected element not to have focus, but it does" : `Expected element to have focus, but active element is <${document.activeElement?.tagName?.toLowerCase() || "none"}>`
1200
- };
1201
- },
1202
- /** Check if element has specific style */
1203
- toHaveStyle(element, style) {
1204
- const mismatches = [];
1205
- for (const [prop, expected] of Object.entries(style)) {
1206
- const kebabProp = prop.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
1207
- const actual = element.style.getPropertyValue(kebabProp);
1208
- if (actual !== expected) {
1209
- mismatches.push(`${prop}: expected "${expected}" but got "${actual}"`);
1210
- }
1211
- }
1212
- const pass = mismatches.length === 0;
1213
- return {
1214
- pass,
1215
- message: () => pass ? "Expected element not to have the specified styles, but it does" : `Style mismatches: ${mismatches.join("; ")}`
1216
- };
1217
- }
1218
- }
1219
- };
1001
+ function queryByTestId(container, testId) {
1002
+ return queryByAttribute(container, "data-testid", testId);
1220
1003
  }
1221
- function createCypressAdapter() {
1222
- return {
1223
- /** Mount a component for Cypress component testing */
1224
- mount(component, options) {
1225
- const container = options?.container || document.createElement("div");
1226
- if (!options?.container) {
1227
- container.setAttribute("data-testenv", "cypress");
1228
- document.body.appendChild(container);
1004
+ function queryByRole(container, role) {
1005
+ return queryByAttribute(container, "role", role);
1006
+ }
1007
+ function queryByLabel(container, labelText) {
1008
+ const labels = Array.from(container.querySelectorAll("label"));
1009
+ for (const label of labels) {
1010
+ if (label.textContent?.trim() === labelText) {
1011
+ const forId = label.getAttribute("for");
1012
+ if (forId) {
1013
+ const target = queryByAttribute(container, "id", forId);
1014
+ if (target) return target;
1229
1015
  }
1230
- const element = typeof component === "function" ? component() : component;
1231
- container.appendChild(element);
1232
- return { element, container };
1233
- },
1234
- /** Generate Cypress custom commands for SibuJS */
1235
- commands: {
1236
- /** Find by data-testid */
1237
- getByTestId: (id) => `[data-testid="${id}"]`,
1238
- /** Find by text */
1239
- getByText: (text) => `:contains("${text}")`,
1240
- /** Find by role */
1241
- getByRole: (role) => `[role="${role}"]`
1016
+ const child = label.querySelector("input, select, textarea, button");
1017
+ if (child) return child;
1242
1018
  }
1243
- };
1019
+ }
1020
+ return queryByAttribute(container, "aria-label", labelText);
1244
1021
  }
1245
- function createPlaywrightAdapter() {
1246
- return {
1247
- /** Selectors for common SibuJS patterns */
1248
- selectors: {
1249
- byTestId: (id) => `[data-testid="${id}"]`,
1250
- byRole: (role) => `[role="${role}"]`,
1251
- byAriaLabel: (label) => `[aria-label="${label}"]`,
1252
- byDataAttr: (attr, value) => value ? `[data-${attr}="${value}"]` : `[data-${attr}]`
1253
- },
1254
- /** Generate a page object for a SibuJS component */
1255
- createPageObject(name, selectors) {
1256
- const selectorMap = { ...selectors };
1257
- return {
1258
- name,
1259
- getSelector(key) {
1260
- const selector = selectorMap[key];
1261
- if (!selector) {
1262
- throw new Error(
1263
- `Page object "${name}" has no selector for key "${key}". Available keys: ${Object.keys(selectorMap).join(", ")}`
1264
- );
1265
- }
1266
- return selector;
1267
- },
1268
- allSelectors() {
1269
- return { ...selectorMap };
1270
- }
1271
- };
1272
- }
1273
- };
1274
- }
1275
- function createUniversalAdapter() {
1276
- let container = null;
1277
- return {
1278
- /** Setup test environment */
1279
- setup() {
1280
- container = document.createElement("div");
1281
- container.setAttribute("data-testenv", "universal");
1282
- document.body.appendChild(container);
1283
- return container;
1284
- },
1285
- /** Teardown test environment */
1286
- teardown() {
1287
- if (container) {
1288
- container.innerHTML = "";
1289
- if (container.parentNode) {
1290
- container.parentNode.removeChild(container);
1291
- }
1292
- container = null;
1293
- }
1294
- },
1295
- /** Render component */
1296
- render(component) {
1297
- if (!container) {
1298
- container = document.createElement("div");
1299
- container.setAttribute("data-testenv", "universal");
1300
- document.body.appendChild(container);
1022
+ async function pollUntil(fn, timeout, interval, errorIfTimeout) {
1023
+ const start = Date.now();
1024
+ return new Promise((resolve, reject) => {
1025
+ let timer;
1026
+ const check = () => {
1027
+ timer = void 0;
1028
+ let result;
1029
+ try {
1030
+ result = fn();
1031
+ } catch (err) {
1032
+ reject(err);
1033
+ return;
1301
1034
  }
1302
- const element = typeof component === "function" ? component() : component;
1303
- container.appendChild(element);
1304
- return { element, container };
1305
- },
1306
- /** Query helpers */
1307
- queries: {
1308
- byTestId(container2, id) {
1309
- return container2.querySelector(`[data-testid="${escSel2(id)}"]`);
1310
- },
1311
- byRole(container2, role) {
1312
- return container2.querySelector(`[role="${escSel2(role)}"]`);
1313
- },
1314
- byText(container2, text) {
1315
- const walker = document.createTreeWalker(container2, NodeFilter.SHOW_TEXT);
1316
- while (walker.nextNode()) {
1317
- if (walker.currentNode.textContent?.includes(text)) {
1318
- return walker.currentNode.parentElement;
1319
- }
1320
- }
1321
- return null;
1322
- },
1323
- byLabelText(container2, label) {
1324
- const labels = container2.querySelectorAll("label");
1325
- for (const labelEl of Array.from(labels)) {
1326
- if (labelEl.textContent?.includes(label)) {
1327
- const forAttr = labelEl.getAttribute("for");
1328
- if (forAttr) {
1329
- return container2.querySelector(`[id="${escSel2(forAttr)}"]`);
1330
- }
1331
- const nested = labelEl.querySelector("input, select, textarea");
1332
- if (nested) return nested;
1333
- }
1334
- }
1335
- return container2.querySelector(`[aria-label="${escSel2(label)}"]`);
1336
- },
1337
- allByRole(container2, role) {
1338
- return Array.from(container2.querySelectorAll(`[role="${escSel2(role)}"]`));
1035
+ if (result !== null) {
1036
+ resolve(result);
1037
+ return;
1339
1038
  }
1340
- },
1341
- /** Assertion helpers */
1342
- assert: {
1343
- textContent(el, expected) {
1344
- const actual = el.textContent || "";
1345
- if (!actual.includes(expected)) {
1346
- throw new Error(`Expected text content to include "${expected}", but got "${actual}"`);
1347
- }
1348
- },
1349
- attribute(el, attr, value) {
1350
- if (!el.hasAttribute(attr)) {
1351
- throw new Error(`Expected element to have attribute "${attr}", but it does not`);
1352
- }
1353
- if (value !== void 0) {
1354
- const actual = el.getAttribute(attr);
1355
- if (actual !== value) {
1356
- throw new Error(`Expected attribute "${attr}" to be "${value}", but got "${actual}"`);
1357
- }
1358
- }
1359
- },
1360
- visible(el) {
1361
- const htmlEl = el;
1362
- const reasons = [];
1363
- if (el.hasAttribute("hidden")) reasons.push('has "hidden" attribute');
1364
- if (htmlEl.style?.display === "none") reasons.push("has display:none");
1365
- if (htmlEl.style?.visibility === "hidden") reasons.push("has visibility:hidden");
1366
- if (reasons.length > 0) {
1367
- throw new Error(`Expected element to be visible, but it ${reasons.join(" and ")}`);
1368
- }
1369
- },
1370
- disabled(el) {
1371
- const isDisabled = el.disabled === true || el.hasAttribute("disabled") || el.getAttribute("aria-disabled") === "true";
1372
- if (!isDisabled) {
1373
- throw new Error("Expected element to be disabled, but it is not");
1374
- }
1375
- },
1376
- className(el, name) {
1377
- if (!el.classList.contains(name)) {
1378
- throw new Error(`Expected element to have class "${name}", but it has "${el.className}"`);
1379
- }
1039
+ if (Date.now() - start >= timeout) {
1040
+ reject(new Error(errorIfTimeout));
1041
+ return;
1380
1042
  }
1381
- }
1382
- };
1043
+ timer = setTimeout(check, interval);
1044
+ };
1045
+ check();
1046
+ void timer;
1047
+ });
1383
1048
  }
1384
-
1385
- // src/testing/e2e.ts
1386
- function createHttpMock(routes = [], options = {}) {
1387
- const originalFetch = globalThis.fetch;
1388
- const hadOriginalFetch = Object.hasOwn(globalThis, "fetch");
1389
- const requestLog = [];
1390
- const mockRoutes = [...routes];
1391
- function matchRoute(url, method) {
1392
- return mockRoutes.find((route) => {
1393
- const methodMatch = !route.method || route.method.toUpperCase() === method.toUpperCase();
1394
- if (!methodMatch) return false;
1395
- if (typeof route.url === "string") return url === route.url || url.endsWith(route.url);
1396
- return route.url.test(url);
1049
+ function findByText(container, text, options = {}) {
1050
+ return pollUntil(
1051
+ () => queryByText(container, text),
1052
+ options.timeout ?? 1e3,
1053
+ options.interval ?? 50,
1054
+ `findByText: no element with text "${text}" after ${options.timeout ?? 1e3}ms`
1055
+ );
1056
+ }
1057
+ function findByTestId(container, testId, options = {}) {
1058
+ return pollUntil(
1059
+ () => queryByTestId(container, testId),
1060
+ options.timeout ?? 1e3,
1061
+ options.interval ?? 50,
1062
+ `findByTestId: no element with data-testid="${testId}" after ${options.timeout ?? 1e3}ms`
1063
+ );
1064
+ }
1065
+ function findByRole(container, role, options = {}) {
1066
+ return pollUntil(
1067
+ () => queryByRole(container, role),
1068
+ options.timeout ?? 1e3,
1069
+ options.interval ?? 50,
1070
+ `findByRole: no element with role="${role}" after ${options.timeout ?? 1e3}ms`
1071
+ );
1072
+ }
1073
+ function waitForSignal(getter, predicate, options = {}) {
1074
+ const timeoutMs = options.timeout ?? 1e3;
1075
+ return new Promise((resolve, reject) => {
1076
+ let resolved = false;
1077
+ let timer;
1078
+ const finish = (fn) => {
1079
+ if (resolved) return;
1080
+ resolved = true;
1081
+ if (timer !== void 0) clearTimeout(timer);
1082
+ fn();
1083
+ };
1084
+ timer = setTimeout(() => {
1085
+ finish(() => {
1086
+ teardown();
1087
+ reject(new Error(`waitForSignal: predicate did not match within ${timeoutMs}ms`));
1088
+ });
1089
+ }, timeoutMs);
1090
+ const teardown = effect(() => {
1091
+ if (resolved) return;
1092
+ const value = getter();
1093
+ if (predicate(value)) {
1094
+ finish(() => {
1095
+ queueMicrotask(() => teardown());
1096
+ resolve(value);
1097
+ });
1098
+ }
1397
1099
  });
1100
+ });
1101
+ }
1102
+ function type(element, text) {
1103
+ for (const char of text) {
1104
+ element.value += char;
1105
+ element.dispatchEvent(new InputEvent("input", { bubbles: true, data: char }));
1398
1106
  }
1399
- const mockFetch = async (input, init) => {
1400
- const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
1401
- const method = init?.method || "GET";
1402
- let body;
1403
- if (init?.body != null) {
1404
- const raw = String(init.body);
1405
- try {
1406
- body = JSON.parse(raw);
1407
- } catch {
1408
- body = raw;
1409
- }
1410
- }
1411
- requestLog.push({ url, method, body, timestamp: Date.now() });
1412
- const route = matchRoute(url, method);
1413
- if (!route) {
1414
- return new Response(JSON.stringify({ error: "Not mocked" }), { status: 404 });
1107
+ element.dispatchEvent(new Event("change", { bubbles: true }));
1108
+ }
1109
+
1110
+ // src/testing/listenerTracking.ts
1111
+ var KEY = /* @__PURE__ */ Symbol.for("sibujs.listenerTracking.v2");
1112
+ var host = globalThis;
1113
+ function captureOf(options) {
1114
+ if (typeof options === "boolean") return options;
1115
+ return typeof options === "object" && options !== null && !!options.capture;
1116
+ }
1117
+ function enableListenerTracking() {
1118
+ if (host[KEY] || typeof EventTarget === "undefined") return;
1119
+ const tracker = /* @__PURE__ */ new WeakMap();
1120
+ host[KEY] = tracker;
1121
+ const proto = EventTarget.prototype;
1122
+ const add = proto.addEventListener;
1123
+ const remove = proto.removeEventListener;
1124
+ const find = (target, type2, listener, capture) => tracker.get(target)?.get(type2)?.find((e) => e.listener === listener && e.capture === capture);
1125
+ const drop = (target, type2, entry) => {
1126
+ const types = tracker.get(target);
1127
+ const entries = types?.get(type2);
1128
+ if (!types || !entries) return;
1129
+ const index = entries.indexOf(entry);
1130
+ if (index === -1) return;
1131
+ entries.splice(index, 1);
1132
+ if (entries.length === 0) types.delete(type2);
1133
+ entry.release?.();
1134
+ };
1135
+ proto.addEventListener = function(type2, listener, options) {
1136
+ const tracked = listener != null && typeof Element !== "undefined" && this instanceof Element;
1137
+ const capture = captureOf(options);
1138
+ const opts = typeof options === "object" && options !== null ? options : void 0;
1139
+ if (!tracked || opts?.signal?.aborted || find(this, type2, listener, capture)) {
1140
+ add.call(this, type2, listener, options);
1141
+ return;
1415
1142
  }
1416
- let mockResponse;
1417
- try {
1418
- if (typeof route.response === "function") {
1419
- mockResponse = await route.response({ url, method, body, headers: new Headers(init?.headers) });
1420
- } else {
1421
- mockResponse = route.response;
1422
- }
1423
- if (mockResponse.delay) {
1424
- await new Promise((r) => setTimeout(r, mockResponse.delay));
1425
- }
1426
- return new Response(
1427
- typeof mockResponse.body === "string" ? mockResponse.body : JSON.stringify(mockResponse.body),
1428
- {
1429
- status: mockResponse.status || 200,
1430
- statusText: mockResponse.statusText || "OK",
1431
- headers: mockResponse.headers
1432
- }
1433
- );
1434
- } catch (err) {
1435
- return new Response(JSON.stringify({ error: String(err) }), { status: 500 });
1143
+ const entry = { listener, capture };
1144
+ const releases = [];
1145
+ if (opts?.once) {
1146
+ const onFire = () => drop(this, type2, entry);
1147
+ add.call(this, type2, onFire, { capture, once: true });
1148
+ releases.push(() => remove.call(this, type2, onFire, { capture }));
1149
+ }
1150
+ if (opts?.signal) {
1151
+ const signal = opts.signal;
1152
+ const onAbort = () => drop(this, type2, entry);
1153
+ add.call(signal, "abort", onAbort, { once: true });
1154
+ releases.push(() => remove.call(signal, "abort", onAbort));
1155
+ }
1156
+ if (releases.length > 0) {
1157
+ entry.release = () => {
1158
+ for (const release of releases) release();
1159
+ };
1436
1160
  }
1161
+ let types = tracker.get(this);
1162
+ if (!types) tracker.set(this, types = /* @__PURE__ */ new Map());
1163
+ let entries = types.get(type2);
1164
+ if (!entries) types.set(type2, entries = []);
1165
+ entries.push(entry);
1166
+ add.call(this, type2, listener, options);
1437
1167
  };
1438
- const restore = () => {
1439
- if (hadOriginalFetch) {
1440
- globalThis.fetch = originalFetch;
1441
- } else {
1442
- delete globalThis.fetch;
1443
- }
1168
+ proto.removeEventListener = function(type2, listener, options) {
1169
+ remove.call(this, type2, listener, options);
1170
+ const entry = find(this, type2, listener, captureOf(options));
1171
+ if (entry) drop(this, type2, entry);
1444
1172
  };
1445
- const api = {
1446
- /** Install the mock (replace global fetch) */
1447
- install() {
1448
- globalThis.fetch = mockFetch;
1449
- },
1450
- /** Restore original fetch */
1451
- restore,
1452
- /** Add a mock route */
1453
- addRoute(route) {
1454
- mockRoutes.push(route);
1455
- },
1456
- /** Remove all mock routes */
1457
- clearRoutes() {
1458
- mockRoutes.length = 0;
1459
- },
1460
- /** Get the request log */
1461
- getRequests() {
1462
- return [...requestLog];
1463
- },
1464
- /** Clear the request log */
1465
- clearLog() {
1466
- requestLog.length = 0;
1467
- },
1468
- /** Assert that a URL was called */
1469
- assertCalled(url, method = "GET") {
1470
- const found = requestLog.some((r) => r.url.includes(url) && r.method.toUpperCase() === method.toUpperCase());
1471
- if (!found) throw new Error(`Expected ${method} ${url} to have been called`);
1472
- },
1473
- /** Assert that a URL was NOT called */
1474
- assertNotCalled(url, method = "GET") {
1475
- const found = requestLog.some((r) => r.url.includes(url) && r.method.toUpperCase() === method.toUpperCase());
1476
- if (found) throw new Error(`Expected ${method} ${url} to NOT have been called`);
1477
- },
1478
- /** Get number of times a URL was called */
1479
- callCount(url, method = "GET") {
1480
- return requestLog.filter((r) => r.url.includes(url) && r.method.toUpperCase() === method.toUpperCase()).length;
1173
+ }
1174
+ function listenerTypes(el) {
1175
+ const types = host[KEY]?.get(el);
1176
+ return new Set(types ? types.keys() : []);
1177
+ }
1178
+
1179
+ // src/testing/a11y.ts
1180
+ enableListenerTracking();
1181
+ var VALID_ARIA_ROLES = /* @__PURE__ */ new Set([
1182
+ "alert",
1183
+ "alertdialog",
1184
+ "application",
1185
+ "article",
1186
+ "banner",
1187
+ "button",
1188
+ "cell",
1189
+ "checkbox",
1190
+ "columnheader",
1191
+ "combobox",
1192
+ "complementary",
1193
+ "contentinfo",
1194
+ "definition",
1195
+ "dialog",
1196
+ "directory",
1197
+ "document",
1198
+ "feed",
1199
+ "figure",
1200
+ "form",
1201
+ "grid",
1202
+ "gridcell",
1203
+ "group",
1204
+ "heading",
1205
+ "img",
1206
+ "link",
1207
+ "list",
1208
+ "listbox",
1209
+ "listitem",
1210
+ "log",
1211
+ "main",
1212
+ "marquee",
1213
+ "math",
1214
+ "menu",
1215
+ "menubar",
1216
+ "menuitem",
1217
+ "menuitemcheckbox",
1218
+ "menuitemradio",
1219
+ "meter",
1220
+ "navigation",
1221
+ "none",
1222
+ "note",
1223
+ "option",
1224
+ "presentation",
1225
+ "progressbar",
1226
+ "radio",
1227
+ "radiogroup",
1228
+ "region",
1229
+ "row",
1230
+ "rowgroup",
1231
+ "rowheader",
1232
+ "scrollbar",
1233
+ "search",
1234
+ "searchbox",
1235
+ "separator",
1236
+ "slider",
1237
+ "spinbutton",
1238
+ "status",
1239
+ "switch",
1240
+ "tab",
1241
+ "table",
1242
+ "tablist",
1243
+ "tabpanel",
1244
+ "term",
1245
+ "textbox",
1246
+ "timer",
1247
+ "toolbar",
1248
+ "tooltip",
1249
+ "tree",
1250
+ "treegrid",
1251
+ "treeitem"
1252
+ ]);
1253
+ var LANDMARK_ROLES = /* @__PURE__ */ new Set([
1254
+ "banner",
1255
+ "complementary",
1256
+ "contentinfo",
1257
+ "form",
1258
+ "main",
1259
+ "navigation",
1260
+ "region",
1261
+ "search"
1262
+ ]);
1263
+ var LANDMARK_ELEMENTS = {
1264
+ header: "banner",
1265
+ footer: "contentinfo",
1266
+ main: "main",
1267
+ nav: "navigation",
1268
+ aside: "complementary",
1269
+ form: "form",
1270
+ section: "region"
1271
+ };
1272
+ var REQUIRED_ARIA_PROPS = {
1273
+ checkbox: ["aria-checked"],
1274
+ combobox: ["aria-expanded"],
1275
+ heading: ["aria-level"],
1276
+ meter: ["aria-valuenow"],
1277
+ option: ["aria-selected"],
1278
+ radio: ["aria-checked"],
1279
+ scrollbar: ["aria-controls", "aria-valuenow"],
1280
+ separator: [],
1281
+ // only required when focusable
1282
+ slider: ["aria-valuenow"],
1283
+ spinbutton: ["aria-valuenow"],
1284
+ switch: ["aria-checked"]
1285
+ };
1286
+ var VALID_ARIA_ATTRIBUTES = /* @__PURE__ */ new Set([
1287
+ "aria-activedescendant",
1288
+ "aria-atomic",
1289
+ "aria-autocomplete",
1290
+ "aria-busy",
1291
+ "aria-checked",
1292
+ "aria-colcount",
1293
+ "aria-colindex",
1294
+ "aria-colspan",
1295
+ "aria-controls",
1296
+ "aria-current",
1297
+ "aria-describedby",
1298
+ "aria-details",
1299
+ "aria-disabled",
1300
+ "aria-dropeffect",
1301
+ "aria-errormessage",
1302
+ "aria-expanded",
1303
+ "aria-flowto",
1304
+ "aria-grabbed",
1305
+ "aria-haspopup",
1306
+ "aria-hidden",
1307
+ "aria-invalid",
1308
+ "aria-keyshortcuts",
1309
+ "aria-label",
1310
+ "aria-labelledby",
1311
+ "aria-level",
1312
+ "aria-live",
1313
+ "aria-modal",
1314
+ "aria-multiline",
1315
+ "aria-multiselectable",
1316
+ "aria-orientation",
1317
+ "aria-owns",
1318
+ "aria-placeholder",
1319
+ "aria-posinset",
1320
+ "aria-pressed",
1321
+ "aria-readonly",
1322
+ "aria-relevant",
1323
+ "aria-required",
1324
+ "aria-roledescription",
1325
+ "aria-rowcount",
1326
+ "aria-rowindex",
1327
+ "aria-rowspan",
1328
+ "aria-selected",
1329
+ "aria-setsize",
1330
+ "aria-sort",
1331
+ "aria-valuemax",
1332
+ "aria-valuemin",
1333
+ "aria-valuenow",
1334
+ "aria-valuetext"
1335
+ ]);
1336
+ function getSelector(el) {
1337
+ if (el.id) return `#${el.id}`;
1338
+ const tag = el.tagName.toLowerCase();
1339
+ const classes = el.className && typeof el.className === "string" ? `.${el.className.trim().split(/\s+/).join(".")}` : "";
1340
+ const role = el.getAttribute("role");
1341
+ const roleStr = role ? `[role="${role}"]` : "";
1342
+ return `${tag}${classes}${roleStr}`;
1343
+ }
1344
+ function selectAll(root, selector) {
1345
+ const matches = Array.from(root.querySelectorAll(selector));
1346
+ if (root.matches(selector)) matches.unshift(root);
1347
+ return matches;
1348
+ }
1349
+ function hasActivationHandler(el) {
1350
+ if (el.hasAttribute("onclick")) return true;
1351
+ const types = listenerTypes(el);
1352
+ return ["click", "pointerdown", "pointerup", "mousedown", "mouseup"].some((t) => types.has(t));
1353
+ }
1354
+ function hasKeyboardHandler(el) {
1355
+ if (el.hasAttribute("onkeydown") || el.hasAttribute("onkeyup") || el.hasAttribute("onkeypress")) return true;
1356
+ const types = listenerTypes(el);
1357
+ return types.has("keydown") || types.has("keyup") || types.has("keypress");
1358
+ }
1359
+ function hasAccessibleName(el) {
1360
+ if (el.getAttribute("aria-label")?.trim()) return true;
1361
+ const labelledBy = el.getAttribute("aria-labelledby");
1362
+ if (labelledBy) {
1363
+ const doc = el.ownerDocument;
1364
+ const ids = labelledBy.split(/\s+/);
1365
+ for (const id of ids) {
1366
+ const ref = doc.getElementById(id);
1367
+ if (ref?.textContent?.trim()) return true;
1481
1368
  }
1482
- };
1483
- if (typeof options.afterEach === "function") {
1484
- options.afterEach(() => api.restore());
1485
1369
  }
1486
- return api;
1370
+ if (el.getAttribute("title")?.trim()) return true;
1371
+ if (el.textContent?.trim()) return true;
1372
+ return false;
1487
1373
  }
1488
- function createTimerMock(options = {}) {
1489
- const g = globalThis;
1490
- const snapshot = (key) => ({
1491
- had: Object.hasOwn(globalThis, key),
1492
- value: g[key]
1493
- });
1494
- const saved = {
1495
- setTimeout: snapshot("setTimeout"),
1496
- setInterval: snapshot("setInterval"),
1497
- clearTimeout: snapshot("clearTimeout"),
1498
- clearInterval: snapshot("clearInterval"),
1499
- requestAnimationFrame: snapshot("requestAnimationFrame"),
1500
- cancelAnimationFrame: snapshot("cancelAnimationFrame")
1501
- };
1502
- let currentTime = 0;
1503
- let nextId = 1;
1504
- const timers = [];
1505
- const api = {
1506
- install() {
1507
- currentTime = 0;
1508
- globalThis.setTimeout = (cb, delay = 0) => {
1509
- const id = nextId++;
1510
- timers.push({ id, callback: cb, time: currentTime + delay });
1511
- return id;
1512
- };
1513
- globalThis.setInterval = (cb, interval) => {
1514
- const id = nextId++;
1515
- timers.push({ id, callback: cb, time: currentTime + interval, interval });
1516
- return id;
1517
- };
1518
- globalThis.clearTimeout = (id) => {
1519
- const idx = timers.findIndex((t) => t.id === id);
1520
- if (idx !== -1) timers.splice(idx, 1);
1521
- };
1522
- globalThis.clearInterval = (id) => {
1523
- const idx = timers.findIndex((t) => t.id === id);
1524
- if (idx !== -1) timers.splice(idx, 1);
1525
- };
1526
- globalThis.requestAnimationFrame = (cb) => {
1527
- const id = nextId++;
1528
- timers.push({ id, callback: () => cb(currentTime), time: currentTime + 16 });
1529
- return id;
1530
- };
1531
- globalThis.cancelAnimationFrame = (id) => {
1532
- const idx = timers.findIndex((t) => t.id === id);
1533
- if (idx !== -1) timers.splice(idx, 1);
1534
- };
1535
- },
1536
- restore() {
1537
- for (const [key, snap] of Object.entries(saved)) {
1538
- if (snap.had) {
1539
- g[key] = snap.value;
1540
- } else {
1541
- delete g[key];
1374
+ function isNativeInteractive(el) {
1375
+ const tag = el.tagName.toLowerCase();
1376
+ if (["button", "select", "textarea", "summary"].includes(tag)) return true;
1377
+ if (tag === "input" && el.getAttribute("type") !== "hidden") return true;
1378
+ if (tag === "a" && el.hasAttribute("href")) return true;
1379
+ if (tag === "area" && el.hasAttribute("href")) return true;
1380
+ return false;
1381
+ }
1382
+ var DELEGATES_ATTRIBUTE = "data-a11y-delegates";
1383
+ function checkImageAlt(root) {
1384
+ const violations = [];
1385
+ const images = selectAll(root, "img");
1386
+ for (const img of Array.from(images)) {
1387
+ if (!img.hasAttribute("alt")) {
1388
+ violations.push({
1389
+ rule: "image-alt",
1390
+ level: "error",
1391
+ message: 'Image element must have an alt attribute. Use alt="" for decorative images.',
1392
+ element: img,
1393
+ selector: getSelector(img)
1394
+ });
1395
+ } else if (img.getAttribute("alt") === "" && !img.getAttribute("role")) {
1396
+ violations.push({
1397
+ rule: "image-alt-empty",
1398
+ level: "info",
1399
+ message: "Image has empty alt text. Ensure this is a decorative image; otherwise provide meaningful alt text.",
1400
+ element: img,
1401
+ selector: getSelector(img)
1402
+ });
1403
+ }
1404
+ }
1405
+ const roleImgs = selectAll(root, '[role="img"]');
1406
+ for (const el of Array.from(roleImgs)) {
1407
+ if (!hasAccessibleName(el)) {
1408
+ violations.push({
1409
+ rule: "image-alt",
1410
+ level: "error",
1411
+ message: 'Element with role="img" must have an accessible name (aria-label or aria-labelledby).',
1412
+ element: el,
1413
+ selector: getSelector(el)
1414
+ });
1415
+ }
1416
+ }
1417
+ return violations;
1418
+ }
1419
+ function checkFormLabels(root) {
1420
+ const violations = [];
1421
+ const inputs = selectAll(root, "input, select, textarea");
1422
+ for (const input of Array.from(inputs)) {
1423
+ if (input.getAttribute("type") === "hidden") continue;
1424
+ const inputType = input.getAttribute("type");
1425
+ if (inputType === "submit" || inputType === "reset" || inputType === "button") continue;
1426
+ const hasLabel = checkInputHasLabel(input, root);
1427
+ if (!hasLabel) {
1428
+ violations.push({
1429
+ rule: "form-label",
1430
+ level: "error",
1431
+ message: `Form ${input.tagName.toLowerCase()} element must have an associated label. Use <label for="id">, aria-label, aria-labelledby, or wrap in a <label>.`,
1432
+ element: input,
1433
+ selector: getSelector(input)
1434
+ });
1435
+ }
1436
+ }
1437
+ return violations;
1438
+ }
1439
+ function checkInputHasLabel(input, root) {
1440
+ if (input.getAttribute("aria-label")?.trim()) return true;
1441
+ const labelledBy = input.getAttribute("aria-labelledby");
1442
+ if (labelledBy) {
1443
+ const doc = input.ownerDocument;
1444
+ const ids = labelledBy.split(/\s+/);
1445
+ for (const id2 of ids) {
1446
+ const ref = doc.getElementById(id2);
1447
+ if (ref?.textContent?.trim()) return true;
1448
+ }
1449
+ }
1450
+ if (input.getAttribute("title")?.trim()) return true;
1451
+ const id = input.getAttribute("id");
1452
+ if (id) {
1453
+ const scope = input.getRootNode();
1454
+ const searchIn = typeof scope.querySelectorAll === "function" ? scope : root;
1455
+ const label = queryAllByAttribute(searchIn, "for", id).find((el) => el.tagName === "LABEL");
1456
+ if (label?.textContent?.trim()) return true;
1457
+ }
1458
+ let parent = input.parentElement;
1459
+ while (parent && parent !== root) {
1460
+ if (parent.tagName.toLowerCase() === "label") return true;
1461
+ parent = parent.parentElement;
1462
+ }
1463
+ return false;
1464
+ }
1465
+ function checkHeadingHierarchy(root) {
1466
+ const violations = [];
1467
+ const headings = selectAll(root, "h1, h2, h3, h4, h5, h6");
1468
+ if (headings.length === 0) return violations;
1469
+ let previousLevel = 0;
1470
+ for (const heading of Array.from(headings)) {
1471
+ const currentLevel = parseInt(heading.tagName[1], 10);
1472
+ if (previousLevel === 0 && currentLevel !== 1) {
1473
+ violations.push({
1474
+ rule: "heading-order",
1475
+ level: "warning",
1476
+ message: `Heading hierarchy should start with h1. Found ${heading.tagName.toLowerCase()} as the first heading.`,
1477
+ element: heading,
1478
+ selector: getSelector(heading)
1479
+ });
1480
+ }
1481
+ if (previousLevel > 0 && currentLevel > previousLevel + 1) {
1482
+ violations.push({
1483
+ rule: "heading-order",
1484
+ level: "error",
1485
+ message: `Heading level skipped: ${heading.tagName.toLowerCase()} follows h${previousLevel}. Headings should not skip levels.`,
1486
+ element: heading,
1487
+ selector: getSelector(heading)
1488
+ });
1489
+ }
1490
+ if (!heading.textContent?.trim()) {
1491
+ violations.push({
1492
+ rule: "heading-empty",
1493
+ level: "error",
1494
+ message: `Heading ${heading.tagName.toLowerCase()} is empty. Headings must have discernible text.`,
1495
+ element: heading,
1496
+ selector: getSelector(heading)
1497
+ });
1498
+ }
1499
+ previousLevel = currentLevel;
1500
+ }
1501
+ return violations;
1502
+ }
1503
+ function checkColorContrast(root) {
1504
+ const violations = [];
1505
+ const roledElements = selectAll(root, "[role]");
1506
+ for (const el of Array.from(roledElements)) {
1507
+ const role = el.getAttribute("role");
1508
+ if (!role) continue;
1509
+ if (role === "presentation" || role === "none") continue;
1510
+ const nameRequiredRoles = /* @__PURE__ */ new Set([
1511
+ "alert",
1512
+ "alertdialog",
1513
+ "button",
1514
+ "checkbox",
1515
+ "combobox",
1516
+ "dialog",
1517
+ "link",
1518
+ "menuitem",
1519
+ "menuitemcheckbox",
1520
+ "menuitemradio",
1521
+ "option",
1522
+ "progressbar",
1523
+ "radio",
1524
+ "slider",
1525
+ "spinbutton",
1526
+ "switch",
1527
+ "tab",
1528
+ "textbox",
1529
+ "treeitem"
1530
+ ]);
1531
+ if (nameRequiredRoles.has(role) && !hasAccessibleName(el)) {
1532
+ violations.push({
1533
+ rule: "accessible-name",
1534
+ level: "error",
1535
+ message: `Element with role="${role}" must have an accessible name (via aria-label, aria-labelledby, or text content).`,
1536
+ element: el,
1537
+ selector: getSelector(el)
1538
+ });
1539
+ }
1540
+ }
1541
+ const inlineStyled = selectAll(root, "[style]");
1542
+ for (const el of Array.from(inlineStyled)) {
1543
+ const style = el.getAttribute("style") || "";
1544
+ const hasColor = /(?:^|;)\s*color\s*:/i.test(style);
1545
+ const hasBg = /(?:^|;)\s*background(?:-color)?\s*:/i.test(style);
1546
+ if (hasColor && !hasBg && el.textContent?.trim()) {
1547
+ violations.push({
1548
+ rule: "color-contrast",
1549
+ level: "warning",
1550
+ message: "Element sets text color but not background color. Ensure sufficient contrast with the inherited background.",
1551
+ element: el,
1552
+ selector: getSelector(el)
1553
+ });
1554
+ }
1555
+ if (hasBg && !hasColor && el.textContent?.trim()) {
1556
+ violations.push({
1557
+ rule: "color-contrast",
1558
+ level: "warning",
1559
+ message: "Element sets background color but not text color. Ensure sufficient contrast with the inherited text color.",
1560
+ element: el,
1561
+ selector: getSelector(el)
1562
+ });
1563
+ }
1564
+ }
1565
+ return violations;
1566
+ }
1567
+ function checkKeyboardAccess(root, options) {
1568
+ const violations = [];
1569
+ const clickElements = selectAll(root, "*").filter(hasActivationHandler);
1570
+ for (const el of clickElements) {
1571
+ if (isNativeInteractive(el)) continue;
1572
+ if (el.hasAttribute(DELEGATES_ATTRIBUTE) || options?.delegatesActivation?.(el)) continue;
1573
+ const hasTabindex = el.hasAttribute("tabindex");
1574
+ const hasRole = el.hasAttribute("role");
1575
+ const hasKeyHandler = hasKeyboardHandler(el);
1576
+ if (!hasTabindex) {
1577
+ violations.push({
1578
+ rule: "keyboard-access",
1579
+ level: "error",
1580
+ message: "Interactive element with onclick must have tabindex to be keyboard accessible.",
1581
+ element: el,
1582
+ selector: getSelector(el)
1583
+ });
1584
+ }
1585
+ if (!hasRole) {
1586
+ violations.push({
1587
+ rule: "keyboard-access",
1588
+ level: "warning",
1589
+ message: 'Interactive element with onclick should have an explicit role attribute (e.g., role="button").',
1590
+ element: el,
1591
+ selector: getSelector(el)
1592
+ });
1593
+ }
1594
+ if (!hasKeyHandler) {
1595
+ violations.push({
1596
+ rule: "keyboard-access",
1597
+ level: "error",
1598
+ message: "Interactive element with onclick must also have a keyboard event handler (onkeydown or onkeyup) for keyboard accessibility.",
1599
+ element: el,
1600
+ selector: getSelector(el)
1601
+ });
1602
+ }
1603
+ }
1604
+ const interactiveRoles = selectAll(
1605
+ root,
1606
+ '[role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"]'
1607
+ );
1608
+ for (const el of Array.from(interactiveRoles)) {
1609
+ if (isNativeInteractive(el)) continue;
1610
+ if (!el.hasAttribute("tabindex")) {
1611
+ violations.push({
1612
+ rule: "keyboard-access",
1613
+ level: "error",
1614
+ message: `Element with role="${el.getAttribute("role")}" must have tabindex to be keyboard accessible.`,
1615
+ element: el,
1616
+ selector: getSelector(el)
1617
+ });
1618
+ }
1619
+ }
1620
+ return violations;
1621
+ }
1622
+ function checkAriaAttributes(root) {
1623
+ const violations = [];
1624
+ const roledElements = selectAll(root, "[role]");
1625
+ for (const el of Array.from(roledElements)) {
1626
+ const role = el.getAttribute("role");
1627
+ if (!role) continue;
1628
+ if (!VALID_ARIA_ROLES.has(role)) {
1629
+ violations.push({
1630
+ rule: "aria-valid-role",
1631
+ level: "error",
1632
+ message: `Invalid ARIA role "${role}". Must be a valid WAI-ARIA role.`,
1633
+ element: el,
1634
+ selector: getSelector(el)
1635
+ });
1636
+ }
1637
+ if (REQUIRED_ARIA_PROPS[role]) {
1638
+ for (const requiredProp of REQUIRED_ARIA_PROPS[role]) {
1639
+ if (!el.hasAttribute(requiredProp)) {
1640
+ violations.push({
1641
+ rule: "aria-required-attr",
1642
+ level: "error",
1643
+ message: `Element with role="${role}" requires the ${requiredProp} attribute.`,
1644
+ element: el,
1645
+ selector: getSelector(el)
1646
+ });
1542
1647
  }
1543
1648
  }
1544
- timers.length = 0;
1545
- },
1546
- /** Advance time by a given number of ms, running any timers that fire */
1547
- advance(ms) {
1548
- const targetTime = currentTime + ms;
1549
- while (true) {
1550
- timers.sort((a, b) => a.time - b.time);
1551
- const next = timers.find((t) => t.time <= targetTime);
1552
- if (!next) break;
1553
- currentTime = next.time;
1554
- const idx = timers.indexOf(next);
1555
- if (next.interval) {
1556
- next.time += next.interval;
1557
- } else {
1558
- timers.splice(idx, 1);
1649
+ }
1650
+ }
1651
+ const allElements = selectAll(root, "*");
1652
+ for (const el of Array.from(allElements)) {
1653
+ for (const attr of Array.from(el.attributes)) {
1654
+ if (!attr.name.startsWith("aria-")) continue;
1655
+ if (!VALID_ARIA_ATTRIBUTES.has(attr.name)) {
1656
+ violations.push({
1657
+ rule: "aria-valid-attr",
1658
+ level: "error",
1659
+ message: `Invalid ARIA attribute "${attr.name}". Must be a valid WAI-ARIA attribute.`,
1660
+ element: el,
1661
+ selector: getSelector(el)
1662
+ });
1663
+ }
1664
+ if (attr.name === "aria-labelledby" || attr.name === "aria-describedby" || attr.name === "aria-controls" || attr.name === "aria-owns") {
1665
+ const ids = attr.value.split(/\s+/).filter(Boolean);
1666
+ const doc = el.ownerDocument;
1667
+ for (const id of ids) {
1668
+ if (!doc.getElementById(id)) {
1669
+ violations.push({
1670
+ rule: "aria-valid-attr-value",
1671
+ level: "error",
1672
+ message: `${attr.name} references id "${id}" which does not exist in the document.`,
1673
+ element: el,
1674
+ selector: getSelector(el)
1675
+ });
1676
+ }
1559
1677
  }
1560
- next.callback();
1561
1678
  }
1562
- currentTime = targetTime;
1563
- },
1564
- /** Run all pending timers immediately */
1565
- flush() {
1566
- const maxIterations = 1e3;
1567
- let i = 0;
1568
- while (timers.length > 0 && i++ < maxIterations) {
1569
- timers.sort((a, b) => a.time - b.time);
1570
- const next = timers[0];
1571
- currentTime = next.time;
1572
- if (next.interval) {
1573
- next.time += next.interval;
1574
- } else {
1575
- timers.shift();
1679
+ const booleanAttrs = /* @__PURE__ */ new Set([
1680
+ "aria-checked",
1681
+ "aria-disabled",
1682
+ "aria-expanded",
1683
+ "aria-hidden",
1684
+ "aria-pressed",
1685
+ "aria-required",
1686
+ "aria-selected",
1687
+ "aria-busy",
1688
+ "aria-modal",
1689
+ "aria-multiline",
1690
+ "aria-multiselectable",
1691
+ "aria-readonly"
1692
+ ]);
1693
+ if (booleanAttrs.has(attr.name)) {
1694
+ const validValues = ["true", "false"];
1695
+ if (attr.name === "aria-checked" || attr.name === "aria-pressed") {
1696
+ validValues.push("mixed");
1697
+ }
1698
+ if (!validValues.includes(attr.value)) {
1699
+ violations.push({
1700
+ rule: "aria-valid-attr-value",
1701
+ level: "error",
1702
+ message: `${attr.name} has invalid value "${attr.value}". Expected one of: ${validValues.join(", ")}.`,
1703
+ element: el,
1704
+ selector: getSelector(el)
1705
+ });
1576
1706
  }
1577
- next.callback();
1578
1707
  }
1579
- },
1580
- /** Get current fake time */
1581
- now() {
1582
- return currentTime;
1583
- },
1584
- /** Get number of pending timers */
1585
- pendingCount() {
1586
- return timers.length;
1587
1708
  }
1588
- };
1589
- if (typeof options.afterEach === "function") {
1590
- options.afterEach(() => api.restore());
1591
1709
  }
1592
- return api;
1710
+ return violations;
1593
1711
  }
1594
- function createDOMSnapshot(element) {
1595
- return serializeElement(element, 0);
1712
+ function checkLandmarks(root) {
1713
+ const violations = [];
1714
+ const foundLandmarks = /* @__PURE__ */ new Set();
1715
+ for (const role of LANDMARK_ROLES) {
1716
+ const elements = selectAll(root, `[role="${role}"]`);
1717
+ if (elements.length > 0) {
1718
+ foundLandmarks.add(role);
1719
+ }
1720
+ }
1721
+ for (const [tag, role] of Object.entries(LANDMARK_ELEMENTS)) {
1722
+ const elements = selectAll(root, tag);
1723
+ if (elements.length > 0) {
1724
+ foundLandmarks.add(role);
1725
+ }
1726
+ }
1727
+ if (!foundLandmarks.has("main")) {
1728
+ violations.push({
1729
+ rule: "landmark-main",
1730
+ level: "warning",
1731
+ message: 'Page should have a main landmark (<main> element or role="main").',
1732
+ selector: getSelector(root)
1733
+ });
1734
+ }
1735
+ const mainElements = selectAll(root, 'main, [role="main"]');
1736
+ if (mainElements.length > 1) {
1737
+ violations.push({
1738
+ rule: "landmark-unique",
1739
+ level: "error",
1740
+ message: `Multiple main landmarks found (${mainElements.length}). A page should have at most one main landmark.`,
1741
+ selector: getSelector(root)
1742
+ });
1743
+ }
1744
+ const bannerElements = selectAll(root, 'header, [role="banner"]');
1745
+ if (bannerElements.length > 1) {
1746
+ violations.push({
1747
+ rule: "landmark-unique",
1748
+ level: "warning",
1749
+ message: `Multiple banner landmarks found (${bannerElements.length}). Consider using a single banner landmark.`,
1750
+ selector: getSelector(root)
1751
+ });
1752
+ }
1753
+ const contentinfoElements = selectAll(root, 'footer, [role="contentinfo"]');
1754
+ if (contentinfoElements.length > 1) {
1755
+ violations.push({
1756
+ rule: "landmark-unique",
1757
+ level: "warning",
1758
+ message: `Multiple contentinfo landmarks found (${contentinfoElements.length}). Consider using a single contentinfo landmark.`,
1759
+ selector: getSelector(root)
1760
+ });
1761
+ }
1762
+ const navElements = selectAll(root, 'nav, [role="navigation"]');
1763
+ if (navElements.length > 1) {
1764
+ for (const nav of Array.from(navElements)) {
1765
+ if (!nav.getAttribute("aria-label") && !nav.getAttribute("aria-labelledby")) {
1766
+ violations.push({
1767
+ rule: "landmark-label",
1768
+ level: "warning",
1769
+ message: "When multiple navigation landmarks exist, each should have a unique label (aria-label or aria-labelledby).",
1770
+ element: nav,
1771
+ selector: getSelector(nav)
1772
+ });
1773
+ }
1774
+ }
1775
+ }
1776
+ return violations;
1596
1777
  }
1597
- function serializeElement(el, indent) {
1598
- const pad = " ".repeat(indent);
1599
- const tag = el.tagName.toLowerCase();
1600
- const attrs = Array.from(el.attributes).sort((a, b) => a.name.localeCompare(b.name)).map((a) => `${a.name}="${a.value}"`).join(" ");
1601
- const open = attrs ? `${pad}<${tag} ${attrs}>` : `${pad}<${tag}>`;
1602
- const children = Array.from(el.childNodes);
1603
- if (children.length === 0) {
1604
- return `${open}</${tag}>`;
1778
+ function checkLinksAndButtons(root) {
1779
+ const violations = [];
1780
+ const links = selectAll(root, "a[href]");
1781
+ for (const link of Array.from(links)) {
1782
+ if (!hasAccessibleName(link)) {
1783
+ violations.push({
1784
+ rule: "link-name",
1785
+ level: "error",
1786
+ message: "Link must have discernible text. Add text content, aria-label, or aria-labelledby.",
1787
+ element: link,
1788
+ selector: getSelector(link)
1789
+ });
1790
+ }
1791
+ if (link.getAttribute("target") === "_blank") {
1792
+ violations.push({
1793
+ rule: "link-new-window",
1794
+ level: "warning",
1795
+ message: 'Link opens in a new window/tab (target="_blank"). Consider warning users or adding an icon to indicate this.',
1796
+ element: link,
1797
+ selector: getSelector(link)
1798
+ });
1799
+ const rel = link.getAttribute("rel") || "";
1800
+ if (!rel.includes("noopener") && !rel.includes("noreferrer")) {
1801
+ violations.push({
1802
+ rule: "link-noopener",
1803
+ level: "warning",
1804
+ message: 'Link with target="_blank" should include rel="noopener" or rel="noreferrer" for security.',
1805
+ element: link,
1806
+ selector: getSelector(link)
1807
+ });
1808
+ }
1809
+ }
1810
+ const linkText = link.textContent?.trim().toLowerCase() || "";
1811
+ const genericTexts = ["click here", "here", "read more", "more", "learn more", "link"];
1812
+ if (genericTexts.includes(linkText)) {
1813
+ violations.push({
1814
+ rule: "link-purpose",
1815
+ level: "warning",
1816
+ message: `Link text "${link.textContent?.trim()}" is generic. Use descriptive text that explains the link's purpose.`,
1817
+ element: link,
1818
+ selector: getSelector(link)
1819
+ });
1820
+ }
1605
1821
  }
1606
- if (children.length === 1 && children[0].nodeType === 3) {
1607
- const text = children[0].textContent?.trim() || "";
1608
- return `${open}${text}</${tag}>`;
1822
+ const buttons = selectAll(root, 'button, [role="button"]');
1823
+ for (const button of Array.from(buttons)) {
1824
+ if (!hasAccessibleName(button)) {
1825
+ violations.push({
1826
+ rule: "button-name",
1827
+ level: "error",
1828
+ message: "Button must have an accessible name. Add text content, aria-label, or aria-labelledby.",
1829
+ element: button,
1830
+ selector: getSelector(button)
1831
+ });
1832
+ }
1833
+ }
1834
+ const inputButtons = selectAll(root, 'input[type="submit"], input[type="reset"], input[type="button"]');
1835
+ for (const input of Array.from(inputButtons)) {
1836
+ const value = input.getAttribute("value");
1837
+ if (!value?.trim() && !input.getAttribute("aria-label") && !input.getAttribute("aria-labelledby")) {
1838
+ violations.push({
1839
+ rule: "button-name",
1840
+ level: "error",
1841
+ message: `Input button (type="${input.getAttribute("type")}") must have a value, aria-label, or aria-labelledby.`,
1842
+ element: input,
1843
+ selector: getSelector(input)
1844
+ });
1845
+ }
1609
1846
  }
1610
- const childStr = children.map((child) => {
1611
- if (child.nodeType === 3) {
1612
- const text = child.textContent?.trim();
1613
- return text ? `${" ".repeat(indent + 1)}${text}` : "";
1847
+ return violations;
1848
+ }
1849
+ function checkListSemantics(root) {
1850
+ const violations = [];
1851
+ const lists = selectAll(root, "ul, ol");
1852
+ for (const list of Array.from(lists)) {
1853
+ const children = Array.from(list.children);
1854
+ for (const child of children) {
1855
+ const tag = child.tagName.toLowerCase();
1856
+ if (tag !== "li" && tag !== "script" && tag !== "template") {
1857
+ violations.push({
1858
+ rule: "list-semantics",
1859
+ level: "error",
1860
+ message: `Direct children of <${list.tagName.toLowerCase()}> must be <li> elements. Found <${tag}>.`,
1861
+ element: child,
1862
+ selector: getSelector(child)
1863
+ });
1864
+ }
1614
1865
  }
1615
- if (child.nodeType === 1) {
1616
- return serializeElement(child, indent + 1);
1866
+ if (children.length === 0) {
1867
+ violations.push({
1868
+ rule: "list-empty",
1869
+ level: "info",
1870
+ message: `Empty <${list.tagName.toLowerCase()}> element found. Consider removing or adding items.`,
1871
+ element: list,
1872
+ selector: getSelector(list)
1873
+ });
1617
1874
  }
1618
- return "";
1619
- }).filter(Boolean).join("\n");
1620
- return `${open}
1621
- ${childStr}
1622
- ${pad}</${tag}>`;
1623
- }
1624
- function assertDOMEquals(actual, expected) {
1625
- const actualSnapshot = createDOMSnapshot(actual);
1626
- const expectedSnapshot = createDOMSnapshot(expected);
1627
- if (actualSnapshot !== expectedSnapshot) {
1628
- throw new Error(`DOM mismatch:
1629
-
1630
- Actual:
1631
- ${actualSnapshot}
1632
-
1633
- Expected:
1634
- ${expectedSnapshot}`);
1635
1875
  }
1636
- }
1637
- function testComponent(component, options = {}) {
1638
- const container = options.container || document.createElement("div");
1639
- if (!options.container) document.body.appendChild(container);
1640
- const element = typeof component === "function" ? component() : component;
1641
- container.appendChild(element);
1642
- return {
1643
- element,
1644
- container,
1645
- getByTestId(id) {
1646
- return container.querySelector(`[data-testid="${id}"]`);
1647
- },
1648
- getAllByTestId(id) {
1649
- return Array.from(container.querySelectorAll(`[data-testid="${id}"]`));
1650
- },
1651
- getByText(text) {
1652
- const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
1653
- while (walker.nextNode()) {
1654
- if (walker.currentNode.textContent?.includes(text)) {
1655
- return walker.currentNode.parentElement;
1656
- }
1876
+ const dlLists = selectAll(root, "dl");
1877
+ for (const dl of Array.from(dlLists)) {
1878
+ const children = Array.from(dl.children);
1879
+ for (const child of children) {
1880
+ const tag = child.tagName.toLowerCase();
1881
+ if (tag !== "dt" && tag !== "dd" && tag !== "div" && tag !== "script" && tag !== "template") {
1882
+ violations.push({
1883
+ rule: "list-semantics",
1884
+ level: "error",
1885
+ message: `Direct children of <dl> must be <dt>, <dd>, or <div> elements. Found <${tag}>.`,
1886
+ element: child,
1887
+ selector: getSelector(child)
1888
+ });
1657
1889
  }
1658
- return null;
1659
- },
1660
- click(el) {
1661
- el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
1662
- },
1663
- type(el, value) {
1664
- el.value = value;
1665
- el.dispatchEvent(new Event("input", { bubbles: true }));
1666
- el.dispatchEvent(new Event("change", { bubbles: true }));
1667
- },
1668
- async waitForUpdate() {
1669
- await new Promise((r) => setTimeout(r, 0));
1670
- },
1671
- destroy() {
1672
- if (container.parentNode) container.parentNode.removeChild(container);
1673
1890
  }
1674
- };
1675
- }
1676
-
1677
- // src/reactivity/track-core.ts
1678
- var POOL_MAX = 4096;
1679
- var nodePool = [];
1680
- function createNode() {
1681
- return {
1682
- sig: null,
1683
- sub: null,
1684
- epoch: 0,
1685
- depVersion: 0,
1686
- sigPrev: null,
1687
- sigNext: null,
1688
- subPrev: null,
1689
- subNext: null,
1690
- prevActive: null
1691
- };
1692
- }
1693
- function allocNode(sig, sub, epoch) {
1694
- const n = nodePool.pop();
1695
- if (n) {
1696
- n.sig = sig;
1697
- n.sub = sub;
1698
- n.epoch = epoch;
1699
- return n;
1700
1891
  }
1701
- const fresh = createNode();
1702
- fresh.sig = sig;
1703
- fresh.sub = sub;
1704
- fresh.epoch = epoch;
1705
- return fresh;
1706
- }
1707
- function freeNode(node) {
1708
- node.sig = null;
1709
- node.sub = null;
1710
- node.sigPrev = null;
1711
- node.sigNext = null;
1712
- node.subPrev = null;
1713
- node.subNext = null;
1714
- node.prevActive = null;
1715
- if (nodePool.length < POOL_MAX) nodePool.push(node);
1716
- }
1717
- function linkSignal(sig, node) {
1718
- const oldHead = sig.subsHead ?? null;
1719
- node.sigPrev = null;
1720
- node.sigNext = oldHead;
1721
- if (oldHead) oldHead.sigPrev = node;
1722
- else sig.subsTail = node;
1723
- sig.subsHead = node;
1724
- sig.__sc = (sig.__sc ?? 0) + 1;
1725
- }
1726
- function unlinkSignal(node) {
1727
- const sig = node.sig;
1728
- if (!sig) return;
1729
- const prev = node.sigPrev;
1730
- const next = node.sigNext;
1731
- if (prev) prev.sigNext = next;
1732
- else sig.subsHead = next;
1733
- if (next) next.sigPrev = prev;
1734
- else sig.subsTail = prev;
1735
- sig.__sc = (sig.__sc ?? 1) - 1;
1736
- if (sig.__activeNode === node) sig.__activeNode = node.prevActive;
1737
- if (sig.__sc === 0) {
1738
- sig.subsHead = null;
1739
- sig.subsTail = null;
1892
+ const roleLists = selectAll(root, '[role="list"]');
1893
+ for (const list of Array.from(roleLists)) {
1894
+ const children = Array.from(list.children);
1895
+ const hasListItems = children.some(
1896
+ (child) => child.getAttribute("role") === "listitem" || child.tagName.toLowerCase() === "li"
1897
+ );
1898
+ if (children.length > 0 && !hasListItems) {
1899
+ violations.push({
1900
+ rule: "list-semantics",
1901
+ level: "warning",
1902
+ message: 'Element with role="list" should contain children with role="listitem".',
1903
+ element: list,
1904
+ selector: getSelector(list)
1905
+ });
1906
+ }
1740
1907
  }
1908
+ return violations;
1741
1909
  }
1742
- function linkSub(sub, node) {
1743
- const oldTail = sub.depsTail ?? null;
1744
- node.subPrev = oldTail;
1745
- node.subNext = null;
1746
- if (oldTail) oldTail.subNext = node;
1747
- else sub.depsHead = node;
1748
- sub.depsTail = node;
1749
- }
1750
- function unlinkSub(node) {
1751
- const sub = node.sub;
1752
- if (!sub) return;
1753
- const prev = node.subPrev;
1754
- const next = node.subNext;
1755
- if (prev) prev.subNext = next;
1756
- else sub.depsHead = next;
1757
- if (next) next.subPrev = prev;
1758
- else sub.depsTail = prev;
1759
- }
1760
- var currentSubscriber = null;
1761
- var suspendSavedSub = null;
1762
- var notifyDepth = 0;
1763
- var pendingQueue = [];
1764
- var pendingSet = /* @__PURE__ */ new Set();
1765
- var propagateStack = [];
1766
- function safeInvoke(sub) {
1767
- try {
1768
- sub();
1769
- } catch (err) {
1770
- const s = sub;
1771
- reportError(err, { phase: s._errorPhase ?? "effect", name: s.__name, node: s._errorNode });
1910
+ function checkTabOrder(root) {
1911
+ const violations = [];
1912
+ const tabbable = selectAll(root, "[tabindex]");
1913
+ for (const el of Array.from(tabbable)) {
1914
+ const tabindex = parseInt(el.getAttribute("tabindex") ?? "", 10);
1915
+ if (!Number.isNaN(tabindex) && tabindex > 0) {
1916
+ violations.push({
1917
+ rule: "tabindex-positive",
1918
+ level: "warning",
1919
+ message: `Element has tabindex="${tabindex}". Positive tabindex values create a confusing tab order. Use tabindex="0" instead and rely on DOM order.`,
1920
+ element: el,
1921
+ selector: getSelector(el)
1922
+ });
1923
+ }
1772
1924
  }
1773
- }
1774
- function depsChanged(sub) {
1775
- let node = sub.depsHead ?? null;
1776
- if (node === null) return true;
1777
- while (node !== null) {
1778
- const sig = node.sig;
1779
- if (sig === null) return true;
1780
- if (sig._d === true && sig._validate !== void 0) {
1781
- try {
1782
- sig._validate();
1783
- } catch {
1784
- return true;
1925
+ const focusTraps = selectAll(root, '[data-sibu-focus-trap], [aria-modal="true"]');
1926
+ for (const trap of Array.from(focusTraps)) {
1927
+ const role = trap.getAttribute("role");
1928
+ if (role === "dialog" || role === "alertdialog" || trap.hasAttribute("aria-modal")) {
1929
+ const hasCloseButton = trap.querySelector(
1930
+ 'button[aria-label*="close" i], button[aria-label*="dismiss" i], [data-dismiss], [data-close]'
1931
+ );
1932
+ if (!hasCloseButton) {
1933
+ violations.push({
1934
+ rule: "focus-trap-escape",
1935
+ level: "warning",
1936
+ message: "Modal/dialog with focus trap should have a visible close button. Users must be able to dismiss the dialog.",
1937
+ element: trap,
1938
+ selector: getSelector(trap)
1939
+ });
1785
1940
  }
1786
1941
  }
1787
- if (node.depVersion !== sig.__v) return true;
1788
- node = node.subNext;
1789
- }
1790
- return false;
1791
- }
1792
- var suspendDepth = 0;
1793
- var trackingSuspended = false;
1794
- function suspendTracking() {
1795
- if (suspendDepth === 0) {
1796
- suspendSavedSub = currentSubscriber;
1797
- currentSubscriber = null;
1798
- trackingSuspended = true;
1799
- }
1800
- suspendDepth++;
1801
- }
1802
- function resumeTracking() {
1803
- suspendDepth--;
1804
- if (suspendDepth === 0) {
1805
- currentSubscriber = suspendSavedSub;
1806
- suspendSavedSub = null;
1807
- trackingSuspended = false;
1942
+ const focusable = trap.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
1943
+ if (focusable.length === 0) {
1944
+ violations.push({
1945
+ rule: "focus-trap-empty",
1946
+ level: "error",
1947
+ message: "Focus trap contains no focusable elements. Users will be trapped with no way to interact.",
1948
+ element: trap,
1949
+ selector: getSelector(trap)
1950
+ });
1951
+ }
1808
1952
  }
1953
+ return violations;
1809
1954
  }
1810
- function isTrackingSuspended() {
1811
- return trackingSuspended;
1812
- }
1813
- function untracked(fn) {
1814
- suspendTracking();
1815
- try {
1816
- return fn();
1817
- } finally {
1818
- resumeTracking();
1955
+ var CHECK_FUNCTIONS = {
1956
+ images: checkImageAlt,
1957
+ forms: checkFormLabels,
1958
+ headings: checkHeadingHierarchy,
1959
+ contrast: checkColorContrast,
1960
+ keyboard: checkKeyboardAccess,
1961
+ aria: checkAriaAttributes,
1962
+ landmarks: checkLandmarks,
1963
+ links: checkLinksAndButtons,
1964
+ lists: checkListSemantics,
1965
+ tabOrder: checkTabOrder
1966
+ };
1967
+ var ALL_CHECKS = [
1968
+ "images",
1969
+ "forms",
1970
+ "headings",
1971
+ "contrast",
1972
+ "keyboard",
1973
+ "aria",
1974
+ "landmarks",
1975
+ "links",
1976
+ "lists",
1977
+ "tabOrder"
1978
+ ];
1979
+ function checkA11y(root, options) {
1980
+ const checksToRun = options?.checks || ALL_CHECKS;
1981
+ const minLevel = options?.level || "info";
1982
+ const levelPriority = {
1983
+ error: 2,
1984
+ warning: 1,
1985
+ info: 0
1986
+ };
1987
+ const minPriority = levelPriority[minLevel];
1988
+ const allViolations = [];
1989
+ for (const check of checksToRun) {
1990
+ const fn = CHECK_FUNCTIONS[check];
1991
+ if (fn) {
1992
+ const results = fn(root);
1993
+ allViolations.push(...results);
1994
+ }
1819
1995
  }
1996
+ const filtered = allViolations.filter((v) => levelPriority[v.level] >= minPriority);
1997
+ const violations = filtered.filter((v) => v.level === "error");
1998
+ const warnings = filtered.filter((v) => v.level === "warning");
1999
+ const info = filtered.filter((v) => v.level === "info");
2000
+ const parts = [];
2001
+ if (violations.length > 0) parts.push(`${violations.length} error(s)`);
2002
+ if (warnings.length > 0) parts.push(`${warnings.length} warning(s)`);
2003
+ if (info.length > 0) parts.push(`${info.length} info`);
2004
+ const passed = violations.length === 0;
2005
+ const summary = passed ? parts.length > 0 ? `Passed with ${parts.join(", ")}.` : "Passed with no issues." : `Failed with ${parts.join(", ")}.`;
2006
+ return { passed, violations, warnings, info, summary };
1820
2007
  }
1821
- var subscriberEpochCounter = 0;
1822
- function retrack(effectFn, subscriber) {
1823
- const prev = currentSubscriber;
1824
- currentSubscriber = subscriber;
1825
- const sub = subscriber;
1826
- const epoch = ++subscriberEpochCounter;
1827
- sub._epoch = epoch;
1828
- sub._structDirty = false;
1829
- for (let n = sub.depsHead ?? null; n !== null; n = n.subNext) {
1830
- const sig = n.sig;
1831
- n.prevActive = sig.__activeNode ?? null;
1832
- sig.__activeNode = n;
1833
- }
1834
- try {
1835
- effectFn();
1836
- } finally {
1837
- currentSubscriber = prev;
1838
- let node = sub.depsHead ?? null;
1839
- while (node !== null) {
1840
- const next = node.subNext;
1841
- const sig = node.sig;
1842
- sig.__activeNode = node.prevActive;
1843
- node.prevActive = null;
1844
- if (node.epoch !== epoch) {
1845
- unlinkSub(node);
1846
- unlinkSignal(node);
1847
- freeNode(node);
2008
+ function assertA11y(root, options) {
2009
+ const result = checkA11y(root, options);
2010
+ if (!result.passed) {
2011
+ const lines = [`Accessibility check failed: ${result.summary}`, ""];
2012
+ if (result.violations.length > 0) {
2013
+ lines.push("ERRORS:");
2014
+ for (const v of result.violations) {
2015
+ lines.push(` [${v.rule}] ${v.message}${v.selector ? ` (${v.selector})` : ""}`);
1848
2016
  }
1849
- node = next;
2017
+ lines.push("");
1850
2018
  }
1851
- }
1852
- }
1853
- function track(effectFn, subscriber) {
1854
- if (!subscriber) return reactiveBinding(effectFn);
1855
- cleanup(subscriber);
1856
- const prev = currentSubscriber;
1857
- currentSubscriber = subscriber;
1858
- try {
1859
- effectFn();
1860
- } finally {
1861
- currentSubscriber = prev;
1862
- const sub2 = subscriber;
1863
- for (let n = sub2.depsHead ?? null; n !== null; n = n.subNext) {
1864
- const sig = n.sig;
1865
- sig.__activeNode = n.prevActive;
1866
- n.prevActive = null;
2019
+ if (result.warnings.length > 0) {
2020
+ lines.push("WARNINGS:");
2021
+ for (const w of result.warnings) {
2022
+ lines.push(` [${w.rule}] ${w.message}${w.selector ? ` (${w.selector})` : ""}`);
2023
+ }
2024
+ lines.push("");
1867
2025
  }
2026
+ throw new Error(lines.join("\n"));
1868
2027
  }
1869
- const sub = subscriber;
1870
- return sub._dispose ?? (sub._dispose = () => cleanup(subscriber));
1871
2028
  }
1872
- function reactiveBinding(commit, ownerNode) {
1873
- const run = () => {
1874
- const s = subscriber;
1875
- if (s._disposed || s._reentrant) return;
1876
- s._reentrant = true;
1877
- try {
1878
- retrack(commit, subscriber);
1879
- } finally {
1880
- s._reentrant = false;
2029
+
2030
+ // src/testing/adapters.ts
2031
+ function escapeAttrValue(value) {
2032
+ return value.replace(/[\\"]/g, "\\$&").replace(/\n/g, "\\a ").replace(/\r/g, "\\d ").replace(/\0/g, "\\fffd ");
2033
+ }
2034
+ function createJestAdapter() {
2035
+ let container = null;
2036
+ return {
2037
+ /** Call in beforeEach to set up DOM container */
2038
+ setup() {
2039
+ container = document.createElement("div");
2040
+ container.setAttribute("data-testenv", "jest");
2041
+ document.body.appendChild(container);
2042
+ return container;
2043
+ },
2044
+ /** Call in afterEach to clean up */
2045
+ teardown() {
2046
+ if (container) {
2047
+ replaceChildrenSafely(container);
2048
+ if (container.parentNode) {
2049
+ container.parentNode.removeChild(container);
2050
+ }
2051
+ container = null;
2052
+ }
2053
+ },
2054
+ /** Render a component into the test container */
2055
+ render(component) {
2056
+ if (!container) {
2057
+ container = document.createElement("div");
2058
+ container.setAttribute("data-testenv", "jest");
2059
+ document.body.appendChild(container);
2060
+ }
2061
+ const element = typeof component === "function" ? component() : component;
2062
+ container.appendChild(element);
2063
+ return { element, container };
2064
+ },
2065
+ /** Custom Jest matchers */
2066
+ matchers: {
2067
+ /** Check if element has specific text content */
2068
+ toHaveTextContent(element, expected) {
2069
+ const actual = element.textContent || "";
2070
+ const pass = actual.includes(expected);
2071
+ return {
2072
+ pass,
2073
+ message: () => pass ? `Expected element not to have text content "${expected}", but found "${actual}"` : `Expected element to have text content "${expected}", but found "${actual}"`
2074
+ };
2075
+ },
2076
+ /** Check if element has specific attribute */
2077
+ toHaveAttribute(element, attr, value) {
2078
+ const hasAttr = element.hasAttribute(attr);
2079
+ const attrValue = element.getAttribute(attr);
2080
+ const pass = value !== void 0 ? hasAttr && attrValue === value : hasAttr;
2081
+ return {
2082
+ pass,
2083
+ message: () => {
2084
+ if (value !== void 0) {
2085
+ return pass ? `Expected element not to have attribute "${attr}" with value "${value}", but it does` : `Expected element to have attribute "${attr}" with value "${value}", but got "${attrValue}"`;
2086
+ }
2087
+ return pass ? `Expected element not to have attribute "${attr}", but it does` : `Expected element to have attribute "${attr}", but it does not`;
2088
+ }
2089
+ };
2090
+ },
2091
+ /** Check if element has specific class */
2092
+ toHaveClass(element, className) {
2093
+ const pass = element.classList.contains(className);
2094
+ return {
2095
+ pass,
2096
+ message: () => pass ? `Expected element not to have class "${className}", but it does` : `Expected element to have class "${className}", but it has "${element.className}"`
2097
+ };
2098
+ },
2099
+ /** Check if element is visible (no display:none, visibility:hidden, hidden attr) */
2100
+ toBeVisible(element) {
2101
+ const htmlEl = element;
2102
+ const isHiddenAttr = element.hasAttribute("hidden");
2103
+ const style = htmlEl.style;
2104
+ const isDisplayNone = style?.display === "none";
2105
+ const isVisibilityHidden = style?.visibility === "hidden";
2106
+ const isOpacityZero = style?.opacity === "0";
2107
+ const pass = !isHiddenAttr && !isDisplayNone && !isVisibilityHidden && !isOpacityZero;
2108
+ return {
2109
+ pass,
2110
+ message: () => {
2111
+ if (pass) {
2112
+ return "Expected element to be hidden, but it is visible";
2113
+ }
2114
+ const reasons = [];
2115
+ if (isHiddenAttr) reasons.push('has "hidden" attribute');
2116
+ if (isDisplayNone) reasons.push("has display:none");
2117
+ if (isVisibilityHidden) reasons.push("has visibility:hidden");
2118
+ if (isOpacityZero) reasons.push("has opacity:0");
2119
+ return `Expected element to be visible, but it ${reasons.join(" and ")}`;
2120
+ }
2121
+ };
2122
+ },
2123
+ /** Check if element is disabled */
2124
+ toBeDisabled(element) {
2125
+ const isDisabledProp = element.disabled === true;
2126
+ const isDisabledAttr = element.hasAttribute("disabled");
2127
+ const isAriaDisabled = element.getAttribute("aria-disabled") === "true";
2128
+ const pass = isDisabledProp || isDisabledAttr || isAriaDisabled;
2129
+ return {
2130
+ pass,
2131
+ message: () => pass ? "Expected element not to be disabled, but it is" : "Expected element to be disabled, but it is not"
2132
+ };
2133
+ },
2134
+ /** Check if element has focus */
2135
+ toHaveFocus(element) {
2136
+ const pass = document.activeElement === element;
2137
+ return {
2138
+ pass,
2139
+ message: () => pass ? "Expected element not to have focus, but it does" : `Expected element to have focus, but active element is <${document.activeElement?.tagName?.toLowerCase() || "none"}>`
2140
+ };
2141
+ },
2142
+ /** Check if element has specific style */
2143
+ toHaveStyle(element, style) {
2144
+ const mismatches = [];
2145
+ for (const [prop, expected] of Object.entries(style)) {
2146
+ const kebabProp = prop.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
2147
+ const actual = element.style.getPropertyValue(kebabProp);
2148
+ if (actual !== expected) {
2149
+ mismatches.push(`${prop}: expected "${expected}" but got "${actual}"`);
2150
+ }
2151
+ }
2152
+ const pass = mismatches.length === 0;
2153
+ return {
2154
+ pass,
2155
+ message: () => pass ? "Expected element not to have the specified styles, but it does" : `Style mismatches: ${mismatches.join("; ")}`
2156
+ };
2157
+ }
1881
2158
  }
1882
2159
  };
1883
- const subscriber = run;
1884
- subscriber.depsHead = null;
1885
- subscriber.depsTail = null;
1886
- subscriber._epoch = 0;
1887
- subscriber._structDirty = false;
1888
- subscriber._hasComputedDep = false;
1889
- subscriber._runEpoch = 0;
1890
- subscriber._runs = 0;
1891
- subscriber._reentrant = false;
1892
- subscriber._disposed = false;
1893
- subscriber._errorPhase = "binding";
1894
- subscriber._errorNode = ownerNode;
1895
- try {
1896
- run();
1897
- } catch (err) {
1898
- subscriber._disposed = true;
1899
- subscriber._errorNode = void 0;
1900
- cleanup(subscriber);
1901
- throw err;
1902
- }
1903
- return subscriber._dispose ?? (subscriber._dispose = () => {
1904
- subscriber._disposed = true;
1905
- subscriber._errorNode = void 0;
1906
- cleanup(subscriber);
1907
- });
1908
- }
1909
- function recordDependency(signal) {
1910
- if (!currentSubscriber) return;
1911
- const sub = currentSubscriber;
1912
- const sig = signal;
1913
- const epoch = sub._epoch ?? 0;
1914
- const active = sig.__activeNode ?? null;
1915
- if (active !== null && active.sub === sub) {
1916
- active.epoch = epoch;
1917
- active.depVersion = sig.__v ?? Number.NaN;
1918
- return;
1919
- }
1920
- if (sig._validate !== void 0) sub._hasComputedDep = true;
1921
- const node = allocNode(signal, sub, epoch);
1922
- node.depVersion = sig.__v ?? Number.NaN;
1923
- node.prevActive = active;
1924
- sig.__activeNode = node;
1925
- linkSub(sub, node);
1926
- linkSignal(sig, node);
1927
- sub._structDirty = true;
1928
- }
1929
- function cleanup(subscriber) {
1930
- const sub = subscriber;
1931
- let node = sub.depsHead ?? null;
1932
- sub.depsHead = null;
1933
- sub.depsTail = null;
1934
- while (node) {
1935
- const next = node.subNext;
1936
- unlinkSignal(node);
1937
- freeNode(node);
1938
- node = next;
1939
- }
1940
- }
1941
- var maxSubscriberRepeats = 1e3;
1942
- var maxDrainIterations = 1e6;
1943
- var drainEpoch = 0;
1944
- var quarantined = /* @__PURE__ */ new Set();
1945
- function setMaxSubscriberRepeats(n) {
1946
- const prev = maxSubscriberRepeats;
1947
- if (Number.isFinite(n) && n > 0) maxSubscriberRepeats = Math.floor(n);
1948
- return prev;
1949
- }
1950
- function setMaxDrainIterations(n) {
1951
- const prev = maxDrainIterations;
1952
- if (Number.isFinite(n) && n > 0) maxDrainIterations = Math.floor(n);
1953
- return prev;
1954
- }
1955
- function tickRepeat(sub) {
1956
- const s = sub;
1957
- if (s._runEpoch !== drainEpoch) {
1958
- s._runEpoch = drainEpoch;
1959
- s._runs = 1;
1960
- return false;
1961
- }
1962
- s._runs = (s._runs ?? 0) + 1;
1963
- return s._runs > maxSubscriberRepeats;
1964
- }
1965
- function cycleError(sub) {
1966
- const name = sub.__name ?? "<unnamed>";
1967
- reportError(
1968
- new Error(
1969
- `subscriber "${name}" fired more than ${maxSubscriberRepeats} times \u2014 likely a write-reads-self cycle between effects/signals. This subscriber is quarantined for the rest of this update; other pending work still runs.`
1970
- ),
1971
- { phase: "scheduler", name }
1972
- );
1973
- }
1974
- function absoluteDrainError() {
1975
- reportError(
1976
- new Error(
1977
- `Notification drain exceeded ${maxDrainIterations} iterations \u2014 absolute safety net tripped. Breaking to prevent infinite loop.`
1978
- ),
1979
- { phase: "scheduler" }
1980
- );
1981
2160
  }
1982
- function drainQueue() {
1983
- let i = 0;
1984
- while (i < pendingQueue.length) {
1985
- if (i >= maxDrainIterations) {
1986
- absoluteDrainError();
1987
- break;
1988
- }
1989
- const sub = pendingQueue[i++];
1990
- pendingSet.delete(sub);
1991
- if (quarantined.size > 0 && quarantined.has(sub)) continue;
1992
- if (sub._hasComputedDep === true && !depsChanged(sub)) continue;
1993
- if (tickRepeat(sub)) {
1994
- cycleError(sub);
1995
- quarantined.add(sub);
1996
- continue;
2161
+ function createCypressAdapter() {
2162
+ return {
2163
+ /** Mount a component for Cypress component testing */
2164
+ mount(component, options) {
2165
+ const ownsContainer = !options?.container;
2166
+ const container = options?.container || document.createElement("div");
2167
+ if (ownsContainer) {
2168
+ container.setAttribute("data-testenv", "cypress");
2169
+ document.body.appendChild(container);
2170
+ }
2171
+ const element = typeof component === "function" ? component() : component;
2172
+ container.appendChild(element);
2173
+ let unmounted = false;
2174
+ const unmount = () => {
2175
+ if (unmounted) return;
2176
+ unmounted = true;
2177
+ dispose(element);
2178
+ element.parentNode?.removeChild(element);
2179
+ if (ownsContainer) container.parentNode?.removeChild(container);
2180
+ };
2181
+ return { element, container, unmount };
2182
+ },
2183
+ /** Generate Cypress custom commands for SibuJS */
2184
+ commands: {
2185
+ /** Find by data-testid */
2186
+ getByTestId: (id) => `[data-testid="${escapeAttrValue(id)}"]`,
2187
+ /** Find by text */
2188
+ getByText: (text) => `:contains("${escapeAttrValue(text)}")`,
2189
+ /** Find by role */
2190
+ getByRole: (role) => `[role="${escapeAttrValue(role)}"]`
1997
2191
  }
1998
- safeInvoke(sub);
1999
- }
2192
+ };
2000
2193
  }
2001
- function drainNotificationQueue() {
2002
- if (notifyDepth > 0) return;
2003
- notifyDepth++;
2004
- drainEpoch++;
2005
- try {
2006
- drainQueue();
2007
- } finally {
2008
- notifyDepth--;
2009
- if (notifyDepth === 0) {
2010
- pendingQueue.length = 0;
2011
- pendingSet.clear();
2012
- if (quarantined.size > 0) quarantined.clear();
2194
+ function createPlaywrightAdapter() {
2195
+ return {
2196
+ /** Selectors for common SibuJS patterns */
2197
+ selectors: {
2198
+ byTestId: (id) => `[data-testid="${escapeAttrValue(id)}"]`,
2199
+ byRole: (role) => `[role="${escapeAttrValue(role)}"]`,
2200
+ byAriaLabel: (label) => `[aria-label="${escapeAttrValue(label)}"]`,
2201
+ byDataAttr: (attr, value) => value ? `[data-${attr}="${escapeAttrValue(value)}"]` : `[data-${attr}]`
2202
+ },
2203
+ /** Generate a page object for a SibuJS component */
2204
+ createPageObject(name, selectors) {
2205
+ const selectorMap = { ...selectors };
2206
+ return {
2207
+ name,
2208
+ getSelector(key) {
2209
+ const selector = selectorMap[key];
2210
+ if (!selector) {
2211
+ throw new Error(
2212
+ `Page object "${name}" has no selector for key "${key}". Available keys: ${Object.keys(selectorMap).join(", ")}`
2213
+ );
2214
+ }
2215
+ return selector;
2216
+ },
2217
+ allSelectors() {
2218
+ return { ...selectorMap };
2219
+ }
2220
+ };
2013
2221
  }
2014
- }
2222
+ };
2015
2223
  }
2016
- function propagateDirty(sub) {
2017
- sub();
2018
- const rootSig = sub._sig;
2019
- if (!rootSig) return;
2020
- const stack = propagateStack;
2021
- const baseLen = stack.length;
2022
- stack.push(rootSig);
2023
- while (stack.length > baseLen) {
2024
- const sig = stack.pop();
2025
- let node = sig.subsHead ?? null;
2026
- while (node) {
2027
- const s = node.sub;
2028
- if (s) {
2029
- if (s._c) {
2030
- const nSig = s._sig;
2031
- if (nSig) {
2032
- if (!nSig._d) {
2033
- nSig._d = true;
2034
- stack.push(nSig);
2224
+ function createUniversalAdapter() {
2225
+ let container = null;
2226
+ return {
2227
+ /** Setup test environment */
2228
+ setup() {
2229
+ container = document.createElement("div");
2230
+ container.setAttribute("data-testenv", "universal");
2231
+ document.body.appendChild(container);
2232
+ return container;
2233
+ },
2234
+ /** Teardown test environment */
2235
+ teardown() {
2236
+ if (container) {
2237
+ replaceChildrenSafely(container);
2238
+ if (container.parentNode) {
2239
+ container.parentNode.removeChild(container);
2240
+ }
2241
+ container = null;
2242
+ }
2243
+ },
2244
+ /** Render component */
2245
+ render(component) {
2246
+ if (!container) {
2247
+ container = document.createElement("div");
2248
+ container.setAttribute("data-testenv", "universal");
2249
+ document.body.appendChild(container);
2250
+ }
2251
+ const element = typeof component === "function" ? component() : component;
2252
+ container.appendChild(element);
2253
+ return { element, container };
2254
+ },
2255
+ /** Query helpers */
2256
+ queries: {
2257
+ byTestId(container2, id) {
2258
+ return queryByAttribute(container2, "data-testid", id);
2259
+ },
2260
+ byRole(container2, role) {
2261
+ return queryByAttribute(container2, "role", role);
2262
+ },
2263
+ byText(container2, text) {
2264
+ const walker = document.createTreeWalker(container2, NodeFilter.SHOW_TEXT);
2265
+ while (walker.nextNode()) {
2266
+ if (walker.currentNode.textContent?.includes(text)) {
2267
+ return walker.currentNode.parentElement;
2268
+ }
2269
+ }
2270
+ return null;
2271
+ },
2272
+ byLabelText(container2, label) {
2273
+ const labels = container2.querySelectorAll("label");
2274
+ for (const labelEl of Array.from(labels)) {
2275
+ if (labelEl.textContent?.includes(label)) {
2276
+ const forAttr = labelEl.getAttribute("for");
2277
+ if (forAttr) {
2278
+ return queryByAttribute(container2, "id", forAttr);
2035
2279
  }
2036
- } else {
2037
- s();
2280
+ const nested = labelEl.querySelector("input, select, textarea");
2281
+ if (nested) return nested;
2038
2282
  }
2039
- } else if (!pendingSet.has(s)) {
2040
- pendingSet.add(s);
2041
- pendingQueue.push(s);
2042
2283
  }
2284
+ return queryByAttribute(container2, "aria-label", label);
2285
+ },
2286
+ allByRole(container2, role) {
2287
+ return queryAllByAttribute(container2, "role", role);
2043
2288
  }
2044
- node = node.sigNext;
2045
- }
2046
- }
2047
- }
2048
- function queueSignalNotification(signal) {
2049
- const sig = signal;
2050
- let node = sig.subsHead ?? null;
2051
- while (node) {
2052
- const s = node.sub;
2053
- if (s) {
2054
- if (s._c) {
2055
- propagateDirty(s);
2056
- } else if (!pendingSet.has(s)) {
2057
- pendingSet.add(s);
2058
- pendingQueue.push(s);
2059
- }
2060
- }
2061
- node = node.sigNext;
2062
- }
2063
- }
2064
- function notifySubscribers(signal) {
2065
- const sig = signal;
2066
- const head = sig.subsHead;
2067
- if (!head) return;
2068
- if (notifyDepth > 0) {
2069
- let node = head;
2070
- while (node) {
2071
- const s = node.sub;
2072
- if (s) {
2073
- if (s._c) {
2074
- propagateDirty(s);
2075
- } else if (!pendingSet.has(s)) {
2076
- pendingSet.add(s);
2077
- pendingQueue.push(s);
2289
+ },
2290
+ /** Assertion helpers */
2291
+ assert: {
2292
+ textContent(el, expected) {
2293
+ const actual = el.textContent || "";
2294
+ if (!actual.includes(expected)) {
2295
+ throw new Error(`Expected text content to include "${expected}", but got "${actual}"`);
2078
2296
  }
2079
- }
2080
- node = node.sigNext;
2081
- }
2082
- return;
2083
- }
2084
- notifyDepth++;
2085
- drainEpoch++;
2086
- try {
2087
- let node = head;
2088
- while (node) {
2089
- const s = node.sub;
2090
- if (s) {
2091
- if (s._c) {
2092
- propagateDirty(s);
2093
- } else if (!pendingSet.has(s)) {
2094
- pendingSet.add(s);
2095
- pendingQueue.push(s);
2297
+ },
2298
+ attribute(el, attr, value) {
2299
+ if (!el.hasAttribute(attr)) {
2300
+ throw new Error(`Expected element to have attribute "${attr}", but it does not`);
2301
+ }
2302
+ if (value !== void 0) {
2303
+ const actual = el.getAttribute(attr);
2304
+ if (actual !== value) {
2305
+ throw new Error(`Expected attribute "${attr}" to be "${value}", but got "${actual}"`);
2306
+ }
2307
+ }
2308
+ },
2309
+ visible(el) {
2310
+ const htmlEl = el;
2311
+ const reasons = [];
2312
+ if (el.hasAttribute("hidden")) reasons.push('has "hidden" attribute');
2313
+ if (htmlEl.style?.display === "none") reasons.push("has display:none");
2314
+ if (htmlEl.style?.visibility === "hidden") reasons.push("has visibility:hidden");
2315
+ if (reasons.length > 0) {
2316
+ throw new Error(`Expected element to be visible, but it ${reasons.join(" and ")}`);
2317
+ }
2318
+ },
2319
+ disabled(el) {
2320
+ const isDisabled = el.disabled === true || el.hasAttribute("disabled") || el.getAttribute("aria-disabled") === "true";
2321
+ if (!isDisabled) {
2322
+ throw new Error("Expected element to be disabled, but it is not");
2323
+ }
2324
+ },
2325
+ className(el, name) {
2326
+ if (!el.classList.contains(name)) {
2327
+ throw new Error(`Expected element to have class "${name}", but it has "${el.className}"`);
2096
2328
  }
2097
2329
  }
2098
- node = node.sigNext;
2099
- }
2100
- drainQueue();
2101
- } finally {
2102
- notifyDepth--;
2103
- if (notifyDepth === 0) {
2104
- pendingQueue.length = 0;
2105
- pendingSet.clear();
2106
- if (quarantined.size > 0) quarantined.clear();
2107
2330
  }
2108
- }
2109
- }
2110
- function getSubscriberCount(signal) {
2111
- return signal.__sc ?? 0;
2331
+ };
2112
2332
  }
2113
- function getSubscriberDeps(subscriber) {
2114
- const sub = subscriber;
2115
- const out = [];
2116
- let node = sub.depsHead ?? null;
2117
- while (node) {
2118
- if (node.sig) out.push(node.sig);
2119
- node = node.subNext;
2120
- }
2121
- return out;
2333
+
2334
+ // src/testing/serializeDom.ts
2335
+ function escapeSnapshotText(value) {
2336
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
2122
2337
  }
2123
- function forEachSubscriber(signal, visit) {
2124
- let node = signal.subsHead ?? null;
2125
- while (node) {
2126
- const s = node.sub;
2127
- if (s) visit(s);
2128
- node = node.sigNext;
2129
- }
2338
+ function escapeSnapshotAttribute(value) {
2339
+ return escapeSnapshotText(value).replace(/"/g, "&quot;");
2130
2340
  }
2131
-
2132
- // src/reactivity/track.ts
2133
- var _runtimeVersion = true ? "4.4.0" : "dev";
2134
- var REGISTRY_KEY = /* @__PURE__ */ Symbol.for("sibujs.reactive.v1");
2135
- function resolveReactiveApi() {
2136
- const g = globalThis;
2137
- const existing = g[REGISTRY_KEY];
2138
- if (existing) {
2139
- if (DEV && !existing.__dupWarned) {
2140
- existing.__dupWarned = true;
2141
- devWarn(
2142
- `Multiple instances of the reactive runtime detected on this page (active: ${existing.version}, duplicate: ${_runtimeVersion}). Reactivity is NOT broken and no bundler configuration is required: every copy routes through the first one via a global registry, so signal writes still reach subscribers registered by any copy. This is only a SIZE issue \u2014 the duplicate copy's bytes are downloaded and parsed for nothing. If you want them back, de-duplicate sibujs in your bundler (Vite: resolve.dedupe: ['sibujs']). Mismatched VERSIONS above are worth fixing regardless, since only the first one loaded is used.`
2143
- );
2144
- }
2145
- return existing;
2146
- }
2147
- const local = {
2148
- suspendTracking,
2149
- resumeTracking,
2150
- isTrackingSuspended,
2151
- untracked,
2152
- retrack,
2153
- track,
2154
- reactiveBinding,
2155
- recordDependency,
2156
- cleanup,
2157
- setMaxSubscriberRepeats,
2158
- setMaxDrainIterations,
2159
- drainNotificationQueue,
2160
- queueSignalNotification,
2161
- notifySubscribers,
2162
- getSubscriberCount,
2163
- getSubscriberDeps,
2164
- forEachSubscriber,
2165
- version: _runtimeVersion
2166
- };
2167
- g[REGISTRY_KEY] = local;
2168
- return local;
2341
+ function escapeSnapshotComment(value) {
2342
+ return escapeSnapshotText(value).replace(/-/g, "&#45;");
2169
2343
  }
2170
- var API = resolveReactiveApi();
2171
- var suspendTracking2 = API.suspendTracking;
2172
- var resumeTracking2 = API.resumeTracking;
2173
- var isTrackingSuspended2 = API.isTrackingSuspended;
2174
- var untracked2 = API.untracked;
2175
- var retrack2 = API.retrack;
2176
- var track2 = API.track;
2177
- var reactiveBinding2 = API.reactiveBinding;
2178
- var recordDependency2 = API.recordDependency;
2179
- var cleanup2 = API.cleanup;
2180
- var setMaxSubscriberRepeats2 = API.setMaxSubscriberRepeats;
2181
- var setMaxDrainIterations2 = API.setMaxDrainIterations;
2182
- var drainNotificationQueue2 = API.drainNotificationQueue;
2183
- var queueSignalNotification2 = API.queueSignalNotification;
2184
- var notifySubscribers2 = API.notifySubscribers;
2185
- var getSubscriberCount2 = API.getSubscriberCount;
2186
- var getSubscriberDeps2 = API.getSubscriberDeps;
2187
- var forEachSubscriber2 = API.forEachSubscriber;
2188
-
2189
- // src/core/ssr-context.ts
2190
- var SSR_KEY = /* @__PURE__ */ Symbol.for("sibujs.ssr.v1");
2191
- function detectSSRShared() {
2192
- let detected = null;
2193
- try {
2194
- if (typeof process !== "undefined" && process.versions && process.versions.node) {
2195
- let mod = null;
2196
- const getBuiltin = process.getBuiltinModule;
2197
- if (typeof getBuiltin === "function") {
2198
- mod = getBuiltin("node:async_hooks");
2199
- } else if (typeof require === "function") {
2200
- mod = require("async_hooks");
2201
- }
2202
- if (mod) detected = new mod.AsyncLocalStorage();
2203
- }
2204
- } catch {
2205
- detected = null;
2344
+ function serializeDom(el, indent = 0, options = {}) {
2345
+ const pad = " ".repeat(indent);
2346
+ const tag = el.tagName.toLowerCase();
2347
+ const attrs = Array.from(el.attributes).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0).map((a) => `${a.name}="${escapeSnapshotAttribute(a.value)}"`).join(" ");
2348
+ const open = attrs ? `${pad}<${tag} ${attrs}>` : `${pad}<${tag}>`;
2349
+ const children = Array.from(el.childNodes);
2350
+ if (children.length === 0) {
2351
+ return `${open}</${tag}>`;
2206
2352
  }
2207
- return {
2208
- als: detected,
2209
- fallbackStore: { ssr: false, suspenseIdCounter: 0, locale: void 0 },
2210
- fallbackDepth: 0
2211
- };
2212
- }
2213
- var _shared = globalThis[SSR_KEY] ?? (globalThis[SSR_KEY] = detectSSRShared());
2214
- var als = _shared.als;
2215
- var fallbackStore = _shared.fallbackStore;
2216
- _shared.fallbackDepth ?? (_shared.fallbackDepth = 0);
2217
- function getSSRStore() {
2218
- if (als) {
2219
- const s = als.getStore();
2220
- if (s) return s;
2353
+ if (children.length === 1 && children[0].nodeType === 3) {
2354
+ return `${open}${escapeSnapshotText(children[0].textContent?.trim() || "")}</${tag}>`;
2221
2355
  }
2222
- return fallbackStore;
2223
- }
2224
- function isSSR() {
2225
- return getSSRStore().ssr;
2356
+ const childPad = " ".repeat(indent + 1);
2357
+ const childStr = children.map((child) => {
2358
+ if (child.nodeType === 3) {
2359
+ const text = child.textContent?.trim();
2360
+ return text ? `${childPad}${escapeSnapshotText(text)}` : "";
2361
+ }
2362
+ if (child.nodeType === 1) {
2363
+ return serializeDom(child, indent + 1, options);
2364
+ }
2365
+ if (child.nodeType === 8 && options.comments) {
2366
+ return `${childPad}<!-- ${escapeSnapshotComment(child.textContent?.trim() || "")} -->`;
2367
+ }
2368
+ return "";
2369
+ }).filter(Boolean).join("\n");
2370
+ return `${open}
2371
+ ${childStr}
2372
+ ${pad}</${tag}>`;
2226
2373
  }
2227
2374
 
2228
- // src/core/signals/effect.ts
2229
- var _g = globalThis;
2230
- var MAX_RERUNS = 100;
2231
- function flushUserCleanups(ctx) {
2232
- const list = ctx.userCleanups;
2233
- if (list.length === 0) return;
2234
- ctx.userCleanups = [];
2235
- for (let i = list.length - 1; i >= 0; i--) {
2375
+ // src/testing/e2e.ts
2376
+ function createHttpMock(routes = [], options = {}) {
2377
+ const originalFetch = globalThis.fetch;
2378
+ const hadOriginalFetch = Object.hasOwn(globalThis, "fetch");
2379
+ const requestLog = [];
2380
+ const mockRoutes = [...routes];
2381
+ function matchesStringRoute(route, url) {
2382
+ if (url === route) return true;
2383
+ let target;
2236
2384
  try {
2237
- list[i]();
2238
- } catch (err) {
2239
- reportError(err, { phase: "cleanup" });
2385
+ target = new URL(url, "http://localhost");
2386
+ } catch {
2387
+ return false;
2388
+ }
2389
+ if (/^[a-z][a-z\d+.-]*:/i.test(route)) {
2390
+ return `${target.origin}${target.pathname}${target.search}` === route || target.href === route;
2240
2391
  }
2392
+ const hashIndex = route.indexOf("#");
2393
+ const bare = hashIndex === -1 ? route : route.slice(0, hashIndex);
2394
+ return bare.includes("?") ? `${target.pathname}${target.search}` === bare : target.pathname === bare;
2241
2395
  }
2242
- }
2243
- function drainReruns(ctx) {
2244
- let reruns = 1;
2245
- do {
2246
- ctx.rerunPending = false;
2247
- if (ctx.userCleanups.length > 0) flushUserCleanups(ctx);
2248
- retrack2(ctx.bodyFn, ctx.subscriber);
2249
- } while (ctx.rerunPending && ++reruns <= MAX_RERUNS);
2250
- if (ctx.rerunPending) {
2251
- ctx.rerunPending = false;
2252
- reportError(
2253
- new Error(
2254
- `effect re-requested itself ${MAX_RERUNS}+ times \u2014 likely a write-reads-self cycle. Breaking to prevent infinite loop.`
2255
- ),
2256
- { phase: "effect" }
2257
- );
2396
+ function matchRoute(url, method) {
2397
+ return mockRoutes.find((route) => {
2398
+ const methodMatch = !route.method || route.method.toUpperCase() === method.toUpperCase();
2399
+ if (!methodMatch) return false;
2400
+ if (typeof route.url === "string") return matchesStringRoute(route.url, url);
2401
+ route.url.lastIndex = 0;
2402
+ return route.url.test(url);
2403
+ });
2258
2404
  }
2259
- }
2260
- function disposeEffect(ctx) {
2261
- if (ctx.disposed) return;
2262
- ctx.disposed = true;
2263
- const h = _g.__SIBU_DEVTOOLS_GLOBAL_HOOK__;
2264
- if (h) {
2405
+ const abortError = (signal) => signal.reason !== void 0 ? signal.reason : new DOMException("The operation was aborted.", "AbortError");
2406
+ const isFormEncoded = (type2) => /^application\/x-www-form-urlencoded\b/i.test(type2);
2407
+ const isTextLike = (type2) => /^text\/|[/+](?:json|xml|javascript)\b/i.test(type2);
2408
+ const decodeText = (text, type2) => {
2409
+ if (isFormEncoded(type2)) return new URLSearchParams(text);
2265
2410
  try {
2266
- h.emit("effect:destroy", { effectFn: ctx.fn });
2411
+ return JSON.parse(text);
2267
2412
  } catch {
2413
+ return text;
2268
2414
  }
2269
- }
2270
- try {
2271
- if (ctx.userCleanups.length > 0) flushUserCleanups(ctx);
2272
- } catch (err) {
2273
- if (typeof console !== "undefined") {
2274
- console.warn("[SibuJS effect] onCleanup threw during dispose:", err);
2415
+ };
2416
+ const httpBase = () => {
2417
+ const href = globalThis.location?.href;
2418
+ if (href) {
2419
+ try {
2420
+ if (/^https?:$/.test(new URL(href).protocol)) return href;
2421
+ } catch {
2422
+ }
2275
2423
  }
2276
- }
2277
- try {
2278
- cleanup2(ctx.subscriber);
2279
- } catch (err) {
2280
- if (typeof console !== "undefined") {
2281
- console.warn("[SibuJS effect] dispose threw:", err);
2424
+ return "http://localhost";
2425
+ };
2426
+ const STRUCTURED_CONTENT_TYPE = {
2427
+ FormData: () => "multipart/form-data",
2428
+ URLSearchParams: () => "application/x-www-form-urlencoded;charset=UTF-8",
2429
+ Blob: (raw) => raw.type || null,
2430
+ File: (raw) => raw.type || null
2431
+ };
2432
+ const AUTO_TEXT_TYPE = /^text\/plain;charset=utf-8$/i;
2433
+ const structuredTag = (raw) => {
2434
+ if (raw === null || typeof raw !== "object") return null;
2435
+ const tag = Object.prototype.toString.call(raw).slice(8, -1);
2436
+ return Object.hasOwn(STRUCTURED_CONTENT_TYPE, tag) ? tag : null;
2437
+ };
2438
+ const unusableBody = async (raw, tag) => {
2439
+ let probe;
2440
+ try {
2441
+ probe = new Request("http://localhost/", { method: "POST", body: raw });
2442
+ } catch {
2443
+ return true;
2282
2444
  }
2283
- }
2284
- }
2285
- function effect(effectFn, options) {
2286
- devAssert(typeof effectFn === "function", "effect: argument must be a function.");
2287
- if (isSSR()) return () => {
2445
+ if (!AUTO_TEXT_TYPE.test(probe.headers.get("content-type") ?? "")) return false;
2446
+ return tag === "URLSearchParams" || await probe.text() === String(raw);
2288
2447
  };
2289
- const ctx = {
2290
- fn: effectFn,
2291
- onError: options?.onError,
2292
- userCleanups: [],
2293
- running: false,
2294
- rerunPending: false,
2295
- disposed: false,
2296
- onCleanup: null,
2297
- subscriber: null,
2298
- bodyFn: null
2448
+ const toEffectiveRequest = (input, request, init, method, rawBody, omitBody) => {
2449
+ const overrides = {};
2450
+ const headers = init?.headers;
2451
+ if (method !== void 0) overrides.method = method;
2452
+ if (headers !== void 0) overrides.headers = headers;
2453
+ if (rawBody != null && !omitBody) {
2454
+ overrides.body = rawBody;
2455
+ if (typeof ReadableStream !== "undefined" && rawBody instanceof ReadableStream) overrides.duplex = "half";
2456
+ }
2457
+ if (request) return new Request(request.clone(), overrides);
2458
+ const raw = input instanceof URL ? input.href : input;
2459
+ const href = new URL(raw, httpBase()).href;
2460
+ return new Request(href, overrides);
2299
2461
  };
2300
- ctx.onCleanup = (fn) => {
2301
- ctx.userCleanups.push(fn);
2462
+ const decodeBody = async (effective) => {
2463
+ if (effective.body === null) return void 0;
2464
+ const type2 = effective.headers.get("content-type") ?? "";
2465
+ if (/^multipart\/form-data\b/i.test(type2)) return effective.formData();
2466
+ if (isTextLike(type2) || isFormEncoded(type2)) return decodeText(await effective.text(), type2);
2467
+ return effective.blob();
2302
2468
  };
2303
- const onErrorCaptured = ctx.onError;
2304
- ctx.bodyFn = onErrorCaptured ? () => {
2469
+ const mockFetch = async (input, init) => {
2470
+ const request = typeof Request !== "undefined" && input instanceof Request ? input : void 0;
2471
+ const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
2472
+ const initSignal = init?.signal;
2473
+ const signal = initSignal === null ? void 0 : initSignal ?? request?.signal;
2474
+ if (signal?.aborted) throw abortError(signal);
2475
+ const rawBody = init?.body;
2476
+ const initMethod = init?.method;
2477
+ const tag = structuredTag(rawBody);
2478
+ const passThrough = tag !== null && await unusableBody(rawBody, tag);
2479
+ if (passThrough) {
2480
+ const intended = (initMethod ?? request?.method ?? "GET").toUpperCase();
2481
+ if (intended === "GET" || intended === "HEAD") {
2482
+ throw new TypeError(`Request with ${intended} method cannot have body.`);
2483
+ }
2484
+ }
2485
+ const effective = toEffectiveRequest(input, request, init, initMethod, rawBody, passThrough);
2486
+ const method = effective.method.toUpperCase();
2487
+ let headers = effective.headers;
2488
+ let body;
2489
+ if (passThrough && tag) {
2490
+ body = rawBody;
2491
+ if (!headers.has("content-type")) {
2492
+ const type2 = STRUCTURED_CONTENT_TYPE[tag](rawBody);
2493
+ if (type2) {
2494
+ headers = new Headers(headers);
2495
+ headers.set("content-type", type2);
2496
+ }
2497
+ }
2498
+ } else {
2499
+ body = await decodeBody(effective);
2500
+ }
2501
+ if (signal?.aborted) throw abortError(signal);
2502
+ requestLog.push({ url, method, body, timestamp: Date.now() });
2503
+ const route = matchRoute(url, method);
2504
+ if (!route) {
2505
+ return new Response(JSON.stringify({ error: "Not mocked" }), { status: 404 });
2506
+ }
2507
+ const abortable = (work) => {
2508
+ if (!signal) return work;
2509
+ if (signal.aborted) return Promise.reject(abortError(signal));
2510
+ return new Promise((resolve, reject) => {
2511
+ const onAbort = () => reject(abortError(signal));
2512
+ signal.addEventListener("abort", onAbort, { once: true });
2513
+ work.then(
2514
+ (value) => {
2515
+ signal.removeEventListener("abort", onAbort);
2516
+ resolve(value);
2517
+ },
2518
+ (err) => {
2519
+ signal.removeEventListener("abort", onAbort);
2520
+ reject(err);
2521
+ }
2522
+ );
2523
+ });
2524
+ };
2525
+ let mockResponse;
2305
2526
  try {
2306
- ctx.fn(ctx.onCleanup);
2527
+ if (typeof route.response === "function") {
2528
+ const handler = route.response;
2529
+ mockResponse = await abortable(Promise.resolve().then(() => handler({ url, method, body, headers })));
2530
+ } else {
2531
+ mockResponse = route.response;
2532
+ }
2307
2533
  } catch (err) {
2308
- onErrorCaptured(err);
2534
+ if (signal?.aborted) throw abortError(signal);
2535
+ return new Response(JSON.stringify({ error: String(err) }), { status: 500 });
2536
+ }
2537
+ if (mockResponse.delay) {
2538
+ let timer;
2539
+ try {
2540
+ await abortable(
2541
+ new Promise((r) => {
2542
+ timer = setTimeout(r, mockResponse.delay);
2543
+ })
2544
+ );
2545
+ } finally {
2546
+ clearTimeout(timer);
2547
+ }
2548
+ }
2549
+ return new Response(typeof mockResponse.body === "string" ? mockResponse.body : JSON.stringify(mockResponse.body), {
2550
+ status: mockResponse.status || 200,
2551
+ statusText: mockResponse.statusText || "OK",
2552
+ headers: mockResponse.headers
2553
+ });
2554
+ };
2555
+ const restore = () => {
2556
+ if (hadOriginalFetch) {
2557
+ globalThis.fetch = originalFetch;
2558
+ } else {
2559
+ delete globalThis.fetch;
2560
+ }
2561
+ };
2562
+ const api = {
2563
+ /** Install the mock (replace global fetch) */
2564
+ install() {
2565
+ globalThis.fetch = mockFetch;
2566
+ },
2567
+ /** Restore original fetch */
2568
+ restore,
2569
+ /** Add a mock route */
2570
+ addRoute(route) {
2571
+ mockRoutes.push(route);
2572
+ },
2573
+ /** Remove all mock routes */
2574
+ clearRoutes() {
2575
+ mockRoutes.length = 0;
2576
+ },
2577
+ /** Get the request log */
2578
+ getRequests() {
2579
+ return [...requestLog];
2580
+ },
2581
+ /** Clear the request log */
2582
+ clearLog() {
2583
+ requestLog.length = 0;
2584
+ },
2585
+ /** Assert that a URL was called */
2586
+ assertCalled(url, method = "GET") {
2587
+ const found = requestLog.some((r) => r.url.includes(url) && r.method.toUpperCase() === method.toUpperCase());
2588
+ if (!found) throw new Error(`Expected ${method} ${url} to have been called`);
2589
+ },
2590
+ /** Assert that a URL was NOT called */
2591
+ assertNotCalled(url, method = "GET") {
2592
+ const found = requestLog.some((r) => r.url.includes(url) && r.method.toUpperCase() === method.toUpperCase());
2593
+ if (found) throw new Error(`Expected ${method} ${url} to NOT have been called`);
2594
+ },
2595
+ /** Get number of times a URL was called */
2596
+ callCount(url, method = "GET") {
2597
+ return requestLog.filter((r) => r.url.includes(url) && r.method.toUpperCase() === method.toUpperCase()).length;
2598
+ }
2599
+ };
2600
+ if (typeof options.afterEach === "function") {
2601
+ options.afterEach(() => api.restore());
2602
+ }
2603
+ return api;
2604
+ }
2605
+ var MIN_INTERVAL_MS = 1;
2606
+ function normalizeIntervalDelay(delay) {
2607
+ return Number.isFinite(delay) && delay >= MIN_INTERVAL_MS ? delay : MIN_INTERVAL_MS;
2608
+ }
2609
+ function createTimerMock(options = {}) {
2610
+ const g = globalThis;
2611
+ const snapshot = (key) => ({
2612
+ had: Object.hasOwn(globalThis, key),
2613
+ value: g[key]
2614
+ });
2615
+ const saved = {
2616
+ setTimeout: snapshot("setTimeout"),
2617
+ setInterval: snapshot("setInterval"),
2618
+ clearTimeout: snapshot("clearTimeout"),
2619
+ clearInterval: snapshot("clearInterval"),
2620
+ requestAnimationFrame: snapshot("requestAnimationFrame"),
2621
+ cancelAnimationFrame: snapshot("cancelAnimationFrame")
2622
+ };
2623
+ let currentTime = 0;
2624
+ let nextId = 1;
2625
+ const timers = [];
2626
+ const api = {
2627
+ install() {
2628
+ currentTime = 0;
2629
+ globalThis.setTimeout = (cb, delay = 0) => {
2630
+ const id = nextId++;
2631
+ timers.push({ id, callback: cb, time: currentTime + delay });
2632
+ return id;
2633
+ };
2634
+ globalThis.setInterval = (cb, interval) => {
2635
+ const id = nextId++;
2636
+ const period = normalizeIntervalDelay(interval);
2637
+ timers.push({ id, callback: cb, time: currentTime + period, interval: period });
2638
+ return id;
2639
+ };
2640
+ globalThis.clearTimeout = (id) => {
2641
+ const idx = timers.findIndex((t) => t.id === id);
2642
+ if (idx !== -1) timers.splice(idx, 1);
2643
+ };
2644
+ globalThis.clearInterval = (id) => {
2645
+ const idx = timers.findIndex((t) => t.id === id);
2646
+ if (idx !== -1) timers.splice(idx, 1);
2647
+ };
2648
+ globalThis.requestAnimationFrame = (cb) => {
2649
+ const id = nextId++;
2650
+ timers.push({ id, callback: () => cb(currentTime), time: currentTime + 16 });
2651
+ return id;
2652
+ };
2653
+ globalThis.cancelAnimationFrame = (id) => {
2654
+ const idx = timers.findIndex((t) => t.id === id);
2655
+ if (idx !== -1) timers.splice(idx, 1);
2656
+ };
2657
+ },
2658
+ restore() {
2659
+ for (const [key, snap] of Object.entries(saved)) {
2660
+ if (snap.had) {
2661
+ g[key] = snap.value;
2662
+ } else {
2663
+ delete g[key];
2664
+ }
2665
+ }
2666
+ timers.length = 0;
2667
+ },
2668
+ /** Advance time by a given number of ms, running any timers that fire */
2669
+ advance(ms) {
2670
+ const targetTime = currentTime + ms;
2671
+ while (true) {
2672
+ timers.sort((a, b) => a.time - b.time);
2673
+ const next = timers.find((t) => t.time <= targetTime);
2674
+ if (!next) break;
2675
+ currentTime = next.time;
2676
+ const idx = timers.indexOf(next);
2677
+ if (next.interval !== void 0) {
2678
+ next.time += next.interval;
2679
+ } else {
2680
+ timers.splice(idx, 1);
2681
+ }
2682
+ next.callback();
2683
+ }
2684
+ currentTime = targetTime;
2685
+ },
2686
+ /** Run all pending timers immediately */
2687
+ flush() {
2688
+ const maxIterations = 1e3;
2689
+ let i = 0;
2690
+ while (timers.length > 0 && i++ < maxIterations) {
2691
+ timers.sort((a, b) => a.time - b.time);
2692
+ const next = timers[0];
2693
+ currentTime = next.time;
2694
+ if (next.interval !== void 0) {
2695
+ next.time += next.interval;
2696
+ } else {
2697
+ timers.shift();
2698
+ }
2699
+ next.callback();
2700
+ }
2701
+ if (timers.length > 0 && i > maxIterations) {
2702
+ reportError(
2703
+ new Error(
2704
+ `[createTimerMock] flush() stopped after ${maxIterations} timer runs with ${timers.length} still pending \u2014 likely a recurring interval.`
2705
+ ),
2706
+ { phase: "scheduler", name: "createTimerMock.flush" }
2707
+ );
2708
+ }
2709
+ },
2710
+ /** Get current fake time */
2711
+ now() {
2712
+ return currentTime;
2713
+ },
2714
+ /** Get number of pending timers */
2715
+ pendingCount() {
2716
+ return timers.length;
2309
2717
  }
2310
- } : () => {
2311
- ctx.fn(ctx.onCleanup);
2312
2718
  };
2313
- const sub = (() => {
2314
- if (ctx.disposed) return;
2315
- if (ctx.running) {
2316
- ctx.rerunPending = true;
2317
- return;
2318
- }
2319
- ctx.running = true;
2320
- try {
2321
- ctx.rerunPending = false;
2322
- if (ctx.userCleanups.length > 0) flushUserCleanups(ctx);
2323
- retrack2(ctx.bodyFn, sub);
2324
- if (ctx.rerunPending) drainReruns(ctx);
2325
- } finally {
2326
- ctx.running = false;
2327
- ctx.rerunPending = false;
2328
- }
2329
- });
2330
- sub.depsHead = null;
2331
- sub.depsTail = null;
2332
- sub._epoch = 0;
2333
- sub._structDirty = false;
2334
- sub._hasComputedDep = false;
2335
- sub._errorPhase = "effect";
2336
- sub._runEpoch = 0;
2337
- sub._runs = 0;
2338
- ctx.subscriber = sub;
2339
- ctx.running = true;
2340
- try {
2341
- retrack2(ctx.bodyFn, ctx.subscriber);
2342
- if (ctx.rerunPending) drainReruns(ctx);
2343
- } finally {
2344
- ctx.running = false;
2345
- ctx.rerunPending = false;
2719
+ if (typeof options.afterEach === "function") {
2720
+ options.afterEach(() => api.restore());
2346
2721
  }
2347
- const hook = _g.__SIBU_DEVTOOLS_GLOBAL_HOOK__;
2348
- if (hook) hook.emit("effect:create", { effectFn });
2349
- return () => disposeEffect(ctx);
2350
- }
2351
-
2352
- // src/testing/queries.ts
2353
- function queryByText(container, text) {
2354
- const walk = (node) => {
2355
- if (node.childNodes.length === 1 && node.childNodes[0].nodeType === 3) {
2356
- if (node.textContent?.includes(text)) return node;
2357
- }
2358
- for (const child of Array.from(node.children)) {
2359
- const found = walk(child);
2360
- if (found) return found;
2361
- }
2362
- return null;
2363
- };
2364
- return walk(container);
2365
- }
2366
- function queryByTestId(container, testId) {
2367
- return container.querySelector(`[data-testid="${testId}"]`);
2368
- }
2369
- function queryByRole(container, role) {
2370
- return container.querySelector(`[role="${role}"]`);
2722
+ return api;
2371
2723
  }
2372
- function cssEscape(value) {
2373
- const g = globalThis;
2374
- if (g.CSS && typeof g.CSS.escape === "function") return g.CSS.escape(value);
2375
- return value.replace(/[^\w-]/g, (m) => `\\${m.charCodeAt(0).toString(16)} `);
2724
+ function createDOMSnapshot(element) {
2725
+ return serializeDom(element);
2376
2726
  }
2377
- function queryByLabel(container, labelText) {
2378
- const labels = Array.from(container.querySelectorAll("label"));
2379
- for (const label of labels) {
2380
- if (label.textContent?.trim() === labelText) {
2381
- const forId = label.getAttribute("for");
2382
- if (forId) {
2383
- const target = container.querySelector(`#${cssEscape(forId)}`);
2384
- if (target) return target;
2385
- }
2386
- const child = label.querySelector("input, select, textarea, button");
2387
- if (child) return child;
2388
- }
2727
+ function assertDOMEquals(actual, expected) {
2728
+ const actualSnapshot = createDOMSnapshot(actual);
2729
+ const expectedSnapshot = createDOMSnapshot(expected);
2730
+ if (actualSnapshot !== expectedSnapshot) {
2731
+ throw new Error(`DOM mismatch:
2732
+
2733
+ Actual:
2734
+ ${actualSnapshot}
2735
+
2736
+ Expected:
2737
+ ${expectedSnapshot}`);
2389
2738
  }
2390
- return container.querySelector(`[aria-label="${labelText}"]`);
2391
- }
2392
- async function pollUntil(fn, timeout, interval, errorIfTimeout) {
2393
- const start = Date.now();
2394
- return new Promise((resolve, reject) => {
2395
- const check = () => {
2396
- const result = fn();
2397
- if (result !== null) {
2398
- resolve(result);
2399
- return;
2400
- }
2401
- if (Date.now() - start >= timeout) {
2402
- reject(new Error(errorIfTimeout));
2403
- return;
2404
- }
2405
- setTimeout(check, interval);
2406
- };
2407
- check();
2408
- });
2409
- }
2410
- function findByText(container, text, options = {}) {
2411
- return pollUntil(
2412
- () => queryByText(container, text),
2413
- options.timeout ?? 1e3,
2414
- options.interval ?? 50,
2415
- `findByText: no element with text "${text}" after ${options.timeout ?? 1e3}ms`
2416
- );
2417
- }
2418
- function findByTestId(container, testId, options = {}) {
2419
- return pollUntil(
2420
- () => queryByTestId(container, testId),
2421
- options.timeout ?? 1e3,
2422
- options.interval ?? 50,
2423
- `findByTestId: no element with data-testid="${testId}" after ${options.timeout ?? 1e3}ms`
2424
- );
2425
- }
2426
- function findByRole(container, role, options = {}) {
2427
- return pollUntil(
2428
- () => queryByRole(container, role),
2429
- options.timeout ?? 1e3,
2430
- options.interval ?? 50,
2431
- `findByRole: no element with role="${role}" after ${options.timeout ?? 1e3}ms`
2432
- );
2433
2739
  }
2434
- function waitForSignal(getter, predicate, options = {}) {
2435
- const timeoutMs = options.timeout ?? 1e3;
2436
- return new Promise((resolve, reject) => {
2437
- let resolved = false;
2438
- let timer;
2439
- const finish = (fn) => {
2440
- if (resolved) return;
2441
- resolved = true;
2442
- if (timer !== void 0) clearTimeout(timer);
2443
- fn();
2444
- };
2445
- timer = setTimeout(() => {
2446
- finish(() => {
2447
- teardown();
2448
- reject(new Error(`waitForSignal: predicate did not match within ${timeoutMs}ms`));
2449
- });
2450
- }, timeoutMs);
2451
- const teardown = effect(() => {
2452
- if (resolved) return;
2453
- const value = getter();
2454
- if (predicate(value)) {
2455
- finish(() => {
2456
- queueMicrotask(() => teardown());
2457
- resolve(value);
2458
- });
2740
+ function testComponent(component, options = {}) {
2741
+ const container = options.container || document.createElement("div");
2742
+ if (!options.container) document.body.appendChild(container);
2743
+ const element = typeof component === "function" ? component() : component;
2744
+ container.appendChild(element);
2745
+ return {
2746
+ element,
2747
+ container,
2748
+ getByTestId(id) {
2749
+ return queryByAttribute(container, "data-testid", id);
2750
+ },
2751
+ getAllByTestId(id) {
2752
+ return queryAllByAttribute(container, "data-testid", id);
2753
+ },
2754
+ getByText(text) {
2755
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
2756
+ while (walker.nextNode()) {
2757
+ if (walker.currentNode.textContent?.includes(text)) {
2758
+ return walker.currentNode.parentElement;
2759
+ }
2459
2760
  }
2460
- });
2461
- });
2462
- }
2463
- function type(element, text) {
2464
- for (const char of text) {
2465
- element.value += char;
2466
- element.dispatchEvent(new InputEvent("input", { bubbles: true, data: char }));
2467
- }
2468
- element.dispatchEvent(new Event("change", { bubbles: true }));
2761
+ return null;
2762
+ },
2763
+ click(el) {
2764
+ el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
2765
+ },
2766
+ type(el, value) {
2767
+ el.value = value;
2768
+ el.dispatchEvent(new Event("input", { bubbles: true }));
2769
+ el.dispatchEvent(new Event("change", { bubbles: true }));
2770
+ },
2771
+ async waitForUpdate() {
2772
+ await new Promise((r) => setTimeout(r, 0));
2773
+ },
2774
+ destroy() {
2775
+ replaceChildrenSafely(container);
2776
+ if (container.parentNode) container.parentNode.removeChild(container);
2777
+ }
2778
+ };
2469
2779
  }
2470
2780
 
2471
2781
  // src/testing/snapshot.ts
2472
- function serializeElement2(el, indent) {
2473
- const pad = " ".repeat(indent);
2474
- const tag = el.tagName.toLowerCase();
2475
- const attrs = Array.from(el.attributes).sort((a, b) => a.name.localeCompare(b.name)).map((a) => `${a.name}="${a.value}"`).join(" ");
2476
- const open = attrs ? `${pad}<${tag} ${attrs}>` : `${pad}<${tag}>`;
2477
- const children = Array.from(el.childNodes);
2478
- if (children.length === 0) {
2479
- return `${open}</${tag}>`;
2480
- }
2481
- if (children.length === 1 && children[0].nodeType === 3) {
2482
- const text = children[0].textContent?.trim() || "";
2483
- return `${open}${text}</${tag}>`;
2484
- }
2485
- const childStr = children.map((child) => {
2486
- if (child.nodeType === 3) {
2487
- const text = child.textContent?.trim();
2488
- return text ? `${" ".repeat(indent + 1)}${text}` : "";
2489
- }
2490
- if (child.nodeType === 1) {
2491
- return serializeElement2(child, indent + 1);
2492
- }
2493
- if (child.nodeType === 8) {
2494
- return `${" ".repeat(indent + 1)}<!-- ${child.textContent?.trim() || ""} -->`;
2495
- }
2496
- return "";
2497
- }).filter(Boolean).join("\n");
2498
- return `${open}
2499
- ${childStr}
2500
- ${pad}</${tag}>`;
2782
+ function serializeElement(el, indent) {
2783
+ return serializeDom(el, indent, { comments: true });
2501
2784
  }
2502
2785
  function simpleDiff(a, b) {
2503
2786
  const aLines = a.split("\n");
@@ -2559,7 +2842,11 @@ function snapshotComponent(component) {
2559
2842
  const container = document.createElement("div");
2560
2843
  const element = component();
2561
2844
  container.appendChild(element);
2562
- return serializeElement2(element, 0);
2845
+ try {
2846
+ return serializeElement(element, 0);
2847
+ } finally {
2848
+ replaceChildrenSafely(container);
2849
+ }
2563
2850
  }
2564
2851
  function matchSnapshot(store, name, component, options) {
2565
2852
  const snapshot = snapshotComponent(component);
@@ -2615,31 +2902,57 @@ function walkElements(root) {
2615
2902
  return elements;
2616
2903
  }
2617
2904
  function serializeStructure(el, indent) {
2618
- const pad = " ".repeat(indent);
2619
- const tag = el.tagName.toLowerCase();
2620
- const attrs = Array.from(el.attributes).sort((a, b) => a.name.localeCompare(b.name)).map((a) => `${a.name}="${a.value}"`).join(" ");
2621
- const open = attrs ? `${pad}<${tag} ${attrs}>` : `${pad}<${tag}>`;
2622
- const children = Array.from(el.childNodes);
2623
- if (children.length === 0) {
2624
- return `${open}</${tag}>`;
2625
- }
2626
- if (children.length === 1 && children[0].nodeType === 3) {
2627
- const text = children[0].textContent?.trim() || "";
2628
- return `${open}${text}</${tag}>`;
2629
- }
2630
- const childStr = children.map((child) => {
2631
- if (child.nodeType === 3) {
2632
- const text = child.textContent?.trim();
2633
- return text ? `${" ".repeat(indent + 1)}${text}` : "";
2634
- }
2635
- if (child.nodeType === 1) {
2636
- return serializeStructure(child, indent + 1);
2905
+ return serializeDom(el, indent);
2906
+ }
2907
+ var APPEARANCE_PROPERTIES = [
2908
+ "display",
2909
+ "visibility",
2910
+ "opacity",
2911
+ "position",
2912
+ "color",
2913
+ "background-color",
2914
+ "background-image",
2915
+ "font-family",
2916
+ "font-size",
2917
+ "font-weight",
2918
+ "font-style",
2919
+ "line-height",
2920
+ "text-align",
2921
+ "text-decoration",
2922
+ "text-transform",
2923
+ "width",
2924
+ "height",
2925
+ "margin",
2926
+ "padding",
2927
+ "border",
2928
+ "border-radius",
2929
+ "box-shadow",
2930
+ "transform",
2931
+ "z-index",
2932
+ "overflow",
2933
+ "flex-direction",
2934
+ "justify-content",
2935
+ "align-items",
2936
+ "gap"
2937
+ ];
2938
+ function captureComputedStyles(elements) {
2939
+ const view = elements[0]?.ownerDocument?.defaultView;
2940
+ if (!view || typeof view.getComputedStyle !== "function") return [];
2941
+ const counters = {};
2942
+ return elements.map((el) => {
2943
+ const tag = el.tagName.toLowerCase();
2944
+ counters[tag] = (counters[tag] || 0) + 1;
2945
+ let declarations = "";
2946
+ try {
2947
+ const style = view.getComputedStyle(el);
2948
+ for (const property of APPEARANCE_PROPERTIES) {
2949
+ const value = style.getPropertyValue(property);
2950
+ if (value) declarations += `${property}:${value};`;
2951
+ }
2952
+ } catch {
2637
2953
  }
2638
- return "";
2639
- }).filter(Boolean).join("\n");
2640
- return `${open}
2641
- ${childStr}
2642
- ${pad}</${tag}>`;
2954
+ return `${tag}[${counters[tag]}]: ${declarations}`;
2955
+ });
2643
2956
  }
2644
2957
  function captureFingerprint(element) {
2645
2958
  const allElements = walkElements(element);
@@ -2677,14 +2990,16 @@ function captureFingerprint(element) {
2677
2990
  }
2678
2991
  }
2679
2992
  }
2680
- const composite = [
2993
+ const computedStyles = captureComputedStyles(allElements);
2994
+ const composite = JSON.stringify([
2681
2995
  structure,
2682
2996
  textContent,
2683
- JSON.stringify(elementCounts),
2684
- classNames.join(","),
2685
- inlineStyles.join(";"),
2686
- JSON.stringify(dataAttributes)
2687
- ].join("|");
2997
+ elementCounts,
2998
+ classNames,
2999
+ inlineStyles,
3000
+ dataAttributes,
3001
+ computedStyles
3002
+ ]);
2688
3003
  const hash = djb2Hash(composite);
2689
3004
  return {
2690
3005
  structure,
@@ -2693,6 +3008,7 @@ function captureFingerprint(element) {
2693
3008
  classNames,
2694
3009
  inlineStyles,
2695
3010
  dataAttributes,
3011
+ computedStyles,
2696
3012
  hash
2697
3013
  };
2698
3014
  }
@@ -2767,6 +3083,20 @@ function compareFingerprints(baseline, current) {
2767
3083
  description: `Data attributes changed: ${dataChanges.join("; ")}`
2768
3084
  });
2769
3085
  }
3086
+ const baselineComputed = baseline.computedStyles ?? [];
3087
+ const currentComputed = current.computedStyles ?? [];
3088
+ const computedChanges = [];
3089
+ for (let i = 0; i < Math.max(baselineComputed.length, currentComputed.length); i++) {
3090
+ if (baselineComputed[i] !== currentComputed[i]) {
3091
+ computedChanges.push(currentComputed[i] ?? baselineComputed[i]);
3092
+ }
3093
+ }
3094
+ if (computedChanges.length > 0) {
3095
+ changes.push({
3096
+ type: "computed",
3097
+ description: `Computed styles changed on ${computedChanges.length} element(s): ${truncate(computedChanges.join(" | "), 200)}`
3098
+ });
3099
+ }
2770
3100
  return {
2771
3101
  match: changes.length === 0,
2772
3102
  changes
@@ -2806,11 +3136,6 @@ function truncate(str, maxLen) {
2806
3136
  }
2807
3137
 
2808
3138
  // src/testing/index.ts
2809
- function escapeSelector(value) {
2810
- const g = globalThis;
2811
- if (g.CSS && typeof g.CSS.escape === "function") return g.CSS.escape(value);
2812
- return value.replace(/[^\w-]/g, (m) => `\\${m.charCodeAt(0).toString(16)} `);
2813
- }
2814
3139
  var _renderedContainers = globalSingleton(/* @__PURE__ */ Symbol.for("sibujs.testing.containers.v1"), () => /* @__PURE__ */ new Set());
2815
3140
  function unmountAll() {
2816
3141
  for (const container of _renderedContainers) {
@@ -2839,10 +3164,10 @@ function render(component) {
2839
3164
  return walk(container);
2840
3165
  }
2841
3166
  function getByTestId(testId) {
2842
- return container.querySelector(`[data-testid="${escapeSelector(testId)}"]`);
3167
+ return queryByAttribute(container, "data-testid", testId);
2843
3168
  }
2844
3169
  function getByRole(role) {
2845
- return container.querySelector(`[role="${role}"]`);
3170
+ return queryByAttribute(container, "role", role);
2846
3171
  }
2847
3172
  function queryAll(selector) {
2848
3173
  return Array.from(container.querySelectorAll(selector));
@@ -2946,6 +3271,7 @@ function mockStore(initialState) {
2946
3271
  createTimerMock,
2947
3272
  createUniversalAdapter,
2948
3273
  createVisualSuite,
3274
+ enableListenerTracking,
2949
3275
  findByRole,
2950
3276
  findByTestId,
2951
3277
  findByText,
@@ -2953,6 +3279,8 @@ function mockStore(initialState) {
2953
3279
  matchSnapshot,
2954
3280
  mockRouter,
2955
3281
  mockStore,
3282
+ queryAllByAttribute,
3283
+ queryByAttribute,
2956
3284
  queryByLabel,
2957
3285
  queryByRole,
2958
3286
  queryByTestId,