1// A tab's end of the hosted MCP (myTabs.mjs): what the listener's own AI 2// plays goes to the sandbox as foreign code, never into the page, and 3// what the tab answers stays within what the hub accepts. 4import { describe, expect, it, vi } from 'vitest'; 5import { clearLogs, codeKind, loggedForMcp, MAX_CODE_BYTES, recordLog, replyFrame, runCommand } from './myTabs.mjs'; 6 7function editor(code = '') { 8 return { 9 code, 10 evaluate: vi.fn(), 11 setCode: vi.fn(), 12 stop: vi.fn(async () => {}), 13 repl: { evaluate: vi.fn(), scheduler: { started: false, now: () => 0 } }, 14 }; 15} 16function sandboxDeps({ result = { ok: true }, foreign = null, playing = false, cycle = null } = {}) { 17 const deps = { 18 showForeign: vi.fn((ed, code, from) => { 19 deps.foreignNow = from; 20 ed.code = code; 21 }), 22 playForeign: vi.fn(async () => result), 23 sandbox: { playing: () => playing, foreign: () => deps.foreignNow ?? foreign }, 24 sandboxCycle: () => cycle, 25 }; 26 return deps; 27} 28 29describe("the AI's play", () => { 30 it('goes to the sandbox as foreign code, after stopping what played, and never through the page', async () => { 31 const ed = editor('note("c")'); 32 const deps = sandboxDeps(); 33 expect(await runCommand(ed, { type: 'play', code: 's("bd*4")', app: 'Claude' }, deps)).toEqual({ ok: true }); 34 expect(ed.stop).toHaveBeenCalledBefore(deps.showForeign); 35 expect(deps.showForeign).toHaveBeenCalledWith(ed, 's("bd*4")', { ai: 'Claude' }); 36 expect(deps.playForeign).toHaveBeenCalledWith('s("bd*4")'); 37 // the page's own evaluation is never asked 38 expect(ed.evaluate).not.toHaveBeenCalled(); 39 expect(ed.repl.evaluate).not.toHaveBeenCalled(); 40 expect(ed.setCode).not.toHaveBeenCalled(); 41 }); 42 43 it("passes on the sandbox's evaluation error", async () => { 44 const deps = sandboxDeps({ result: { error: 'unexpected token' } }); 45 expect(await runCommand(editor(), { type: 'play', code: 's(', app: 'x' }, deps)).toEqual({ error: 'unexpected token' }); 46 }); 47 48 it('refuses empty or oversized code, and names an app it was not told as "your AI"', async () => { 49 const deps = sandboxDeps(); 50 expect(await runCommand(editor(), { type: 'play', code: ' ' }, deps)).toEqual({ error: 'code is empty' }); 51 expect((await runCommand(editor(), { type: 'play', code: 'x'.repeat(MAX_CODE_BYTES + 1) }, deps)).error).toMatch(/at most/); 52 expect(deps.showForeign).not.toHaveBeenCalled(); 53 await runCommand(editor(), { type: 'play', code: 's("hh")', app: 42 }, deps); 54 expect(deps.showForeign.mock.calls[0][2]).toEqual({ ai: 'your AI' }); 55 }); 56}); 57 58describe("the tab's answers", () => { 59 it('reports status by where its code came from', async () => { 60 const ed = editor('s("bd")'); 61 expect(codeKind(ed, null)).toBe('own'); 62 expect(codeKind(editor(''), null)).toBe('none'); 63 expect(codeKind(ed, { ai: 'x' })).toBe('ai'); 64 expect(codeKind(ed, { song: {} })).toBe('listener'); 65 const deps = sandboxDeps({ foreign: { ai: 'x' }, playing: true, cycle: 4.5 }); 66 expect(await runCommand(ed, { type: 'status' }, deps)).toEqual({ status: { playing: true, cycle: 4.5, code: 'ai' } }); 67 expect(await runCommand(ed, { type: 'get-code' }, deps)).toEqual({ code: 's("bd")' }); 68 expect(await runCommand(ed, { type: 'eval' }, deps)).toEqual({ error: 'unknown command eval' }); 69 }); 70 71 it('keeps logs bounded, folds repeats, and trims a reply to what the hub accepts', async () => { 72 clearLogs(); 73 recordLog('[sampler] loaded bd'); 74 recordLog('[sampler] loaded bd'); 75 recordLog('x'.repeat(10_000), 'warning'); 76 const { logs } = await runCommand(editor(), { type: 'get-logs' }, sandboxDeps()); 77 expect(logs).toHaveLength(2); 78 expect(logs[0]).toMatchObject({ message: '[sampler] loaded bd', count: 2 }); 79 expect(logs[1].message).toHaveLength(500); 80 const many = Array.from({ length: 2000 }, (_, i) => ({ at: i, message: 'y'.repeat(400), kind: '', count: 1 })); 81 const frame = replyFrame('1-2', { logs: many }); 82 expect(frame.length).toBeLessThanOrEqual(250 * 1024); 83 const parsed = JSON.parse(frame); 84 expect(parsed.logs.at(-1).at).toBe(1999); 85 expect(JSON.parse(replyFrame('1-3', { code: 'z'.repeat(300 * 1024) }))).toEqual({ type: 'reply', id: '1-3', error: 'the answer is too large to send' }); 86 }); 87}); 88 89describe('the shared tools, from the hosted hub', () => { 90 it('hands them to tabTools, starting the logs afresh for a song played by id', async () => { 91 const deps = { ...sandboxDeps(), tabTool: vi.fn(async () => ({ song: { id: 'jev/dial-up' } })) }; 92 recordLog('before'); 93 const ed = editor(); 94 expect(await runCommand(ed, { type: 'play-song', id: 'jev/dial-up' }, deps)).toEqual({ song: { id: 'jev/dial-up' } }); 95 expect(deps.tabTool).toHaveBeenCalledWith(ed, { type: 'play-song', id: 'jev/dial-up' }); 96 expect((await runCommand(ed, { type: 'get-logs' }, deps)).logs).toEqual([]); 97 recordLog('kept'); 98 await runCommand(ed, { type: 'get-settings' }, deps); 99 expect((await runCommand(ed, { type: 'get-logs' }, deps)).logs).toHaveLength(1); 100 // nothing else of the page's is in reach 101 expect(await runCommand(ed, { type: 'set-code', code: 'x' }, deps)).toEqual({ error: 'unknown command set-code' }); 102 expect(deps.showForeign).not.toHaveBeenCalled(); 103 }); 104 105 it("keeps the sandbox's logs for its AI's code and a song it played, not for a song the listener clicked", () => { 106 const played = { song: { id: 'x' } }; 107 expect(loggedForMcp({ ai: 'Claude' }, null)).toBe(true); 108 expect(loggedForMcp(played, played)).toBe(true); 109 expect(loggedForMcp({ song: { id: 'x' } }, played)).toBe(false); 110 expect(loggedForMcp(null, null)).toBe(false); 111 }); 112});