@warpgogol/werkstatt-shared 0.13.0 → 0.14.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 (63) hide show
  1. package/package.json +21 -69
  2. package/src/integration/funnel.ts +78 -1
  3. package/src/integration/index.ts +19 -2
  4. package/src/integration/lagebild-ingress.ts +169 -0
  5. package/src/integration/orchestration.ts +7 -13
  6. package/src/integration/port-barrel.ts +9 -28
  7. package/src/integration/tests/funnel-bridge.test.ts +7 -7
  8. package/src/integration/tests/lagebild-ingress.test.ts +118 -0
  9. package/src/observability/typed-refs.ts +1 -1
  10. package/src/ontology/analytics/matomo-fleet.registry.yaml +1 -1
  11. package/src/ontology/archetypes/index.json +12 -9
  12. package/src/ontology/archetypes/index.yaml +26 -10
  13. package/src/ontology/blueprints/website-local.yaml +11 -11
  14. package/src/ontology/blueprints/website-service.yaml +20 -20
  15. package/src/ontology/capabilities/lead.submit.yaml +2 -2
  16. package/src/ontology/enums.ts +2 -2
  17. package/src/ontology/external-surfaces/jsonld-types.yaml +14 -14
  18. package/src/ontology/external-surfaces/url-schema.yaml +1 -1
  19. package/src/ontology/index.ts +2 -0
  20. package/src/ontology/schemas/index.ts +7 -2
  21. package/src/ontology/schemas/system/integrations.ts +1 -1
  22. package/src/ontology/schemas/system/manifest.ts +38 -1
  23. package/src/ontology/schemas/system.ts +7 -2
  24. package/src/ontology/shared-section-props/common.ts +11 -0
  25. package/src/ontology/shared-section-props/visual-header.ts +1 -1
  26. package/src/share/agent/api-catalog.ts +5 -0
  27. package/src/share/agent/ard-catalog.ts +14 -0
  28. package/src/share/agent/index.ts +2 -0
  29. package/src/share/agent/manifest.ts +25 -2
  30. package/src/share/agent/mcp-card.ts +4 -0
  31. package/src/share/agent/openapi.ts +78 -0
  32. package/src/share/agent/search.test.ts +188 -0
  33. package/src/share/agent/search.ts +164 -0
  34. package/src/share/env.d.ts +0 -4
  35. package/src/share/schemas/effects.ts +16 -0
  36. package/src/share/schemas/media.ts +4 -0
  37. package/src/share/schemas/section-shell.ts +2 -1
  38. package/src/share/scripts/external-link-qr.ts +9 -1
  39. package/src/share/scripts/gsap-counter.ts +2 -0
  40. package/src/share/scripts/inline-number-animation.ts +2 -0
  41. package/src/share/scripts/live-photos.ts +2 -0
  42. package/src/share/scripts/video-player.test.ts +210 -0
  43. package/src/share/scripts/video-player.ts +73 -13
  44. package/src/share/semantic/extract.ts +25 -1
  45. package/src/share/semantic/tests/split-sentences.test.ts +57 -0
  46. package/src/share/tests/manifest.test.ts +51 -0
  47. package/src/share/tests/openapi.test.ts +30 -0
  48. package/src/share/types/qrcode-browser.d.ts +18 -0
  49. package/src/share/utility-registry.yaml +2 -2
  50. package/src/integration/crm-buffer.ts +0 -491
  51. package/src/integration-adapter-supabase-crm/adapter.ts +0 -437
  52. package/src/integration-adapter-supabase-crm/client.ts +0 -605
  53. package/src/integration-adapter-supabase-crm/index.ts +0 -28
  54. package/src/integration-adapter-supabase-crm/pipedrive-sync-target.ts +0 -482
  55. package/src/integration-adapter-supabase-crm/supabase-rest.ts +0 -89
  56. package/src/integration-adapter-supabase-crm/tenant-registry.ts +0 -334
  57. package/src/integration-adapter-supabase-crm/tests/change-balance.pbt.test.ts +0 -83
  58. package/src/integration-adapter-supabase-crm/tests/funnel-persistence.test.ts +0 -409
  59. package/src/integration-adapter-supabase-crm/tests/lifecycle-sync.test.ts +0 -288
  60. package/src/integration-adapter-supabase-crm/tests/stage-map.test.ts +0 -80
  61. package/src/integration-adapter-supabase-crm/vitest.config.ts +0 -8
  62. package/src/integration-adapter-supabase-crm/worker.ts +0 -223
  63. package/src/ontology/archetypes/components/currency-selector.yaml +0 -19
