@openwop/openwop-conformance 1.26.0 → 1.29.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.
- package/CHANGELOG.md +24 -0
- package/README.md +2 -2
- package/api/openapi.yaml +215 -0
- package/coverage.md +19 -0
- package/package.json +1 -1
- package/schemas/README.md +4 -0
- package/schemas/capabilities.schema.json +53 -0
- package/schemas/localized-content-language-settings.schema.json +26 -0
- package/schemas/localized-content-page-response.schema.json +60 -0
- package/schemas/localized-content-page.schema.json +62 -0
- package/schemas/localized-content-section.schema.json +51 -0
- package/schemas/suspend-request.schema.json +20 -0
- package/src/scenarios/aiproviders-speechsynth-shape.test.ts +92 -0
- package/src/scenarios/i18n-negotiation.test.ts +30 -3
- package/src/scenarios/interrupt-approver-routing.test.ts +166 -0
- package/src/scenarios/localized-content-delivery.test.ts +221 -0
- package/src/scenarios/spec-corpus-validity.test.ts +1 -0
- package/src/scenarios/speech-synthesis-roundtrip.test.ts +78 -0
- package/src/scenarios/speech-synthesis-unadvertised.test.ts +65 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Localized content surface — durable authored content (pages → sections) that
|
|
3
|
+
* reuses the Stable i18n annex's Accept-Language/Content-Language negotiation
|
|
4
|
+
* (RFC 0103; `spec/v1/localized-content.md`). Public tests for the protocol-tier
|
|
5
|
+
* SECURITY invariants `content-published-cache-no-draft`,
|
|
6
|
+
* `content-response-tenant-scoped`, and `content-no-cross-tenant-enumeration`.
|
|
7
|
+
*
|
|
8
|
+
* Two layers:
|
|
9
|
+
*
|
|
10
|
+
* A. Always-on, server-free legs — the `content` capability block, the four
|
|
11
|
+
* content schemas (section / page / language-settings / page-response),
|
|
12
|
+
* the §A capability-coherence constraints, and the §C per-section field
|
|
13
|
+
* merge reference algorithm (`resolveSection`, exact → family → base,
|
|
14
|
+
* shallow overlay) shared verbatim with hosts.
|
|
15
|
+
*
|
|
16
|
+
* B. Capability-gated behavioral legs — on a host advertising
|
|
17
|
+
* `capabilities.content` that exposes `GET /v1/content/pages/{slug}`:
|
|
18
|
+
* malformed Accept-Language succeeds with the base locale, Content-Language
|
|
19
|
+
* reflects the locale used, published-only delivery, tenant isolation, and
|
|
20
|
+
* no cross-tenant enumeration. Unadvertised hosts skip via the gate;
|
|
21
|
+
* hosts without a live target soft-skip (no OPENWOP_BASE_URL).
|
|
22
|
+
*
|
|
23
|
+
* @see spec/v1/localized-content.md §A–§F
|
|
24
|
+
* @see spec/v1/i18n.md §"Accept-Language request header", §"Fallback rules"
|
|
25
|
+
* @see SECURITY/invariants.yaml id: content-published-cache-no-draft, content-response-tenant-scoped, content-no-cross-tenant-enumeration
|
|
26
|
+
* @see RFCS/0103-localized-content-surface.md
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { describe, it, expect } from 'vitest';
|
|
30
|
+
import { readFileSync } from 'node:fs';
|
|
31
|
+
import { join } from 'node:path';
|
|
32
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
33
|
+
import addFormats from 'ajv-formats';
|
|
34
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
35
|
+
import { driver } from '../lib/driver.js';
|
|
36
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
37
|
+
import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
|
|
38
|
+
|
|
39
|
+
const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
|
|
40
|
+
function loadSchema(name: string): Record<string, unknown> {
|
|
41
|
+
return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ContentCap {
|
|
45
|
+
supported?: boolean;
|
|
46
|
+
baseLocale?: string;
|
|
47
|
+
supportedLocales?: string[];
|
|
48
|
+
}
|
|
49
|
+
interface I18nCap {
|
|
50
|
+
supported?: boolean;
|
|
51
|
+
defaultLocale?: string;
|
|
52
|
+
supportedLocales?: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
|
|
56
|
+
|
|
57
|
+
// ── §C reference merge — shared verbatim with conforming hosts ──────────────
|
|
58
|
+
type Section = {
|
|
59
|
+
data: Record<string, unknown>;
|
|
60
|
+
localizations: Record<string, Record<string, unknown>>;
|
|
61
|
+
};
|
|
62
|
+
function resolveSection(section: Section, negotiatedLocale: string, baseLocale: string): Record<string, unknown> {
|
|
63
|
+
const loc = section.localizations ?? {};
|
|
64
|
+
if (negotiatedLocale === baseLocale || Object.keys(loc).length === 0) return section.data;
|
|
65
|
+
if (loc[negotiatedLocale]) return { ...section.data, ...loc[negotiatedLocale] };
|
|
66
|
+
if (negotiatedLocale.includes('-')) {
|
|
67
|
+
const lang = negotiatedLocale.split('-')[0];
|
|
68
|
+
if (loc[lang]) return { ...section.data, ...loc[lang] };
|
|
69
|
+
}
|
|
70
|
+
return section.data;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── §A capability-coherence predicate ──────────────────────────────────────
|
|
74
|
+
function contentCoherent(content: ContentCap, i18n: I18nCap): boolean {
|
|
75
|
+
if (content.supported !== true) return true; // absent/false ⇒ nothing to check
|
|
76
|
+
if (i18n.supported !== true) return false; // (1) requires i18n
|
|
77
|
+
if (content.baseLocale !== i18n.defaultLocale) return false; // (2)
|
|
78
|
+
const i18nSet = new Set(i18n.supportedLocales ?? []);
|
|
79
|
+
const resolvable = [content.baseLocale!, ...(content.supportedLocales ?? [])];
|
|
80
|
+
if (!resolvable.every((l) => i18nSet.has(l))) return false; // (3) subset
|
|
81
|
+
if ((content.supportedLocales ?? []).includes(content.baseLocale!)) return false; // (4) base ∉ supported
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
86
|
+
// A. Server-free legs
|
|
87
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
88
|
+
|
|
89
|
+
describe('localized-content: capability advertisement shape (localized-content.md §A, server-free)', () => {
|
|
90
|
+
it('capabilities schema declares content with its required fields', () => {
|
|
91
|
+
const caps = loadSchema('capabilities.schema.json');
|
|
92
|
+
const content = (caps.properties as Record<string, { required?: string[]; properties?: Record<string, unknown> }>).content;
|
|
93
|
+
expect(content, why('capabilities.schema.json §content', 'the content block MUST be declared')).toBeDefined();
|
|
94
|
+
expect(content?.required, why('localized-content.md §A', 'supported + baseLocale + supportedLocales MUST be required')).toEqual(
|
|
95
|
+
expect.arrayContaining(['supported', 'baseLocale', 'supportedLocales']),
|
|
96
|
+
);
|
|
97
|
+
for (const f of ['supported', 'baseLocale', 'supportedLocales']) {
|
|
98
|
+
expect(content?.properties?.[f], why('localized-content.md §A', `content.${f} MUST be declared`)).toBeDefined();
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('localized-content: schema shapes (localized-content.md §B, server-free)', () => {
|
|
104
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
105
|
+
addFormats(ajv);
|
|
106
|
+
const section = ajv.compile(loadSchema('localized-content-section.schema.json'));
|
|
107
|
+
const page = ajv.compile(loadSchema('localized-content-page.schema.json'));
|
|
108
|
+
const settings = ajv.compile(loadSchema('localized-content-language-settings.schema.json'));
|
|
109
|
+
const response = ajv.compile(loadSchema('localized-content-page-response.schema.json'));
|
|
110
|
+
|
|
111
|
+
const goodSection = {
|
|
112
|
+
sectionId: 'hero',
|
|
113
|
+
sectionType: 'hero',
|
|
114
|
+
data: { heading: 'Welcome', cta: 'Get started' },
|
|
115
|
+
localizations: { es: { heading: 'Bienvenido', cta: 'Empezar' }, 'pt-BR': { heading: 'Bem-vindo' } },
|
|
116
|
+
status: 'published',
|
|
117
|
+
enabled: true,
|
|
118
|
+
order: 0,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
it('a conforming section validates', () => {
|
|
122
|
+
expect(section(goodSection), why('RFC 0103 §B', `a conforming section MUST validate. Errors: ${JSON.stringify(section.errors)}`)).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
it('a localizations key with wrong case/underscore is rejected', () => {
|
|
125
|
+
expect(section({ ...goodSection, localizations: { EN: { heading: 'x' } } }), why('RFC 0103 §B', 'a non-BCP-47-subset key MUST be rejected')).toBe(false);
|
|
126
|
+
expect(section({ ...goodSection, localizations: { en_US: { heading: 'x' } } }), why('RFC 0103 §B', 'an underscore locale key MUST be rejected')).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
it('a section missing a required field is rejected', () => {
|
|
129
|
+
const { status: _omit, ...noStatus } = goodSection;
|
|
130
|
+
expect(section(noStatus), why('RFC 0103 §B', 'status is REQUIRED')).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
it('a status outside the enum is rejected', () => {
|
|
133
|
+
expect(section({ ...goodSection, status: 'archived' }), why('RFC 0103 §B', 'status MUST be draft|published')).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('a conforming page validates; a bad slug is rejected', () => {
|
|
137
|
+
const goodPage = { pageId: 'home', slug: 'home', name: 'Home', status: 'published', sectionOrder: ['hero'] };
|
|
138
|
+
expect(page(goodPage), why('RFC 0103 §B', `a conforming page MUST validate. Errors: ${JSON.stringify(page.errors)}`)).toBe(true);
|
|
139
|
+
expect(page({ ...goodPage, slug: 'Home Page' }), why('RFC 0103 §B', 'slug MUST match ^[a-z][a-z0-9-]*$')).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('a conforming language-settings validates', () => {
|
|
143
|
+
const good = { baseLocale: 'en', supportedLocales: ['es', 'pt-BR', 'fr'], autoTranslateOnPublish: false };
|
|
144
|
+
expect(settings(good), why('RFC 0103 §B', `settings MUST validate. Errors: ${JSON.stringify(settings.errors)}`)).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('a conforming page-response validates', () => {
|
|
148
|
+
const good = {
|
|
149
|
+
version: '1',
|
|
150
|
+
generatedAt: '2026-06-17T00:00:00Z',
|
|
151
|
+
locale: 'pt-BR',
|
|
152
|
+
slug: 'home',
|
|
153
|
+
page: { pageId: 'home', slug: 'home', name: 'Home' },
|
|
154
|
+
sections: [{ sectionId: 'hero', sectionType: 'hero', data: { heading: 'Bem-vindo', cta: 'Get started' } }],
|
|
155
|
+
};
|
|
156
|
+
expect(response(good), why('RFC 0103 §D', `a resolved response MUST validate. Errors: ${JSON.stringify(response.errors)}`)).toBe(true);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('localized-content: per-section field merge (localized-content.md §C, server-free)', () => {
|
|
161
|
+
const section: Section = {
|
|
162
|
+
data: { heading: 'Welcome', cta: 'Get started' },
|
|
163
|
+
localizations: { es: { heading: 'Bienvenido', cta: 'Empezar' }, 'pt-BR': { heading: 'Bem-vindo' } },
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
it('exact-locale hit overrides matching fields', () => {
|
|
167
|
+
expect(resolveSection(section, 'es', 'en'), why('RFC 0103 §C', 'exact hit MUST overlay locale fields onto data')).toEqual({
|
|
168
|
+
heading: 'Bienvenido',
|
|
169
|
+
cta: 'Empezar',
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
it('partial translation falls through to base for missing fields', () => {
|
|
173
|
+
expect(resolveSection(section, 'pt-BR', 'en'), why('RFC 0103 §C', 'missing locale fields MUST fall through to data')).toEqual({
|
|
174
|
+
heading: 'Bem-vindo',
|
|
175
|
+
cta: 'Get started',
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
it('language-family fallback applies when exact tag is absent', () => {
|
|
179
|
+
const s: Section = { data: { h: 'Hi' }, localizations: { pt: { h: 'Oi' } } };
|
|
180
|
+
expect(resolveSection(s, 'pt-BR', 'en'), why('RFC 0103 §C', 'pt-BR MUST fall back to the pt family override')).toEqual({ h: 'Oi' });
|
|
181
|
+
});
|
|
182
|
+
it('unsupported/base locale returns base data unchanged', () => {
|
|
183
|
+
expect(resolveSection(section, 'de', 'en'), why('RFC 0103 §C', 'no match MUST return base data')).toEqual(section.data);
|
|
184
|
+
expect(resolveSection(section, 'en', 'en'), why('RFC 0103 §C', 'base locale MUST return base data')).toEqual(section.data);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('localized-content: §A capability coherence predicate (server-free)', () => {
|
|
189
|
+
const i18n: I18nCap = { supported: true, defaultLocale: 'en', supportedLocales: ['en', 'es', 'pt-BR', 'fr'] };
|
|
190
|
+
it('a coherent advertisement passes', () => {
|
|
191
|
+
expect(contentCoherent({ supported: true, baseLocale: 'en', supportedLocales: ['es', 'pt-BR', 'fr'] }, i18n), why('RFC 0103 §A', 'a coherent content block MUST pass')).toBe(true);
|
|
192
|
+
});
|
|
193
|
+
it('content without i18n is incoherent', () => {
|
|
194
|
+
expect(contentCoherent({ supported: true, baseLocale: 'en', supportedLocales: ['es'] }, { supported: false }), why('RFC 0103 §A.1', 'content requires i18n.supported')).toBe(false);
|
|
195
|
+
});
|
|
196
|
+
it('baseLocale != i18n.defaultLocale is incoherent', () => {
|
|
197
|
+
expect(contentCoherent({ supported: true, baseLocale: 'es', supportedLocales: ['fr'] }, i18n), why('RFC 0103 §A.2', 'baseLocale MUST equal i18n.defaultLocale')).toBe(false);
|
|
198
|
+
});
|
|
199
|
+
it('a supportedLocales not ⊆ i18n.supportedLocales is incoherent', () => {
|
|
200
|
+
expect(contentCoherent({ supported: true, baseLocale: 'en', supportedLocales: ['de'] }, i18n), why('RFC 0103 §A.3', 'content locales MUST be a subset of i18n locales')).toBe(false);
|
|
201
|
+
});
|
|
202
|
+
it('baseLocale appearing in supportedLocales is incoherent', () => {
|
|
203
|
+
expect(contentCoherent({ supported: true, baseLocale: 'en', supportedLocales: ['en', 'es'] }, i18n), why('RFC 0103 §A.4', 'baseLocale MUST NOT appear in supportedLocales')).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
208
|
+
// B. Capability-gated behavioral legs (live host)
|
|
209
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
210
|
+
|
|
211
|
+
describe.skipIf(HTTP_SKIP)('localized-content: live advertisement coherence (localized-content.md §A)', () => {
|
|
212
|
+
it('advertised content block is coherent with the advertised i18n block', async () => {
|
|
213
|
+
const content = await readCapabilityFamily<ContentCap>('content');
|
|
214
|
+
if (!behaviorGate('openwop-content', content?.supported === true)) return;
|
|
215
|
+
const i18n = (await readCapabilityFamily<I18nCap>('i18n')) ?? {};
|
|
216
|
+
expect(
|
|
217
|
+
contentCoherent(content!, i18n),
|
|
218
|
+
driver.describe('localized-content.md §A', 'the advertised content block MUST satisfy the i18n-subset + baseLocale invariants'),
|
|
219
|
+
).toBe(true);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Speech-synthesis round-trip (RFC 0105 §A) — behavioral.
|
|
3
|
+
*
|
|
4
|
+
* Gated on `capabilities.aiProviders.speechSynthesis === 'supported'`
|
|
5
|
+
* (root-first per RFC 0073). Soft-skips when unadvertised (default) /
|
|
6
|
+
* hard-fails under `OPENWOP_REQUIRE_BEHAVIOR=true`. The always-on wire-shape
|
|
7
|
+
* coverage lives in `aiproviders-speechsynth-shape.test.ts`; this asserts host
|
|
8
|
+
* BEHAVIOR via the documented host-sample seam
|
|
9
|
+
* `POST /v1/host/sample/ai/call-speech-synthesizer` (soft-skips on 404 until a
|
|
10
|
+
* host wires it):
|
|
11
|
+
*
|
|
12
|
+
* - the synthesizer returns 200 with an `audio` object;
|
|
13
|
+
* - EXACTLY ONE of `audio.url` / `audio.base64` is present (a host-served URL
|
|
14
|
+
* reference OR an inline base64 asset — never both, never neither);
|
|
15
|
+
* - `audio.mimeType` is a non-empty string;
|
|
16
|
+
* - `audio.voiceId` echoes the input `voiceId` (the opaque host-resolved id).
|
|
17
|
+
*
|
|
18
|
+
* Spec references:
|
|
19
|
+
* - https://github.com/openwop/openwop/blob/main/RFCS/0105-speech-synthesis-adapter.md (§A)
|
|
20
|
+
* - https://github.com/openwop/openwop/blob/main/spec/v1/host-capabilities.md (§host.aiProviders)
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { describe, it, expect } from 'vitest';
|
|
24
|
+
import { driver } from '../lib/driver.js';
|
|
25
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
26
|
+
import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
|
|
27
|
+
|
|
28
|
+
const SEAM = '/v1/host/sample/ai/call-speech-synthesizer';
|
|
29
|
+
const VOICE_ID = 'host:narrator-test';
|
|
30
|
+
|
|
31
|
+
/** Pull the `audio` object out of a seam response body (tolerant). */
|
|
32
|
+
function audioOf(json: unknown): Record<string, unknown> | undefined {
|
|
33
|
+
const a = (json as { audio?: unknown })?.audio;
|
|
34
|
+
return a && typeof a === 'object' ? (a as Record<string, unknown>) : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('speech-synthesis-roundtrip (RFC 0105 §A)', () => {
|
|
38
|
+
it('synthesizes an audio asset with exactly one of url/base64 and echoes the voiceId', async () => {
|
|
39
|
+
const ai = await readCapabilityFamily<Record<string, unknown>>('aiProviders');
|
|
40
|
+
const advertised = ai?.speechSynthesis === 'supported';
|
|
41
|
+
if (!behaviorGate('openwop-speech-synthesis', advertised)) return;
|
|
42
|
+
|
|
43
|
+
const res = await driver.post(SEAM, {
|
|
44
|
+
text: 'Welcome to the weekly digest.',
|
|
45
|
+
voiceId: VOICE_ID,
|
|
46
|
+
});
|
|
47
|
+
if (res.status === 404) return; // seam unwired — soft-skip the behavioral suite
|
|
48
|
+
|
|
49
|
+
expect(
|
|
50
|
+
res.status === 200,
|
|
51
|
+
driver.describe('RFC 0105 §A', 'an advertised host MUST synthesize and return 200'),
|
|
52
|
+
).toBe(true);
|
|
53
|
+
|
|
54
|
+
const audio = audioOf(res.json);
|
|
55
|
+
expect(
|
|
56
|
+
audio !== undefined,
|
|
57
|
+
driver.describe('RFC 0105 §A', 'the response MUST carry an `audio` object'),
|
|
58
|
+
).toBe(true);
|
|
59
|
+
if (!audio) return;
|
|
60
|
+
|
|
61
|
+
const hasUrl = typeof audio.url === 'string' && (audio.url as string).length > 0;
|
|
62
|
+
const hasBase64 = typeof audio.base64 === 'string' && (audio.base64 as string).length > 0;
|
|
63
|
+
expect(
|
|
64
|
+
hasUrl !== hasBase64,
|
|
65
|
+
driver.describe('RFC 0105 §A', 'audio MUST carry EXACTLY ONE of `url` / `base64`'),
|
|
66
|
+
).toBe(true);
|
|
67
|
+
|
|
68
|
+
expect(
|
|
69
|
+
typeof audio.mimeType === 'string' && (audio.mimeType as string).length > 0,
|
|
70
|
+
driver.describe('RFC 0105 §A', 'audio.mimeType MUST be a non-empty string'),
|
|
71
|
+
).toBe(true);
|
|
72
|
+
|
|
73
|
+
expect(
|
|
74
|
+
audio.voiceId === VOICE_ID,
|
|
75
|
+
driver.describe('RFC 0105 §A', 'audio.voiceId MUST echo the input voiceId'),
|
|
76
|
+
).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Speech-synthesis on an unadvertised host (RFC 0105 §C) — behavioral.
|
|
3
|
+
*
|
|
4
|
+
* The mirror of `speech-synthesis-roundtrip.test.ts`: a host that does NOT
|
|
5
|
+
* advertise `capabilities.aiProviders.speechSynthesis === 'supported'` MUST
|
|
6
|
+
* REJECT a `ctx.callSpeechSynthesizer` call with the canonical
|
|
7
|
+
* `speech_synthesis_unsupported` error — never a 200 success, never a silent
|
|
8
|
+
* no-op (parallel to RFC 0091's `unsupported_modality`).
|
|
9
|
+
*
|
|
10
|
+
* Gating is BY ABSENCE: the leg is active precisely when TTS is NOT advertised
|
|
11
|
+
* (`behaviorGate('openwop-speech-synthesis-unadvertised', !advertised)`), so
|
|
12
|
+
* it soft-skips on a host that DOES advertise (where the round-trip leg runs
|
|
13
|
+
* instead) / hard-fails under `OPENWOP_REQUIRE_BEHAVIOR=true` when the seam is
|
|
14
|
+
* wired but the host fails to reject. Exercised via the documented host-sample
|
|
15
|
+
* seam `POST /v1/host/sample/ai/call-speech-synthesizer` (soft-skips on 404
|
|
16
|
+
* until a host wires it).
|
|
17
|
+
*
|
|
18
|
+
* Spec references:
|
|
19
|
+
* - https://github.com/openwop/openwop/blob/main/RFCS/0105-speech-synthesis-adapter.md (§C)
|
|
20
|
+
* - https://github.com/openwop/openwop/blob/main/spec/v1/host-capabilities.md (§host.aiProviders)
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { describe, it, expect } from 'vitest';
|
|
24
|
+
import { driver } from '../lib/driver.js';
|
|
25
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
26
|
+
import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
|
|
27
|
+
|
|
28
|
+
const SEAM = '/v1/host/sample/ai/call-speech-synthesizer';
|
|
29
|
+
|
|
30
|
+
/** Read the canonical error code from a seam response body (tolerant of
|
|
31
|
+
* `{error}` / `{code}` / `{error:{code}}` shapes) — mirrors
|
|
32
|
+
* `callai-multimodal.test.ts`'s `errCode`. */
|
|
33
|
+
function errCode(json: unknown): string | undefined {
|
|
34
|
+
const j = json as { error?: unknown; code?: unknown };
|
|
35
|
+
if (typeof j?.code === 'string') return j.code;
|
|
36
|
+
if (typeof j?.error === 'string') return j.error;
|
|
37
|
+
const e = j?.error as { code?: unknown } | undefined;
|
|
38
|
+
if (e && typeof e.code === 'string') return e.code;
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('speech-synthesis-unadvertised (RFC 0105 §C)', () => {
|
|
43
|
+
it('a host NOT advertising speechSynthesis MUST reject the call with speech_synthesis_unsupported', async () => {
|
|
44
|
+
const ai = await readCapabilityFamily<Record<string, unknown>>('aiProviders');
|
|
45
|
+
const advertised = ai?.speechSynthesis === 'supported';
|
|
46
|
+
// Active precisely when TTS is ABSENT but the seam exists.
|
|
47
|
+
if (!behaviorGate('openwop-speech-synthesis-unadvertised', !advertised)) return;
|
|
48
|
+
|
|
49
|
+
const res = await driver.post(SEAM, {
|
|
50
|
+
text: 'Welcome to the weekly digest.',
|
|
51
|
+
voiceId: 'host:narrator-test',
|
|
52
|
+
});
|
|
53
|
+
if (res.status === 404) return; // seam unwired — soft-skip
|
|
54
|
+
|
|
55
|
+
// MUST reject — never a 200 success, never a silent no-op.
|
|
56
|
+
expect(
|
|
57
|
+
res.status !== 200,
|
|
58
|
+
driver.describe('RFC 0105 §C', 'an unadvertising host MUST NOT return a 200 success (never a no-op)'),
|
|
59
|
+
).toBe(true);
|
|
60
|
+
expect(
|
|
61
|
+
errCode(res.json) === 'speech_synthesis_unsupported',
|
|
62
|
+
driver.describe('RFC 0105 §C', 'the call MUST be rejected with `speech_synthesis_unsupported`'),
|
|
63
|
+
).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
});
|