@warpgogol/werkstatt-shared 0.13.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/README.md +26 -16
  2. package/README.uk.md +24 -14
  3. package/package.json +27 -69
  4. package/src/checks/result-helpers.ts +21 -4
  5. package/src/checks/tests/remediation-auto-populate.test.ts +83 -0
  6. package/src/integration/funnel.ts +78 -1
  7. package/src/integration/index.ts +19 -2
  8. package/src/integration/lagebild-ingress.ts +169 -0
  9. package/src/integration/orchestration.ts +7 -13
  10. package/src/integration/port-barrel.ts +9 -28
  11. package/src/integration/tests/funnel-bridge.test.ts +7 -7
  12. package/src/integration/tests/lagebild-ingress.test.ts +118 -0
  13. package/src/integration/vitest.config.ts +7 -0
  14. package/src/observability/typed-refs.ts +1 -1
  15. package/src/ontology/analytics/matomo-fleet.registry.yaml +1 -1
  16. package/src/ontology/archetypes/index.json +12 -9
  17. package/src/ontology/archetypes/index.yaml +26 -10
  18. package/src/ontology/blueprints/website-local.yaml +11 -11
  19. package/src/ontology/blueprints/website-service.yaml +20 -20
  20. package/src/ontology/capabilities/lead.submit.yaml +2 -2
  21. package/src/ontology/enums.ts +2 -2
  22. package/src/ontology/external-surfaces/jsonld-types.yaml +14 -14
  23. package/src/ontology/external-surfaces/url-schema.yaml +1 -1
  24. package/src/ontology/index.ts +2 -0
  25. package/src/ontology/manifest.ts +19 -0
  26. package/src/ontology/schemas/index.ts +7 -2
  27. package/src/ontology/schemas/system/integrations.ts +1 -1
  28. package/src/ontology/schemas/system/manifest.ts +38 -1
  29. package/src/ontology/schemas/system/tests/growth.test.ts +22 -0
  30. package/src/ontology/schemas/system/tests/integrations.test.ts +12 -0
  31. package/src/ontology/schemas/system/tests/manifest.test.ts +22 -0
  32. package/src/ontology/schemas/system/tests/page-output.test.ts +13 -0
  33. package/src/ontology/schemas/system/tests/release.test.ts +22 -0
  34. package/src/ontology/schemas/system/tests/text.test.ts +18 -0
  35. package/src/ontology/schemas/system/tests/verification.test.ts +12 -0
  36. package/src/ontology/schemas/system.ts +7 -2
  37. package/src/ontology/schemas/tests/biome.test.ts +19 -0
  38. package/src/ontology/schemas/tests/capability.test.ts +22 -0
  39. package/src/ontology/schemas/tests/constellation.test.ts +22 -0
  40. package/src/ontology/schemas/tests/dns-records.test.ts +13 -0
  41. package/src/ontology/schemas/tests/manifest-resolver.test.ts +13 -0
  42. package/src/ontology/schemas/tests/page-entry.test.ts +22 -0
  43. package/src/ontology/schemas/tests/section-archetype.test.ts +12 -0
  44. package/src/ontology/schemas/tests/site-family.test.ts +18 -0
  45. package/src/ontology/schemas/tests/system.test.ts +20 -0
  46. package/src/ontology/shared-section-props/common.ts +11 -0
  47. package/src/ontology/shared-section-props/visual-header.ts +1 -1
  48. package/src/share/agent/api-catalog.ts +5 -0
  49. package/src/share/agent/ard-catalog.ts +14 -0
  50. package/src/share/agent/index.ts +2 -0
  51. package/src/share/agent/manifest.ts +25 -2
  52. package/src/share/agent/mcp-card.ts +4 -0
  53. package/src/share/agent/openapi.ts +78 -0
  54. package/src/share/agent/search.test.ts +188 -0
  55. package/src/share/agent/search.ts +164 -0
  56. package/src/share/agent/tests/api-catalog.test.ts +8 -0
  57. package/src/share/agent/tests/ard-catalog.test.ts +8 -0
  58. package/src/share/agent/tests/capability.test.ts +14 -0
  59. package/src/share/agent/tests/fleet-catalog.test.ts +15 -0
  60. package/src/share/agent/tests/knowledge.test.ts +20 -0
  61. package/src/share/agent/tests/manifest.test.ts +9 -0
  62. package/src/share/agent/tests/mcp-card.test.ts +8 -0
  63. package/src/share/agent/tests/omit-empty.test.ts +33 -0
  64. package/src/share/agent/tests/openapi.test.ts +8 -0
  65. package/src/share/agent/tests/proof.test.ts +15 -0
  66. package/src/share/env.d.ts +0 -4
  67. package/src/share/knowledge/tests/claim.test.ts +11 -0
  68. package/src/share/knowledge/tests/derivation.test.ts +30 -0
  69. package/src/share/knowledge/tests/freshness.test.ts +18 -0
  70. package/src/share/knowledge/tests/ledger.test.ts +35 -0
  71. package/src/share/knowledge/tests/plan.test.ts +19 -0
  72. package/src/share/knowledge/tests/source.test.ts +19 -0
  73. package/src/share/remediation/remediation-catalog.ts +196 -0
  74. package/src/share/schemas/effects.ts +16 -0
  75. package/src/share/schemas/media.ts +4 -0
  76. package/src/share/schemas/section-shell.ts +2 -1
  77. package/src/share/schemas/tests/claim-records.test.ts +52 -0
  78. package/src/share/schemas/tests/claims.test.ts +23 -0
  79. package/src/share/schemas/tests/effects.test.ts +116 -0
  80. package/src/share/schemas/tests/features.test.ts +61 -0
  81. package/src/share/schemas/tests/horizontal-align.test.ts +16 -0
  82. package/src/share/schemas/tests/icon-color.test.ts +16 -0
  83. package/src/share/schemas/tests/live-photo.test.ts +40 -0
  84. package/src/share/schemas/tests/material-credit.test.ts +51 -0
  85. package/src/share/schemas/tests/media.test.ts +26 -0
  86. package/src/share/schemas/tests/navigation.test.ts +58 -0
  87. package/src/share/schemas/tests/page-base.test.ts +19 -0
  88. package/src/share/schemas/tests/participant.test.ts +26 -0
  89. package/src/share/schemas/tests/person.test.ts +12 -0
  90. package/src/share/schemas/tests/print.test.ts +30 -0
  91. package/src/share/schemas/tests/section-background.test.ts +31 -0
  92. package/src/share/schemas/tests/section-body.test.ts +16 -0
  93. package/src/share/schemas/tests/section-cards.test.ts +17 -0
  94. package/src/share/schemas/tests/section-cta.test.ts +41 -0
  95. package/src/share/schemas/tests/section-header.test.ts +31 -0
  96. package/src/share/schemas/tests/section-image.test.ts +24 -0
  97. package/src/share/schemas/tests/section-motion.test.ts +29 -0
  98. package/src/share/schemas/tests/section-shell.test.ts +42 -0
  99. package/src/share/schemas/tests/section-stats.test.ts +17 -0
  100. package/src/share/schemas/tests/site-background.test.ts +33 -0
  101. package/src/share/schemas/tests/standard-list-item.test.ts +16 -0
  102. package/src/share/scripts/external-link-qr.ts +9 -1
  103. package/src/share/scripts/gsap-counter.ts +2 -0
  104. package/src/share/scripts/inline-number-animation.ts +2 -0
  105. package/src/share/scripts/live-photos.ts +2 -0
  106. package/src/share/scripts/tests/external-link-qr.test.ts +8 -0
  107. package/src/share/scripts/tests/external-links.test.ts +25 -0
  108. package/src/share/scripts/tests/gsap-counter.test.ts +8 -0
  109. package/src/share/scripts/tests/gsap-mountain-journey.test.ts +8 -0
  110. package/src/share/scripts/tests/gsap-parallax.test.ts +8 -0
  111. package/src/share/scripts/tests/gsap-reveal.test.ts +8 -0
  112. package/src/share/scripts/tests/gsap-stagger.test.ts +8 -0
  113. package/src/share/scripts/tests/inline-number-animation.test.ts +8 -0
  114. package/src/share/scripts/tests/lenis.test.ts +8 -0
  115. package/src/share/scripts/tests/live-photos.test.ts +8 -0
  116. package/src/share/scripts/tests/lordicon.test.ts +8 -0
  117. package/src/share/scripts/tests/orchestrator.test.ts +8 -0
  118. package/src/share/scripts/tests/scheduler.test.ts +23 -0
  119. package/src/share/scripts/video-player.test.ts +210 -0
  120. package/src/share/scripts/video-player.ts +73 -13
  121. package/src/share/semantic/extract.ts +25 -1
  122. package/src/share/semantic/jsonld/tests/article.test.ts +89 -0
  123. package/src/share/semantic/jsonld/tests/breadcrumb.test.ts +62 -0
  124. package/src/share/semantic/jsonld/tests/collection-list.test.ts +39 -0
  125. package/src/share/semantic/jsonld/tests/faq.test.ts +60 -0
  126. package/src/share/semantic/jsonld/tests/initiative.test.ts +56 -0
  127. package/src/share/semantic/jsonld/tests/organization.test.ts +123 -0
  128. package/src/share/semantic/jsonld/tests/person.test.ts +89 -0
  129. package/src/share/semantic/jsonld/tests/service.test.ts +65 -0
  130. package/src/share/semantic/jsonld/tests/shared.test.ts +41 -0
  131. package/src/share/semantic/jsonld/tests/video.test.ts +84 -0
  132. package/src/share/semantic/jsonld/tests/webpage.test.ts +93 -0
  133. package/src/share/semantic/jsonld/tests/website.test.ts +27 -0
  134. package/src/share/semantic/jsonld/types.ts +1 -0
  135. package/src/share/semantic/models.ts +1 -0
  136. package/src/share/semantic/tests/ai.test.ts +55 -0
  137. package/src/share/semantic/tests/block-extraction.test.ts +34 -0
  138. package/src/share/semantic/tests/breadcrumbs.test.ts +127 -0
  139. package/src/share/semantic/tests/build-page.test.ts +122 -0
  140. package/src/share/semantic/tests/business-projection.test.ts +188 -0
  141. package/src/share/semantic/tests/derived-prices-loader.test.ts +46 -0
  142. package/src/share/semantic/tests/extract.test.ts +126 -0
  143. package/src/share/semantic/tests/feed.test.ts +113 -0
  144. package/src/share/semantic/tests/helpers.ts +32 -0
  145. package/src/share/semantic/tests/ids.test.ts +118 -0
  146. package/src/share/semantic/tests/image-sitemap.test.ts +109 -0
  147. package/src/share/semantic/tests/jsonld.test.ts +61 -0
  148. package/src/share/semantic/tests/llms-policy.test.ts +60 -0
  149. package/src/share/semantic/tests/llms.test.ts +118 -0
  150. package/src/share/semantic/tests/markdown-hygiene.test.ts +72 -0
  151. package/src/share/semantic/tests/markdown-page.test.ts +97 -0
  152. package/src/share/semantic/tests/markdown-twin-provenance.test.ts +152 -0
  153. package/src/share/semantic/tests/organization-profile.test.ts +107 -0
  154. package/src/share/semantic/tests/output-projection.test.ts +77 -0
  155. package/src/share/semantic/tests/page-markdown.test.ts +98 -0
  156. package/src/share/semantic/tests/page-utils.test.ts +88 -0
  157. package/src/share/semantic/tests/price-marker-resolver.test.ts +88 -0
  158. package/src/share/semantic/tests/robots.test.ts +70 -0
  159. package/src/share/semantic/tests/split-sentences.test.ts +57 -0
  160. package/src/share/semantic/tests/update-stamp.test.ts +102 -0
  161. package/src/share/tests/manifest.test.ts +51 -0
  162. package/src/share/tests/openapi.test.ts +30 -0
  163. package/src/share/types/qrcode-browser.d.ts +18 -0
  164. package/src/share/utility-registry.yaml +16 -2
  165. package/src/integration/crm-buffer.ts +0 -491
  166. package/src/integration-adapter-supabase-crm/adapter.ts +0 -437
  167. package/src/integration-adapter-supabase-crm/client.ts +0 -605
  168. package/src/integration-adapter-supabase-crm/index.ts +0 -28
  169. package/src/integration-adapter-supabase-crm/pipedrive-sync-target.ts +0 -482
  170. package/src/integration-adapter-supabase-crm/supabase-rest.ts +0 -89
  171. package/src/integration-adapter-supabase-crm/tenant-registry.ts +0 -334
  172. package/src/integration-adapter-supabase-crm/tests/change-balance.pbt.test.ts +0 -83
  173. package/src/integration-adapter-supabase-crm/tests/funnel-persistence.test.ts +0 -409
  174. package/src/integration-adapter-supabase-crm/tests/lifecycle-sync.test.ts +0 -288
  175. package/src/integration-adapter-supabase-crm/tests/stage-map.test.ts +0 -80
  176. package/src/integration-adapter-supabase-crm/vitest.config.ts +0 -8
  177. package/src/integration-adapter-supabase-crm/worker.ts +0 -223
  178. package/src/ontology/archetypes/components/currency-selector.yaml +0 -19
