jevstrudel.git / tools / mcp-e2e / tools.mjs
tools.mjsannotatedtools.mjssource158 lines · 11.1 KB · raw

The tools both MCPs share (worker/src/shared-tools.json) end to end, on a dev server of its own (README.md beside it): a listener's own AI through the hosted MCP, with the real Jev, and the dev hub's MCP on the same tab.

Hosted: list_sounds per category (a sound folder imported through the sounds tab shows under user and imported), ask_jev_sound (charged to the listener's budget), api_reference, a settings round trip (and a refused prebake), play_song for a site song (in the page) and a listener's song (in the sandbox), get_logs from the console tab, react, vote (counted in the summary), ask_jev_song, comment. Dev: the same tools listed and answered by the dev hub. Exits nonzero on any failed check.

12import { mkdirSync, copyFileSync, readdirSync } from 'node:fs';
13import { browser, check, fail, log, person, SITE } from './person.mjs';
15const WORKER = process.env.WORKER ?? 'http://localhost:4392';
16const ROOT = new URL('../../', import.meta.url);
17
18try {
19  const link = await person('Tools', 'e2et1', 33411);
20  const { page, tool, budget } = link;
21  const names = (await link.client.listTools()).tools.map((t) => t.name);
22  const shared = ['list_sounds', 'ask_jev_sound', 'api_reference', 'get_settings', 'set_settings', 'play_song', 'ask_jev_song', 'react', 'vote'];
23  check(shared.every((n) => names.includes(n)) && names.includes('comment'), `hosted lists the shared tools and comment (${names.length} tools)`);

── settings: open the sounds tab by changing activeFooter, round trip ──

26  const before = JSON.parse((await tool('get_settings')).text);
27  check(before.themes.includes('dracula') && typeof before.settings.fontSize === 'number' && !('prebakeScript' in before.settings), `get_settings: ${JSON.stringify(before.settings).slice(0, 120)}…`);
28  const set = await tool('set_settings', { changes: { theme: 'dracula', fontSize: 22, activeFooter: 'sounds' } });
29  check(!set.isError && /changed theme, fontSize, activeFooter/.test(set.text), `set_settings: ${set.text.slice(0, 100)}`);
30  const after = JSON.parse((await tool('get_settings')).text).settings;
31  check(after.theme === 'dracula' && after.fontSize === 22 && after.activeFooter === 'sounds', 'get_settings reads the changes back');
32  const stored = await page.evaluate(() => Object.fromEntries(Object.keys(localStorage).filter((k) => k.startsWith('strudel-settings')).map((k) => [k, localStorage.getItem(k)])));
33  check(Object.entries(stored).some(([k, v]) => k.endsWith('theme') && v === 'dracula'), "the page's settings store has them");
34  const prebake = await tool('set_settings', { changes: { prebakeScript: 'fetch("/jev/auth/me")' } });
35  check(prebake.isError && /not a setting an MCP may change/.test(prebake.text), `prebakeScript refused: ${prebake.text}`);

── import a sound folder through the sounds tab, as a listener would ──

38  const folder = new URL('../../worker/.wrangler/e2e-import/e2ewhoosh/', import.meta.url).pathname;
39  mkdirSync(folder, { recursive: true });
40  const jeff = readdirSync(new URL('samples/jeff/', ROOT)).find((f) => /\.(mp3|wav)$/.test(f));
41  copyFileSync(new URL(`samples/jeff/${jeff}`, ROOT), `${folder}${jeff}`);
42  await page.click('#importSounds');
43  await page.setInputFiles('#audio_file', folder.replace(/\/$/, ''));
44  await page.waitForFunction(() => document.body.textContent.includes('e2ewhoosh'), null, { timeout: 15000 }).catch(() => {});

── list_sounds, per category ──

47  for (const category of ['samples', 'drum-machines', 'synths', 'wavetables', 'user', 'imported']) {
48    const r = await tool('list_sounds', { category, limit: 3 });
49    const total = Number(/^(\d+) /.exec(r.text)?.[1] ?? 0);
50    check(!r.isError && total > 0, `list_sounds ${category}: ${total} sounds; ${r.text.split('\n').slice(1, 3).join(' | ')}`);
51    if (category === 'imported') check(r.text.includes('e2ewhoosh (imported') && /sandbox your code plays in cannot/.test(r.text), 'the imported folder is listed, with where it plays');
52  }
53  const paged = await tool('list_sounds', { category: 'drum-machines', filter: '808', limit: 2 });
54  const cursor = /cursor "(\d+)"/.exec(paged.text)?.[1];
55  const next = await tool('list_sounds', { category: 'drum-machines', filter: '808', limit: 2, cursor });
56  check(cursor === '2' && next.text.includes('; 3 to 4') && /\.bank\("\w*808\w*"\)/.test(paged.text), `pages: ${paged.text.split('\n')[0]} → ${next.text.split('\n')[0]}`);

── ask_jev_sound, the real Jev, charged to the listener ──

59  const spent0 = await budget();
60  const kick = await tool('ask_jev_sound', { description: 'a deep, booming 808 kick drum', category: 'drum-machines' });
61  log('ask_jev_sound (drum-machines):\n' + kick.text);
62  check(!kick.isError && /^Jev picks \S+/.test(kick.text) && /banks/.test(kick.text) && /finalists/.test(kick.text), 'ask_jev_sound banks the drum machines and says the odds are the finalists\'');
63  const keys = await tool('ask_jev_sound', { description: 'a warm electric piano for chords', category: 'synths' });
64  log('ask_jev_sound (synths):\n' + keys.text);
65  check(!keys.isError && /^Jev picks \S+.*note\("c3 e3 g3"\)/.test(keys.text), 'ask_jev_sound among the synths');
66  const spent1 = await budget();
67  check(spent1 > spent0 || /no Jev call/.test(kick.text), `charged to the listener's budget (${spent0} → ${spent1}; a cached answer is free)`);

── api_reference ──

70  const search = await tool('api_reference', { query: 'low-pass filter' });
71  check(!search.isError && /\blpf\b/.test(search.text), `api_reference query: ${search.text.split('\n').slice(0, 3).join(' | ')}`);
72  const lpf = await tool('api_reference', { name: 'lpf' });
73  check(/^lpf\(frequency\)/.test(lpf.text) && lpf.text.includes('examples:'), `api_reference name: ${lpf.text.split('\n')[0]}`);

── play_song: a site song in the page ──

76  const songs = JSON.parse((await tool('list_songs')).text);
77  const played = await tool('play_song', { id: 'jev/dial-up' });
78  check(!played.isError && /in the page/.test(played.text), `play_song site: ${played.text}`);
79  await page.waitForTimeout(4000);
80  const inPage = await page.evaluate(() => ({
81    started: Boolean(window.strudelMirror?.repl?.scheduler?.started),
82    sandbox: document.querySelectorAll('iframe[sandbox]').length,
83  }));
84  check(inPage.started && inPage.sandbox === 0, `it plays in the page, no sandbox (${JSON.stringify(inPage)})`);
85  const consoleTab = await tool('get_logs', { source: 'console' });
86  check(!consoleTab.isError && consoleTab.text !== 'nothing logged', `get_logs console: ${consoleTab.text.split('\n').slice(-3).join(' | ')}`);

── react while it plays ──

89  let reacted = { isError: true, text: '' };
90  for (let i = 0; i < 20 && reacted.isError; i++) {
91    reacted = await tool('react', { kind: 'fire' });
92    if (reacted.isError) await page.waitForTimeout(1000);
93  }
94  check(!reacted.isError && /Jev hears it/.test(reacted.text), `react: ${reacted.text}`);

── play_song: a listener's song in the sandbox ──

97  const mine = await tool('publish_song', { title: 'E2E Tools Beat', description: 'a beat to play by id', code: 'setcps(1)\ns("bd hh sd hh")' });
98  const listenerId = JSON.parse(mine.text).id;
99  const theirs = await tool('play_song', { id: listenerId });
100  check(!theirs.isError && /in its sandbox/.test(theirs.text), `play_song listener: ${theirs.text}`);
101  const inSandbox = await page.evaluate(() => ({
102    started: Boolean(window.strudelMirror?.repl?.scheduler?.started),
103    frames: [...document.querySelectorAll('iframe[sandbox]')].map((f) => f.getAttribute('sandbox')),
104  }));
105  check(!inSandbox.started && inSandbox.frames.length === 1 && inSandbox.frames[0] === 'allow-scripts', `it plays in the sandbox, not the page (${JSON.stringify(inSandbox)})`);
106  const status = await tool('get_status');
107  check(status.text.includes("a listener's song (sandboxed)"), `get_status: ${status.text.split('\n')[1]}`);
108  await page.waitForTimeout(2000);
109  const sandboxLogs = await tool('get_logs');
110  check(/load sound|\[sampler\]/.test(sandboxLogs.text), `get_logs has the sandbox's: ${sandboxLogs.text.split('\n')[0]}`);

── vote ──

113  const scored = songs.site.filter((s) => typeof s.art === 'number');
114  const [a, b] = scored.sort((x, y) => y.art - x.art);
115  const voted = await tool('vote', { a: a.id, b: b.id, pick: a.id });
116  check(!voted.isError && /counted/.test(voted.text) && /agree with Jev/.test(voted.text), `vote: ${voted.text}`);
117  const summary = await fetch(`${SITE}/jev/votes/summary`).then((r) => r.json());
118  check(summary.votes.some((v) => v.a === a.id && v.b === b.id && v.pick === a.id && v.n >= 1), 'the vote is in the summary');
119  const badVote = await tool('vote', { a: a.id, b: 'jev/not-a-song', pick: a.id });
120  check(badVote.isError && /not a song/.test(badVote.text), `a vote on no song is refused: ${badVote.text}`);

── ask_jev_song, playing Jev's pick ──

123  const mood = await tool('ask_jev_song', { mood: 'sleepy on a rainy sunday, want something gentle', play: true });
124  check(!mood.isError && /^Jev picks .+ \((\w+\/[\w-]+)\).*playing it in the page/.test(mood.text), `ask_jev_song: ${mood.text}`);
125  const pickedId = /\((\w+\/[\w-]+)\)/.exec(mood.text)?.[1];
126  await page.waitForTimeout(1500);
127  check(await page.evaluate(() => Boolean(window.strudelMirror?.repl?.scheduler?.started)), `${pickedId} plays in the page`);

── comment ──

130  const said = await tool('comment', { song: 'jev/dial-up', body: 'the modem handshake as a hook is inspired' });
131  check(!said.isError && ['public', 'pending', 'held'].includes(JSON.parse(said.text).status), `comment: ${said.text.replace(/\s+/g, ' ')}`);
133  await tool('stop_play');
134  await tool('set_settings', { changes: { theme: before.settings.theme, fontSize: before.settings.fontSize, activeFooter: 'intro' } });

── the dev hub's MCP, on the same tab ──

137  let id = 0;
138  const dev = async (method, params = {}) => {
139    const res = await fetch(`${WORKER}/mcp`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: ++id, method, params }) });
140    return (await res.json()).result;
141  };
142  const devNames = (await dev('tools/list')).tools.map((t) => t.name);
143  check(shared.every((n) => devNames.includes(n)) && !devNames.includes('comment'), `dev lists the shared tools: ${devNames.join(', ')}`);
144  const devCall = async (name, args) => (await dev('tools/call', { name, arguments: { session_id: 'e2et1', ...args } })).content[0].text;
145  const devSynths = await devCall('list_sounds', { category: 'synths', limit: 2 });
146  check(/^\d+ synths sounds/.test(devSynths), `dev list_sounds: ${devSynths.split('\n')[0]}`);
147  const devSettings = JSON.parse(await devCall('get_settings', {}));
148  check(devSettings.settings.theme === before.settings.theme, `dev get_settings: theme ${devSettings.settings.theme}`);
149  const devRef = await devCall('api_reference', { name: 'fast' });
150  check(/^fast\(/.test(devRef), `dev api_reference: ${devRef.split('\n')[0]}`);
151  const devPlay = await devCall('play_song', { id: 'jev/dial-up' });
152  check(/in the page/.test(devPlay), `dev play_song: ${devPlay}`);
153  await devCall('stop_play', {});
154} catch (e) {
155  fail(e.stack ?? e);
156} finally {
157  await browser.close();
158}