@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.
- package/README.md +26 -16
- package/README.uk.md +24 -14
- package/package.json +27 -69
- package/src/checks/result-helpers.ts +21 -4
- package/src/checks/tests/remediation-auto-populate.test.ts +83 -0
- package/src/integration/funnel.ts +78 -1
- package/src/integration/index.ts +19 -2
- package/src/integration/lagebild-ingress.ts +169 -0
- package/src/integration/orchestration.ts +7 -13
- package/src/integration/port-barrel.ts +9 -28
- package/src/integration/tests/funnel-bridge.test.ts +7 -7
- package/src/integration/tests/lagebild-ingress.test.ts +118 -0
- package/src/integration/vitest.config.ts +7 -0
- package/src/observability/typed-refs.ts +1 -1
- package/src/ontology/analytics/matomo-fleet.registry.yaml +1 -1
- package/src/ontology/archetypes/index.json +12 -9
- package/src/ontology/archetypes/index.yaml +26 -10
- package/src/ontology/blueprints/website-local.yaml +11 -11
- package/src/ontology/blueprints/website-service.yaml +20 -20
- package/src/ontology/capabilities/lead.submit.yaml +2 -2
- package/src/ontology/enums.ts +2 -2
- package/src/ontology/external-surfaces/jsonld-types.yaml +14 -14
- package/src/ontology/external-surfaces/url-schema.yaml +1 -1
- package/src/ontology/index.ts +2 -0
- package/src/ontology/manifest.ts +19 -0
- package/src/ontology/schemas/index.ts +7 -2
- package/src/ontology/schemas/system/integrations.ts +1 -1
- package/src/ontology/schemas/system/manifest.ts +38 -1
- package/src/ontology/schemas/system/tests/growth.test.ts +22 -0
- package/src/ontology/schemas/system/tests/integrations.test.ts +12 -0
- package/src/ontology/schemas/system/tests/manifest.test.ts +22 -0
- package/src/ontology/schemas/system/tests/page-output.test.ts +13 -0
- package/src/ontology/schemas/system/tests/release.test.ts +22 -0
- package/src/ontology/schemas/system/tests/text.test.ts +18 -0
- package/src/ontology/schemas/system/tests/verification.test.ts +12 -0
- package/src/ontology/schemas/system.ts +7 -2
- package/src/ontology/schemas/tests/biome.test.ts +19 -0
- package/src/ontology/schemas/tests/capability.test.ts +22 -0
- package/src/ontology/schemas/tests/constellation.test.ts +22 -0
- package/src/ontology/schemas/tests/dns-records.test.ts +13 -0
- package/src/ontology/schemas/tests/manifest-resolver.test.ts +13 -0
- package/src/ontology/schemas/tests/page-entry.test.ts +22 -0
- package/src/ontology/schemas/tests/section-archetype.test.ts +12 -0
- package/src/ontology/schemas/tests/site-family.test.ts +18 -0
- package/src/ontology/schemas/tests/system.test.ts +20 -0
- package/src/ontology/shared-section-props/common.ts +11 -0
- package/src/ontology/shared-section-props/visual-header.ts +1 -1
- package/src/share/agent/api-catalog.ts +5 -0
- package/src/share/agent/ard-catalog.ts +14 -0
- package/src/share/agent/index.ts +2 -0
- package/src/share/agent/manifest.ts +25 -2
- package/src/share/agent/mcp-card.ts +4 -0
- package/src/share/agent/openapi.ts +78 -0
- package/src/share/agent/search.test.ts +188 -0
- package/src/share/agent/search.ts +164 -0
- package/src/share/agent/tests/api-catalog.test.ts +8 -0
- package/src/share/agent/tests/ard-catalog.test.ts +8 -0
- package/src/share/agent/tests/capability.test.ts +14 -0
- package/src/share/agent/tests/fleet-catalog.test.ts +15 -0
- package/src/share/agent/tests/knowledge.test.ts +20 -0
- package/src/share/agent/tests/manifest.test.ts +9 -0
- package/src/share/agent/tests/mcp-card.test.ts +8 -0
- package/src/share/agent/tests/omit-empty.test.ts +33 -0
- package/src/share/agent/tests/openapi.test.ts +8 -0
- package/src/share/agent/tests/proof.test.ts +15 -0
- package/src/share/env.d.ts +0 -4
- package/src/share/knowledge/tests/claim.test.ts +11 -0
- package/src/share/knowledge/tests/derivation.test.ts +30 -0
- package/src/share/knowledge/tests/freshness.test.ts +18 -0
- package/src/share/knowledge/tests/ledger.test.ts +35 -0
- package/src/share/knowledge/tests/plan.test.ts +19 -0
- package/src/share/knowledge/tests/source.test.ts +19 -0
- package/src/share/remediation/remediation-catalog.ts +196 -0
- package/src/share/schemas/effects.ts +16 -0
- package/src/share/schemas/media.ts +4 -0
- package/src/share/schemas/section-shell.ts +2 -1
- package/src/share/schemas/tests/claim-records.test.ts +52 -0
- package/src/share/schemas/tests/claims.test.ts +23 -0
- package/src/share/schemas/tests/effects.test.ts +116 -0
- package/src/share/schemas/tests/features.test.ts +61 -0
- package/src/share/schemas/tests/horizontal-align.test.ts +16 -0
- package/src/share/schemas/tests/icon-color.test.ts +16 -0
- package/src/share/schemas/tests/live-photo.test.ts +40 -0
- package/src/share/schemas/tests/material-credit.test.ts +51 -0
- package/src/share/schemas/tests/media.test.ts +26 -0
- package/src/share/schemas/tests/navigation.test.ts +58 -0
- package/src/share/schemas/tests/page-base.test.ts +19 -0
- package/src/share/schemas/tests/participant.test.ts +26 -0
- package/src/share/schemas/tests/person.test.ts +12 -0
- package/src/share/schemas/tests/print.test.ts +30 -0
- package/src/share/schemas/tests/section-background.test.ts +31 -0
- package/src/share/schemas/tests/section-body.test.ts +16 -0
- package/src/share/schemas/tests/section-cards.test.ts +17 -0
- package/src/share/schemas/tests/section-cta.test.ts +41 -0
- package/src/share/schemas/tests/section-header.test.ts +31 -0
- package/src/share/schemas/tests/section-image.test.ts +24 -0
- package/src/share/schemas/tests/section-motion.test.ts +29 -0
- package/src/share/schemas/tests/section-shell.test.ts +42 -0
- package/src/share/schemas/tests/section-stats.test.ts +17 -0
- package/src/share/schemas/tests/site-background.test.ts +33 -0
- package/src/share/schemas/tests/standard-list-item.test.ts +16 -0
- package/src/share/scripts/external-link-qr.ts +9 -1
- package/src/share/scripts/gsap-counter.ts +2 -0
- package/src/share/scripts/inline-number-animation.ts +2 -0
- package/src/share/scripts/live-photos.ts +2 -0
- package/src/share/scripts/tests/external-link-qr.test.ts +8 -0
- package/src/share/scripts/tests/external-links.test.ts +25 -0
- package/src/share/scripts/tests/gsap-counter.test.ts +8 -0
- package/src/share/scripts/tests/gsap-mountain-journey.test.ts +8 -0
- package/src/share/scripts/tests/gsap-parallax.test.ts +8 -0
- package/src/share/scripts/tests/gsap-reveal.test.ts +8 -0
- package/src/share/scripts/tests/gsap-stagger.test.ts +8 -0
- package/src/share/scripts/tests/inline-number-animation.test.ts +8 -0
- package/src/share/scripts/tests/lenis.test.ts +8 -0
- package/src/share/scripts/tests/live-photos.test.ts +8 -0
- package/src/share/scripts/tests/lordicon.test.ts +8 -0
- package/src/share/scripts/tests/orchestrator.test.ts +8 -0
- package/src/share/scripts/tests/scheduler.test.ts +23 -0
- package/src/share/scripts/video-player.test.ts +210 -0
- package/src/share/scripts/video-player.ts +73 -13
- package/src/share/semantic/extract.ts +25 -1
- package/src/share/semantic/jsonld/tests/article.test.ts +89 -0
- package/src/share/semantic/jsonld/tests/breadcrumb.test.ts +62 -0
- package/src/share/semantic/jsonld/tests/collection-list.test.ts +39 -0
- package/src/share/semantic/jsonld/tests/faq.test.ts +60 -0
- package/src/share/semantic/jsonld/tests/initiative.test.ts +56 -0
- package/src/share/semantic/jsonld/tests/organization.test.ts +123 -0
- package/src/share/semantic/jsonld/tests/person.test.ts +89 -0
- package/src/share/semantic/jsonld/tests/service.test.ts +65 -0
- package/src/share/semantic/jsonld/tests/shared.test.ts +41 -0
- package/src/share/semantic/jsonld/tests/video.test.ts +84 -0
- package/src/share/semantic/jsonld/tests/webpage.test.ts +93 -0
- package/src/share/semantic/jsonld/tests/website.test.ts +27 -0
- package/src/share/semantic/jsonld/types.ts +1 -0
- package/src/share/semantic/models.ts +1 -0
- package/src/share/semantic/tests/ai.test.ts +55 -0
- package/src/share/semantic/tests/block-extraction.test.ts +34 -0
- package/src/share/semantic/tests/breadcrumbs.test.ts +127 -0
- package/src/share/semantic/tests/build-page.test.ts +122 -0
- package/src/share/semantic/tests/business-projection.test.ts +188 -0
- package/src/share/semantic/tests/derived-prices-loader.test.ts +46 -0
- package/src/share/semantic/tests/extract.test.ts +126 -0
- package/src/share/semantic/tests/feed.test.ts +113 -0
- package/src/share/semantic/tests/helpers.ts +32 -0
- package/src/share/semantic/tests/ids.test.ts +118 -0
- package/src/share/semantic/tests/image-sitemap.test.ts +109 -0
- package/src/share/semantic/tests/jsonld.test.ts +61 -0
- package/src/share/semantic/tests/llms-policy.test.ts +60 -0
- package/src/share/semantic/tests/llms.test.ts +118 -0
- package/src/share/semantic/tests/markdown-hygiene.test.ts +72 -0
- package/src/share/semantic/tests/markdown-page.test.ts +97 -0
- package/src/share/semantic/tests/markdown-twin-provenance.test.ts +152 -0
- package/src/share/semantic/tests/organization-profile.test.ts +107 -0
- package/src/share/semantic/tests/output-projection.test.ts +77 -0
- package/src/share/semantic/tests/page-markdown.test.ts +98 -0
- package/src/share/semantic/tests/page-utils.test.ts +88 -0
- package/src/share/semantic/tests/price-marker-resolver.test.ts +88 -0
- package/src/share/semantic/tests/robots.test.ts +70 -0
- package/src/share/semantic/tests/split-sentences.test.ts +57 -0
- package/src/share/semantic/tests/update-stamp.test.ts +102 -0
- package/src/share/tests/manifest.test.ts +51 -0
- package/src/share/tests/openapi.test.ts +30 -0
- package/src/share/types/qrcode-browser.d.ts +18 -0
- package/src/share/utility-registry.yaml +16 -2
- package/src/integration/crm-buffer.ts +0 -491
- package/src/integration-adapter-supabase-crm/adapter.ts +0 -437
- package/src/integration-adapter-supabase-crm/client.ts +0 -605
- package/src/integration-adapter-supabase-crm/index.ts +0 -28
- package/src/integration-adapter-supabase-crm/pipedrive-sync-target.ts +0 -482
- package/src/integration-adapter-supabase-crm/supabase-rest.ts +0 -89
- package/src/integration-adapter-supabase-crm/tenant-registry.ts +0 -334
- package/src/integration-adapter-supabase-crm/tests/change-balance.pbt.test.ts +0 -83
- package/src/integration-adapter-supabase-crm/tests/funnel-persistence.test.ts +0 -409
- package/src/integration-adapter-supabase-crm/tests/lifecycle-sync.test.ts +0 -288
- package/src/integration-adapter-supabase-crm/tests/stage-map.test.ts +0 -80
- package/src/integration-adapter-supabase-crm/vitest.config.ts +0 -8
- package/src/integration-adapter-supabase-crm/worker.ts +0 -223
- 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
|
}
|
|
@@ -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 { 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 { 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 { 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:
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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> {
|