@@ -0,0 +1,42 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ sectionDensitySchema,
4
+ sectionToneSchema,
5
+ sectionContainerVariantSchema,
6
+ sectionShellAnimatedSchema,
7
+ } from "../section-shell.ts";
8
+
9
+ describe("sectionDensitySchema", () => {
10
+ it("accepts valid densities", () => {
11
+ for (const d of ["compact", "normal", "spacious", "flush"]) {
12
+ expect(sectionDensitySchema.parse(d)).toBe(d);
13
+ }
14
+ });
15
+
16
+ it("rejects invalid density", () => {
17
+ expect(() => sectionDensitySchema.parse("loose")).toThrow();
18
+ });
19
+ });
20
+
21
+ describe("sectionToneSchema", () => {
22
+ it("accepts valid tones", () => {
23
+ for (const t of ["default", "warning", "success", "muted"]) {
24
+ expect(sectionToneSchema.parse(t)).toBe(t);
25
+ }
26
+ });
27
+ });
28
+
29
+ describe("sectionContainerVariantSchema", () => {
30
+ it("accepts valid variants", () => {
31
+ for (const v of ["default", "narrow", "full"]) {
32
+ expect(sectionContainerVariantSchema.parse(v)).toBe(v);
33
+ }
34
+ });
35
+ });
36
+
37
+ describe("sectionShellAnimatedSchema", () => {
38
+ it("accepts valid values", () => {
39
+ expect(sectionShellAnimatedSchema.parse("none")).toBe("none");
40
+ expect(sectionShellAnimatedSchema.parse("reveal")).toBe("reveal");
41
+ });
42
+ });
@@ -0,0 +1,17 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { statItemSchema } from "../section-stats.ts";
3
+
4
+ describe("statItemSchema", () => {
5
+ it("accepts a valid stat item", () => {
6
+ const result = statItemSchema.safeParse({
7
+ label: "Users",
8
+ value: "10K",
9
+ });
10
+ expect(result.success).toBe(true);
11
+ });
12
+
13
+ it("rejects empty object", () => {
14
+ const result = statItemSchema.safeParse({});
15
+ expect(result.success).toBe(false);
16
+ });
17
+ });
@@ -0,0 +1,33 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { siteBackgroundLayerSchema } from "../site-background.ts";
3
+
4
+ describe("siteBackgroundLayerSchema", () => {
5
+ it("accepts color kind", () => {
6
+ const result = siteBackgroundLayerSchema.safeParse({ kind: "color" });
7
+ expect(result.success).toBe(true);
8
+ });
9
+
10
+ it("accepts image kind with imageName", () => {
11
+ const result = siteBackgroundLayerSchema.safeParse({
12
+ kind: "image",
13
+ imageName: "bg",
14
+ });
15
+ expect(result.success).toBe(true);
16
+ });
17
+
18
+ it("accepts gradient kind with required fields", () => {
19
+ const result = siteBackgroundLayerSchema.safeParse({
20
+ kind: "gradient",
21
+ direction: "vertical",
22
+ stops: [
23
+ { at: 0, color: "#000" },
24
+ { at: 1, color: "#fff" },
25
+ ],
26
+ });
27
+ expect(result.success).toBe(true);
28
+ });
29
+
30
+ it("rejects invalid kind", () => {
31
+ expect(() => siteBackgroundLayerSchema.parse({ kind: "video" })).toThrow();
32
+ });
33
+ });
@@ -0,0 +1,16 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { standardListItemSchema } from "../standard-list-item.ts";
3
+
4
+ describe("standardListItemSchema", () => {
5
+ it("accepts a valid list item", () => {
6
+ const result = standardListItemSchema.safeParse({
7
+ text: "Test item",
8
+ });
9
+ expect(result.success).toBe(true);
10
+ });
11
+
12
+ it("rejects empty object", () => {
13
+ const result = standardListItemSchema.safeParse({});
14
+ expect(result.success).toBe(false);
15
+ });
16
+ });
@@ -85,7 +85,7 @@ export function initExternalLinkQr(options?: ExternalLinkQrOptions): void {
85
85
 
86
86
  async function generateQr(href: string): Promise<void> {
87
87
  if (!canvas) return;
88
- const { toCanvas } = await import("qrcode");
88
+ const { toCanvas } = await import("qrcode/lib/browser.js");
89
89
  await toCanvas(canvas, href, {
90
90
  width: 256,
91
91
  margin: 2,
@@ -189,8 +189,16 @@ export function initExternalLinkQr(options?: ExternalLinkQrOptions): void {
189
189
  canvas.addEventListener("click", () => {
190
190
  const link = openLinkBtn?.getAttribute("href");
191
191
  if (link) window.open(link, "_blank", "noopener,noreferrer");
192
+ closeModal();
193
+ });
194
+ }
195
+
196
+ if (openLinkBtn) {
197
+ openLinkBtn.addEventListener("click", () => {
198
+ closeModal();
192
199
  });
193
200
  }
194
201
 
195
202
  attachTriggers();
203
+ modal.setAttribute("data-external-link-qr-modal-initialized", "true");
196
204
  }
@@ -165,4 +165,6 @@ export async function initGsapCounter(options: GsapCounterOptions = {}): Promise
165
165
  }
166
166
  });