@@ -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,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> {
@@ -68,8 +68,32 @@ const SENTENCE_ABBREVIATIONS: Record<string, string[]> = {
68
68
  "u.",
69
69
  "vgl.",
70
70
  "bspw.",
71
+ "zzgl.",
72
+ "inkl.",
73
+ "exkl.",
74
+ "bzw.",
75
+ "ggf.",
76
+ "ggfls.",
77
+ "sog.",
78
+ "o.Ä.",
79
+ ],
80
+ uk: [
81
+ "т.д.",
82
+ "т.п.",
83
+ "п.",
84
+ "ст.",
85
+ "див.",
86
+ "пор.",
87
+ "напр.",
88
+ "ім.",
89
+ "о.",
90
+ "вкл.",
91
+ "тис.",
92
+ "млн.",
93
+ "млрд.",
94
+ "грн.",
95
+ "№",
71
96
  ],
72
- uk: ["т.д.", "т.п.", "п.", "ст.", "див.", "пор.", "напр.", "ім.", "о."],
73
97
  en: ["e.g.", "i.e.", "etc.", "vs.", "Mr.", "Mrs.", "Dr.", "Inc.", "Ltd."],
74
98
  };
75
99
 
@@ -94,4 +94,61 @@ describe("splitSentences", () => {
94
94
  expect(result[0]).toBe("Das Studio kann nicht wesentliche Prozesse (z. B. Werkzeuge) ändern.");
95
95
  expect(result[1]).toBe("Der wesentliche Leistungsumfang kann nicht einseitig geändert werden.");
96
96
  });
97
+
98
+ it("does not split on German business abbreviation zzgl.", () => {
99
+ const result = splitSentences(
100
+ "€ 49 / Monat (zzgl. USt.) — entspricht 10 Monaten. Zwei Monate gratis.",
101
+ "de",
102
+ );
103
+ expect(result).toHaveLength(2);
104
+ expect(result[0]).toBe("€ 49 / Monat (zzgl. USt.) — entspricht 10 Monaten.");
105
+ expect(result[1]).toBe("Zwei Monate gratis.");
106
+ });
107
+
108
+ it("does not split on German abbreviation inkl.", () => {
109
+ const result = splitSentences(
110
+ "Das Paket enthält Hosting (inkl. SSL-Zertifikat). Das ist ein guter Deal.",
111
+ "de",
112
+ );
113
+ expect(result).toHaveLength(2);
114
+ expect(result[0]).toBe("Das Paket enthält Hosting (inkl. SSL-Zertifikat).");
115
+ expect(result[1]).toBe("Das ist ein guter Deal.");
116
+ });
117
+
118
+ it("does not split on German abbreviation bzw.", () => {
119
+ const result = splitSentences(
120
+ "Rechnungen werden monatlich bzw. jährlich erstellt. Das ist fair.",
121
+ "de",
122
+ );
123
+ expect(result).toHaveLength(2);
124
+ expect(result[0]).toBe("Rechnungen werden monatlich bzw. jährlich erstellt.");
125
+ expect(result[1]).toBe("Das ist fair.");
126
+ });
127
+
128
+ it("does not split on German abbreviation ggf.", () => {
129
+ const result = splitSentences(
130
+ "Der Kunde kann ggf. weitere Module buchen. Das ist optional.",
131
+ "de",
132
+ );
133
+ expect(result).toHaveLength(2);
134
+ expect(result[0]).toBe("Der Kunde kann ggf. weitere Module buchen.");
135
+ expect(result[1]).toBe("Das ist optional.");
136
+ });
137
+
138
+ it("does not split on Ukrainian abbreviation тис.", () => {
139
+ const result = splitSentences(
140
+ "Вартість складає 5 тис. гривень на місяць. Це вигідна пропозиція.",
141
+ "uk",
142
+ );
143
+ expect(result).toHaveLength(2);
144
+ expect(result[0]).toBe("Вартість складає 5 тис. гривень на місяць.");
145
+ expect(result[1]).toBe("Це вигідна пропозиція.");
146
+ });
147
+
148
+ it("does not split on Ukrainian abbreviation грн.", () => {
149
+ const result = splitSentences("Ціна: 1000 грн. за місяць. Друге речення.", "uk");
150
+ expect(result).toHaveLength(2);
151
+ expect(result[0]).toBe("Ціна: 1000 грн. за місяць.");
152
+ expect(result[1]).toBe("Друге речення.");
153
+ });
97
154
  });
