@runsnative/mcp-server 0.1.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.
@@ -0,0 +1,339 @@
1
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
5
+ // ---------------------------------------------------------------------------
6
+ // Helpers — copied from content.ts to avoid importing filesystem-bound code
7
+ // ---------------------------------------------------------------------------
8
+ function extractSection(text, heading) {
9
+ const sections = text.split(/(?=^## )/m);
10
+ for (const section of sections) {
11
+ const headingLine = section.split(/\r?\n/)[0] ?? '';
12
+ if (headingLine.trim() === `## ${heading}`) {
13
+ const newlineIdx = section.indexOf('\n');
14
+ return newlineIdx !== -1 ? section.slice(newlineIdx).trim() : '';
15
+ }
16
+ }
17
+ return '';
18
+ }
19
+ function stripFrontmatter(raw) {
20
+ const match = raw.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n/);
21
+ if (!match)
22
+ return raw;
23
+ return raw.slice(match[0].length);
24
+ }
25
+ // ---------------------------------------------------------------------------
26
+ // RemoteContentProvider — fetches from the MCP API Worker with offline cache.
27
+ //
28
+ // Cache strategy: stale-while-revalidate keyed by manifest version.
29
+ // - On initialize(): fetch /mcp/manifest → compare version to cached manifest
30
+ // - Version match → cache is fresh, serve from cache
31
+ // - Version mismatch or no cached manifest → set cacheStale = true, refresh on first access
32
+ // - Network error at initialize() → leave cacheStale = false (use cache as-is)
33
+ //
34
+ // Per-call:
35
+ // - Cache hit + !cacheStale → return from cache
36
+ // - Cache miss or stale → fetch from Worker, write to cache, return
37
+ // - Network error + cache hit → return from cache
38
+ // - Network error + no cache → throw McpError
39
+ //
40
+ // 402 handling:
41
+ // - tenantExpired = true, extract payment_url from body
42
+ // - Throw agent-friendly McpError
43
+ // - Subsequent calls drop Bearer token (anonymous mode for session)
44
+ // ---------------------------------------------------------------------------
45
+ export class RemoteContentProvider {
46
+ baseUrl;
47
+ token;
48
+ cacheDir;
49
+ cacheStale = false;
50
+ tenantExpired = false;
51
+ bootstrapUrl = undefined;
52
+ constructor(baseUrl = process.env['RUNSNATIVE_API_URL'] ?? 'https://api.runsnative.org/mcp', token = process.env['RUNSNATIVE_TENANT_TOKEN'], cacheDir = process.env['RUNSNATIVE_CACHE_DIR'] ?? join(homedir(), '.runsnative', 'cache')) {
53
+ this.baseUrl = baseUrl;
54
+ this.token = token;
55
+ this.cacheDir = cacheDir;
56
+ }
57
+ // ── Startup: version-check manifest ────────────────────────────────────────
58
+ async initialize() {
59
+ try {
60
+ const res = await this.fetchApi('/manifest');
61
+ if (!res.ok)
62
+ return; // non-2xx at startup → skip, cache is usable as-is
63
+ const manifest = await res.json();
64
+ this.bootstrapUrl = manifest.bootstrap_url;
65
+ const cachedVersion = this.readCachedManifestVersion();
66
+ if (cachedVersion === null || cachedVersion !== manifest.version) {
67
+ this.cacheStale = true;
68
+ // Write the fresh manifest to cache
69
+ this.writeCache('manifest.json', JSON.stringify(manifest));
70
+ }
71
+ // version matches → cache is fresh, cacheStale stays false
72
+ }
73
+ catch {
74
+ // Network unavailable at startup — use cache as-is, do not mark stale
75
+ }
76
+ }
77
+ // ── ContentProvider interface ───────────────────────────────────────────────
78
+ async getComponent(name, tab) {
79
+ const cachePath = join('components', name, `${tab}.md`);
80
+ return this.fetchText(`/components/${name}/${tab}`, cachePath);
81
+ }
82
+ async listComponents(includeDrafts = false) {
83
+ const manifest = await this.getManifest();
84
+ return manifest.components
85
+ .filter(e => includeDrafts || e.status === 'ready')
86
+ .map(e => ({
87
+ name: e.name,
88
+ title: e.title,
89
+ element_tag: e.element_tag,
90
+ surface: e.surface,
91
+ status: e.status,
92
+ purpose: e.purpose,
93
+ }))
94
+ .sort((a, b) => a.name.localeCompare(b.name));
95
+ }
96
+ async getFoundation(name) {
97
+ const cachePath = join('foundations', `${name}.md`);
98
+ return this.fetchText(`/foundations/${name}`, cachePath);
99
+ }
100
+ async listFoundations() {
101
+ const manifest = await this.getManifest();
102
+ return manifest.foundations
103
+ .map(e => ({
104
+ foundation: e.name,
105
+ title: e.title,
106
+ description: e.description,
107
+ }))
108
+ .sort((a, b) => a.foundation.localeCompare(b.foundation));
109
+ }
110
+ async searchDocs(query, limit = 5) {
111
+ const encoded = encodeURIComponent(query);
112
+ const cachePath = join('search', `${encoded}-${limit}.json`);
113
+ try {
114
+ const res = await this.fetchApi(`/search?q=${encoded}&limit=${limit}`);
115
+ await this.handle402(res);
116
+ if (res.ok) {
117
+ const results = await res.json();
118
+ this.writeCache(cachePath, JSON.stringify(results));
119
+ return results;
120
+ }
121
+ }
122
+ catch (err) {
123
+ if (err instanceof McpError)
124
+ throw err;
125
+ // Network error — fall through to cache
126
+ }
127
+ const cached = this.readCache(cachePath);
128
+ if (cached !== null)
129
+ return JSON.parse(cached);
130
+ // No cache and no network for search — return empty rather than hard error
131
+ return [];
132
+ }
133
+ async listExercises(includeDrafts = false) {
134
+ const manifest = await this.getManifest();
135
+ return manifest.exercises
136
+ .filter(e => includeDrafts || e.status === 'ready')
137
+ .map(e => ({
138
+ name: e.name,
139
+ title: e.title,
140
+ tagline: e.tagline,
141
+ activity_type: e.activity_type,
142
+ difficulty: e.difficulty,
143
+ estimated_minutes: e.estimated_minutes,
144
+ status: e.status,
145
+ }))
146
+ .sort((a, b) => a.name.localeCompare(b.name));
147
+ }
148
+ async getExerciseDetail(name) {
149
+ const cachePath = join('exercises', name, 'index.json');
150
+ try {
151
+ const res = await this.fetchApi(`/exercises/${name}`);
152
+ await this.handle402(res);
153
+ if (res.ok) {
154
+ const data = await res.json();
155
+ this.writeCache(cachePath, JSON.stringify(data));
156
+ return {
157
+ exercise: data.exercise,
158
+ title: data.title,
159
+ overview: data.overview,
160
+ steps: data.steps,
161
+ };
162
+ }
163
+ if (res.status === 404) {
164
+ throw new RangeError(`Exercise "${name}" not found.`);
165
+ }
166
+ }
167
+ catch (err) {
168
+ if (err instanceof McpError || err instanceof RangeError)
169
+ throw err;
170
+ // Network error — fall through to cache
171
+ }
172
+ const cached = this.readCache(cachePath);
173
+ if (cached !== null) {
174
+ const data = JSON.parse(cached);
175
+ return { exercise: data.exercise, title: data.title, overview: data.overview, steps: data.steps };
176
+ }
177
+ throw new McpError(ErrorCode.InternalError, `Network unavailable and content not cached: exercises/${name}/index`);
178
+ }
179
+ async getExerciseStep(name, step) {
180
+ const cachePath = join('exercises', name, 'steps', `${step}.json`);
181
+ try {
182
+ const res = await this.fetchApi(`/exercises/${name}/steps/${step}`);
183
+ await this.handle402(res);
184
+ if (res.ok) {
185
+ const data = await res.json();
186
+ this.writeCache(cachePath, JSON.stringify(data));
187
+ return this.buildStepContent(data);
188
+ }
189
+ if (res.status === 404) {
190
+ throw new RangeError(`Step ${step} not found in exercise "${name}".`);
191
+ }
192
+ }
193
+ catch (err) {
194
+ if (err instanceof McpError || err instanceof RangeError)
195
+ throw err;
196
+ // Network error — fall through to cache
197
+ }
198
+ const cached = this.readCache(cachePath);
199
+ if (cached !== null)
200
+ return this.buildStepContent(JSON.parse(cached));
201
+ throw new McpError(ErrorCode.InternalError, `Network unavailable and content not cached: exercises/${name}/steps/${step}`);
202
+ }
203
+ // Recipes are not yet served by the remote Worker. Return empty list so
204
+ // list_composition_patterns degrades gracefully; throw a clear error on
205
+ // get_composition_pattern so the agent knows what's missing.
206
+ async listRecipes() {
207
+ return [];
208
+ }
209
+ async getRecipe(name) {
210
+ throw new McpError(ErrorCode.InternalError, `Composition pattern "${name}" is not available from the remote content API yet. ` +
211
+ `Connect a local content tree via RUNSNATIVE_CONTENT_ROOT to access recipes.`);
212
+ }
213
+ // ── Private helpers ─────────────────────────────────────────────────────────
214
+ async fetchApi(path) {
215
+ const headers = {};
216
+ if (this.token && !this.tenantExpired) {
217
+ headers['Authorization'] = `Bearer ${this.token}`;
218
+ }
219
+ return fetch(`${this.baseUrl}${path}`, { headers });
220
+ }
221
+ async handle402(res) {
222
+ if (res.status !== 402)
223
+ return;
224
+ this.tenantExpired = true;
225
+ let paymentUrl = 'https://runsnative.org/billing';
226
+ try {
227
+ const body = await res.clone().json();
228
+ if (typeof body['payment_url'] === 'string')
229
+ paymentUrl = body['payment_url'];
230
+ }
231
+ catch { /* ignore parse failure */ }
232
+ throw new McpError(ErrorCode.InternalError, `Your RunsNative tenant access has ended. Visit ${paymentUrl} to reactivate. Until then, this MCP server is operating in anonymous mode.`);
233
+ }
234
+ async fetchText(apiPath, cachePath) {
235
+ // Cache hit when not stale → serve from cache
236
+ if (!this.cacheStale) {
237
+ const cached = this.readCache(cachePath);
238
+ if (cached !== null)
239
+ return cached;
240
+ }
241
+ try {
242
+ const res = await this.fetchApi(apiPath);
243
+ await this.handle402(res);
244
+ if (res.ok) {
245
+ const text = await res.text();
246
+ this.writeCache(cachePath, text);
247
+ return text;
248
+ }
249
+ if (res.status === 404) {
250
+ const segments = apiPath.split('/').filter(Boolean);
251
+ throw new RangeError(`Content not found: ${apiPath}. Check the ${segments[0] ?? 'resource'} name and tab are correct.`);
252
+ }
253
+ }
254
+ catch (err) {
255
+ if (err instanceof McpError || err instanceof RangeError)
256
+ throw err;
257
+ // Network error — fall through to cache
258
+ }
259
+ const cached = this.readCache(cachePath);
260
+ if (cached !== null)
261
+ return cached;
262
+ throw new McpError(ErrorCode.InternalError, `Network unavailable and content not cached: ${apiPath}`);
263
+ }
264
+ async getManifest() {
265
+ const cachePath = 'manifest.json';
266
+ if (!this.cacheStale) {
267
+ const cached = this.readCache(cachePath);
268
+ if (cached !== null)
269
+ return JSON.parse(cached);
270
+ }
271
+ try {
272
+ const res = await this.fetchApi('/manifest');
273
+ await this.handle402(res);
274
+ if (res.ok) {
275
+ const manifest = await res.json();
276
+ this.writeCache(cachePath, JSON.stringify(manifest));
277
+ this.cacheStale = false;
278
+ return manifest;
279
+ }
280
+ }
281
+ catch (err) {
282
+ if (err instanceof McpError)
283
+ throw err;
284
+ // Network error — fall through to cache
285
+ }
286
+ const cached = this.readCache(cachePath);
287
+ if (cached !== null)
288
+ return JSON.parse(cached);
289
+ throw new McpError(ErrorCode.InternalError, 'Network unavailable and manifest not cached. Cannot list components/foundations/exercises.');
290
+ }
291
+ buildStepContent(data) {
292
+ const body = stripFrontmatter(data.content);
293
+ const goal = extractSection(data.content, 'Goal') || extractSection(body, 'Goal');
294
+ return {
295
+ exercise: data.exercise,
296
+ step: data.step,
297
+ title: data.title,
298
+ goal,
299
+ content: data.content,
300
+ };
301
+ }
302
+ // ── Cache I/O ───────────────────────────────────────────────────────────────
303
+ readCachedManifestVersion() {
304
+ const raw = this.readCache('manifest.json');
305
+ if (raw === null)
306
+ return null;
307
+ try {
308
+ const manifest = JSON.parse(raw);
309
+ return manifest.version ?? null;
310
+ }
311
+ catch {
312
+ return null;
313
+ }
314
+ }
315
+ cachePath(relativePath) {
316
+ return join(this.cacheDir, relativePath);
317
+ }
318
+ readCache(relativePath) {
319
+ const fullPath = this.cachePath(relativePath);
320
+ if (!existsSync(fullPath))
321
+ return null;
322
+ try {
323
+ return readFileSync(fullPath, 'utf8');
324
+ }
325
+ catch {
326
+ return null;
327
+ }
328
+ }
329
+ writeCache(relativePath, content) {
330
+ const fullPath = this.cachePath(relativePath);
331
+ try {
332
+ mkdirSync(dirname(fullPath), { recursive: true });
333
+ writeFileSync(fullPath, content, 'utf8');
334
+ }
335
+ catch {
336
+ // Cache write failure is non-fatal — content was already served
337
+ }
338
+ }
339
+ }
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Phase 5 validation: two use cases against the stub MCP.
3
+ *
4
+ * Run after building: node dist/test/validate.js
5
+ *
6
+ * Use case A — Q&A: get_component("button", "usage") returns content that
7
+ * lets an agent answer "How do I use a button in RunsNative?"
8
+ *
9
+ * Use case B — Authoring: get_component("button", "code") returns content
10
+ * that lets an agent generate correct <run-button> markup.
11
+ */
12
+ import { getComponent, validateContentRoot, listComponentMeta, getFoundation, listFoundations, searchDocs, listExerciseMeta, getExerciseDetail, getExerciseStep, } from '../content.js';
13
+ import { handleInferTheme } from '../tools/infer-theme.js';
14
+ validateContentRoot();
15
+ let passed = 0;
16
+ let failed = 0;
17
+ function check(label, fn) {
18
+ try {
19
+ fn();
20
+ console.log(` ✓ ${label}`);
21
+ passed++;
22
+ }
23
+ catch (err) {
24
+ console.error(` ✗ ${label}`);
25
+ console.error(` ${err.message}`);
26
+ failed++;
27
+ }
28
+ }
29
+ function assert(condition, message) {
30
+ if (!condition)
31
+ throw new Error(message);
32
+ }
33
+ console.log('\nUse case A — Q&A (usage tab)');
34
+ {
35
+ const content = await getComponent('button', 'usage');
36
+ check('returns non-empty content', () => assert(content.length > 100, 'content too short'));
37
+ check('covers variants', () => assert(content.includes('variant'), 'missing variant documentation'));
38
+ check('covers disabled state', () => assert(content.includes('disabled'), 'missing disabled state'));
39
+ check('covers loading state', () => assert(content.includes('loading'), 'missing loading state'));
40
+ check('has do/don\'t guidance', () => assert(content.includes('Do') && content.includes("Don't"), 'missing do/don\'t section'));
41
+ check('tab frontmatter present', () => assert(content.includes('tab: usage'), 'missing tab frontmatter'));
42
+ }
43
+ console.log('\nUse case B — Authoring (code tab)');
44
+ {
45
+ const content = await getComponent('button', 'code');
46
+ check('returns non-empty content', () => assert(content.length > 100, 'content too short'));
47
+ check('contains element tag run-button', () => assert(content.includes('run-button'), 'missing run-button element tag'));
48
+ check('lists variant property', () => assert(content.includes('variant'), 'missing variant in API'));
49
+ check('lists disabled property', () => assert(content.includes('disabled'), 'missing disabled in API'));
50
+ check('lists run-click event', () => assert(content.includes('run-click'), 'missing run-click event'));
51
+ check('contains markup example', () => assert(content.includes('<run-button'), 'missing markup example'));
52
+ check('has @cem-inject placeholder', () => assert(content.includes('@cem-inject'), 'missing @cem-inject placeholder'));
53
+ }
54
+ console.log('\nCompound validation (tabs usage tab)');
55
+ {
56
+ const content = await getComponent('tabs', 'usage');
57
+ check('returns non-empty content', () => assert(content.length > 100, 'content too short'));
58
+ check('references run-tabs', () => assert(content.includes('run-tabs'), 'missing run-tabs element tag'));
59
+ check('references run-tab', () => assert(content.includes('run-tab'), 'missing run-tab constituent'));
60
+ check('references run-tab-panel', () => assert(content.includes('run-tab-panel'), 'missing run-tab-panel constituent'));
61
+ check('explains panel pairing', () => assert(content.includes('panel') && content.includes('name'), 'missing panel/name pairing explanation'));
62
+ }
63
+ console.log('\nError handling');
64
+ {
65
+ check('throws RangeError for unknown component', async () => {
66
+ try {
67
+ await getComponent('nonexistent-component-xyz', 'usage');
68
+ throw new Error('should have thrown');
69
+ }
70
+ catch (err) {
71
+ assert(err instanceof RangeError, `expected RangeError, got ${err.constructor.name}`);
72
+ }
73
+ });
74
+ check('throws RangeError for unknown tab', async () => {
75
+ try {
76
+ await getComponent('button', 'notreal');
77
+ throw new Error('should have thrown');
78
+ }
79
+ catch (err) {
80
+ assert(err instanceof RangeError, `expected RangeError, got ${err.constructor.name}`);
81
+ }
82
+ });
83
+ }
84
+ // ---------------------------------------------------------------------------
85
+ // list_components
86
+ // ---------------------------------------------------------------------------
87
+ console.log('\nlist_components');
88
+ {
89
+ const ready = await listComponentMeta(false);
90
+ const all = await listComponentMeta(true);
91
+ check('ready list is non-empty', () => assert(ready.length > 0, 'no ready components found'));
92
+ check('includes button', () => assert(ready.some((c) => c.name === 'button'), 'button not in ready list'));
93
+ check('includes checkbox', () => assert(ready.some((c) => c.name === 'checkbox'), 'checkbox not in ready list'));
94
+ check('all list is larger than ready list', () => assert(all.length > ready.length, 'draft filtering not working'));
95
+ check('every component has a name and title', () => {
96
+ for (const c of ready) {
97
+ assert(typeof c.name === 'string' && c.name.length > 0, `component missing name`);
98
+ assert(typeof c.title === 'string' && c.title.length > 0, `${c.name} missing title`);
99
+ }
100
+ });
101
+ check('every component has a purpose field', () => {
102
+ for (const c of ready) {
103
+ assert(typeof c.purpose === 'string', `${c.name} missing purpose field`);
104
+ }
105
+ });
106
+ check('drafts contain stub components', () => {
107
+ const drafts = all.filter((c) => c.status === 'draft');
108
+ assert(drafts.length > 5, `expected >5 draft components, got ${drafts.length}`);
109
+ });
110
+ }
111
+ // ---------------------------------------------------------------------------
112
+ // get_foundation
113
+ // ---------------------------------------------------------------------------
114
+ console.log('\nget_foundation');
115
+ {
116
+ const spacing = await getFoundation('spacing');
117
+ check('spacing returns non-empty content', () => assert(spacing.length > 100, 'content too short'));
118
+ check('spacing contains token table', () => assert(spacing.includes('--run-space'), 'missing spacing token'));
119
+ check('spacing frontmatter present', () => assert(spacing.includes('foundation: spacing'), 'missing frontmatter'));
120
+ const color = await getFoundation('color');
121
+ check('color returns non-empty content', () => assert(color.length > 100, 'color content too short'));
122
+ check('color contains color tokens', () => assert(color.includes('--run-color'), 'missing color token'));
123
+ check('throws RangeError for unknown foundation', async () => {
124
+ try {
125
+ await getFoundation('nonexistent-foundation-xyz');
126
+ throw new Error('should have thrown');
127
+ }
128
+ catch (err) {
129
+ assert(err instanceof RangeError, `expected RangeError, got ${err.constructor.name}`);
130
+ }
131
+ });
132
+ }
133
+ // ---------------------------------------------------------------------------
134
+ // listFoundations
135
+ // ---------------------------------------------------------------------------
136
+ console.log('\nlistFoundations');
137
+ {
138
+ const foundations = await listFoundations();
139
+ check('returns 7 foundations', () => assert(foundations.length === 7, `expected 7 foundations, got ${foundations.length}`));
140
+ check('includes color', () => assert(foundations.some((f) => f.foundation === 'color'), 'missing color'));
141
+ check('includes typography', () => assert(foundations.some((f) => f.foundation === 'typography'), 'missing typography'));
142
+ check('each foundation has title and description', () => {
143
+ for (const f of foundations) {
144
+ assert(typeof f.title === 'string' && f.title.length > 0, `${f.foundation} missing title`);
145
+ assert(typeof f.description === 'string', `${f.foundation} missing description`);
146
+ }
147
+ });
148
+ }
149
+ // ---------------------------------------------------------------------------
150
+ // search_docs
151
+ // ---------------------------------------------------------------------------
152
+ console.log('\nsearch_docs');
153
+ {
154
+ const results = await searchDocs('spacing token padding', 3);
155
+ check('returns results for spacing query', () => assert(results.length > 0, 'no results for spacing query'));
156
+ check('result limit respected', () => assert(results.length <= 3, `expected ≤3 results, got ${results.length}`));
157
+ check('results have required fields', () => {
158
+ for (const r of results) {
159
+ assert(typeof r.name === 'string' && r.name.length > 0, 'result missing name');
160
+ assert(typeof r.excerpt === 'string' && r.excerpt.length > 0, 'result missing excerpt');
161
+ assert(r.score > 0, 'result has zero score');
162
+ }
163
+ });
164
+ check('results are sorted by score desc', () => {
165
+ for (let i = 1; i < results.length; i++) {
166
+ assert(results[i - 1].score >= results[i].score, 'results not sorted by score');
167
+ }
168
+ });
169
+ const empty = await searchDocs('', 5);
170
+ check('empty query returns empty results', () => assert(empty.length === 0, 'empty query should return no results'));
171
+ const limit1 = await searchDocs('button variant', 1);
172
+ check('limit: 1 returns at most 1 result', () => assert(limit1.length <= 1, `expected ≤1 result, got ${limit1.length}`));
173
+ }
174
+ // ---------------------------------------------------------------------------
175
+ // infer_theme
176
+ // ---------------------------------------------------------------------------
177
+ console.log('\ninfer_theme');
178
+ {
179
+ // Normal case: dark skin via data-theme attribute
180
+ const darkResult = await handleInferTheme({ html: '<div data-theme="dark"><run-button>Click</run-button></div>' });
181
+ const dark = JSON.parse(darkResult.content[0].text);
182
+ check('detects dark skin from data-theme', () => assert(dark.skin === 'dark', `expected dark, got ${dark.skin}`));
183
+ check('dark detection has medium or high confidence', () => assert(dark.confidence === 'medium' || dark.confidence === 'high', `unexpected confidence: ${dark.confidence}`));
184
+ // Token overrides
185
+ const tokenHtml = '<style>:root { --run-color-bg-base: #000; --run-color-text-primary: #fff; }</style>';
186
+ const tokenResult = await handleInferTheme({ html: tokenHtml });
187
+ const tokenData = JSON.parse(tokenResult.content[0].text);
188
+ check('extracts CSS custom property overrides', () => assert(Object.keys(tokenData.token_overrides).length >= 2, `expected ≥2 overrides, got ${Object.keys(tokenData.token_overrides).length}`));
189
+ // Fallback stub: no skin signals
190
+ const plainResult = await handleInferTheme({ html: '<div><p>Hello</p></div>' });
191
+ const plain = JSON.parse(plainResult.content[0].text);
192
+ check('returns null skin for unstyled markup', () => assert(plain.skin === null, `expected null, got ${plain.skin}`));
193
+ check('fallback has low confidence', () => assert(plain.confidence === 'low', `expected low, got ${plain.confidence}`));
194
+ check('fallback reasoning is non-empty', () => assert(typeof plain.reasoning === 'string' && plain.reasoning.length > 0, 'missing reasoning'));
195
+ // Edge case: high-contrast skin
196
+ const hcResult = await handleInferTheme({ html: '<html data-skin="high-contrast"><body></body></html>' });
197
+ const hc = JSON.parse(hcResult.content[0].text);
198
+ check('detects high-contrast skin', () => assert(hc.skin === 'high-contrast', `expected high-contrast, got ${hc.skin}`));
199
+ // Error: empty html
200
+ check('throws for empty html', async () => {
201
+ try {
202
+ await handleInferTheme({ html: '' });
203
+ throw new Error('should have thrown');
204
+ }
205
+ catch (err) {
206
+ assert(err.message.includes('empty') || err.message.includes('html'), `unexpected error: ${err.message}`);
207
+ }
208
+ });
209
+ }
210
+ // ---------------------------------------------------------------------------
211
+ // list_exercises
212
+ // ---------------------------------------------------------------------------
213
+ console.log('\nlist_exercises');
214
+ {
215
+ const ready = await listExerciseMeta(false);
216
+ const all = await listExerciseMeta(true);
217
+ check('returns all three ready exercises', () => assert(ready.length === 3, `expected 3 ready exercises, got ${ready.length}`));
218
+ check('each exercise has required fields', () => {
219
+ for (const ex of ready) {
220
+ assert(typeof ex.name === 'string' && ex.name.length > 0, `exercise missing name`);
221
+ assert(typeof ex.title === 'string' && ex.title.length > 0, `${ex.name} missing title`);
222
+ assert(typeof ex.activity_type === 'string' && ex.activity_type.length > 0, `${ex.name} missing activity_type`);
223
+ assert(typeof ex.difficulty === 'string' && ex.difficulty.length > 0, `${ex.name} missing difficulty`);
224
+ assert(typeof ex.estimated_minutes === 'number' && ex.estimated_minutes > 0, `${ex.name} missing estimated_minutes`);
225
+ }
226
+ });
227
+ check('include_drafts: false excludes drafts', () => {
228
+ const drafts = all.filter((ex) => ex.status === 'draft');
229
+ // No draft exercises authored yet, so all.length >= ready.length is sufficient
230
+ assert(all.length >= ready.length, 'all list should be at least as large as ready list');
231
+ });
232
+ }
233
+ // ---------------------------------------------------------------------------
234
+ // start_exercise
235
+ // ---------------------------------------------------------------------------
236
+ console.log('\nstart_exercise');
237
+ {
238
+ const detail = await getExerciseDetail('vibe-with-runsnative');
239
+ check('overview returned', () => {
240
+ assert(typeof detail.overview === 'string' && detail.overview.length > 20, 'overview missing or too short');
241
+ });
242
+ check('step list present with correct count', () => {
243
+ assert(Array.isArray(detail.steps) && detail.steps.length === 4, `expected 4 steps for vibe-with-runsnative, got ${detail.steps.length}`);
244
+ for (const s of detail.steps) {
245
+ assert(typeof s.step === 'number' && s.step >= 1, `step has invalid step number`);
246
+ assert(typeof s.slug === 'string' && s.slug.length > 0, `step missing slug`);
247
+ assert(typeof s.title === 'string' && s.title.length > 0, `step missing title`);
248
+ }
249
+ });
250
+ check('unknown exercise throws RangeError', async () => {
251
+ try {
252
+ await getExerciseDetail('nonexistent-exercise-xyz');
253
+ throw new Error('should have thrown');
254
+ }
255
+ catch (err) {
256
+ assert(err instanceof RangeError, `expected RangeError, got ${err.constructor.name}`);
257
+ }
258
+ });
259
+ }
260
+ // ---------------------------------------------------------------------------
261
+ // get_step
262
+ // ---------------------------------------------------------------------------
263
+ console.log('\nget_step');
264
+ {
265
+ const step1 = await getExerciseStep('vibe-with-runsnative', 1);
266
+ check('step 1 returned with content', () => {
267
+ assert(step1.step === 1, `expected step 1, got ${step1.step}`);
268
+ assert(typeof step1.goal === 'string' && step1.goal.length > 10, 'goal missing or too short');
269
+ assert(typeof step1.content === 'string' && step1.content.length > 50, 'content missing or too short');
270
+ });
271
+ const lastStep = await getExerciseStep('vibe-with-runsnative', 4);
272
+ check('last step returned', () => {
273
+ assert(lastStep.step === 4, `expected step 4, got ${lastStep.step}`);
274
+ assert(typeof lastStep.title === 'string' && lastStep.title.length > 0, 'last step missing title');
275
+ });
276
+ check('out-of-range step throws RangeError', async () => {
277
+ try {
278
+ await getExerciseStep('vibe-with-runsnative', 99);
279
+ throw new Error('should have thrown');
280
+ }
281
+ catch (err) {
282
+ assert(err instanceof RangeError, `expected RangeError, got ${err.constructor.name}`);
283
+ }
284
+ });
285
+ }
286
+ // ---------------------------------------------------------------------------
287
+ // Exercise ↔ live-site wiring (RUN-354)
288
+ //
289
+ // Regression guard for the RUN-326 defect class: exercises that referenced
290
+ // abstract placeholders and component tags that do not resolve against the
291
+ // live Harlow Pets site (`run-text-field`, the invented `slot="tab"` on
292
+ // run-tab, the invented `activation="automatic"`). RUN-354 wired the three
293
+ // exercises to the real site at http://localhost:3250; these assertions stop
294
+ // them silently drifting back to placeholders or invented APIs.
295
+ // ---------------------------------------------------------------------------
296
+ console.log('\nexercise wiring (RUN-354)');
297
+ {
298
+ // Tokens that do not resolve anywhere (live site, component knowledge base,
299
+ // or real run-tabs API). Each is a concrete RUN-326 bug RUN-354 corrected.
300
+ const INVALID_TOKENS = ['run-text-field', 'slot="tab"', 'activation="automatic"', '"automatic"'];
301
+ for (const meta of await listExerciseMeta(false)) {
302
+ const detail = await getExerciseDetail(meta.name);
303
+ let surfaced = detail.overview;
304
+ for (let i = 1; i <= detail.steps.length; i++) {
305
+ surfaced += '\n' + (await getExerciseStep(meta.name, i)).content;
306
+ }
307
+ check(`${meta.name}: references the live Harlow Pets site (localhost:3250)`, () => assert(surfaced.includes('localhost:3250'), `no http://localhost:3250 reference in surfaced content — exercise is not wired to the live site`));
308
+ for (const token of INVALID_TOKENS) {
309
+ check(`${meta.name}: free of non-resolving token ${token}`, () => assert(!surfaced.includes(token), `surfaced content contains "${token}" — a placeholder/invented reference that does not resolve against the live site`));
310
+ }
311
+ }
312
+ }
313
+ console.log(`\n${passed + failed} checks: ${passed} passed, ${failed} failed`);
314
+ if (failed > 0)
315
+ process.exit(1);