jevstrudel.git / website / src / jev / myTabs.test.mjs

A tab's end of the hosted MCP (myTabs.mjs): what the listener's own AI plays goes to the sandbox as foreign code, never into the page, and 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';
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});