@@ -8,6 +8,7 @@
8
8
  </MODULE_MAP>
9
9
  <CHANGE_SUMMARY>
10
10
  <item>RFC-0286: initial builder tests.</item>
11
+ <item>RFC-0954: add search interface tests.</item>
11
12
  </CHANGE_SUMMARY>
12
13
  */
13
14
 
@@ -16,6 +17,7 @@ import {
16
17
  AGENT_SURFACE_VERSION,
17
18
  buildAgentSurfaceManifest,
18
19
  computeAgentManifestContentHash,
20
+ computeSignedContentHash,
19
21
  canonicalJson,
20
22
  } from "../agent/manifest.ts";
21
23
 
@@ -38,6 +40,7 @@ test("buildAgentSurfaceManifest: safe default — empty knowledge/actions, null
38
40
  expect(m.interfaces.openapi).toBe(null);
39
41
  expect(m.interfaces.mcp).toBe(null);
40
42
  expect(m.interfaces.twins).toBe(null);
43
+ expect(m.interfaces.search).toBe(null);
41
44
  expect(m.proof).toBe(null);
42
45
  expect(m.surfaceVersion).toBe(AGENT_SURFACE_VERSION);
43
46
  });
@@ -81,6 +84,32 @@ test("buildAgentSurfaceManifest: sorts knowledge refs by domain and actions by i
81
84
  expect(m.actions.map((a) => a.id)).toEqual(["appointment.request", "lead.submit"]);
82
85
  });
83
86
 