167
167
  });
168
+
169
+ document.documentElement.setAttribute("data-gsap-counter-initialized", "true");
168
170
  }
@@ -117,4 +117,6 @@ export async function initInlineNumberAnimation(
117
117
  },
118
118
  });
119
119
  });
120
+
121
+ document.documentElement.setAttribute("data-inline-number-initialized", "true");
120
122
  }
@@ -247,4 +247,6 @@ export async function initLivePhotos(options: LivePhotosOptions = {}): Promise<v
247
247
  }
248
248
  });
249
249
  }
250
+
251
+ document.documentElement.setAttribute("data-live-photos-initialized", "true");
250
252
  }
@@ -0,0 +1,8 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { initExternalLinkQr } from "../external-link-qr.ts";
3
+
4
+ describe("initExternalLinkQr", () => {
5
+ it("is a function", () => {
6
+ expect(typeof initExternalLinkQr).toBe("function");
7
+ });
8
+ });
@@ -0,0 +1,25 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { applyExternalLinkBehavior } from "../external-links.ts";
3
+
4
+ describe("applyExternalLinkBehavior", () => {
5
+ beforeEach(() => {
6
+ vi.stubGlobal("document", {
7
+ querySelectorAll: vi.fn(() => []),
8
+ });
9
+ vi.stubGlobal("window", {
10
+ location: { href: "https://example.com/", origin: "https://example.com" },
11
+ });
12
+ });
13
+
14
+ afterEach(() => {
15
+ vi.unstubAllGlobals();
16
+ });
17
+
18
+ it("is a function", () => {
19
+ expect(typeof applyExternalLinkBehavior).toBe("function");
20
+ });
21
+
22
+ it("does not throw when no anchors exist", () => {
23
+ expect(() => applyExternalLinkBehavior()).not.toThrow();
24
+ });
25
+ });
@@ -0,0 +1,8 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { initGsapCounter } from "../gsap-counter.ts";
3
+
4
+ describe("initGsapCounter", () => {
5
+ it("is an async function", () => {
6
+ expect(typeof initGsapCounter).toBe("function");
7
+ });
8
+ });
@@ -0,0 +1,8 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { initMountainJourneyAnimation } from "../gsap-mountain-journey.ts";
3
+
4
+ describe("initMountainJourneyAnimation", () => {
5
+ it("is an async function", () => {
6
+ expect(typeof initMountainJourneyAnimation).toBe("function");
7
+ });
8
+ });
@@ -0,0 +1,8 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { initGsapParallax } from "../gsap-parallax.ts";
3
+
4
+ describe("initGsapParallax", () => {
5
+ it("is an async function", () => {
6
+ expect(typeof initGsapParallax).toBe("function");
7
+ });
8
+ });
@@ -0,0 +1,8 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { initGsapReveal } from "../gsap-reveal.ts";
3
+
4
+ describe("initGsapReveal", () => {
5
+ it("is an async function", () => {
6
+ expect(typeof initGsapReveal).toBe("function");
7
+ });
8
+ });
@@ -0,0 +1,8 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { initGsapStagger } from "../gsap-stagger.ts";
3
+
4
+ describe("initGsapStagger", () => {
5
+ it("is an async function", () => {
6
+ expect(typeof initGsapStagger).toBe("function");
7
+ });
8
+ });
@@ -0,0 +1,8 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { initInlineNumberAnimation } from "../inline-number-animation.ts";
3
+
4
+ describe("initInlineNumberAnimation", () => {
5
+ it("is an async function", () => {
6
+ expect(typeof initInlineNumberAnimation).toBe("function");
7
+ });
8
+ });
@@ -0,0 +1,8 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { initLenis } from "../lenis.ts";
3
+
4
+ describe("initLenis", () => {
5
+ it("is an async function", () => {
6
+ expect(typeof initLenis).toBe("function");
7
+ });
8
+ });
@@ -0,0 +1,8 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { initLivePhotos } from "../live-photos.ts";
3
+
4
+ describe("initLivePhotos", () => {
5
+ it("is an async function", () => {
6
+ expect(typeof initLivePhotos).toBe("function");
7
+ });
8
+ });
@@ -0,0 +1,8 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { initLordIconOnDemand } from "../lordicon.ts";
3
+
4
+ describe("initLordIconOnDemand", () => {
5
+ it("is a function", () => {
6
+ expect(typeof initLordIconOnDemand).toBe("function");
7
+ });
8
+ });
@@ -0,0 +1,8 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { runStandardLayoutOrchestration } from "../orchestrator.ts";
3
+
4
+ describe("runStandardLayoutOrchestration", () => {
5
+ it("is an async function", () => {
6
+ expect(typeof runStandardLayoutOrchestration).toBe("function");
7
+ });
8
+ });
@@ -0,0 +1,23 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { scheduleTask } from "../scheduler.ts";
3
+
4
+ describe("scheduleTask", () => {
5
+ beforeEach(() => {
6
+ vi.stubGlobal("window", {
7
+ requestIdleCallback: vi.fn(),
8
+ setTimeout: vi.fn(),
9
+ });
10
+ });
11
+
12
+ afterEach(() => {
13
+ vi.unstubAllGlobals();
14
+ });
15
+
16
+ it("is a function", () => {
17
+ expect(typeof scheduleTask).toBe("function");
18
+ });
19
+
20
+ it("does not throw when scheduling a task", () => {
21
+ expect(() => scheduleTask(() => undefined)).not.toThrow();
22
+ });
23
+ });
@@ -0,0 +1,210 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>RFC-0950: tests for video-player runtime — conditional Plyr controls, viewport play/pause, HLS+autoplay timing.</purpose>
4
+ <keywords>RFC-0950, video-player, test</keywords>
5
+ </MODULE_CONTRACT>
6
+ <CHANGE_SUMMARY><item>RFC-0950: initial video-player tests.</item></CHANGE_SUMMARY>
7
+ */
8
+
9
+ import { test, expect, vi, beforeEach, afterEach } from "vitest";
10
+
11
+ // ── Mocks ───────────────────────────────────────────────────────────────────
12
+
13
+ vi.mock("plyr", () => ({
14
+ default: vi.fn(() => ({ destroy: vi.fn() })),
15
+ }));
16
+
17
+ vi.mock("hls.js/light", () => {
18
+ const hlsRef = { current: null as any };
19
+ const fn = vi.fn(function Hls() {
20
+ return hlsRef.current;
21
+ });
22
+ const Hls = Object.assign(fn, {
23
+ isSupported: vi.fn(() => true),
24
+ _hlsRef: hlsRef,
25
+ });
26
+ return { default: Hls };
27
+ });
28
+
29
+ // ── Helpers ─────────────────────────────────────────────────────────────────
30
+
31
+ function createMockVideo(attrs: Record<string, string | undefined> = {}): any {
32
+ const video: any = {
33
+ dataset: {},
34
+ canPlayType: vi.fn(() => ""),
35
+ play: vi.fn(() => Promise.resolve()),
36
+ pause: vi.fn(),
37
+ load: vi.fn(),
38
+ insertBefore: vi.fn(),
39
+ firstChild: null,
40
+ };
41
+ for (const [k, v] of Object.entries(attrs)) {
42
+ if (v !== undefined) video.dataset[k] = v;
43
+ }
44
+ return video;
45
+ }
46
+
47
+ let observerCallbacks: ((
48
+ entries: { isIntersecting: boolean; target: any }[],
49
+ obs: { unobserve: () => void },
50
+ ) => void)[] = [];
51
+ let observerOptions: { threshold?: number; rootMargin?: string }[] = [];
52
+
53
+ class MockIntersectionObserver {
54
+ callback: (
55
+ entries: { isIntersecting: boolean; target: any }[],
56
+ obs: { unobserve: () => void },
57
+ ) => void;
58
+ options: { threshold?: number; rootMargin?: string };
59
+ constructor(
60
+ cb: (
61
+ entries: { isIntersecting: boolean; target: any }[],
62
+ obs: { unobserve: () => void },
63
+ ) => void,
64
+ opts: { threshold?: number; rootMargin?: string },
65
+ ) {
66
+ this.callback = cb;
67
+ this.options = opts;
68
+ observerCallbacks.push(cb);
69
+ observerOptions.push(opts);
70
+ }
71
+ observe = vi.fn();
72
+ unobserve = vi.fn();
73
+ disconnect = vi.fn();
74
+ }
75
+
76
+ // ── Setup / teardown ────────────────────────────────────────────────────────
77
+
78
+ beforeEach(() => {
79
+ vi.clearAllMocks();
80
+ observerCallbacks = [];
81
+ observerOptions = [];
82
+ (globalThis as any).IntersectionObserver = MockIntersectionObserver;
83
+ (globalThis as any).window = globalThis;
84
+ (globalThis as any).document = {
85
+ querySelectorAll: vi.fn(() => []),
86
+ createElement: vi.fn((tag: string) => ({ tagName: tag.toUpperCase() })),
87
+ };
88
+ });
89
+
90
+ afterEach(() => {
91
+ delete (globalThis as any).IntersectionObserver;
92
+ delete (globalThis as any).window;
93
+ delete (globalThis as any).document;
94
+ vi.restoreAllMocks();
95
+ });
96
+
97
+ async function triggerUpgrade(video: any): Promise<void> {
98
+ const { initVideoPlayers } = await import("../scripts/video-player.ts");
99
+ vi.spyOn(globalThis.document as any, "querySelectorAll").mockReturnValue([video]);
100
+ await initVideoPlayers();
101
+
102
+ expect(observerCallbacks.length).toBeGreaterThanOrEqual(1);
103
+ observerCallbacks[0]!([{ isIntersecting: true, target: video }], { unobserve: vi.fn() });
104
+ await new Promise((r) => setTimeout(r, 100));
105
+ }
106
+
107
+ // ── Tests ───────────────────────────────────────────────────────────────────
108
+
109
+ test("initVideoPlayers: returns early when no [data-video-player] elements", async () => {
110
+ const querySelectorAll = vi.spyOn(globalThis.document as any, "querySelectorAll");
111
+ querySelectorAll.mockReturnValue([]);
112
+ const { initVideoPlayers } = await import("../scripts/video-player.ts");
113
+ await initVideoPlayers();
114
+ expect(observerCallbacks.length).toBe(0);
115
+ querySelectorAll.mockRestore();
116
+ });
117
+
118
+ test("upgrade: data-controls='false' configures Plyr with only ['mute']", async () => {
119
+ const video = createMockVideo({ controls: "false" });
120
+ await triggerUpgrade(video);
121
+
122
+ const Plyr = (await import("plyr")).default;
123
+ expect(Plyr).toHaveBeenCalledWith(video, expect.objectContaining({ controls: ["mute"] }));
124
+ });
125
+
126
+ test("upgrade: without data-controls uses full Plyr controls list", async () => {
127
+ const video = createMockVideo({});
128
+ await triggerUpgrade(video);
129
+
130
+ const Plyr = (await import("plyr")).default;
131
+ expect(Plyr).toHaveBeenCalledWith(
132
+ video,
133
+ expect.objectContaining({
134
+ controls: expect.arrayContaining([
135
+ "play-large",
136
+ "play",
137
+ "progress",
138
+ "current-time",
139
+ "mute",
140
+ "volume",
141
+ "captions",
142
+ "fullscreen",
143
+ ]),
144
+ }),
145
+ );
146
+ });
147
+
148
+ test("upgrade: data-autoplay='true' sets up viewport play/pause observer with threshold 0.5", async () => {
149
+ const video = createMockVideo({ autoplay: "true" });
150
+ await triggerUpgrade(video);
151
+
152
+ const playPauseOpts = observerOptions.find((o) => o.threshold === 0.5);
153
+ expect(
154
+ playPauseOpts,
155
+ "play/pause observer with threshold 0.5 must be created when data-autoplay is true",
156
+ ).toBeDefined();
157
+ });
158
+
159
+ test("upgrade: data-autoplay='true' with HLS waits for MEDIA_ATTACHED before play()", async () => {
160
+ const video = createMockVideo({ autoplay: "true", hls: "https://example.com/stream.m3u8" });
161
+
162
+ const hlsInstance = {
163
+ loadSource: vi.fn(),
164
+ attachMedia: vi.fn(),
165
+ destroy: vi.fn(),
166
+ on: vi.fn(),
167
+ };
168
+ const Hls = (await import("hls.js/light")).default as any;
169
+ Hls._hlsRef.current = hlsInstance;
170
+
171
+ await triggerUpgrade(video);
172
+
173
+ expect(Hls).toHaveBeenCalled();
174
+ expect(hlsInstance.attachMedia).toHaveBeenCalledWith(video);
175
+
176
+ const attachedCall = hlsInstance.on.mock.calls.find((c: any[]) => c[0] === "hls.MEDIA_ATTACHED");
177
+ expect(attachedCall, "Hls must register a MEDIA_ATTACHED listener before play()").toBeDefined();
178
+ attachedCall![1]();
179
+
180
+ await new Promise((r) => setTimeout(r, 20));
181
+ expect(video.play).toHaveBeenCalled();
182
+ });
183
+
184
+ test("upgrade: data-autoplay='true' without HLS calls play() directly", async () => {
185
+ const video = createMockVideo({ autoplay: "true" });
186
+ await triggerUpgrade(video);
187
+
188
+ expect(video.play).toHaveBeenCalled();
189
+ });
190
+
191
+ test("upgrade: skip already-upgraded videos (data-video-player-ready='true')", async () => {
192
+ const video = createMockVideo({ videoPlayerReady: "true" });
193
+ await triggerUpgrade(video);
194
+
195
+ const Plyr = (await import("plyr")).default;
196
+ expect(Plyr).not.toHaveBeenCalled();
197
+ });
198
+
199
+ test("upgrade: viewport observer pauses video when scrolled out", async () => {
200
+ const video = createMockVideo({ autoplay: "true" });
201
+ await triggerUpgrade(video);
202
+
203
+ const playPauseIdx = observerOptions.findIndex((o) => o.threshold === 0.5);
204
+ expect(playPauseIdx).toBeGreaterThan(-1);
205
+
206
+ observerCallbacks[playPauseIdx]!([{ isIntersecting: false, target: video }], {
207
+ unobserve: vi.fn(),
208
+ });
209
+ expect(video.pause).toHaveBeenCalled();
210
+ });
@@ -15,6 +15,7 @@
15
15
  </MODULE_CONTRACT>
