1// The tools both MCPs share (worker/src/shared-tools.json) end to end, on a 2// dev server of its own (README.md beside it): a listener's own AI through 3// the hosted MCP, with the real Jev, and the dev hub's MCP on the same tab. 4// 5// Hosted: list_sounds per category (a sound folder imported through the 6// sounds tab shows under user and imported), ask_jev_sound (charged to the 7// listener's budget), api_reference, a settings round trip (and a refused 8// prebake), play_song for a site song (in the page) and a listener's song 9// (in the sandbox), get_logs from the console tab, react, vote (counted in 10// the summary), ask_jev_song, comment. Dev: the same tools listed and 11// 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'; 14 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)`); 24 25 // ── 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}`); 36 37 // ── 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(() => {}); 45 46 // ── 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]}`); 57 58 // ── 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)`); 68 69 // ── 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]}`); 74 75 // ── 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(' | ')}`); 87 88 // ── 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}`); 95 96 // ── 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]}`); 111 112 // ── 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}`); 121 122 // ── 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`); 128 129 // ── 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, ' ')}`); 132 133 await tool('stop_play'); 134 await tool('set_settings', { changes: { theme: before.settings.theme, fontSize: before.settings.fontSize, activeFooter: 'intro' } }); 135 136 // ── 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}