87
+ test("buildAgentSurfaceManifest: populates search interface when search input is provided", () => {
88
+ const m = buildAgentSurfaceManifest({
89
+ ...baseInput,
90
+ search: { url: "/api/agent/search", model: "@cf/baai/bge-m3", dimensions: 1024 },
91
+ });
92
+ expect(m.interfaces.search).toEqual({
93
+ url: "/api/agent/search",
94
+ model: "@cf/baai/bge-m3",
95
+ dimensions: 1024,
96
+ });
97
+ });
98
+
99
+ test("buildAgentSurfaceManifest: search interface is null by default", () => {
100
+ const m = buildAgentSurfaceManifest(baseInput);
101
+ expect(m.interfaces.search).toBe(null);
102
+ });
103
+
104
+ test("buildAgentSurfaceManifest: search interface presence changes contentHash", () => {
105
+ const without = buildAgentSurfaceManifest(baseInput);
106
+ const withSearch = buildAgentSurfaceManifest({
107
+ ...baseInput,
108
+ search: { url: "/api/agent/search", model: "@cf/baai/bge-m3", dimensions: 1024 },
109
+ });
110
+ expect(without.contentHash).not.toBe(withSearch.contentHash);
111
+ });
112
+
84
113
  test("computeAgentManifestContentHash: excludes contentHash and proof fields from the hash input", () => {
85
114
  const doc: Record<string, unknown> = { a: 1, b: 2, contentHash: "whatever", proof: { x: 1 } };
86
115
  const withoutHashFields = { a: 1, b: 2 };
@@ -98,3 +127,25 @@ test("canonicalJson: sorts object keys deeply, preserves array order", () => {
98
127
  expect(canonicalJson({ b: 1, a: { d: 2, c: 3 } })).toBe('{"a":{"c":3,"d":2},"b":1}');
99
128
  expect(canonicalJson([3, 1, 2])).toBe("[3,1,2]");
100
129
  });
130
+
131
+ test("computeSignedContentHash: always excludes contentHash and proof", () => {
132
+ const base = { a: 1, b: 2 };
133
+ const withHash = { ...base, contentHash: "abc", proof: { type: "test" } };
134
+ const withDifferentHash = { ...base, contentHash: "xyz", proof: { type: "other" } };
135
+ expect(computeSignedContentHash(withHash)).toBe(computeSignedContentHash(base));
136
+ expect(computeSignedContentHash(withDifferentHash)).toBe(computeSignedContentHash(base));
137
+ });
138
+
139
+ test("computeSignedContentHash: supports additional excludeFields", () => {
140
+ const base = { a: 1, b: 2 };
141
+ const withTimestamp = { ...base, generatedAt: "2026-01-01T00:00:00Z" };
142
+ const withDifferentTimestamp = { ...base, generatedAt: "2026-12-31T23:59:59Z" };
143
+ expect(computeSignedContentHash(withTimestamp, ["generatedAt"])).toBe(
144
+ computeSignedContentHash(withDifferentTimestamp, ["generatedAt"]),
145
+ );
146
+ });
147
+
148
+ test("computeSignedContentHash: matches computeAgentManifestContentHash for same inputs", () => {
149
+ const doc = { a: 1, b: 2, contentHash: "x", proof: null };
150
+ expect(computeSignedContentHash(doc)).toBe(computeAgentManifestContentHash(doc));
151
+ });
@@ -8,6 +8,7 @@
8
8
  </MODULE_MAP>
9
9
  <CHANGE_SUMMARY>
10
10
  <item>RFC-0289: initial OpenAPI formatter tests.</item>
11
+ <item>RFC-0954: add search path projection tests.</item>
11
12
  </CHANGE_SUMMARY>
12
13
  */
13
14
 
@@ -114,3 +115,32 @@ test("formatAgentOpenApi: mcp interface present → x-gogol-mcp is set", () => {
114
115
  const doc = formatAgentOpenApi(manifest, []);
115
116
  expect(doc["x-gogol-mcp"]).toEqual({ url: "/api/agent/mcp", protocolVersion: "2025-06-18" });
116
117
  });
118
+
119
+ test("formatAgentOpenApi: search interface present → /api/agent/search GET path with dynamic lang enum", () => {
120
+ const manifest = buildAgentSurfaceManifest({
121
+ site: "s",
122
+ baseUrl: "https://s.example",
123
+ languages: { default: "de", supported: ["de", "uk"] },
124
+ search: { url: "/api/agent/search", model: "@cf/baai/bge-m3", dimensions: 1024 },
125
+ });
126
+ const doc = formatAgentOpenApi(manifest, []);
127
+ const op = doc.paths["/api/agent/search"]?.get;
128
+ expect(op).toBeTruthy();
129
+ expect(op!.operationId).toBe("search.query");
130
+ expect(op!.tags).toEqual(["search"]);
131
+ expect(doc.components.schemas["search-response"]).toBeTruthy();
132
+ const langParam = op!.parameters!.find((p: { name: string }) => p.name === "lang");
133
+ expect(langParam).toBeTruthy();
134
+ expect(langParam!.schema.enum).toEqual(["de", "uk"]);
135
+ });
136
+
137
+ test("formatAgentOpenApi: search interface absent → no search path", () => {
138
+ const manifest = buildAgentSurfaceManifest({
139
+ site: "s",
140
+ baseUrl: "https://s.example",
141
+ languages: { default: "de", supported: ["de"] },
142
+ });
143
+ const doc = formatAgentOpenApi(manifest, []);
144
+ expect(doc.paths["/api/agent/search"]).toBe(undefined);
145
+ expect(doc.components.schemas["search-response"]).toBe(undefined);
146
+ });
@@ -0,0 +1,18 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Type shim for the qrcode browser entry point (qrcode/lib/browser.js).
4
+ The qrcode package only ships types for its main entry, but the browser entry
5
+ exports the same toCanvas function. This shim re-exports all types from qrcode
6
+ so the client-side QR code script gets full type safety.</purpose>
7
+ <non-goals>
8
+ <item>Do not change runtime resolution; this file only fills the missing package declaration.</item>
9
+ </non-goals>
10
+ </MODULE_CONTRACT>
11
+ <CHANGE_SUMMARY>
12
+ <item>Add type declaration for qrcode/lib/browser.js deep import used by external-link-qr.ts.</item>
13
+ </CHANGE_SUMMARY>
14
+ */
15
+
16
+ declare module "qrcode/lib/browser.js" {
17
+ export * from "qrcode";
18
+ }
@@ -3,8 +3,8 @@ utilities:
3
3
  canonicalPath: packages/werkstatt-shared/src/share/slug/
4
4
  forbiddenImports:
5
5
  - "@sindresorhus/slugify"
6
- - "cyrillic-to-translit-js"
7
- - "github-slugger"
6
+ - cyrillic-to-translit-js
7
+ - github-slugger
8
8
  functionNames:
9
9
  - slugify
10
10
  - toSlug