16
16
  <CHANGE_SUMMARY>
17
17
  <item>RFC-0210: introduced the lazy feature-video player runtime.</item>
18
+ <item>RFC-0950: added viewport play/pause observer, conditional Plyr controls, and HLS+autoplay timing.</item>
18
19
  </CHANGE_SUMMARY>
19
20
  */
20
21
 
@@ -33,9 +34,11 @@ async function upgrade(video: HTMLVideoElement): Promise<void> {
33
34
  video.dataset.videoPlayerReady = "true";
34
35
 
35
36
  const hlsUrl = video.dataset.hls;
37
+ const autoplay = video.dataset.autoplay === "true";
38
+ const controlsDisabled = video.dataset.controls === "false";
36
39
  let mode: "file" | "stream" = "file";
37
40
  let impl: "native" | "hlsjs" | "progressive" = "progressive";
38
- let hlsInstance: { destroy: () => void } | null = null;
41
+ let hlsInstance: unknown = null;
39
42
 
40
43
  if (hlsUrl) {
41
44
  if (nativeHlsSupported(video)) {
@@ -70,24 +73,81 @@ async function upgrade(video: HTMLVideoElement): Promise<void> {
70
73
  // Brand the UI with Plyr (the native controls remain the fallback if this fails).
71
74
  try {
72
75
  const { default: Plyr } = await import("plyr");
73
- const player = new Plyr(video, {
74
- controls: [
75
- "play-large",
76
- "play",
77
- "progress",
78
- "current-time",
79
- "mute",
80
- "volume",
81
- "captions",
82
- "fullscreen",
83
- ],
84
- });
76
+ const plyrControls = controlsDisabled
77
+ ? ["mute"]
78
+ : [
79
+ "play-large",
80
+ "play",
81
+ "progress",
82
+ "current-time",
83
+ "mute",
84
+ "volume",
85
+ "captions",
86
+ "fullscreen",
87
+ ];
88
+ const player = new Plyr(video, { controls: plyrControls });
85
89
  // Keep hls.js bound across Plyr's internal media swaps.
86
90
  void player;
87
91
  void hlsInstance;
88
92
  } catch {
89
93
  /* Plyr unavailable — native controls already present */
90
94
  }
95
+
96
+ // RFC-0950: viewport play/pause for autoplay-enabled videos.
97
+ if (autoplay) {
98
+ await playWhenReady(
99
+ video,
100
+ hlsInstance as { on: (event: string, listener: () => void) => void } | null,
101
+ );
102
+ setupViewportObserver(video);
103
+ }
104
+ }
105
+
106
+ async function playWhenReady(
107
+ video: HTMLVideoElement,
108
+ hls: { on: (event: string, listener: () => void) => void } | null,
109
+ ): Promise<void> {
110
+ if (hls) {
111
+ // Wait for hls.js to attach media before calling play().
112
+ await new Promise<void>((resolve) => {
113
+ let resolved = false;
114
+ const timeout = setTimeout(() => {
115
+ if (!resolved) {
116
+ resolved = true;
117
+ resolve();
118
+ }
119
+ }, 200);
120
+ hls.on("hls.MEDIA_ATTACHED", () => {
121
+ if (!resolved) {
122
+ resolved = true;
123
+ clearTimeout(timeout);
124
+ resolve();
125
+ }
126
+ });
127
+ });
128
+ }
129
+ try {
130
+ await video.play();
131
+ } catch {
132
+ /* Autoplay may be blocked by browser policy — user can interact via controls. */
133
+ }
134
+ }
135
+
136
+ function setupViewportObserver(video: HTMLVideoElement): void {
137
+ if (!("IntersectionObserver" in window)) return;
138
+ const observer = new IntersectionObserver(
139
+ (entries) => {
140
+ for (const entry of entries) {
141
+ if (entry.isIntersecting) {
142
+ void video.play().catch(() => {});
143
+ } else {
144
+ video.pause();
145
+ }
146
+ }
147
+ },
148
+ { threshold: 0.5 },
149
+ );
150
+ observer.observe(video);
91
151
  }
92
152
 
93
153
  export async function initVideoPlayers(_options: VideoPlayersOptions = {}): Promise<void> {