Clean release snapshot

This commit is contained in:
byrongamatos
2026-06-16 18:47:13 +02:00
commit 6c110398b4
574 changed files with 162566 additions and 0 deletions
View File
+103
View File
@@ -0,0 +1,103 @@
# Browser Tests
This directory contains Playwright browser tests for Slopsmith keyboard shortcuts.
## Prerequisites
1. **Slopsmith web server**: Tests need the server reachable at `http://localhost:8000`.
Playwright auto-starts it via `webServer.command` in `playwright.config.ts`, so manual
startup is optional. Start it manually if you want to debug the server, run tests
outside Playwright, or skip the per-run boot delay:
```bash
LIBRARY_PATH=/path/to/your/library docker compose up -d
```
Playwright reuses an already-running server locally (`reuseExistingServer: true`).
2. **Node.js installed**: Required for running Playwright tests
```bash
node --version # Should be v18 or higher
```
## Installation
Install dependencies:
```bash
npm install
```
Install Playwright browsers:
```bash
npm run install:playwright
```
## Running Tests
Run all tests:
```bash
npm test
```
Run tests in headed mode (watch the browser):
```bash
npm run test:headed
```
Debug tests with interactive inspector:
```bash
npm run test:debug
```
## Test Files
- `basic-load.spec.ts` - Basic app load and shortcut registry availability
- `check-errors.spec.ts` - Check for console errors
- `keyboard-shortcuts.spec.ts` - Comprehensive keyboard shortcut tests
## Writing Tests
Tests use Playwright's test API. Example:
```typescript
import { test, expect } from '@playwright/test';
test('my test', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.screen.active');
// Interact with the page
await page.keyboard.press('?');
// Assert
await expect(page.locator('#shortcuts-modal')).toBeVisible();
});
```
## Troubleshooting
### Container won't start
If the Docker container exits immediately, check the logs:
```bash
docker compose logs
```
Common issue: Missing dependencies. Rebuild the container:
```bash
docker compose build --no-cache
docker compose up -d
```
### Tests timeout
Increase timeout in `playwright.config.ts` if needed.
### LIBRARY_PATH not set
Make sure to set the LIBRARY_PATH environment variable:
```bash
LIBRARY_PATH=~/slopsmith-library docker compose up -d
```
## CI/CD
In CI environments, Playwright will:
- Run tests in headless mode
- Retry failed tests up to 2 times
- Generate HTML report with screenshots/videos on failure
@@ -0,0 +1,38 @@
import { test, expect } from '@playwright/test';
test('legacy audio fader and analyser bridges stay visible in browser diagnostics', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
const result = await page.evaluate(() => {
const appWindow = window as any;
let faderValue = 1;
appWindow.slopsmith.audio.registerFader({
id: 'browser-smoke',
label: 'Browser Smoke',
min: 0,
max: 1,
step: 0.1,
defaultValue: 1,
getValue: () => faderValue,
setValue: (value: number) => { faderValue = value; },
});
appWindow.slopsmith.audioSession.recordBridgeHit({
domain: 'audio-mix',
bridgeId: 'audio-mix.analyser',
legacySurface: 'browser smoke analyser',
participantId: 'highway_3d',
});
const snapshot = appWindow.slopsmith.audioSession.snapshot();
const diagnostics = appWindow.slopsmith.capabilities.snapshotDiagnostics();
return {
hasFader: snapshot.domains['audio-mix'].participants.some((entry: any) => entry.participantId === 'fader.browser-smoke'),
hasAnalyserBridge: snapshot.domains['audio-mix'].bridges.some((entry: any) => entry.bridgeId === 'audio-mix.analyser'),
shimHit: diagnostics.compatibilityShims.some((entry: any) => entry.shimId === 'audio-mix.analyser' && entry.hitCount >= 1),
};
});
expect(result.hasFader).toBe(true);
expect(result.hasAnalyserBridge).toBe(true);
expect(result.shimHit).toBe(true);
});
+20
View File
@@ -0,0 +1,20 @@
import { test, expect } from '@playwright/test';
test('audio session runtime is available on page load', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
const snapshot = await page.evaluate(() => {
const appWindow = window as any;
if (!appWindow.slopsmith?.audioSession?.snapshot) {
throw new Error('audioSession host not available');
}
return appWindow.slopsmith.audioSession.snapshot();
});
expect(snapshot.schema).toBe('slopsmith.audio_session.diagnostics.v1');
expect(snapshot.domains['audio-mix']).toBeTruthy();
expect(snapshot.domains['audio-input']).toBeTruthy();
expect(snapshot.domains['audio-monitoring']).toBeTruthy();
expect(snapshot.domains.stems).toBeTruthy();
});
+25
View File
@@ -0,0 +1,25 @@
import { test, expect } from '@playwright/test';
test('app loads', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
// Check if the page loaded
const title = await page.title();
expect(title).toBe('Slopsmith');
});
test('check if window has any shortcuts', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
// Wait a bit for JS to load
await page.waitForTimeout(2000);
// Check if the keyboard shortcuts system is loaded
const hasShortcuts = await page.evaluate(() => {
return typeof window._listShortcuts === 'function';
});
expect(hasShortcuts).toBe(true);
});
@@ -0,0 +1,22 @@
import { test, expect } from '@playwright/test';
test('capability inspector renders runtime snapshot', async ({ page }) => {
await page.goto('/');
// Plugins load asynchronously after the initial HTML, so `.screen.active`
// (already satisfied by the home screen) is not a sufficient signal. Wait
// until showScreen exists AND the bundled inspector plugin's screen has been
// injected before navigating, otherwise showScreen() targets a missing id
// and the test races.
await page.waitForFunction(() => {
const appWindow = window as any;
return typeof appWindow.showScreen === 'function'
&& document.getElementById('plugin-capability_inspector') !== null;
}, { timeout: 30000 });
// showScreen is async; await it so navigation completes before we assert.
await page.evaluate(async () => {
await (window as any).showScreen('plugin-capability_inspector');
});
await page.waitForSelector('#capability-inspector-content', { timeout: 10000 });
await expect(page.locator('#capability-inspector-summary')).toContainText('domains', { timeout: 10000 });
await expect(page.locator('#capability-inspector-content')).toContainText('playback', { timeout: 10000 });
});
+53
View File
@@ -0,0 +1,53 @@
import { test, expect } from '@playwright/test';
test('check for console errors', async ({ page }) => {
const errors: string[] = [];
const logs: string[] = [];
page.on('console', msg => {
if (msg.type() === 'error') {
errors.push(msg.text());
}
logs.push(`[${msg.type()}] ${msg.text()}`);
});
page.on('pageerror', error => {
errors.push(`PAGE ERROR: ${error.message}\n${error.stack}`);
});
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
// Wait for JS to load
await page.waitForTimeout(3000);
console.log('=== Console Errors ===');
errors.forEach(e => console.log(e));
console.log('=== All Logs ===');
logs.forEach(l => console.log(l));
// Assert no unexpected errors
const allowedErrors = ['favicon.ico']; // benign 404s and known noise
const unexpected = errors.filter(e => !allowedErrors.some(a => e.includes(a)));
console.log('Unexpected errors:', unexpected);
expect(unexpected).toEqual([]);
});
test('audio mixer opens with audio-mix command-backed controls', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('#btn-mixer', { state: 'attached', timeout: 10000 });
await page.evaluate(() => window.slopsmith?.audio?.openMixer?.());
await expect(page.locator('#mixer-popover')).not.toHaveClass(/hidden/);
const faderState = await page.evaluate(async () => {
const api = window.slopsmith?.capabilities;
if (!api?.command) return { outcome: 'no-owner' };
const result = await api.command('audio-mix', 'list-faders', { requester: 'browser-smoke' });
return { outcome: result.outcome, count: result.payload?.faders?.length || 0 };
});
expect(faderState.outcome).toBe('handled');
expect(faderState.count).toBeGreaterThan(0);
await expect(page.locator('#mixer-popover .mixer-strip').first()).toBeAttached();
});
@@ -0,0 +1,220 @@
import { test, expect } from '@playwright/test';
interface SettingsPayload {
dlc_dir: string;
default_arrangement: string;
demucs_server_url: string;
master_difficulty: number;
av_offset_ms: number;
}
interface SettingsPostPayload {
default_arrangement?: string;
[key: string]: unknown;
}
const settingsPayload: SettingsPayload = {
dlc_dir: '',
default_arrangement: 'Rhythm',
demucs_server_url: '',
master_difficulty: 100,
av_offset_ms: 0,
};
test.beforeEach(async ({ page }) => {
await page.route('**/api/settings', async route => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: settingsPayload });
return;
}
await route.continue();
});
});
test('settings labels auto arrangement as most notes', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('#default-arrangement', { state: 'attached' });
const labels = await page.locator('#default-arrangement option').allTextContents();
expect(labels).toContain('Most notes (auto)');
expect(labels).not.toContain('Auto (most notes)');
});
test('player arrangement pin saves the selected arrangement name', async ({ page }) => {
const settingsPosts: SettingsPostPayload[] = [];
await page.route('**/api/settings', async route => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: settingsPayload });
return;
}
settingsPosts.push(route.request().postDataJSON());
await route.fulfill({ json: { message: 'Settings saved' } });
});
await page.goto('/');
await page.waitForSelector('#arr-select', { state: 'attached' });
await page.evaluate(() => {
// @ts-ignore - browser app helper
window.showScreen('player');
const arrangements = [
{ index: 0, name: 'Lead', notes: 420 },
{ index: 1, name: 'Rhythm', notes: 553 },
{ index: 2, name: 'Bass', notes: 386 },
];
const select = document.getElementById('arr-select') as HTMLSelectElement;
select.innerHTML = arrangements
.map(a => `<option value="${a.index}">${a.name} (${a.notes})</option>`)
.join('');
select.value = '2';
// @ts-ignore - browser app namespace
window.slopsmith.currentSong = {
filename: 'demo.psarc',
arrangement: 'Rhythm',
arrangementIndex: 2,
arrangements,
};
// @ts-ignore - browser app namespace
window.slopsmith.emit('song:loaded', window.slopsmith.currentSong);
});
const pin = page.locator('#arr-default-pin');
await expect(pin).toBeVisible();
await expect(pin).toHaveAttribute('aria-pressed', 'false');
await expect(pin).toHaveAttribute('aria-label', 'Make Bass the default for new songs');
await expect(pin).toHaveAttribute('title', 'Make Bass the default for new songs');
await expect.poll(async () => (
await page.locator('#arr-default-pin').evaluate(el => el.previousElementSibling?.id)
)).toBe('arr-select');
await pin.click();
await expect.poll(() => settingsPosts.length).toBe(1);
expect(settingsPosts[0]).toEqual({ default_arrangement: 'Bass' });
await expect(pin).toHaveAttribute('aria-pressed', 'true');
await expect(pin).toHaveAttribute('aria-label', 'Bass is the default arrangement');
await expect(pin).toHaveAttribute('title', 'Bass is the default arrangement');
await expect(page.locator('#default-arrangement')).toHaveValue('Bass');
await pin.click();
const unexpectedPost = page
.waitForRequest(
req => req.url().includes('/api/settings') && req.method() === 'POST',
{ timeout: 300 }
)
.then(() => true)
.catch(() => false);
expect(await unexpectedPost).toBe(false);
expect(settingsPosts).toHaveLength(1);
});
test('player arrangement pin preserves non-built-in arrangement names', async ({ page }) => {
const settingsPosts: SettingsPostPayload[] = [];
await page.route('**/api/settings', async route => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: settingsPayload });
return;
}
settingsPosts.push(route.request().postDataJSON());
await route.fulfill({ json: { message: 'Settings saved' } });
});
await page.goto('/');
await page.waitForSelector('#arr-select', { state: 'attached' });
await page.evaluate(() => {
// @ts-ignore - browser app helper
window.showScreen('player');
const arrangements = [
{ index: 0, name: 'Lead', notes: 420 },
{ index: 1, name: 'Rhythm', notes: 553 },
{ index: 2, name: 'Combo', notes: 610 },
];
const select = document.getElementById('arr-select') as HTMLSelectElement;
select.innerHTML = arrangements
.map(a => `<option value="${a.index}">${a.name} (${a.notes})</option>`)
.join('');
select.value = '2';
// @ts-ignore - browser app namespace
window.slopsmith.currentSong = {
filename: 'demo.psarc',
arrangement: 'Rhythm',
arrangementIndex: 2,
arrangements,
};
// @ts-ignore - browser app namespace
window.slopsmith.emit('song:loaded', window.slopsmith.currentSong);
});
await page.locator('#arr-default-pin').click();
await expect.poll(() => settingsPosts.length).toBe(1);
expect(settingsPosts[0]).toEqual({ default_arrangement: 'Combo' });
await expect(page.locator('#default-arrangement')).toHaveValue('Combo');
await expect(page.locator('#default-arrangement option[value="Combo"]')).toHaveText('Combo (saved default)');
await page.evaluate(() => {
// @ts-ignore - browser app helper
window.saveSettings();
});
await expect.poll(() => settingsPosts.length).toBe(2);
expect(settingsPosts[1]).toMatchObject({ default_arrangement: 'Combo' });
});
test('failed settings save does not mark arrangement default as persisted', async ({ page }) => {
const settingsPosts: SettingsPostPayload[] = [];
await page.route('**/api/settings', async route => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: settingsPayload });
return;
}
settingsPosts.push(route.request().postDataJSON());
await route.fulfill({ status: 500, json: { error: 'settings write failed' } });
});
await page.goto('/');
await page.waitForSelector('#arr-select', { state: 'attached' });
await page.evaluate(() => {
// @ts-ignore - browser app helper
window.showScreen('player');
const arrangements = [
{ index: 0, name: 'Lead', notes: 420 },
{ index: 1, name: 'Rhythm', notes: 553 },
{ index: 2, name: 'Bass', notes: 386 },
];
const select = document.getElementById('arr-select') as HTMLSelectElement;
select.innerHTML = arrangements
.map(a => `<option value="${a.index}">${a.name} (${a.notes})</option>`)
.join('');
select.value = '2';
// @ts-ignore - browser app namespace
window.slopsmith.currentSong = {
filename: 'demo.psarc',
arrangement: 'Rhythm',
arrangementIndex: 2,
arrangements,
};
// @ts-ignore - browser app namespace
window.slopsmith.emit('song:loaded', window.slopsmith.currentSong);
});
const pin = page.locator('#arr-default-pin');
await expect(pin).toHaveAttribute('aria-pressed', 'false');
await page.evaluate(() => {
// @ts-ignore - browser app helper
document.getElementById('default-arrangement').value = 'Bass';
// @ts-ignore - browser app helper
window.saveSettings();
});
await expect.poll(() => settingsPosts.length).toBe(1);
expect(settingsPosts[0]).toMatchObject({ default_arrangement: 'Bass' });
await expect(page.locator('#settings-status')).toHaveText('settings write failed');
await expect(pin).toHaveAttribute('aria-pressed', 'false');
await expect(pin).toHaveAttribute('aria-label', 'Make Bass the default for new songs');
await expect(pin).toHaveAttribute('title', 'Make Bass the default for new songs');
});
+120
View File
@@ -0,0 +1,120 @@
import { test, expect } from '@playwright/test';
test('left-handed setting reaches the 3D Highway renderer with a mocked song stream', async ({ page }) => {
const errors: string[] = [];
page.on('console', msg => {
if (msg.type() === 'error') errors.push(msg.text());
});
page.on('pageerror', error => {
errors.push(`PAGE ERROR: ${error.message}`);
});
await page.addInitScript(() => {
localStorage.setItem('lefty', '1');
localStorage.setItem('vizSelection', 'highway_3d');
class MockHighwayWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
url: string;
readyState = MockHighwayWebSocket.CONNECTING;
onopen: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onclose: ((event: CloseEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
constructor(url: string) {
this.url = url;
setTimeout(() => {
this.readyState = MockHighwayWebSocket.OPEN;
this.onopen?.(new Event('open'));
this.emitSong();
}, 0);
}
send() {}
close() {
this.readyState = MockHighwayWebSocket.CLOSED;
this.onclose?.(new CloseEvent('close'));
}
private emit(payload: unknown) {
this.onmessage?.(new MessageEvent('message', { data: JSON.stringify(payload) }));
}
private emitSong() {
this.emit({
type: 'song_info',
artist: 'Smoke Test',
title: 'Lefty Highway',
arrangement: 'Lead',
arrangement_smart_name: 'Lead',
arrangement_index: 0,
naming_mode: 'smart',
tuning: [0, 0, 0, 0, 0, 0],
stringCount: 6,
duration: 30,
audio_url: null,
});
this.emit({ type: 'beats', data: [{ time: 0 }, { time: 1 }] });
this.emit({ type: 'sections', data: [] });
this.emit({ type: 'anchors', data: [{ time: 0, fret: 1, width: 4 }] });
this.emit({ type: 'chord_templates', data: [] });
this.emit({ type: 'notes', data: [{ t: 1, s: 0, f: 3, d: 0 }] });
this.emit({ type: 'chords', data: [] });
this.emit({ type: 'handshapes', data: [] });
this.emit({ type: 'ready' });
}
}
Object.assign(MockHighwayWebSocket, {
CONNECTING: MockHighwayWebSocket.CONNECTING,
OPEN: MockHighwayWebSocket.OPEN,
CLOSING: MockHighwayWebSocket.CLOSING,
CLOSED: MockHighwayWebSocket.CLOSED,
});
window.WebSocket = MockHighwayWebSocket as unknown as typeof WebSocket;
});
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
await page.waitForFunction(() => typeof (window as any).playSong === 'function' && !!(window as any).highway);
await page.waitForFunction(() => {
const picker = document.getElementById('viz-picker') as HTMLSelectElement | null;
return picker?.value === 'highway_3d';
});
await expect(page.locator('#viz-picker')).toHaveValue('highway_3d');
await page.evaluate(() => {
(window as any).__h3dReadySeen = false;
(window as any).slopsmith.on('viz:renderer:ready', () => {
(window as any).__h3dReadySeen = true;
}, { once: true });
});
await page.evaluate(() => (window as any).playSong('mock-lefty.sloppak', 0));
await page.waitForSelector('#player.active', { timeout: 10000 });
await page.waitForFunction(() => (window as any).highway?.getSongInfo?.().title === 'Lefty Highway');
await page.waitForFunction(() => (window as any).__h3dReadySeen === true, { timeout: 10000 });
await expect.poll(
async () => page.evaluate(() => !(window as any).highway.isDefaultRenderer()),
{ timeout: 5000 },
).toBe(true);
await expect.poll(
async () => page.evaluate(() => (window as any).highway.getLefty()),
{ timeout: 5000 },
).toBe(true);
await page.evaluate(() => (window as any).showScreen('settings'));
await page.waitForSelector('#settings.active', { timeout: 5000 });
await expect(page.locator('#setting-lefty')).toBeChecked();
const allowedErrors = ['favicon.ico'];
expect(errors.filter(e => !allowedErrors.some(allowed => e.includes(allowed)))).toEqual([]);
});
+706
View File
@@ -0,0 +1,706 @@
import { test, expect } from '@playwright/test';
async function openPlayerWithMockSong(page) {
await page.evaluate(() => {
const messages = [
{ type: 'song_info', title: 'Mock Song', artist: 'Mock Artist', arrangement: 'Lead', arrangement_index: 0, duration: 90, tuning: [0, 0, 0, 0, 0, 0], stringCount: 6, arrangements: [{ index: 0, name: 'Lead', notes: 1 }] },
{ type: 'ready' },
];
class MockWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
readyState = MockWebSocket.CONNECTING;
onopen = null;
onmessage = null;
onerror = null;
onclose = null;
url;
constructor(url) {
this.url = url;
setTimeout(() => {
this.readyState = MockWebSocket.OPEN;
if (this.onopen) this.onopen(new Event('open'));
for (const message of messages) {
if (this.onmessage) this.onmessage({ data: JSON.stringify(message) });
}
}, 0);
}
send() {}
close() {
this.readyState = MockWebSocket.CLOSED;
if (this.onclose) this.onclose(new CloseEvent('close'));
}
}
// @ts-ignore
window.WebSocket = MockWebSocket;
});
await page.evaluate(async () => {
// @ts-ignore
await window.playSong('mock-song.sloppak');
});
await page.waitForSelector('#player.active', { timeout: 5000 });
await expect(page.locator('#hud-title')).toHaveText('Mock Song', { timeout: 5000 });
}
test.describe('Keyboard Shortcuts', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
});
test.afterEach(async ({ page }) => {
// Clean up any modals
await page.evaluate(() => {
const modal = document.getElementById('shortcuts-modal');
if (modal) modal.remove();
});
});
test('should have shortcut registry available', async ({ page }) => {
const hasRegistry = await page.evaluate(() => {
// @ts-ignore
return typeof window._listShortcuts === 'function';
});
expect(hasRegistry).toBe(true);
});
test('should list all registered shortcuts', async ({ page }) => {
const shortcuts = await page.evaluate(() => {
// @ts-ignore
window._listShortcuts();
// @ts-ignore
const activePanel = window._panels.get(window.getActiveShortcutPanel());
if (activePanel) {
return Array.from(activePanel.shortcuts.values()).map((s: any) => ({
key: s.key, scope: s.scope
}));
}
return [];
});
console.log('Registered shortcuts:', shortcuts);
// Assert every built-in is present rather than an exact count, so adding a
// shortcut elsewhere doesn't break this test for the wrong reason.
const required = [
{ key: '?', scope: 'global' },
{ key: '/', scope: 'library' },
{ key: 'c', scope: 'library' },
{ key: 'f', scope: 'library' },
{ key: 'e', scope: 'library' },
{ key: 'Space', scope: 'player' },
{ key: 'ArrowLeft', scope: 'player' },
{ key: 'ArrowRight', scope: 'player' },
{ key: 'Escape', scope: 'player' },
{ key: 'Escape', scope: 'settings' },
{ key: '[', scope: 'player' },
{ key: ']', scope: 'player' },
];
for (const r of required) {
expect(shortcuts).toContainEqual(r);
}
expect(shortcuts.filter(s => s.key === '?' && s.scope === 'global')).toHaveLength(1);
});
test('should have global ? shortcut for help', async ({ page }) => {
await page.keyboard.press('?');
const modal = page.locator('#shortcuts-modal');
await expect(modal).toBeVisible({ timeout: 5000 });
// Title should be "Keyboard shortcuts"
await expect(modal.locator('h3')).toContainText('Keyboard shortcuts');
});
test('should show library shortcuts in help modal', async ({ page }) => {
await page.keyboard.press('?');
const modal = page.locator('#shortcuts-modal');
// Library shortcuts should be visible on library screen
await expect(modal).toContainText('Focus search');
await expect(modal).toContainText('/');
await expect(modal).toContainText('Convert library entry');
await expect(modal).toContainText('c');
// Player shortcuts should NOT be visible on library screen
await expect(modal).not.toContainText('Play/Pause');
});
test('should have correct shortcut scopes', async ({ page }) => {
const shortcuts = await page.evaluate(() => {
// @ts-ignore
const shortcuts = [];
// @ts-ignore
const activePanel = window._panels.get(window.getActiveShortcutPanel());
if (activePanel) {
for (const [, s] of activePanel.shortcuts) {
shortcuts.push({ key: s.key, scope: s.scope, description: s.description });
}
}
return shortcuts;
});
const expectedShortcuts = [
{ key: '?', scope: 'global' },
{ key: '/', scope: 'library' },
{ key: 'c', scope: 'library' },
{ key: 'f', scope: 'library' },
{ key: 'e', scope: 'library' },
{ key: 'Space', scope: 'player' },
{ key: 'ArrowLeft', scope: 'player' },
{ key: 'ArrowRight', scope: 'player' },
{ key: 'Escape', scope: 'player' },
{ key: 'Escape', scope: 'settings' },
{ key: '[', scope: 'player' },
{ key: ']', scope: 'player' },
];
for (const expected of expectedShortcuts) {
const found = shortcuts.find(s => s.key === expected.key && s.scope === expected.scope);
expect(found, `expected shortcut ${expected.scope}::${expected.key}`).toBeDefined();
}
});
test('should trigger ? shortcut on library screen', async ({ page }) => {
// On library screen
await page.keyboard.press('?');
await expect(page.locator('#shortcuts-modal')).toBeVisible();
});
test('should focus library search for unshifted / without opening shortcut help', async ({ page }) => {
await page.keyboard.press('/');
await expect(page.locator('#lib-filter')).toBeFocused();
await expect(page.locator('#shortcuts-modal')).toHaveCount(0);
});
test('should not open shortcut help for Shift+Slash while typing in search', async ({ page }) => {
await page.locator('#lib-filter').focus();
await page.evaluate(() => {
const input = document.getElementById('lib-filter');
input?.dispatchEvent(new KeyboardEvent('keydown', {
key: '/',
code: 'Slash',
shiftKey: true,
bubbles: true,
cancelable: true,
}));
});
await expect(page.locator('#lib-filter')).toBeFocused();
await expect(page.locator('#shortcuts-modal')).toHaveCount(0);
});
test('Linux Shift+Slash on the library opens help without focusing search behind it (#602)', async ({ page }) => {
// Linux/Electron reports Shift+/ as key='/', code='Slash'. The help
// handler must open the modal AND stop the event so the shortcut
// registry's plain `/` library-search shortcut can't also fire and pull
// focus to #lib-filter behind the modal (regression for the Copilot
// review finding on this PR).
await page.evaluate(() => {
document.dispatchEvent(new KeyboardEvent('keydown', {
key: '/',
code: 'Slash',
shiftKey: true,
bubbles: true,
cancelable: true,
}));
});
await expect(page.locator('#shortcuts-modal')).toBeVisible();
await expect(page.locator('#lib-filter')).not.toBeFocused();
});
test('should trigger ? shortcut on player screen', async ({ page }) => {
await openPlayerWithMockSong(page);
await page.keyboard.press('?');
await expect(page.locator('#shortcuts-modal')).toBeVisible();
await expect(page.locator('#shortcuts-modal')).toContainText('Player');
});
test('should trigger shortcut help on player screen for Linux Electron Shift+Slash event', async ({ page }) => {
await openPlayerWithMockSong(page);
await page.evaluate(() => {
document.dispatchEvent(new KeyboardEvent('keydown', {
key: '/',
code: 'Slash',
shiftKey: true,
bubbles: true,
cancelable: true,
}));
});
await expect(page.locator('#shortcuts-modal')).toBeVisible();
await expect(page.locator('#shortcuts-modal')).toContainText('Player');
});
test('should trigger shortcut help on player screen when visualization picker is focused', async ({ page }) => {
await openPlayerWithMockSong(page);
await page.locator('#viz-picker').focus();
await expect(page.locator('#viz-picker')).toBeFocused();
await page.keyboard.press('?');
await expect(page.locator('#shortcuts-modal')).toBeVisible();
await expect(page.locator('#shortcuts-modal')).toContainText('Player');
});
test('should trigger ? shortcut on settings screen', async ({ page }) => {
// Navigate to settings
await page.click('text=Settings');
await page.waitForSelector('#settings.active', { timeout: 5000 });
await page.keyboard.press('?');
await expect(page.locator('#shortcuts-modal')).toBeVisible();
// Should show Settings section with Esc to go back
await expect(page.locator('#shortcuts-modal')).toContainText('Settings');
await expect(page.locator('#shortcuts-modal')).toContainText('Go back to previous screen');
// Should NOT show Library or Global shortcuts
await expect(page.locator('#shortcuts-modal')).not.toContainText('Focus search');
await expect(page.locator('#shortcuts-modal')).not.toContainText('Show keyboard shortcuts');
});
test('should close modal on Close button', async ({ page }) => {
await page.keyboard.press('?');
const modal = page.locator('#shortcuts-modal');
await expect(modal).toBeVisible();
// Click the close button (SVG icon)
await page.click('#shortcuts-modal button[data-shortcuts-close]');
await expect(modal).not.toBeVisible();
});
test('should unregister shortcut', async ({ page }) => {
const result = await page.evaluate(() => {
// @ts-ignore
window.registerShortcut({
key: 'test-key',
description: 'Test shortcut',
scope: 'global',
handler: () => {}
});
// @ts-ignore
const activePanel = window._panels.get(window.getActiveShortcutPanel());
const beforeUnregister = activePanel ? activePanel.shortcuts.has('global::test-key') : false;
// @ts-ignore
const unregistered = window.unregisterShortcut('test-key');
const afterUnregister = activePanel ? activePanel.shortcuts.has('global::test-key') : false;
return { beforeUnregister, unregistered, afterUnregister };
});
expect(result.beforeUnregister).toBe(true);
expect(result.unregistered).toBe(true);
expect(result.afterUnregister).toBe(false);
});
test('should support condition callbacks', async ({ page }) => {
const result = await page.evaluate(() => {
let conditionMet = false;
// @ts-ignore
window.registerShortcut({
key: 'test-cond',
description: 'Test with condition',
scope: 'global',
condition: () => conditionMet,
// @ts-ignore
handler: () => { window._conditionHandlerCalled = true; }
});
// Test with condition false
// @ts-ignore
window._conditionHandlerCalled = false;
const event1 = new KeyboardEvent('keydown', { key: 'test-cond' });
document.dispatchEvent(event1);
// @ts-ignore
const called1 = window._conditionHandlerCalled;
// Test with condition true
conditionMet = true;
// @ts-ignore
window._conditionHandlerCalled = false;
const event2 = new KeyboardEvent('keydown', { key: 'test-cond' });
document.dispatchEvent(event2);
// @ts-ignore
const called2 = window._conditionHandlerCalled;
// Cleanup
// @ts-ignore
window.unregisterShortcut('test-cond');
return { called1, called2 };
});
expect(result.called1).toBe(false); // Should not fire when condition is false
expect(result.called2).toBe(true); // Should fire when condition is true
});
test('should support modifier key combinations', async ({ page }) => {
const result = await page.evaluate(() => {
let ctrlCalled = false;
let shiftCalled = false;
let noModifierCalled = false;
let sWithoutCtrlCalled = false;
// @ts-ignore
window.registerShortcut({
key: 's',
description: 'Save with Ctrl',
scope: 'global',
modifiers: { ctrl: true },
handler: () => { ctrlCalled = true; }
});
// @ts-ignore
window.registerShortcut({
key: 't',
description: 'Test with Shift',
scope: 'global',
modifiers: { shift: true },
handler: () => { shiftCalled = true; }
});
// @ts-ignore
window.registerShortcut({
key: 'n',
description: 'No modifier',
scope: 'global',
handler: () => { noModifierCalled = true; }
});
// Test S without Ctrl first (should not fire)
const event0 = new KeyboardEvent('keydown', { key: 's' });
document.dispatchEvent(event0);
sWithoutCtrlCalled = ctrlCalled;
// Test Ctrl+S
const event1 = new KeyboardEvent('keydown', { key: 's', ctrlKey: true });
document.dispatchEvent(event1);
// Test Shift+T
const event2 = new KeyboardEvent('keydown', { key: 't', shiftKey: true });
document.dispatchEvent(event2);
// Test N without modifier
const event3 = new KeyboardEvent('keydown', { key: 'n' });
document.dispatchEvent(event3);
// Cleanup
// @ts-ignore
window.unregisterShortcut('s');
// @ts-ignore
window.unregisterShortcut('t');
// @ts-ignore
window.unregisterShortcut('n');
return { ctrlCalled, shiftCalled, noModifierCalled, sWithoutCtrlCalled };
});
expect(result.ctrlCalled).toBe(true);
expect(result.shiftCalled).toBe(true);
expect(result.noModifierCalled).toBe(true);
expect(result.sWithoutCtrlCalled).toBe(false); // Should not fire without Ctrl
});
test('should support panel-specific shortcuts', async ({ page }) => {
const result = await page.evaluate(() => {
// Create a new panel
// @ts-ignore
const panel1 = window.createShortcutPanel('panel-1');
let panelShortcutCalled = false;
let globalShortcutCalled = false;
// Register a panel-specific shortcut
// @ts-ignore
window.setActiveShortcutPanel('panel-1');
// @ts-ignore
window.registerShortcut({
key: 'w',
description: 'Panel-specific action',
scope: 'global',
handler: () => { panelShortcutCalled = true; }
});
// Register a global shortcut in default panel
// @ts-ignore
window.setActiveShortcutPanel('default');
// @ts-ignore
window.registerShortcut({
key: 'g',
description: 'Global action',
scope: 'global',
handler: () => { globalShortcutCalled = true; }
});
// Test panel-specific shortcut (set panel-1 as active)
// @ts-ignore
window.setActiveShortcutPanel('panel-1');
const event1 = new KeyboardEvent('keydown', { key: 'w' });
document.dispatchEvent(event1);
// Test global shortcut (set default as active)
// @ts-ignore
window.setActiveShortcutPanel('default');
const event2 = new KeyboardEvent('keydown', { key: 'g' });
document.dispatchEvent(event2);
// Cleanup
// @ts-ignore
panel1.clearShortcuts();
// @ts-ignore
window.unregisterShortcut('g');
// @ts-ignore
window.setActiveShortcutPanel('default');
return { panelShortcutCalled, globalShortcutCalled };
});
expect(result.panelShortcutCalled).toBe(true);
expect(result.globalShortcutCalled).toBe(true);
});
test('should show panel-specific shortcuts in modal', async ({ page }) => {
const result = await page.evaluate(() => {
// Create a new panel
// @ts-ignore
const panel1 = window.createShortcutPanel('panel-1');
// Register a panel-specific shortcut
// @ts-ignore
window.setActiveShortcutPanel('panel-1');
// @ts-ignore
window.registerShortcut({
key: 'x',
description: 'Panel-specific action',
scope: 'global',
handler: () => {}
});
// Register a shortcut in default panel
// @ts-ignore
window.setActiveShortcutPanel('default');
// @ts-ignore
window.registerShortcut({
key: 'y',
description: 'Default panel action',
scope: 'global',
handler: () => {}
});
// Open the modal (default panel is active)
// @ts-ignore
window._openShortcutsModal();
// Check if the modal exists and contains the panel-specific shortcut.
// Use section-targeted DOM queries so an unrelated string elsewhere in
// the modal can't satisfy these assertions.
const modal = document.getElementById('shortcuts-modal');
let hasPanelSection = false;
let hasShortcut = false;
let hasKey = false;
if (modal) {
const sections = modal.querySelectorAll('section');
for (const section of sections) {
const heading = section.querySelector('h4');
if (heading && heading.textContent.trim() === 'Panel panel-1') {
hasPanelSection = true;
hasShortcut = (section.textContent || '').includes('Panel-specific action');
const kbd = section.querySelector('kbd');
if (kbd && kbd.textContent.trim() === 'x') {
hasKey = true;
}
break;
}
}
}
// The "Default panel action" entry should live in a row in the Global
// section; assert that a row description matches exactly rather than
// matching anywhere in innerHTML (the modal renders rows as <div>s
// containing a description <span> and a key <kbd>).
const hasDefaultShortcut = modal
? Array.from(modal.querySelectorAll('section span')).some(
(s) => (s.textContent || '').trim() === 'Default panel action'
)
: false;
// Cleanup
if (modal) modal.remove();
// @ts-ignore
panel1.clearShortcuts();
// @ts-ignore
window.unregisterShortcut('y');
// @ts-ignore
window.setActiveShortcutPanel('default');
return { hasPanelSection, hasShortcut, hasKey, hasDefaultShortcut };
});
expect(result.hasPanelSection).toBe(true); // Should show "Panel panel-1" section
expect(result.hasShortcut).toBe(true); // Should show the shortcut description
expect(result.hasKey).toBe(true); // Should show the shortcut key
expect(result.hasDefaultShortcut).toBe(true); // Should show default panel shortcut
});
test('should clear panel shortcuts on cleanup', async ({ page }) => {
const result = await page.evaluate(() => {
// Create a new panel
// @ts-ignore
const panel1 = window.createShortcutPanel('panel-1');
// Register panel-specific shortcuts
// @ts-ignore
window.setActiveShortcutPanel('panel-1');
// @ts-ignore
window.registerShortcut({
key: 'x',
description: 'Panel shortcut 1',
scope: 'global',
handler: () => {}
});
// @ts-ignore
window.registerShortcut({
key: 'y',
description: 'Panel shortcut 2',
scope: 'global',
handler: () => {}
});
// Register a global shortcut in default panel (should not be cleared)
// @ts-ignore
window.setActiveShortcutPanel('default');
// @ts-ignore
window.registerShortcut({
key: 'z',
description: 'Global shortcut',
scope: 'global',
handler: () => {}
});
// Clear panel shortcuts
// @ts-ignore
panel1.clearShortcuts();
// Check that panel shortcuts are gone
// @ts-ignore
const hasX = panel1.shortcuts.has('global::x');
// @ts-ignore
const hasY = panel1.shortcuts.has('global::y');
// @ts-ignore
const defaultPanel = window._panels.get('default');
const hasZ = defaultPanel ? defaultPanel.shortcuts.has('global::z') : false;
// Cleanup
// @ts-ignore
window.unregisterShortcut('z');
// @ts-ignore
window.setActiveShortcutPanel('default');
return { hasX, hasY, hasZ };
});
expect(result.hasX).toBe(false); // Panel shortcut should be gone
expect(result.hasY).toBe(false); // Panel shortcut should be gone
expect(result.hasZ).toBe(true); // Global shortcut should still exist
});
test('should match shortcut by e.code (Space)', async ({ page }) => {
// The dispatcher matches against both e.key and e.code so that special
// keys (Space, ArrowLeft, …) registered by their code still fire when the
// browser delivers e.key=' ' / 'ArrowLeft'. Lock that behaviour in.
const result = await page.evaluate(() => {
let calledByCode = false;
let calledByKey = false;
// @ts-ignore
window.registerShortcut({
key: 'Space',
description: 'Test e.code match',
scope: 'global',
// @ts-ignore
handler: () => { window._codeHandlerCalled = (window._codeHandlerCalled || 0) + 1; }
});
// @ts-ignore
window._codeHandlerCalled = 0;
// Real-keyboard event: e.key=' ', e.code='Space'
document.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', code: 'Space' }));
// @ts-ignore
calledByCode = window._codeHandlerCalled === 1;
// Synthetic event by e.key='Space' (legacy-style) should also work
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Space' }));
// @ts-ignore
calledByKey = window._codeHandlerCalled === 2;
// Cleanup
// @ts-ignore
window.unregisterShortcut('Space');
return { calledByCode, calledByKey };
});
expect(result.calledByCode).toBe(true);
expect(result.calledByKey).toBe(true);
});
test('should warn on invalid scope', async ({ page }) => {
const messages: string[] = [];
page.on('console', msg => {
if (msg.type() === 'warning') messages.push(msg.text());
});
await page.evaluate(() => {
// @ts-ignore
window.registerShortcut({
key: 'test-key',
description: 'Test shortcut',
scope: 'invalid-scope',
handler: () => {}
});
});
expect(messages.some(m => m.includes('invalid scope'))).toBe(true);
});
});
test.describe('Debug Helpers', () => {
test('should list shortcuts', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.screen.active');
const result = await page.evaluate(() => {
// @ts-ignore
if (typeof window._listShortcuts === 'function') {
// @ts-ignore
window._listShortcuts();
return true;
}
return false;
});
expect(result).toBe(true);
});
test('should test specific shortcut', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.screen.active');
const result = await page.evaluate(() => {
// @ts-ignore
if (typeof window._testShortcut === 'function') {
// @ts-ignore
window._testShortcut('Space');
return true;
}
return false;
});
expect(result).toBe(true);
});
});
+82
View File
@@ -0,0 +1,82 @@
import { test, expect } from '@playwright/test';
// Progression (spec 010) smoke: nav entries render, the Progress screen shows
// a fresh rank-0 state, the `progression` capability domain has its core
// owner, and equipping a theme toggles the html[data-fb-theme] gate.
// A fresh profile shows the blocking onboarding overlay; onboard via the API
// so nav clicks aren't intercepted (idempotent on an already-onboarded db).
// The 3-step flow requires: (1) profile, (2) path selection, (3) skip calibration.
test.beforeEach(async ({ request }) => {
await request.post('/api/profile', { data: { display_name: 'Smoke Tester' } });
await request.post('/api/progression/paths', { data: { add: ['guitar'] } });
await request.post('/api/progression/onboarding', { data: { action: 'skip' } });
});
test('progress + shop nav entries render and route', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
const progressNav = page.locator('[data-v3-nav="progress"]');
const shopNav = page.locator('[data-v3-nav="shop"]');
await expect(progressNav).toBeVisible();
await expect(shopNav).toBeVisible();
await progressNav.click();
await expect(page.locator('#v3-progress')).toHaveClass(/active/);
// Fresh install: Mastery Rank hero renders (rank value present).
await expect(page.locator('#v3-progress')).toContainText('Mastery Rank');
await expect(page.locator('#v3-progress')).toContainText('Decibels');
await shopNav.click();
await expect(page.locator('#v3-shop')).toHaveClass(/active/);
await expect(page.locator('#v3-shop')).toContainText('Your Decibels');
});
test('progression capability domain is owned by core', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
const result = await page.evaluate(async () => {
const appWindow = window as any;
const inspectCmd = await appWindow.slopsmith.capabilities.command('progression', 'inspect', {
requester: 'browser-smoke',
});
const pipeline = appWindow.slopsmith.capabilities.inspect('progression');
const owner = (pipeline.participants || []).find((p: any) => p.pluginId === 'core.progression');
return {
outcome: inspectCmd.outcome,
masteryRank: inspectCmd.payload ? inspectCmd.payload.mastery_rank : null,
ownerRoles: owner ? owner.roles : [],
// buy-item without user-action authorization must be denied.
deniedBuy: (await appWindow.slopsmith.capabilities.command('progression', 'buy-item', {
requester: 'browser-smoke',
payload: { item_id: 'theme.sunset-strat' },
})).outcome,
};
});
expect(result.outcome).toBe('handled');
expect(typeof result.masteryRank).toBe('number');
expect(result.ownerRoles).toContain('owner');
expect(result.deniedBuy).toBe('denied');
});
test('theme apply toggles the data-fb-theme gate', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
const states = await page.evaluate(() => {
const appWindow = window as any;
const before = document.documentElement.hasAttribute('data-fb-theme');
appWindow.v3Theme.apply({ colors: { bg: '#101010', card: '#202020', text: '#ffffff' } });
const applied = document.documentElement.hasAttribute('data-fb-theme');
appWindow.v3Theme.apply(null);
const cleared = document.documentElement.hasAttribute('data-fb-theme');
return { before, applied, cleared };
});
expect(states.before).toBe(false);
expect(states.applied).toBe(true);
expect(states.cleared).toBe(false);
});
+158
View File
@@ -0,0 +1,158 @@
import { test, expect } from '@playwright/test';
// Issue #686 — a Screen Wake Lock is held only while a song plays so the OS
// screensaver doesn't kick in during windowed-mode playback. We stub
// navigator.wakeLock before app.js runs (headless Chromium rejects a real
// 'screen' request — there is no display) and drive the song:* bus events the
// wake-lock helper listens to. `held` flips true only once a request actually
// resolves and the lock is kept, so the fast play→pause race is observable.
const installWakeLockSpy = () => {
(window as any).__wakeLockSpy = {
requestCount: 0,
releaseCount: 0,
lastType: null as string | null,
held: false,
lastSentinel: null as any,
};
Object.defineProperty(navigator, 'wakeLock', {
configurable: true,
value: {
request(type: string) {
const spy = (window as any).__wakeLockSpy;
spy.requestCount++;
spy.lastType = type;
const listeners: Array<() => void> = [];
const sentinel = {
released: false,
addEventListener(t: string, fn: () => void) {
if (t === 'release') listeners.push(fn);
},
removeEventListener() {},
release() {
if (this.released) return Promise.resolve();
this.released = true;
spy.releaseCount++;
spy.held = false;
listeners.forEach((fn) => fn());
return Promise.resolve();
},
};
spy.lastSentinel = sentinel;
// The lock is only "held" once the request resolves and is kept — model
// that on the microtask so a release that lands first wins the race.
Promise.resolve().then(() => { if (!sentinel.released) spy.held = true; });
return Promise.resolve(sentinel);
},
},
});
};
test.beforeEach(async ({ page }) => {
await page.addInitScript(installWakeLockSpy);
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
await page.waitForFunction(() => typeof (window as any).slopsmith?.emit === 'function');
});
test('acquires a single screen wake lock on play and releases on pause', async ({ page }) => {
// The audio 'play' listener emits song:play AND song:resume synchronously;
// only one 'screen' lock should be requested (the in-flight guard must hold
// before the first request resolves).
await page.evaluate(() => {
(window as any).slopsmith.emit('song:play');
(window as any).slopsmith.emit('song:resume');
});
await page.waitForFunction(() => (window as any).__wakeLockSpy.held === true);
expect(await page.evaluate(() => (window as any).__wakeLockSpy.requestCount)).toBe(1);
expect(await page.evaluate(() => (window as any).__wakeLockSpy.lastType)).toBe('screen');
await page.evaluate(() => (window as any).slopsmith.emit('song:pause'));
await page.waitForFunction(() => (window as any).__wakeLockSpy.held === false);
});
test('song:ended and song:stop release the wake lock', async ({ page }) => {
await page.evaluate(() => (window as any).slopsmith.emit('song:play'));
await page.waitForFunction(() => (window as any).__wakeLockSpy.held === true);
await page.evaluate(() => (window as any).slopsmith.emit('song:ended'));
await page.waitForFunction(() => (window as any).__wakeLockSpy.held === false);
await page.evaluate(() => (window as any).slopsmith.emit('song:play'));
await page.waitForFunction(() => (window as any).__wakeLockSpy.held === true);
await page.evaluate(() => (window as any).slopsmith.emit('song:stop'));
await page.waitForFunction(() => (window as any).__wakeLockSpy.held === false);
});
test('fast play→pause before the request resolves leaves no stale lock', async ({ page }) => {
const before = await page.evaluate(() => (window as any).__wakeLockSpy.requestCount);
// Pause arrives while navigator.wakeLock.request is still in flight — the
// resolved sentinel must release itself instead of being held stale.
await page.evaluate(() => {
(window as any).slopsmith.emit('song:play');
(window as any).slopsmith.emit('song:pause');
});
await page.waitForTimeout(150);
expect(await page.evaluate(() => (window as any).__wakeLockSpy.held)).toBe(false);
expect(await page.evaluate((n) => (window as any).__wakeLockSpy.requestCount === n + 1, before)).toBe(true);
expect(await page.evaluate(() => (window as any).__wakeLockSpy.lastSentinel.released)).toBe(true);
});
test('re-acquires the wake lock when the UA releases it while still playing', async ({ page }) => {
await page.evaluate(() => (window as any).slopsmith.emit('song:play'));
await page.waitForFunction(() => (window as any).__wakeLockSpy.held === true);
const before = await page.evaluate(() => (window as any).__wakeLockSpy.requestCount);
// Simulate the UA releasing the lock (power policy / page hide) while
// playback continues; the release handler re-acquires when still visible.
await page.evaluate(() => {
(window as any).slopsmith.isPlaying = true;
(window as any).__wakeLockSpy.lastSentinel.release();
});
await page.waitForFunction((n) => (window as any).__wakeLockSpy.requestCount === n + 1 && (window as any).__wakeLockSpy.held === true, before);
// After a real pause there must be no further re-acquire churn.
await page.evaluate(() => (window as any).slopsmith.emit('song:pause'));
await page.waitForFunction(() => (window as any).__wakeLockSpy.held === false);
const settled = await page.evaluate(() => (window as any).__wakeLockSpy.requestCount);
await page.waitForTimeout(150);
expect(await page.evaluate(() => (window as any).__wakeLockSpy.requestCount)).toBe(settled);
});
test('drives the slopsmith-desktop native power bridge, deduped and visibility-gated', async ({ page }) => {
// In the packaged Electron app navigator.wakeLock is unreliable, so the
// helper also drives window.slopsmithDesktop.power.setScreenAwake — to
// exactly (wanted && visible), emitting only on change. Inject a spy bridge
// before app.js runs and assert it tracks playback without duplicate starts.
await page.addInitScript(() => {
(window as any).__bridgeCalls = [];
(window as any).slopsmithDesktop = {
power: { setScreenAwake: (keep: boolean) => (window as any).__bridgeCalls.push(keep) },
};
});
await page.reload();
await page.waitForSelector('.screen.active', { timeout: 10000 });
await page.waitForFunction(() => typeof (window as any).slopsmith?.emit === 'function');
// song:play + song:resume fire together but must produce a single `true`.
await page.evaluate(() => {
(window as any).slopsmith.emit('song:play');
(window as any).slopsmith.emit('song:resume');
});
await page.waitForFunction(() => (window as any).__bridgeCalls.filter((x: boolean) => x === true).length === 1);
await page.evaluate(() => (window as any).slopsmith.emit('song:pause'));
await page.waitForFunction(() => (window as any).__bridgeCalls.filter((x: boolean) => x === false).length === 1);
// Hidden while playing → bridge OFF (a minimized window mustn't keep the
// whole display awake); restoring visibility while playing turns it back ON.
await page.evaluate(() => (window as any).slopsmith.emit('song:play'));
await page.waitForFunction(() => (window as any).__bridgeCalls[(window as any).__bridgeCalls.length - 1] === true);
await page.evaluate(() => {
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' });
document.dispatchEvent(new Event('visibilitychange'));
});
await page.waitForFunction(() => (window as any).__bridgeCalls[(window as any).__bridgeCalls.length - 1] === false);
await page.evaluate(() => {
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible' });
document.dispatchEvent(new Event('visibilitychange'));
});
await page.waitForFunction(() => (window as any).__bridgeCalls[(window as any).__bridgeCalls.length - 1] === true);
});
+48
View File
@@ -0,0 +1,48 @@
"""Shared pytest fixtures for the slopsmith test suite."""
import logging
import pytest
import structlog
_LOGGING_NAMES = ("slopsmith", "uvicorn", "uvicorn.error", "uvicorn.access")
@pytest.fixture()
def isolate_logging():
"""Restore slopsmith / uvicorn logger state after each test.
Saves handlers, level, and propagate flag before the test runs and
restores all three on teardown. Import into any test module that calls
configure_logging() so mutations don't bleed across tests.
"""
saved = {}
for name in _LOGGING_NAMES:
lg = logging.getLogger(name)
saved[name] = (
list(lg.handlers), # snapshot the handler list
lg.level,
lg.propagate,
)
yield
for name in _LOGGING_NAMES:
lg = logging.getLogger(name)
original_handlers, original_level, original_propagate = saved[name]
# Close and remove any handlers that were added during the test.
for h in list(lg.handlers):
if h not in original_handlers:
lg.removeHandler(h)
h.close()
# Remove any original handlers that may have been removed during the test
# so we can add them back cleanly.
for h in list(lg.handlers):
lg.removeHandler(h)
# Reattach the original handlers.
for h in original_handlers:
lg.addHandler(h)
lg.setLevel(original_level)
lg.propagate = original_propagate
structlog.reset_defaults()
Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,20 @@
{
"id": "broken_capability_metadata",
"name": "Broken Capability Metadata",
"nav": { "label": "Broken", "screen": "plugin-broken" },
"screen": "screen.html",
"settings": { "html": "settings.html" },
"standards": ["capability-pipelines.v1"],
"capabilities": {
"stems": {
"roles": ["owner", "not-a-role"],
"commands": ["mute", 12],
"events": "not-a-list",
"mode": "teleport",
"compatibility": "magic",
"ownership": "surprise-owner",
"safety": "dangerous",
"version": 1
}
}
}
@@ -0,0 +1,17 @@
{
"id": "future_capabilities",
"name": "Future Capabilities",
"standards": ["capability-pipelines.v999"],
"capabilities": {
"stems": {
"roles": ["owner", "provider"],
"commands": ["mute"],
"events": [],
"mode": "active",
"compatibility": "none",
"ownership": "exclusive-owner",
"safety": "safe",
"version": 999
}
}
}
@@ -0,0 +1,18 @@
{
"id": "stems",
"name": "Stems",
"standards": ["capability-pipelines.v1", "plugin-runtime-idempotent.v1"],
"capabilities": {
"stems": {
"roles": ["owner", "provider"],
"commands": ["mute", "restore", "inspect"],
"events": ["claim:created", "claim:released", "stems.ready"],
"description": "Owns stem mute and restore coordination for integrated audio plugins.",
"mode": "active",
"compatibility": "none",
"ownership": "exclusive-owner",
"safety": "safe",
"version": 1
}
}
}
@@ -0,0 +1,17 @@
{
"id": "nam_tone",
"name": "NAM Tone",
"standards": ["capability-pipelines.v1", "plugin-runtime-idempotent.v1"],
"capabilities": {
"stems": {
"roles": ["requester", "observer"],
"commands": ["mute", "restore", "inspect"],
"events": ["claim:created", "claim:released", "stems.manual-unmute"],
"mode": "active",
"compatibility": "legacy-window-shim",
"ownership": "requester-only",
"safety": "safe",
"version": 1
}
}
}
+144
View File
@@ -0,0 +1,144 @@
// Verify the alpha-build heads-up banner: markup is present in
// static/index.html and `_updateAlphaWarningBanner(version)` in
// static/app.js toggles its visibility correctly per the version string.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const INDEX_HTML = path.join(__dirname, '..', '..', 'static', 'index.html');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
test('index.html ships the alpha-warning banner inside the library section', () => {
const html = fs.readFileSync(INDEX_HTML, 'utf8');
// Locate the library section so we can prove the banner lives there
// and not stuck somewhere it would render off-screen.
const libStart = html.indexOf('id="library-section"');
assert.ok(libStart !== -1, 'library-section anchor not found in index.html');
const libEnd = html.indexOf('</section>', libStart);
assert.ok(libEnd !== -1, 'library-section closing tag not found');
const librarySection = html.slice(libStart, libEnd);
assert.match(
librarySection,
/id="alpha-warning-banner"/,
'alpha-warning-banner must live inside library-section',
);
// `hidden` Tailwind class must be present so the banner stays invisible
// until JS opts it in via classList.toggle('hidden', false).
assert.match(
librarySection,
/id="alpha-warning-banner"[^>]*\bhidden\b/,
'alpha-warning-banner must start with the `hidden` class',
);
// role="status" gives screen readers a non-interrupting announcement
// rather than treating it as decorative.
assert.match(
librarySection,
/id="alpha-warning-banner"[^>]*role="status"/,
'alpha-warning-banner must declare role="status"',
);
});
// Brace-balanced extraction so the function body — including nested
// object literals, template strings, or future guards — survives a
// naive regex stopping at the first `}`.
function extractFunctionSource(src, name) {
const sig = `function ${name}`;
const start = src.indexOf(sig);
assert.ok(start !== -1, `function declaration '${name}' not found`);
const openBrace = src.indexOf('{', start);
assert.ok(openBrace !== -1, `opening brace after '${name}' not found`);
let depth = 1;
let i = openBrace + 1;
while (i < src.length && depth > 0) {
const ch = src[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
assert.ok(depth === 0, `unbalanced braces in function '${name}'`);
return src.slice(start, i);
}
// Build a fresh sandbox per test so cross-case state can't leak —
// classList.toggle mutations on one fake banner can't bias the next case.
function setupSandbox({ bannerExists = true } = {}) {
const classes = new Set(['hidden']);
const banner = bannerExists ? {
classList: {
// Mirror DOMTokenList.toggle: when `force` is provided, it
// sets/removes deterministically; the production code relies
// on that signature, so test it the same way.
toggle: (cls, force) => {
if (force === true) classes.add(cls);
else if (force === false) classes.delete(cls);
else if (classes.has(cls)) classes.delete(cls);
else classes.add(cls);
},
},
} : null;
const sandbox = {
document: {
getElementById: (id) => (id === 'alpha-warning-banner' ? banner : null),
},
};
vm.createContext(sandbox);
const src = fs.readFileSync(APP_JS, 'utf8');
const fnSrc = extractFunctionSource(src, '_updateAlphaWarningBanner');
// Hoist into the sandbox global so the test can call it like a
// regular function. The production declaration is inside an IIFE,
// but the function body itself is independent of that closure.
vm.runInContext(`${fnSrc}\nglobalThis.__update = _updateAlphaWarningBanner;`, sandbox);
return { update: sandbox.__update, classes };
}
test('unhides the banner on an alpha version string', () => {
const { update, classes } = setupSandbox();
update('0.2.9-alpha.5');
assert.equal(classes.has('hidden'), false, 'banner should be visible on alpha versions');
});
test('keeps the banner hidden on a stable version string', () => {
const { update, classes } = setupSandbox();
update('0.2.9');
assert.equal(classes.has('hidden'), true, 'banner must stay hidden on stable versions');
});
test('alpha detection is case-insensitive', () => {
const { update, classes } = setupSandbox();
update('0.2.9-ALPHA.5');
assert.equal(classes.has('hidden'), false, 'uppercase ALPHA should also trigger the banner');
});
test('does not confuse beta or rc with alpha', () => {
for (const v of ['0.2.9-beta.1', '0.2.9-rc.2', '1.0.0']) {
const { update, classes } = setupSandbox();
update(v);
assert.equal(
classes.has('hidden'),
true,
`banner must remain hidden on non-alpha version '${v}'`,
);
}
});
test('handles non-string or missing version input gracefully', () => {
for (const v of [null, undefined, 42, {}, '']) {
const { update, classes } = setupSandbox();
update(v);
assert.equal(
classes.has('hidden'),
true,
`banner must stay hidden when version is ${JSON.stringify(v)}`,
);
}
});
test('no-ops without throwing when the banner element is absent', () => {
const { update } = setupSandbox({ bannerExists: false });
assert.doesNotThrow(() => update('0.2.9-alpha.5'));
});
+63
View File
@@ -0,0 +1,63 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const tourScript = path.join(__dirname, '..', '..', 'plugins', 'app_tour_library', 'script.js');
const SRC = fs.readFileSync(tourScript, 'utf8');
function extractBlock(src, signature) {
const start = src.indexOf(signature);
assert.ok(start !== -1, `signature '${signature}' not found`);
const openBrace = src.indexOf('{', start);
assert.ok(openBrace !== -1, `opening brace after '${signature}' not found`);
let depth = 1;
let i = openBrace + 1;
while (i < src.length && depth > 0) {
const ch = src[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
assert.ok(depth === 0, `unbalanced braces after '${signature}'`);
return src.slice(start, i);
}
test('library tour registers dynamic steps for provider-aware instructions', () => {
const register = extractBlock(SRC, 'function _register()');
assert.match(register, /buildSteps:\s*_buildSteps/,
'Library tour registration must use buildSteps so provider instructions can be conditional');
});
test('library tour adds provider step only when multiple browsable providers exist', () => {
const providerCheck = extractBlock(SRC, 'async function _hasMultipleProviders()');
assert.match(providerCheck, /fetch\(\s*['"]\/api\/library\/providers['"]\s*\)/,
'Provider count must come from the library providers endpoint');
assert.match(providerCheck, /filter\(_isBrowsableProvider\)/,
'Provider count must ignore non-browsable providers');
assert.match(providerCheck, /providers\.length\s*>\s*1/,
'Provider step must require more than one provider');
const buildSteps = extractBlock(SRC, 'async function _buildSteps()');
assert.match(buildSteps, /!\(await\s+_hasMultipleProviders\(\)\)/,
'buildSteps must skip the provider step unless multiple providers are present');
assert.match(buildSteps, /splice\([\s\S]*PROVIDER_STEP/,
'buildSteps must insert the provider instruction into the tour');
});
test('library tour inserts provider step after the search step', () => {
const buildSteps = extractBlock(SRC, 'async function _buildSteps()');
assert.match(buildSteps, /insertAt\s*===\s*-1\s*\?\s*1\s*:\s*insertAt\s*\+\s*1/,
'Provider step must be inserted after the search step when search is found');
assert.match(buildSteps, /splice\([\s\S]*PROVIDER_STEP/,
'buildSteps must splice PROVIDER_STEP into the tour');
});
test('provider tour step targets the library provider selector', () => {
assert.match(SRC, /id:\s*['"]library-provider['"]/,
'Provider step must have a stable id');
assert.match(SRC, /selector:\s*['"]#lib-provider['"]/,
'Provider step must spotlight the provider selector');
assert.match(SRC, /waitFor:\s*['"]#lib-provider['"]/,
'Provider step must wait for the selector before showing');
});
+921
View File
@@ -0,0 +1,921 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { loadAudioEffects, diagnosticsSnapshot } = require('./audio_effects_test_harness');
function registerProvider(api, overrides = {}) {
return api.dispatch({
capability: 'audio-effects',
command: 'register-provider',
source: 'test',
payload: {
providerId: overrides.providerId || 'rig-builder',
pluginId: overrides.pluginId || 'rig_builder',
routeKey: overrides.routeKey || 'desktop-main',
priority: overrides.priority == null ? 40 : overrides.priority,
operations: overrides.operations || ['chain.resolve', 'segment.activate', 'stage.set-bypass', 'stage.set-parameter'],
requests: overrides.requests || [],
operationHandlers: overrides.operationHandlers || {
'chain.resolve': () => ({
outcome: 'handled',
plan: {
schema: 'slopsmith.audio_effects.chain_plan.v1',
planId: 'plan-1',
routeKey: 'desktop-main',
providerId: overrides.providerId || 'rig-builder',
stages: [
{ stageId: 'pre-1', kind: 'nam', role: 'pre-pedal', assetRef: 'provider:nam:pre', bypassed: false },
{ stageId: 'amp-1', kind: 'nam', role: 'amp', assetRef: 'provider:nam:amp', bypassed: false },
{ stageId: 'cab-1', kind: 'ir', role: 'cab', assetRef: 'provider:ir:cab', bypassed: false },
],
segments: [{ segmentId: 'ToneA', stageIds: ['pre-1', 'amp-1', 'cab-1'], stageBypass: { 'pre-1': true, 'amp-1': false } }],
summary: { stageCount: 3, kinds: ['nam', 'ir'], assetRefs: ['provider:nam:amp'] },
},
summary: { stageCount: 3, categoryCount: 3, assetRefs: ['provider:nam:amp'] },
}),
'segment.activate': () => ({ outcome: 'handled', summary: { active: true } }),
'stage.set-bypass': () => ({ outcome: 'handled', summary: { bypassed: true } }),
'stage.set-parameter': () => ({ outcome: 'handled', summary: { changed: true } }),
},
},
});
}
test('audio-effects host registers active domain and contributes diagnostics', () => {
const window = loadAudioEffects();
const api = window.slopsmith.capabilities;
const pipeline = api.inspect('audio-effects');
const diagnostics = window.slopsmith.diagnostics.snapshotContributions();
assert.equal(pipeline.review.lifecycle, 'active');
assert.equal(pipeline.participants.some(p => p.pluginId === 'core.audio.effects' && p.roles.includes('owner')), true);
assert.equal(diagnostics['audio-effects'].schema, 'slopsmith.audio_effects.diagnostics.v1');
});
test('audio-effects runtime providers and executors are capability participants', async () => {
const window = loadAudioEffects();
const api = window.slopsmith.capabilities;
await registerProvider(api, {
providerId: 'nam-tone',
pluginId: 'nam-tone',
routeKey: 'live-guitar',
operations: ['chain.resolve', 'chain.inspect', 'stage.set-bypass'],
requests: ['list-mappings', 'upsert-mapping'],
});
const executor = await api.dispatch({
capability: 'audio-effects',
command: 'register-executor',
source: 'test',
payload: {
executorId: 'nam-tone-browser-wasm',
pluginId: 'nam-tone',
routeKey: 'live-guitar',
providerIds: ['nam-tone'],
supportedKinds: ['nam', 'ir'],
operations: ['loadChainPlan'],
},
});
const pipeline = api.inspect('audio-effects');
const participant = pipeline.participants.find(item => item.pluginId === 'nam-tone');
assert.equal(executor.outcome, 'handled');
assert.ok(participant);
assert.deepEqual([...participant.roles].sort(), ['executor', 'provider', 'requester']);
assert.equal(participant.runtime, true);
assert.equal(participant.safety, 'sensitive');
assert.equal(participant.ownership, 'multi-provider');
assert.deepEqual(Array.from(participant.requests), ['list-mappings', 'upsert-mapping']);
assert.equal(participant.operations.includes('chain.resolve'), true);
assert.equal(participant.operations.includes('executor.load-chain-plan'), true);
});
test('unregistering a provider drops its role/operations and clearing all removes the participant', async () => {
const window = loadAudioEffects();
const api = window.slopsmith.capabilities;
await registerProvider(api, {
providerId: 'nam-tone',
pluginId: 'nam-tone',
routeKey: 'live-guitar',
operations: ['chain.resolve', 'chain.inspect', 'stage.set-bypass'],
requests: ['list-mappings'],
});
await api.dispatch({
capability: 'audio-effects',
command: 'register-executor',
source: 'test',
payload: {
executorId: 'nam-tone-browser-wasm',
pluginId: 'nam-tone',
routeKey: 'live-guitar',
providerIds: ['nam-tone'],
supportedKinds: ['nam', 'ir'],
operations: ['loadChainPlan'],
},
});
// Unregister only the provider; the executor for the same plugin remains.
await api.dispatch({
capability: 'audio-effects',
command: 'unregister-provider',
source: 'test',
payload: { providerId: 'nam-tone', pluginId: 'nam-tone' },
});
const afterProvider = api.inspect('audio-effects').participants.find(item => item.pluginId === 'nam-tone');
assert.ok(afterProvider, 'participant should survive while the executor is still registered');
// The merge-only registry would otherwise keep the stale provider role/operations/requests.
assert.deepEqual([...afterProvider.roles].sort(), ['executor']);
assert.equal(afterProvider.operations.includes('chain.resolve'), false);
assert.equal(afterProvider.operations.includes('chain.inspect'), false);
assert.equal(afterProvider.operations.includes('executor.load-chain-plan'), true);
assert.deepEqual(Array.from(afterProvider.requests), []);
// Removing the last executor must remove the participant entirely.
await api.dispatch({
capability: 'audio-effects',
command: 'unregister-executor',
source: 'test',
payload: { executorId: 'nam-tone-browser-wasm', pluginId: 'nam-tone' },
});
const afterAll = api.inspect('audio-effects').participants.find(item => item.pluginId === 'nam-tone');
assert.equal(afterAll, undefined, 'participant should be removed once no providers or executors remain');
});
test('host runtime overlay preserves a plugin-declared audio-effects manifest entry', async () => {
const window = loadAudioEffects();
const api = window.slopsmith.capabilities;
// The plugin declares its own audio-effects participancy in its manifest before any runtime
// registration goes through the host.
api.registerParticipant('rig_builder', {
'audio-effects': {
roles: ['observer'],
operations: ['chain.inspect'],
description: 'Manifest-declared audio-effects observer.',
},
});
await registerProvider(api, { providerId: 'rig-builder', pluginId: 'rig_builder', routeKey: 'desktop-main' });
const withProvider = api.inspect('audio-effects').participants.find(item => item.pluginId === 'rig_builder');
assert.ok(withProvider);
// Manifest role/operation survive alongside the host-added provider overlay.
assert.equal(withProvider.roles.includes('observer'), true, 'manifest role must survive host sync');
assert.equal(withProvider.roles.includes('provider'), true);
assert.equal(withProvider.operations.includes('chain.inspect'), true);
// Unregistering the runtime provider must leave the manifest declaration intact, not wipe it.
await api.dispatch({
capability: 'audio-effects',
command: 'unregister-provider',
source: 'test',
payload: { providerId: 'rig-builder', pluginId: 'rig_builder' },
});
const afterProvider = api.inspect('audio-effects').participants.find(item => item.pluginId === 'rig_builder');
assert.ok(afterProvider, 'manifest-declared participant must remain after the host overlay is removed');
assert.deepEqual([...afterProvider.roles].sort(), ['observer']);
assert.equal(afterProvider.operations.includes('chain.inspect'), true);
assert.equal(afterProvider.roles.includes('provider'), false, 'stale host role must be cleared');
});
test('select-chain requires user action and records selected provider', async () => {
const window = loadAudioEffects();
const api = window.slopsmith.capabilities;
await registerProvider(api);
const denied = await api.dispatch({ capability: 'audio-effects', command: 'select-chain', source: 'test', payload: { routeKey: 'desktop-main' } });
const selected = await api.dispatch({ capability: 'audio-effects', command: 'select-chain', source: 'test', payload: { routeKey: 'desktop-main', authorization: 'user-action', requesterId: 'spoofed-plugin' } });
const route = await api.dispatch({ capability: 'audio-effects', command: 'inspect-route', source: 'test', payload: { routeKey: 'desktop-main' } });
assert.equal(denied.outcome, 'user-action-required');
assert.equal(selected.outcome, 'handled');
assert.equal(selected.payload.route.providerId, 'rig-builder');
assert.equal(route.payload.route.state, 'selected');
assert.equal(JSON.stringify(diagnosticsSnapshot(window)).includes('spoofed-plugin'), false);
});
test('resolve-plan calls selected provider and returns constrained plan without storing raw payload in diagnostics', async () => {
const window = loadAudioEffects();
const api = window.slopsmith.capabilities;
await registerProvider(api);
await api.dispatch({ capability: 'audio-effects', command: 'select-chain', source: 'test', payload: { routeKey: 'desktop-main', authorization: 'user-action' } });
const resolved = await api.dispatch({ capability: 'audio-effects', command: 'resolve-plan', source: 'nam_tone', payload: { routeKey: 'desktop-main', target: { settingsKey: 'settings-v1-abc1234' } } });
const snapshot = diagnosticsSnapshot(window);
const encoded = JSON.stringify(snapshot);
assert.equal(resolved.outcome, 'handled');
assert.equal(resolved.payload.plan.schema, 'slopsmith.audio_effects.chain_plan.v1');
assert.equal(resolved.payload.plan.stages.length, 3);
assert.deepEqual(JSON.parse(JSON.stringify(resolved.payload.plan.segments[0].stageBypass)), { 'pre-1': true, 'amp-1': false });
assert.equal(snapshot.routes[0].state, 'resolved');
assert.equal(snapshot.routes[0].activePlanId, 'plan-1');
assert.equal(encoded.includes('provider:nam:amp'), false);
assert.equal(encoded.includes('assetRef'), false);
assert.equal(encoded.includes('categoryCount'), false);
});
test('resolve-plan rejects raw file paths and records fallback state', async () => {
const window = loadAudioEffects();
const api = window.slopsmith.capabilities;
await registerProvider(api, {
operationHandlers: {
'chain.resolve': () => ({
outcome: 'handled',
plan: {
schema: 'slopsmith.audio_effects.chain_plan.v1',
planId: 'bad-plan',
routeKey: 'desktop-main',
providerId: 'rig-builder',
stages: [{ stageId: 'amp', kind: 'nam', role: 'amp', assetRef: '/Users/example/private/amp.nam' }],
},
}),
},
});
const resolved = await api.dispatch({ capability: 'audio-effects', command: 'resolve-plan', source: 'nam_tone', payload: { routeKey: 'desktop-main' } });
const route = await api.dispatch({ capability: 'audio-effects', command: 'inspect-route', source: 'test', payload: { routeKey: 'desktop-main' } });
const encoded = JSON.stringify(diagnosticsSnapshot(window));
assert.equal(resolved.outcome, 'failed');
assert.match(resolved.reason, /invalid/i);
assert.equal(route.payload.route.state, 'fallback');
assert.equal(encoded.includes('/Users/example'), false);
assert.equal(encoded.includes('amp.nam'), false);
});
test('load-plan calls trusted executor with provider-private assets without diagnostic leakage', async () => {
const window = loadAudioEffects();
const api = window.slopsmith.capabilities;
let executorRequest = null;
window.slopsmithDesktop = {
audioEffects: {
loadChainPlan(request) {
executorRequest = request;
return { outcome: 'handled', status: 'loaded', payload: { slotsLoaded: 1 } };
},
},
};
await registerProvider(api, {
operationHandlers: {
'chain.resolve': () => ({
outcome: 'handled',
plan: {
schema: 'slopsmith.audio_effects.chain_plan.v1',
planId: 'private-plan',
routeKey: 'desktop-main',
providerId: 'rig-builder',
stages: [{ stageId: 'amp', kind: 'nam', role: 'amp', assetRef: 'provider:asset:amp' }],
},
assets: {
'provider:asset:amp': { kind: 'nam', path: '/Users/example/private/amp.nam', stateBase64: 'secret-state' },
},
summary: { stageCount: 1 },
}),
},
});
const loaded = await api.dispatch({
capability: 'audio-effects',
command: 'load-plan',
source: 'nam_tone',
payload: {
routeKey: 'desktop-main',
authorization: 'playback-session',
target: { presetRef: 'safe-ref' },
options: { preloadMute: { targetGain: 4, holdMs: 25 }, gains: { input: 8, chain: 4 }, startAudio: true },
},
});
const snapshot = diagnosticsSnapshot(window);
const encoded = JSON.stringify(snapshot);
assert.equal(loaded.outcome, 'handled');
assert.equal(executorRequest.authorization, 'playback-session');
assert.equal(executorRequest.plan.planId, 'private-plan');
assert.equal(executorRequest.assets['provider:asset:amp'].path, '/Users/example/private/amp.nam');
assert.deepEqual(JSON.parse(JSON.stringify(executorRequest.options)), { preloadMute: { enabled: true, dryDuringLoad: true, targetGain: 4, holdMs: 25 }, gains: { input: 8, chain: 4 }, startAudio: true });
assert.equal(snapshot.routes[0].state, 'loaded');
assert.equal(encoded.includes('/Users/example'), false);
assert.equal(encoded.includes('secret-state'), false);
assert.equal(encoded.includes('provider:asset:amp'), false);
});
test('route gain and release delegate to the selected executor', async () => {
const window = loadAudioEffects();
const api = window.slopsmith.capabilities;
const calls = [];
window.slopsmithDesktop = {
audioEffects: {
loadChainPlan() { calls.push(['load']); return { outcome: 'handled', status: 'loaded', payload: { slotsLoaded: 1 } }; },
setRouteGain(request) { calls.push(['gain', request.gains]); return { outcome: 'handled', payload: { gains: request.gains } }; },
releaseRoute(request) { calls.push(['release', request.routeKey]); return { outcome: 'handled', payload: { released: true } }; },
},
};
await registerProvider(api);
const loaded = await api.dispatch({ capability: 'audio-effects', command: 'load-plan', source: 'test', payload: { routeKey: 'desktop-main', authorization: 'playback-session' } });
const gained = await window.slopsmith.audioEffects.setRouteGain({ routeKey: 'desktop-main', authorization: 'playback-session', gains: { input: 3, chain: 2 } });
const released = await window.slopsmith.audioEffects.releaseRoute({ routeKey: 'desktop-main', authorization: 'playback-session' });
const inspected = await window.slopsmith.audioEffects.inspectRoute({ routeKey: 'desktop-main' });
assert.equal(loaded.outcome, 'handled');
assert.equal(gained.outcome, 'handled');
assert.equal(released.outcome, 'handled');
assert.equal(inspected.payload.route, null);
assert.deepEqual(JSON.parse(JSON.stringify(calls)), [['load'], ['gain', { input: 3, chain: 2 }], ['release', 'desktop-main']]);
});
test('provider unregister clears route plan and executor state', async () => {
const window = loadAudioEffects();
const api = window.slopsmith.capabilities;
window.slopsmithDesktop = {
audioEffects: {
loadChainPlan() { return { outcome: 'handled', status: 'loaded', payload: { slotsLoaded: 1 } }; },
},
};
await registerProvider(api);
const loaded = await api.dispatch({
capability: 'audio-effects',
command: 'load-plan',
source: 'nam_tone',
payload: { routeKey: 'desktop-main', authorization: 'playback-session' },
});
assert.equal(loaded.outcome, 'handled');
const unregistered = await api.dispatch({
capability: 'audio-effects',
command: 'unregister-provider',
source: 'rig_builder',
payload: { pluginId: 'rig_builder', providerId: 'rig-builder' },
});
const route = await api.dispatch({ capability: 'audio-effects', command: 'inspect-route', source: 'test', payload: { routeKey: 'desktop-main' } });
assert.equal(unregistered.outcome, 'handled');
assert.equal(route.payload.route.state, 'provider-unavailable');
assert.equal(route.payload.route.activePlanId, '');
assert.equal(route.payload.route.activeSegmentId, '');
assert.equal(route.payload.route.executorId, '');
assert.equal(JSON.stringify(route.payload.route.planSummary), '{}');
});
test('registry caps still allow provider and executor refreshes', async () => {
const window = loadAudioEffects();
const api = window.slopsmith.capabilities;
for (let i = 0; i < 50; i++) {
const result = await registerProvider(api, { providerId: `provider-${i}`, pluginId: `plugin-${i}` });
assert.equal(result.outcome, 'handled');
}
const newProvider = await registerProvider(api, { providerId: 'provider-overflow', pluginId: 'plugin-overflow' });
const refreshedProvider = await registerProvider(api, { providerId: 'provider-0', pluginId: 'plugin-0', priority: 99 });
assert.equal(newProvider.outcome, 'failed');
assert.equal(refreshedProvider.outcome, 'handled');
assert.equal(refreshedProvider.payload.provider.priority, 99);
for (let i = 0; i < 20; i++) {
const result = await api.dispatch({
capability: 'audio-effects',
command: 'register-executor',
source: 'test',
payload: { executorId: `executor-${i}`, pluginId: `executor-plugin-${i}`, routeKey: 'desktop-main', supportedKinds: ['nam'] },
});
assert.equal(result.outcome, 'handled');
}
const newExecutor = await api.dispatch({
capability: 'audio-effects',
command: 'register-executor',
source: 'test',
payload: { executorId: 'executor-overflow', pluginId: 'executor-plugin-overflow', routeKey: 'desktop-main' },
});
const refreshedExecutor = await api.dispatch({
capability: 'audio-effects',
command: 'register-executor',
source: 'test',
payload: { executorId: 'executor-0', pluginId: 'executor-plugin-0', routeKey: 'desktop-main', priority: 77, supportedKinds: ['ir'] },
});
assert.equal(newExecutor.outcome, 'failed');
assert.equal(refreshedExecutor.outcome, 'handled');
assert.equal(refreshedExecutor.payload.executor.priority, 77);
assert.equal(JSON.stringify(refreshedExecutor.payload.executor.supportedKinds), '["ir"]');
});
test('route restore preserves loaded state when an executor remains active', async () => {
const window = loadAudioEffects();
const api = window.slopsmith.capabilities;
window.slopsmithDesktop = {
audioEffects: {
loadChainPlan() { return { outcome: 'handled', status: 'loaded', payload: { slotsLoaded: 1 } }; },
},
};
await registerProvider(api);
const loaded = await api.dispatch({ capability: 'audio-effects', command: 'load-plan', source: 'nam_tone', payload: { routeKey: 'desktop-main', authorization: 'playback-session' } });
const bypassed = await api.dispatch({ capability: 'audio-effects', command: 'bypass', source: 'test', payload: { routeKey: 'desktop-main', authorization: 'user-action' } });
const restored = await api.dispatch({ capability: 'audio-effects', command: 'restore', source: 'test', payload: { routeKey: 'desktop-main', authorization: 'user-action' } });
assert.equal(loaded.payload.route.state, 'loaded');
assert.equal(bypassed.payload.route.state, 'bypassed');
assert.equal(restored.payload.route.state, 'loaded');
assert.equal(restored.payload.route.executorId, loaded.payload.route.executorId);
});
test('load-plan uses a registered compatible executor when Desktop is unavailable', async () => {
const window = loadAudioEffects();
const api = window.slopsmith.capabilities;
let executorRequest = null;
await registerProvider(api, {
providerId: 'nam-tone',
pluginId: 'nam_tone',
operationHandlers: {
'chain.resolve': request => ({
outcome: 'handled',
plan: {
schema: 'slopsmith.audio_effects.chain_plan.v1',
planId: 'browser-plan',
routeKey: request.routeKey,
providerId: 'nam-tone',
stages: [{ stageId: 'amp', kind: 'nam', role: 'amp', assetRef: 'provider:asset:browser-amp' }],
},
assets: {
'provider:asset:browser-amp': { kind: 'nam', browserFile: 'clean.json', path: '/Users/example/private/clean.nam' },
},
summary: { stageCount: 1 },
}),
},
});
await api.dispatch({
capability: 'audio-effects',
command: 'register-executor',
source: 'nam_tone',
payload: {
executorId: 'nam-tone-browser-wasm',
pluginId: 'nam_tone',
routeKey: 'desktop-main',
sourceMode: 'browser',
providerIds: ['nam-tone'],
operations: ['loadChainPlan'],
loadChainPlan(request) {
executorRequest = request;
return { outcome: 'handled', status: 'loaded', payload: { engineMode: 'wasm', slotsLoaded: 1 } };
},
},
});
const loaded = await api.dispatch({
capability: 'audio-effects',
command: 'load-plan',
source: 'nam_tone',
payload: { routeKey: 'desktop-main', authorization: 'playback-session', target: { presetId: 42 } },
});
const snapshot = diagnosticsSnapshot(window);
const encoded = JSON.stringify(snapshot);
assert.equal(loaded.outcome, 'handled');
assert.equal(loaded.payload.executor.executorId, 'nam-tone-browser-wasm');
assert.equal(executorRequest.target.presetId, 42);
assert.equal(executorRequest.assets['provider:asset:browser-amp'].browserFile, 'clean.json');
assert.equal(snapshot.routes[0].state, 'loaded');
assert.equal(snapshot.routes[0].executorId, 'nam-tone-browser-wasm');
assert.equal(snapshot.executors.some(executor => executor.executorId === 'nam-tone-browser-wasm' && executor.sourceMode === 'browser'), true);
assert.equal(encoded.includes('/Users/example'), false);
assert.equal(encoded.includes('clean.nam'), false);
assert.equal(encoded.includes('provider:asset:browser-amp'), false);
});
test('load-plan redacts circular executor payloads without overflowing', async () => {
const window = loadAudioEffects();
const api = window.slopsmith.capabilities;
const circular = { loaded: true };
circular.self = circular;
await registerProvider(api);
await api.dispatch({
capability: 'audio-effects',
command: 'register-executor',
source: 'test',
payload: {
executorId: 'cycle-executor',
pluginId: 'cycle_executor',
routeKey: 'desktop-main',
providerIds: ['rig-builder'],
operations: ['loadChainPlan'],
loadChainPlan() {
return { outcome: 'handled', status: 'loaded', payload: circular };
},
},
});
const loaded = await api.dispatch({
capability: 'audio-effects',
command: 'load-plan',
source: 'test',
payload: { routeKey: 'desktop-main', executorId: 'cycle-executor', authorization: 'playback-session' },
});
assert.equal(loaded.outcome, 'handled');
assert.equal(loaded.payload.result.self, '[circular]');
assert.doesNotThrow(() => diagnosticsSnapshot(window));
});
test('load-plan does not use an executor registered for a different provider', async () => {
const window = loadAudioEffects();
const api = window.slopsmith.capabilities;
await registerProvider(api, {
providerId: 'rig-builder',
pluginId: 'rig_builder',
operationHandlers: {
'chain.resolve': () => ({
outcome: 'handled',
plan: {
schema: 'slopsmith.audio_effects.chain_plan.v1',
planId: 'rig-plan',
routeKey: 'desktop-main',
providerId: 'rig-builder',
stages: [{ stageId: 'amp', kind: 'nam', role: 'amp', assetRef: 'provider:asset:rig-amp' }],
},
assets: { 'provider:asset:rig-amp': { kind: 'nam', path: '/Users/example/private/rig.nam' } },
}),
},
});
await api.dispatch({
capability: 'audio-effects',
command: 'register-executor',
source: 'nam_tone',
payload: {
executorId: 'nam-tone-browser-wasm',
pluginId: 'nam_tone',
providerIds: ['nam-tone'],
loadChainPlan() { return { outcome: 'handled' }; },
},
});
const loaded = await api.dispatch({
capability: 'audio-effects',
command: 'load-plan',
source: 'rig_builder',
payload: { routeKey: 'desktop-main', providerId: 'rig-builder', authorization: 'playback-session' },
});
const encoded = JSON.stringify(diagnosticsSnapshot(window));
assert.equal(loaded.outcome, 'unavailable');
assert.match(loaded.reason, /No compatible/);
assert.equal(encoded.includes('/Users/example'), false);
assert.equal(encoded.includes('rig.nam'), false);
});
test('load-plan rejects executors that cannot support the resolved stage kinds', async () => {
const window = loadAudioEffects();
const api = window.slopsmith.capabilities;
let called = false;
await registerProvider(api, {
providerId: 'rig-builder',
pluginId: 'rig_builder',
operationHandlers: {
'chain.resolve': () => ({
outcome: 'handled',
plan: {
schema: 'slopsmith.audio_effects.chain_plan.v1',
planId: 'rig-vst-plan',
routeKey: 'desktop-main',
providerId: 'rig-builder',
stages: [{ stageId: 'drive', kind: 'vst', role: 'pre-pedal', assetRef: 'provider:asset:drive' }],
},
assets: { 'provider:asset:drive': { kind: 'vst', path: '/Users/example/private/drive.vst3' } },
}),
},
});
await api.dispatch({
capability: 'audio-effects',
command: 'register-executor',
source: 'nam_tone',
payload: {
executorId: 'nam-only-test',
pluginId: 'nam_tone',
providerIds: ['rig-builder'],
supportedKinds: ['nam', 'ir'],
loadChainPlan() { called = true; return { outcome: 'handled' }; },
},
});
const loaded = await api.dispatch({
capability: 'audio-effects',
command: 'load-plan',
source: 'rig_builder',
payload: { routeKey: 'desktop-main', providerId: 'rig-builder', authorization: 'playback-session' },
});
const encoded = JSON.stringify(diagnosticsSnapshot(window));
assert.equal(loaded.outcome, 'unavailable');
assert.match(loaded.payload.reason, /vst/);
assert.equal(called, false);
assert.equal(encoded.includes('/Users/example'), false);
assert.equal(encoded.includes('drive.vst3'), false);
});
test('load-plan can fall back to a compatible provider executor when the selected provider has none', async () => {
const window = loadAudioEffects();
const api = window.slopsmith.capabilities;
let loadedProviderId = '';
await registerProvider(api, {
providerId: 'rig-builder',
pluginId: 'rig_builder',
priority: 40,
operationHandlers: {
'chain.resolve': () => ({
outcome: 'handled',
plan: {
schema: 'slopsmith.audio_effects.chain_plan.v1',
planId: 'rig-plan',
routeKey: 'desktop-main',
providerId: 'rig-builder',
stages: [{ stageId: 'amp', kind: 'nam', role: 'amp', assetRef: 'provider:asset:rig-amp' }],
},
}),
},
});
await registerProvider(api, {
providerId: 'nam-tone',
pluginId: 'nam_tone',
priority: 10,
operationHandlers: {
'chain.resolve': () => ({
outcome: 'handled',
plan: {
schema: 'slopsmith.audio_effects.chain_plan.v1',
planId: 'nam-plan',
routeKey: 'desktop-main',
providerId: 'nam-tone',
stages: [{ stageId: 'amp', kind: 'nam', role: 'amp', assetRef: 'provider:asset:nam-amp' }],
},
assets: { 'provider:asset:nam-amp': { kind: 'nam', browserFile: 'fallback.json' } },
}),
},
});
await api.dispatch({
capability: 'audio-effects',
command: 'register-executor',
source: 'nam_tone',
payload: {
executorId: 'nam-tone-browser-wasm',
pluginId: 'nam_tone',
providerIds: ['nam-tone'],
loadChainPlan(request) {
loadedProviderId = request.plan.providerId;
return { outcome: 'handled', payload: { slotsLoaded: 1 } };
},
},
});
const loaded = await api.dispatch({
capability: 'audio-effects',
command: 'load-plan',
source: 'nam_tone',
payload: { routeKey: 'desktop-main', authorization: 'playback-session', fallbackProviderId: 'nam-tone' },
});
assert.equal(loaded.outcome, 'handled');
assert.equal(loaded.payload.route.providerId, 'nam-tone');
assert.equal(loaded.payload.executor.executorId, 'nam-tone-browser-wasm');
assert.equal(loadedProviderId, 'nam-tone');
});
test('load-plan can fall back from a selected provider when its executor cannot load the plan', async () => {
const window = loadAudioEffects();
const api = window.slopsmith.capabilities;
let loadedProviderId = '';
await registerProvider(api, {
providerId: 'rig-builder',
pluginId: 'rig_builder',
priority: 40,
operationHandlers: {
'chain.resolve': () => ({
outcome: 'handled',
plan: {
schema: 'slopsmith.audio_effects.chain_plan.v1',
planId: 'rig-vst-plan',
routeKey: 'desktop-main',
providerId: 'rig-builder',
stages: [{ stageId: 'drive', kind: 'vst', role: 'pre-pedal', assetRef: 'provider:asset:drive' }],
},
}),
},
});
await registerProvider(api, {
providerId: 'nam-tone',
pluginId: 'nam_tone',
priority: 10,
operationHandlers: {
'chain.resolve': () => ({
outcome: 'handled',
plan: {
schema: 'slopsmith.audio_effects.chain_plan.v1',
planId: 'nam-plan',
routeKey: 'desktop-main',
providerId: 'nam-tone',
stages: [{ stageId: 'amp', kind: 'nam', role: 'amp', assetRef: 'provider:asset:nam-amp' }],
},
assets: { 'provider:asset:nam-amp': { kind: 'nam', browserFile: 'fallback.json' } },
}),
},
});
await api.dispatch({
capability: 'audio-effects',
command: 'register-executor',
source: 'nam_tone',
payload: {
executorId: 'rig-nam-only',
pluginId: 'nam_tone',
providerIds: ['rig-builder'],
supportedKinds: ['nam', 'ir'],
loadChainPlan() { throw new Error('should not load VST rig plan'); },
},
});
await api.dispatch({
capability: 'audio-effects',
command: 'register-executor',
source: 'nam_tone',
payload: {
executorId: 'nam-tone-browser-wasm',
pluginId: 'nam_tone',
providerIds: ['nam-tone'],
supportedKinds: ['nam', 'ir'],
loadChainPlan(request) {
loadedProviderId = request.plan.providerId;
return { outcome: 'handled', payload: { slotsLoaded: 1 } };
},
},
});
await api.dispatch({ capability: 'audio-effects', command: 'select-chain', source: 'user', payload: { routeKey: 'desktop-main', providerId: 'rig-builder', authorization: 'user-action' } });
const loaded = await api.dispatch({
capability: 'audio-effects',
command: 'load-plan',
source: 'playback',
payload: { routeKey: 'desktop-main', authorization: 'playback-session', fallbackProviderId: 'nam-tone' },
});
assert.equal(loaded.outcome, 'handled');
assert.equal(loaded.payload.route.providerId, 'nam-tone');
assert.equal(loaded.payload.executor.executorId, 'nam-tone-browser-wasm');
assert.equal(loadedProviderId, 'nam-tone');
});
test('provider operations route stage and segment changes through selected provider', async () => {
const window = loadAudioEffects();
const api = window.slopsmith.capabilities;
const calls = [];
await registerProvider(api, {
operationHandlers: {
'chain.resolve': () => ({
outcome: 'handled',
plan: {
schema: 'slopsmith.audio_effects.chain_plan.v1',
planId: 'switch-plan',
routeKey: 'desktop-main',
providerId: 'rig-builder',
stages: [{ stageId: 'amp', kind: 'nam', role: 'amp', assetRef: 'provider:nam:amp' }],
segments: [{ segmentId: 'dist', stageIds: ['amp'] }],
},
}),
'segment.activate': request => { calls.push(['segment.activate', request.segmentId]); return { outcome: 'handled' }; },
'stage.set-bypass': request => { calls.push(['stage.set-bypass', request.stageId, request.bypassed]); return { outcome: 'handled' }; },
'stage.set-parameter': request => { calls.push(['stage.set-parameter', request.stageId, request.parameterId]); return { outcome: 'handled' }; },
},
});
await api.dispatch({ capability: 'audio-effects', command: 'resolve-plan', source: 'nam_tone', payload: { routeKey: 'desktop-main' } });
const segment = await api.dispatch({ capability: 'audio-effects', command: 'activate-segment', source: 'playback', payload: { routeKey: 'desktop-main', segmentId: 'dist' } });
const bypass = await api.dispatch({ capability: 'audio-effects', command: 'set-stage-bypass', source: 'rig_builder', payload: { routeKey: 'desktop-main', stageId: 'amp', bypassed: true } });
const parameter = await api.dispatch({ capability: 'audio-effects', command: 'set-stage-parameter', source: 'rig_builder', payload: { routeKey: 'desktop-main', stageId: 'amp', parameterId: 'gain', value: 0.7 } });
assert.equal(segment.outcome, 'handled');
assert.equal(bypass.outcome, 'handled');
assert.equal(parameter.outcome, 'handled');
assert.deepEqual(calls, [['segment.activate', 'dist'], ['stage.set-bypass', 'amp', true], ['stage.set-parameter', 'amp', 'gain']]);
});
test('mapping helpers call core mapping API with provider-tagged payloads', async () => {
const window = loadAudioEffects();
const api = window.slopsmith.capabilities;
const calls = [];
window.fetch = async (url, options = {}) => {
calls.push({ url: String(url), options });
return {
ok: true,
status: 200,
async json() {
if (String(url).includes('/activate')) {
return { ok: true, mapping: { id: 7, provider_id: 'rig-builder', active: true } };
}
if ((options.method || 'GET') === 'DELETE') {
return { ok: true, cleared: true };
}
if ((options.method || 'GET') === 'POST') {
return { ok: true, mapping: { id: 7, song_key: 'settings-v1-song', tone_key: 'Dist', provider_id: 'rig-builder', provider_ref: 'chain:99', active: true } };
}
return { mappings: [{ id: 7, song_key: 'settings-v1-song', tone_key: 'Dist', provider_id: 'rig-builder', provider_ref: 'chain:99', active: true }] };
},
};
};
const saved = await window.slopsmith.audioEffects.upsertMapping({
song_key: 'settings-v1-song',
filename: 'Artist - Song_p.psarc',
tone_key: 'Dist',
provider_id: 'rig-builder',
provider_ref: 'chain:99',
active: true,
});
const listed = await api.dispatch({
capability: 'audio-effects',
command: 'list-mappings',
source: 'rig_builder',
payload: { song_key: 'settings-v1-song', provider_id: 'rig-builder' },
});
const activated = await window.slopsmith.audioEffects.activateMapping({ mappingId: 7, providerId: 'rig-builder' });
const cleared = await window.slopsmith.audioEffects.clearActiveMapping({ songKey: 'settings-v1-song', toneKey: 'Dist' });
assert.equal(saved.outcome, 'handled');
assert.equal(saved.payload.mapping.provider_ref, 'chain:99');
assert.equal(listed.outcome, 'handled');
assert.equal(listed.payload.mappings[0].provider_id, 'rig-builder');
assert.equal(activated.payload.mapping.active, true);
assert.equal(cleared.payload.cleared, true);
assert.equal(calls[0].url, '/api/audio-effects/mappings');
assert.equal(JSON.parse(calls[0].options.body).provider_id, 'rig-builder');
assert.match(calls[1].url, /song_key=settings-v1-song/);
assert.match(calls[1].url, /provider_id=rig-builder/);
assert.equal(calls[2].url, '/api/audio-effects/mappings/7/activate');
assert.match(calls[3].url, /\/api\/audio-effects\/active-mapping\?/);
});
test('mapping helpers forward present falsey fields to the server instead of swallowing them', async () => {
const window = loadAudioEffects();
const calls = [];
window.fetch = async (url, options = {}) => {
calls.push({ url: String(url), options });
return { ok: true, status: 200, async json() { return { ok: true }; } };
};
// A falsey non-string provider_id must reach the server (which rejects it) rather than being
// coerced to '' client-side, which would become a silent unscoped activate.
await window.slopsmith.audioEffects.activateMapping({ mappingId: 7, providerId: false });
assert.equal(JSON.parse(calls[0].options.body).provider_id, false);
// A present falsey query filter is forwarded (stringified) rather than dropped.
await window.slopsmith.audioEffects.listMappings({ provider_id: 0 });
assert.match(calls[1].url, /provider_id=0/);
// An omitted filter stays omitted (no spurious empty filter).
await window.slopsmith.audioEffects.listMappings({});
assert.equal(calls[2].url, '/api/audio-effects/mappings');
});
test('bridge hits are safe and diagnosable', async () => {
const window = loadAudioEffects();
const api = window.slopsmith.capabilities;
const result = await api.dispatch({
capability: 'audio-effects',
command: 'record-bridge-hit',
source: 'rig_builder',
payload: {
routeKey: 'desktop-main',
bridgeId: 'audio-effects.legacy-nam-routing',
pluginId: 'rig_builder',
legacySurface: 'fetch /Users/example/song.psarc token=abc123',
},
});
const dbResult = await api.dispatch({
capability: 'audio-effects',
command: 'record-bridge-hit',
source: 'nam_tone',
payload: {
routeKey: 'desktop-main',
bridgeId: 'audio-effects.legacy-tone-db',
pluginId: 'nam_tone',
legacySurface: 'nam_tone.db tone_mappings /Users/example/nam_tone.db',
},
});
const nativeResult = await api.dispatch({
capability: 'audio-effects',
command: 'record-bridge-hit',
source: 'nam_tone',
payload: {
routeKey: 'desktop-main',
bridgeId: 'audio-effects.legacy-native-load',
pluginId: 'nam_tone',
legacySurface: 'window.slopsmithDesktop.audio.loadPreset /Users/example/model.nam',
},
});
const encoded = JSON.stringify(diagnosticsSnapshot(window));
assert.equal(result.outcome, 'handled');
assert.equal(dbResult.outcome, 'handled');
assert.equal(nativeResult.outcome, 'handled');
const sharedShim = window.slopsmith.capabilities.snapshotDiagnostics().compatibilityShims.find(entry => entry.shimId === 'audio-effects.legacy-nam-routing');
assert.equal(sharedShim.status, 'used');
assert.equal(sharedShim.hitCount >= 1, true);
assert.equal(encoded.includes('audio-effects.legacy-nam-routing'), true);
assert.equal(encoded.includes('audio-effects.legacy-tone-db'), true);
assert.equal(encoded.includes('audio-effects.legacy-native-load'), true);
assert.equal(encoded.includes('/Users/example'), false);
assert.equal(encoded.includes('token=abc123'), false);
});
+22
View File
@@ -0,0 +1,22 @@
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { createWindow, ROOT } = require('./capabilities_test_harness');
const CAPABILITIES_JS = path.join(ROOT, 'static', 'capabilities.js');
const AUDIO_EFFECTS_JS = path.join(ROOT, 'static', 'capabilities', 'audio-effects.js');
function loadAudioEffects(options = {}) {
const window = createWindow(options);
const context = vm.createContext(window);
vm.runInContext(fs.readFileSync(CAPABILITIES_JS, 'utf8'), context, { filename: CAPABILITIES_JS });
vm.runInContext(fs.readFileSync(AUDIO_EFFECTS_JS, 'utf8'), context, { filename: AUDIO_EFFECTS_JS });
window.__vmContext = context;
return window;
}
function diagnosticsSnapshot(window) {
return window.slopsmith.audioEffects.snapshot();
}
module.exports = { loadAudioEffects, diagnosticsSnapshot, ROOT };
+72
View File
@@ -0,0 +1,72 @@
// Verify static/highway.js exposes `getAudioElement()` on the public api
// so plugins don't have to reach for `document.getElementById('audio')`
// directly. Static + behavioral checks.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const HIGHWAY_JS = path.join(__dirname, '..', '..', 'static', 'highway.js');
test('highway.api object declares getAudioElement', () => {
// Source-level guard: catches a future contributor renaming or
// dropping the method silently. The api object lives at the bottom
// of the closure; we look for the method's signature inside it.
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
// Locate the api block boundaries.
const apiStart = src.indexOf('const api = {');
assert.ok(apiStart !== -1, 'highway api block not found');
// The api block extends to `return api;`.
const apiEnd = src.indexOf('return api;', apiStart);
assert.ok(apiEnd !== -1, 'api block end (return api) not found');
const apiBlock = src.slice(apiStart, apiEnd);
assert.match(
apiBlock,
/getAudioElement\s*\(\s*\)\s*\{/,
'api object must declare getAudioElement()',
);
});
// Brace-balanced extraction so a future getAudioElement that grows
// (guards, try/catch, nested object literals) doesn't get truncated by
// a naive `[^}]*\}` regex.
function extractMethodBody(src, signature) {
const start = src.indexOf(signature);
assert.ok(start !== -1, `signature '${signature}' not found`);
const openBrace = src.indexOf('{', start);
assert.ok(openBrace !== -1, `opening brace after '${signature}' not found`);
let depth = 1;
let i = openBrace + 1;
while (i < src.length && depth > 0) {
const ch = src[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
assert.ok(depth === 0, `unbalanced braces after '${signature}'`);
return src.slice(start, i);
}
test('getAudioElement returns the #audio element from document', () => {
// Behavioral check: extract the getAudioElement method body and
// exercise it against a fake document. Confirms it's calling
// getElementById('audio') (not 'btn-play' or anything else).
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
const body = extractMethodBody(src, 'getAudioElement()');
// Wrap it so we can call it standalone.
const stub = { id: 'audio-stub' };
const sandbox = {
document: {
getElementById: (id) => (id === 'audio' ? stub : null),
},
};
vm.createContext(sandbox);
vm.runInContext(`globalThis.__getAudioElement = function ${body};`, sandbox);
const result = sandbox.__getAudioElement();
assert.equal(result, stub, 'getAudioElement must return getElementById("audio")');
});
+222
View File
@@ -0,0 +1,222 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { loadAudioSession, runBrowserScript, installMixerDom, makeInputProvider, makeMonitoringProvider } = require('./audio_session_test_harness');
function installAnalyserDom(window) {
const audio = { addEventListener() {} };
window.document.getElementById = id => (id === 'audio' ? audio : null);
window.document.createElement = () => ({ getContext: () => null, style: {}, addEventListener() {}, setAttribute() {} });
window.document.addEventListener = () => {};
window.document.removeEventListener = () => {};
window.Image = class Image {};
window.URL = { createObjectURL: () => 'blob:test', revokeObjectURL() {} };
window.Blob = class Blob {};
window.AudioContext = class AudioContext {
constructor() { this.state = 'running'; this.destination = {}; }
createMediaElementSource() { return { connect() {} }; }
createAnalyser() { return { context: this, frequencyBinCount: 128, fftSize: 0, connect() {}, getByteFrequencyData(data) { data.fill(1); } }; }
resume() { return Promise.resolve(); }
close() { return Promise.resolve(); }
};
}
test('legacy fader API remains compatible while bridge hits are attributed', async () => {
const window = loadAudioSession();
installMixerDom(window);
runBrowserScript(window, 'static/audio-mixer.js');
let volume = 0.5;
window.slopsmith.audio.registerFader({
id: 'plugin.delay',
label: 'Delay',
min: 0,
max: 1,
step: 0.1,
defaultValue: 0.5,
getValue: () => volume,
setValue: value => { volume = value; },
});
const snapshot = window.slopsmith.audioSession.snapshot();
assert.equal(window.slopsmith.audio.getFaders().some(fader => fader.id === 'plugin.delay'), true);
assert.equal(snapshot.domains['audio-mix'].participants.some(participant => participant.participantId === 'fader.plugin.delay'), true);
assert.equal(snapshot.domains['audio-mix'].bridges.some(bridge => bridge.bridgeId === 'audio-mix.fader-registry'), true);
});
test('legacy analyser fallback records bridge status without losing analyser output', () => {
const window = loadAudioSession();
installAnalyserDom(window);
runBrowserScript(window, 'plugins/highway_3d/screen.js');
const analyser = window.slopsmithViz_highway_3d.__test.getAnalyserForBridgeTest();
const bands = window.slopsmithViz_highway_3d.__test.readBandsForBridgeTest();
const bridge = window.slopsmith.audioSession.snapshot().domains['audio-mix'].bridges.find(entry => entry.bridgeId === 'audio-mix.analyser');
assert.equal(analyser.source, 'core');
assert.equal(bands.bass > 0, true);
assert.equal(bridge.outcome, 'handled');
});
test('barrier and input compatibility surfaces are visible in diagnostics', () => {
const window = loadAudioSession();
const audioSession = window.slopsmith.audioSession;
audioSession.recordBridgeHit({ domain: 'audio-monitoring', bridgeId: 'audio-monitoring.audio-barrier', legacySurface: 'window.slopsmithAudioBarrier', participantId: 'note_detect', outcome: 'degraded', reason: 'timeout' });
audioSession.recordBridgeHit({ domain: 'audio-input', bridgeId: 'audio-input.legacy-source', legacySurface: 'navigator.mediaDevices.getUserMedia', participantId: 'note_detect', outcome: 'denied', reason: 'permission denied' });
const snapshot = audioSession.snapshot();
assert.equal(snapshot.domains['audio-monitoring'].bridges.some(bridge => bridge.bridgeId === 'audio-monitoring.audio-barrier' && bridge.outcome === 'degraded'), true);
assert.equal(snapshot.domains['audio-input'].bridges.some(bridge => bridge.bridgeId === 'audio-input.legacy-source' && bridge.outcome === 'denied'), true);
});
test('a legacy bridge hit with unsafe fields never leaks a path/token into diagnostics', () => {
const window = loadAudioSession();
const audioSession = window.slopsmith.audioSession;
audioSession.recordBridgeHit({
domain: 'audio-input',
legacySurface: '/Users/me/legacy token=brk1',
participantId: '/Users/me/who token=brk2',
logicalSourceKey: '/Users/me/key token=brk3',
outcome: 'degraded',
reason: 'legacy handoff',
});
const encoded = JSON.stringify(audioSession.snapshot());
assert.equal(encoded.includes('/Users/me'), false);
assert.equal(encoded.includes('token=brk1'), false);
assert.equal(encoded.includes('token=brk2'), false);
assert.equal(encoded.includes('token=brk3'), false);
});
test('audio-input explicit enumeration registers provider sources without list prompting', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
const provider = makeInputProvider({
providerId: 'desktop_audio',
sourceId: 'bootstrap-source',
logicalSourceKey: 'desktop:bootstrap',
sources: [
{ sourceId: 'desktop-source-2', logicalSourceKey: 'desktop:instrument:secondary', kind: 'instrument', safeLabel: 'Desktop Input 2', channelSummary: { channelCount: 1, channelShape: 'mono', supports: ['mono'] } },
],
});
await api.dispatch({ capability: 'audio-input', command: 'register-source', source: 'desktop_audio', payload: provider.source });
const listed = await api.dispatch({ capability: 'audio-input', command: 'list-sources', source: 'test' });
assert.equal(listed.payload.sources.some(source => source.logicalSourceKey === 'desktop:instrument:secondary'), false);
assert.deepEqual(provider.calls, []);
const enumerated = await window.slopsmith.audioSession.enumerateInputSources({ providerId: 'desktop_audio', explicit: true, requesterId: 'settings' });
const after = await api.dispatch({ capability: 'audio-input', command: 'list-sources', source: 'test' });
assert.equal(enumerated.outcome, 'handled');
assert.equal(provider.calls.length, 1);
assert.equal(provider.calls[0][0], 'source.enumerate');
assert.equal(after.payload.sources.some(source => source.logicalSourceKey === 'desktop:instrument:secondary'), true);
});
test('audio-input native source wins over compatibility-backed duplicate', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await api.dispatch({
capability: 'audio-input',
command: 'register-source',
source: 'legacy_input',
payload: {
sourceId: 'legacy-raw-source',
logicalSourceKey: 'shared:instrument:primary',
providerId: 'legacy_input',
kind: 'instrument',
safeLabel: 'Legacy Input',
sourceMode: 'compatibility',
compatibilitySource: 'navigator.mediaDevices.getUserMedia',
channelSummary: { channelCount: 1, channelShape: 'mono', supports: ['mono'] },
},
});
await api.dispatch({
capability: 'audio-input',
command: 'register-source',
source: 'native_input',
payload: {
sourceId: 'native-raw-source',
logicalSourceKey: 'shared:instrument:primary',
providerId: 'native_input',
kind: 'instrument',
safeLabel: 'Native Input',
sourceMode: 'native',
channelSummary: { channelCount: 1, channelShape: 'mono', supports: ['mono'] },
},
});
const listed = await api.dispatch({ capability: 'audio-input', command: 'list-sources', source: 'test' });
const snapshot = window.slopsmith.audioSession.snapshot().domains['audio-input'];
assert.equal(listed.payload.sources.length, 1);
assert.equal(listed.payload.sources[0].providerId, 'native_input');
assert.equal(snapshot.sources.some(source => source.providerId === 'legacy_input' && source.supersededBy), true);
assert.equal(snapshot.bridges.some(bridge => bridge.bridgeId === 'audio-input.legacy-source' && bridge.status === 'overshadowed'), true);
});
test('audio-monitoring native provider wins over legacy compatibility provider and overshadows its compatibility bridge', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
const legacy = makeMonitoringProvider({
providerId: 'legacy_monitor',
logicalMonitoringKey: 'shared:monitor:primary',
sourceMode: 'compatibility',
compatibilitySource: 'audio-monitoring.audio-barrier',
});
const native = makeMonitoringProvider({
providerId: 'native_monitor',
logicalMonitoringKey: 'shared:monitor:primary',
sourceMode: 'native',
});
await api.dispatch({ capability: 'audio-monitoring', command: 'register-provider', source: 'legacy_monitor', payload: legacy.provider });
await api.dispatch({ capability: 'audio-monitoring', command: 'register-provider', source: 'native_monitor', payload: native.provider });
const listed = await api.dispatch({ capability: 'audio-monitoring', command: 'list-providers', source: 'test' });
const snapshot = window.slopsmith.audioSession.snapshot().domains['audio-monitoring'];
// The compatibility bridge below is the one the registration/supersession path actually produces;
// asserting only on it (not on manually pre-seeded bridge hits) keeps this test honest if the
// compatibility layer ever stops overshadowing superseded providers.
assert.equal(listed.payload.providers.length, 1);
assert.equal(listed.payload.providers[0].providerId, 'native_monitor');
assert.equal(snapshot.providers.some(provider => provider.providerId === 'legacy_monitor' && provider.supersededBy), true);
assert.equal(snapshot.bridges.some(bridge => bridge.bridgeId === 'audio-monitoring.audio-barrier' && bridge.status === 'overshadowed'), true);
});
test('legacy registerFader callbacks are usable through audio-mix get and set operations', async () => {
const window = loadAudioSession();
installMixerDom(window);
runBrowserScript(window, 'static/audio-mixer.js');
window.slopsmith.audioSession.startSession({ sessionId: 'main:test-song' });
let gain = 0.35;
window.slopsmith.audio.registerFader({
id: 'plugin.gain',
label: 'Plugin Gain',
min: 0,
max: 1,
step: 0.05,
defaultValue: 0.35,
getValue: () => gain,
setValue: value => { gain = Math.min(0.9, value); return gain; },
});
const api = window.slopsmith.capabilities;
const listed = await api.dispatch({ capability: 'audio-mix', command: 'list-faders', source: 'test' });
const read = await api.dispatch({ capability: 'audio-mix', command: 'get-fader-value', source: 'test', payload: { participantId: 'fader.plugin.gain', faderId: 'plugin.gain' } });
const written = await api.dispatch({ capability: 'audio-mix', command: 'set-fader-value', source: 'test', payload: { participantId: 'fader.plugin.gain', faderId: 'plugin.gain', value: 1 } });
assert.equal(listed.payload.faders.some(fader => fader.participantId === 'fader.plugin.gain' && fader.sourceMode === 'compatibility'), true);
assert.equal(read.payload.committedValue, 0.35);
assert.equal(written.payload.committedValue, 0.9);
assert.equal(gain, 0.9);
window.slopsmith.audio.unregisterFader('plugin.gain');
assert.equal(window.slopsmith.audio.getFaders().some(fader => fader.id === 'plugin.gain'), false);
assert.equal(window.slopsmith.audioSession.snapshot().domains['audio-mix'].participants.some(participant => participant.participantId === 'fader.plugin.gain'), false);
});
+252
View File
@@ -0,0 +1,252 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { loadAudioSession, diagnosticsSnapshot, makeInputProvider, makeMonitoringProvider } = require('./audio_session_test_harness');
test('audio session host registers active core domains and contributes diagnostics', () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
const diagnostics = window.slopsmith.diagnostics.snapshotContributions();
for (const domain of ['audio-mix', 'audio-input', 'audio-monitoring']) {
const pipeline = api.inspect(domain);
assert.equal(pipeline.review.lifecycle, 'active');
assert.equal(pipeline.participants.some(p => p.pluginId === 'core.audio.session' && p.roles.includes('owner')), true);
}
const stemsPipeline = api.inspect('stems');
assert.equal(stemsPipeline.review.lifecycle, 'active');
assert.equal(stemsPipeline.participants.some(p => p.pluginId === 'core.audio.session' && p.roles.includes('coordinator') && !p.roles.includes('owner')), true);
assert.equal(diagnostics['audio-session'].schema, 'slopsmith.audio_session.diagnostics.v1');
});
test('audio session lifecycle and snapshots redact source identity with per-snapshot pseudonyms', () => {
const window = loadAudioSession();
const audioSession = window.slopsmith.audioSession;
audioSession.startSession({ sessionId: 'main:/Users/example/DLC/song.psarc', songKey: '/Users/example/DLC/song.psarc', songFormat: 'psarc' });
audioSession.setRoute({ routeKind: 'html5', availability: 'available', deviceLabel: 'Scarlett 2i2 Serial 1234' });
audioSession.registerInputSource({ sourceId: 'mic-raw-id', logicalSourceKey: 'browser:instrument:primary', providerId: 'browser', kind: 'instrument', channelCount: 2, availability: 'available', label: 'Scarlett 2i2 Serial 1234' });
const snapshot = audioSession.snapshot();
const encoded = JSON.stringify(snapshot);
assert.equal(snapshot.session.songFormat, 'psarc');
assert.match(snapshot.domains['audio-input'].sources[0].diagnosticsPseudonym, /^source-\d{2}$/);
assert.equal(encoded.includes('Scarlett'), false);
assert.equal(encoded.includes('/Users/example'), false);
});
test('audio-input diagnostics redact source ids labels handles and bounded reasons', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await api.dispatch({
capability: 'audio-input',
command: 'register-source',
source: 'secret_plugin',
payload: {
sourceId: 'Built-in Microphone Serial ABC123',
logicalSourceKey: 'secret:instrument:primary',
providerId: 'secret_plugin',
kind: 'instrument',
label: 'Built-in Microphone Serial ABC123',
availability: 'available',
reason: 'Path /Users/barlind/private/project token=abc123 should be redacted',
channelSummary: { channelCount: 2, channelShape: 'stereo', supports: ['mono', 'stereo'] },
operations: ['source.open'],
operationHandlers: {
'source.open': () => ({ outcome: 'handled', mediaStream: { secret: true }, audioNode: { secret: true } }),
},
},
});
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'secret:instrument:primary' } });
await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect', requiredChannelShape: 'mono' } });
const encoded = JSON.stringify(diagnosticsSnapshot(window));
assert.equal(encoded.includes('Built-in Microphone'), false);
assert.equal(encoded.includes('ABC123'), false);
assert.equal(encoded.includes('/Users/barlind'), false);
assert.equal(encoded.includes('token=abc123'), false);
assert.equal(encoded.includes('mediaStream'), false);
assert.equal(encoded.includes('audioNode'), false);
assert.match(encoded, /source-\d+/);
});
test('audio-input shares compatible open sessions and closes provider after last release', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
const calls = [];
await api.dispatch({
capability: 'audio-input',
command: 'register-source',
source: 'provider',
payload: {
sourceId: 'shared-source',
logicalSourceKey: 'shared:instrument:primary',
providerId: 'provider',
kind: 'instrument',
safeLabel: 'Shared Input',
channelSummary: { channelCount: 2, channelShape: 'stereo', supports: ['mono', 'stereo'] },
operations: ['source.open', 'source.close'],
operationHandlers: {
'source.open': request => { calls.push(['open', request.requesterId]); return { outcome: 'handled' }; },
'source.close': request => { calls.push(['close', request.openSessionId]); return { outcome: 'handled' }; },
},
},
});
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'shared:instrument:primary' } });
const first = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect', requiredChannelShape: 'mono' } });
const second = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'practice_overlay', payload: { requesterId: 'practice_overlay', requiredChannelShape: 'mono' } });
const closeFirst = await api.dispatch({ capability: 'audio-input', command: 'close-source', source: 'note_detect', payload: { requesterId: 'note_detect', openSessionId: first.payload.openSessionId } });
const closeSecond = await api.dispatch({ capability: 'audio-input', command: 'close-source', source: 'practice_overlay', payload: { requesterId: 'practice_overlay', openSessionId: second.payload.openSessionId } });
assert.equal(first.outcome, 'handled');
assert.equal(second.outcome, 'handled');
assert.equal(first.payload.openSessionId, second.payload.openSessionId);
assert.equal(closeFirst.payload.state, 'open');
assert.equal(closeSecond.payload.state, 'closed');
assert.deepEqual(calls.map(call => call[0]), ['open', 'close']);
});
test('audio diagnostics record bounded runtime outcomes and domain statuses', () => {
const window = loadAudioSession();
const audioSession = window.slopsmith.audioSession;
for (let i = 0; i < 120; i += 1) {
audioSession.recordOutcome({ domain: 'audio-input', operation: 'select-source', participantId: 'test', outcome: 'degraded', status: 'unavailable', reason: `missing-${i}` });
}
const snapshot = audioSession.snapshot();
assert.equal(snapshot.recentOutcomes.length, 100);
assert.equal(snapshot.recentOutcomes.at(-1).status, 'unavailable');
assert.equal(snapshot.recentOutcomes.at(-1).outcome, 'degraded');
});
test('disabled missing incompatible unsupported and timeout paths are diagnosable', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
const audioSession = window.slopsmith.audioSession;
audioSession.registerMixParticipant({ participantId: 'disabled-fader', availability: 'disabled' });
assert.equal(audioSession.snapshot().domains['audio-mix'].participants[0].availability, 'disabled');
const missingOwner = await api.dispatch({ capability: 'stems', command: 'inspect', source: 'test' });
assert.equal(missingOwner.outcome, 'no-owner');
const incompatible = await api.dispatch({ capability: 'audio-mix', command: 'register-participant', source: 'test', payload: { participantId: 'bad', version: 2 } });
assert.equal(incompatible.outcome, 'incompatible-version');
const unsupported = await api.dispatch({ capability: 'audio-mix', command: 'not-a-command', source: 'test' });
assert.equal(unsupported.outcome, 'unsupported-command');
api.registerParticipant('slow_audio_probe', {
'audio-mix': {
roles: ['provider'],
commands: ['slow-probe'],
runtime: true,
handlers: { 'slow-probe': () => new Promise(resolve => setTimeout(() => resolve({ outcome: 'handled' }), 20)) },
},
});
const timedOut = await api.command('audio-mix', 'slow-probe', { requester: 'test', timeoutMs: 1 });
assert.equal(timedOut.outcome, 'failed');
assert.match(timedOut.reason, /timed out/i);
});
test('audio-mix diagnostics include faders routes analysers bridge hits and redacted outcomes', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
const audioSession = window.slopsmith.audioSession;
audioSession.startSession({ sessionId: 'main:/Users/example/DLC/song.psarc', songKey: '/Users/example/DLC/song.psarc' });
audioSession.setRoute({ routeKind: 'desktop', availability: 'degraded', deviceLabel: 'Secret Studio Output', fallbackReason: 'fallback token=abc123 at /Users/example/device' });
audioSession.setAnalyser({ source: 'plugin', availability: 'available', participantId: 'plugin.visualizer', reason: 'ok', rawFft: [1, 2, 3] });
audioSession.recordBridgeHit({ domain: 'audio-mix', bridgeId: 'audio-mix.fader-registry', legacySurface: 'registerFader', participantId: 'legacy.delay', outcome: 'failed', reason: 'password=abc path /Users/example/plugin' });
await api.dispatch({
capability: 'audio-mix',
command: 'register-participant',
source: 'test',
payload: {
participantId: 'plugin.delay',
ownerPluginId: 'delay',
label: 'Delay',
kind: 'plugin',
sourceMode: 'native',
fader: { id: 'wet', label: 'Wet', min: 0, max: 1, step: 0.1, defaultValue: 0.5, currentValue: 0.5 },
operations: ['fader.get-value', 'fader.set-value'],
operationHandlers: { 'fader.set-value': () => { throw new Error('failed near /Users/example/secret token=abc'); } },
},
});
const failed = await api.dispatch({ capability: 'audio-mix', command: 'set-fader-value', source: 'test', payload: { participantId: 'plugin.delay', faderId: 'wet', value: 0.7 } });
const route = await api.dispatch({ capability: 'audio-mix', command: 'inspect-route', source: 'test' });
const analyser = await api.dispatch({ capability: 'audio-mix', command: 'inspect-analyser', source: 'test' });
const snapshot = audioSession.snapshot();
const encoded = JSON.stringify(snapshot);
assert.equal(failed.outcome, 'failed');
assert.equal(route.payload.routeKind, 'desktop');
assert.equal(analyser.payload.source, 'plugin');
assert.equal(snapshot.domains['audio-mix'].faders.some(fader => fader.participantId === 'plugin.delay' && fader.sourceMode === 'native'), true);
assert.equal(snapshot.domains['audio-mix'].bridges.some(bridge => bridge.bridgeId === 'audio-mix.fader-registry' && bridge.outcome === 'failed'), true);
assert.equal(snapshot.recentOutcomes.some(outcome => outcome.operation === 'set-fader-value' && outcome.faderId === 'wet' && outcome.outcome === 'failed'), true);
assert.equal(encoded.includes('rawFft'), false);
assert.equal(encoded.includes('Secret Studio Output'), false);
assert.equal(encoded.includes('/Users/example'), false);
assert.equal(encoded.includes('token=abc'), false);
assert.equal(encoded.includes('password=abc'), false);
});
test('audio-monitoring diagnostics redact provider source session handles and private payloads', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
const input = makeInputProvider({
providerId: 'secret_input',
sourceId: 'USB Interface Hardware ABC1234',
logicalSourceKey: 'secret:instrument:primary',
label: 'USB Interface Hardware ABC1234',
channelSummary: { channelCount: 2, channelShape: 'stereo', supports: ['mono', 'stereo'] },
openResult: { outcome: 'handled', status: 'open', mediaStream: { raw: true }, audioNode: { raw: true }, samples: [1, 2, 3] },
});
const monitoring = makeMonitoringProvider({
providerId: 'secret_monitor',
logicalMonitoringKey: 'secret:monitor:primary',
safeLabel: 'Secret Monitor Serial 9988',
startResult: {
outcome: 'handled',
status: 'active',
summary: {
directMonitor: { state: 'muted', control: 'supported', preference: 'muted', applied: true, reason: 'path /Users/barlind/private token=abc123 waveform raw-audio' },
latencySummary: { bucket: 'low', minMs: 2, maxMs: 6, rawBuffer: [1, 2, 3] },
mediaStream: { secret: true },
nativeHandle: { secret: true },
},
},
});
await api.dispatch({ capability: 'audio-input', command: 'register-source', source: input.source.providerId, payload: input.source });
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: input.source.logicalSourceKey } });
const providerPayload = { ...monitoring.provider, privatePayload: { password: 'abc123' }, rawAudioBuffer: [1, 2, 3], nativeHandle: { secret: true }, label: 'Secret Monitor Serial 9988' };
delete providerPayload.safeLabel;
await api.dispatch({ capability: 'audio-monitoring', command: 'register-provider', source: monitoring.provider.providerId, payload: providerPayload });
const started = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { requesterId: 'user', authorization: 'user-action', requiredChannelShape: 'mono' } });
window.slopsmith.audioSession.recordOutcome({ domain: 'audio-monitoring', operation: 'start', participantId: 'secret_monitor', providerId: 'secret_monitor', monitoringId: started.payload.monitoringId, sourceId: 'USB Interface Hardware ABC1234', openSessionId: 'open raw id 1234', requesterId: 'user', outcome: 'failed', status: 'timeout', reason: 'failed at /Users/barlind/private secret=abc123' });
const snapshot = diagnosticsSnapshot(window);
const encoded = JSON.stringify(snapshot);
assert.equal(started.outcome, 'handled');
assert.equal(encoded.includes('USB Interface'), false);
assert.equal(encoded.includes('ABC1234'), false);
assert.equal(encoded.includes('Secret Monitor Serial'), false);
assert.equal(encoded.includes('/Users/barlind'), false);
assert.equal(encoded.includes('token=abc123'), false);
assert.equal(encoded.includes('secret=abc123'), false);
assert.equal(encoded.includes('rawAudioBuffer'), false);
assert.equal(encoded.includes('rawBuffer'), false);
assert.equal(encoded.includes('samples'), false);
assert.equal(encoded.includes('waveform'), false);
assert.equal(encoded.includes('mediaStream'), false);
assert.equal(encoded.includes('audioNode'), false);
assert.equal(encoded.includes('nativeHandle'), false);
assert.match(encoded, /monitoring-\d+/);
assert.match(encoded, /source-\d+/);
});
+754
View File
@@ -0,0 +1,754 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { loadAudioSession, captureEvents, makeInputProvider, makeMonitoringProvider } = require('./audio_session_test_harness');
async function registerSource(api, payload = {}) {
return api.dispatch({
capability: 'audio-input',
command: 'register-source',
source: payload.providerId || 'test',
payload: {
sourceId: 'source-raw-id-12345',
logicalSourceKey: 'test:instrument:primary',
providerId: 'test_provider',
kind: 'instrument',
safeLabel: 'Input 1',
channelSummary: { channelCount: 2, channelShape: 'stereo', supports: ['mono', 'stereo'] },
operations: ['source.open', 'source.close'],
operationHandlers: {
'source.open': () => ({ outcome: 'handled', status: 'open', mediaStream: { secret: true } }),
'source.close': () => ({ outcome: 'handled', status: 'closed', nativeHandle: { secret: true } }),
},
...payload,
},
});
}
test('audio-input requires sourceId providerId and logicalSourceKey', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
const missingSource = await api.dispatch({ capability: 'audio-input', command: 'register-source', source: 'test', payload: { providerId: 'test', logicalSourceKey: 'test:key' } });
const missingProvider = await api.dispatch({ capability: 'audio-input', command: 'register-source', source: 'test', payload: { sourceId: 'source-1', logicalSourceKey: 'test:key' } });
const missingLogical = await api.dispatch({ capability: 'audio-input', command: 'register-source', source: 'test', payload: { sourceId: 'source-1', providerId: 'test' } });
const incompatible = await api.dispatch({ capability: 'audio-input', command: 'register-source', source: 'test', payload: { sourceId: 'source-1', providerId: 'test', logicalSourceKey: 'test:key', version: 99 } });
assert.equal(missingSource.outcome, 'failed');
assert.equal(missingProvider.outcome, 'failed');
assert.equal(missingLogical.outcome, 'failed');
assert.equal(incompatible.outcome, 'incompatible-version');
});
test('audio-input registration list inspect select and snapshots pseudonymize source identity', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
const registeredEvents = captureEvents(window, 'audio-input:source-registered');
const selectedEvents = captureEvents(window, 'audio-input:source-selected');
const registered = await registerSource(api, { label: 'Scarlett 2i2 Serial 987654' });
const listed = await api.dispatch({ capability: 'audio-input', command: 'list-sources', source: 'note_detect' });
const inspected = await api.dispatch({ capability: 'audio-input', command: 'inspect', source: 'note_detect' });
const selected = await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'note_detect', payload: { logicalSourceKey: 'test:instrument:primary' } });
const missing = await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'note_detect', payload: { sourceId: 'missing-device' } });
assert.equal(registered.status, 'applied');
assert.equal(listed.payload.sources.length, 1);
assert.equal(inspected.payload.totalSources, 1);
assert.equal(selected.status, 'applied');
assert.equal(selected.payload.logicalSourceKey, 'test:instrument:primary');
assert.equal(missing.outcome, 'degraded');
assert.match(registered.payload.sourceId, /^source-\d+$/);
assert.match(selected.payload.sourceId, /^source-\d+$/);
assert.equal(registeredEvents.length, 1);
assert.equal(selectedEvents.length, 1);
const encoded = JSON.stringify(window.slopsmith.audioSession.snapshot().domains['audio-input']);
assert.equal(encoded.includes('source-raw-id-12345'), false);
assert.equal(encoded.includes('Scarlett'), false);
assert.equal(encoded.includes('987654'), false);
});
test('audio-input pseudonyms are per-bundle: distinct within a snapshot, never leak raw identity', () => {
const window = loadAudioSession();
const audioSession = window.slopsmith.audioSession;
audioSession.registerInputSource({ sourceId: 'mic-A', providerId: 'note_detect', logicalSourceKey: 'note_detect:mic-a', label: '/Users/me/My Songs/mic A' });
audioSession.registerInputSource({ sourceId: 'mic-B', providerId: 'note_detect', logicalSourceKey: 'note_detect:mic-b', label: 'device B' });
const inputDomain = audioSession.snapshot().domains['audio-input'];
const pseudonyms = inputDomain.sources.map(source => source.diagnosticsPseudonym);
// Per-bundle pseudonyms are distinct within one snapshot (spec FR-011/SC-005).
assert.equal(new Set(pseudonyms).size, pseudonyms.length);
for (const pseudonym of pseudonyms) assert.match(pseudonym, /^source-\d+$/);
// Raw source ids/labels never leak into diagnostics.
const encoded = JSON.stringify(inputDomain);
assert.equal(encoded.includes('mic-A'), false);
assert.equal(encoded.includes('/Users/me'), false);
});
test('audio-input degraded select and unknown unregister never leak the raw source id', () => {
const window = loadAudioSession();
const audioSession = window.slopsmith.audioSession;
const degraded = audioSession.selectInputSource('/Users/me/secret-device', 'note_detect');
const removed = audioSession.unregisterInputSource('/Users/me/secret-device');
assert.equal(degraded.outcome, 'degraded');
assert.match(degraded.payload.sourceId, /^source-\d+$/);
assert.equal(removed.outcome, 'no-handler');
assert.match(removed.payload.sourceId, /^source-\d+$/);
const encoded = JSON.stringify({ degraded, removed, snapshot: audioSession.snapshot() });
assert.equal(encoded.includes('/Users/me'), false);
assert.equal(encoded.includes('secret-device'), false);
});
test('unknown unregister echoes the requested logicalSourceKey/providerId without pseudonymizing them', () => {
const window = loadAudioSession();
const audioSession = window.slopsmith.audioSession;
const removed = audioSession.unregisterInputSource({ logicalSourceKey: 'note_detect:instrument:primary', providerId: 'note_detect' });
assert.equal(removed.outcome, 'no-handler');
// The logical key is a redaction-safe handle — echoed back verbatim, not pseudonymized as a sourceId.
assert.equal(removed.payload.logicalSourceKey, 'note_detect:instrument:primary');
assert.equal(removed.payload.providerId, 'note_detect');
assert.equal(removed.payload.sourceId, '');
assert.equal(removed.payload.removed, false);
});
test('unregister-source uses providerId to target the right source among logical-key duplicates', () => {
const window = loadAudioSession();
const audioSession = window.slopsmith.audioSession;
audioSession.registerInputSource({ sourceId: 'native-x', logicalSourceKey: 'dup:disambig', providerId: 'native_p', kind: 'instrument', safeLabel: 'N' });
audioSession.registerInputSource({ sourceId: 'compat-x', logicalSourceKey: 'dup:disambig', providerId: 'compat_p', compatibilitySource: 'legacy', kind: 'instrument', safeLabel: 'C' });
// Without providerId, priority resolves to the native winner; the compat providerId must target compat.
const removed = audioSession.unregisterInputSource({ logicalSourceKey: 'dup:disambig', providerId: 'compat_p' });
assert.equal(removed.outcome, 'handled');
assert.equal(removed.payload.providerId, 'compat_p');
assert.equal(audioSession.snapshot().domains['audio-input'].totalSources, 1);
});
test('register-source rejects a sourceId already owned by another provider', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
const first = await api.dispatch({ capability: 'audio-input', command: 'register-source', source: 'prov_a', payload: { sourceId: 'shared-id', logicalSourceKey: 'a:key', providerId: 'prov_a' } });
const collision = await api.dispatch({ capability: 'audio-input', command: 'register-source', source: 'prov_b', payload: { sourceId: 'shared-id', logicalSourceKey: 'b:key', providerId: 'prov_b' } });
// Re-registering the same source by the same provider is still an update, not a collision.
const update = await api.dispatch({ capability: 'audio-input', command: 'register-source', source: 'prov_a', payload: { sourceId: 'shared-id', logicalSourceKey: 'a:key', providerId: 'prov_a' } });
assert.equal(first.outcome, 'handled');
assert.equal(collision.outcome, 'failed');
assert.equal(update.outcome, 'handled');
// The original provider's source survives the rejected collision.
const inspected = await api.dispatch({ capability: 'audio-input', command: 'inspect', source: 'test', payload: { logicalSourceKey: 'a:key' } });
assert.equal(inspected.payload.source.providerId, 'prov_a');
});
test('register-source rejects a logicalSourceKey that is not redaction-safe', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
const result = await api.dispatch({ capability: 'audio-input', command: 'register-source', source: 'p', payload: { sourceId: 's1', providerId: 'p', logicalSourceKey: '/Users/me/secret token=abc123' } });
assert.equal(result.outcome, 'failed');
// The unsafe key must not be stored or leak into diagnostics.
const encoded = JSON.stringify(window.slopsmith.audioSession.snapshot());
assert.equal(encoded.includes('/Users/me'), false);
assert.equal(encoded.includes('token=abc123'), false);
});
test('enumerate denied with an unsafe providerId never leaks it into diagnostics', async () => {
const window = loadAudioSession();
const audioSession = window.slopsmith.audioSession;
// Missing explicit/userInitiated -> denied; the caller-supplied providerId is unsafe and is
// recorded as the outcome participantId/providerId, so it must be bounded in the snapshot.
const result = await audioSession.enumerateInputSources({ providerId: '/Users/me/p token=xyz789' });
assert.equal(result.outcome, 'denied');
const outcomes = audioSession.snapshot().recentOutcomes;
assert.equal(outcomes.some(outcome => outcome.operation === 'source.enumerate' && outcome.outcome === 'denied'), true);
const encoded = JSON.stringify(audioSession.snapshot());
assert.equal(encoded.includes('/Users/me'), false);
assert.equal(encoded.includes('token=xyz789'), false);
});
test('a malicious dispatch source is redacted before becoming a requesterId in diagnostics', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await registerSource(api, { sourceId: 'req-raw', logicalSourceKey: 'test:req' });
// The capability dispatch `source` (the requester identity) is attacker-controlled here.
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: '/Users/me/plugin token=abc123', payload: { logicalSourceKey: 'test:req' } });
const encoded = JSON.stringify(window.slopsmith.audioSession.snapshot());
assert.equal(encoded.includes('/Users/me'), false);
assert.equal(encoded.includes('abc123'), false);
});
test('caller-provided logical keys are bounded so a path/token cannot leak into diagnostics on a miss', () => {
const window = loadAudioSession();
const audioSession = window.slopsmith.audioSession;
// Untrusted callers may pass an unsafe value as a logical key; select/unregister misses must not
// echo or record it raw into the redaction-safe diagnostics snapshot.
const selected = audioSession.selectInputSource({ logicalSourceKey: '/Users/me/secret token=abc123' }, 'note_detect');
const removed = audioSession.unregisterInputSource({ logicalSourceKey: '/Users/me/secret token=abc123', providerId: '/Users/me/provider' });
assert.equal(selected.outcome, 'degraded');
assert.equal(removed.outcome, 'no-handler');
const encoded = JSON.stringify({ selected, removed, snapshot: audioSession.snapshot() });
assert.equal(encoded.includes('/Users/me'), false);
assert.equal(encoded.includes('token=abc123'), false);
});
test('audio-monitoring distinguishes a failed state from transient unavailability', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await registerSource(api);
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:instrument:primary' } });
const failedProvider = makeMonitoringProvider({ providerId: 'mon-failed', startResult: { outcome: 'failed', status: 'failed', reason: 'JUCE barrier failed' } });
const unavailableProvider = makeMonitoringProvider({ providerId: 'mon-unavail', availability: 'unavailable' });
await api.dispatch({ capability: 'audio-monitoring', command: 'register-provider', source: 'mon-failed', payload: failedProvider.provider });
await api.dispatch({ capability: 'audio-monitoring', command: 'register-provider', source: 'mon-unavail', payload: unavailableProvider.provider });
const failed = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'note_detect', payload: { providerId: 'mon-failed', requesterId: 'note_detect', authorization: 'user-action' } });
const unavailable = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'note_detect', payload: { providerId: 'mon-unavail', requesterId: 'note_detect', authorization: 'user-action' } });
assert.equal(failed.outcome, 'failed');
assert.equal(failed.payload.state, 'failed');
assert.equal(unavailable.outcome, 'unavailable');
assert.equal(unavailable.payload.availability, 'unavailable');
});
test('inspect list and select do not call provider enumeration or open handlers', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
const provider = makeInputProvider({ providerId: 'note_detect', logicalSourceKey: 'note_detect:instrument:primary' });
await registerSource(api, provider.source);
await api.dispatch({ capability: 'audio-input', command: 'inspect', source: 'test' });
await api.dispatch({ capability: 'audio-input', command: 'list-sources', source: 'test' });
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'test', payload: { logicalSourceKey: 'note_detect:instrument:primary' } });
assert.deepEqual(provider.calls, []);
});
test('open-source and close-source record outcomes events and no live handles', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
const openedEvents = captureEvents(window, 'audio-input:source-opened');
const closedEvents = captureEvents(window, 'audio-input:source-closed');
const degradedEvents = captureEvents(window, 'audio-input:source-open-degraded');
const deniedEvents = captureEvents(window, 'audio-input:permission-denied');
await registerSource(api);
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:instrument:primary' } });
const open = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect', requiredChannelShape: 'mono', purpose: 'note-detection' } });
const close = await api.dispatch({ capability: 'audio-input', command: 'close-source', source: 'note_detect', payload: { requesterId: 'note_detect', openSessionId: open.payload.openSessionId } });
assert.equal(open.outcome, 'handled');
assert.equal(open.payload.state, 'open');
assert.equal(close.outcome, 'handled');
assert.equal(close.payload.state, 'closed');
assert.equal(openedEvents.length, 1);
assert.equal(closedEvents.length, 1);
await registerSource(api, { sourceId: 'denied-source', logicalSourceKey: 'test:denied', availability: 'denied', reason: 'permission denied token=abc' });
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:denied' } });
const denied = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect' } });
assert.equal(denied.outcome, 'denied');
assert.equal(deniedEvents.length, 1);
await registerSource(api, { sourceId: 'mono-only', logicalSourceKey: 'test:mono', channelSummary: { channelCount: 1, channelShape: 'mono', supports: ['mono'] } });
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:mono' } });
const incompatible = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect', requiredChannelShape: 'stereo' } });
assert.equal(incompatible.outcome, 'degraded');
assert.equal(incompatible.payload.state, 'incompatible');
assert.equal(degradedEvents.length >= 1, true);
const encoded = JSON.stringify(window.slopsmith.audioSession.snapshot());
assert.equal(encoded.includes('mediaStream'), false);
assert.equal(encoded.includes('nativeHandle'), false);
assert.equal(encoded.includes('token=abc'), false);
});
test('open-source reports no-owner no-handler unsupported failed and malformed provider data distinctly', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
const noOwner = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect' } });
assert.equal(noOwner.outcome, 'no-owner');
await registerSource(api, { sourceId: 'no-open', logicalSourceKey: 'test:no-open', providerId: 'no_open_provider', operations: [], operationHandlers: {} });
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:no-open' } });
const unsupported = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect' } });
assert.equal(unsupported.outcome, 'unsupported-command');
await registerSource(api, { sourceId: 'no-handler', logicalSourceKey: 'test:no-handler', providerId: 'no_handler_provider', operations: ['source.open'], operationHandlers: {} });
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:no-handler' } });
const noHandler = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect' } });
assert.equal(noHandler.outcome, 'no-handler');
await registerSource(api, { sourceId: 'failed', logicalSourceKey: 'test:failed', providerId: 'failed_provider', operationHandlers: { 'source.open': () => { throw new Error('failed near /Users/example/source'); } } });
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:failed' } });
const failed = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect' } });
assert.equal(failed.outcome, 'failed');
await registerSource(api, { sourceId: 'malformed', logicalSourceKey: 'test:malformed', providerId: 'malformed_provider', operationHandlers: { 'source.open': () => 'ok' } });
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:malformed' } });
const malformed = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect' } });
assert.equal(malformed.outcome, 'handled');
const outcomes = window.slopsmith.audioSession.snapshot().recentOutcomes.filter(outcome => outcome.domain === 'audio-input');
assert.equal(outcomes.some(outcome => outcome.status === 'no-owner' || outcome.outcome === 'no-owner'), true);
assert.equal(outcomes.some(outcome => outcome.outcome === 'unsupported-command'), true);
assert.equal(outcomes.some(outcome => outcome.outcome === 'no-handler'), true);
assert.equal(outcomes.some(outcome => outcome.outcome === 'failed'), true);
});
test('open-source never switches to a non-selected source addressed by raw sourceId or logical key', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await registerSource(api, { sourceId: 'primary-raw', logicalSourceKey: 'test:primary' });
await registerSource(api, { sourceId: 'other-raw', logicalSourceKey: 'test:other' });
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:primary' } });
const bySourceId = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect', sourceId: 'other-raw' } });
assert.equal(bySourceId.outcome, 'degraded');
const byKey = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect', logicalSourceKey: 'test:other' } });
assert.equal(byKey.outcome, 'degraded');
const selectedOpen = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect' } });
assert.equal(selectedOpen.outcome, 'handled');
assert.equal(selectedOpen.payload.logicalSourceKey, 'test:primary');
});
test('open-source emits an open-session-shaped payload (with requester attribution) for a pre-denied source', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
const deniedEvents = captureEvents(window, 'audio-input:permission-denied');
await registerSource(api, { sourceId: 'predenied-raw', logicalSourceKey: 'test:predenied', availability: 'denied', reason: 'permission denied' });
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:predenied' } });
const denied = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect', purpose: 'note-detection' } });
assert.equal(denied.outcome, 'denied');
const payload = deniedEvents[0].payload;
assert.ok(payload.openSessionId);
assert.equal(payload.requesters[0].requesterId, 'note_detect');
assert.equal(payload.requesters[0].purpose, 'note-detection');
// Requester entries match the real open-session shape — no per-requester openedAt.
assert.equal('openedAt' in payload.requesters[0], false);
});
test('open-source source-open-degraded uses an open-session-shaped payload when nothing is selected', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
const degraded = captureEvents(window, 'audio-input:source-open-degraded');
// No source selected -> degraded; the event must share the OpenInputSessionSummary schema.
const result = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect', purpose: 'note-detection' } });
assert.equal(result.outcome, 'no-owner');
const payload = degraded[degraded.length - 1].payload;
assert.ok(payload.openSessionId);
assert.equal(payload.state, 'unavailable');
assert.equal(payload.requesters[0].requesterId, 'note_detect');
assert.equal('openedAt' in payload.requesters[0], false);
});
test('open-source reports the selected source as unavailable when no matching source is registered', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await registerSource(api, { sourceId: 'gone-raw', logicalSourceKey: 'test:gone' });
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:gone' } });
// The provider unregisters the source while it is still the selected logical key.
await api.dispatch({ capability: 'audio-input', command: 'unregister-source', source: 'test', payload: { logicalSourceKey: 'test:gone', providerId: 'test_provider' } });
const open = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect' } });
assert.equal(open.outcome, 'degraded');
// Distinct, accurate message — not the misleading "not the selected source".
assert.match(open.reason, /selected input source is unavailable/i);
});
test('open-source and close-source ignore a payload requesterId so callers cannot spoof session ownership', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await registerSource(api, { sourceId: 'owned-raw', logicalSourceKey: 'test:owned' });
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:owned' } });
// The authenticated dispatch caller is note_detect; a spoofed payload requesterId must be ignored.
const open = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'victim_plugin' } });
assert.equal(open.outcome, 'handled');
assert.equal(open.payload.requesters.some(item => item.requesterId === 'note_detect'), true);
assert.equal(open.payload.requesters.some(item => item.requesterId === 'victim_plugin'), false);
// A different caller spoofing note_detect's id cannot release note_detect's reference.
const spoofClose = await api.dispatch({ capability: 'audio-input', command: 'close-source', source: 'attacker', payload: { requesterId: 'note_detect', openSessionId: open.payload.openSessionId } });
assert.equal(spoofClose.outcome, 'handled');
assert.equal(spoofClose.payload.state, 'open');
assert.equal(spoofClose.payload.requesters.some(item => item.requesterId === 'note_detect'), true);
});
test('open-source and close-source propagate a provider non-handled outcome exactly', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
// source.open returns an explicit non-denied/failed outcome -> must propagate, not collapse to degraded.
await registerSource(api, { sourceId: 'po-raw', logicalSourceKey: 'test:po', operationHandlers: {
'source.open': () => ({ outcome: 'unsupported-command', reason: 'no can do' }),
'source.close': () => ({ outcome: 'handled', status: 'closed' }),
} });
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:po' } });
const openUnsupported = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect' } });
assert.equal(openUnsupported.outcome, 'unsupported-command');
// source.close returns an explicit non-failed outcome -> propagate exactly.
await registerSource(api, { sourceId: 'pc-raw', logicalSourceKey: 'test:pc', providerId: 'pc_provider', operationHandlers: {
'source.open': () => ({ outcome: 'handled', status: 'open' }),
'source.close': () => ({ outcome: 'unsupported-command', reason: 'nope' }),
} });
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:pc' } });
const open = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect' } });
assert.equal(open.outcome, 'handled');
const closeUnsupported = await api.dispatch({ capability: 'audio-input', command: 'close-source', source: 'note_detect', payload: { requesterId: 'note_detect', openSessionId: open.payload.openSessionId } });
assert.equal(closeUnsupported.outcome, 'unsupported-command');
});
test('close-source accepts logicalKey/sourceKey aliases like the other audio-input paths', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await registerSource(api, { sourceId: 'alias-raw', logicalSourceKey: 'test:alias' });
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:alias' } });
const open = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect' } });
assert.equal(open.outcome, 'handled');
// Close by the `logicalKey` alias (no openSessionId) — must resolve the same session.
const close = await api.dispatch({ capability: 'audio-input', command: 'close-source', source: 'note_detect', payload: { requesterId: 'note_detect', logicalKey: 'test:alias' } });
assert.equal(close.outcome, 'handled');
assert.equal(close.payload.state, 'closed');
});
test('close-source resolves by logicalSourceKey when requiredChannelShape is omitted', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
// Default source is stereo; open without a channel-shape hint so the session is keyed by the
// source's resolved shape, then close by logical key alone (requiredChannelShape is optional).
await registerSource(api, { sourceId: 'closable-raw', logicalSourceKey: 'test:closable' });
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:closable' } });
const open = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect' } });
assert.equal(open.outcome, 'handled');
const close = await api.dispatch({ capability: 'audio-input', command: 'close-source', source: 'note_detect', payload: { requesterId: 'note_detect', logicalSourceKey: 'test:closable' } });
assert.equal(close.outcome, 'handled');
assert.equal(close.payload.state, 'closed');
});
test('close-source with an explicit wrong requiredChannelShape does not close a differently-shaped session', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
// Default source supports mono+stereo; open as mono so the session is keyed by 'mono'.
await registerSource(api, { sourceId: 'shaped-raw', logicalSourceKey: 'test:shaped' });
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:shaped' } });
const open = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect', requiredChannelShape: 'mono' } });
assert.equal(open.outcome, 'handled');
// An explicit, wrong shape must NOT fall back and close the mono session.
const wrongShape = await api.dispatch({ capability: 'audio-input', command: 'close-source', source: 'note_detect', payload: { requesterId: 'note_detect', logicalSourceKey: 'test:shaped', requiredChannelShape: 'stereo' } });
assert.equal(wrongShape.outcome, 'no-handler');
assert.equal(window.slopsmith.audioSession.snapshot().domains['audio-input'].totalOpenSessions, 1);
// The original session still closes via openSessionId.
const right = await api.dispatch({ capability: 'audio-input', command: 'close-source', source: 'note_detect', payload: { requesterId: 'note_detect', openSessionId: open.payload.openSessionId } });
assert.equal(right.outcome, 'handled');
});
test('open-source rejects a non-selected duplicate sharing the logical key, addressed by sourceId', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
// Native + compatibility-backed duplicate share one logical key; the native source wins.
await registerSource(api, { sourceId: 'native-raw', logicalSourceKey: 'dup:key', providerId: 'native_provider' });
await registerSource(api, { sourceId: 'compat-raw', logicalSourceKey: 'dup:key', providerId: 'compat_provider', compatibilitySource: 'legacy browser handoff' });
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'dup:key' } });
// Opening by the compatibility duplicate's sourceId must not switch to it.
const wrong = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect', sourceId: 'compat-raw' } });
assert.equal(wrong.outcome, 'degraded');
// The selected native winner still opens, routed to its own provider.
const right = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect', sourceId: 'native-raw' } });
assert.equal(right.outcome, 'handled');
assert.equal(right.payload.providerId, 'native_provider');
});
test('inspect resolves a raw sourceId to its source via the stable logical key', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await registerSource(api, { sourceId: 'inspect-raw-id', logicalSourceKey: 'test:inspectable' });
// The snapshot pseudonymizes sourceId, so inspect must resolve the raw provider sourceId itself.
const byRaw = await api.dispatch({ capability: 'audio-input', command: 'inspect', source: 'test', payload: { sourceId: 'inspect-raw-id' } });
assert.ok(byRaw.payload.source);
assert.equal(byRaw.payload.source.logicalSourceKey, 'test:inspectable');
const byKey = await api.dispatch({ capability: 'audio-input', command: 'inspect', source: 'test', payload: { logicalSourceKey: 'test:inspectable' } });
assert.ok(byKey.payload.source);
assert.equal(byKey.payload.source.logicalSourceKey, 'test:inspectable');
});
test('inspect by a shared logical key returns the native winner, not a suppressed duplicate', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
// Register the compatibility duplicate FIRST so a naive logicalSourceKey-only match would pick it.
await registerSource(api, { sourceId: 'compat-first', logicalSourceKey: 'dup:inspect', providerId: 'compat_p', compatibilitySource: 'legacy' });
await registerSource(api, { sourceId: 'native-second', logicalSourceKey: 'dup:inspect', providerId: 'native_p' });
const inspected = await api.dispatch({ capability: 'audio-input', command: 'inspect', source: 'test', payload: { logicalSourceKey: 'dup:inspect' } });
assert.ok(inspected.payload.source);
assert.equal(inspected.payload.source.providerId, 'native_p');
assert.equal(inspected.payload.source.sourceMode, 'native');
});
test('enumerate preserves a provider-supplied safeLabel through redaction', async () => {
const window = loadAudioSession();
const audioSession = window.slopsmith.audioSession;
const provider = makeInputProvider({
providerId: 'labelled',
sourceId: 'labelled-bootstrap',
logicalSourceKey: 'labelled:bootstrap',
sources: [{ sourceId: 'lbl-1', logicalSourceKey: 'lbl:one', kind: 'instrument', safeLabel: 'Studio Mic' }],
});
audioSession.registerInputSource(provider.source);
const enumerated = await audioSession.enumerateInputSources({ providerId: 'labelled', explicit: true, requesterId: 'settings' });
assert.equal(enumerated.outcome, 'handled');
const registered = enumerated.payload.sources.find(source => source.logicalSourceKey === 'lbl:one');
assert.ok(registered);
// The explicitly-safe label survives _safeInputValue instead of falling back to a pseudonym.
assert.equal(registered.label, 'Studio Mic');
});
test('enumerate returns no-handler when providers exist but none support source.enumerate', async () => {
const window = loadAudioSession();
const audioSession = window.slopsmith.audioSession;
audioSession.registerInputSource({ sourceId: 'nohandler-raw', logicalSourceKey: 'nh:key', providerId: 'nh_provider', operations: ['source.open'], operationHandlers: { 'source.open': () => ({ outcome: 'handled', status: 'open' }) } });
const result = await audioSession.enumerateInputSources({ providerId: 'nh_provider', explicit: true, requesterId: 'settings' });
assert.equal(result.outcome, 'no-handler');
});
test('open-source hint mismatch echoes a pseudonymized sourceId hint without leaking the raw id', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await registerSource(api, { sourceId: 'sel-raw', logicalSourceKey: 'test:sel' });
await registerSource(api, { sourceId: 'other-raw', logicalSourceKey: 'test:other' });
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:sel' } });
const open = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect', sourceId: 'other-raw' } });
assert.equal(open.outcome, 'degraded');
assert.match(open.payload.sourceId, /^source-\d+$/);
const encoded = JSON.stringify({ open, snapshot: window.slopsmith.audioSession.snapshot() });
assert.equal(encoded.includes('other-raw'), false);
});
test('enumerate propagates a provider source.enumerate denial instead of empty success', async () => {
const window = loadAudioSession();
const audioSession = window.slopsmith.audioSession;
const provider = makeInputProvider({
providerId: 'denier',
sourceId: 'denier-bootstrap',
logicalSourceKey: 'denier:bootstrap',
operationHandlers: {
'source.enumerate': () => ({ outcome: 'denied', reason: 'user declined microphone' }),
},
});
audioSession.registerInputSource(provider.source);
const enumerated = await audioSession.enumerateInputSources({ providerId: 'denier', explicit: true, requesterId: 'settings' });
assert.equal(enumerated.outcome, 'denied');
const outcomes = audioSession.snapshot().recentOutcomes;
assert.equal(outcomes.some(outcome => outcome.operation === 'source.enumerate' && outcome.outcome === 'denied'), true);
// A non-denied/failed explicit outcome is preserved exactly (not collapsed to degraded).
const incompatible = makeInputProvider({
providerId: 'oldproto',
sourceId: 'oldproto-bootstrap',
logicalSourceKey: 'oldproto:bootstrap',
operationHandlers: { 'source.enumerate': () => ({ outcome: 'incompatible-version', reason: 'needs v1' }) },
});
audioSession.registerInputSource(incompatible.source);
const enumeratedOld = await audioSession.enumerateInputSources({ providerId: 'oldproto', explicit: true, requesterId: 'settings' });
assert.equal(enumeratedOld.outcome, 'incompatible-version');
});
test('enumerateInputSources returns distinct sourceId pseudonyms across sources', async () => {
const window = loadAudioSession();
const audioSession = window.slopsmith.audioSession;
const provider = makeInputProvider({
providerId: 'multi_provider',
sourceId: 'mp-bootstrap',
logicalSourceKey: 'mp:bootstrap',
sources: [
{ sourceId: 'mp-raw-1', logicalSourceKey: 'mp:one', kind: 'instrument', safeLabel: 'In 1' },
{ sourceId: 'mp-raw-2', logicalSourceKey: 'mp:two', kind: 'instrument', safeLabel: 'In 2' },
],
});
audioSession.registerInputSource(provider.source);
const enumerated = await audioSession.enumerateInputSources({ providerId: 'multi_provider', explicit: true, requesterId: 'settings' });
assert.equal(enumerated.outcome, 'handled');
assert.equal(enumerated.payload.sources.length, 2);
const ids = enumerated.payload.sources.map(source => source.sourceId);
ids.forEach(id => assert.match(id, /^source-\d+$/));
// Distinct sources must get distinct pseudonyms, not all collapse to `source-01`.
assert.equal(new Set(ids).size, 2);
});
test('open-session ids correlate between openSessions and recentOutcomes in a snapshot', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await registerSource(api, { sourceId: 'corr-raw', logicalSourceKey: 'test:corr' });
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:corr' } });
const open = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect' } });
assert.equal(open.outcome, 'handled');
const openId = open.payload.openSessionId;
const snap = window.slopsmith.audioSession.snapshot();
assert.equal(snap.domains['audio-input'].openSessions[0].openSessionId, openId);
const openOutcome = snap.recentOutcomes.find(outcome => outcome.operation === 'open-source' && outcome.status === 'open' && outcome.openSessionId);
assert.ok(openOutcome);
// The generated input-open id is left verbatim so an outcome correlates with its open session.
assert.equal(openOutcome.openSessionId, openId);
});
test('startSession closes open input sessions from the previous session before replacing it', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
const audioSession = window.slopsmith.audioSession;
let providerClosed = 0;
await registerSource(api, {
sourceId: 'restart-raw',
logicalSourceKey: 'test:restart',
operationHandlers: {
'source.open': () => ({ outcome: 'handled', status: 'open' }),
'source.close': () => { providerClosed += 1; return { outcome: 'handled', status: 'closed' }; },
},
});
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:restart' } });
const open = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect' } });
assert.equal(open.outcome, 'handled');
// A direct song-switch startSession() (no stopSession first) must still release provider capture.
audioSession.startSession({ sessionId: 'main:next-song' });
await new Promise(resolve => setImmediate(resolve));
assert.equal(providerClosed, 1);
assert.equal(audioSession.snapshot().domains['audio-input'].totalOpenSessions, 0);
});
test('a persisted selected-source key that is not redaction-safe is ignored on restore', () => {
const window = loadAudioSession();
// Simulate a tampered/mutated localStorage entry.
window.localStorage.setItem('slopsmith.audioInput.selectedLogicalSourceKey', '/Users/me/evil token=zzz999');
const snap = window.slopsmith.audioSession.startSession({ sessionId: 'main:restore-test' });
assert.equal(snap.domains['audio-input'].selected, null);
const encoded = JSON.stringify(snap);
assert.equal(encoded.includes('/Users/me'), false);
assert.equal(encoded.includes('token=zzz999'), false);
});
test('stopSession closes open input sessions and notifies providers', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
const audioSession = window.slopsmith.audioSession;
const closedEvents = captureEvents(window, 'audio-input:source-closed');
let providerClosed = 0;
await registerSource(api, {
sourceId: 'live-raw',
logicalSourceKey: 'test:live',
operationHandlers: {
'source.open': () => ({ outcome: 'handled', status: 'open' }),
'source.close': () => { providerClosed += 1; return { outcome: 'handled', status: 'closed' }; },
},
});
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:live' } });
const open = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect' } });
assert.equal(open.outcome, 'handled');
assert.equal(audioSession.snapshot().domains['audio-input'].totalOpenSessions, 1);
audioSession.stopSession('song switch');
await new Promise(resolve => setImmediate(resolve));
assert.equal(audioSession.snapshot().domains['audio-input'].totalOpenSessions, 0);
assert.equal(closedEvents.length >= 1, true);
assert.equal(providerClosed, 1);
});
test('startSession keeps the in-memory selection when persistence has failed, ignoring stale storage', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
// Seed storage with a stale key, then make subsequent writes fail.
window.localStorage.setItem('slopsmith.audioInput.selectedLogicalSourceKey', 'stale:old-input');
window.localStorage.setItem = () => { throw new Error('quota'); };
await registerSource(api, { sourceId: 'current-raw', logicalSourceKey: 'current:input' });
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'current:input' } });
assert.equal(window.slopsmith.audioSession.snapshot().domains['audio-input'].storageStatus, 'failed');
const snap = window.slopsmith.audioSession.startSession({ sessionId: 'main:after-fail' });
// Must keep the in-memory 'current:input', not revert to the stale storage key.
assert.equal(snap.domains['audio-input'].selected.logicalSourceKey, 'current:input');
assert.equal(snap.domains['audio-input'].storageStatus, 'failed');
});
test('selected source persistence restore and storage-unavailable fallback are stable', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await registerSource(api, { sourceId: 'persisted-source', logicalSourceKey: 'persisted:input' });
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'persisted:input' } });
const afterStart = window.slopsmith.audioSession.startSession({ sessionId: 'main:next-song' });
assert.equal(afterStart.domains['audio-input'].selected.logicalSourceKey, 'persisted:input');
assert.equal(afterStart.domains['audio-input'].selected.restoreStatus, 'restored');
const noStorageWindow = loadAudioSession();
noStorageWindow.localStorage.setItem = () => { throw new Error('blocked'); };
const noStorageApi = noStorageWindow.slopsmith.capabilities;
await registerSource(noStorageApi, { sourceId: 'session-source', logicalSourceKey: 'session:input' });
await noStorageApi.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'session:input' } });
const noStorageSnapshot = noStorageWindow.slopsmith.audioSession.startSession({ sessionId: 'main:no-storage-song' });
assert.equal(noStorageSnapshot.domains['audio-input'].storageStatus, 'failed');
assert.equal(noStorageSnapshot.domains['audio-input'].selected.logicalSourceKey, 'session:input');
});
+246
View File
@@ -0,0 +1,246 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { loadAudioSession } = require('./audio_session_test_harness');
test('audio-mix commands inspect register and unregister participants', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
const events = [];
window.slopsmith.on('audio-mix:participant-registered', event => events.push(event.detail.payload.participantId));
const registered = await api.dispatch({
capability: 'audio-mix',
command: 'register-participant',
source: 'test',
payload: {
participantId: 'plugin.delay',
ownerPluginId: 'delay_plugin',
label: 'Delay Return',
kind: 'plugin',
fader: { id: 'wet', label: 'Wet', min: 0, max: 1, step: 0.01, defaultValue: 0.5, currentValue: 0.6 },
operations: ['fader.get-value', 'fader.set-value'],
},
});
const inspected = await api.dispatch({ capability: 'audio-mix', command: 'inspect', source: 'test' });
const removed = await api.dispatch({ capability: 'audio-mix', command: 'unregister-participant', source: 'test', payload: { participantId: 'plugin.delay' } });
assert.equal(registered.status, 'applied');
assert.equal(registered.payload.participantId, 'plugin.delay');
assert.equal(inspected.payload.participants.some(p => p.participantId === 'plugin.delay'), true);
assert.equal(events.includes('plugin.delay'), true);
assert.equal(removed.status, 'applied');
assert.equal(window.slopsmith.audioSession.snapshot().domains['audio-mix'].participants.some(p => p.participantId === 'plugin.delay'), false);
});
test('audio-mix registration reports incompatible participants explicitly', async () => {
const window = loadAudioSession();
const result = await window.slopsmith.capabilities.dispatch({
capability: 'audio-mix',
command: 'register-participant',
source: 'test',
payload: { participantId: 'future.plugin', version: 2 },
});
assert.equal(result.status, 'incompatible-version');
assert.equal(result.outcome, 'incompatible-version');
assert.equal(window.slopsmith.audioSession.snapshot().recentOutcomes.at(-1).outcome, 'incompatible-version');
});
test('audio-mix lists required participant kinds and commits provider fader values', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
const audioSession = window.slopsmith.audioSession;
audioSession.startSession({ sessionId: 'main:test-song', songKey: 'test-song', songFormat: 'sloppak' });
let pluginValue = 0.25;
for (const [participantId, kind] of [
['core.song', 'song'],
['plugin.delay', 'plugin'],
['stems.master', 'stem'],
['monitoring.input', 'monitoring'],
['preview.player', 'preview'],
]) {
const isPlugin = participantId === 'plugin.delay';
await api.dispatch({
capability: 'audio-mix',
command: 'register-participant',
source: 'test',
payload: {
participantId,
ownerPluginId: participantId.split('.')[0],
label: kind,
kind,
fader: { id: 'volume', label: `${kind} volume`, min: 0, max: 1, step: 0.05, defaultValue: 0.5, currentValue: isPlugin ? pluginValue : 0.5 },
operations: ['fader.get-value', 'fader.set-value'],
operationHandlers: isPlugin ? {
'fader.get-value': () => pluginValue,
'fader.set-value': value => { pluginValue = Math.round(value * 10) / 10; return { committedValue: pluginValue }; },
} : {},
},
});
}
const listed = await api.dispatch({ capability: 'audio-mix', command: 'list-faders', source: 'test' });
const read = await api.dispatch({ capability: 'audio-mix', command: 'get-fader-value', source: 'test', payload: { participantId: 'plugin.delay', faderId: 'volume' } });
const started = Date.now();
const written = await api.dispatch({ capability: 'audio-mix', command: 'set-fader-value', source: 'test', payload: { participantId: 'plugin.delay', faderId: 'volume', value: 0.76 } });
const latency = Date.now() - started;
const clamped = await api.dispatch({ capability: 'audio-mix', command: 'set-fader-value', source: 'test', payload: { participantId: 'plugin.delay', faderId: 'volume', value: 5 } });
assert.equal(listed.status, 'applied');
for (const kind of ['song', 'plugin', 'stem', 'monitoring', 'preview']) assert.equal(listed.payload.requiredKinds[kind], true, kind);
assert.equal(read.payload.committedValue, 0.25);
assert.equal(written.payload.requestedValue, 0.76);
assert.equal(written.payload.committedValue, 0.8);
assert.equal(latency < 500, true, `committed display latency ${latency}ms`);
assert.equal(clamped.payload.normalizedValue, 1);
assert.equal(clamped.payload.committedValue, 1);
});
test('audio-mix reports invalid unavailable and timed-out fader operations', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
const events = [];
window.slopsmith.on('audio-mix:fader-unavailable', event => events.push(event.detail.payload.participantId));
window.slopsmith.audioSession.startSession({ sessionId: 'main:test-song' });
await api.dispatch({
capability: 'audio-mix',
command: 'register-participant',
source: 'test',
payload: {
participantId: 'plugin.disabled',
ownerPluginId: 'plugin.disabled',
label: 'Disabled',
kind: 'plugin',
availability: 'unavailable',
fader: { id: 'volume', label: 'Disabled', min: 0, max: 1, step: 0.1, defaultValue: 0.5, currentValue: 0.5 },
operations: ['fader.get-value', 'fader.set-value'],
},
});
await api.dispatch({
capability: 'audio-mix',
command: 'register-participant',
source: 'test',
payload: {
participantId: 'plugin.slow',
ownerPluginId: 'plugin.slow',
label: 'Slow',
kind: 'plugin',
fader: { id: 'volume', label: 'Slow', min: 0, max: 1, step: 0.1, defaultValue: 0.4, currentValue: 0.4 },
operations: ['fader.get-value', 'fader.set-value'],
operationHandlers: { 'fader.set-value': () => new Promise(() => {}) },
},
});
const invalid = await api.dispatch({ capability: 'audio-mix', command: 'set-fader-value', source: 'test', payload: { participantId: 'plugin.slow', faderId: 'volume', value: 'loud' } });
const unavailable = await api.dispatch({ capability: 'audio-mix', command: 'set-fader-value', source: 'test', payload: { participantId: 'plugin.disabled', faderId: 'volume', value: 0.8 } });
const started = Date.now();
const timedOut = await api.dispatch({ capability: 'audio-mix', command: 'set-fader-value', source: 'test', payload: { participantId: 'plugin.slow', faderId: 'volume', value: 0.8 } });
const elapsed = Date.now() - started;
assert.equal(invalid.outcome, 'denied');
assert.equal(unavailable.outcome, 'degraded');
assert.equal(events.includes('plugin.disabled'), true);
assert.equal(timedOut.outcome, 'failed');
assert.equal(timedOut.payload.timedOut, true);
assert.equal(timedOut.payload.committedValue, 0.4);
assert.equal(elapsed >= 1900 && elapsed < 2600, true, `timeout elapsed ${elapsed}ms`);
});
test('audio-mix keeps pre-session participants pending then attaches them on session start', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await api.dispatch({
capability: 'audio-mix',
command: 'register-participant',
source: 'test',
payload: {
participantId: 'plugin.presession',
ownerPluginId: 'plugin.presession',
label: 'Pre-session',
kind: 'plugin',
fader: { id: 'volume', label: 'Pre-session', min: 0, max: 1, step: 0.1, defaultValue: 0.3, currentValue: 0.3 },
operations: ['fader.get-value', 'fader.set-value'],
},
});
const pending = await api.dispatch({ capability: 'audio-mix', command: 'list-faders', source: 'test' });
window.slopsmith.audioSession.startSession({ sessionId: 'main:next-song' });
const active = await api.dispatch({ capability: 'audio-mix', command: 'list-faders', source: 'test' });
assert.equal(pending.payload.faders.find(fader => fader.participantId === 'plugin.presession').availability, 'pending');
assert.equal(active.payload.faders.find(fader => fader.participantId === 'plugin.presession').availability, 'available');
});
test('audio-mix registration is idempotent and song switching keeps known participants without stale route', async () => {
const window = loadAudioSession();
const audioSession = window.slopsmith.audioSession;
audioSession.startSession({ sessionId: 'main:first-song', songKey: 'first-song' });
audioSession.setRoute({ routeKind: 'stems', availability: 'available' });
for (let i = 0; i < 3; i += 1) {
audioSession.registerMixParticipant({
participantId: 'plugin.rehydrated',
ownerPluginId: 'plugin.rehydrated',
label: 'Rehydrated',
kind: 'plugin',
fader: { id: 'volume', label: 'Rehydrated', min: 0, max: 1, step: 0.1, defaultValue: 0.5, currentValue: 0.5 + i * 0.1 },
operations: ['fader.get-value', 'fader.set-value'],
});
}
const beforeStop = await window.slopsmith.capabilities.dispatch({ capability: 'audio-mix', command: 'list-faders', source: 'test' });
audioSession.stopSession('song switch');
const stopped = audioSession.snapshot();
audioSession.startSession({ sessionId: 'main:second-song', songKey: 'second-song' });
const afterStart = await window.slopsmith.capabilities.dispatch({ capability: 'audio-mix', command: 'list-faders', source: 'test' });
assert.equal(beforeStop.payload.faders.filter(fader => fader.participantId === 'plugin.rehydrated').length, 1);
assert.equal(stopped.domains['audio-mix'].route.availability, 'unavailable');
assert.equal(afterStart.payload.faders.filter(fader => fader.participantId === 'plugin.rehydrated').length, 1);
assert.equal(afterStart.payload.faders.find(fader => fader.participantId === 'plugin.rehydrated').availability, 'available');
});
test('re-registering a mix participant without handlers preserves the existing set-fader-value handler', async () => {
// Regression for the song-volume mixer no-op: _applySongVolume() runs on
// every song load and re-registers core.song WITHOUT get/set handlers.
// registerMixParticipant replaces the participant, so before the fix this
// wiped the fader.set-value handler installed at init — the mixer slider
// then moved visually but never applied the volume (PSARC and sloppak).
const window = loadAudioSession();
const audioSession = window.slopsmith.audioSession;
audioSession.startSession({ sessionId: 'main:test-song', songKey: 'test-song', songFormat: 'sloppak' });
const applied = [];
// Initial registration WITH handlers (mirrors _registerSongFader()).
audioSession.registerMixParticipant({
participantId: 'core.song',
ownerPluginId: 'core',
label: 'Song',
kind: 'song',
sourceMode: 'core',
fader: { id: 'song', label: 'Song', unit: '%', min: 0, max: 100, step: 1, defaultValue: 80, currentValue: 80 },
operations: ['fader.get-value', 'fader.set-value'],
operationHandlers: {
'fader.get-value': () => 80,
'fader.set-value': (value) => { applied.push(value); return value; },
},
});
// Re-registration WITHOUT handlers (mirrors _applySongVolume()'s spec).
audioSession.registerMixParticipant({
participantId: 'core.song',
ownerPluginId: 'core',
label: 'Song',
kind: 'song',
sourceMode: 'core',
fader: { id: 'song', label: 'Song', unit: '%', min: 0, max: 100, step: 1, defaultValue: 80, currentValue: 55 },
});
const result = await audioSession.setFaderValue({ participantId: 'core.song', faderId: 'song', value: 42 });
assert.equal(result.outcome, 'handled');
assert.equal(result.payload.committedValue, 42);
// The handler must still have been invoked — the actual volume was applied,
// not silently swallowed by a dropped handler.
assert.deepEqual(applied, [42]);
});
+642
View File
@@ -0,0 +1,642 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { loadAudioSession, captureEvents, makeInputProvider, makeMonitoringProvider, storageEntries } = require('./audio_session_test_harness');
async function installInput(api, overrides = {}) {
const input = makeInputProvider({
providerId: 'desktop_input',
sourceId: 'desktop-source',
logicalSourceKey: 'desktop:instrument:primary',
channelSummary: { channelCount: 2, channelShape: 'stereo', supports: ['mono', 'stereo'] },
...overrides,
});
await api.dispatch({ capability: 'audio-input', command: 'register-source', source: 'desktop_input', payload: input.source });
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: input.source.logicalSourceKey } });
return input;
}
async function installMonitoring(api, overrides = {}) {
const monitoring = makeMonitoringProvider({ providerId: 'desktop_monitor', logicalMonitoringKey: 'desktop:monitor:main', safeLabel: 'Desktop Monitor', ...overrides });
await api.dispatch({ capability: 'audio-monitoring', command: 'register-provider', source: monitoring.provider.providerId, payload: monitoring.provider });
return monitoring;
}
test('audio-monitoring starts and stops through selected provider and source', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
const startedEvents = captureEvents(window, 'audio-monitoring:monitoring-started');
const stoppedEvents = captureEvents(window, 'audio-monitoring:monitoring-stopped');
const input = await installInput(api);
const monitoring = await installMonitoring(api);
const active = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { requesterId: 'user', authorization: 'user-action', requiredChannelShape: 'mono' } });
const stopped = await api.dispatch({ capability: 'audio-monitoring', command: 'stop', source: 'user', payload: { requesterId: 'user', monitoringId: active.payload.monitoringId } });
const snapshot = window.slopsmith.audioSession.snapshot().domains['audio-monitoring'];
assert.equal(active.outcome, 'handled');
assert.equal(active.payload.state, 'active');
assert.equal(active.payload.sourceRef.logicalSourceKey, 'desktop:instrument:primary');
assert.equal(stopped.outcome, 'stopped');
assert.equal(startedEvents.length, 1);
assert.equal(stoppedEvents.length, 1);
assert.deepEqual(monitoring.calls.map(call => call[0]), ['monitoring.start', 'monitoring.stop']);
assert.deepEqual(input.calls.map(call => call[0]), ['source.open']);
assert.equal(snapshot.sessions.some(session => session.state === 'stopped'), true);
});
test('audio-monitoring redacts circular provider payloads without overflowing', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
const circular = { directMonitor: { state: 'muted' }, latencySummary: { bucket: 'low' } };
circular.self = circular;
await installInput(api);
await installMonitoring(api, {
operationHandlers: {
'monitoring.start': () => ({ outcome: 'handled', status: 'active', payload: circular }),
},
});
const active = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { requesterId: 'user', authorization: 'user-action', requiredChannelShape: 'mono' } });
const snapshot = window.slopsmith.audioSession.snapshot().domains['audio-monitoring'];
assert.equal(active.outcome, 'handled');
assert.equal(snapshot.sessions[0].state, 'active');
});
test('audio-monitoring snapshots redact session identifiers and source refs', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await installInput(api);
await installMonitoring(api);
const active = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { requesterId: 'user', authorization: 'user-action', requiredChannelShape: 'mono' } });
const snapshot = window.slopsmith.audioSession.snapshot().domains['audio-monitoring'];
const session = snapshot.sessions[0];
const encoded = JSON.stringify(snapshot);
assert.equal(active.outcome, 'handled');
assert.notEqual(session.monitoringId, active.payload.monitoringId);
assert.notEqual(session.sessionKey, active.payload.sessionKey);
assert.equal(session.sessionKey.includes('::'), false);
assert.equal(encoded.includes('desktop-source'), false);
});
test('audio-monitoring reports no provider unavailable degraded denied failed user action and reload boundaries', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
const degradedEvents = captureEvents(window, 'audio-monitoring:monitoring-degraded');
const deniedEvents = captureEvents(window, 'audio-monitoring:monitoring-denied');
const unavailableEvents = captureEvents(window, 'audio-monitoring:monitoring-unavailable');
const failedEvents = captureEvents(window, 'audio-monitoring:monitoring-failed');
const userActionEvents = captureEvents(window, 'audio-monitoring:monitoring-user-action-required');
await installInput(api);
const noProvider = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { requesterId: 'user', authorization: 'user-action' } });
const unavailableProvider = await installMonitoring(api, { providerId: 'unavailable_monitor', logicalMonitoringKey: 'unavailable:main', availability: 'unavailable', operations: ['monitoring.start'] });
const unavailable = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { providerId: unavailableProvider.provider.providerId, requesterId: 'user', authorization: 'user-action' } });
await api.dispatch({ capability: 'audio-monitoring', command: 'unregister-provider', source: 'test', payload: { providerId: unavailableProvider.provider.providerId } });
const degradedProvider = await installMonitoring(api, { providerId: 'degraded_monitor', logicalMonitoringKey: 'degraded:main', startResult: { outcome: 'degraded', status: 'degraded', reason: 'high latency', summary: { latencySummary: { bucket: 'high' } } } });
const degraded = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { providerId: degradedProvider.provider.providerId, requesterId: 'user', authorization: 'user-action' } });
await api.dispatch({ capability: 'audio-monitoring', command: 'unregister-provider', source: 'test', payload: { providerId: degradedProvider.provider.providerId } });
const deniedProvider = await installMonitoring(api, { providerId: 'denied_monitor', logicalMonitoringKey: 'denied:main', startResult: { outcome: 'denied', status: 'denied', reason: 'permission denied' } });
const denied = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { providerId: deniedProvider.provider.providerId, requesterId: 'user', authorization: 'user-action' } });
await api.dispatch({ capability: 'audio-monitoring', command: 'unregister-provider', source: 'test', payload: { providerId: deniedProvider.provider.providerId } });
const failedProvider = await installMonitoring(api, { providerId: 'failed_monitor', logicalMonitoringKey: 'failed:main', operationHandlers: { 'monitoring.start': () => { throw new Error('boom'); } } });
const failed = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { providerId: failedProvider.provider.providerId, requesterId: 'user', authorization: 'user-action' } });
const background = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'note_detect', payload: { providerId: failedProvider.provider.providerId, requesterId: 'note_detect', authorization: 'background' } });
const activeProvider = await installMonitoring(api, { providerId: 'active_monitor', logicalMonitoringKey: 'active:main' });
const activeBeforeSwitch = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { providerId: activeProvider.provider.providerId, requesterId: 'user', authorization: 'user-action' } });
assert.equal(noProvider.outcome, 'no-owner');
assert.equal(unavailable.outcome, 'unavailable');
assert.equal(degraded.outcome, 'degraded');
assert.equal(denied.outcome, 'denied');
assert.equal(failed.outcome, 'failed');
assert.equal(background.outcome, 'user-action-required');
assert.equal(degradedEvents.length >= 1, true);
assert.equal(deniedEvents.length >= 1, true);
// user-action-required must surface as its own event, not as a 'monitoring-denied' permission signal.
assert.equal(userActionEvents.length >= 1, true);
assert.equal(deniedEvents.some(detail => detail && detail.requesterId === 'note_detect'), false);
assert.equal(unavailableEvents.length >= 1, true);
assert.equal(failedEvents.length >= 1, true);
assert.equal(activeBeforeSwitch.outcome, 'handled');
window.slopsmith.audioSession.startSession({ sessionId: 'main:next-song' });
const afterSongSwitch = window.slopsmith.audioSession.snapshot().domains['audio-monitoring'];
assert.equal(afterSongSwitch.sessions.some(session => session.state === 'active'), true);
const restoredWindow = loadAudioSession({ storage: storageEntries(window) });
const restored = restoredWindow.slopsmith.audioSession.snapshot().domains['audio-monitoring'];
assert.equal(restored.sessions.length, 0);
});
test('audio-monitoring provider registration is idempotent and selected provider is deterministic', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await installInput(api);
const legacy = await installMonitoring(api, { providerId: 'legacy_monitor', logicalMonitoringKey: 'shared:monitor', sourceMode: 'compatibility', compatibilitySource: 'legacy.monitor' });
const native = await installMonitoring(api, { providerId: 'native_monitor', logicalMonitoringKey: 'shared:monitor', sourceMode: 'native', safeLabel: 'Native Monitor' });
await api.dispatch({ capability: 'audio-monitoring', command: 'register-provider', source: 'native_monitor', payload: { ...native.provider, availability: 'pending' } });
const listed = await api.dispatch({ capability: 'audio-monitoring', command: 'list-providers', source: 'test' });
const snapshot = window.slopsmith.audioSession.snapshot().domains['audio-monitoring'];
assert.equal(listed.payload.providers.length, 1);
assert.equal(listed.payload.providers[0].providerId, 'native_monitor');
assert.equal(snapshot.providers.some(provider => provider.providerId === 'legacy_monitor' && provider.supersededBy), true);
await installMonitoring(api, { providerId: 'browser_monitor', logicalMonitoringKey: 'browser:monitor' });
const selectionRequired = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { requesterId: 'user', authorization: 'user-action' } });
assert.equal(selectionRequired.outcome, 'provider-selection-required');
const selected = await api.dispatch({ capability: 'audio-monitoring', command: 'select-provider', source: 'user', payload: { providerId: 'native_monitor' } });
assert.equal(selected.outcome, 'handled');
assert.equal(selected.payload.logicalMonitoringKey, 'shared:monitor');
assert.equal(legacy.calls.length, 0);
});
test('audio-monitoring shares compatible sessions and stops provider after final requester', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await installInput(api);
const monitoring = await installMonitoring(api);
const first = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { requesterId: 'user', authorization: 'user-action', requiredChannelShape: 'mono' } });
const second = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'note_detect', payload: { requesterId: 'note_detect', authorization: 'background', requiredChannelShape: 'mono' } });
const stopFirst = await api.dispatch({ capability: 'audio-monitoring', command: 'stop', source: 'user', payload: { requesterId: 'note_detect', monitoringId: first.payload.monitoringId } });
const stopSecond = await api.dispatch({ capability: 'audio-monitoring', command: 'stop', source: 'note_detect', payload: { requesterId: 'note_detect', monitoringId: second.payload.monitoringId } });
assert.equal(first.outcome, 'handled');
assert.equal(second.outcome, 'handled');
assert.equal(first.payload.monitoringId, second.payload.monitoringId);
assert.equal(stopFirst.payload.state, 'active');
assert.equal(stopFirst.payload.requesters.map(item => item.requesterId).join(','), 'note_detect');
assert.equal(stopSecond.outcome, 'stopped');
assert.deepEqual(monitoring.calls.map(call => call[0]), ['monitoring.start', 'monitoring.stop']);
const activeAgain = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { requesterId: 'user', authorization: 'user-action', requiredChannelShape: 'mono' } });
await api.dispatch({ capability: 'audio-monitoring', command: 'unregister-provider', source: 'system', payload: { providerId: monitoring.provider.providerId } });
const afterDisappear = window.slopsmith.audioSession.snapshot().domains['audio-monitoring'];
assert.equal(activeAgain.outcome, 'handled');
assert.equal(afterDisappear.sessions.some(session => session.state === 'orphaned'), true);
});
test('audio-monitoring owner can retry a stop after a transient provider stop failure', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await installInput(api);
let stopCalls = 0;
await installMonitoring(api, {
operationHandlers: {
'monitoring.stop': () => {
stopCalls += 1;
if (stopCalls === 1) return { outcome: 'failed', reason: 'transient stop failure' };
return { outcome: 'handled', status: 'stopped' };
},
},
});
const active = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { authorization: 'user-action', requiredChannelShape: 'mono' } });
const stopFails = await api.dispatch({ capability: 'audio-monitoring', command: 'stop', source: 'user', payload: { monitoringId: active.payload.monitoringId } });
// The failed final stop emptied the requester list before the provider confirmed; the original
// owner must still be able to retry rather than being locked out as a non-owner.
const stopRetry = await api.dispatch({ capability: 'audio-monitoring', command: 'stop', source: 'user', payload: { monitoringId: active.payload.monitoringId } });
assert.equal(active.outcome, 'handled');
assert.equal(stopFails.outcome, 'failed');
assert.equal(stopRetry.outcome, 'stopped');
assert.equal(stopCalls, 2);
});
test('audio-monitoring surfaces a provider denial reason even when a direct-monitor conflict is present', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await installInput(api);
await installMonitoring(api, { startResult: { outcome: 'handled', status: 'denied', reason: 'microphone permission blocked' } });
await api.dispatch({ capability: 'audio-monitoring', command: 'set-direct-monitor', source: 'user', payload: { state: 'unmuted' } });
// Provider reports a terminal denial while the request also carries a conflicting direct-monitor
// requirement; the terminal provider reason must win, not the conflict message.
const denied = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { authorization: 'user-action', requiredChannelShape: 'mono', directMonitorRequirement: 'muted' } });
assert.equal(denied.outcome, 'denied');
assert.match(denied.reason, /permission blocked/i);
assert.equal(/conflict/i.test(denied.reason || ''), false);
});
test('audio-monitoring normalizes an unsafe provider id before storing it', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await installInput(api);
await api.dispatch({ capability: 'audio-monitoring', command: 'register-provider', source: 'evil', payload: { providerId: '/Users/secret token=abcdef0123456789 monitor', logicalMonitoringKey: 'evil:main', operations: ['monitoring.start'], operationHandlers: { 'monitoring.start': () => ({ outcome: 'handled', status: 'active' }) } } });
const snapshot = window.slopsmith.audioSession.snapshot().domains['audio-monitoring'];
const provider = snapshot.providers[0];
// The surfaced providerId must be redacted + charset-restricted, never the raw path/token.
assert.equal(JSON.stringify(snapshot).includes('token=abcdef0123456789'), false);
assert.equal(provider.providerId.includes('/'), false);
assert.equal(provider.providerId.includes(' '), false);
});
test('audio-monitoring bounds caller-supplied identifiers reflected back in error reasons', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await installInput(api);
const evil = '/Users/secret/private token=supersecretvalue123 ' + 'x'.repeat(400);
const stop = await api.dispatch({ capability: 'audio-monitoring', command: 'stop', source: 'user', payload: { monitoringId: evil } });
const select = await api.dispatch({ capability: 'audio-monitoring', command: 'select-provider', source: 'user', payload: { providerId: evil } });
const unregister = await api.dispatch({ capability: 'audio-monitoring', command: 'unregister-provider', source: 'user', payload: { providerId: evil } });
assert.equal(stop.outcome, 'no-handler');
assert.equal(select.outcome, 'unavailable');
assert.equal(unregister.outcome, 'no-handler');
// Reflected identifiers must be bounded (no raw token, length-capped) before being returned.
assert.equal(stop.payload.monitoringId.length <= 240, true);
assert.equal(select.payload.logicalMonitoringKey.length <= 240, true);
assert.equal(unregister.payload.providerId.length <= 240, true);
for (const result of [stop, select, unregister]) {
assert.equal(JSON.stringify(result).includes('token=supersecretvalue123'), false);
}
});
test('audio-monitoring attaching to a shared session honors a conflicting direct-monitor requirement', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await installInput(api);
await installMonitoring(api);
await api.dispatch({ capability: 'audio-monitoring', command: 'set-direct-monitor', source: 'user', payload: { state: 'unmuted' } });
const first = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { authorization: 'user-action', requiredChannelShape: 'mono' } });
const attach = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'note_detect', payload: { authorization: 'user-action', requiredChannelShape: 'mono', directMonitorRequirement: 'muted' } });
assert.equal(first.outcome, 'handled');
// Same shared session, but the conflicting requirement must surface as degraded with an annotation.
assert.equal(attach.payload.monitoringId, first.payload.monitoringId);
assert.equal(attach.outcome, 'degraded');
const requester = attach.payload.requesters.find(item => item.requesterId === 'note_detect');
assert.equal(requester.status, 'degraded');
});
test('audio-monitoring start never lets a caller inject raw sourceRef fields', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await installInput(api);
await installMonitoring(api);
const first = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { authorization: 'user-action', requiredChannelShape: 'mono' } });
const realOpenId = first.payload.sourceRef.openSessionId;
assert.match(realOpenId, /^input-open-\d+$/);
// A caller passing a tracked openSessionId alongside an injected raw sourceId must not get that raw
// value into the monitoring session/response — the sourceRef is derived only from openInputSource.
const injected = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'note_detect', payload: { authorization: 'user-action', requiredChannelShape: 'mono', sourceRef: { openSessionId: realOpenId, sourceId: 'EVIL-/Users/secret-token-abc123' } } });
assert.equal(first.outcome, 'handled');
assert.equal(JSON.stringify(injected.payload).includes('EVIL'), false);
assert.equal(JSON.stringify(injected.payload).includes('secret-token'), false);
});
test('audio-monitoring start treats a void provider result as failed', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await installInput(api);
await installMonitoring(api, { providerId: 'void_monitor', logicalMonitoringKey: 'void:main', operationHandlers: { 'monitoring.start': () => undefined } });
const started = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { providerId: 'void_monitor', authorization: 'user-action', requiredChannelShape: 'mono' } });
// A provider that returns nothing from monitoring.start must not be reported as active.
assert.equal(started.outcome, 'failed');
});
test('audio-monitoring stopAll requires explicit user action', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await installInput(api);
await installMonitoring(api);
const active = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { authorization: 'user-action', requiredChannelShape: 'mono' } });
// A background requester must not be able to tear down everyone's monitoring.
const background = await api.dispatch({ capability: 'audio-monitoring', command: 'stop', source: 'note_detect', payload: { stopAll: true } });
const mid = window.slopsmith.audioSession.snapshot().domains['audio-monitoring'];
// An explicit user action can.
const user = await api.dispatch({ capability: 'audio-monitoring', command: 'stop', source: 'user', payload: { stopAll: true, authorization: 'user-action' } });
assert.equal(active.outcome, 'handled');
assert.equal(background.outcome, 'user-action-required');
assert.equal(mid.sessions.some(session => session.state === 'active'), true);
assert.equal(user.outcome, 'stopped');
});
test('audio-monitoring stop does not report stopped when the provider reports a terminal status', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await installInput(api);
await installMonitoring(api, { stopResult: { outcome: 'handled', status: 'failed', reason: 'device fell off the bus' } });
const active = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { authorization: 'user-action', requiredChannelShape: 'mono' } });
const stop = await api.dispatch({ capability: 'audio-monitoring', command: 'stop', source: 'user', payload: { monitoringId: active.payload.monitoringId } });
assert.equal(active.outcome, 'handled');
// Provider said handled but reported a terminal failed status — must surface failed, not stopped.
assert.equal(stop.outcome, 'failed');
assert.equal(stop.payload.state, 'failed');
});
test('audio-monitoring stop reports no-owner when the provider has disappeared', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await installInput(api);
const monitoring = await installMonitoring(api);
const active = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { authorization: 'user-action', requiredChannelShape: 'mono' } });
await api.dispatch({ capability: 'audio-monitoring', command: 'unregister-provider', source: 'system', payload: { providerId: monitoring.provider.providerId } });
const stopped = await api.dispatch({ capability: 'audio-monitoring', command: 'stop', source: 'user', payload: { monitoringId: active.payload.monitoringId } });
assert.equal(active.outcome, 'handled');
// The provider is gone, so we cannot confirm the live capture stopped — orphan rather than 'stopped'.
assert.equal(stopped.outcome, 'no-owner');
assert.equal(stopped.payload.state, 'orphaned');
});
test('audio-monitoring stop reports unsupported-command when the provider has no stop operation', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await installInput(api);
await installMonitoring(api, { providerId: 'nostop_monitor', logicalMonitoringKey: 'nostop:main', operations: ['monitoring.start'] });
const active = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { providerId: 'nostop_monitor', authorization: 'user-action', requiredChannelShape: 'mono' } });
const stopped = await api.dispatch({ capability: 'audio-monitoring', command: 'stop', source: 'user', payload: { monitoringId: active.payload.monitoringId } });
assert.equal(active.outcome, 'handled');
// No stop operation means we must not pretend the live capture stopped.
assert.equal(stopped.outcome, 'unsupported-command');
});
test('audio-monitoring events emit redaction-safe sessions', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
const startedEvents = captureEvents(window, 'audio-monitoring:monitoring-started');
await installInput(api);
await installMonitoring(api);
const active = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { authorization: 'user-action', requiredChannelShape: 'mono' } });
assert.equal(active.outcome, 'handled');
assert.equal(startedEvents.length, 1);
const evt = startedEvents[0];
// Events are broadcast to all observers, so internal ids/keys and the raw sourceId must be redacted
// (matching diagnostics) rather than emitted as a raw clone.
assert.notEqual(evt.monitoringId, active.payload.monitoringId);
assert.notEqual(evt.sessionKey, active.payload.sessionKey);
assert.equal(JSON.stringify(evt).includes('desktop-source'), false);
});
test('audio-monitoring rejects a providerId collision from a different owner', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await installInput(api);
const first = makeMonitoringProvider({ providerId: 'shared_id', ownerPluginId: 'plugin_a', logicalMonitoringKey: 'a:main' });
const second = makeMonitoringProvider({ providerId: 'shared_id', ownerPluginId: 'plugin_b', logicalMonitoringKey: 'b:main' });
const reg1 = await api.dispatch({ capability: 'audio-monitoring', command: 'register-provider', source: 'plugin_a', payload: first.provider });
const reg2 = await api.dispatch({ capability: 'audio-monitoring', command: 'register-provider', source: 'plugin_b', payload: second.provider });
// The guard must also hold when the colliding registration omits ownerPluginId (no silent inherit).
const sneaky = makeMonitoringProvider({ providerId: 'shared_id', logicalMonitoringKey: 'c:main' });
delete sneaky.provider.ownerPluginId;
const reg3 = await api.dispatch({ capability: 'audio-monitoring', command: 'register-provider', source: 'plugin_c', payload: sneaky.provider });
const snapshot = window.slopsmith.audioSession.snapshot().domains['audio-monitoring'];
assert.equal(reg1.outcome, 'handled');
assert.equal(reg2.outcome, 'failed');
assert.equal(reg3.outcome, 'failed');
// The original owner's provider must remain intact, not be silently overwritten.
const provider = snapshot.providers.find(entry => entry.providerId === 'shared_id');
assert.equal(provider.logicalMonitoringKey, 'a:main');
});
test('audio-monitoring session keeps openInputSessionId verbatim for cross-domain correlation', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await installInput(api);
await installMonitoring(api);
const active = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { authorization: 'user-action', requiredChannelShape: 'mono' } });
const session = window.slopsmith.audioSession.snapshot().domains['audio-monitoring'].sessions.at(-1);
assert.equal(active.outcome, 'handled');
assert.match(active.payload.openInputSessionId, /^input-open-\d+$/);
// The redacted snapshot must keep the real input-open id verbatim (not a fresh pseudonym) so it
// still correlates with the audio-input open session inside the same diagnostics snapshot.
assert.equal(session.openInputSessionId, active.payload.openInputSessionId);
assert.equal(session.sourceRef.openSessionId, active.payload.openInputSessionId);
});
test('audio-monitoring status refresh tolerates a void provider result without faking active', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await installInput(api);
await installMonitoring(api, { availability: 'available', operationHandlers: { 'monitoring.status': () => undefined } });
await api.dispatch({ capability: 'audio-monitoring', command: 'inspect', source: 'user', payload: { includeStatus: true } });
const outcomes = window.slopsmith.audioSession.snapshot().recentOutcomes.filter(entry => entry.domain === 'audio-monitoring' && entry.operation === 'status');
// A void status reply is tolerated but must not be recorded as an 'active' session.
assert.equal(outcomes.length >= 1, true);
assert.equal(outcomes.every(entry => entry.status !== 'active'), true);
});
test('audio-monitoring status refresh does not leak the raw device sourceId to providers', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await installInput(api);
let statusSourceRef = null;
await installMonitoring(api, { operationHandlers: { 'monitoring.status': (request) => { statusSourceRef = request.sourceRef; return { outcome: 'handled', status: 'active' }; } } });
await api.dispatch({ capability: 'audio-monitoring', command: 'inspect', source: 'user', payload: { includeStatus: true } });
assert.notEqual(statusSourceRef, null);
// The prompt-free status request must not carry the raw device sourceId.
assert.equal(statusSourceRef.sourceId, '');
assert.equal(JSON.stringify(statusSourceRef).includes('desktop-source'), false);
});
test('audio-monitoring status refresh does not downgrade availability on a non-state provider reply', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await installInput(api);
await installMonitoring(api, { availability: 'available', statusResult: { outcome: 'no-handler' } });
const inspected = await api.dispatch({ capability: 'audio-monitoring', command: 'inspect', source: 'user', payload: { includeStatus: true } });
const provider = inspected.payload.providers.find(entry => entry.providerId === 'desktop_monitor');
// A status handler that returns a non-state outcome (no availability) must leave the provider's
// availability intact rather than clamping it to 'unknown'.
assert.equal(provider.availability, 'available');
});
test('audio-monitoring re-keys active sessions on a preference change so requesters still attach', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await installInput(api);
await installMonitoring(api);
await api.dispatch({ capability: 'audio-monitoring', command: 'set-direct-monitor', source: 'user', payload: { state: 'muted' } });
const first = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { authorization: 'user-action', requiredChannelShape: 'mono' } });
// User flips the preference; the active session's key must follow it.
await api.dispatch({ capability: 'audio-monitoring', command: 'set-direct-monitor', source: 'user', payload: { state: 'unmuted' } });
// A background requester should now attach to the existing session under the new preference,
// rather than failing to match and being told user-action-required.
const attach = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'note_detect', payload: { authorization: 'background', requiredChannelShape: 'mono' } });
assert.equal(first.outcome, 'handled');
assert.equal(attach.outcome, 'handled');
assert.equal(attach.payload.monitoringId, first.payload.monitoringId);
});
test('audio-monitoring does not assume direct-monitor applied without provider confirmation', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await installInput(api);
await installMonitoring(api, { directMonitorResult: { outcome: 'handled' } });
await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { authorization: 'user-action', requiredChannelShape: 'mono' } });
const changed = await api.dispatch({ capability: 'audio-monitoring', command: 'set-direct-monitor', source: 'user', payload: { state: 'unmuted' } });
const snapshot = window.slopsmith.audioSession.snapshot().domains['audio-monitoring'];
assert.equal(changed.outcome, 'handled');
// The provider handled the request but did not confirm application, so applied must stay false.
assert.equal(snapshot.sessions.at(-1).directMonitor.applied, false);
assert.equal(snapshot.directMonitor.applied, false);
});
test('audio-monitoring direct-monitor summary preserves an unavailable control state', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await installInput(api);
await installMonitoring(api, {
directMonitorResult: { outcome: 'handled', summary: { directMonitor: { state: 'unmuted', control: 'unavailable', applied: false, reason: 'temporarily unavailable' } } },
});
await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { authorization: 'user-action', requiredChannelShape: 'mono' } });
const changed = await api.dispatch({ capability: 'audio-monitoring', command: 'set-direct-monitor', source: 'user', payload: { state: 'unmuted' } });
const snapshot = window.slopsmith.audioSession.snapshot().domains['audio-monitoring'];
// A session that reports control 'unavailable' must not be collapsed to 'unknown' in the rollup.
assert.equal(changed.payload.directMonitor.control, 'unavailable');
assert.equal(snapshot.directMonitor.control, 'unavailable');
assert.equal(snapshot.directMonitor.applied, false);
});
test('audio-monitoring direct-monitor summary reflects a provider that handles but does not apply', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await installInput(api);
await installMonitoring(api, {
directMonitorResult: { outcome: 'handled', summary: { directMonitor: { state: 'unmuted', control: 'supported', applied: false, reason: 'hardware busy' } } },
});
await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { authorization: 'user-action', requiredChannelShape: 'mono' } });
const changed = await api.dispatch({ capability: 'audio-monitoring', command: 'set-direct-monitor', source: 'user', payload: { state: 'unmuted' } });
const snapshot = window.slopsmith.audioSession.snapshot().domains['audio-monitoring'];
assert.equal(changed.outcome, 'handled');
// Provider handled the request but reported it was not applied; the domain summary must not
// collapse that into applied:true just because the aggregate outcome was handled.
assert.equal(changed.payload.directMonitor.applied, false);
assert.equal(snapshot.directMonitor.applied, false);
assert.equal(snapshot.directMonitor.control, 'supported');
assert.equal(snapshot.sessions.at(-1).directMonitor.applied, false);
// The provider's per-session note must survive into the domain-level summary.
assert.match(snapshot.directMonitor.reason, /hardware busy/i);
});
test('audio-monitoring direct monitor preference is user authoritative', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
await installInput(api);
const monitoring = await installMonitoring(api, { directMonitor: { state: 'muted', control: 'supported', preference: 'muted', applied: true } });
const changed = await api.dispatch({ capability: 'audio-monitoring', command: 'set-direct-monitor', source: 'user', payload: { state: 'unmuted' } });
const startConflict = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'note_detect', payload: { requesterId: 'note_detect', authorization: 'user-action', directMonitorRequirement: 'muted', requiredChannelShape: 'mono' } });
const snapshot = window.slopsmith.audioSession.snapshot().domains['audio-monitoring'];
assert.equal(changed.outcome, 'handled');
assert.equal(startConflict.outcome, 'degraded');
assert.equal(startConflict.payload.directMonitor.preference, 'unmuted');
assert.equal(snapshot.directMonitor.preference, 'unmuted');
assert.equal(snapshot.sessions.at(-1).requesters[0].status, 'degraded');
assert.equal(monitoring.calls.some(call => call[0] === 'monitoring.set-direct-monitor'), false);
});
test('audio-monitoring direct monitor unsupported control remains diagnosable', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
const changedEvents = captureEvents(window, 'audio-monitoring:direct-monitor-changed');
await installInput(api);
await installMonitoring(api, { providerId: 'noctl_monitor', logicalMonitoringKey: 'noctl:monitor', operations: ['monitoring.start', 'monitoring.stop'] });
const active = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { providerId: 'noctl_monitor', requesterId: 'user', authorization: 'user-action', requiredChannelShape: 'mono' } });
const changed = await api.dispatch({ capability: 'audio-monitoring', command: 'set-direct-monitor', source: 'user', payload: { state: 'unmuted' } });
const snapshot = window.slopsmith.audioSession.snapshot().domains['audio-monitoring'];
assert.equal(active.outcome, 'handled');
assert.equal(changed.outcome, 'unsupported-command');
assert.equal(changed.payload.directMonitor.preference, 'unmuted');
assert.equal(snapshot.sessions.at(-1).directMonitor.control, 'unsupported');
assert.equal(changedEvents.length, 1);
});
test('audio-monitoring distinguishes failure outcomes and prompt-free status inspection', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
const input = await installInput(api, { channelSummary: { channelCount: 1, channelShape: 'mono', supports: ['mono'] } });
const monitoring = await installMonitoring(api);
const inspect = await api.dispatch({ capability: 'audio-monitoring', command: 'inspect', source: 'support', payload: { includeStatus: true } });
const unsupportedCommand = await api.dispatch({ capability: 'audio-monitoring', command: 'not-real', source: 'support' });
const unsupportedProvider = await installMonitoring(api, { providerId: 'unsupported_monitor', logicalMonitoringKey: 'unsupported:main', operations: ['monitoring.status'] });
const unsupported = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { providerId: unsupportedProvider.provider.providerId, requesterId: 'user', authorization: 'user-action', requiredChannelShape: 'mono' } });
const incompatible = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { providerId: monitoring.provider.providerId, requesterId: 'user', authorization: 'user-action', requiredChannelShape: 'stereo' } });
const badVersion = await api.dispatch({ capability: 'audio-monitoring', command: 'register-provider', source: 'bad', payload: { providerId: 'bad-monitor', version: 2 } });
const missingStop = await api.dispatch({ capability: 'audio-monitoring', command: 'stop', source: 'support', payload: { monitoringId: 'missing-monitor' } });
const timeoutProvider = await installMonitoring(api, { providerId: 'timeout_monitor', logicalMonitoringKey: 'timeout:main', operationHandlers: { 'monitoring.start': () => new Promise(resolve => setTimeout(() => resolve({ outcome: 'handled' }), 2100)) } });
const timedOut = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { providerId: timeoutProvider.provider.providerId, requesterId: 'user', authorization: 'user-action', requiredChannelShape: 'mono' } });
const malformedProvider = await installMonitoring(api, { providerId: 'malformed_monitor', logicalMonitoringKey: 'malformed:main', operationHandlers: { 'monitoring.start': () => 'not-object' } });
const malformed = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { providerId: malformedProvider.provider.providerId, requesterId: 'user', authorization: 'user-action', requiredChannelShape: 'mono' } });
assert.equal(inspect.outcome, 'handled');
assert.equal(unsupportedCommand.outcome, 'unsupported-command');
assert.equal(unsupported.outcome, 'unsupported-command');
assert.equal(incompatible.outcome, 'incompatible');
assert.equal(badVersion.outcome, 'incompatible-version');
assert.equal(missingStop.outcome, 'no-handler');
assert.equal(timedOut.outcome, 'failed');
assert.match(timedOut.reason, /timed out/i);
assert.equal(malformed.outcome, 'failed');
assert.deepEqual(input.calls.map(call => call[0]), ['source.open']);
assert.equal(monitoring.calls.some(call => call[0] === 'monitoring.status'), true);
assert.equal(monitoring.calls.some(call => call[0] === 'monitoring.start'), false);
const outcomes = window.slopsmith.audioSession.snapshot().recentOutcomes.filter(entry => entry.domain === 'audio-monitoring');
for (const outcome of ['handled', 'unsupported-command', 'incompatible', 'incompatible-version', 'no-handler']) {
assert.equal(outcomes.some(entry => entry.outcome === outcome), true, `missing outcome ${outcome}`);
}
});
test('audio-session diagnostics remain bounded during frequent input monitoring updates', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
for (let index = 0; index < 80; index += 1) {
await api.dispatch({ capability: 'audio-input', command: 'register-source', source: 'bench', payload: { sourceId: `source-${index}`, logicalSourceKey: `bench:source:${index}`, providerId: 'bench', safeLabel: `/Users/example/private-${index}`, channelSummary: { channelCount: 1, channelShape: 'mono', supports: ['mono'] }, operations: ['source.open'], operationHandlers: { 'source.open': () => ({ outcome: 'handled' }) } } });
await api.dispatch({ capability: 'audio-monitoring', command: 'register-provider', source: 'bench', payload: { providerId: `monitor-${index}`, logicalMonitoringKey: `bench:monitor:${index}`, operations: ['monitoring.start'], operationHandlers: { 'monitoring.start': () => ({ outcome: 'handled', status: 'active' }) } } });
}
const snapshot = window.slopsmith.audioSession.snapshot();
const encoded = JSON.stringify(snapshot);
assert.equal(snapshot.recentOutcomes.length <= 100, true);
assert.equal(encoded.length < 96 * 1024, true);
assert.equal(encoded.includes('/Users/example'), false);
});
+121
View File
@@ -0,0 +1,121 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { loadAudioSession, runBrowserScript, installMixerDom } = require('./audio_session_test_harness');
test('audio session records route transitions without blocking callers', () => {
const window = loadAudioSession();
const audioSession = window.slopsmith.audioSession;
const html5 = audioSession.setRoute({ routeKind: 'html5', availability: 'available', selectedByUser: true });
const stems = audioSession.setRoute({ routeKind: 'stems', availability: 'available', selectedByUser: true });
const juce = audioSession.setRoute({ routeKind: 'juce', availability: 'degraded', fallbackReason: 'native route unavailable' });
const snapshot = audioSession.snapshot();
assert.equal(html5.routeKind, 'html5');
assert.equal(stems.routeKind, 'stems');
assert.equal(juce.availability, 'degraded');
assert.equal(snapshot.domains['audio-mix'].route.routeKind, 'juce');
assert.equal(snapshot.recentOutcomes.at(-1).outcome, 'degraded');
});
test('legacy song fader registration is bridged into audio-mix participants and route diagnostics', async () => {
const window = loadAudioSession();
const { audio } = installMixerDom(window);
window.localStorage.setItem('volume', '65');
runBrowserScript(window, 'static/audio-mixer.js');
assert.equal(typeof window.slopsmith.audio.applySongVolume, 'function');
await window.slopsmith.audio.applySongVolume(72);
const snapshot = window.slopsmith.audioSession.snapshot();
const songParticipant = snapshot.domains['audio-mix'].participants.find(p => p.participantId === 'core.song');
assert.equal(audio.volume, 0.72);
assert.equal(songParticipant.label, 'Song');
assert.equal(songParticipant.fader.currentValue, 72);
assert.equal(snapshot.domains['audio-mix'].route.routeKind, 'html5');
assert.equal(snapshot.domains['audio-mix'].bridges.some(b => b.bridgeId === 'audio-mix.song-volume'), true);
});
test('song volume persists through html5 stems and desktop routes', async () => {
const window = loadAudioSession();
const { audio } = installMixerDom(window);
const stemsCalls = [];
const desktopCalls = [];
window.localStorage.setItem('volume', '41');
window.slopsmith.stems = { setMasterVolume(value) { stemsCalls.push(value); return Promise.resolve(); } };
window.slopsmithDesktop = { audio: { setGain(name, value) { desktopCalls.push([name, value]); return Promise.resolve(); } } };
runBrowserScript(window, 'static/audio-mixer.js');
assert.equal(window.slopsmith.audio.readSongVolume(), 41);
await window.slopsmith.audio.applySongVolume(55);
assert.equal(audio.volume, 0.55);
assert.equal(stemsCalls.at(-1), 0.55);
assert.equal(window.slopsmith.audioSession.snapshot().domains['audio-mix'].route.routeKind, 'stems');
window._juceMode = true;
delete window.slopsmith.stems;
await window.slopsmith.audio.applySongVolume(66);
assert.deepEqual(desktopCalls.at(-1), ['backing', 0.66]);
assert.equal(window.slopsmith.audioSession.snapshot().domains['audio-mix'].route.routeKind, 'juce');
});
test('stems provider ownership remains separate from audio-mix stem participation', async () => {
const window = loadAudioSession();
const audioSession = window.slopsmith.audioSession;
audioSession.startSession({ sessionId: 'main:stems-song' });
audioSession.registerStemOwner({ ownerId: 'stems_plugin', stemIds: ['guitar', 'bass'], availability: 'available' });
audioSession.registerMixParticipant({
participantId: 'stems.master',
ownerPluginId: 'stems_plugin',
label: 'Stems',
kind: 'stem',
sourceMode: 'native',
fader: { id: 'master', label: 'Stems', min: 0, max: 1, step: 0.1, defaultValue: 1, currentValue: 1 },
operations: ['fader.get-value', 'fader.set-value'],
});
const stemsInspect = await window.slopsmith.capabilities.dispatch({ capability: 'stems', command: 'inspect', source: 'test' });
const mixInspect = await window.slopsmith.capabilities.dispatch({ capability: 'audio-mix', command: 'inspect', source: 'test' });
assert.equal(stemsInspect.payload.owner.ownerId, 'stems_plugin');
assert.equal(mixInspect.payload.faders.some(fader => fader.kind === 'stem' && fader.ownerPluginId === 'stems_plugin'), true);
});
test('audio-input selection and registered providers survive song session switches without live sessions', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
const audioSession = window.slopsmith.audioSession;
audioSession.startSession({ sessionId: 'main:first-song', songKey: 'first-song.sloppak', songFormat: 'sloppak' });
await api.dispatch({
capability: 'audio-input',
command: 'register-source',
source: 'note_detect',
payload: {
sourceId: 'switch-source',
logicalSourceKey: 'switch:instrument:primary',
providerId: 'note_detect',
kind: 'instrument',
safeLabel: 'Switch Input',
channelSummary: { channelCount: 1, channelShape: 'mono', supports: ['mono'] },
operations: ['source.open', 'source.close'],
operationHandlers: {
'source.open': () => ({ outcome: 'handled' }),
'source.close': () => ({ outcome: 'handled' }),
},
},
});
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'switch:instrument:primary' } });
const open = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect', requiredChannelShape: 'mono' } });
assert.equal(open.outcome, 'handled');
const next = audioSession.startSession({ sessionId: 'main:second-song', songKey: 'second-song.psarc', songFormat: 'psarc' });
const listed = await api.dispatch({ capability: 'audio-input', command: 'list-sources', source: 'note_detect' });
assert.equal(next.session.songFormat, 'psarc');
assert.equal(next.domains['audio-input'].selected.logicalSourceKey, 'switch:instrument:primary');
assert.equal(next.domains['audio-input'].totalOpenSessions, 0);
assert.equal(listed.payload.sources.some(source => source.logicalSourceKey === 'switch:instrument:primary'), true);
});
+62
View File
@@ -0,0 +1,62 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { loadAudioSession } = require('./audio_session_test_harness');
test('stem owner claim restore orphan and manual override lifecycle is recorded', async () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
const audioSession = window.slopsmith.audioSession;
const noOwner = await api.dispatch({ capability: 'stems', command: 'mute', source: 'nam_tone', payload: { stemIds: ['guitar'] } });
assert.equal(noOwner.outcome, 'no-owner');
audioSession.registerStemOwner({ ownerId: 'stems.plugin', stemIds: ['guitar', 'bass'], stemStates: { guitar: { muted: false } } });
const muted = await api.dispatch({ capability: 'stems', command: 'mute', source: 'nam_tone', payload: { claimId: 'nam.amp-active', stemIds: ['guitar'] } });
assert.equal(muted.status, 'applied');
assert.equal(muted.payload.state, 'active');
assert.equal(api.snapshotDiagnostics().activeClaims.some(claim => claim.claimId === 'nam.amp-active'), true);
const override = audioSession.recordStemManualOverride({ stemIds: ['guitar'], requester: 'user' });
assert.equal(override.overriddenClaims.length, 1);
assert.equal(audioSession.snapshot().domains.stems.claims[0].state, 'overridden');
const restored = await api.dispatch({ capability: 'stems', command: 'restore', source: 'nam_tone', payload: { claimId: 'nam.amp-active' } });
assert.equal(restored.outcome, 'overridden');
});
test('audio session coordinates stems without replacing the active stems owner', () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
const audioSession = window.slopsmith.audioSession;
let stemsPipeline = api.inspect('stems');
let coordinator = stemsPipeline.participants.find(entry => entry.pluginId === 'core.audio.session');
assert.equal(coordinator.roles.includes('coordinator'), true);
assert.equal(coordinator.roles.includes('owner'), false);
audioSession.registerStemOwner({ ownerId: 'stems.plugin', stemIds: ['guitar'] });
stemsPipeline = api.inspect('stems');
coordinator = stemsPipeline.participants.find(entry => entry.pluginId === 'core.audio.session');
const provider = stemsPipeline.participants.find(entry => entry.pluginId === 'stems.plugin');
assert.equal(coordinator.roles.includes('owner'), false);
assert.equal(provider.roles.includes('provider'), true);
assert.equal(audioSession.snapshot().domains.stems.owner.ownerId, 'stems.plugin');
});
test('stem automation claims become orphaned when owner disappears', () => {
const window = loadAudioSession();
const api = window.slopsmith.capabilities;
const audioSession = window.slopsmith.audioSession;
const unavailableEvents = [];
api.subscribe('stems:owner-unavailable', detail => unavailableEvents.push(detail));
audioSession.registerStemOwner({ ownerId: 'stems.plugin', stemIds: ['guitar'] });
audioSession.muteStems({ claimId: 'claim-one', requester: 'nam_tone', stemIds: ['guitar'] });
audioSession.registerStemOwner({ ownerId: 'stems.plugin', availability: 'disabled', stemIds: ['guitar'] });
const claim = audioSession.snapshot().domains.stems.claims.find(entry => entry.claimId === 'claim-one');
assert.equal(claim.state, 'orphaned');
assert.equal(unavailableEvents.length, 1);
assert.equal(unavailableEvents[0].payload.availability, 'disabled');
});
@@ -0,0 +1,18 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { loadAudioSession, runBrowserScript, installMixerDom } = require('./audio_session_test_harness');
test('Stems master-volume compatibility bridge hit is attributed through audio session', async () => {
const window = loadAudioSession();
const calls = [];
installMixerDom(window);
window.slopsmith.stems = { setMasterVolume(value) { calls.push(value); } };
runBrowserScript(window, 'static/audio-mixer.js');
await window.slopsmith.audio.applySongVolume(50);
const snapshot = window.slopsmith.audioSession.snapshot();
assert.deepEqual(calls, [0.5]);
assert.equal(snapshot.domains.stems.bridges.some(bridge => bridge.bridgeId === 'stems.master-volume'), true);
assert.equal(window.slopsmith.capabilities.snapshotDiagnostics().compatibilityShims.some(shim => shim.shimId === 'stems.master-volume' && shim.hitCount >= 1), true);
});
+193
View File
@@ -0,0 +1,193 @@
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { createWindow, ROOT } = require('./capabilities_test_harness');
const CAPABILITIES_JS = path.join(ROOT, 'static', 'capabilities.js');
const AUDIO_SESSION_JS = path.join(ROOT, 'static', 'capabilities', 'audio-session.js');
const AUDIO_MIXER_JS = path.join(ROOT, 'static', 'audio-mixer.js');
function loadAudioSession(options = {}) {
const window = createWindow(options);
const context = vm.createContext(window);
vm.runInContext(fs.readFileSync(CAPABILITIES_JS, 'utf8'), context, { filename: CAPABILITIES_JS });
vm.runInContext(fs.readFileSync(AUDIO_SESSION_JS, 'utf8'), context, { filename: AUDIO_SESSION_JS });
window.__vmContext = context;
return window;
}
function runBrowserScript(window, relativePath) {
const filePath = path.join(ROOT, relativePath);
vm.runInContext(fs.readFileSync(filePath, 'utf8'), window.__vmContext, { filename: filePath });
}
function captureEvents(window, eventName) {
const events = [];
window.slopsmith.on(eventName, event => events.push(event.detail));
return events;
}
function diagnosticsSnapshot(window) {
return window.slopsmith.audioSession.snapshot();
}
function storageEntries(window) {
return Object.fromEntries(window.__storage || new Map());
}
function makeInputProvider(overrides = {}) {
const calls = [];
const sources = overrides.sources || [];
return {
calls,
source: {
sourceId: overrides.sourceId || 'provider-source-1',
logicalSourceKey: overrides.logicalSourceKey || 'provider:input:primary',
providerId: overrides.providerId || 'provider',
ownerPluginId: overrides.ownerPluginId || overrides.providerId || 'provider',
kind: overrides.kind || 'instrument',
safeLabel: overrides.safeLabel || 'Input 1',
availability: overrides.availability || 'available',
channelSummary: overrides.channelSummary || { channelCount: 2, channelShape: 'stereo', supports: ['mono', 'stereo'] },
sourceMode: overrides.sourceMode || 'native',
operations: overrides.operations || ['source.enumerate', 'source.open', 'source.close'],
operationHandlers: {
'source.enumerate': request => { calls.push(['source.enumerate', request]); return { sources }; },
'source.open': request => { calls.push(['source.open', request]); return overrides.openResult || { outcome: 'handled', status: 'open' }; },
'source.close': request => { calls.push(['source.close', request]); return overrides.closeResult || { outcome: 'handled', status: 'closed' }; },
...(overrides.operationHandlers || {}),
},
},
};
}
function makeMonitoringProvider(overrides = {}) {
const calls = [];
const providerId = overrides.providerId || 'monitoring-provider';
const logicalMonitoringKey = overrides.logicalMonitoringKey || `${providerId}:main`;
const operations = overrides.operations || ['monitoring.start', 'monitoring.stop', 'monitoring.status', 'monitoring.set-direct-monitor'];
const defaultHandlers = {
'monitoring.start': request => {
calls.push(['monitoring.start', request]);
return overrides.startResult || { outcome: 'handled', status: 'active', summary: { directMonitor: overrides.directMonitor || { state: 'muted', control: 'supported', preference: 'muted', applied: true }, latencySummary: overrides.latencySummary || { bucket: 'low' } } };
},
'monitoring.stop': request => {
calls.push(['monitoring.stop', request]);
return overrides.stopResult || { outcome: 'handled', status: 'stopped' };
},
'monitoring.status': request => {
calls.push(['monitoring.status', request]);
return overrides.statusResult || { outcome: 'handled', status: 'active', summary: { availability: 'available', directMonitor: overrides.directMonitor || { state: 'muted', control: 'supported', preference: 'muted', applied: true }, latencySummary: overrides.latencySummary || { bucket: 'low' } } };
},
'monitoring.set-direct-monitor': request => {
calls.push(['monitoring.set-direct-monitor', request]);
return overrides.directMonitorResult || { outcome: 'handled', status: 'active', summary: { directMonitor: { state: request.state, control: 'supported', preference: request.state, applied: true } } };
},
};
const operationHandlers = {};
for (const operation of operations) {
if (defaultHandlers[operation]) operationHandlers[operation] = defaultHandlers[operation];
}
Object.assign(operationHandlers, overrides.operationHandlers || {});
return {
calls,
provider: {
providerId,
ownerPluginId: overrides.ownerPluginId || providerId,
logicalMonitoringKey,
safeLabel: overrides.safeLabel || 'Monitoring Provider',
availability: overrides.availability || 'available',
sourceMode: overrides.sourceMode || 'native',
compatibilitySource: overrides.compatibilitySource || '',
operations,
directMonitor: overrides.directMonitor || { state: 'muted', control: 'supported', preference: 'muted', applied: true },
latencySummary: overrides.latencySummary || { bucket: 'low' },
operationHandlers,
},
};
}
function installDeterministicTimers(window) {
const timers = [];
let nextId = 1;
window.setTimeout = (callback, delay = 0) => {
const id = nextId;
nextId += 1;
timers.push({ id, callback, delay, cleared: false });
return id;
};
window.clearTimeout = id => {
const timer = timers.find(item => item.id === id);
if (timer) timer.cleared = true;
};
window.__runTimers = (minimumDelay = 0) => {
for (const timer of timers.slice()) {
if (timer.cleared || timer.delay < minimumDelay) continue;
timer.cleared = true;
timer.callback();
}
};
return timers;
}
function makeElement(tagName) {
return {
tagName,
className: '',
textContent: '',
value: '',
innerHTML: '',
disabled: false,
title: '',
style: {},
children: [],
listeners: {},
classList: {
values: new Set(),
add(name) { this.values.add(name); },
remove(name) { this.values.delete(name); },
contains(name) { return this.values.has(name); },
},
setAttribute(name, value) { this[name] = String(value); },
appendChild(child) { this.children.push(child); return child; },
addEventListener(type, handler) { this.listeners[type] = handler; },
contains() { return false; },
focus() {},
};
}
function installMixerDom(window) {
const elements = new Map();
const audio = { volume: 0, src: '', load() {} };
const button = makeElement('button');
const popover = makeElement('div');
elements.set('audio', audio);
elements.set('btn-mixer', button);
elements.set('mixer-popover', popover);
window.Event = class Event { constructor(type) { this.type = type; } };
window.document.readyState = 'complete';
window.document.getElementById = id => elements.get(id) || null;
window.document.addEventListener = () => {};
window.document.removeEventListener = () => {};
window.document.createElement = makeElement;
return { elements, audio, button, popover };
}
function loadAudioMixer(window) {
runBrowserScript(window, path.relative(ROOT, AUDIO_MIXER_JS));
return window.slopsmith.audio;
}
module.exports = {
loadAudioSession,
runBrowserScript,
captureEvents,
diagnosticsSnapshot,
storageEntries,
makeInputProvider,
makeMonitoringProvider,
installDeterministicTimers,
installMixerDom,
loadAudioMixer,
ROOT,
};
+79
View File
@@ -0,0 +1,79 @@
// Verify static/highway.js emits `beats:loaded` exactly once when the
// WebSocket delivers the song's beats array, with `{ count }` payload.
// Plugins that need to know when beats are available (metronome, beat-
// snapping editors, sync visualizers) consume this contract.
//
// Same isolation strategy as the other tests/js/ files — extract just
// the relevant case-block source by string matching and exercise it in
// a vm sandbox with stubbed deps.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const HIGHWAY_JS = path.join(__dirname, '..', '..', 'static', 'highway.js');
// Extract a single switch case body so assertions can match against just
// that block rather than a fixed-length slice (more robust to harmless
// edits adjacent to the case).
function getCaseBlock(src, label) {
const start = src.indexOf(`case '${label}'`);
assert.ok(start !== -1, `case '${label}' not found in highway.js`);
const tail = src.slice(start);
const nextCase = tail.search(/\n\s*case\s+['"]/);
const nextDefault = tail.search(/\n\s*default\s*:/);
let end = tail.length;
if (nextCase > 0) end = Math.min(end, nextCase);
if (nextDefault > 0) end = Math.min(end, nextDefault);
return tail.slice(0, end);
}
test('beats:loaded emit is wired into the WS beats case', () => {
// Source-level guard: catch a future contributor removing the emit
// (regression) or replacing window.slopsmith.emit with something
// else (intentional refactor — this test then needs updating).
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
const block = getCaseBlock(src, 'beats');
assert.match(
block,
/window\.slopsmith\.emit\(\s*['"]beats:loaded['"]/,
'beats case must emit beats:loaded',
);
assert.match(
block,
/count:\s*beats\.length/,
'beats:loaded payload must include count = beats.length',
);
});
test('beats:loaded emit is guarded against missing window.slopsmith', () => {
// The WS handler can fire before the slopsmith namespace is defined
// (early in app boot). The emit must be guarded so a missing
// namespace doesn't throw inside the WS message dispatcher.
// Looser pattern accepts any guard that reads window.slopsmith
// (including typeof checks and combined conditions) rather than
// mandating the exact `if (window.slopsmith)` form.
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
const block = getCaseBlock(src, 'beats');
assert.match(
block,
/if\s*\(\s*[^)]*window\.slopsmith\b[^)]*\)/,
'beats:loaded emit must be guarded against a missing window.slopsmith',
);
});
test('beats:loaded guard verifies emit is callable (typeof check)', () => {
// A partially-attached namespace (window.slopsmith exists but emit
// isn't a function yet during early boot) would throw without this
// extra check. A truthy check (`window.slopsmith.emit && ...`) lets
// non-callable values pass; require an explicit typeof === 'function'
// check so the guard catches that real edge.
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
const block = getCaseBlock(src, 'beats');
assert.match(
block,
/typeof\s+window\.slopsmith\.emit\s*===\s*['"]function['"]/,
'guard must use typeof === \'function\' (not just truthy) to confirm emit is callable',
);
});
+65
View File
@@ -0,0 +1,65 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { loadCapabilities } = require('./capabilities_test_harness');
function registerStemsOwner(api, calls) {
api.registerParticipant('stems', {
stems: {
roles: ['owner', 'provider'],
commands: ['mute', 'restore'],
runtime: true,
handlers: {
mute: (ctx) => {
calls.push(['mute', ctx.payload]);
return { outcome: 'handled', payload: { muted: true, restoreSnapshotRef: 'snap-1' } };
},
restore: (ctx) => {
calls.push(['restore', ctx.payload]);
return { outcome: 'handled', payload: { restored: true } };
},
},
},
});
}
test('claim dispatch release records lifecycle and removes active claim', async () => {
const window = loadCapabilities();
const api = window.slopsmith.capabilities;
const calls = [];
registerStemsOwner(api, calls);
const cleanup = api.claim({ capability: 'stems', claimId: 'nam.amp-active', requester: 'nam_tone', target: { kind: 'guitar' } });
let snapshot = api.snapshotDiagnostics();
assert.equal(snapshot.activeClaims.find(c => c.claimId === 'nam.amp-active').owner, 'stems');
const result = await api.dispatch({
capability: 'stems', command: 'mute', source: 'nam_tone',
claim: { claimId: 'nam.amp-active' }, args: { target: { kind: 'guitar' } },
});
assert.equal(result.status, 'applied');
assert.equal(calls.length, 1);
cleanup();
snapshot = api.snapshotDiagnostics();
assert.equal(snapshot.activeClaims.some(c => c.claimId === 'nam.amp-active'), false);
assert.equal(snapshot.claimLifecycle.some(c => c.claimId === 'nam.amp-active' && c.state === 'released'), true);
assert.equal(snapshot.claimLifecycle.find(c => c.claimId === 'nam.amp-active').restoreSnapshotRef, null);
});
test('manual override is terminal for matching active claim target', async () => {
const window = loadCapabilities();
const api = window.slopsmith.capabilities;
const calls = [];
registerStemsOwner(api, calls);
api.claim({ capability: 'stems', claimId: 'nam.amp-active', requester: 'nam_tone', target: { kind: 'guitar' } });
api.recordUserOverride({ capability: 'stems', source: 'user', target: { kind: 'guitar' }, reason: 'Player unmuted guitar' });
const result = await api.dispatch({
capability: 'stems', command: 'mute', source: 'nam_tone',
claim: { claimId: 'nam.amp-active' }, args: { target: { kind: 'guitar' } },
});
assert.equal(result.status, 'overridden');
assert.equal(calls.length, 0);
assert.match(result.reason, /user override/i);
});
+183
View File
@@ -0,0 +1,183 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { loadCapabilities } = require('./capabilities_test_harness');
test('diagnostics snapshots redact paths and trim recent decisions under 64 KB', async () => {
const window = loadCapabilities();
const api = window.slopsmith.capabilities;
api.registerParticipant('owner', {
stems: {
roles: ['owner'],
commands: ['mute'],
runtime: true,
handlers: { mute: () => ({ outcome: 'failed', reason: 'token=abc123 path /Users/example/secret/file.txt ' + 'x'.repeat(2000) }) },
},
});
for (let i = 0; i < 120; i += 1) {
await api.dispatch({ capability: 'stems', command: 'mute', source: 'diag-test', args: { target: { id: `target-${i}` } } });
}
const snapshot = api.snapshotDiagnostics();
const encoded = JSON.stringify(snapshot);
assert.ok(encoded.length <= 64 * 1024);
assert.equal(encoded.includes('/Users/example'), false);
assert.equal(encoded.includes('abc123'), false);
assert.equal(snapshot.snapshotBytes <= 64 * 1024, true);
});
test('compatibility shim hit counts and attribution are exported', () => {
const window = loadCapabilities();
const api = window.slopsmith.capabilities;
api.registerCompatibilityShim({ shimId: 'stems:legacy-window', source: 'stems', capability: 'stems', legacySurface: 'window._stemsState', status: 'active', reason: 'legacy global bridge' });
api.registerCompatibilityShim({ shimId: 'stems:legacy-window', source: 'stems', capability: 'stems', legacySurface: 'window._stemsState', status: 'used', used: true });
api.registerCompatibilityShim({ shimId: 'stems:legacy-window', source: 'stems', capability: 'stems', legacySurface: 'window._stemsState', status: 'used', hit: true });
const shim = api.snapshotDiagnostics().compatibilityShims.find(entry => entry.shimId === 'stems:legacy-window');
assert.equal(shim.source, 'stems');
assert.equal(shim.capability, 'stems');
assert.equal(shim.hitCount, 2);
assert.ok(shim.lastHitAt);
});
test('diagnostics export expected compatibility shim surfaces', () => {
const window = loadCapabilities();
const api = window.slopsmith.capabilities;
const expected = api.snapshotDiagnostics().expectedCompatibilityShims;
assert.ok(Array.isArray(expected));
assert.equal(expected.some(entry => entry.capability === 'library'), false);
assert.equal(expected.some(entry => entry.capability === 'backend.routes'), false);
assert.equal(expected.some(entry => entry.capability === 'playback'), false);
assert.equal(expected.some(entry => entry.capability === 'visualization'), false);
assert.equal(expected.some(entry => entry.capability === 'jobs'), false);
});
test('diagnostics include active playback but exclude deferred and documentation-only future core domains', () => {
const window = loadCapabilities();
const api = window.slopsmith.capabilities;
api.registerParticipant('playback_probe', {
playback: {
roles: ['provider'],
commands: ['snapshot'],
runtime: true,
},
});
api.registerParticipant('future_plugin', {
'ui.player-panels': {
roles: ['provider'],
commands: ['register-contribution'],
runtime: true,
},
});
api.registerParticipants([{ id: 'future_manifest', capabilities: { jobs: { roles: ['provider'], commands: ['register'] } } }]);
api.registerCompatibilityShim({ shimId: 'deferred:viz', source: 'highway_3d', capability: 'visualization', legacySurface: 'highway.setRenderer', status: 'used', hit: true });
api.registerCompatibilityShim({ shimId: 'deferred:routes', source: 'legacy_routes_plugin', capability: 'backend.routes', legacySurface: 'routes', status: 'used', hit: true });
const pipelines = api.snapshotDiagnostics().pipelines;
const playback = pipelines.find(entry => entry.name === 'playback');
assert.ok(playback, 'playback should be part of the active runtime graph when participants register');
assert.equal(playback.review.lifecycle, 'active');
// `visualization` (cap:6 slice) and `note-detection` (spec 009 slice)
// left this list when their domains were promoted.
const futureDomains = [
'ui.navigation', 'ui.plugin-screens', 'settings',
'backend.routes', 'ui.player-controls',
'ui.player-panels', 'ui.player-overlays', 'plugins', 'jobs', 'midi-control',
'tempo-clock',
];
for (const domain of futureDomains) {
assert.equal(pipelines.some(entry => entry.name === domain), false, `${domain} should stay out of the runtime graph`);
}
// visualization is active now, so its shim entries are recorded rather
// than dropped; backend.routes stays reserved and dropped.
assert.equal(api.snapshotDiagnostics().compatibilityShims.some(entry => entry.capability === 'visualization'), true);
assert.equal(api.snapshotDiagnostics().compatibilityShims.some(entry => entry.capability === 'backend.routes'), false);
});
test('server-reported shim hit counts are not inflated by refresh registration', () => {
const window = loadCapabilities();
const api = window.slopsmith.capabilities;
const shim = {
shimId: 'stems:legacy-window',
source: 'stems',
capability: 'stems',
legacySurface: 'window._stemsState',
status: 'used',
hitCount: 1,
};
api.registerCompatibilityShim(shim);
api.registerCompatibilityShim(shim);
const exported = api.snapshotDiagnostics().compatibilityShims.find(entry => entry.shimId === shim.shimId);
assert.equal(exported.hitCount, 1);
assert.equal(exported.source, 'stems');
assert.ok(exported.lastHitAt);
});
test('recordLegacyHit preserves non-library shim attribution', () => {
const window = loadCapabilities();
const api = window.slopsmith.capabilities;
const first = api.recordLegacyHit({
capability: 'stems',
legacySurface: 'window._stemsState',
source: 'legacy-stems',
});
api.registerCompatibilityShim({
shimId: first.shimId,
source: 'legacy-stems',
capability: 'stems',
legacySurface: 'window._stemsState',
status: 'active',
});
const exported = api.snapshotDiagnostics().compatibilityShims.find(entry => entry.shimId === first.shimId);
assert.equal(exported.status, 'used');
assert.equal(exported.hitCount, 1);
assert.equal(exported.source, 'legacy-stems');
});
test('recordLegacyHit counts runtime use and preserves used status across active refreshes', () => {
const window = loadCapabilities();
const api = window.slopsmith.capabilities;
const first = api.recordLegacyHit({
capability: 'stems',
legacySurface: 'window._stemsState',
source: 'legacy-event-bus',
reason: 'legacy event emitted',
});
api.registerCompatibilityShim({
shimId: first.shimId,
source: 'legacy-event-bus',
capability: 'stems',
legacySurface: 'window._stemsState',
status: 'active',
reason: 'static metadata refresh',
});
api.recordLegacyHit({
capability: 'stems',
legacySurface: 'window._stemsState',
source: 'legacy-event-bus',
reason: 'legacy event emitted again',
});
const exported = api.snapshotDiagnostics().compatibilityShims.find(entry => entry.shimId === first.shimId);
assert.equal(exported.status, 'used');
assert.equal(exported.hitCount, 2);
assert.equal(exported.capability, 'stems');
assert.equal(exported.legacySurface, 'window._stemsState');
assert.ok(exported.lastHitAt);
});
test('capability diagnostics emit a changed event after runtime updates', async () => {
const window = loadCapabilities();
const api = window.slopsmith.capabilities;
let changes = 0;
window.addEventListener('slopsmith:capabilities:changed', () => { changes += 1; });
api.recordLegacyHit({
capability: 'stems',
legacySurface: 'window._stemsState',
source: 'legacy-event-listener',
});
await new Promise(resolve => setTimeout(resolve, 10));
assert.ok(changes >= 1);
});
+80
View File
@@ -0,0 +1,80 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { loadCapabilities } = require('./capabilities_test_harness');
test('capability runtime installs early slopsmith event bus', () => {
const window = loadCapabilities();
const events = [];
const onceEvents = [];
window.slopsmith.on('screen:changed', event => events.push(event.detail));
window.slopsmith.on('song:ready', event => onceEvents.push(event.detail), { once: true });
window.slopsmith.emit('screen:changed', { id: 'home' });
window.slopsmith.emit('song:ready', { title: 'First' });
window.slopsmith.emit('song:ready', { title: 'Second' });
assert.deepEqual(events, [{ id: 'home' }]);
assert.deepEqual(onceEvents, [{ title: 'First' }]);
assert.equal(typeof window.slopsmith.off, 'function');
});
test('unregistering requester releases its claims', () => {
const window = loadCapabilities();
const api = window.slopsmith.capabilities;
api.registerParticipant('stems', { stems: { roles: ['owner'], commands: ['mute'], handlers: { mute: () => ({ outcome: 'handled' }) }, runtime: true } });
api.registerParticipant('nam_tone', { stems: { roles: ['requester'], commands: ['mute'], runtime: true } });
api.claim({ capability: 'stems', claimId: 'nam.amp-active', requester: 'nam_tone' });
api.unregisterParticipant('nam_tone');
const snapshot = api.snapshotDiagnostics();
assert.equal(snapshot.activeClaims.some(c => c.claimId === 'nam.amp-active'), false);
assert.equal(snapshot.claimLifecycle.some(c => c.claimId === 'nam.amp-active' && c.state === 'released'), true);
});
test('unregistering owner or handler orphans claim and prevents dispatch', async () => {
const window = loadCapabilities();
const api = window.slopsmith.capabilities;
api.registerParticipant('stems', { stems: { roles: ['owner'], commands: ['mute'], handlers: { mute: () => ({ outcome: 'handled' }) }, runtime: true } });
api.claim({ capability: 'stems', claimId: 'nam.amp-active', requester: 'nam_tone' });
api.unregisterParticipant('stems');
const snapshot = api.snapshotDiagnostics();
const claim = snapshot.activeClaims.find(c => c.claimId === 'nam.amp-active');
assert.equal(claim.state, 'orphaned');
assert.equal(claim.nonDispatchable, true);
const result = await api.dispatch({ capability: 'stems', command: 'mute', source: 'nam_tone', claim: { claimId: 'nam.amp-active' } });
assert.equal(result.status, 'no-owner');
assert.equal(result.outcome, 'no-owner');
});
test('runtime enable disable is lifecycle state rather than user override', () => {
const window = loadCapabilities();
const api = window.slopsmith.capabilities;
api.registerParticipant('plugin_a', { stems: { roles: ['provider'], commands: ['inspect'], runtime: true } });
const disabled = api.setParticipantEnabled('plugin_a', 'stems', false, { requester: 'test' });
const enabled = api.setParticipantEnabled('plugin_a', 'stems', true, { requester: 'test' });
const snapshot = api.snapshotDiagnostics();
assert.equal(disabled.ok, true);
assert.equal(enabled.ok, true);
assert.equal(snapshot.userOverrides.length, 0);
assert.equal(snapshot.participants.find(p => p.pluginId === 'plugin_a').runtimeOverride.enabled, true);
});
test('failed no-op registrations do not block reload and rehydrate replacement', async () => {
const window = loadCapabilities();
const api = window.slopsmith.capabilities;
api.registerParticipant('', { stems: { roles: ['owner'] } });
api.registerParticipant('stems', { stems: { roles: ['owner'], commands: ['mute'], handlers: { mute: () => ({ outcome: 'handled', payload: { generation: 1 } }) }, runtime: true } });
api.unregisterParticipant('stems');
api.registerParticipant('stems', { stems: { roles: ['owner'], commands: ['mute'], handlers: { mute: () => ({ outcome: 'handled', payload: { generation: 2 } }) }, runtime: true } });
const result = await api.dispatch({ capability: 'stems', command: 'mute', source: 'test' });
const participants = api.inspect('stems').participants.filter(p => p.pluginId === 'stems');
assert.equal(participants.length, 1);
assert.equal(result.status, 'applied');
assert.equal(result.payload.generation, 2);
});
@@ -0,0 +1,212 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { loadCapabilities, ROOT } = require('./capabilities_test_harness');
const FIXTURE_DIR = path.join(ROOT, 'tests', 'fixtures', 'plugin_capabilities');
function fixture(name) {
return JSON.parse(fs.readFileSync(path.join(FIXTURE_DIR, name), 'utf8'));
}
test('manifest participants are visible before runtime handlers register', () => {
const window = loadCapabilities();
const api = window.slopsmith.capabilities;
api.registerParticipants([fixture('valid_owner_provider.json'), fixture('valid_requester_observer.json')]);
const stems = api.inspect('stems');
assert.equal(stems.participants.length, 2);
const owner = stems.participants.find(p => p.pluginId === 'stems');
assert.equal(owner.runtime, false);
assert.equal(JSON.stringify(owner.commands.slice().sort()), JSON.stringify(['inspect', 'mute', 'restore']));
assert.equal(owner.description, 'Owns stem mute and restore coordination for integrated audio plugins.');
assert.equal(owner.ownership, 'exclusive-owner');
assert.equal(owner.safety, 'safe');
});
test('runtime registration refreshes an existing manifest participant without duplicating it', async () => {
const window = loadCapabilities();
const api = window.slopsmith.capabilities;
api.registerParticipants([fixture('valid_owner_provider.json')]);
api.registerParticipant('stems', {
capabilities: {
stems: {
roles: ['owner', 'provider'],
commands: ['mute'],
runtime: true,
handlers: { mute: () => ({ outcome: 'handled', payload: { muted: true } }) },
},
},
});
api.registerParticipant('stems', {
stems: {
roles: ['owner', 'provider'],
commands: ['mute'],
runtime: true,
handlers: { mute: () => ({ outcome: 'handled', payload: { muted: 'refreshed' } }) },
},
});
const stems = api.inspect('stems');
assert.equal(stems.participants.filter(p => p.pluginId === 'stems').length, 1);
assert.equal(stems.participants[0].runtime, true);
const result = await api.dispatch({ capability: 'stems', command: 'mute', source: 'test' });
assert.equal(result.status, 'applied');
assert.deepEqual(result.payload, { muted: 'refreshed' });
});
test('native library provider capability coordinates providers', async () => {
const providers = [
{ id: 'local', label: 'My Library', capabilities: ['library.read'], default: true },
{ id: 'remote:frodo', label: 'Frodo', capabilities: ['library.read', 'song.sync'], owner_plugin_id: 'frodo_library' },
];
const synced = [];
const window = loadCapabilities({ library: true });
window.fetch = async (url, options = {}) => {
const text = String(url);
if (text === '/api/library/providers') {
return { ok: true, json: async () => ({ providers }) };
}
if (text.includes('/sync') && options.method === 'POST') {
synced.push(text);
return { ok: true, json: async () => ({ ok: true, filename: 'remote:frodo:song-1' }) };
}
throw new Error(`unexpected fetch ${text}`);
};
const api = window.slopsmith.capabilities;
const events = [];
api.subscribe('library:source-changed', event => events.push(event));
await api.command('library', 'refresh-providers', { requester: 'test', payload: { restoreSaved: true } });
const listed = await api.command('library', 'list-providers', { requester: 'test' });
assert.equal(listed.outcome, 'handled');
assert.equal(listed.payload.providers[1].owner_plugin_id, 'frodo_library');
const libraryPipeline = api.inspect('library');
const providerParticipants = libraryPipeline.participants.filter(participant => participant.roles.includes('provider'));
const localProvider = providerParticipants.find(participant => participant.pluginId === 'core.library.local');
const remoteProvider = providerParticipants.find(participant => participant.pluginId === 'frodo_library');
assert.ok(localProvider);
assert.equal(localProvider.providerPolicy.providerId, 'local');
assert.ok(localProvider.operations.includes('query-page'));
assert.ok(remoteProvider);
assert.equal(remoteProvider.providerPolicy.providerId, 'remote:frodo');
assert.ok(remoteProvider.operations.includes('sync-song'));
const selected = await api.command('library', 'select-provider', {
requester: 'test',
target: { providerId: 'remote:frodo' },
});
assert.equal(selected.payload.current, 'remote:frodo');
assert.equal(events.length, 1);
assert.equal(events[0].payload.to, 'remote:frodo');
assert.equal(window.slopsmith.libraryProviders.snapshot().current, 'remote:frodo');
const syncResult = await api.command('library', 'sync-song', {
requester: 'test',
target: { providerId: 'remote:frodo', songId: 'song-1' },
});
assert.equal(syncResult.outcome, 'handled');
assert.equal(syncResult.payload.result.filename, 'remote:frodo:song-1');
assert.equal(synced.length, 1);
});
test('removed library providers are unregistered as library participants on refresh', async () => {
let providerSet = [
{ id: 'local', label: 'My Library', capabilities: ['library.read'], default: true },
{ id: 'remote:frodo', label: 'Frodo', capabilities: ['library.read', 'song.sync'], owner_plugin_id: 'frodo_library' },
{ id: 'remote:sam', label: 'Sam', capabilities: ['library.read'], owner_plugin_id: 'sam_library' },
];
const window = loadCapabilities({ library: true });
window.fetch = async (url) => {
if (String(url) === '/api/library/providers') return { ok: true, json: async () => ({ providers: providerSet }) };
throw new Error(`unexpected fetch ${url}`);
};
const api = window.slopsmith.capabilities;
await api.command('library', 'refresh-providers', { requester: 'test' });
let ids = api.inspect('library').participants.map(p => p.pluginId);
assert.ok(ids.includes('frodo_library'));
assert.ok(ids.includes('sam_library'));
// Sam is removed and the endpoint now omits it; the stale participant must
// not linger in the capability registry / Inspector snapshot.
providerSet = [
{ id: 'local', label: 'My Library', capabilities: ['library.read'], default: true },
{ id: 'remote:frodo', label: 'Frodo', capabilities: ['library.read', 'song.sync'], owner_plugin_id: 'frodo_library' },
];
await api.command('library', 'refresh-providers', { requester: 'test' });
ids = api.inspect('library').participants.map(p => p.pluginId);
assert.ok(ids.includes('frodo_library'));
assert.equal(ids.includes('sam_library'), false);
assert.ok(ids.includes('core.library.local'));
// A fetch failure falls back to local-only — all remote providers drop.
window.fetch = async () => { throw new Error('network down'); };
await api.command('library', 'refresh-providers', { requester: 'test' });
ids = api.inspect('library').participants.map(p => p.pluginId);
assert.equal(ids.includes('frodo_library'), false);
assert.ok(ids.includes('core.library.local'));
});
test('a plugin with non-provider library roles is not wiped when its provider disappears', async () => {
let providerSet = [
{ id: 'local', label: 'My Library', capabilities: ['library.read'], default: true },
{ id: 'remote:sam', label: 'Sam', capabilities: ['library.read'], owner_plugin_id: 'sam_library' },
];
const window = loadCapabilities({ library: true });
window.fetch = async (url) => {
if (String(url) === '/api/library/providers') return { ok: true, json: async () => ({ providers: providerSet }) };
throw new Error(`unexpected fetch ${url}`);
};
const api = window.slopsmith.capabilities;
// The same plugin also declares an observer library role via its manifest —
// legitimate participation the provider-cleanup path must not delete.
api.registerParticipants([{
id: 'sam_library',
name: 'Sam Library',
runtime_domains: { library: { role: 'observer', observes: ['source-changed'] } },
}]);
await api.command('library', 'refresh-providers', { requester: 'test' });
let sam = api.inspect('library').participants.find(p => p.pluginId === 'sam_library');
assert.ok(sam);
assert.ok(sam.roles.includes('provider'));
assert.ok(sam.roles.includes('observer'));
// Sam's provider is removed from the backend list. The participant stays
// because it still carries the manifest-declared observer role.
providerSet = [{ id: 'local', label: 'My Library', capabilities: ['library.read'], default: true }];
await api.command('library', 'refresh-providers', { requester: 'test' });
sam = api.inspect('library').participants.find(p => p.pluginId === 'sam_library');
assert.ok(sam, 'plugin with a manifest library role must survive provider removal');
assert.ok(sam.roles.includes('observer'));
});
test('runtime domain library declarations appear as library participants', () => {
const window = loadCapabilities();
const api = window.slopsmith.capabilities;
const touched = api.registerParticipants([{
id: 'remote-library-client',
name: 'Remote Library Client',
runtime_domains: {
library: {
role: 'provider',
operations: ['query-page'],
description: 'Adds a remote source to the library provider list.',
},
},
}]);
assert.deepEqual(Array.from(touched), ['library']);
const library = api.inspect('library');
const participant = library.participants.find(item => item.pluginId === 'remote-library-client');
assert.ok(participant);
assert.deepEqual(Array.from(participant.roles), ['provider']);
assert.ok(participant.operations.includes('query-page'));
assert.equal(participant.description, 'Adds a remote source to the library provider list.');
assert.equal(participant.ownership, 'exclusive-owner');
});
@@ -0,0 +1,19 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { loadCapabilities } = require('./capabilities_test_harness');
test('multi-provider participants use deterministic order without duplicate-owner conflict', async () => {
const window = loadCapabilities();
const api = window.slopsmith.capabilities;
const calls = [];
api.registerParticipant('provider_b', { 'shared-viz': { roles: ['owner', 'provider'], ownership: 'multi-provider', commands: ['register-provider'], order: { after: ['provider_a'] }, handlers: { 'register-provider': () => { calls.push('b'); return { outcome: 'passed' }; } }, runtime: true } });
api.registerParticipant('provider_a', { 'shared-viz': { roles: ['owner', 'provider'], ownership: 'multi-provider', commands: ['register-provider'], handlers: { 'register-provider': () => { calls.push('a'); return { outcome: 'handled' }; } }, runtime: true } });
const inspection = api.inspect('shared-viz');
const result = await api.dispatch({ capability: 'shared-viz', command: 'register-provider', source: 'test' });
assert.equal(inspection.conflicts.some(c => c.type === 'duplicate-owner'), false);
assert.equal(JSON.stringify(inspection.order.slice(-2)), JSON.stringify(['provider_a', 'provider_b']));
assert.equal(result.status, 'applied');
assert.equal(calls[0], 'a');
});
+34
View File
@@ -0,0 +1,34 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { loadCapabilities } = require('./capabilities_test_harness');
test('exclusive duplicate owners report conflict and degrade dispatch', async () => {
const window = loadCapabilities();
const api = window.slopsmith.capabilities;
api.registerParticipant('owner_a', { stems: { roles: ['owner'], commands: ['mute'], handlers: { mute: () => ({ outcome: 'handled' }) }, runtime: true } });
api.registerParticipant('owner_b', { stems: { roles: ['owner'], commands: ['mute'], handlers: { mute: () => ({ outcome: 'handled' }) }, runtime: true } });
const inspection = api.inspect('stems');
const result = await api.dispatch({ capability: 'stems', command: 'mute', source: 'test' });
assert.equal(inspection.conflicts.some(c => c.type === 'duplicate-owner'), true);
assert.equal(result.status, 'no-handler');
assert.match(result.reason, /multiple owners|duplicate-owner|degraded/i);
});
test('no-owner no-handler and unsupported-command outcomes are explicit', async () => {
const window = loadCapabilities();
const api = window.slopsmith.capabilities;
let result = await api.dispatch({ capability: 'missing-domain', command: 'mute', source: 'test' });
assert.equal(result.status, 'no-owner');
assert.equal(result.outcome, 'no-owner');
api.registerParticipant('stems', { stems: { roles: ['owner'], commands: ['mute'], runtime: true } });
result = await api.dispatch({ capability: 'stems', command: 'unknown', source: 'test' });
assert.equal(result.status, 'unsupported-command');
assert.equal(result.outcome, 'unsupported-command');
result = await api.dispatch({ capability: 'stems', command: 'mute', source: 'test' });
assert.equal(result.status, 'no-handler');
assert.equal(result.outcome, 'no-handler');
});
+79
View File
@@ -0,0 +1,79 @@
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const ROOT = path.join(__dirname, '..', '..');
const CAPABILITIES_JS = path.join(ROOT, 'static', 'capabilities.js');
const LIBRARY_CAPABILITY_JS = path.join(ROOT, 'static', 'capabilities', 'library.js');
function createWindow(options = {}) {
class CustomEvent {
constructor(type, init = {}) {
this.type = type;
this.detail = init.detail;
}
}
const listeners = new Map();
const storage = new Map();
const elements = new Map();
const diagnosticsContributions = new Map();
const window = {
console,
CustomEvent,
setTimeout,
clearTimeout,
performance: { now: () => Date.now() },
addEventListener(type, handler) {
const list = listeners.get(type) || [];
list.push(handler);
listeners.set(type, list);
},
removeEventListener(type, handler) {
const list = listeners.get(type) || [];
listeners.set(type, list.filter(item => item !== handler));
},
dispatchEvent(event) {
for (const handler of (listeners.get(event.type) || []).slice()) handler(event);
return true;
},
localStorage: {
getItem(key) { return storage.has(key) ? storage.get(key) : null; },
setItem(key, value) { storage.set(String(key), String(value)); },
removeItem(key) { storage.delete(String(key)); },
},
document: {
getElementById(id) { return elements.get(id) || null; },
},
slopsmith: {
emit(type, detail) {
window.dispatchEvent(new CustomEvent(type, { detail }));
},
diagnostics: options.diagnostics === false ? undefined : {
contribute(id, payload) { diagnosticsContributions.set(id, payload); },
snapshotContributions() { return Object.fromEntries(diagnosticsContributions); },
},
},
__listeners: listeners,
__storage: storage,
__elements: elements,
__diagnosticsContributions: diagnosticsContributions,
};
window.window = window;
window.globalThis = window;
return window;
}
function loadCapabilities(options = {}) {
const window = createWindow(options);
const context = vm.createContext(window);
const source = fs.readFileSync(CAPABILITIES_JS, 'utf8');
vm.runInContext(source, context, { filename: CAPABILITIES_JS });
if (options.library) {
const librarySource = fs.readFileSync(LIBRARY_CAPABILITY_JS, 'utf8');
vm.runInContext(librarySource, context, { filename: LIBRARY_CAPABILITY_JS });
}
return window;
}
module.exports = { loadCapabilities, createWindow, ROOT };
+24
View File
@@ -0,0 +1,24 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { loadCapabilities, ROOT } = require('./capabilities_test_harness');
test('unsupported capability-pipelines versions are incompatible and do not execute handlers', async () => {
const window = loadCapabilities();
const api = window.slopsmith.capabilities;
const fixture = JSON.parse(fs.readFileSync(path.join(ROOT, 'tests', 'fixtures', 'plugin_capabilities', 'unsupported_capability_version.json'), 'utf8'));
let invoked = false;
fixture.capabilities.stems.handlers = { mute: () => { invoked = true; return { outcome: 'handled' }; } };
fixture.capabilities.stems.runtime = true;
api.registerParticipants([fixture]);
const result = await api.dispatch({ capability: 'stems', command: 'mute', source: 'test' });
const snapshot = api.snapshotDiagnostics();
assert.equal(invoked, false);
assert.equal(result.status, 'incompatible-version');
assert.equal(result.outcome, 'incompatible-version');
assert.equal(snapshot.unsupportedVersions.some(entry => entry.pluginId === 'future_capabilities'), true);
assert.equal(snapshot.participants.find(p => p.pluginId === 'future_capabilities').availability, 'incompatible');
});
+132
View File
@@ -0,0 +1,132 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const APP_JS = path.join(ROOT, 'static', 'app.js');
const MANIFEST = path.join(ROOT, 'plugins', 'capability_inspector', 'plugin.json');
const SCREEN_HTML = path.join(ROOT, 'plugins', 'capability_inspector', 'screen.html');
const SETTINGS_HTML = path.join(ROOT, 'plugins', 'capability_inspector', 'settings.html');
function source(file) {
return fs.readFileSync(file, 'utf8');
}
function region(src, needle, length = 1800) {
const start = src.indexOf(needle);
assert.ok(start !== -1, `missing source needle: ${needle}`);
return src.slice(start, start + length);
}
test('capability inspector manifest ships settings but no default nav entry', () => {
const manifest = JSON.parse(source(MANIFEST));
assert.equal(manifest.id, 'capability_inspector');
assert.equal(manifest.nav, undefined);
assert.equal(manifest.settings.html, 'settings.html');
});
test('capability inspector plugins menu entry is localStorage opt-in', () => {
const src = source(APP_JS);
const helper = region(src, "const CAPABILITY_INSPECTOR_NAV_SETTING = 'capability_inspector.showInPluginsMenu'", 1400);
const menu = region(src, 'const navPlugins = plugins.map', 1000);
const contributions = region(src, 'async function _registerLegacyPluginUiContributions(plugin)', 1400);
assert.match(helper, /localStorage\.getItem\(CAPABILITY_INSPECTOR_NAV_SETTING\)\s*===\s*['"]1['"]/);
assert.match(helper, /if \(plugin\.id === ['"]capability_inspector['"]\)/);
assert.match(helper, /return null/);
assert.match(helper, /label:\s*['"]Capabilities['"]/);
assert.match(menu, /_pluginNav\(plugin\)/);
assert.match(contributions, /const nav = _pluginNav\(plugin\)/);
assert.doesNotMatch(contributions, /if \(plugin\.nav\)/);
});
test('capability inspector settings toggles the plugins menu setting', () => {
const html = source(SETTINGS_HTML);
assert.match(html, /id="capability-inspector-show-nav"/);
assert.match(html, /capability_inspector\.showInPluginsMenu/);
assert.match(html, /localStorage\.setItem\(key, '1'\)/);
assert.match(html, /localStorage\.removeItem\(key\)/);
assert.match(html, /window\.loadPlugins\(\)/);
assert.match(html, /window\.showScreen\('plugin-capability_inspector'\)/);
});
test('capability inspector screen ships scoped graph lane CSS', () => {
const html = source(SCREEN_HTML);
assert.match(html, /<style>/);
assert.match(html, /\.capability-inspector \[data-domain-graph-fallback\]/);
assert.match(html, /display: flex !important/);
assert.match(html, /\.capability-inspector \[data-domain-provider-card\]/);
assert.match(html, /\.capability-inspector \[data-domain-participant-lane\]/);
assert.match(html, /flex: 0 0 24rem/);
assert.match(html, /\.capability-inspector \[data-domain-graph-cy\]/);
assert.match(html, /display: block !important/);
assert.match(html, /\.capability-inspector \[data-endpoint-icon="command"\]/);
assert.match(html, /background: #fb923c !important/);
assert.match(html, /\.capability-inspector \[data-endpoint-icon="operation"\]/);
assert.match(html, /background: #c084fc !important/);
assert.match(html, /\.capability-inspector \[data-endpoint-flow="provider-operation"\]/);
assert.match(html, /background: #d8b4fe !important/);
assert.match(html, /\.capability-inspector \[data-toggle-graph-group\]/);
assert.match(html, /gap: 0\.625rem !important/);
assert.match(html, /\.capability-inspector \[data-graph-provider-endpoint-row\]/);
assert.match(html, /\.capability-inspector \[data-graph-participant-endpoint-row\]/);
assert.match(html, /\.capability-inspector \[data-role-icon\]/);
assert.match(html, /\.capability-inspector \[data-origin-icon\]/);
assert.match(html, /\.capability-inspector \[data-availability-icon\]/);
assert.match(html, /width: 2\.125rem !important/);
assert.match(html, /margin-left: 0\.5rem !important/);
assert.match(html, /\.capability-inspector \[data-domain-owner-footer\]/);
assert.match(html, /padding-top: 1rem !important/);
assert.match(html, /\.capability-inspector \[data-domain-owner-description\]/);
assert.match(html, /line-height: 1\.45 !important/);
assert.match(html, /\.capability-inspector \[data-domain-graph-filter\]/);
assert.match(html, /background: rgba\(31, 41, 55, 0\.72\) !important/);
assert.match(html, /\.capability-inspector \[data-domain-graph-filter\]\[aria-pressed="true"\]/);
assert.match(html, /background: rgba\(126, 34, 206, 0\.55\) !important/);
assert.match(html, /\.capability-inspector \[data-legend-icon="command"\]/);
assert.match(html, /background: #fb923c !important/);
assert.match(html, /\.capability-inspector \[data-legend-line="shimmed"\]/);
assert.match(html, /border-top: 2px dashed #9ca3af !important/);
assert.match(html, /<div class="max-w-7xl mx-auto px-6 pt-24 pb-16 capability-inspector">/);
assert.match(html, /data-inspector-header/);
assert.match(html, /\.capability-inspector \[data-summary-dashboard\]/);
assert.match(html, /width: 100% !important/);
assert.match(html, /grid-template-columns: repeat\(4, minmax\(0, 1fr\)\) !important/);
assert.match(html, /\.capability-inspector \[data-summary-card\]\[data-tone="clean"\]/);
assert.match(html, /border-color: rgba\(52, 211, 153, 0\.55\) !important/);
assert.match(html, /\.capability-inspector \[data-summary-status-value\]/);
assert.match(html, /color: #34d399 !important/);
assert.match(html, /\.capability-inspector \[data-graph-capability-port\]/);
assert.match(html, /right: -1\.75rem/);
assert.match(html, /\.capability-inspector \[data-graph-participant-port\]/);
assert.match(html, /left: -1\.75rem/);
});
test('_navLabel resolves string, object, synthesized, and empty nav values', () => {
const src = source(APP_JS);
const m = src.match(/function _navLabel\(nav, plugin\) \{[\s\S]*?\n\}/);
assert.ok(m, 'could not extract _navLabel from app.js');
const _navLabel = new Function(`${m[0]}; return _navLabel;`)();
// String nav (manifest "nav": "Declared") must win over the plugin name.
assert.equal(_navLabel('Declared', { name: 'Fallback', id: 'x' }), 'Declared');
// Object nav with a label.
assert.equal(_navLabel({ label: 'Capabilities' }, { name: 'Fallback', id: 'x' }), 'Capabilities');
// Object nav without a label falls back to name then id.
assert.equal(_navLabel({}, { name: 'My Plugin', id: 'x' }), 'My Plugin');
assert.equal(_navLabel({}, { id: 'x' }), 'x');
// Null / whitespace-only nav falls back too.
assert.equal(_navLabel(null, { name: 'My Plugin' }), 'My Plugin');
assert.equal(_navLabel(' ', { name: 'My Plugin' }), 'My Plugin');
});
test('plugin nav dropdown label uses the computed nav, not the raw plugin.nav', () => {
const src = source(APP_JS);
// Regression guard for the string/synthesized-nav label fix: the dropdown
// label must derive from the loop's computed nav via _navLabel, not from
// plugin.nav?.label (which drops string and synthesized labels).
assert.match(src, /const label = _navLabel\(nav, plugin\);/);
assert.doesNotMatch(src, /const label = plugin\.nav\?\.label/);
});
@@ -0,0 +1,738 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const ROOT = path.join(__dirname, '..', '..');
const INSPECTOR_JS = path.join(ROOT, 'plugins', 'capability_inspector', 'screen.js');
function makeElement(id) {
const element = {
id,
innerHTML: '',
textContent: '',
value: '',
dataset: {},
children: [],
listeners: {},
classList: {
values: new Set(),
add(name) { this.values.add(name); },
remove(name) { this.values.delete(name); },
contains(name) { return this.values.has(name); },
},
appendChild(child) { this.children.push(child); return child; },
addEventListener(type, handler) { this.listeners[type] = handler; },
};
return element;
}
function loadInspector(snapshot, options = {}) {
class CustomEvent {
constructor(type, init = {}) {
this.type = type;
this.detail = init.detail;
}
}
const listeners = new Map();
const elements = new Map([
['capability-inspector-filter', makeElement('capability-inspector-filter')],
['capability-inspector-content', makeElement('capability-inspector-content')],
['capability-inspector-empty', makeElement('capability-inspector-empty')],
['capability-inspector-summary', makeElement('capability-inspector-summary')],
['capability-inspector-refresh', makeElement('capability-inspector-refresh')],
]);
const window = {
console,
CustomEvent,
setTimeout(callback) { callback(); return 1; },
clearTimeout() {},
addEventListener(type, handler) {
const list = listeners.get(type) || [];
list.push(handler);
listeners.set(type, list);
},
dispatchEvent(event) {
for (const handler of (listeners.get(event.type) || []).slice()) handler(event);
return true;
},
slopsmith: {
capabilities: {
snapshotDiagnostics: () => (typeof snapshot === 'function' ? snapshot() : snapshot),
},
playback: options.playbackSnapshot ? {
snapshot: () => options.playbackSnapshot,
} : undefined,
},
navigator: { clipboard: { writeText: async () => {} } },
document: {
readyState: 'complete',
getElementById(id) { return elements.get(id) || null; },
createElement(tagName) { return makeElement(tagName); },
addEventListener() {},
},
};
window.window = window;
window.globalThis = window;
window.__listeners = listeners;
const context = vm.createContext(window);
vm.runInContext(fs.readFileSync(INSPECTOR_JS, 'utf8'), context, { filename: INSPECTOR_JS });
return { window, elements };
}
test('capability inspector renders playback session route loop bridges and outcomes', () => {
const snapshot = {
pipelines: [{ name: 'playback', review: { lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Core playback facade.' }, participants: [{ pluginId: 'core', roles: ['owner'], commands: ['inspect'], events: ['ready'], runtime: true, availability: 'available' }], conflicts: [] }],
participants: [{ pluginId: 'core' }],
compatibilityShims: [],
expectedCompatibilityShims: [],
};
const playbackSnapshot = {
schema: 'slopsmith.playback.diagnostics.v1',
state: {
sessionId: 'playback-1',
state: 'playing',
target: { targetId: 'target-abc', localDisplay: { title: 'Song', artist: 'Artist', arrangement: 'Lead' } },
media: { currentTime: 12.5, duration: 90, route: { routeKind: 'browser-media', state: 'active' }, loop: { enabled: true, startTime: 10, endTime: 20, state: 'active' } },
route: { routeKind: 'browser-media', state: 'active', safeReason: 'browser media route active' },
loop: { enabled: true, startTime: 10, endTime: 20, state: 'active' },
},
participants: [{ requesterId: 'plugin.practice' }, { observerId: 'plugin.hud' }],
bridges: [{ bridgeId: 'playback.window-play-song', hitCount: 2 }],
history: { current: { recentOutcomes: [{ operation: 'seek', status: 'completed' }], lifecycleEvents: [{ event: 'playback:seeked', state: 'playing' }] } },
};
const { elements } = loadInspector(snapshot, { playbackSnapshot });
const content = elements.get('capability-inspector-content').innerHTML;
assert.match(content, /data-playback-support/);
assert.match(content, /Session: playback-1/);
assert.match(content, /Target: Song - Artist/);
assert.match(content, /Route: browser-media \(active\)/);
assert.match(content, /playback\.window-play-song:2/);
assert.match(content, /seek:completed/);
});
test('capability inspector renders shims inside their capability domain', () => {
const snapshot = {
pipelines: [
{ name: 'library', review: { lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Current library surface.' }, participants: [
{ pluginId: 'core', roles: ['owner'], commands: ['list-providers', 'refresh-providers', 'get-current', 'select-provider', 'sync-song', 'inspect'], operations: ['query-page', 'query-artists', 'query-stats', 'tuning-names', 'get-art', 'sync-song'], events: ['providers-refreshed', 'source-changed', 'song-sync-started', 'song-sync-succeeded', 'song-sync-failed'], description: 'Owns the library provider registry and dispatches source selection, browsing, and song sync commands.', runtime: true, availability: 'available', ownership: 'multi-provider', safety: 'safe' },
{ pluginId: 'local', roles: ['provider'], operations: ['query-page', 'query-artists', 'query-stats', 'tuning-names', 'get-art'], events: ['providers-refreshed', 'source-changed'], runtime: true, availability: 'available', ownership: 'multi-provider', safety: 'safe', providerPolicy: { providerId: 'local', kind: 'local', default: true } },
{ pluginId: 'remote_library_client', roles: ['provider'], operations: ['query-page', 'query-artists', 'query-stats', 'tuning-names', 'get-art', 'sync-song'], events: ['providers-refreshed', 'source-changed', 'song-sync-started', 'song-sync-succeeded', 'song-sync-failed'], runtime: true, availability: 'available', ownership: 'multi-provider', safety: 'safe', providerPolicy: { providerId: 'remote:client', kind: 'remote', ownerPluginId: 'remote_library_client' } },
{ pluginId: 'remote_library_server', roles: ['requester', 'observer'], commands: ['list-providers', 'get-current', 'inspect'], events: ['providers-refreshed', 'source-changed'], description: 'Wraps the local library source for direct remote-library clients.', runtime: false, availability: 'available', ownership: 'requester-only', safety: 'safe' },
], conflicts: [] },
{ name: 'custom.practice', review: { lifecycle: 'plugin-defined', label: 'Plugin-defined', tone: 'info', summary: 'Plugin-specific practice surface.' }, participants: [{ pluginId: 'practice_hud', roles: ['observer'], commands: [], runtime: true, availability: 'available', ownership: 'observer-only', safety: 'safe' }], conflicts: [] },
{ name: 'backend.routes', review: { lifecycle: 'future-expansion', label: 'Future expansion', tone: 'warning', summary: 'Backend route bridge.' }, participants: [{ pluginId: 'core', roles: ['owner'], commands: ['inspect'], runtime: true, availability: 'available', ownership: 'multi-provider', safety: 'privileged' }], conflicts: [] },
{ name: 'playback', review: { lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Core playback facade.' }, participants: [
{ pluginId: 'core', roles: ['owner', 'provider'], commands: ['play', 'pause', 'seek', 'snapshot'], events: ['song:ready', 'song:seek', 'beats:loaded', 'arrangement:changed'], description: 'Owns player transport commands and lifecycle events.', runtime: true, availability: 'available', ownership: 'exclusive-owner', safety: 'safe' },
{ pluginId: 'plugin_1', roles: ['observer'], commands: [], events: ['song:ready', 'beats:loaded'], runtime: true, availability: 'available', ownership: 'exclusive-owner', safety: 'safe' },
{ pluginId: 'plugin_2', roles: ['requester'], commands: ['play'], events: ['song:ready', 'arrangement:changed'], runtime: true, availability: 'available', ownership: 'exclusive-owner', safety: 'safe' },
{ pluginId: 'plugin_3', roles: ['participant'], commands: ['pause', 'seek'], events: ['song:seek'], runtime: true, availability: 'available', ownership: 'exclusive-owner', safety: 'safe' },
], conflicts: [] },
{ name: 'audio-effects', review: { lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Core audio-effects facade.' }, participants: [
{ pluginId: 'core.audio.effects', roles: ['owner'], commands: ['register-provider', 'register-executor', 'resolve-plan', 'load-plan', 'inspect'], events: ['provider-registered', 'executor-registered'], description: 'Owns the provider and executor registry.', runtime: true, availability: 'available', ownership: 'multi-provider', safety: 'sensitive' },
{ pluginId: 'nam-tone', roles: ['provider', 'executor'], operations: ['chain.resolve', 'executor.load-chain-plan'], events: ['plan-resolved'], description: 'Provides NAM fallback plans and browser execution.', runtime: true, availability: 'available', ownership: 'multi-provider', safety: 'sensitive' },
], conflicts: [] },
{ name: 'diagnostics', review: { lifecycle: 'diagnostic', label: 'Snapshot surface', tone: 'info', summary: 'Read-only diagnostics snapshot/export facade for support bundles and the Capability Inspector.' }, participants: [
{ pluginId: 'core', roles: ['owner', 'provider'], commands: ['snapshot'], events: [], description: 'Provides read-only capability snapshots for support bundles and the Capability Inspector.', runtime: true, availability: 'available', ownership: 'diagnostic-only', safety: 'diagnostic-only' },
{ pluginId: 'capability_inspector', roles: ['requester'], commands: ['snapshot'], events: [], runtime: true, availability: 'available', ownership: 'diagnostic-only', safety: 'diagnostic-only' },
], conflicts: [] },
{ name: 'pipeline', review: { lifecycle: 'diagnostic', label: 'Graph controls', tone: 'info', summary: 'Capability graph operations: resolve, inspect, validate, and enable or disable participants.' }, participants: [
{ pluginId: 'core', roles: ['owner', 'provider'], commands: ['resolve', 'inspect', 'validate', 'participant.set-enabled'], events: ['resolved', 'runtime.validated', 'participant.state-changed'], description: 'Owns capability graph inspection, validation, resolution, and participant enablement commands.', runtime: true, availability: 'available', ownership: 'diagnostic-only', safety: 'diagnostic-only' },
{ pluginId: 'capability_inspector', roles: ['requester', 'observer'], commands: ['inspect', 'validate'], events: ['runtime.validated', 'participant.state-changed'], runtime: true, availability: 'available', ownership: 'diagnostic-only', safety: 'diagnostic-only' },
], conflicts: [] },
],
participants: [{ pluginId: 'core' }, { pluginId: 'remote_library_client' }, { pluginId: 'remote_library_server' }],
compatibilityShims: [
{
shimId: 'remote_library_client:register_library_provider:library:remote:client',
source: 'remote_library_client',
capability: 'library',
legacySurface: 'register_library_provider',
status: 'used',
hitCount: 3,
providerId: 'remote:client',
reason: 'legacy backend register_library_provider() registered provider',
lastHitAt: '2026-05-24T00:00:00.000Z',
},
{
shimId: 'runtime:library:refresh:window.slopsmith.libraryProviders.refresh',
source: 'window.slopsmith.libraryProviders.refresh',
capability: 'library',
legacySurface: 'refresh',
status: 'used',
hitCount: 1,
reason: 'Legacy library provider refresh invoked',
lastHitAt: '2026-05-24T00:01:00.000Z',
},
],
expectedCompatibilityShims: [
{
capability: 'library',
legacySurface: 'register_library_provider',
reason: 'legacy backend provider registration becomes a library participant',
},
{
capability: 'library',
legacySurface: 'refresh',
reason: 'legacy library provider client refresh calls are counted as library.refresh command use',
},
],
};
const { window, elements } = loadInspector(snapshot);
const content = elements.get('capability-inspector-content').innerHTML;
const summary = elements.get('capability-inspector-summary').innerHTML;
const filterElement = elements.get('capability-inspector-filter');
const filter = filterElement.innerHTML;
assert.match(summary, /Domains/);
assert.match(summary, /Participants/);
assert.match(summary, /data-summary-dashboard/);
assert.match(summary, /<span class="text-\[11px\] uppercase tracking-wide text-gray-500" data-summary-label>Shim hit<\/span>\s*<span class="text-lg font-semibold text-white" data-summary-value>1<\/span>/);
assert.doesNotMatch(summary, />Shim hits<\/span>\s*<span class="text-lg font-semibold text-white" data-summary-value>4<\/span>/);
assert.doesNotMatch(summary, /Legacy listeners/);
assert.match(summary, /data-summary-card="status" data-tone="clean"/);
assert.match(summary, /<span data-summary-status-value data-tone="clean">Clean<\/span>/);
assert.match(filter, /<optgroup label="Application and Library">/);
assert.match(filter, /<optgroup label="Player and Audio Runtime">/);
assert.match(filter, /<optgroup label="Plugin-defined Domains">/);
assert.match(filter, /<optgroup label="Capability Runtime">/);
assert.ok(filter.indexOf('ui.navigation') === -1);
assert.ok(filter.indexOf('library') < filter.indexOf('playback'));
assert.ok(filter.indexOf('playback') < filter.indexOf('audio-effects'));
assert.ok(filter.indexOf('audio-effects') < filter.indexOf('custom.practice'));
assert.ok(filter.indexOf('playback') < filter.indexOf('custom.practice'));
assert.ok(filter.indexOf('custom.practice') < filter.indexOf('diagnostics'));
assert.ok(filter.indexOf('backend.routes') === -1);
assert.doesNotMatch(content, /Domain group/);
assert.match(content, /<div class="h-4" aria-hidden="true"><\/div>/);
assert.doesNotMatch(content, /Capability relationship map/);
assert.doesNotMatch(content, /data-capability-link=/);
assert.match(content, /data-domain-graph="library" data-domain-graph-expanded="false"/);
assert.match(content, /title="library domain" aria-label="library domain" role="img">[\s\S]*?<path d="M12 7v14"/);
assert.match(content, /<button type="button" data-toggle-domain="library"[^>]*title="Expand library domain graph"[^>]*>[^]*?<span class="min-w-0 break-all font-bold">library<\/span>/);
assert.doesNotMatch(content, /Domain: <span/);
assert.match(content, /title="4 participants" aria-label="4 participants" role="img"/);
assert.doesNotMatch(content, /2 Participants/);
assert.doesNotMatch(content, /1 Capability/);
assert.doesNotMatch(content, /Observed links/);
assert.doesNotMatch(content, /Shimmed link/);
assert.match(content, /title="Domain status: Clean" aria-label="Domain status: Clean" role="img"/);
assert.match(content, /Application and Library/);
assert.match(content, /Player and Audio Runtime/);
assert.match(content, /Plugin-defined Domains/);
assert.match(content, /Capability Runtime/);
assert.match(content, /title="pipeline domain" aria-label="pipeline domain" role="img">[\s\S]*?<path d="M22 12h-4l-3 7L9 5l-3 7H2"/);
assert.doesNotMatch(content, /title="pipeline domain" aria-label="pipeline domain" role="img">[\s\S]*?<circle cx="6" cy="6" r="3"/);
assert.ok(content.indexOf('library') < content.indexOf('playback'));
assert.ok(content.indexOf('playback') < content.indexOf('audio-effects'));
assert.ok(content.indexOf('audio-effects') < content.indexOf('custom.practice'));
assert.ok(content.indexOf('playback') < content.indexOf('custom.practice'));
assert.match(content, /data-domain-graph="audio-effects" data-domain-graph-expanded="false"/);
assert.ok(content.indexOf('custom.practice') < content.indexOf('diagnostics'));
assert.doesNotMatch(content, /backend\.routes/);
assert.doesNotMatch(content, /Review scope/);
assert.doesNotMatch(content, /Snapshot surface/);
assert.doesNotMatch(content, /Graph controls/);
assert.match(content, /library/);
assert.match(content, /data-toggle-domain="library"/);
assert.match(content, /title="Expand library domain graph"/);
assert.match(content, /aria-expanded="false"/);
assert.doesNotMatch(content, /id="capability-domain-library-graph-frame"/);
assert.doesNotMatch(content, /Domain summary/);
window.__slopsmithCapabilityInspector.expandedDomains = { library: true, playback: true, 'custom.practice': true };
window.__slopsmithCapabilityInspector.render();
const expandedContent = elements.get('capability-inspector-content').innerHTML;
assert.match(expandedContent, /aria-expanded="true"/);
assert.match(expandedContent, /data-domain-graph="library" data-domain-graph-expanded="true"/);
assert.match(expandedContent, /title="Collapse library domain graph"/);
assert.match(expandedContent, /data-domain-graph="library"[\s\S]*title="1 shimmed link"/);
assert.doesNotMatch(expandedContent, /Domain summary/);
assert.match(expandedContent, /title="Domain status: Clean"/);
assert.match(expandedContent, /id="capability-domain-library-graph-frame"/);
assert.match(expandedContent, /id="capability-domain-library-graph-cy"/);
assert.match(expandedContent, /id="capability-domain-library-graph-fallback"/);
assert.match(expandedContent, /data-domain-provider-card="true"/);
assert.doesNotMatch(expandedContent, /Review scope/);
assert.match(expandedContent, /data-domain-owner-name="core"/);
assert.match(expandedContent, /data-domain-owner-name="core"[\s\S]*data-origin-icon="core"[^>]*title="Core owner"/);
assert.match(expandedContent, /data-domain-owner-name="core"[\s\S]*data-origin-icon="core"[^>]*title="Core owner"[\s\S]*<path d="M12 2\.8 21 8\.1v8\.8L12 22l-9-5\.1V8\.1L12 2\.8z"/);
assert.match(expandedContent, /data-domain-owner-name="core"[\s\S]*data-origin-icon="core"[^>]*title="Core owner"[\s\S]*<circle cx="12" cy="12\.2" r="3\.4"/);
assert.match(expandedContent, /data-domain-provider-card="true"[\s\S]*data-domain-owner-name="core"[\s\S]*data-graph-provider-group="operation"[\s\S]*data-graph-provider-group="command"[\s\S]*data-graph-provider-group="event"[\s\S]*data-domain-owner-description="core"[\s\S]*Owns the library provider registry and dispatches source selection, browsing, and song sync commands\./);
assert.match(expandedContent, /data-domain-graph="library"[\s\S]*data-domain-owner-name="core"[\s\S]*<div class="min-w-0 truncate text-lg font-semibold text-white">core<\/div>[\s\S]*<div class="flex shrink-0 items-center justify-end gap-1">[\s\S]*data-role-icon="owner"[^>]*title="Owner"[\s\S]*data-origin-icon="core"[^>]*title="Core owner"[\s\S]*data-role-icon="provider-coordinator"[^>]*title="Provider coordinator"/);
assert.doesNotMatch(expandedContent, /data-domain-owner-name="local"/);
assert.doesNotMatch(expandedContent, /data-domain-owner-name="remote_library_client"/);
assert.match(expandedContent, /data-domain-participant-card="local"/);
assert.match(expandedContent, /title="Provider: local" aria-label="Provider: local" role="img"/);
assert.match(expandedContent, /data-domain-participant-card="local"[\s\S]*data-origin-icon="core"[^>]*title="Core provider"/);
assert.match(expandedContent, /data-domain-participant-card="local"[\s\S]*data-origin-icon="core"[^>]*title="Core provider"[\s\S]*<path d="M12 2\.8 21 8\.1v8\.8L12 22l-9-5\.1V8\.1L12 2\.8z"/);
assert.match(expandedContent, /data-domain-participant-card="local"[\s\S]*data-origin-icon="core"[^>]*title="Core provider"[\s\S]*<circle cx="12" cy="12\.2" r="3\.4"/);
assert.match(expandedContent, /data-domain-participant-card="remote_library_client"/);
assert.match(expandedContent, /data-domain-participant-card="remote_library_client"[^>]*title="Plugin: remote_library_client \| Roles: provider \| Provider: remote:client \| Registration: runtime \| Availability: available \| Safety: safe"/);
assert.match(expandedContent, /data-domain-participant-card="remote_library_client"[\s\S]*data-origin-icon="non-core"[^>]*title="Non-core provider"/);
assert.match(expandedContent, /title="Provider: remote:client" aria-label="Provider: remote:client" role="img"/);
assert.match(expandedContent, /data-domain-participant-card="remote_library_server"[^>]*title="Plugin: remote_library_server \| Roles: requester, observer \| Registration: manifest \| Availability: available \| Safety: safe"/);
assert.match(expandedContent, /data-domain-participant-card="remote_library_server"[\s\S]*data-origin-icon="non-core"[^>]*title="Non-core participant"/);
assert.doesNotMatch(expandedContent, /data-domain-participant-card="remote_library_server"[\s\S]*Provider: remote_library_server/);
assert.match(expandedContent, /data-link-kind="observed"[^>]*>query-page<\/span>/);
assert.match(expandedContent, /data-link-kind="observed" data-link-flow="provider-operation" data-link-participant="local">query-page<\/span>/);
assert.match(expandedContent, /data-link-kind="observed" data-link-flow="provider-event" data-link-participant="local">providers-refreshed<\/span>/);
assert.match(expandedContent, /data-link-kind="observed" data-link-flow="provider-operation" data-link-participant="remote_library_client">sync-song<\/span>/);
assert.match(expandedContent, /data-link-kind="observed" data-link-flow="provider-event" data-link-participant="remote_library_client">song-sync-succeeded<\/span>/);
assert.match(expandedContent, /data-link-kind="observed" data-link-flow="command" data-link-participant="remote_library_server">list-providers<\/span>/);
assert.match(expandedContent, /data-link-kind="observed" data-link-flow="event" data-link-participant="remote_library_server">source-changed<\/span>/);
assert.doesNotMatch(expandedContent, /data-link-flow="provider-command" data-link-participant="remote_library_server"/);
assert.doesNotMatch(expandedContent, /data-link-flow="provider-command" data-link-participant="local"/);
assert.doesNotMatch(expandedContent, /data-link-flow="provider-event" data-link-participant="remote_library_server"/);
assert.match(expandedContent, /data-domain-participant-card="local"[\s\S]*data-endpoint-icon="operation" data-endpoint-flow="provider-operation" data-graph-participant-port="0:operation:query-page"/);
assert.match(expandedContent, /data-domain-participant-card="local"[\s\S]*data-endpoint-icon="event" data-endpoint-flow="provider-event" data-graph-participant-port="0:event:providers-refreshed"/);
assert.ok(expandedContent.indexOf('data-domain-participant-card="remote_library_server"') < expandedContent.indexOf('data-graph-participant-group="2:command"'));
assert.ok(expandedContent.indexOf('data-graph-provider-group="operation"') < expandedContent.indexOf('data-graph-provider-group="command"'));
assert.match(expandedContent, /Operation link/);
assert.match(expandedContent, /Provider operation link/);
assert.match(expandedContent, /Provider event link/);
assert.match(expandedContent, /data-link-kind="shimmed"[^>]*>refresh<\/span>/);
assert.doesNotMatch(expandedContent, /data-link-kind="shimmed"[^>]*>list<\/span>/);
assert.doesNotMatch(expandedContent, /No command, operation, or event usage declared/);
assert.doesNotMatch(expandedContent, /data-domain-participant-card="practice_hud"/);
assert.match(expandedContent, /data-domain-graph="playback" data-domain-graph-expanded="true"/);
assert.match(expandedContent, /data-domain-participant-card="plugin_3"/);
assert.doesNotMatch(expandedContent, /Capability usage/);
assert.doesNotMatch(expandedContent, /Compatibility shims/);
assert.doesNotMatch(expandedContent, /Expected legacy surfaces/);
assert.doesNotMatch(expandedContent, /data-copy-surface=/);
filterElement.value = 'playback';
window.__slopsmithCapabilityInspector.render();
const selectedContent = elements.get('capability-inspector-content').innerHTML;
assert.match(selectedContent, /data-domain-graph="playback"/);
assert.match(selectedContent, /title="playback domain" aria-label="playback domain" role="img">[\s\S]*?<circle cx="12" cy="12" r="10"/);
assert.match(selectedContent, /id="capability-domain-playback-graph-cy"/);
assert.match(selectedContent, /id="capability-domain-playback-graph-fallback"/);
assert.match(selectedContent, /lg:flex lg:items-stretch lg:justify-between lg:gap-16 xl:gap-24/);
assert.match(selectedContent, /lg:w-96 lg:max-w-96 lg:flex-none" data-domain-provider-card="true"/);
assert.match(selectedContent, /lg:w-96 lg:max-w-96 lg:flex-none" data-domain-participant-lane="true"/);
assert.match(selectedContent, /<h3 class="text-lg font-semibold text-white"><span class="font-bold">playback<\/span><\/h3>/);
assert.doesNotMatch(selectedContent, /Domain: <span/);
assert.doesNotMatch(selectedContent, /data-toggle-domain="playback"/);
assert.match(selectedContent, /title="3 participants" aria-label="3 participants" role="img"/);
assert.doesNotMatch(selectedContent, /3 Participants/);
assert.doesNotMatch(selectedContent, /Owner \/ provider/);
assert.match(selectedContent, /Commands/);
assert.match(selectedContent, /Events/);
assert.match(selectedContent, /play/);
assert.match(selectedContent, /pause/);
assert.match(selectedContent, /seek/);
assert.match(selectedContent, /snapshot/);
assert.match(selectedContent, /song:ready/);
assert.match(selectedContent, /song:seek/);
assert.match(selectedContent, /beats:loaded/);
assert.match(selectedContent, /arrangement:changed/);
assert.match(selectedContent, /plugin_1/);
assert.match(selectedContent, /plugin_2/);
assert.match(selectedContent, /plugin_3/);
assert.match(selectedContent, /id="capability-domain-playback-graph-frame"/);
assert.match(selectedContent, /data-graph-lanes="playback"/);
assert.match(selectedContent, /data-graph-provider-group="command"/);
assert.match(selectedContent, /data-graph-provider-group="event"/);
assert.match(selectedContent, /data-graph-provider-group="command" data-graph-group-collapsed="false" data-graph-focus-kind="provider-group" data-graph-focus-type="command"/);
assert.match(selectedContent, /data-graph-focus-kind="provider-group" data-graph-focus-type="command" data-graph-provider-focus="group"/);
assert.match(selectedContent, /data-toggle-graph-group="playback\|provider\|all\|command"/);
assert.match(selectedContent, /data-toggle-graph-group="playback\|provider\|all\|event"/);
assert.match(selectedContent, /data-graph-capability-port="command:play"/);
assert.match(selectedContent, /data-graph-capability-port="event:song:ready"/);
assert.match(selectedContent, /data-graph-focus-kind="provider-endpoint" data-graph-focus-type="command" data-graph-focus-label="play" data-graph-provider-focus="endpoint"/);
assert.match(selectedContent, /relative flex min-h-5 min-w-0 items-center justify-end pr-0 text-right/);
assert.doesNotMatch(selectedContent, /data-capability-node="command:play" data-graph-focus-kind/);
assert.match(selectedContent, /<span class="min-w-0 truncate" data-graph-focus-kind="provider-endpoint" data-graph-focus-type="command" data-graph-focus-label="play" data-graph-provider-focus="endpoint">play<\/span><span class="absolute -right-\[1\.4375rem\] top-1\/2 -translate-y-1\/2" data-graph-focus-kind="provider-endpoint" data-graph-focus-type="command" data-graph-focus-label="play" data-graph-provider-focus="endpoint"><span class="inline-block[^\"]*" data-endpoint-icon="command" data-endpoint-flow="command" data-graph-capability-port="command:play"/);
assert.match(selectedContent, /<span class="min-w-0 truncate" data-graph-focus-kind="provider-endpoint" data-graph-focus-type="event" data-graph-focus-label="song:ready" data-graph-provider-focus="endpoint">song:ready<\/span><span class="absolute -right-\[1\.4375rem\] top-1\/2 -translate-y-1\/2" data-graph-focus-kind="provider-endpoint" data-graph-focus-type="event" data-graph-focus-label="song:ready" data-graph-provider-focus="endpoint"><span class="inline-block[^\"]*" data-endpoint-icon="event" data-endpoint-flow="event" data-graph-capability-port="event:song:ready"/);
assert.doesNotMatch(selectedContent, /grid content-center gap-3 px-1/);
assert.doesNotMatch(selectedContent, /start or resume playback|song is ready to play/);
assert.match(selectedContent, /data-graph-hover-participant="2"/);
assert.match(selectedContent, /title="Availability: available" aria-label="Availability: available" role="img"/);
assert.doesNotMatch(selectedContent, /Review scope/);
assert.match(selectedContent, /data-domain-owner-name="core"[\s\S]*<div class="min-w-0 truncate text-lg font-semibold text-white">core<\/div>[\s\S]*<div class="flex shrink-0 items-center justify-end gap-1">[\s\S]*data-role-icon="owner"[^>]*title="Owner"[\s\S]*data-origin-icon="core"[^>]*title="Core owner"[\s\S]*data-role-icon="provider"[^>]*title="Capability provider"/);
assert.match(selectedContent, /data-domain-owner-name="core"[\s\S]*data-origin-icon="core"[^>]*title="Core owner"/);
assert.match(selectedContent, /data-domain-provider-card="true"[\s\S]*data-domain-owner-name="core"[\s\S]*data-graph-provider-group="command"[\s\S]*data-graph-provider-group="event"[\s\S]*data-domain-owner-description="core"[\s\S]*Owns player transport commands and lifecycle events\./);
assert.doesNotMatch(selectedContent, /data-domain-participant-card="core"/);
assert.match(selectedContent, /data-domain-participant-card="plugin_1"[^>]*title="Plugin: plugin_1 \| Roles: observer \| Registration: runtime \| Availability: available \| Safety: safe"/);
assert.match(selectedContent, /data-domain-participant-card="plugin_1"[\s\S]*data-origin-icon="non-core"[^>]*title="Non-core participant"/);
assert.match(selectedContent, /data-domain-participant-card="plugin_2"[\s\S]*data-origin-icon="non-core"[^>]*title="Non-core participant"/);
assert.doesNotMatch(selectedContent, /data-role-icon="observer"/);
assert.doesNotMatch(selectedContent, /data-role-icon="requester"/);
assert.match(selectedContent, /data-domain-participant-card="plugin_3"[\s\S]*?<div class="flex items-center gap-3">[\s\S]*?<div class="min-w-0 flex-1 truncate text-lg font-semibold text-white">plugin_3<\/div>[\s\S]*?<div class="flex shrink-0 items-center justify-end gap-1">[\s\S]*?<span class="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded border bg-emerald-500\/10 text-emerald-300 border-emerald-700\/60" title="Availability: available"/);
assert.doesNotMatch(selectedContent, /title="Availability: available"[^>]*>[\s\S]*?<span>available<\/span><\/span>/);
assert.match(selectedContent, /data-domain-participant-card="plugin_3"[\s\S]*?<div class="mt-3 grid gap-3 border-t border-gray-800\/70 pt-3">/);
assert.doesNotMatch(selectedContent, /<span class="bg-purple-500\/15 text-purple-200 border border-purple-500\/30 px-2 py-1 rounded text-xs leading-none">participant<\/span>/);
assert.match(selectedContent, /data-graph-participant-group="2:command"/);
assert.match(selectedContent, /data-graph-participant-group="2:event"/);
assert.match(selectedContent, /data-graph-participant-group="2:event" data-graph-group-collapsed="false" data-graph-focus-kind="participant-group" data-graph-focus-participant-index="2" data-graph-focus-type="event"/);
assert.match(selectedContent, /data-graph-focus-kind="participant-group" data-graph-focus-participant-index="2" data-graph-focus-type="event"/);
assert.match(selectedContent, /data-toggle-graph-group="playback\|participant\|2\|event"/);
assert.match(selectedContent, /data-graph-participant-port="2:event:song:seek"/);
assert.match(selectedContent, /data-graph-focus-kind="participant-endpoint" data-graph-focus-participant-index="2" data-graph-focus-type="event" data-graph-focus-label="song:seek"/);
assert.match(selectedContent, /relative flex min-h-5 min-w-0 items-center pl-0 text-sm/);
assert.doesNotMatch(selectedContent, /relative flex min-h-5 min-w-0 items-center pl-0 text-sm text-gray-300" data-graph-focus-kind/);
assert.match(selectedContent, /class="absolute -left-\[1\.4375rem\] top-1\/2 -translate-y-1\/2" data-graph-focus-kind="participant-endpoint" data-graph-focus-participant-index="2" data-graph-focus-type="event" data-graph-focus-label="song:seek"><span class="inline-block[^\"]*" data-endpoint-icon="event" data-endpoint-flow="event" data-graph-participant-port="2:event:song:seek"/);
assert.match(selectedContent, /<span class="min-w-0 truncate" data-graph-focus-kind="participant-endpoint" data-graph-focus-participant-index="2" data-graph-focus-type="event" data-graph-focus-label="song:seek">song:seek<\/span>/);
assert.match(selectedContent, /data-endpoint-icon="event" data-endpoint-flow="event" data-graph-participant-port="0:event:song:ready"/);
assert.match(selectedContent, /data-endpoint-icon="event" data-endpoint-flow="event" data-graph-participant-port="2:event:song:seek"/);
assert.match(selectedContent, /Command link/);
assert.match(selectedContent, /Event link/);
assert.match(selectedContent, /data-link-kind="observed"/);
assert.doesNotMatch(selectedContent, /data-link-kind="shimmed"/);
assert.doesNotMatch(selectedContent, /<svg class="pointer-events-none absolute inset-0 hidden h-full w-full lg:block"/);
assert.match(selectedContent, /title="8 observed links" aria-label="8 observed links" role="img"/);
assert.match(selectedContent, /title="0 shimmed links" aria-label="0 shimmed links" role="img"/);
assert.match(selectedContent, /Show:/);
assert.match(selectedContent, /class="flex flex-wrap items-center justify-end gap-2" data-domain-graph-filter-row="playback"/);
assert.ok(selectedContent.indexOf('data-domain-graph-filter-row="playback"') < selectedContent.indexOf('>Legend</span>'));
assert.match(selectedContent, /class="flex flex-wrap items-center gap-4 border-t border-gray-800 pt-2"/);
assert.match(selectedContent, /data-domain-graph-filter="all"/);
assert.match(selectedContent, />All<\/button>/);
assert.match(selectedContent, />Operations<\/button>/);
assert.ok(selectedContent.indexOf('>Operations<\/button>') < selectedContent.indexOf('>Commands<\/button>'));
assert.ok(selectedContent.indexOf('data-legend-icon="operation"') < selectedContent.indexOf('data-legend-icon="command"'));
assert.doesNotMatch(selectedContent, /Domain group/);
window.__slopsmithCapabilityInspector.graphCollapsedGroups = {
'playback|provider|all|command': true,
'playback|participant|2|event': true,
};
window.__slopsmithCapabilityInspector.render();
const collapsedGroupContent = elements.get('capability-inspector-content').innerHTML;
assert.match(collapsedGroupContent, /data-graph-group-collapsed="true"/);
assert.match(collapsedGroupContent, /data-graph-capability-port="group:command"/);
assert.doesNotMatch(collapsedGroupContent, /data-graph-capability-port="command:play"/);
assert.match(collapsedGroupContent, /data-graph-participant-port="2:group:event"/);
assert.doesNotMatch(collapsedGroupContent, /data-graph-participant-port="2:event:song:seek"/);
const collapsedParticipantButton = collapsedGroupContent.match(/<button type="button" data-toggle-graph-group="playback\|participant\|2\|event"[\s\S]*?<\/button>/)[0];
assert.match(collapsedParticipantButton, /<span class="min-w-0 truncate">Events<\/span>/);
assert.doesNotMatch(collapsedParticipantButton, /<svg class="h-3\.5 w-3\.5/);
assert.doesNotMatch(collapsedParticipantButton, /<path d="m9 6 6 6-6 6"\/>/);
window.__slopsmithCapabilityInspector.graphCollapsedGroups = {};
window.__slopsmithCapabilityInspector.render();
filterElement.value = 'library';
window.__slopsmithCapabilityInspector.domainGraphFilter = 'operations';
window.__slopsmithCapabilityInspector.render();
const operationsOnlyContent = elements.get('capability-inspector-content').innerHTML;
assert.match(operationsOnlyContent, /aria-pressed="true" class="rounded border px-2 py-1 transition bg-purple-500\/40 text-white border-purple-400\/40">Operations/);
assert.match(operationsOnlyContent, /data-capability-node="operation:query-page"/);
assert.match(operationsOnlyContent, /data-link-kind="observed" data-link-flow="provider-operation" data-link-participant="local">query-page<\/span>/);
assert.doesNotMatch(operationsOnlyContent, /data-capability-node="command:list-providers"/);
assert.doesNotMatch(operationsOnlyContent, /data-capability-node="event:providers-refreshed"/);
filterElement.value = 'playback';
window.__slopsmithCapabilityInspector.domainGraphFilter = 'events';
window.__slopsmithCapabilityInspector.render();
const eventsOnlyContent = elements.get('capability-inspector-content').innerHTML;
assert.match(eventsOnlyContent, /aria-pressed="true" class="rounded border px-2 py-1 transition bg-purple-500\/40 text-white border-purple-400\/40">Events/);
assert.match(eventsOnlyContent, /data-capability-node="event:song:ready"/);
assert.doesNotMatch(eventsOnlyContent, /data-capability-node="command:play"/);
filterElement.value = 'library';
window.__slopsmithCapabilityInspector.domainGraphFilter = 'shimmed';
window.__slopsmithCapabilityInspector.render();
const shimmedOnlyContent = elements.get('capability-inspector-content').innerHTML;
assert.match(shimmedOnlyContent, /data-link-kind="shimmed"/);
assert.doesNotMatch(shimmedOnlyContent, /data-link-kind="observed"/);
assert.match(shimmedOnlyContent, /title="4 participants" aria-label="4 participants" role="img"/);
filterElement.value = '';
window.__slopsmithCapabilityInspector.expandedDomains = {};
window.__slopsmithCapabilityInspector.domainGraphFilter = 'all';
window.__slopsmithCapabilityInspector.render();
const collapsedFilteredContent = elements.get('capability-inspector-content').innerHTML;
assert.match(collapsedFilteredContent, /data-domain-graph="playback" data-domain-graph-expanded="false"[\s\S]*?title="3 participants" aria-label="3 participants" role="img"/);
});
test('capability inspector drops stale future-domain filter options', () => {
const snapshot = {
pipelines: [
{ name: 'library', review: { lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Library surface.' }, participants: [], conflicts: [] },
{ name: 'ui.player-panels', review: { lifecycle: 'future-expansion', label: 'Future expansion', tone: 'warning', summary: 'Stale future domain.' }, participants: [], conflicts: [] },
],
participants: [],
compatibilityShims: [],
expectedCompatibilityShims: [],
};
const { window, elements } = loadInspector(snapshot);
const filter = elements.get('capability-inspector-filter');
filter.innerHTML += '<option value="ui.player-panels">ui.player-panels</option>';
filter.value = 'ui.player-panels';
window.__slopsmithCapabilityInspector.render();
assert.equal(filter.value, '');
assert.doesNotMatch(filter.innerHTML, /ui\.player-panels/);
assert.match(elements.get('capability-inspector-content').innerHTML, /library/);
assert.doesNotMatch(elements.get('capability-inspector-content').innerHTML, /ui\.player-panels/);
});
test('capability inspector refreshes collapsed counts after runtime capability changes', () => {
let currentSnapshot = {
pipelines: [
{ name: 'library', review: { lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Library surface.' }, participants: [
{ pluginId: 'core', roles: ['owner'], commands: ['refresh', 'select'], events: [], runtime: true, availability: 'available', ownership: 'multi-provider', safety: 'safe' },
], conflicts: [] },
],
participants: [{ pluginId: 'core' }],
compatibilityShims: [],
expectedCompatibilityShims: [],
};
const { window, elements } = loadInspector(() => currentSnapshot);
assert.match(elements.get('capability-inspector-content').innerHTML, /data-domain-graph="library" data-domain-graph-expanded="false"[\s\S]*?title="0 participants"/);
currentSnapshot = {
...currentSnapshot,
participants: [{ pluginId: 'core' }, { pluginId: 'window.slopsmith.libraryProviders.refresh' }, { pluginId: 'remote_library_client' }],
compatibilityShims: [
{ shimId: 'runtime:library:refresh:window.slopsmith.libraryProviders.refresh', source: 'window.slopsmith.libraryProviders.refresh', capability: 'library', legacySurface: 'refresh', status: 'used', hitCount: 1 },
{ shimId: 'runtime:library:select:remote_library_client', source: 'remote_library_client', capability: 'library', legacySurface: 'select', status: 'used', hitCount: 1 },
],
expectedCompatibilityShims: [
{ capability: 'library', legacySurface: 'refresh', reason: 'legacy library provider client refresh calls are counted as library.refresh command use' },
{ capability: 'library', legacySurface: 'select', reason: 'legacy library provider selector calls are counted as library.select command use' },
],
};
window.dispatchEvent(new window.CustomEvent('slopsmith:capabilities:changed'));
const refreshedContent = elements.get('capability-inspector-content').innerHTML;
assert.match(refreshedContent, /data-domain-graph="library" data-domain-graph-expanded="false"[\s\S]*?title="2 participants"/);
assert.match(refreshedContent, /title="2 shimmed links"/);
});
test('capability inspector links library legacy command surfaces to canonical endpoints', () => {
const snapshot = {
pipelines: [
{ name: 'library', review: { lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Library surface.' }, participants: [
{ pluginId: 'core', roles: ['owner'], commands: ['list', 'refresh', 'select', 'sync-song'], events: [], runtime: true, availability: 'available', ownership: 'multi-provider', safety: 'safe' },
], conflicts: [] },
],
participants: [{ pluginId: 'core' }],
compatibilityShims: [
{ shimId: 'runtime:library:refresh:window.slopsmith.libraryProviders.refresh', source: 'window.slopsmith.libraryProviders.refresh', capability: 'library', legacySurface: 'refresh', status: 'used', hitCount: 1 },
{ shimId: 'runtime:library:select:remote_library_client', source: 'remote_library_client', capability: 'library', legacySurface: 'select', status: 'used', hitCount: 1 },
{ shimId: 'runtime:library:sync-song:remote_library_client', source: 'remote_library_client', capability: 'library', legacySurface: 'sync-song', status: 'used', hitCount: 1 },
],
expectedCompatibilityShims: [
{ capability: 'library', legacySurface: 'refresh', reason: 'legacy library provider client refresh calls are counted as library.refresh command use' },
{ capability: 'library', legacySurface: 'select', reason: 'legacy library provider selector calls are counted as library.select command use' },
{ capability: 'library', legacySurface: 'sync-song', reason: 'legacy library provider sync calls are counted as library.sync-song command use' },
],
};
const { window, elements } = loadInspector(snapshot);
const filter = elements.get('capability-inspector-filter');
filter.value = 'library';
window.__slopsmithCapabilityInspector.render();
const libraryContent = elements.get('capability-inspector-content').innerHTML;
assert.match(libraryContent, /data-domain-participant-card="window\.slopsmith\.libraryProviders\.refresh"/);
assert.match(libraryContent, /data-domain-participant-card="remote_library_client"/);
assert.match(libraryContent, /data-link-kind="shimmed"[^>]*>refresh<\/span>/);
assert.match(libraryContent, /data-link-kind="shimmed"[^>]*>select<\/span>/);
assert.match(libraryContent, /data-link-kind="shimmed"[^>]*>sync-song<\/span>/);
assert.match(libraryContent, /title="2 participants"/);
});
test('capability inspector clears provider hover without a graph hover target', () => {
const snapshot = {
pipelines: [
{ name: 'library', review: { lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Library surface.' }, participants: [
{ pluginId: 'core', roles: ['owner'], commands: [], events: ['providers-refreshed'], runtime: true, availability: 'available', ownership: 'multi-provider', safety: 'safe' },
], conflicts: [] },
],
participants: [{ pluginId: 'core' }],
compatibilityShims: [],
expectedCompatibilityShims: [],
};
const { elements, window } = loadInspector(snapshot);
const focusElement = { dataset: { graphFocusKind: 'provider-group', graphFocusType: 'event' }, contains: () => false };
focusElement.closest = selector => selector.includes('data-graph-focus-kind') ? focusElement : null;
const target = { dataset: { graphProviderFocus: 'group' }, style: {} };
window.document.querySelectorAll = selector => selector === '[data-domain-graph-frame]'
? [{ querySelectorAll: frameSelector => (frameSelector === '[data-graph-provider-focus]' ? [target] : []) }]
: [];
assert.doesNotThrow(() => {
elements.get('capability-inspector-content').listeners.mouseout({ target: focusElement, relatedTarget: null });
});
});
test('selected domain participant count includes shim-only graph participants', () => {
const snapshot = {
pipelines: [
{ name: 'library', review: { lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Library surface.' }, participants: [
{ pluginId: 'core', roles: ['owner'], commands: ['refresh', 'select'], events: [], runtime: true, availability: 'available', ownership: 'multi-provider', safety: 'safe' },
], conflicts: [] },
],
participants: [{ pluginId: 'core' }],
compatibilityShims: [
{ shimId: 'runtime:library:refresh:window.slopsmith.libraryProviders.refresh', source: 'window.slopsmith.libraryProviders.refresh', capability: 'library', legacySurface: 'refresh', status: 'used', hitCount: 1 },
{ shimId: 'runtime:library:select:remote_library_client', source: 'remote_library_client', capability: 'library', legacySurface: 'select', status: 'used', hitCount: 1 },
],
expectedCompatibilityShims: [],
};
const { window, elements } = loadInspector(snapshot);
const filter = elements.get('capability-inspector-filter');
filter.value = 'library';
window.__slopsmithCapabilityInspector.render();
const selectedContent = elements.get('capability-inspector-content').innerHTML;
assert.match(selectedContent, /title="2 participants"/);
assert.doesNotMatch(selectedContent, /2 Participants/);
assert.match(selectedContent, /data-domain-participant-card="window\.slopsmith\.libraryProviders\.refresh"/);
assert.match(selectedContent, /data-domain-participant-card="remote_library_client"/);
});
test('capability inspector renders audio-mix fader diagnostics from audio-session snapshot', () => {
const snapshot = {
pipelines: [
{ name: 'audio-mix', review: { lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Audio mix surface.' }, participants: [
{ pluginId: 'core.audio.session', roles: ['owner'], commands: ['list-faders', 'set-fader-value'], operations: ['fader.get-value', 'fader.set-value'], events: ['fader-value-changed', 'fader-unavailable'], runtime: true, availability: 'available', ownership: 'multi-provider', safety: 'safe' },
], conflicts: [] },
],
participants: [{ pluginId: 'core.audio.session' }],
compatibilityShims: [],
expectedCompatibilityShims: [],
};
const { window, elements } = loadInspector(snapshot);
window.slopsmith.audioSession = {
snapshot: () => ({
schema: 'slopsmith.audio_session.diagnostics.v1',
session: { route: { routeKind: 'desktop', availability: 'degraded' }, analyser: { source: 'plugin', availability: 'available' } },
domains: {
'audio-mix': {
participants: [{ participantId: 'plugin.delay', label: 'Delay', sourceMode: 'native' }],
faders: [
{ participantId: 'plugin.delay', label: 'Delay Wet', faderId: 'wet', availability: 'available', sourceMode: 'native' },
{ participantId: 'fader.legacy', label: 'Legacy Gain', faderId: 'gain', availability: 'disabled', sourceMode: 'compatibility', lastRejectedValue: 0.8 },
],
route: { routeKind: 'desktop', availability: 'degraded' },
analyser: { source: 'plugin', availability: 'available' },
bridges: [{ bridgeId: 'audio-mix.fader-registry', outcome: 'overridden', reason: 'overshadowed', hitCount: 1 }],
},
'audio-input': { sources: [], totalSources: 0 },
'audio-monitoring': { sessions: [], totalSessions: 0 },
stems: { owner: null, claims: [], bridges: [] },
},
recentOutcomes: [{ domain: 'audio-mix', operation: 'set-fader-value', participantId: 'fader.legacy', faderId: 'gain', outcome: 'failed', status: 'timeout' }],
}),
};
window.__slopsmithCapabilityInspector.render();
const content = elements.get('capability-inspector-content').innerHTML;
assert.match(content, /data-audio-session-support/);
assert.match(content, /Faders: Delay Wet:available:native, Legacy Gain:disabled:compatibility:failed/);
assert.match(content, /Analyser: plugin \(available\)/);
assert.match(content, /audio-mix\.fader-registry:overridden \(overshadowed\)/);
assert.match(content, /Failures: audio-mix:set-fader-value:gain:timeout/);
});
test('capability inspector renders audio-input sources selection sessions bridges and failures', () => {
const snapshot = {
pipelines: [
{ name: 'audio-input', review: { lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Audio input surface.' }, participants: [
{ pluginId: 'core.audio.session', roles: ['owner'], commands: ['list-sources', 'open-source', 'close-source'], operations: ['source.enumerate', 'source.open', 'source.close'], events: ['source-opened', 'source-closed'], runtime: true, availability: 'available', ownership: 'multi-provider', safety: 'sensitive' },
], conflicts: [] },
],
participants: [{ pluginId: 'core.audio.session' }],
compatibilityShims: [],
expectedCompatibilityShims: [],
};
const { window, elements } = loadInspector(snapshot);
window.slopsmith.audioSession = {
snapshot: () => ({
schema: 'slopsmith.audio_session.diagnostics.v1',
session: { route: { routeKind: 'html5', availability: 'available' }, analyser: { source: 'none', availability: 'unavailable' } },
domains: {
'audio-mix': { participants: [], faders: [], route: { routeKind: 'html5', availability: 'available' }, analyser: { source: 'none', availability: 'unavailable' }, bridges: [] },
'audio-input': {
sources: [
{ sourceId: 'source-01', logicalSourceKey: 'native:instrument:primary', providerId: 'native', label: 'Input 1', availability: 'available', sourceMode: 'native', channelSummary: { channelShape: 'mono' } },
{ sourceId: 'source-02', logicalSourceKey: 'legacy:instrument:primary', providerId: 'legacy', label: 'Legacy Input', availability: 'available', sourceMode: 'compatibility', channelSummary: { channelShape: 'stereo' }, supersededBy: 'source-01' },
],
selected: { logicalSourceKey: 'native:instrument:primary', availability: 'available', restoreStatus: 'available' },
openSessions: [{ openSessionId: 'input-open-01', channelShape: 'mono', state: 'open', requesters: [{ requesterId: 'note_detect' }] }],
totalSources: 2,
totalOpenSessions: 1,
bridges: [{ bridgeId: 'audio-input.legacy-source', status: 'overshadowed', outcome: 'overridden', hitCount: 1 }],
},
'audio-monitoring': { sessions: [], totalSessions: 0 },
stems: { owner: null, claims: [], bridges: [] },
},
recentOutcomes: [{ domain: 'audio-input', operation: 'open-source', sourceId: 'source-03', outcome: 'denied', status: 'denied' }],
}),
};
window.__slopsmithCapabilityInspector.render();
const content = elements.get('capability-inspector-content').innerHTML;
assert.match(content, /Input: Input 1:available:mono:native, Legacy Input:available:stereo:compatibility:superseded/);
assert.match(content, /Selected input: native:instrument:primary:available:available/);
assert.match(content, /Open input: input-open-01:mono:open:note_detect/);
assert.match(content, /Input bridges: audio-input\.legacy-source:overshadowed/);
assert.match(content, /Failures: audio-input:open-source:source-03:denied/);
});
test('capability inspector renders audio-monitoring providers sessions direct monitor bridges and failures', () => {
const snapshot = {
pipelines: [
{ name: 'audio-monitoring', review: { lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Audio monitoring surface.' }, participants: [
{ pluginId: 'core.audio.session', roles: ['owner'], commands: ['list-providers', 'select-provider', 'start', 'stop', 'set-direct-monitor'], operations: ['monitoring.start', 'monitoring.stop', 'monitoring.status'], events: ['monitoring-started', 'direct-monitor-changed'], runtime: true, availability: 'available', ownership: 'multi-provider', safety: 'sensitive' },
], conflicts: [] },
],
participants: [{ pluginId: 'core.audio.session' }],
compatibilityShims: [],
expectedCompatibilityShims: [],
};
const { window, elements } = loadInspector(snapshot);
window.slopsmith.audioSession = {
snapshot: () => ({
schema: 'slopsmith.audio_session.diagnostics.v1',
session: { route: { routeKind: 'desktop', availability: 'available' }, analyser: { source: 'plugin', availability: 'available' } },
domains: {
'audio-mix': { participants: [], faders: [], route: { routeKind: 'desktop', availability: 'available' }, analyser: { source: 'plugin', availability: 'available' }, bridges: [] },
'audio-input': { sources: [], totalSources: 0, openSessions: [], totalOpenSessions: 0, bridges: [] },
'audio-monitoring': {
providers: [
{ providerId: 'native_monitor', logicalMonitoringKey: 'native:monitor:main', label: 'Native Monitor', availability: 'available', sourceMode: 'native' },
{ providerId: 'legacy_monitor', logicalMonitoringKey: 'native:monitor:main', label: 'Legacy Monitor', availability: 'available', sourceMode: 'compatibility', supersededBy: 'native_monitor' },
],
selectedProvider: { providerId: 'native_monitor', logicalMonitoringKey: 'native:monitor:main', availability: 'available' },
sessions: [
{ monitoringId: 'monitoring-01', state: 'active', sourceRef: { logicalSourceKey: 'native:instrument:primary' }, requesters: [{ requesterId: 'user' }, { requesterId: 'note_detect' }], directMonitor: { preference: 'muted', control: 'supported', applied: true } },
{ monitoringId: 'monitoring-02', state: 'failed', sourceRef: { logicalSourceKey: 'native:instrument:primary' }, requesters: [{ requesterId: 'practice_overlay' }], directMonitor: { preference: 'unmuted', control: 'unsupported', applied: false } },
],
totalProviders: 2,
totalSessions: 2,
directMonitor: { preference: 'muted', control: 'supported', applied: true },
bridges: [{ bridgeId: 'audio-monitoring.audio-barrier', status: 'overshadowed', outcome: 'overridden', hitCount: 2 }],
},
stems: { owner: null, claims: [], bridges: [] },
},
recentOutcomes: [
{ domain: 'audio-monitoring', operation: 'start', monitoringId: 'monitoring-02', requesterId: 'practice_overlay', outcome: 'failed', status: 'timeout' },
{ domain: 'audio-monitoring', operation: 'start', requesterId: 'note_detect', outcome: 'provider-selection-required', status: 'provider-selection-required' },
],
}),
};
window.__slopsmithCapabilityInspector.render();
const content = elements.get('capability-inspector-content').innerHTML;
assert.match(content, /Monitoring providers: Native Monitor:available:native, Legacy Monitor:available:compatibility:superseded/);
assert.match(content, /Selected monitoring: native:monitor:main:available:native_monitor/);
assert.match(content, /Monitoring: monitoring-01:active:user\+note_detect:native:instrument:primary, monitoring-02:failed:practice_overlay:native:instrument:primary/);
assert.match(content, /Direct monitor: muted:supported:applied/);
assert.match(content, /Monitoring bridges: audio-monitoring\.audio-barrier:overshadowed/);
assert.match(content, /Failures: audio-monitoring:start:monitoring-02:timeout, audio-monitoring:start:note_detect:provider-selection-required/);
});
+71
View File
@@ -0,0 +1,71 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { createWindow, ROOT } = require('./capabilities_test_harness');
function loadDiagnostics() {
const window = createWindow();
// diagnostics.js short-circuits if window.slopsmith.diagnostics already
// exists (idempotent guard); the harness stubs it, so clear it first.
window.slopsmith.diagnostics = undefined;
window.navigator = { userAgent: 'test' };
const context = vm.createContext(window);
const source = fs.readFileSync(path.join(ROOT, 'static', 'diagnostics.js'), 'utf8');
vm.runInContext(source, context, { filename: 'diagnostics.js' });
return window;
}
test('summarizeRuntimeDomains counts actual UI contributions, not the {declared,legacy} wrapper keys', () => {
const { summarizeRuntimeDomains } = loadDiagnostics().slopsmith.diagnostics;
const snapshot = {
plugins: [
{
// Normalized backend shape: two declared regions (3 entries
// total) + two legacy entries = 5, NOT 2 (the wrapper keys).
ui_contributions: {
declared: {
'ui.navigation': [{ id: 'a' }, { id: 'b' }],
'settings': [{ id: 'c' }],
},
legacy: [
{ region: 'ui.navigation', legacy_source: 'nav' },
{ region: 'settings', legacy_source: 'settings' },
],
},
runtime_domains: { library: {}, playback: {} },
},
{
ui_contributions: { declared: {}, legacy: [] },
runtime_domains: {},
},
],
};
const summary = summarizeRuntimeDomains(snapshot);
assert.equal(summary.plugin_count, 2);
assert.equal(summary.ui_contribution_count, 5);
assert.equal(summary.runtime_domain_count, 2);
});
test('summarizeRuntimeDomains tolerates a flat region→contributions map', () => {
const { summarizeRuntimeDomains } = loadDiagnostics().slopsmith.diagnostics;
const summary = summarizeRuntimeDomains({
plugins: [{ ui_contributions: { 'ui.navigation': [{ id: 'a' }, { id: 'b' }] } }],
});
assert.equal(summary.ui_contribution_count, 2);
});
test('summarizeRuntimeDomains counts only array region values (malformed values are ignored)', () => {
const { summarizeRuntimeDomains } = loadDiagnostics().slopsmith.diagnostics;
// Non-array region values (a stray object/string from a malformed payload)
// must not inflate the count — only the two real array entries count.
const declaredSummary = summarizeRuntimeDomains({
plugins: [{ ui_contributions: { declared: { good: [{ id: 'a' }, { id: 'b' }], bad: { id: 'x' }, alsoBad: 'nope' }, legacy: [] } }],
});
assert.equal(declaredSummary.ui_contribution_count, 2);
const flatSummary = summarizeRuntimeDomains({
plugins: [{ ui_contributions: { good: [{ id: 'a' }], bad: { id: 'x' } } }],
});
assert.equal(flatSummary.ui_contribution_count, 1);
});
+68
View File
@@ -0,0 +1,68 @@
// Verify static/highway.js handles the `handshapes` WebSocket message:
// accumulates incoming chunks, time-sorts on `ready`, and exposes the
// result on the renderer bundle. The 3D arpeggio frame / chord rails
// pipeline consumes that bundle; if accumulation or sort drifts, those
// visuals silently render wrong (out-of-order frames, missing hints).
//
// Same source-level guard strategy as the other tests/js/ files —
// extract the relevant case body and assert the wiring.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const HIGHWAY_JS = path.join(__dirname, '..', '..', 'static', 'highway.js');
function getCaseBlock(src, label) {
const start = src.indexOf(`case '${label}'`);
assert.ok(start !== -1, `case '${label}' not found in highway.js`);
const tail = src.slice(start);
const nextCase = tail.search(/\n\s*case\s+['"]/);
const nextDefault = tail.search(/\n\s*default\s*:/);
let end = tail.length;
if (nextCase > 0) end = Math.min(end, nextCase);
if (nextDefault > 0) end = Math.min(end, nextDefault);
return tail.slice(0, end);
}
test('handshapes WS case accumulates incoming chunks into handShapes', () => {
// Server streams handshapes in chunks; the case must concat rather
// than replace so multi-chunk sources don't silently truncate.
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
const block = getCaseBlock(src, 'handshapes');
assert.match(
block,
/handShapes\s*=\s*handShapes\.concat\(\s*msg\.data\s*\)/,
'handshapes case must concat msg.data into the handShapes accumulator',
);
});
test('ready case time-sorts handShapes before rendering', () => {
// Out-of-order chunks would otherwise leave handShapes interleaved,
// breaking the binary-search lookups the 3D renderer does when
// probing arpeggio coverage for a chord at time t.
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
const block = getCaseBlock(src, 'ready');
assert.match(
block,
/handShapes\.sort\(\s*\(\s*a\s*,\s*b\s*\)\s*=>\s*a\.start_time\s*-\s*b\.start_time\s*\)/,
'ready case must sort handShapes by start_time so the renderer can rely on ordering',
);
});
test('bundle exposes handShapes to renderers with flat-list fallback', () => {
// Renderers (highway_3d in particular) read `bundle.handShapes`; if
// the bundle key gets renamed or dropped, the 3D arp-frame pipeline
// goes dark without a runtime error. The current shape is a ternary
// that picks `_filteredHandShapes` when phrase data carries any and
// falls back to the flat `handShapes` list otherwise (DLC pattern).
// Pin both sides of the ternary so accidentally dropping the
// fallback branch fails the test.
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
assert.match(
src,
/\bhandShapes:\s*\([^)]*_filteredHandShapes[^)]*\)\s*\?\s*_filteredHandShapes\s*:\s*handShapes\b/,
'bundle must expose handShapes with the _filteredHandShapes-vs-handShapes ternary fallback',
);
});
+51
View File
@@ -0,0 +1,51 @@
// Pins the arpeggio chord-gem deferral gating in plugins/highway_3d/screen.js
// (slopsmith#262). Without these guards, an over-eager `deferChordGems` makes
// arpeggio frames empty when standalone notes don't actually cover the shape,
// and an under-eager one duplicates gems on top of the standalone passage.
//
// Source-level only — same strategy as the other tests/js/ files.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
test('chordShapeCoveredByStandaloneNotes helper exists with the expected signature', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8');
assert.match(
src,
/function\s+chordShapeCoveredByStandaloneNotes\s*\(\s*ch\s*,\s*shape\s*,\s*notesArr\s*,\s*timeWin\s*\)/,
'helper that scans the note stream for shape coverage must remain on screen.js',
);
});
test('deferChordGems gates both synth and explicit+covered branches on note-stream coverage', () => {
// Either branch firing without coverage produces the empty-lavender-frame
// regression PR #262 fixed. Pin both predicates so a refactor that drops
// one gate fails the test.
const src = fs.readFileSync(SCREEN_JS, 'utf8');
assert.match(
src,
/const\s+deferChordGems\s*=\s*\(\s*ch\.h3dSynth\s*&&\s*noteStreamCoversArpShape\(\)\s*\)\s*\|\|\s*inferredArpPattern\s*\|\|\s*\(\s*hsHintFrame\.explicit\s*&&\s*hsHintFrame\.covered\s*&&\s*noteStreamCoversArpShape\(\)\s*\)/,
'deferChordGems must guard the h3dSynth and explicit+covered branches with the coverage check',
);
});
test('noteStreamCoversArpShape is computed lazily (called, not eagerly bound)', () => {
// Eager allocation regressed perf on dense charts (Copilot review on PR
// #262). The shape must be a callable so short-circuit evaluation skips
// the note-stream scan when neither gating branch needs it.
const src = fs.readFileSync(SCREEN_JS, 'utf8');
assert.match(
src,
/const\s+noteStreamCoversArpShape\s*=\s*(?:\(\s*\)\s*=>|function(?:\s+\w+)?\s*\(\s*\))/,
'noteStreamCoversArpShape must be an arrow/function so the scan is lazy',
);
assert.doesNotMatch(
src,
/const\s+noteStreamCoversArpShape\s*=\s*chordShapeCoveredByStandaloneNotes\(/,
'noteStreamCoversArpShape must not eagerly invoke the coverage helper',
);
});
+138
View File
@@ -0,0 +1,138 @@
// Pins the camera framing + lookahead behaviour in
// plugins/highway_3d/screen.js.
//
// Two independent pieces are covered:
// 1. Zoom-dependent framing — the cam.position height/depth multipliers are
// interpolated by zoom distance between a NEAR (tight, nut-position) and a
// FAR (wide, whole-neck) view via the CAM_FRAME_* constants, instead of
// being fixed literals.
// 2. Measure-based lookahead — the camera lookahead window spans
// CAM_LOOKAHEAD_MEASURES measures ahead (derived from the chart beats,
// ignoring intra-measure measure === -1 beats) instead of a fixed number
// of seconds.
//
// A refactor that re-hardcodes the framing multipliers, drops the measure
// cache, or reverts the lookahead window to seconds would silently regress the
// camera. Also guards that the temporary debug hook stayed removed.
//
// Source-level only — same strategy as the other tests/js/ files.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const src = fs.readFileSync(SCREEN_JS, 'utf8');
// ── Zoom-dependent framing ──────────────────────────────────────────────────
test('framing NEAR/FAR multiplier constants are defined', () => {
for (const name of [
'CAM_FRAME_DIST_NEAR', 'CAM_FRAME_DIST_FAR',
'CAM_FRAME_H_NEAR', 'CAM_FRAME_H_FAR',
'CAM_FRAME_D_NEAR', 'CAM_FRAME_D_FAR',
]) {
assert.match(src, new RegExp('const\\s+' + name + '\\s*='),
`${name} must be declared as a framing constant`);
}
});
test('cam.position uses interpolated framing multipliers, not literals', () => {
// The height/depth multipliers are computed (_hMul / _dMul), not inlined.
// The base position is assigned into _camX/_camY/_camZ so the opt-in
// free-camera bridge (#771) can layer orbit/zoom/height on top before the
// single cam.position.set; the multipliers must still feed _camY/_camZ.
assert.match(
src,
/_camX\s*=\s*curX\s*\+\s*shoulderOffset\s*,\s*_camY\s*=\s*h\s*\*\s*_hMul\s*,\s*_camZ\s*=\s*dist\s*\*\s*_dMul/,
'the base camera position must use the interpolated _hMul / _dMul multipliers',
);
assert.match(
src,
/cam\.position\.set\(\s*_camX\s*,\s*_camY\s*,\s*_camZ\s*\)/,
'cam.position.set must apply the computed _camX / _camY / _camZ',
);
});
test('framing multipliers are a clamped zoom-distance interpolation', () => {
// _zt is clamped to [0,1] and lerps each multiplier between NEAR and FAR.
assert.match(
src,
/Math\.max\(0,\s*Math\.min\(1,[\s\S]*?CAM_FRAME_DIST_NEAR[\s\S]*?CAM_FRAME_DIST_FAR/,
'_zt must clamp (dist - NEAR)/(FAR - NEAR) into [0,1]',
);
assert.match(
src,
/CAM_FRAME_H_NEAR\s*\+\s*\(\s*CAM_FRAME_H_FAR\s*-\s*CAM_FRAME_H_NEAR\s*\)\s*\*\s*_zt/,
'height multiplier must lerp NEAR->FAR by _zt',
);
assert.match(
src,
/CAM_FRAME_D_NEAR\s*\+\s*\(\s*CAM_FRAME_D_FAR\s*-\s*CAM_FRAME_D_NEAR\s*\)\s*\*\s*_zt/,
'depth multiplier must lerp NEAR->FAR by _zt',
);
});
// ── Measure-based lookahead window ──────────────────────────────────────────
test('lookahead window is expressed in measures with a seconds fallback', () => {
assert.match(src, /const\s+CAM_LOOKAHEAD_MEASURES\s*=\s*9\b/,
'CAM_LOOKAHEAD_MEASURES must default to 9');
assert.match(src, /const\s+CAM_LOOKAHEAD_SEC\s*=\s*3\.0\b/,
'CAM_LOOKAHEAD_SEC must stay as the no-beats fallback');
});
test('measure-start cache only keeps beats with measure >= 0', () => {
// Intra-measure beats carry measure === -1 and must be skipped.
assert.match(
src,
/Number\.isFinite\(\s*_b\.measure\s*\)\s*&&\s*_b\.measure\s*>=\s*0[\s\S]*?_measureStarts\s*=\s*_ms/,
'only measure-start beats (measure >= 0) feed _measureStarts',
);
});
test('lookaheadEndTime targets the measure CAM_LOOKAHEAD_MEASURES ahead', () => {
assert.match(
src,
/function\s+lookaheadEndTime\s*\(\s*now\s*\)/,
'lookaheadEndTime(now) helper must exist',
);
assert.match(
src,
/const\s+targetIdx\s*=\s*curIdx\s*\+\s*CAM_LOOKAHEAD_MEASURES/,
'target measure index = current measure + CAM_LOOKAHEAD_MEASURES',
);
// No beats → seconds fallback.
assert.match(
src,
/if\s*\(\s*!ms\s*\|\|\s*ms\.length\s*===\s*0\s*\)\s*return\s+now\s*\+\s*CAM_LOOKAHEAD_SEC/,
'lookaheadEndTime must fall back to seconds when there are no measures',
);
});
test('fret-bounds scan drives its window off lookaheadEndTime, not fixed seconds', () => {
assert.match(
src,
/function\s+lookaheadComputeFretBounds[\s\S]*?const\s+tEnd\s*=\s*lookaheadEndTime\(\s*now\s*\)/,
'lookaheadComputeFretBounds must derive tEnd from lookaheadEndTime(now)',
);
});
test('measure-start cache is invalidated on song change', () => {
// The song-change reset (reconnect path) resets _camSnapped; it must also
// drop the measure-start cache, otherwise lookaheadEndTime sizes the window
// off the previous song's measure grid and over-zooms the first-data snap.
assert.match(
src,
/_camSnapped\s*=\s*false\s*;[\s\S]*?_measureStarts\s*=\s*\[\]\s*;\s*_measureStartsRef\s*=\s*null\s*;/,
'song-change reset must clear _measureStarts / _measureStartsRef alongside _camSnapped',
);
});
// ── Debug hook stayed removed ───────────────────────────────────────────────
test('temporary camera debug hook is not present', () => {
assert.doesNotMatch(src, /h3dCamDebug/,
'the window.h3dCamDebug tuning hook must not ship in the renderer');
});
@@ -0,0 +1,86 @@
// Pins the renderOrder of fret-number labels and connector/drop lines in
// plugins/highway_3d/screen.js.
//
// The 3D highway uses depth-proportional renderOrder values so near entities
// paint over far entities while same-depth sublayers stay deterministic. Fret
// numbers must be labels, not gem peers: they render above the note symbols so
// incoming gems never partially cover the digits.
//
// Same-note ordering:
// renderOrderForLayerAtZ(z, CHORD_FRAME) chord frame edge
// renderOrderForLayerAtZ(noteZ, CONNECTOR_LINE) connector / drop line
// renderOrderForLayerAtZ(noteZ, NOTE_OUTLINE) gem outline
// renderOrderForLayerAtZ(noteZ, NOTE_CORE) gem core
// renderOrderForLayerAtZ(noteZ, TECHNIQUE_MARKER) technique marker
// renderOrderForLayerAtZ(noteZ, NOTE_FRET_LABEL) primary fret number
// renderOrderForLayerAtZ(noteZ, ARP_NOTE_FRET_LABEL) arpeggio fret-number tie-breaker
//
// Source-level regex checks; no Three.js or DOM required.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
let _src;
/** Returns the cached 3D highway screen source under test. */
function src() {
if (!_src) _src = fs.readFileSync(SCREEN_JS, 'utf8');
return _src;
}
test('connector line uses the named connector layer', () => {
assert.match(
src(),
/line\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*_isArpNote\s*\?\s*'ARP_CONNECTOR_LINE'\s*:\s*'CONNECTOR_LINE'\s*\)\s*;/,
'pConnectorLine renderOrder must use the connector layer names',
);
});
test('primary fret label renders above gem core and technique markers', () => {
assert.match(
src(),
/fretLabel\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*_isArpNote\s*\?\s*'ARP_NOTE_FRET_LABEL'\s*:\s*'NOTE_FRET_LABEL'\s*\)\s*;/,
'pNoteFretLabel renderOrder must use the fret-label layer names',
);
});
test('synthetic chord fret label uses the same label layer as primary labels', () => {
assert.match(
src(),
/fl2\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*_isArp2\s*\?\s*'ARP_NOTE_FRET_LABEL'\s*:\s*'NOTE_FRET_LABEL'\s*\)\s*;/,
'fl2 renderOrder must use the same fret-label layer names',
);
});
test('drop line uses the named connector layer below gems', () => {
assert.match(
src(),
/dl\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'CONNECTOR_LINE'\s*\)\s*;/,
'pDropLine renderOrder must use CONNECTOR_LINE',
);
});
test('chord-loop fret labels render above same-depth gem symbols', () => {
assert.match(
src(),
/lbl\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_FRET_LABEL'\s*\)\s*;/,
'chord-loop fret label must use CHORD_FRET_LABEL',
);
});
test('chord frame and note outline use named depth-layer helper calls', () => {
assert.match(src(), /const\s+chordFrameRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_FRAME'\s*\)\s*;/);
assert.match(src(), /outline\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'NOTE_OUTLINE'\s*\)\s*;/);
});
test('no fixed low renderOrder assignments remain for affected label paths', () => {
const s = src();
assert.doesNotMatch(s, /fretLabel\.renderOrder\s*=\s*(?:16|23)\s*;/);
assert.doesNotMatch(s, /fl2\.renderOrder\s*=\s*(?:16|23)\s*;/);
assert.doesNotMatch(s, /lbl\.renderOrder\s*=\s*21\s*;/);
assert.doesNotMatch(s, /line\.renderOrder\s*=\s*_isArpNote\s*\?\s*22\s*:\s*15\s*;/);
assert.doesNotMatch(s, /dl\.renderOrder\s*=\s*22\s*;/);
});
@@ -0,0 +1,80 @@
// Pins the fret-row label visibility and color rules in
// plugins/highway_3d/screen.js.
//
// Rules:
// 1. Only main frets (DOTS: 3,5,7,9,12,…) show a gray label by default.
// Non-dot frets outside the anchor range are skipped entirely.
// 2. Every fret inside the active anchor range [f0, f1] shows a gold label,
// even if it is not a dot fret.
// f0 = anchor.fret, f1 = anchor.fret + anchor.width - 1
// e.g. { fret:3, width:4 } → 3,4,5,6 gold (4 is not a dot fret).
//
// Source-level regex checks — no Three.js or DOM required.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
let _src;
function src() {
if (!_src) _src = fs.readFileSync(SCREEN_JS, 'utf8');
return _src;
}
test('fret row uses anchorPlayedFretSpanAt to get anchor range [f0, f1]', () => {
// anchorPlayedFretSpanAt returns { f0: anchor.fret, f1: anchor.fret+width-1 }.
// This is the correct span for note labels (the played zone), distinct from
// the wire span used by fret wires (anchorLaneBoundsAt: dMin=fret-1, dMax=fret+width-1).
assert.match(
src(),
/anchorPlayedFretSpanAt\(\s*anchors\s*,\s*now\s*\)/,
'fret row must call anchorPlayedFretSpanAt(anchors, now)',
);
});
test('non-main frets outside anchor range are skipped (continue)', () => {
// The loop must skip frets that are neither in the anchor range nor a
// DOTS (main) fret, so non-dot frets never show a gray ghost label.
assert.match(
src(),
/if\s*\(\s*!isInAnchor\s*&&\s*!isMainFret\s*\)\s*continue\s*;/,
'fret row loop must skip non-anchor non-dot frets with: if (!isInAnchor && !isMainFret) continue',
);
});
test('in-anchor frets use FRET_LABEL_GOLD_HEX color', () => {
assert.match(
src(),
/isInAnchor\s*\?\s*FRET_LABEL_GOLD_HEX\s*:\s*FRET_LABEL_IDLE_HEX/,
'fret row color must be FRET_LABEL_GOLD_HEX when in anchor, FRET_LABEL_IDLE_HEX otherwise',
);
});
test('in-anchor frets use opacity 1.0, idle frets use opacity 0.55', () => {
assert.match(
src(),
/lb\.material\.opacity\s*=\s*isInAnchor\s*\?\s*1\.0\s*:\s*0\.55\s*;/,
'fret row opacity must be 1.0 in anchor and 0.55 idle',
);
});
test('isMainFret uses DOTS.includes(f) — same dot positions as fret wires and inlays', () => {
// Main frets are the dot-inlay positions (3,5,7,9,12,15,17,19,21,24).
// Reusing DOTS keeps the visible set consistent across all fret indicators.
assert.match(
src(),
/const\s+isMainFret\s*=\s*DOTS\.includes\(\s*f\s*\)/,
'isMainFret must be defined as DOTS.includes(f)',
);
});
test('isInAnchor checks anchorSpan.f0 and anchorSpan.f1 inclusive bounds', () => {
assert.match(
src(),
/f\s*>=\s*anchorSpan\.f0\s*&&\s*f\s*<=\s*anchorSpan\.f1/,
'isInAnchor must check f >= anchorSpan.f0 && f <= anchorSpan.f1',
);
});
+44
View File
@@ -0,0 +1,44 @@
// Pins the fret-spacing setting in plugins/highway_3d/screen.js (PR #329).
// The board can render fret columns either Uniform (equal width, Rocksmith
// Remastered style) or Logarithmic (real instrument geometry), switchable at
// runtime via window.h3dSetFretSpacing and persisted in localStorage. A
// refactor that renames the storage key, drops the uniform/log branch in
// fretX, or stops validating the mode would silently regress the setting.
//
// Source-level only — same strategy as the other tests/js/ files.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
test('fret-spacing mode is read from the highway_3d.fretSpacing localStorage key', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8');
assert.match(
src,
/_h3dFretUniform\s*=\s*localStorage\.getItem\(\s*'highway_3d\.fretSpacing'\s*\)\s*!==\s*'logarithmic'/,
'startup must read highway_3d.fretSpacing and treat anything but "logarithmic" as uniform',
);
});
test('fretX switches between the uniform and logarithmic implementations', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8');
assert.match(
src,
/const\s+fretX\s*=\s*f\s*=>\s*_h3dFretUniform\s*\?\s*_fretXUni\(f\)\s*:\s*_fretXLog\(f\)/,
'fretX must pick _fretXUni when _h3dFretUniform else _fretXLog',
);
});
test('h3dSetFretSpacing validates the mode against the two supported values', () => {
// An unexpected input must not be persisted verbatim — it is coerced to
// one of 'logarithmic' | 'uniform' before writing to localStorage.
const src = fs.readFileSync(SCREEN_JS, 'utf8');
assert.match(
src,
/window\.h3dSetFretSpacing\s*=\s*mode\s*=>\s*\{[\s\S]*?mode\s*===\s*'logarithmic'\s*\?\s*'logarithmic'\s*:\s*'uniform'[\s\S]*?localStorage\.setItem\(\s*'highway_3d\.fretSpacing'/,
'h3dSetFretSpacing must coerce mode to a supported value before persisting',
);
});
+82
View File
@@ -0,0 +1,82 @@
// Pins the "lean sustain" rendering default in plugins/highway_3d/screen.js.
//
// Dense palm-mute / fret-hand-mute passages are GPU fill-bound: the
// transparent sustain trails/rails stack many blended fragments. Profiling
// (a pinned A/B loop) traced most of the cost to the additive rail BLOOM
// halo, so by default the renderer skips ONLY the bloom (the most expensive
// per-pixel layer) while KEEPING the thin trail/ribbon white outline that
// gives tails their hit/miss-coloured border.
//
// A refactor that (a) flips the lean default off, (b) re-gates the trail or
// ribbon outline behind the lean flag, or (c) stops feeding the outline the
// hit/miss-aware material would silently regress the look or the perf win.
//
// Source-level only — same strategy as the other tests/js/ files.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
test('lean sustain rendering is the default (_leanSus starts true)', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8');
assert.match(
src,
/let\s+_leanSus\s*=\s*true\s*;/,
'_leanSus must default to true so lean rendering is the out-of-the-box behaviour',
);
});
test('the full-quality look is an opt-out via localStorage h3d_full_sus', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8');
assert.match(
src,
/_leanSus\s*=\s*localStorage\.getItem\(\s*['"]h3d_full_sus['"]\s*\)\s*!==\s*['"]1['"]/,
"lean must stay on unless localStorage.h3d_full_sus === '1' opts back into the full look",
);
});
test('exactly one element is gated behind the lean flag, and it is the rail bloom', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8');
// Only the additive rail bloom may hide behind the lean flag. If a future
// edit re-gates the trail or ribbon outline behind !_leanSus, this count
// climbs above 1 and the test fails — that's the regression guard.
const gates = src.match(/if\s*\(\s*!_leanSus\s*\)/g) || [];
assert.equal(
gates.length,
1,
'expected exactly one `if (!_leanSus)` gate (the rail bloom); the outline must stay ungated',
);
assert.match(
src,
/if\s*\(\s*!_leanSus\s*\)\s*\{[\s\S]{0,200}?pSusRailBloom\.get\(\)/,
'the single lean gate must be the one that wraps pSusRailBloom.get()',
);
});
test('the trail + ribbon outline always draw and use the hit/miss-aware material', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8');
// Outline material is hit/miss aware: miss -> mMissOutline, confirmed hit
// -> bright, otherwise the default mSusOutline white border.
assert.match(
src,
/_susOlMat\s*=\s*_ndState\s*===\s*'miss'\s*\?\s*mMissOutline[\s\S]*?:\s*mSusOutline\s*;/,
'_susOlMat must remain hit/miss aware so the tail border colours track note state',
);
// Box trail: the outline (trOut, pSusOutline) is drawn and fed _susOlMat,
// immediately followed by the coloured core (tr, pSus) — both ungated.
assert.match(
src,
/const\s+trOut\s*=\s*pSusOutline\.get\(\)\s*;[\s\S]*?trOut\.material\s*=\s*_susOlMat\s*;[\s\S]{0,400}?const\s+tr\s*=\s*pSus\.get\(\)/,
'the box-trail outline (pSusOutline + _susOlMat) must draw alongside the core trail',
);
// Ribbon trail (slide / bend / tremolo / vibrato): the outline (olMesh,
// pSusRibbonOl) is drawn and fed _susOlMat, then the ribbon body.
assert.match(
src,
/const\s+olMesh\s*=\s*pSusRibbonOl\.get\(\)\s*;[\s\S]*?olMesh\.material\s*=\s*_susOlMat\s*;[\s\S]*?const\s+body\s*=\s*pSusRibbon\.get\(\)/,
'the ribbon-trail outline (pSusRibbonOl + _susOlMat) must draw alongside the ribbon body',
);
});
+93
View File
@@ -0,0 +1,93 @@
// Pins 3D Highway left-handed fret ordering (slopsmith#321).
// Source-level only, matching the other tests/js/ regression guards: the
// runtime path is browser/WebGL-heavy, so these tests preserve the exact
// contracts that keep the lefty geometry coherent.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const HIGHWAY_JS = path.join(ROOT, 'static', 'highway.js');
const SCREEN_JS = path.join(ROOT, 'plugins', 'highway_3d', 'screen.js');
const CLAUDE_MD = path.join(ROOT, 'plugins', 'highway_3d', 'CLAUDE.md');
function src(file) {
return fs.readFileSync(file, 'utf8');
}
test('highway renderer bundles surface the core lefty flag', () => {
assert.match(
src(HIGHWAY_JS),
/lefty\s*:\s*_lefty/,
'custom renderer bundles must include lefty: _lefty',
);
});
test('3D Highway defines lefty-aware fret-position helpers', () => {
const screen = src(SCREEN_JS);
assert.match(
screen,
/let\s+_leftyCached\s*=\s*false\s*;/,
'renderer must cache the bundle lefty flag',
);
assert.match(
screen,
/const\s+xFret\s*=\s*f\s*=>\s*\(\s*_leftyCached\s*\?\s*-fretX\(f\)\s*:\s*fretX\(f\)\s*\)/,
'xFret must mirror fret edges when lefty is active',
);
assert.match(
screen,
/const\s+xFretMid\s*=\s*f\s*=>\s*\(\s*_leftyCached\s*\?\s*-fretMid\(f\)\s*:\s*fretMid\(f\)\s*\)/,
'xFretMid must mirror fret centers when lefty is active',
);
assert.match(
screen,
/const\s+boardSpanX\s*=\s*\(\s*\)\s*=>\s*\{[\s\S]*?const\s+x0\s*=\s*xFret\(0\)\s*;[\s\S]*?const\s+xN\s*=\s*xFret\(NFRETS\)\s*;[\s\S]*?min\s*:\s*Math\.min\(x0,\s*xN\)[\s\S]*?max\s*:\s*Math\.max\(x0,\s*xN\)[\s\S]*?center\s*:\s*\(x0\s*\+\s*xN\)\s*\/\s*2[\s\S]*?width\s*:\s*Math\.abs\(xN\s*-\s*x0\)/,
'boardSpanX must derive min/max/center/width from the lefty-aware xFret helper',
);
});
test('draw(bundle) handles lefty changes by flipping camera X state and rebuilding the board', () => {
const screen = src(SCREEN_JS);
assert.match(
screen,
/_leftyCached\s*=\s*!!bundle\.lefty\s*;/,
'draw(bundle) must refresh _leftyCached from bundle.lefty',
);
assert.match(
screen,
/const\s+leftyChanged\s*=\s*_leftyCached\s*!==\s*_leftyForBoard\s*;/,
'draw(bundle) must detect runtime lefty changes',
);
assert.match(
screen,
/if\s*\(\s*_invertedCached\s*!==\s*_invertedForBoard\s*\|\|\s*leftyChanged\s*\|\|\s*newNStr\s*!==\s*nStr\s*\)\s*\{[\s\S]*?if\s*\(\s*leftyChanged\s*\)\s*\{[\s\S]*?curX\s*=\s*-curX\s*;[\s\S]*?tgtX\s*=\s*-tgtX\s*;[\s\S]*?_lookaheadCamX\s*=\s*-_lookaheadCamX\s*;[\s\S]*?\}[\s\S]*?buildBoard\(\)\s*;[\s\S]*?_leftyForBoard\s*=\s*_leftyCached\s*;/,
'lefty changes must mirror curX/tgtX/_lookaheadCamX, rebuild board geometry, and update _leftyForBoard',
);
});
test('camera shoulder offset follows the cached lefty orientation', () => {
assert.match(
src(SCREEN_JS),
// The shoulder offset now feeds the base _camX (which the opt-in
// free-camera bridge layers on top of) before cam.position.set (#771).
/const\s+shoulderOffset\s*=\s*\(\s*_leftyCached\s*\?\s*-1\s*:\s*1\s*\)\s*\*\s*10\s*\*\s*K\s*;[\s\S]*?_camX\s*=\s*curX\s*\+\s*shoulderOffset/,
'camera shoulder offset must flip with _leftyCached',
);
});
test('3D Highway documentation says the renderer consumes bundle.lefty', () => {
const doc = src(CLAUDE_MD);
assert.doesNotMatch(
doc,
/bundle\.lefty[\s\S]{0,180}renderer never reads it/,
'CLAUDE.md must not claim the 3D renderer ignores bundle.lefty',
);
assert.match(
doc,
/bundle\.lefty[\s\S]{0,240}(?:mirrors|lefty|left-handed|left-handed)/i,
'CLAUDE.md should document the lefty flag as part of the renderer contract',
);
});
+178
View File
@@ -0,0 +1,178 @@
// Contract test for 3D Highway per-panel control metadata (slopsmith#247).
// The plugin script is evaluated in a vm sandbox so factory statics are
// tested without constructing a renderer instance or calling init().
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
// 'palette' was removed — per-string colors are now set via the core
// "Highway String Colors" UI, which drives both highways by named string.
const REQUIRED_KEYS = ['cameraSmoothing', 'cameraLockLow', 'cameraLockZoom'];
const FORBIDDEN_KEYS = ['customImageDataUrl', 'customImageName', 'customVideoName'];
const VALID_TYPES = new Set(['select', 'range', 'toggle']);
function loadHighway3dStatics() {
const src = fs.readFileSync(SCREEN_JS, 'utf8');
// Inject test exports right after the factory registration — a stable,
// semantic anchor inside the IIFE — so harmless footer edits (a trailing
// sourceMappingURL comment, extra whitespace, a different IIFE close
// style) do not break this contract test.
const ANCHOR = 'window.slopsmithViz_highway_3d = createFactory;';
assert.equal(
src.split(ANCHOR).length - 1,
1,
'expected exactly one factory-registration anchor in screen.js',
);
const instrumented = src.replace(
ANCHOR,
`${ANCHOR}\n window.__h3dTestExports = { BG_DEFAULTS };`,
);
assert.notEqual(instrumented, src, 'test export injection anchor not found in screen.js');
const sandbox = {
console: {
error() {},
log() {},
warn() {},
},
localStorage: {
getItem() { return null; },
setItem() {},
},
performance: { now: () => 0 },
window: {
slopsmithTour: {
register() {},
},
},
};
vm.createContext(sandbox);
vm.runInContext(instrumented, sandbox, { filename: SCREEN_JS });
return sandbox.window;
}
function cloneJson(value) {
return JSON.parse(JSON.stringify(value));
}
function optionValue(option) {
if (option && typeof option === 'object') return option.id;
return undefined;
}
function assertOptionObject(option, controlKey) {
assert.equal(
Object.prototype.toString.call(option),
'[object Object]',
`${controlKey}.options entries must be { id, label } objects`,
);
assert.equal(typeof option.id, 'string', `${controlKey}.options id must be a string`);
assert.ok(option.id.length > 0, `${controlKey}.options id must not be blank`);
assert.equal(typeof option.label, 'string', `${controlKey}.options label must be a string`);
assert.ok(option.label.trim().length > 0, `${controlKey}.options label must not be blank`);
}
test('3D Highway exposes static panelControls descriptors for per-panel hosts', () => {
const window = loadHighway3dStatics();
const factory = window.slopsmithViz_highway_3d;
assert.equal(typeof factory, 'function', 'screen.js must register the 3D Highway factory');
assert.ok(
Object.prototype.hasOwnProperty.call(factory, 'panelControls'),
'panelControls must be an own static property on the factory',
);
assert.ok(Array.isArray(factory.panelControls), 'panelControls must be an array');
const controls = cloneJson(factory.panelControls);
const defaults = cloneJson(window.__h3dTestExports.BG_DEFAULTS);
const keys = controls.map((control) => control && control.key);
assert.deepEqual(keys, REQUIRED_KEYS, 'panelControls must expose exactly the issue #247 control set');
const duplicateKeys = keys.filter((key, index) => keys.indexOf(key) !== index);
assert.deepEqual(duplicateKeys, [], 'panelControls keys must be unique');
const controlsByKey = new Map();
for (const control of controls) {
assert.equal(
Object.prototype.toString.call(control),
'[object Object]',
'each panel control must be a plain descriptor object',
);
assert.equal(typeof control.key, 'string', 'descriptor.key must be a string');
assert.match(control.key, /^[A-Za-z][A-Za-z0-9]*$/, 'descriptor.key must be a BG_DEFAULTS-style key');
assert.equal(typeof control.label, 'string', `${control.key}.label must be a string`);
assert.ok(control.label.trim().length > 0, `${control.key}.label must not be blank`);
assert.equal(typeof control.type, 'string', `${control.key}.type must be a string`);
assert.ok(VALID_TYPES.has(control.type), `${control.key}.type must be select, range, or toggle`);
assert.ok(Object.prototype.hasOwnProperty.call(control, 'default'), `${control.key} must declare a default`);
assert.ok(
Object.prototype.hasOwnProperty.call(defaults, control.key),
`${control.key} must map to a BG_DEFAULTS entry`,
);
assert.deepEqual(control.default, defaults[control.key], `${control.key}.default must match BG_DEFAULTS`);
assert.ok(!controlsByKey.has(control.key), `${control.key} appears more than once in panelControls`);
controlsByKey.set(control.key, control);
if (control.type === 'select') {
assert.ok(Array.isArray(control.options), `${control.key}.options must be an array`);
assert.ok(control.options.length > 0, `${control.key}.options must not be empty`);
const values = control.options.map(optionValue);
assert.equal(values.length, new Set(values).size, `${control.key}.options values must be unique`);
for (const option of control.options) {
assertOptionObject(option, control.key);
}
for (const value of values) {
assert.equal(typeof value, 'string', `${control.key}.options values must be strings`);
}
assert.ok(values.includes(control.default), `${control.key}.options must include the default`);
}
if (control.type === 'range') {
assert.equal(typeof control.min, 'number', `${control.key}.min must be a number`);
assert.equal(typeof control.max, 'number', `${control.key}.max must be a number`);
assert.ok(Number.isFinite(control.min), `${control.key}.min must be finite`);
assert.ok(Number.isFinite(control.max), `${control.key}.max must be finite`);
assert.ok(control.min < control.max, `${control.key}.min must be less than max`);
assert.equal(typeof control.default, 'number', `${control.key}.default must be numeric`);
assert.ok(control.default >= control.min, `${control.key}.default must be >= min`);
assert.ok(control.default <= control.max, `${control.key}.default must be <= max`);
if (Object.prototype.hasOwnProperty.call(control, 'step')) {
assert.equal(typeof control.step, 'number', `${control.key}.step must be a number`);
assert.ok(control.step > 0, `${control.key}.step must be positive`);
}
}
if (control.type === 'toggle') {
assert.equal(typeof control.default, 'boolean', `${control.key}.default must be boolean`);
}
}
for (const key of REQUIRED_KEYS) {
assert.ok(controlsByKey.has(key), `panelControls must include ${key}`);
}
for (const key of FORBIDDEN_KEYS) {
assert.ok(!controlsByKey.has(key), `panelControls must not expose global-only asset key ${key}`);
}
const cameraSmoothing = controlsByKey.get('cameraSmoothing');
assert.equal(cameraSmoothing.type, 'range', 'cameraSmoothing must be a range control');
assert.equal(cameraSmoothing.min, 0);
assert.equal(cameraSmoothing.max, 1);
assert.equal(cameraSmoothing.default, defaults.cameraSmoothing);
const cameraLockLow = controlsByKey.get('cameraLockLow');
assert.equal(cameraLockLow.type, 'toggle', 'cameraLockLow must be a toggle control');
assert.equal(typeof cameraLockLow.default, 'boolean', 'cameraLockLow default must be boolean');
assert.equal(cameraLockLow.default, defaults.cameraLockLow);
const cameraLockZoom = controlsByKey.get('cameraLockZoom');
assert.equal(cameraLockZoom.type, 'range', 'cameraLockZoom must be a range control');
assert.equal(cameraLockZoom.min, 0);
assert.equal(cameraLockZoom.max, 1);
assert.equal(cameraLockZoom.default, defaults.cameraLockZoom);
});
+73
View File
@@ -0,0 +1,73 @@
// Source-level guards for the pool().warm() helper added in
// slopsmith#226 — locks in:
// 1. The factory exposes .warm() (so future refactors don't quietly
// remove the boardInit pre-allocation strategy).
// 2. warm() coerces its argument via `cap | 0` + `Math.max(0, …)` so a
// non-finite or negative input can't spin a while-loop until OOM.
//
// Like the other highway_3d tests in this directory, this pattern-matches
// the source rather than executing it — the createHighway() closure owns
// canvas + WebGL lifecycle that's too heavy for a vm sandbox.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
// Brace-balanced extraction so warm() / coercion checks scope to the
// `function pool(...)` body (matching the helper shape used in
// highway_note_state.test.js). Without scoping, a future helper named
// `warm(cap)` elsewhere in the file would satisfy these guards while
// the pool factory's contract was silently broken.
function extractBlock(src, signature) {
const start = src.indexOf(signature);
assert.ok(start !== -1, `signature '${signature}' not found`);
const openBrace = src.indexOf('{', start);
assert.ok(openBrace !== -1, `opening brace after '${signature}' not found`);
let depth = 1;
let i = openBrace + 1;
while (i < src.length && depth > 0) {
const ch = src[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
assert.ok(depth === 0, `unbalanced braces after '${signature}'`);
return src.slice(start, i);
}
test('pool factory exposes warm(cap)', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8');
// The pool() factory's return object must include a `warm(cap)`
// method. Scope the match to the factory body so an unrelated
// future `warm(cap)` helper elsewhere in the file can't satisfy
// this guard.
const poolBody = extractBlock(src, 'function pool(parent, mk)');
assert.match(poolBody, /\bwarm\s*\(\s*cap\s*\)\s*\{/, 'pool factory must expose warm(cap)');
});
test('pool.warm coerces cap to a non-negative integer', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8');
// Same scoping discipline as above — the coercion must live
// inside the pool factory's warm() body, not anywhere else.
const poolBody = extractBlock(src, 'function pool(parent, mk)');
assert.match(
poolBody,
/warm\s*\(\s*cap\s*\)\s*\{[\s\S]*?Math\.max\(\s*0\s*,\s*cap\s*\|\s*0\s*\)/,
'pool.warm must guard against non-finite / negative cap via Math.max(0, cap | 0)'
);
});
test('warm() is called at boardInit with renderer-scoped cap constants', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8');
// The note / chord / lane / beat cap constants live inside the
// boardInit/initScene path (renderer-instance scope, not module
// scope); each must exist as a const and drive at least one .warm()
// call site.
for (const cap of ['_WARM_NOTE', '_WARM_CHORD', '_WARM_LANE', '_WARM_BEAT']) {
assert.match(src, new RegExp(`const ${cap}\\s*=`), `${cap} const must exist`);
assert.match(src, new RegExp(`\\.warm\\(\\s*${cap}\\b`), `${cap} must drive at least one .warm() call`);
}
});
+461
View File
@@ -0,0 +1,461 @@
// Pins the renderOrder hierarchy in plugins/highway_3d/screen.js.
//
// Three.js renders transparent objects by renderOrder first, then back-to-front
// Z sort within the same renderOrder. All 3D-highway materials use depthTest:false, so
// renderOrder is the *only* draw-order control — getting it wrong silently
// causes one layer to bleed through another (gems clipping through chord frames,
// strings buried under notes, etc.).
//
// Full hierarchy bottom → top:
//
// -1 background stage traversal
// 1 lane quads
// 2 fret dividers
// 4 sus-rail bloom (pSusRailBloom seed) ← highway_3d_sustain_bloom.test.js
// 5 sus-rail core (pSusRail seed) ← highway_3d_sustain_rail.test.js
// 7 string-line glows (in-lane glow lines)
// 14 board-projection frame
// [renderOrderForLayerAtZ(z, FRET_COLUMN)] fret-column markers (pFretColMarker) — between chord frame and gem
// [layered below chordFrameRenderOrder] chord fill / PM-FH fill / PM-FH lines
// [chordFrameRenderOrder] chord frame edges = renderOrderForLayerAtZ(z, CHORD_FRAME)
// [layered above chordFrameRenderOrder] chord-frame glow, connector/drop lines
// [below chordFrameRenderOrder] sustain-trail strip segments (Z-proportional, always < frame)
// [renderOrderForLayerAtZ(noteZ, NOTE_OUTLINE)] note gem outline
// [renderOrderForLayerAtZ(noteZ, NOTE_CORE)] note gem core
// [techniqueMarkerRenderOrder] technique markers
// [after board wire layers] note fret labels, above gem symbols and fret wires
// [renderOrderForLayerAtZ(0, BOARD_STRING)] string mesh (drawn over gems but under fret wires)
// [renderOrderForLayerAtZ(0, BOARD_FRET_WIRE)] static fret wires (above strings, as on a real guitar)
// 1000 technique labels, ghost-fret overlay
//
// Tests are source-level regex checks — no need to load Three.js or a DOM.
//
// Any PR that changes a renderOrder value must update the relevant test(s) here
// and provide a visual justification in the PR description.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
let _src;
/** Returns the cached 3D highway screen source under test. */
function src() {
if (!_src) _src = fs.readFileSync(SCREEN_JS, 'utf8');
return _src;
}
/** Parses the declared render-order layer stack from screen.js. */
function layers() {
const match = src().match(/const\s+RENDER_ORDER_LAYER_STACK\s*=\s*Object\.freeze\(\s*\[([\s\S]*?)\]\s*\)/);
assert.ok(match, 'RENDER_ORDER_LAYER_STACK must be declared');
return Array.from(match[1].matchAll(/'([^']+)'/g), m => m[1]);
}
/** Returns the position of a named layer in the render-order stack. */
function layerIndex(name) {
const ordered = layers();
const idx = ordered.indexOf(name);
assert.ok(idx !== -1, `${name} must be present in RENDER_ORDER_LAYER_STACK`);
return idx;
}
/** Reads the render-order base used for objects at z = 0. */
function zZeroRenderOrder() {
const match = src().match(/const\s+RENDER_ORDER_AT_Z_ZERO\s*=\s*(-?\d+(?:\.\d+)?)\s*;/);
assert.ok(match, 'RENDER_ORDER_AT_Z_ZERO must be declared');
return Number(match[1]);
}
// ---------------------------------------------------------------------------
// Static / fixed renderOrder values
// ---------------------------------------------------------------------------
test('lane quads use renderOrder 1', () => {
assert.match(
src(),
/lane\.renderOrder\s*=\s*1\s*;/,
'lane quads must use renderOrder = 1 (bottom-most visible layer)',
);
});
test('fret dividers use renderOrder 2', () => {
assert.match(
src(),
/div\.renderOrder\s*=\s*2\s*;/,
'fret dividers must use renderOrder = 2, above lane (1)',
);
});
test('fret inlay dots use renderOrder 3, above lane (1) and dividers (2)', () => {
// The translucent lane would otherwise paint over and hide the inlay.
// The dots must draw after the lane/dividers but stay below the depth-layer stack.
assert.match(
src(),
/d\.renderOrder\s*=\s*3\s*;/,
'fret inlay dots must use renderOrder = 3 so the lane no longer hides them',
);
});
test('string-line glows use renderOrder 7, above sus-rails (4/5)', () => {
// The in-lane string glow lines sit at 7 — above sus-rail bloom (4) and
// core (5) so the glow is visible, but below chord fill (chordFrameRenderOrder-4,
// min=44) so chord interiors don't disappear behind glow overdraw.
assert.match(
src(),
/line\.renderOrder\s*=\s*7\s*;/,
'string glow lines must use renderOrder = 7',
);
});
test('board-projection frame mesh uses renderOrder 14', () => {
// The fretboard projection plane sits above string glows (7) but below
// chord fill (min 44). Value 14 keeps it sandwiched cleanly.
// Anchor to the board-projection pool (projMeshArr = activePalette.map(...))
// so the assertion only passes when THAT block seeds renderOrder = 14 —
// not any unrelated renderOrder = 14 elsewhere in the source.
const boardProjRO = /projMeshArr\s*=\s*activePalette\.map\b[\s\S]{0,1200}?m\.renderOrder\s*=\s*14\s*;/;
assert.match(
src(),
boardProjRO,
'board-projection pool (projMeshArr) must seed meshes with renderOrder = 14',
);
const boardMatch = src().match(boardProjRO);
assert.ok(boardMatch, 'board projection mesh must be assigned renderOrder = 14');
});
test('string mesh in buildBoard uses the named board-string layer', () => {
// The physical string cylinders/planes rendered on the fretboard sit above
// the note-gem layers but below fret wires.
assert.match(
src(),
/mesh\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_STRING'\s*\)\s*;/,
'buildBoard string mesh must use BOARD_STRING',
);
assert.ok(layerIndex('BOARD_STRING') > layerIndex('TECHNIQUE_MARKER'));
assert.ok(layerIndex('BOARD_STRING') < layerIndex('BOARD_FRET_WIRE'));
});
test('static fret wires use bowed TubeGeometry + MeshStandardMaterial, named board-fret-wire layer, depthTest+depthWrite false, default gray 0x666688', () => {
// Fret wires are a single shared, bowed TubeGeometry (backported from
// highway_babylon): a CatmullRom curve whose middle pushes away from the
// camera by FRET_BOW_DZ so the row of frets reads as wrapping a cylindrical
// neck. T.Line is avoided — WebGL ignores linewidth > 1px so a Line always
// renders as a hairline. The lit MeshStandardMaterial lets scene light glint
// across the rounded surface (gold in-anchor → brass). depthTest:false is
// required: the string BoxGeometry (MeshStandardMaterial, depthWrite:true)
// writes depth at Z = +STR_THICK/2, so fret wires near Z=0 would fail the
// depth test at string pixels despite the higher layer; depthWrite:false
// keeps the transparent fret from polluting depth for later overlays.
const s = src();
assert.match(
s,
/new\s+T\.TubeGeometry\(\s*tubeCurve\s*,\s*FRET_TUBE_SEG\s*,\s*FRET_TUBE_RADIUS\s*,\s*FRET_TUBE_RADIAL\s*,\s*false\s*,?\s*\)/,
'buildBoard fret wires must use a TubeGeometry built from tubeCurve + FRET_TUBE_* params',
);
assert.match(
s,
/new\s+T\.CatmullRomCurve3\(\s*tubePath\s*\)/,
'buildBoard fret tube must follow a CatmullRomCurve3 through the bowed path',
);
assert.match(
s,
/FRET_BOW_DZ\s*\*\s*zm/,
'fret tube path must bow in Z by FRET_BOW_DZ so the neck reads as curved',
);
assert.match(
s,
/new\s+T\.Mesh\(\s*fretTubeGeo\s*,\s*mat\s*\)/,
'buildBoard fret wires must reuse the shared fretTubeGeo (not T.Line)',
);
assert.match(
s,
/fw\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_FRET_WIRE'\s*\)\s*;/,
'buildBoard fret wire mesh must use BOARD_FRET_WIRE',
);
assert.match(
s,
/new\s+T\.MeshStandardMaterial\(/,
'fret wires must use MeshStandardMaterial so scene light shades the metal',
);
assert.match(
s,
/color\s*:\s*0x666688/,
'fret wire material must have default gray color 0x666688',
);
// Both depth flags asserted independently so the test doesn't pin property
// order in the material literal.
assert.match(
s,
/depthTest\s*:\s*false/,
'fret wire material must set depthTest: false',
);
assert.match(
s,
/depthWrite\s*:\s*false/,
'fret wire material must set depthWrite: false (no z-buffer pollution)',
);
assert.match(
s,
/fretWireMats\s*\[\s*f\s*\]\s*=\s*mat\s*;/,
'buildBoard must store each wire material in fretWireMats[f]',
);
});
test('update() sets fret wire gold (0xD8A636) for in-anchor frets, gray (0x666688) otherwise', () => {
// Uses anchorLaneBoundsAt() — the same helper the dynamic lane uses —
// so fret wire highlight aligns exactly with the lane edges:
// dMin = fret - 1, dMax = fret + width - 1
// Example: { fret: 3, width: 4 } → dMin=2, dMax=6 → wires 2..6 gold.
const s = src();
assert.match(
s,
/fretWireMats\.length/,
'update() must guard the per-frame fret wire loop on fretWireMats.length',
);
assert.match(
s,
/anchorLaneBoundsAt\(\s*anchors\s*,\s*now\s*\)/,
'update() must use anchorLaneBoundsAt(anchors, now) to get fret wire range',
);
assert.match(
s,
/_m\.color\.setHex\(\s*0xD8A636\s*\)/,
'update() must set gold 0xD8A636 for in-anchor fret wires',
);
assert.match(
s,
/_m\.color\.setHex\(\s*0x666688\s*\)/,
'update() must set gray 0x666688 for out-of-anchor fret wires',
);
assert.match(
s,
/_fwBounds\.dMin/,
'update() must use dMin from anchorLaneBoundsAt (= fret - 1)',
);
assert.match(
s,
/_fwBounds\.dMax/,
'update() must use dMax from anchorLaneBoundsAt (= fret + width - 1)',
);
});
test('fret-column markers use Z-proportional renderOrder between chord frame and gem', () => {
// pFretColMarker labels use the named stack: one step above chord frame
// and one step below note gems at the same depth.
// This ensures chord frame borders never overdraw the label and the label
// never overdraws gems, at every Z position across the lookahead window.
assert.match(
src(),
/sp\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'FRET_COLUMN'\s*\)\s*;/,
'pFretColMarker renderOrder must use renderOrderForLayerAtZ(z, FRET_COLUMN)',
);
assert.ok(layerIndex('FRET_COLUMN') > layerIndex('CHORD_FRAME'));
assert.ok(layerIndex('FRET_COLUMN') < layerIndex('NOTE_OUTLINE'));
});
test('technique labels and ghost-fret overlay use renderOrder 1000', () => {
// 1000 is well above the entire Z-proportional range and the
// string/cadence layer — labels must always be readable.
const matches = src().match(/m\.renderOrder\s*=\s*1000\s*;/g) || [];
assert.ok(
matches.length >= 2,
'at least two renderOrder = 1000 assignments must exist (technique labels + ghost fret)',
);
});
// ---------------------------------------------------------------------------
// Z-proportional formulas — chord frame / note gem / technique marker
// ---------------------------------------------------------------------------
test('chordFrameRenderOrder uses renderOrderForLayerAtZ(z, CHORD_FRAME)', () => {
// Per-chord frame renderOrder mirrors the note-gem scale with an earlier
// layer from RENDER_ORDER_LAYER_STACK.
assert.match(
src(),
/const\s+chordFrameRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_FRAME'\s*\)\s*;/,
'chordFrameRenderOrder must use renderOrderForLayerAtZ(z, CHORD_FRAME)',
);
assert.match(src(), /const\s+RENDER_ORDER_LAYER_INDEX\s*=\s*Object\.freeze\(\s*RENDER_ORDER_LAYER_STACK\.reduce\(/);
assert.match(src(), /const\s+layerIndex\s*=\s*RENDER_ORDER_LAYER_INDEX\[layerName\]\s*;/);
assert.match(src(), /if\s*\(\s*layerIndex\s*===\s*undefined\s*\)\s*throw\s+new\s+Error\(`Unknown 3D highway depth layer: \$\{layerName\}`\)\s*;/);
assert.match(src(), /const\s+depthRenderOrder\s*=\s*Math\.max\(\s*RENDER_ORDER_FAR_CLAMP\s*,\s*Math\.round\(\s*RENDER_ORDER_AT_Z_ZERO\s*\+\s*worldZ\s*\/\s*K\s*\)\s*\)\s*;/);
// Layer is a sub-unit fraction so the integer depth bucket strictly
// dominates (a farther object can't outrank a nearer one via a higher
// layer); the layer only breaks ties within the same depth bucket.
assert.match(src(), /return\s+depthRenderOrder\s*\+\s*layerIndex\s*\/\s*RENDER_ORDER_LAYER_STACK\.length\s*;/);
assert.ok(layerIndex('CHORD_FRAME') < layerIndex('NOTE_OUTLINE'));
});
test('note outline uses renderOrderForLayerAtZ(noteZ, NOTE_OUTLINE)', () => {
// Per-note gem renderOrder. noteZ is negative (ahead of hit line → negative
// Z in world space). At noteZ=0 (on the hit line), the note outline uses
// the near render-order base plus its layer index; far notes clamp to the
// far render-order base plus that same layer index.
// The ordered layer list keeps gems above chord frames everywhere.
assert.match(
src(),
/outline\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'NOTE_OUTLINE'\s*\)\s*;/,
'note outline must use renderOrderForLayerAtZ(noteZ, NOTE_OUTLINE)',
);
assert.strictEqual(layerIndex('CHORD_FILL'), 0);
});
test('techniqueMarkerRenderOrder uses the named technique marker layer above gem core', () => {
// Technique markers (PM cross, bend arrow, H/P chevron, etc.) must overlay
// the gem itself.
assert.match(
src(),
/const\s+techniqueMarkerRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'TECHNIQUE_MARKER'\s*\)/,
'techniqueMarkerRenderOrder must use TECHNIQUE_MARKER',
);
assert.ok(layerIndex('TECHNIQUE_MARKER') > layerIndex('NOTE_CORE'));
});
// ---------------------------------------------------------------------------
// Intra-chord layering (chord fill < PM/FH fill < PM/FH lines < frame edge)
// ---------------------------------------------------------------------------
test('chord fill interior uses the named layer below chord frame', () => {
// The translucent chord-box fill sits below the frame edge so the edge
// always wins when both cover the same pixel.
assert.match(
src(),
/fill\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_FILL'\s*\)\s*;/,
'chord fill must use CHORD_FILL',
);
assert.ok(layerIndex('CHORD_FILL') < layerIndex('CHORD_FRAME'));
});
test('PM/FH X fill (pPMXFill / pFHXFill) uses its ordered layer', () => {
// The black background fill of the muted-note X symbol is above chord fill
// but below the X lines — same chord, so same chord-frame renderOrder base.
const matches = src().match(/xf\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_STRUM_FILL'\s*\)\s*;/g) || [];
assert.ok(
matches.length >= 2,
'both PM and FH X-fill meshes must use CHORD_STRUM_FILL (found ' + matches.length + ')',
);
assert.ok(layerIndex('CHORD_FILL') < layerIndex('CHORD_STRUM_FILL'));
assert.ok(layerIndex('CHORD_STRUM_FILL') < layerIndex('CHORD_STRUM_LINE'));
});
test('PM/FH X lines (pMuteXLines / pFHXLines) use their ordered layer', () => {
// The coloured X stroke lines are above the black fill but below
// the chord frame border edge, so they don't escape the box.
const matches = src().match(/xl\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_STRUM_LINE'\s*\)\s*;/g) || [];
assert.ok(
matches.length >= 2,
'both PM and FH X-line meshes must use CHORD_STRUM_LINE (found ' + matches.length + ')',
);
assert.ok(layerIndex('CHORD_STRUM_LINE') < layerIndex('CHORD_FRAME'));
});
test('chord frame glow uses the layer after chord frame', () => {
// Accent glow draws after the frame while still remaining below connectors
// and note symbols in the ordered layer list.
assert.match(
src(),
/b\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_EDGE_GLOW'\s*\)\s*;/,
'chord frame edge slabs must use CHORD_EDGE_GLOW',
);
assert.ok(layerIndex('CHORD_EDGE_GLOW') > layerIndex('CHORD_FRAME'));
assert.ok(layerIndex('CHORD_EDGE_GLOW') < layerIndex('CONNECTOR_LINE'));
});
// ---------------------------------------------------------------------------
// Sustain-trail strip & ribbon — always below chord frame of same depth
// ---------------------------------------------------------------------------
test('sus-trail strip renderOrder formula keeps trails strictly below chord frames at same Z', () => {
// Sustain trails use the ordered layer immediately below chord frames at
// the same depth.
assert.match(
src(),
/const\s+trailRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*Math\.min\(\s*0\s*,\s*zCenter\s*\)\s*,\s*'SUSTAIN_TRAIL'\s*\)\s*;/,
'sus-trail strip renderOrder must use renderOrderForLayerAtZ(min zCenter, SUSTAIN_TRAIL)',
);
assert.ok(layerIndex('SUSTAIN_TRAIL') < layerIndex('CHORD_FRAME'));
});
test('sus-trail ribbon renderOrder formula mirrors strip formula using time-based depth', () => {
// Ribbons use _ribDt (time from now to ribbon midpoint) converted to the
// same Z scale as dZ() on the sustain-trail layer.
assert.match(
src(),
/const\s+ribbonRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*-\s*_ribDt\s*\*\s*TS\s*,\s*'SUSTAIN_TRAIL'\s*\)\s*;/,
'sus-trail ribbon renderOrder must use renderOrderForLayerAtZ on the sustain-trail layer',
);
});
// ---------------------------------------------------------------------------
// Note gem ordering (outline < core, both driven by named depth layers)
// ---------------------------------------------------------------------------
test('note gem outline uses the named outline layer', () => {
assert.match(
src(),
/outline\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'NOTE_OUTLINE'\s*\)\s*;/,
'note gem outline must use NOTE_OUTLINE',
);
assert.ok(layerIndex('NOTE_OUTLINE') > layerIndex('FRET_COLUMN'));
});
test('note gem core uses the named layer above outline', () => {
assert.match(
src(),
/core\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'NOTE_CORE'\s*\)\s*;/,
'note gem core must use NOTE_CORE',
);
assert.ok(layerIndex('NOTE_CORE') > layerIndex('NOTE_OUTLINE'));
});
// ---------------------------------------------------------------------------
// Key relative-ordering invariants (derived constants)
// ---------------------------------------------------------------------------
test('chord frame layer is below note outline layer', () => {
// Chord frames must always render below note gems, even at maximum depth
// (far end of the lookahead).
//
assert.ok(layerIndex('CHORD_FRAME') < layerIndex('NOTE_OUTLINE'));
});
test('fret labels are above note symbols in the named stack', () => {
assert.ok(layerIndex('NOTE_FRET_LABEL') > layerIndex('NOTE_CORE'), 'note fret labels must draw above gem core');
assert.ok(layerIndex('NOTE_FRET_LABEL') > layerIndex('TECHNIQUE_MARKER'), 'note fret labels must draw above technique markers');
assert.ok(layerIndex('ARP_NOTE_FRET_LABEL') > layerIndex('NOTE_FRET_LABEL'), 'arp labels retain a one-layer tie-breaker');
assert.ok(layerIndex('CHORD_FRET_LABEL') > layerIndex('NOTE_CORE'), 'chord-loop fret labels must draw above gem core at the same depth');
assert.ok(layerIndex('NOTE_FRET_LABEL') > layerIndex('BOARD_FRET_WIRE'), 'note fret labels must clear static fret wires');
assert.ok(layerIndex('CHORD_FRET_LABEL') > layerIndex('BOARD_FRET_WIRE'), 'chord fret labels must clear static fret wires');
});
test('string mesh layer is above note symbols and below labels', () => {
// Board strings are never occluded by flying gems, but labels still appear above strings.
const s = src();
assert.match(s, /mesh\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_STRING'\s*\)\s*;/, 'string mesh must use BOARD_STRING');
// Confirm 1000 also exists (labels above strings)
assert.match(s, /m\.renderOrder\s*=\s*1000\s*;/, 'technique label renderOrder 1000 must exist');
assert.ok(layerIndex('BOARD_STRING') > layerIndex('TECHNIQUE_MARKER'), 'string mesh layer must be above note symbols');
assert.ok(layerIndex('BOARD_STRING') < layerIndex('NOTE_FRET_LABEL'), 'string mesh layer must be below fret labels');
});
test('fret-column marker layer is above chord frame and below gem outline', () => {
assert.ok(layerIndex('FRET_COLUMN') > layerIndex('CHORD_FRAME'), 'fret-column marker layer must be above chord frame');
assert.ok(layerIndex('FRET_COLUMN') < layerIndex('NOTE_OUTLINE'), 'fret-column marker layer must be below gem outline');
assert.match(src(), /renderOrderForLayerAtZ\(\s*z\s*,\s*'FRET_COLUMN'\s*\)/);
});
test('static fret wire layer is above string mesh and note symbols', () => {
// Structural invariant: fret wires must always draw after (on top of) strings.
assert.ok(layerIndex('BOARD_FRET_WIRE') > layerIndex('BOARD_STRING'), 'fret wire must be above string mesh');
assert.ok(layerIndex('BOARD_FRET_WIRE') > layerIndex('TECHNIQUE_MARKER'), 'fret wire must be above note symbols');
assert.ok(zZeroRenderOrder() + layerIndex('BOARD_FRET_WIRE') < 1000, 'fret wire must be below technique labels (1000)');
assert.match(src(), /fw\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_FRET_WIRE'\s*\)\s*;/, 'buildBoard fret wire must use BOARD_FRET_WIRE');
assert.match(src(), /mesh\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_STRING'\s*\)\s*;/, 'string mesh must use BOARD_STRING');
});
@@ -0,0 +1,92 @@
// Pins the auto-reframe-on-layout-settle behaviour in
// plugins/highway_3d/screen.js.
//
// Bug it guards against: when a song opens, the player screen may not have
// its final dimensions yet (controls / sections bar still laying out). The
// highway canvas is `#highway { flex: 1; min-height: 0 }`, so its real
// rendered box (canvasSize() via getBoundingClientRect) is temporarily too
// tall — applySize() then frames cam.aspect for the wrong height and the
// camera crops the near strings / fret-number row. Once the layout settles
// the flex box shrinks to the correct size, but the backing store
// (canvas.width/height) does NOT change, so the splitscreen-oriented
// `_lastHwW/_lastHwH` check never fires and the framing stays wrong until the
// user un/re-maximizes the window (which fires a real `resize`).
//
// The fix makes draw() additionally compare the live canvas box against the
// last logical size handed to applySize() (_appliedW/_appliedH) and re-apply
// on >1px drift even when the backing store is unchanged. A refactor that
// drops the CSS-box comparison, stops recording _appliedW/_appliedH, or
// reverts to backing-store-only detection would silently bring the bug back.
//
// Source-level only — same strategy as the other tests/js/ files.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const src = fs.readFileSync(SCREEN_JS, 'utf8');
// ── Applied-size tracking ───────────────────────────────────────────────────
test('the last applied logical size is tracked as instance state', () => {
assert.match(
src,
/let\s+_appliedW\s*=\s*0\s*,\s*_appliedH\s*=\s*0\s*;/,
'_appliedW / _appliedH must be declared as per-instance state',
);
});
test('applySize records the logical w/h it applied', () => {
// Recorded right after the aspect/aspectScale update so the draw() drift
// check can compare against the size actually framed for.
assert.match(
src,
/aspectScale\s*=\s*Math\.max\(1,[\s\S]*?_appliedW\s*=\s*w\s*;\s*_appliedH\s*=\s*h\s*;/,
'applySize must set _appliedW = w; _appliedH = h after computing aspectScale',
);
});
// ── draw() re-frames on CSS-box drift ───────────────────────────────────────
test('draw() reads the live canvas box once per frame', () => {
assert.match(
src,
/const\s+box\s*=\s*canvasSize\(\s*highwayCanvas\s*\)\s*;/,
'draw() must sample canvasSize(highwayCanvas) for the live box',
);
});
test('backing-store drift branch is preserved (splitscreen path)', () => {
// The original check that catches the splitscreen hw.resize override
// resizing the element without calling renderer.resize() must remain.
assert.match(
src,
/if\s*\(\s*highwayCanvas\.width\s*!==\s*_lastHwW\s*\|\|\s*highwayCanvas\.height\s*!==\s*_lastHwH\s*\)\s*\{\s*_lastHwW\s*=\s*highwayCanvas\.width\s*;\s*_lastHwH\s*=\s*highwayCanvas\.height\s*;\s*if\s*\(\s*box\.w\s*>\s*0\s*&&\s*box\.h\s*>\s*0\s*\)\s*applySize\(\s*box\.w\s*,\s*box\.h\s*\)\s*;/,
'the backing-store (canvas.width/height) drift branch must still re-apply',
);
});
test('draw() re-applies on CSS-box drift even without a backing-store change', () => {
// The else-if branch: backing store unchanged, but the flex box drifted
// from the last applied logical size by more than 1px → re-frame. This is
// the branch that fixes the open-song crop without a manual window resize.
assert.match(
src,
/else if\s*\(\s*box\.w\s*>\s*0\s*&&\s*box\.h\s*>\s*0\s*&&\s*\(\s*Math\.abs\(\s*box\.w\s*-\s*_appliedW\s*\)\s*>\s*1\s*\|\|\s*Math\.abs\(\s*box\.h\s*-\s*_appliedH\s*\)\s*>\s*1\s*\)\s*\)\s*\{\s*applySize\(\s*box\.w\s*,\s*box\.h\s*\)\s*;/,
'draw() must re-apply when the live box drifts >1px from _appliedW/_appliedH',
);
});
// ── Lifecycle reset ─────────────────────────────────────────────────────────
test('destroy() resets the applied-size tracking', () => {
// Instances are reused across songs (destroy() → init()); stale applied
// dims would suppress the first reframe of the next song.
assert.match(
src,
/_appliedW\s*=\s*0\s*;\s*_appliedH\s*=\s*0\s*;/,
'destroy() must reset _appliedW / _appliedH to 0',
);
});
+62
View File
@@ -0,0 +1,62 @@
// Pins slide-target gem suppression in plugins/highway_3d/screen.js (PR #329).
// A note that is the slide/link destination of a preceding sustained note has
// its gem body suppressed (skipBody=true) so it does not render a duplicate
// gem on top of the slide trail — but the sustain/slide trail itself still
// renders so the slide motion stays visible. A refactor that drops the
// _slideTargetSet pre-pass, stops threading _isSlideTgt into drawNote, or
// moves the trail back inside the !skipBody gate would silently reintroduce
// duplicate gems or erase slide trails.
//
// Source-level only — same strategy as the other tests/js/ files.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
test('a _slideTargetSet pre-pass builds the suppressed-gem set from bundle.notes', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8');
assert.match(
src,
/const\s+checkSrc\s*=\s*\([^)]*\)\s*=>\s*\{[\s\S]*?stSet\.add\(/,
'pre-pass checkSrc must populate the slide-target set',
);
assert.match(
src,
/if\s*\(\s*stSet\.size\s*>\s*0\s*\)\s*_slideTargetSet\s*=\s*stSet/,
'_slideTargetSet must be assigned from the pre-pass result',
);
});
test('_isSlideTgt is derived from _slideTargetSet membership', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8');
assert.match(
src,
/_isSlideTgt\s*=\s*!!\(\s*_slideTargetSet\s*&&\s*_slideTargetSet\.has\(/,
'_isSlideTgt must test _slideTargetSet membership',
);
});
test('_isSlideTgt is threaded into drawNote as the skipBody argument', () => {
// drawNote(n, now, openX, skipLabel, skipBody, ...) — _isSlideTgt sits in
// the 5th (skipBody) position so the gem body is suppressed.
const src = fs.readFileSync(SCREEN_JS, 'utf8');
assert.match(
src,
/drawNote\(\s*n\s*,\s*now\s*,\s*singleOpenX\s*,\s*skipLabel\s*,\s*_isSlideTgt\s*,/,
'_isSlideTgt must be passed as drawNote\'s skipBody argument',
);
});
test('the sustain trail renders for all notes, including skipBody slide targets', () => {
// The trail block must stay outside the !skipBody gem gate so suppressed
// slide-target gems still show their slide trail.
const src = fs.readFileSync(SCREEN_JS, 'utf8');
assert.match(
src,
/Rendered for ALL notes with sustain, including skipBody=true/,
'the sustain-trail comment contract must remain, marking the trail as unconditional',
);
});
@@ -0,0 +1,82 @@
// Source-level guards for the smoothNow() pause-drift fix.
//
// smoothNow() interpolates bundle.currentTime forward with performance.now()
// between distinct audio samples. Before this fix it only stopped once the
// interpolation cap (dt > 0.1 s) was crossed, so for the first ~100 ms of a
// pause the 3D highway crept forward against a frozen audio clock and then
// snapped back to raw — a visible twitch on every pause.
//
// The fix wires a host pause signal (slopsmith core's bundle.isPlaying) into
// smoothNow: when the chart clock is not advancing, return raw immediately
// and re-anchor. These tests lock in both halves of the contract by
// inspecting source (the createHighway / renderer closures own WebGL + audio
// lifecycle that's too heavy to execute in a vm sandbox — same approach as
// the other highway source-guard tests in this dir).
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');
const highway3dJs = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
// Brace-balanced extraction (same helper shape as highway_note_state.test.js).
function extractBlock(src, signature) {
const start = src.indexOf(signature);
assert.ok(start !== -1, `signature '${signature}' not found`);
const openBrace = src.indexOf('{', start);
assert.ok(openBrace !== -1, `opening brace after '${signature}' not found`);
let depth = 1;
let i = openBrace + 1;
while (i < src.length && depth > 0) {
const ch = src[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
assert.ok(depth === 0, `unbalanced braces after '${signature}'`);
return src.slice(start, i);
}
test('core _makeBundle exposes isPlaying derived from the chart-clock anchor', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const fn = extractBlock(src, 'function _makeBundle()');
// Field present in the bundle.
assert.match(fn, /\bisPlaying\s*:/, 'bundle must expose isPlaying');
// It is computed from the same anchor/advance state getTime() uses, not a
// hardcoded literal — anchor must exist AND the clock must have advanced
// within the interp cap.
assert.match(
fn,
/isPlaying\s*:\s*!Number\.isNaN\(\s*_chartAnchorPerfNow\s*\)/,
'isPlaying must gate on a live anchor (_chartAnchorPerfNow not NaN)',
);
assert.match(
fn,
/_chartLastAdvanceAt\s*\)\s*<=\s*_CHART_MAX_INTERP_MS/,
'isPlaying must require the clock advanced within _CHART_MAX_INTERP_MS',
);
});
test('smoothNow returns raw and re-anchors when the host reports not playing', () => {
const src = fs.readFileSync(highway3dJs, 'utf8');
const fn = extractBlock(src, 'function smoothNow(bundle)');
// Strict === false so downlevel hosts (isPlaying undefined) fall through
// to the existing staleness-based interpolation cap.
const guardIdx = fn.search(/bundle\.isPlaying\s*===\s*false/);
assert.ok(guardIdx !== -1, 'smoothNow must check bundle.isPlaying === false');
// The pause branch re-anchors the clock state and returns the raw sample
// (no forward extrapolation).
const branch = fn.slice(guardIdx);
assert.match(branch, /_clkAudioT\s*=\s*raw/, 'pause branch must re-anchor _clkAudioT to raw');
assert.match(branch, /_clkPerf\s*=\s*p/, 'pause branch must re-anchor _clkPerf to now');
assert.match(branch, /return\s*\(\s*_frameNow\s*=\s*raw\s*\)/, 'pause branch must return raw');
// The pause gate must come before the new-sample re-anchor / interpolation
// path so a frozen clock never extrapolates forward.
const newSampleIdx = fn.search(/if\s*\(\s*raw\s*!==\s*_clkAudioT\s*\)/);
assert.ok(newSampleIdx !== -1, 'smoothNow new-sample branch not found');
assert.ok(guardIdx < newSampleIdx, 'isPlaying pause gate must precede the interpolation path');
});
+49
View File
@@ -0,0 +1,49 @@
// Pins the sustain bloom glow in plugins/highway_3d/screen.js (PR #329).
// Sustained chord rails get a soft gaussian glow: a DataTexture gaussian
// (_makeGaussTex) drives a wider, additive-blended plane mesh (pSusRailBloom)
// rendered behind the core rail. A refactor that drops the gaussian texture,
// stops using additive blending, or bumps the bloom renderOrder above the
// core rail (16) would silently regress or invert the effect.
//
// Source-level only — same strategy as the other tests/js/ files.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
test('a gaussian DataTexture helper (_makeGaussTex) drives the bloom falloff', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8');
assert.match(
src,
/function\s+_makeGaussTex\s*\(/,
'_makeGaussTex must exist to build the bloom gaussian texture',
);
assert.match(
src,
/_bloomGaussTex\s*=\s*_makeGaussTex\(/,
'the bloom texture must be produced by _makeGaussTex',
);
});
test('the bloom rail material uses additive blending', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8');
assert.match(
src,
/mSusRailBloomBase\s*=\s*new\s+T\.MeshBasicMaterial\(\{[\s\S]*?blending:\s*T\.AdditiveBlending[\s\S]*?\}\)/,
'mSusRailBloomBase must blend additively so it brightens what is behind it',
);
});
test('the bloom pool seeds meshes at renderOrder 4, behind the core rail (5)', () => {
// renderOrder 4 keeps the bloom behind the core sustain rail (5) so the
// glow reads as a trail rather than occluding the rail.
const src = fs.readFileSync(SCREEN_JS, 'utf8');
assert.match(
src,
/pSusRailBloom\s*=\s*pool\([^)]*,\s*\(\)\s*=>\s*\{[\s\S]*?m\.renderOrder\s*=\s*4\s*;[\s\S]*?\}\s*\)/,
'pSusRailBloom pool must seed meshes with renderOrder = 4',
);
});
+49
View File
@@ -0,0 +1,49 @@
// Pins the chord sustain-length rail indicator in plugins/highway_3d/screen.js
// (PR #303). The rails are left/right edge plane meshes showing how long a
// chord is held. A refactor that drops the !isRepeat gate, mixes up the
// arpeggio/teal color choice, or changes the rail renderOrder would silently
// regress the indicator (rails on every repeat frame, wrong tint, or rails
// occluding note gems).
//
// Source-level only — same strategy as the other tests/js/ files.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
test('sustain rails are gated on multi-note chords with a known box width within AHEAD', () => {
// Each chord in a sequence (including repeats) draws a rail from its onset
// to the next chord's onset, chaining together to cover the full handshape
// duration visually. Single notes have no chord frame to anchor a rail to.
const src = fs.readFileSync(SCREEN_JS, 'utf8');
assert.match(
src,
/if\s*\(\s*chShape\.size\s*>\s*1\s*&&\s*chordOpenBoxW\s*!=\s*null\s*&&\s*chDt\s*<\s*AHEAD\s*\)/,
'sustain-rail block must stay gated on chShape.size > 1, chordOpenBoxW and chDt < AHEAD',
);
});
test('sustain rails pick arpeggio color for arpeggio frames, teal otherwise', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8');
assert.match(
src,
/chordHighwayLavenderArpVisual\s*\?\s*ARPEGGIO_RIM_BLUE_HEX\s*:\s*CHORD_BOX_TEAL_HEX/,
'rail color must select ARPEGGIO_RIM_BLUE_HEX for arpeggio frames and CHORD_BOX_TEAL_HEX for chords',
);
});
test('sustain-rail pool meshes keep renderOrder 5 so strings (7) stay on top', () => {
// renderOrder 5 sits below string-line glows (7) so strings render on top
// of the rail. Chord frame edges are Z-proportional [48,698] and note gems
// are Z-proportional [50,700], so the flat seed value does not conflict —
// emitSusStrip() assigns its own Z-proportional RO per segment at draw time.
const src = fs.readFileSync(SCREEN_JS, 'utf8');
assert.match(
src,
/pSusRail\s*=\s*pool\([^)]*,\s*\(\)\s*=>\s*\{[\s\S]*?m\.renderOrder\s*=\s*5\s*;[\s\S]*?\}\s*\)/,
'pSusRail pool must seed meshes with renderOrder = 5',
);
});
+118
View File
@@ -0,0 +1,118 @@
// Source-level guards for the load-adaptive render scale (slopsmith#654).
// The createHighway closure owns the rAF loop + WebGL sizing that's too
// heavy for a vm sandbox, so — like highway_visibility.test.js — these
// lock in the wiring rather than execute it.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');
function extractBlock(src, signature) {
const start = src.indexOf(signature);
assert.ok(start !== -1, `signature '${signature}' not found`);
const openBrace = src.indexOf('{', start);
assert.ok(openBrace !== -1, `opening brace after '${signature}' not found`);
let depth = 1;
let i = openBrace + 1;
while (i < src.length && depth > 0) {
const ch = src[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
assert.ok(depth === 0, `unbalanced braces after '${signature}'`);
return src.slice(start, i);
}
test('highway declares adaptive-scale state with a floor', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(src, /let\s+_autoScale\s*=\s*1/, 'missing _autoScale multiplier');
assert.match(src, /const\s+_AUTO_SCALE_MIN\s*=\s*0?\.25/, 'missing _AUTO_SCALE_MIN floor (0.25)');
assert.match(src, /const\s+_DRAW_BUDGET_HI_MS\s*=\s*\d+/, 'missing high draw budget');
assert.match(src, /const\s+_DRAW_BUDGET_LO_MS\s*=\s*\d+/, 'missing low draw budget');
});
test('_effectiveRenderScale clamps user ceiling * auto factor to [MIN, 1]', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const fn = extractBlock(src, 'function _effectiveRenderScale()');
// Derives from the (sanitized) user ceiling and auto factor.
assert.match(fn, /_renderScale/, 'effective scale must derive from the user _renderScale');
assert.match(fn, /_autoScale/, 'effective scale must derive from the auto factor _autoScale');
assert.match(fn, /user\s*\*\s*auto/, 'effective scale must multiply the sanitized factors');
assert.match(fn, /_autoScaleMin/, 'must floor at the configurable _autoScaleMin');
assert.match(fn, /Math\.min\(\s*user/, 'must cap the effective scale at the user ceiling');
});
test('min render scale floor is user-configurable + exposed on the api (#654)', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
// Hard floor constant kept; configurable floor read from localStorage.
assert.match(src, /let\s+_autoScaleMin\s*=/, 'missing configurable _autoScaleMin');
assert.match(src, /localStorage\.getItem\('highwayMinRenderScale'\)/,
'configurable floor must load from localStorage.highwayMinRenderScale');
assert.match(src, /setMinRenderScale\(/, 'api.setMinRenderScale missing');
assert.match(src, /getMinRenderScale\(\)\s*\{\s*return\s+_autoScaleMin/, 'api.getMinRenderScale missing');
// Floor is clamped to the user ceiling so it can never exceed the manual cap.
const eff = extractBlock(src, 'function _effectiveRenderScale()');
assert.match(eff, /Math\.min\(\s*_autoScaleMin\s*,\s*user\s*\)/,
'effective scale must clamp the floor to the user ceiling');
// _adaptRenderScale must cap the lo bound at 1 so _autoScale stays in [_,1].
const adapt = extractBlock(src, 'function _adaptRenderScale(');
assert.match(adapt, /Math\.min\(\s*1\s*,\s*_autoScaleMin\s*\/\s*_renderScale\s*\)/,
'lo bound must be capped at 1 to keep _autoScale a [0,1] multiplier');
});
test('_adaptRenderScale uses the draw budget + cooldown and re-applies via resize', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const fn = extractBlock(src, 'function _adaptRenderScale(');
assert.match(fn, /_DRAW_BUDGET_HI_MS/, 'must scale down past the high budget');
assert.match(fn, /_DRAW_BUDGET_LO_MS/, 'must scale up below the low budget');
assert.match(fn, /_AUTO_ADJUST_COOLDOWN_MS/, 'must respect the adjust cooldown');
assert.match(fn, /api\.resize\(\)/, 'a scale change must re-apply through api.resize()');
});
test('draw() only adapts during active playback and feeds the HUD', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const fn = extractBlock(src, 'function draw()');
assert.match(fn, /if\s*\(\s*!_paused\s*\)\s*_adaptRenderScale/, 'must skip adaptation while paused');
assert.match(fn, /_updatePerfHud\(\)/, 'must update the perf HUD each drawn frame');
});
test('bundle + canvas sizing use the effective scale, not the raw user value', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(src, /renderScale:\s*_effectiveRenderScale\(\)/, 'bundle.renderScale must be the effective scale');
assert.match(src, /canvas\.width\s*=\s*Math\.round\(w\s*\*\s*_effectiveRenderScale\(\)\)/, 'canvas backing store must use effective scale');
});
test('api exposes effective scale + perf stats', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(src, /getEffectiveRenderScale\(\)\s*\{\s*return\s+_effectiveRenderScale\(\)/, 'api.getEffectiveRenderScale missing');
assert.match(src, /getPerfStats\(\)\s*\{/, 'api.getPerfStats missing');
});
// Robustness fixes from the #655 Copilot review.
test('render scale is sanitized on load and effective scale guards non-finite', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(src, /parseFloat\(localStorage\.getItem\('renderScale'\)[\s\S]{0,160}?Number\.isFinite/,
'render scale load must validate via Number.isFinite + clamp');
const eff = extractBlock(src, 'function _effectiveRenderScale()');
assert.match(eff, /Number\.isFinite/, 'effective scale must guard against non-finite inputs');
});
test('stop() tears down the perf HUD and resets per-session accumulators', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(src, /stop\(\)\s*\{[\s\S]{0,400}?_perfHud\.remove\(\)/,
'stop() must remove the perf HUD so it cannot strand in the DOM');
assert.match(src, /stop\(\)\s*\{[\s\S]{0,1200}?_autoScale\s*=\s*1/,
'stop() must reset _autoScale so the next session starts at the manual scale');
assert.match(src, /stop\(\)\s*\{[\s\S]{0,1200}?_lastPausedDrawAt\s*=\s*0/,
'stop() must reset _lastPausedDrawAt so a quick stop→init has fresh paused-throttle timing');
});
test('perf HUD throttles its localStorage flag read off the hot path', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const fn = extractBlock(src, 'function _updatePerfHud()');
assert.match(fn, /_hudFlagAt/, 'HUD must cache the flag and re-read on an interval, not every frame');
});
+39
View File
@@ -0,0 +1,39 @@
// Pins the native-audio barrier ordering in static/highway.js
// (slopsmith-desktop#117). The highway must await window.slopsmithAudioBarrier
// before touching the JUCE backing engine, otherwise a NAM tone graph build
// that restarts the native audio device races the backing-track load.
//
// Source-level only — same strategy as the other tests/js/ files.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const HIGHWAY_JS = path.join(__dirname, '..', '..', 'static', 'highway.js');
test('highway awaits slopsmithAudioBarrier before the JUCE backing path', () => {
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
const barrierIdx = src.indexOf('window.slopsmithAudioBarrier');
const isRunningIdx = src.indexOf('juceApi.isAudioRunning()');
const loadIdx = src.indexOf('juceApi.loadBackingTrack');
assert.ok(barrierIdx !== -1, 'highway must reference window.slopsmithAudioBarrier');
assert.ok(isRunningIdx !== -1, 'highway must still call juceApi.isAudioRunning()');
assert.ok(loadIdx !== -1, 'highway must still call juceApi.loadBackingTrack');
assert.ok(barrierIdx < isRunningIdx,
'the barrier await must precede the isAudioRunning() check');
assert.ok(barrierIdx < loadIdx,
'the barrier await must precede loadBackingTrack');
});
test('the barrier await is timeout-guarded so a stuck plugin barrier cannot wedge song entry', () => {
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
const start = src.indexOf('window.slopsmithAudioBarrier');
assert.ok(start !== -1, 'highway must reference window.slopsmithAudioBarrier');
const region = src.slice(start, start + 600);
// Catching rejections alone does not cover a never-settling promise — the
// await must be raced against a local timeout.
assert.match(region, /Promise\.race/,
'the barrier await must be raced against a local timeout');
});
@@ -0,0 +1,59 @@
// Source-level guards for the 2D highway chord render cache
// (`_ensureChordRenderCache`). Locks in the invalidation contract so a
// regression that drops one of the three keys, or forgets to reset the
// derived state, will fail in CI.
//
// Background: see slopsmith#412 and the Copilot review thread that
// surfaced the `chordTemplates` ordering edge case (templates can land
// after the final `chords` chunk; `isOpen()`-derived `nonZeroNotes`
// would otherwise stay stale until the next chord transition).
//
// Like the other highway tests in this directory, these inspect the
// source rather than executing — the createHighway() closure owns
// canvas + WebGL lifecycle that's too heavy for a vm sandbox.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');
test('_ensureChordRenderCache keys off src, _inverted, AND chordTemplates', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
// The cache key triple must include chordTemplates — without it, a
// late-arriving `chord_templates` WS message leaves cached
// nonZeroNotes / nonZeroFrets stale until the next chord transition.
//
// Match either operand order (`A === B` or `B === A`) so a future
// stylistic refactor that flips sides doesn't trip these guards —
// the semantic invariant is the comparison, not its placement.
const eqEither = (a, b) => new RegExp(
`\\b${a}\\b\\s*===\\s*\\b${b}\\b|\\b${b}\\b\\s*===\\s*\\b${a}\\b`
);
const neqEither = (a, b) => new RegExp(
`\\b${a}\\b\\s*!==\\s*\\b${b}\\b|\\b${b}\\b\\s*!==\\s*\\b${a}\\b`
);
assert.match(src, eqEither('_chordRenderCacheSrc', 'src'), 'cache must key on src');
assert.match(src, eqEither('_chordRenderCacheInverted', '_inverted'), 'cache must key on _inverted');
assert.match(src, neqEither('_chordRenderCacheTemplates', 'chordTemplates'),
'cache must key on chordTemplates (detected via !== for change-flag)');
});
test('chordTemplates change resets fretline preview and frame-mismatch warner', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
// The cache-invalidation block must clear both _chordFretLineNotes
// (so _updateFretLinePreview re-publishes with corrected isOpen
// classification) and _frameMismatchWarned (so a chord ID warned
// against stale templates re-validates against the corrected ones).
// Non-greedy `[\s\S]*?` instead of `[^}]*` so a future nested
// block inside the `if (templatesChanged) { … }` branch (e.g. an
// inner conditional reset) doesn't break the match by introducing
// a `}` before the symbol we're checking for.
assert.match(src, /if\s*\(\s*templatesChanged\s*\)\s*\{[\s\S]*?_chordFretLineNotes\s*=\s*\[\][\s\S]*?\}/,
'templatesChanged branch must reset _chordFretLineNotes');
assert.match(src, /if\s*\(\s*templatesChanged\s*\)\s*\{[\s\S]*?_lastChordOnFretLine\s*=\s*null[\s\S]*?\}/,
'templatesChanged branch must null _lastChordOnFretLine');
assert.match(src, /if\s*\(\s*templatesChanged\s*\)\s*\{[\s\S]*?_frameMismatchWarned\.clear\(\)[\s\S]*?\}/,
'templatesChanged branch must clear _frameMismatchWarned');
});
+163
View File
@@ -0,0 +1,163 @@
// The public plugin API: window.slopsmith.highwayColors. The facade is a thin,
// stable wrapper over the (private) string-color manager in app.js. These tests
// extract _hwcInstallFacade and run it against a fake window/bus with stubbed
// manager functions, so the documented surface + wiring are locked in.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const appJs = path.join(__dirname, '..', '..', 'static', 'app.js');
function extractBlock(src, signature) {
const start = src.indexOf(signature);
assert.ok(start !== -1, `signature '${signature}' not found`);
const openBrace = src.indexOf('{', start);
let depth = 1;
let i = openBrace + 1;
while (i < src.length && depth > 0) {
const ch = src[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
assert.equal(depth, 0, `unbalanced braces after '${signature}'`);
return src.slice(start, i);
}
// Build a live facade by injecting stubs into the extracted installer.
function buildFacade() {
const src = fs.readFileSync(appJs, 'utf8');
const body = [
'const _hwcChangeWrappers = new WeakMap();',
extractBlock(src, 'function _hwcInstallFacade()'),
'return _hwcInstallFacade;',
].join('\n');
const params = [
'window', 'HWC_SLOTS', 'console',
'getHighwayStringColors', 'getHighwayDefaultSlotColors', '_hwcMergedSlotColors',
'_hwcSlotKeysForChart', '_hwcEffectiveIndexColors', '_hwcChartShape',
'applyHighwayStringColors', 'encodeHighwayColorShare', 'decodeHighwayColorShare',
];
const listeners = {};
const calls = [];
const bus = {
on(e, f) { (listeners[e] = listeners[e] || []).push(f); },
off(e, f) { listeners[e] = (listeners[e] || []).filter((x) => x !== f); },
emit(e, d) { (listeners[e] || []).slice().forEach((f) => f({ detail: d })); },
_count: (e) => (listeners[e] || []).length,
};
const win = { slopsmith: bus, highway: { getStringColors: () => ['#aaaaaa'] } };
const HWC_SLOTS = [
{ key: 'highE', label: 'High E', sub: '1st' }, { key: 'B', label: 'B', sub: '2nd' },
{ key: 'G', label: 'G', sub: '3rd' }, { key: 'D', label: 'D', sub: '4th' },
{ key: 'A', label: 'A', sub: '5th' }, { key: 'lowE', label: 'Low E', sub: '6th' },
{ key: 'low7', label: 'Low B', sub: '7-string' }, { key: 'low8', label: 'Low F#', sub: '8-string' },
];
const stubs = {
getHighwayStringColors: () => ({ lowE: '#111111' }),
getHighwayDefaultSlotColors: () => ({ lowE: '#cc0000', highE: '#9900cc' }),
_hwcMergedSlotColors: () => ({ lowE: '#111111', A: '#cca800' }),
_hwcSlotKeysForChart: (sc, isBass) => ['keys', sc, isBass],
_hwcEffectiveIndexColors: (map, sc, isBass) => ['eff', sc, isBass],
_hwcChartShape: () => ({ sc: 6, isBass: false }),
applyHighwayStringColors: (m) => { calls.push(['apply', m]); },
encodeHighwayColorShare: (n, m) => 'SLOPHWY2.CODE',
decodeHighwayColorShare: (c) => ({ name: 'x', colors: {} }),
};
const installer = new Function(...params, body)(
win, HWC_SLOTS, console,
stubs.getHighwayStringColors, stubs.getHighwayDefaultSlotColors, stubs._hwcMergedSlotColors,
stubs._hwcSlotKeysForChart, stubs._hwcEffectiveIndexColors, stubs._hwcChartShape,
stubs.applyHighwayStringColors, stubs.encodeHighwayColorShare, stubs.decodeHighwayColorShare,
);
installer();
return { api: win.slopsmith.highwayColors, win, bus, calls, installer, stubs };
}
test('initHighwayColors installs the facade', () => {
const src = fs.readFileSync(appJs, 'utf8');
const init = extractBlock(src, 'function initHighwayColors()');
assert.match(init, /_hwcInstallFacade\(\)/, 'initHighwayColors must call _hwcInstallFacade');
});
test('facade exposes the documented surface', () => {
const { api } = buildFacade();
assert.equal(api.version, 1);
for (const m of ['get', 'getDefaults', 'getResolved', 'keysForChart', 'toEffective',
'getCurrent', 'apply', 'encodeShare', 'decodeShare', 'onChange', 'offChange']) {
assert.equal(typeof api[m], 'function', `highwayColors.${m} must be a function`);
}
assert.deepEqual(api.slots.map((s) => s.key),
['highE', 'B', 'G', 'D', 'A', 'lowE', 'low7', 'low8'], 'slots in display order');
});
test('facade read methods delegate to the manager', () => {
const { api } = buildFacade();
assert.deepEqual(api.get(), { lowE: '#111111' });
assert.deepEqual(api.getDefaults(), { lowE: '#cc0000', highE: '#9900cc' });
assert.deepEqual(api.getResolved(), { lowE: '#111111', A: '#cca800' });
assert.deepEqual(api.keysForChart(7, true), ['keys', 7, true]);
assert.deepEqual(api.toEffective(7, true), ['eff', 7, true]);
assert.deepEqual(api.toEffective(), ['eff', 6, false], 'no args → current chart shape');
assert.deepEqual(api.getCurrent(), ['#aaaaaa'], 'live 2D applied colors');
});
test('apply / share interop delegate', () => {
const { api, calls } = buildFacade();
api.apply({ lowE: '#abcdef' });
assert.deepEqual(calls[0], ['apply', { lowE: '#abcdef' }]);
assert.match(api.encodeShare('n', {}), /^SLOPHWY2\./);
assert.deepEqual(api.decodeShare('SLOPHWY2.CODE'), { name: 'x', colors: {} });
});
test('onChange fires with resolved map and unsubscribes cleanly', () => {
const { api, bus } = buildFacade();
let got = null;
const handler = (m) => { got = m; };
const unsub = api.onChange(handler);
assert.equal(bus._count('highway:stringColors'), 1, 'subscribed to the change event');
bus.emit('highway:stringColors', { lowE: '#111111' });
assert.deepEqual(got, { lowE: '#111111', A: '#cca800' }, 'handler gets the RESOLVED map');
got = null;
unsub();
assert.equal(bus._count('highway:stringColors'), 0, 'unsubscribe removed the listener');
bus.emit('highway:stringColors', {});
assert.equal(got, null, 'no callback after unsubscribe');
// offChange path
const h2 = () => {};
api.onChange(h2);
assert.equal(bus._count('highway:stringColors'), 1);
api.offChange(h2);
assert.equal(bus._count('highway:stringColors'), 0);
});
test('repeated onChange with the same handler unsubscribes independently (no leak)', () => {
const { api, bus } = buildFacade();
let n = 0;
const handler = () => { n++; };
const unsubA = api.onChange(handler);
const unsubB = api.onChange(handler);
assert.equal(bus._count('highway:stringColors'), 2, 'two independent subscriptions');
// First unsubscribe removes only ITS wrapper, leaving the second live.
unsubA();
assert.equal(bus._count('highway:stringColors'), 1, 'first unsub removes one, not both');
bus.emit('highway:stringColors', {});
assert.equal(n, 1, 'surviving subscription still fires');
unsubB();
assert.equal(bus._count('highway:stringColors'), 0, 'second unsub removes the rest');
// offChange removes ALL remaining subscriptions of a handler at once.
n = 0;
api.onChange(handler); api.onChange(handler);
assert.equal(bus._count('highway:stringColors'), 2);
api.offChange(handler);
assert.equal(bus._count('highway:stringColors'), 0, 'offChange clears all of fn');
});
test('install is idempotent (does not replace an existing facade)', () => {
const { api, win, installer } = buildFacade();
installer();
assert.equal(win.slopsmith.highwayColors, api, 'second install must be a no-op');
});
+56
View File
@@ -0,0 +1,56 @@
// Source-level tests for highway.getFilteredNotes() / getFilteredChords() and
// the hasPhraseData() companion getter. These mirror the pattern used in
// highway_note_state.test.js: the createHighway closure is too heavy for a Node
// sandbox, so tests inspect the source text to lock in correct wiring.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');
test('highway public API exposes getFilteredNotes', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(
src,
/getFilteredNotes\s*\(\s*\)\s*\{[^}]*_filteredNotes\s*!==\s*null/,
'getFilteredNotes must check _filteredNotes !== null',
);
});
test('getFilteredNotes falls through to notes when _filteredNotes is null', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(
src,
/getFilteredNotes\s*\(\s*\)\s*\{[^}]*_filteredNotes[^}]*:\s*notes/,
'getFilteredNotes must return notes as fallback',
);
});
test('highway public API exposes getFilteredChords', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(
src,
/getFilteredChords\s*\(\s*\)\s*\{[^}]*_filteredChords\s*!==\s*null/,
'getFilteredChords must check _filteredChords !== null',
);
});
test('getFilteredChords falls through to chords when _filteredChords is null', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(
src,
/getFilteredChords\s*\(\s*\)\s*\{[^}]*_filteredChords[^}]*:\s*chords/,
'getFilteredChords must return chords as fallback',
);
});
test('highway public API exposes hasPhraseData', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(
src,
/hasPhraseData\s*\(\s*\)\s*\{[^}]*_phrases/,
'hasPhraseData must reference _phrases',
);
});
+261
View File
@@ -0,0 +1,261 @@
// Verify static/highway.js's getTime() interpolates smoothly via
// performance.now() between setTime() calls (so plugins observe sub-
// frame clock motion despite audio.currentTime's coarse step
// quantization — browsers refresh the reported value at ~20+ ms
// granularity even though the underlying audio thread runs faster).
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const HIGHWAY_JS = path.join(__dirname, '..', '..', 'static', 'highway.js');
// Brace-balanced extraction so source-level tests stay robust to body
// growth. Returns the full method-or-block text including its braces.
function extractBlock(src, signature) {
const start = src.indexOf(signature);
assert.ok(start !== -1, `signature '${signature}' not found`);
const openBrace = src.indexOf('{', start);
assert.ok(openBrace !== -1, `opening brace after '${signature}' not found`);
let depth = 1;
let i = openBrace + 1;
while (i < src.length && depth > 0) {
const ch = src[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
assert.ok(depth === 0, `unbalanced braces after '${signature}'`);
return src.slice(start, i);
}
// Build a sandbox with the chart-clock state and the extracted setTime
// + getTime methods so behavioral tests can exercise the real
// implementation in isolation.
function buildClockSandbox(perfNowImpl) {
const sandbox = {
chartTime: 0,
currentTime: 0,
avOffsetSec: 0,
// Per-song chart offset (loose-folder format only). Tests pin
// this at 0 so the clock-behaviour assertions can stay
// expressed in raw audio time without offset bookkeeping.
songOffset: 0,
// Match production: NaN sentinels for "no prior anchor" so
// the first setTime call always re-anchors (even setTime(0))
// and so getTime() before any setTime returns chartTime.
_chartAnchorAudioT: NaN,
_chartAnchorPerfNow: NaN,
_chartLastAdvanceAt: 0,
_chartObservedRate: 1,
_CHART_MAX_INTERP_MS: 100,
performance: { now: perfNowImpl },
};
vm.createContext(sandbox);
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
const setTimeBody = extractBlock(src, 'setTime(t) {');
const getTimeBody = extractBlock(src, 'getTime() {');
// Strip trailing comma if present (object-literal method declarations).
const cleanup = (s) => s.replace(/,?\s*$/, '');
vm.runInContext(`
globalThis.setTime = function ${cleanup(setTimeBody)};
globalThis.getTime = function ${cleanup(getTimeBody)};
`, sandbox);
return sandbox;
}
test('highway declares chart anchor + stall-detect + rate state', () => {
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
// Both anchor fields use NaN sentinels — _chartAnchorAudioT in
// particular MUST start as NaN, not 0, otherwise setTime(0) on the
// very first 60 Hz tick fails the `t !== _chartAnchorAudioT` check
// and never re-anchors, leaving the clock uninitialized.
assert.match(src, /let\s+_chartAnchorAudioT\s*=\s*NaN/, 'missing _chartAnchorAudioT (NaN sentinel)');
assert.match(src, /let\s+_chartAnchorPerfNow\s*=\s*NaN/, 'missing _chartAnchorPerfNow (NaN sentinel)');
assert.match(src, /let\s+_chartLastAdvanceAt\s*=\s*0/, 'missing _chartLastAdvanceAt (pause detection)');
assert.match(src, /let\s+_chartObservedRate\s*=\s*1/, 'missing _chartObservedRate (playback rate awareness)');
assert.match(src, /const\s+_CHART_MAX_INTERP_MS\s*=\s*100/, 'missing _CHART_MAX_INTERP_MS cap');
});
test('getTime scales interpolation by _chartObservedRate (speed-slider safe)', () => {
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
const m = src.match(/getTime\(\)\s*\{[\s\S]+?\n\s*\},/);
assert.ok(m, 'getTime() body not found');
const slice = m[0];
assert.match(
slice,
/_chartObservedRate\s*\*\s*elapsedMs/,
'getTime must scale interpolation by observed rate so audio.playbackRate != 1 stays accurate',
);
});
test('setTime re-anchors and updates _chartLastAdvanceAt only when t actually changes', () => {
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
// Repeated setTime calls with the same value must not refresh the
// anchor (else interpolation stutters); they also must not refresh
// _chartLastAdvanceAt (else getTime would never detect a stalled
// audio clock as paused).
// The implementation may capture performance.now() into a local
// (e.g. newPerfNow) and assign that to both fields; accept either
// direct or via-local writes.
const m = src.match(/if\s*\(\s*t\s*!==\s*_chartAnchorAudioT\s*\)\s*\{[\s\S]+?\}\s*\},/);
assert.ok(m, 'if (t !== _chartAnchorAudioT) block not found inside setTime');
const block = m[0];
assert.match(block, /_chartAnchorAudioT\s*=\s*t/, 'must assign _chartAnchorAudioT = t');
assert.match(block, /_chartAnchorPerfNow\s*=/, 'must assign _chartAnchorPerfNow');
assert.match(block, /_chartLastAdvanceAt\s*=/, 'must assign _chartLastAdvanceAt');
});
test('getTime falls back to chartTime when audio has stalled (paused)', () => {
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
// Find the actual getTime body. Match the whole brace-balanced
// method (using a generous greedy slice to ensure we capture both
// the stall check and the interpolation expression below it).
const m = src.match(/getTime\(\)\s*\{[\s\S]+?\n\s*\},/);
assert.ok(m, 'getTime() body not found');
const slice = m[0];
// Must check stall-since-last-advance against the cap.
assert.match(
slice,
/nowP\s*-\s*_chartLastAdvanceAt\s*>\s*_CHART_MAX_INTERP_MS/,
'getTime must short-circuit when audio has stalled past the cap',
);
// Must interpolate when active.
assert.match(slice, /performance\.now\(\)|nowP/, 'getTime must use perfNow');
// Rate-scaled formula: _chartAnchorAudioT + (_chartObservedRate * elapsedMs) / 1000
assert.match(
slice,
/_chartAnchorAudioT\s*\+\s*\(\s*_chartObservedRate\s*\*\s*elapsedMs\s*\)\s*\/\s*1000/,
'getTime must compute anchor + rate-scaled elapsed during play',
);
});
test('api.stop() clears the chart anchor state so re-init starts fresh', () => {
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
// Use the brace-balanced extractor so the assertions are scoped to
// the actual stop() body — a fixed-size slice would falsely match
// resets that landed in an adjacent method.
const stopBlock = extractBlock(src, 'stop() {');
assert.match(stopBlock, /_chartAnchorAudioT\s*=\s*NaN/, 'stop() must reset _chartAnchorAudioT to the NaN sentinel');
assert.match(stopBlock, /_chartAnchorPerfNow\s*=\s*NaN/, 'stop() must reset _chartAnchorPerfNow to the NaN sentinel');
assert.match(stopBlock, /_chartLastAdvanceAt\s*=\s*0/, 'stop() must reset _chartLastAdvanceAt');
assert.match(stopBlock, /_chartObservedRate\s*=\s*1/, 'stop() must reset _chartObservedRate to 1x');
});
// ── Behavioral tests (run extracted setTime/getTime in vm sandbox) ──────
test('behavior: getTime interpolates smoothly between two anchors at 1x', () => {
let now = 0;
const sb = buildClockSandbox(() => now);
// First anchor at audioT=10, perf=0.
sb.setTime(10);
// Browser hasn't refreshed audio.currentTime; setTime called again
// with the same value at perf=16. Anchor must NOT move.
now = 16;
sb.setTime(10);
// Plugin reads at perf=24 — interpolated 24ms from anchor.
now = 24;
const t = sb.getTime();
assert.ok(Math.abs(t - (10 + 0.024)) < 0.001, `expected ~10.024, got ${t}`);
});
test('behavior: getTime returns chartTime when audio has stalled (paused)', () => {
let now = 0;
const sb = buildClockSandbox(() => now);
sb.setTime(10);
now = 16; sb.setTime(10);
// 200ms after the last advance — well past the 100ms cap. Even
// though setTime is still being called every 16ms with the same
// value (the 60Hz tick), getTime must report raw chartTime.
now = 200;
const t = sb.getTime();
assert.equal(t, 10, `paused getTime must be chartTime (10), got ${t}`);
});
test('behavior: getTime adjusts for non-1x playback rate (observed)', () => {
let now = 0;
const sb = buildClockSandbox(() => now);
// Establish initial anchor.
sb.setTime(10);
// Audio advanced 0.025s in 50ms real time → observed rate = 0.5.
now = 50;
sb.setTime(10.025);
// Read 25ms after the latest anchor: chart should advance by
// rate * elapsed = 0.5 * 0.025 = 0.0125 → 10.0375.
now = 75;
const t = sb.getTime();
assert.ok(Math.abs(t - 10.0375) < 0.0005, `expected ~10.0375 (rate-scaled), got ${t}`);
});
test('behavior: seek discontinuity resets observed rate to 1x', () => {
let now = 0;
const sb = buildClockSandbox(() => now);
sb.setTime(10);
now = 50;
sb.setTime(10.025); // observed rate ≈ 0.5
assert.ok(Math.abs(sb._chartObservedRate - 0.5) < 0.001, `prior segment must measure ≈0.5, got ${sb._chartObservedRate}`);
// Seek: large t jump in same perf delta — observed-rate clamp
// rejects this segment, resets to 1.
now = 70;
sb.setTime(120); // dPerf=20ms, dT=110s → observed=5500 (out of clamp)
assert.equal(sb._chartObservedRate, 1, 'seek must reset rate to 1x');
});
test('behavior: getTime caps interpolation at _CHART_MAX_INTERP_MS', () => {
let now = 0;
const sb = buildClockSandbox(() => now);
sb.setTime(10);
now = 50;
sb.setTime(10.05); // observed rate ~1
// Long pause-like gap with NO setTime call. getTime should detect
// (now - _chartLastAdvanceAt > 100) and return chartTime.
now = 200;
const t = sb.getTime();
assert.equal(t, 10.05, 'beyond cap must fall back to chartTime');
});
test('behavior: setTime(0) on first tick anchors correctly (boot edge case)', () => {
// Regression: _chartAnchorAudioT used to start at 0, so setTime(0)
// on the very first 60 Hz tick failed the `t !== _chartAnchorAudioT`
// check and skipped the re-anchor branch entirely. _chartAnchorPerfNow
// would stay NaN, and getTime would propagate NaN to plugins.
let now = 16; // realistic first-tick perf
const sb = buildClockSandbox(() => now);
sb.setTime(0);
// Anchor must now be initialized.
assert.equal(sb._chartAnchorAudioT, 0, 'setTime(0) on first tick must set anchor.audioT');
assert.equal(sb._chartAnchorPerfNow, 16, 'setTime(0) on first tick must set anchor.perfNow');
// getTime should return a finite value, not NaN.
const t = sb.getTime();
assert.ok(!Number.isNaN(t), `getTime must not return NaN after setTime(0); got ${t}`);
});
test('behavior: getTime before any setTime returns chartTime (no NaN)', () => {
// Regression: getTime called during early boot (before the first
// 60 Hz tick) used to compute `nowP - NaN` and propagate NaN. Must
// bail to chartTime when the anchor is still the NaN sentinel.
let now = 0;
const sb = buildClockSandbox(() => now);
const t = sb.getTime();
assert.equal(t, 0, 'getTime before any setTime must return chartTime, not NaN');
assert.ok(!Number.isNaN(t), 'getTime must not return NaN');
});
test('behavior: long anchor gap resets observed rate to 1x', () => {
// After a long gap (e.g. tab inactive, paused for a while), the
// next setTime() shouldn't carry forward the prior segment's
// observed rate — it likely reflects a different playback state.
let now = 0;
const sb = buildClockSandbox(() => now);
sb.setTime(10);
now = 50;
sb.setTime(10.025); // observed rate ≈ 0.5
assert.ok(Math.abs(sb._chartObservedRate - 0.5) < 0.001, 'first segment measured 0.5x');
// Long gap (1 second) before next setTime — out of the dPerf < 0.5
// window, so the rate must reset to 1.
now = 1100;
sb.setTime(10.5);
assert.equal(sb._chartObservedRate, 1, 'long anchor gap must reset rate to 1x');
});
+129
View File
@@ -0,0 +1,129 @@
// Source-level guards for the per-note judgment hook (slopsmith#254):
// highway.setNoteStateProvider / getNoteStateProvider / getNoteState,
// bundle.getNoteState, isDefaultRenderer, and the _noteState
// normalization rules. The createHighway closure owns canvas + WebGL
// lifecycle that's too heavy for a vm sandbox, so — like the other
// highway tests in this dir — these lock in the wiring by inspecting
// the source rather than executing it.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');
const highway3dJs = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
// Brace-balanced extraction (same helper shape as highway_visibility.test.js).
function extractBlock(src, signature) {
const start = src.indexOf(signature);
assert.ok(start !== -1, `signature '${signature}' not found`);
const openBrace = src.indexOf('{', start);
assert.ok(openBrace !== -1, `opening brace after '${signature}' not found`);
let depth = 1;
let i = openBrace + 1;
while (i < src.length && depth > 0) {
const ch = src[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
assert.ok(depth === 0, `unbalanced braces after '${signature}'`);
return src.slice(start, i);
}
test('highway declares the note-state provider slot', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(src, /let\s+_noteStateProvider\s*=\s*null/, 'missing _noteStateProvider (provider slot, null = none)');
});
test('public API exposes setNoteStateProvider / getNoteStateProvider / getNoteState / isDefaultRenderer', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(src, /setNoteStateProvider\s*\(\s*fn\s*\)\s*\{[^}]*_noteStateProvider\s*=/, 'setNoteStateProvider must assign _noteStateProvider');
assert.match(src, /setNoteStateProvider\s*\(\s*fn\s*\)\s*\{[^}]*typeof\s+fn\s*===\s*['"]function['"][^}]*:\s*null/, 'setNoteStateProvider must coerce non-functions (incl. null) to null');
assert.match(src, /getNoteStateProvider\s*\(\s*\)\s*\{\s*return\s+_noteStateProvider/, 'getNoteStateProvider must return the slot');
assert.match(src, /getNoteState\s*\(\s*note\s*,\s*chartTime\s*\)\s*\{\s*return\s+_noteState\s*\(/, 'getNoteState must delegate to _noteState');
assert.match(src, /isDefaultRenderer\s*\(\s*\)\s*\{\s*return\s+_renderer\s*===\s*_defaultRenderer\s*\|\|\s*_renderer\s*==\s*null/, 'isDefaultRenderer must be (_renderer === _defaultRenderer || _renderer == null)');
});
test('_makeBundle exposes getNoteState (stable reference, no per-frame alloc)', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const fn = extractBlock(src, 'function _makeBundle()');
// The bundle field must point straight at _noteState — not a fresh
// arrow each frame (the per-frame allocation the review flagged).
assert.match(fn, /getNoteState:\s*_noteState\b/, 'bundle.getNoteState must be the stable _noteState reference');
});
test('_makeBundle exposes getNoteStateProvider as a stable reference (slopsmith#254)', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const fn = extractBlock(src, 'function _makeBundle()');
// Same allocation discipline as getNoteState: highway_3d uses this
// bundle field to tell "provider attached" from "no provider but
// getNoteState still exists and returns null", so a per-frame arrow
// here would both burn allocations on the hot path and could trip
// identity-based guards in renderer code.
assert.match(
fn,
/getNoteStateProvider:\s*_getNoteStateProvider\b/,
'bundle.getNoteStateProvider must be the stable _getNoteStateProvider reference (not a per-frame arrow)'
);
// Sanity: the stable accessor exists per-createHighway-instance
// (alongside _noteStateProvider in the closure) and returns the
// slot, so renderers see a live "is a provider registered?" view.
assert.match(
src,
/function\s+_getNoteStateProvider\s*\(\s*\)\s*\{\s*return\s+_noteStateProvider\s*;?\s*\}/,
'_getNoteStateProvider must be defined as a stable named function returning _noteStateProvider'
);
});
test('_noteState normalizes provider output as documented', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const fn = extractBlock(src, 'function _noteState(note, chartTime)');
assert.match(fn, /if\s*\(\s*!_noteStateProvider\s*\)\s*return\s+null/, 'must short-circuit when no provider is registered');
assert.match(fn, /try\s*\{[\s\S]*_noteStateProvider\s*\([\s\S]*catch[\s\S]*return\s+null/, 'must call the provider inside try/catch and return null on throw');
assert.match(fn, /state\s*!==\s*['"]hit['"]\s*&&\s*state\s*!==\s*['"]active['"]\s*&&\s*state\s*!==\s*['"]miss['"]/, 'must reject states other than hit/active/miss');
assert.match(fn, /Math\.max\(\s*0\s*,\s*Math\.min\(\s*1\s*,\s*raw\.alpha\s*\)\s*\)/, 'must clamp alpha to [0,1]');
assert.match(fn, /if\s*\(\s*alpha\s*<=\s*0\s*\)\s*return\s+null/, 'must return null when alpha resolves to <= 0');
assert.match(fn, /const\s+live\s*=\s*\(raw\s*&&\s*typeof\s+raw\s*===\s*['"]object['"]\s*&&\s*raw\.live\s*===\s*true\)/, 'must pass through the provider live flag');
assert.match(fn, /return\s*\{\s*state\s*,\s*alpha\s*,\s*color\s*,\s*live\s*\}/, 'must return the normalized { state, alpha, color, live }');
});
test('default 2D renderer threads note state into drawNote / drawSustains / chord path', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
// drawNote takes the trailing `ns` param.
assert.match(src, /function\s+drawNote\(\s*W\s*,\s*H\s*,\s*x\s*,\s*y\s*,\s*scale\s*,\s*string\s*,\s*fret\s*,\s*opts\s*,\s*ns\s*\)/, 'drawNote must accept the trailing ns param');
// drawNotes / drawSustains / drawChords gate the lookup on the provider.
assert.match(src, /_noteStateProvider\s*\?\s*_noteState\(\s*n\s*,\s*n\.t\s*\)\s*:\s*null/, 'visible-note paths must skip the lookup when no provider is set');
assert.match(src, /_noteStateProvider\s*\?\s*_noteState\(\s*cn\s*,\s*ch\.t\s*\)\s*:\s*null/, 'chord-note path must key the lookup by the chord time and gate on the provider');
});
test('3D highway captures bundle.getNoteState and overrides legacy hit/miss with the provider verdict', () => {
const src = fs.readFileSync(highway3dJs, 'utf8');
assert.match(src, /_ndGetNoteState\s*=\s*\(bundle\s*&&\s*typeof\s+bundle\.getNoteState\s*===\s*['"]function['"]\)\s*\?\s*bundle\.getNoteState\s*:\s*null/, 'update() must capture bundle.getNoteState into _ndGetNoteState');
// Provider verdict wins: miss => not _showHit; otherwise provider state
// or the legacy fallback (`hit`) plus the pre-hit ghost window preview.
assert.match(src, /const\s+_showHit\s*=\s*\(\s*_ndState\s*===\s*['"]miss['"]\s*\)\s*\?\s*false\s*:\s*\(\s*_ndState\s*\?\s*_ndGood\s*:\s*\(\s*hit\s*\|\|\s*\(\s*n\.f\s*>\s*0\s*&&\s*inGhostWin\s*\)\s*\)\s*\)/, '_showHit must honor a provider "miss" and fall back to the hit/ghost heuristic only with no verdict');
});
test('3D highway captures _ndHasProvider via bundle.getNoteStateProvider (slopsmith#254)', () => {
const src = fs.readFileSync(highway3dJs, 'utf8');
// Detect-mode behavior — verdict-window cull extension, chord-frame
// hold floor, and the smart drawNote cull — must be gated on a real
// provider being registered, not on the always-present bundle.
// getNoteState. Capture via bundle.getNoteStateProvider() with a
// fallback to the getNoteState-existence check so downlevel hosts
// (no getNoteStateProvider) still behave as before.
assert.match(
src,
/_ndHasProvider\s*=\s*\(bundle\s*&&\s*typeof\s+bundle\.getNoteStateProvider\s*===\s*['"]function['"]\)[\s\S]{0,80}bundle\.getNoteStateProvider\s*\(\s*\)\s*!=\s*null[\s\S]{0,80}:\s*!!_ndGetNoteState/,
'update() must derive _ndHasProvider from bundle.getNoteStateProvider() with a fallback to _ndGetNoteState'
);
// Verdict-window cull and chord-frame hold floor + smart drawNote
// cull must all gate on _ndHasProvider (not _ndGetNoteState alone).
assert.match(src, /ndVerdictT0\s*=\s*_ndHasProvider/, 'ndVerdictT0 outer-loop extension must gate on _ndHasProvider');
assert.match(src, /if\s*\(\s*_ndHasProvider\s*&&\s*chordTailHoldS\s*<\s*NOTEDETECT_GEM_VERDICT_WINDOW/,
'chord-frame hold floor must gate on _ndHasProvider');
assert.match(src, /if\s*\(\s*!_ndHasProvider\s*\|\|\s*dt\s*<\s*-NOTEDETECT_GEM_VERDICT_WINDOW\s*\)\s*return/,
'drawNote smart-cull provider probe must gate on _ndHasProvider');
});
+62
View File
@@ -0,0 +1,62 @@
// Source-level guards for the playback-aware paused-render throttle
// (slopsmith#654). The createHighway closure owns the rAF loop + WebGL
// context lifecycle that's too heavy to reproduce in a vm sandbox, so —
// like highway_visibility.test.js — these checks lock in the wiring.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');
// Brace-balanced extraction (shared shape with highway_visibility.test.js)
// so a future edit that grows the loop body doesn't get truncated.
function extractBlock(src, signature) {
const start = src.indexOf(signature);
assert.ok(start !== -1, `signature '${signature}' not found`);
const openBrace = src.indexOf('{', start);
assert.ok(openBrace !== -1, `opening brace after '${signature}' not found`);
let depth = 1;
let i = openBrace + 1;
while (i < src.length && depth > 0) {
const ch = src[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
assert.ok(depth === 0, `unbalanced braces after '${signature}'`);
return src.slice(start, i);
}
test('highway declares the paused-render throttle state', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(src, /const\s+_PAUSED_FRAME_INTERVAL_MS\s*=\s*\d+/, 'missing _PAUSED_FRAME_INTERVAL_MS cap');
assert.match(src, /let\s+_lastPausedDrawAt\s*=\s*0/, 'missing _lastPausedDrawAt accumulator');
});
test('draw() throttles full renders while the audio clock is stalled', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const fn = extractBlock(src, 'function draw()');
// Reuse getTime()'s pause signal rather than inventing a parallel one.
assert.match(fn, /_chartLastAdvanceAt/, 'throttle must key off _chartLastAdvanceAt (the advance timestamp)');
assert.match(fn, /_CHART_MAX_INTERP_MS/, 'throttle must reuse the _CHART_MAX_INTERP_MS pause threshold');
assert.match(fn, /_PAUSED_FRAME_INTERVAL_MS/, 'throttle must cap paused draws to _PAUSED_FRAME_INTERVAL_MS');
assert.match(fn, /_lastPausedDrawAt\s*=\s*_nowP/, 'throttle must record the last paused draw time');
});
test('throttle runs after the ready gate, before bundle/draw', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const fn = extractBlock(src, 'function draw()');
// Regex landmarks (not exact-string indexOf) so harmless spacing /
// semicolon changes don't break the ordering guard — matches the
// search-based style of the other highway source-guard tests.
const readyIdx = fn.search(/if\s*\(\s*!ready\s*\)\s*return;/);
const throttleIdx = fn.search(/_PAUSED_FRAME_INTERVAL_MS/);
const drawIdx = fn.search(/_renderer\.draw\s*\(/);
assert.ok(readyIdx !== -1, 'ready gate not found');
assert.ok(throttleIdx !== -1, 'throttle not found');
assert.ok(drawIdx !== -1, '_renderer.draw call not found');
assert.ok(readyIdx < throttleIdx, 'throttle must come after the ready gate');
assert.ok(throttleIdx < drawIdx, 'throttle must come before the renderer draw');
});
+49
View File
@@ -0,0 +1,49 @@
// Source-level tests for highway.getPhrases() and getMastery() public API getters.
// The createHighway closure is too heavy for a Node sandbox, so tests inspect
// source text to lock in correct wiring — same pattern as highway_filtered_notes.test.js.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');
test('highway public API exposes getPhrases', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(
src,
/getPhrases\s*\(\s*\)\s*\{[^}]*_phrases/,
'getPhrases must reference _phrases',
);
});
test('getPhrases returns null when _phrases is falsy or empty', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(
src,
/getPhrases\s*\(\s*\)\s*\{[^}]*!\s*_phrases[^}]*return null/,
'getPhrases must return null when no phrase data is available',
);
});
test('getPhrases maps phrases to index, start_time, end_time, max_difficulty', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
// Match all four fields within a reasonable window after getPhrases
const match = src.match(/getPhrases\s*\(\s*\)([\s\S]{0,400})/);
assert.ok(match, 'getPhrases not found in highway.js');
const block = match[1];
assert.ok(block.includes('start_time'), 'getPhrases must expose start_time');
assert.ok(block.includes('end_time'), 'getPhrases must expose end_time');
assert.ok(block.includes('max_difficulty'), 'getPhrases must expose max_difficulty');
assert.ok(block.includes('index'), 'getPhrases must expose index');
});
test('highway public API exposes getMastery', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(
src,
/getMastery\s*\(\s*\)\s*\{[^}]*_mastery/,
'getMastery must reference _mastery',
);
});
+231
View File
@@ -0,0 +1,231 @@
// User-created, shareable highway string colors. Two layers:
// 1. Source-level wiring guards (the createHighway / highway_3d closures own
// canvas + WebGL lifecycle too heavy for a vm sandbox — same approach as
// the other highway_* tests here), covering the 2D setStringColors API,
// the 3D `custom` palette path, and the app.js color manager.
// 2. Executable behavior tests for the *pure* pieces — the dim/bright
// derivation math and the share-code codec — extracted and run for real.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');
const highway3dJs = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const appJs = path.join(__dirname, '..', '..', 'static', 'app.js');
// Brace-balanced extraction (same helper shape as highway_note_state.test.js).
function extractBlock(src, signature) {
const start = src.indexOf(signature);
assert.ok(start !== -1, `signature '${signature}' not found`);
const openBrace = src.indexOf('{', start);
assert.ok(openBrace !== -1, `opening brace after '${signature}' not found`);
let depth = 1;
let i = openBrace + 1;
while (i < src.length && depth > 0) {
const ch = src[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
assert.ok(depth === 0, `unbalanced braces after '${signature}'`);
return src.slice(start, i);
}
// ── 2D highway (static/highway.js) ────────────────────────────────────────
test('2D palette arrays are mutable (let) with frozen DEFAULT_* originals', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(src, /const\s+DEFAULT_STRING_COLORS\s*=/, 'DEFAULT_STRING_COLORS must exist for reset');
assert.match(src, /const\s+DEFAULT_STRING_DIM\s*=/, 'DEFAULT_STRING_DIM must exist for reset');
assert.match(src, /const\s+DEFAULT_STRING_BRIGHT\s*=/, 'DEFAULT_STRING_BRIGHT must exist for reset');
assert.match(src, /let\s+STRING_COLORS\s*=\s*DEFAULT_STRING_COLORS\.slice\(\)/, 'STRING_COLORS must be a mutable copy of the defaults');
assert.match(src, /let\s+STRING_DIM\s*=\s*DEFAULT_STRING_DIM\.slice\(\)/, 'STRING_DIM must be a mutable copy of the defaults');
assert.match(src, /let\s+STRING_BRIGHT\s*=\s*DEFAULT_STRING_BRIGHT\.slice\(\)/, 'STRING_BRIGHT must be a mutable copy of the defaults');
});
test('2D public API exposes getStringColors / setStringColors', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(src, /getStringColors\s*\(\s*\)\s*\{\s*return\s+STRING_COLORS\.slice\(\)/, 'getStringColors must return a copy');
const fn = extractBlock(src, 'setStringColors(arr)');
// Each provided index sets base + derived dim/bright; missing → default.
assert.match(fn, /STRING_COLORS\[i\]\s*=\s*base/, 'setStringColors must set the base color');
assert.match(fn, /STRING_DIM\[i\]\s*=\s*_darken\(/, 'setStringColors must derive the dim variant');
assert.match(fn, /STRING_BRIGHT\[i\]\s*=\s*_lighten\(/, 'setStringColors must derive the bright variant');
assert.match(fn, /STRING_COLORS\[i\]\s*=\s*DEFAULT_STRING_COLORS\[i\]/, 'setStringColors must restore defaults for missing/invalid indices');
});
// ── 3D highway (plugins/highway_3d/screen.js) ─────────────────────────────
test('3D adds a custom palette path + h3dBgSetStringColors setter', () => {
const src = fs.readFileSync(highway3dJs, 'utf8');
assert.match(src, /window\.h3dBgSetStringColors\s*=/, 'window.h3dBgSetStringColors must be defined');
assert.match(src, /_bgWriteGlobal\('customColors'/, 'setter must persist customColors');
assert.match(src, /_bgWriteGlobal\('palette',\s*'custom'\)/, "setter must flip palette to 'custom'");
// 'custom' must survive palette coercion (else it gets reset to default).
assert.match(src, /key === 'palette'\)\s*return\s*\(PALETTE_IDS\.includes\(val\)\s*\|\|\s*val === 'custom'\)/, "palette coercion must accept 'custom'");
// _bgLoadSettings resolves 'custom' into the in-place _customPalette and
// forces a retint on content change via the signature guard.
assert.match(src, /newPaletteId === 'custom'/, '_bgLoadSettings must branch on custom');
assert.match(src, /_bgPaletteSig/, 'a palette content signature must guard in-place custom edits');
});
test('3D gem-body gradients follow the active palette (not hardcoded)', () => {
const src = fs.readFileSync(highway3dJs, 'utf8');
// The gem bodies (strings 0..5) are a baked per-vertex gradient; a custom
// palette must recolor them, else gems/sustain/vibrato heads stay stock.
assert.match(src, /function _recolorGemGradients\(\)/, '_recolorGemGradients must exist');
const fn = extractBlock(src, 'function _recolorGemGradients()');
assert.match(fn, /isCustom\s*&&\s*base !== PALETTES\.default\[s\]/, 'custom slots must derive stops from the base color');
assert.match(fn, /_lightenInt\(base/, 'derived top highlight via _lightenInt');
assert.match(fn, /_darkenInt\(base/, 'derived bottom shade via _darkenInt');
assert.match(fn, /colAttr\.needsUpdate = true/, 'must flag the color attribute dirty');
// Wired into both the build path and the live palette-change path.
const apply = extractBlock(src, 'function _applyPaletteToMaterials()');
assert.match(apply, /_recolorGemGradients\(\)/, '_applyPaletteToMaterials must recolor gems on palette change');
});
// ── Core color manager (static/app.js) ────────────────────────────────────
test('app.js color manager name-maps to both highways, with identity no-op + builtin guard', () => {
const src = fs.readFileSync(appJs, 'utf8');
const fn = extractBlock(src, 'function reapplyHighwayStringColors()');
assert.match(fn, /_hwcMappingIsIdentity\(sc, isBass\)/, 'must short-circuit the identity (≤6-string / bass default) case');
assert.match(fn, /h3d_bg_palette'\) !== 'default'\) window\.h3dBgSetPalette\?\.\('default'\)/, 'identity+default must put the 3D back on its default palette');
assert.match(fn, /_hwcEffectiveIndexColors\(_hwcMergedSlotColors\(\), sc, isBass\)/, 'must translate merged (default+custom) slots → per-index colors');
assert.match(fn, /window\.highway\?\.setStringColors\?\.\(eff\)/, 'must drive the 2D highway with translated colors');
assert.match(fn, /window\.h3dBgSetStringColors\?\.\(eff\)/, 'must always drive the 3D highway (palette picker removed)');
// identity = guitar ≤6 strings and 4-string bass; 7/8-string guitar and
// 5/6-string bass remap defaults (they prepend lower strings).
const idfn = extractBlock(src, 'function _hwcMappingIsIdentity(sc, isBass)');
assert.match(idfn, /return isBass \? sc <= 4 : sc <= 6/, 'identity must be 4-string bass / ≤6-string guitar');
// Re-apply on song load (string count can change the slot→index mapping).
assert.match(src, /window\.slopsmith\.on\('viz:renderer:ready', reapplyHighwayStringColors\)/, 'must re-apply when a viz renderer becomes ready');
assert.match(src, /window\.slopsmith\.on\('song:loaded', reapplyHighwayStringColors\)/, 'must re-apply on song load');
});
// ── Executable: dim/bright derivation math ────────────────────────────────
function loadColorMath() {
const src = fs.readFileSync(highwayJs, 'utf8');
const snippet = [
extractBlock(src, 'function _clampByte(n)'),
extractBlock(src, 'function _parseHex(hex)'),
extractBlock(src, 'function _toHex(r, g, b)'),
extractBlock(src, 'function _darken(hex, factor)'),
extractBlock(src, 'function _lighten(hex, t)'),
'return { _darken, _lighten, _parseHex };',
].join('\n');
return new Function('Math', snippet)(Math);
}
test('_darken(0.40) reproduces the default DIM band for known colors', () => {
const { _darken } = loadColorMath();
// 204*0.40 = 81.6 → 82 = 0x52; matches DEFAULT_STRING_DIM[0] (#520000).
assert.equal(_darken('#cc0000', 0.40), '#520000');
// Green low E: 204→0x52, 102→0x29; matches DEFAULT_STRING_DIM[4] (#005229).
assert.equal(_darken('#00cc66', 0.40), '#005229');
});
test('_lighten(0.30) produces a valid, brighter hex', () => {
const { _lighten, _parseHex } = loadColorMath();
const out = _lighten('#0066cc', 0.30);
assert.match(out, /^#[0-9a-f]{6}$/, 'lighten must yield a #rrggbb string');
const a = _parseHex('#0066cc'), b = _parseHex(out);
assert.ok(b.r >= a.r && b.g >= a.g && b.b >= a.b, 'each channel must be >= the base');
assert.ok(b.r + b.g + b.b > a.r + a.g + a.b, 'result must be overall brighter');
});
test('color helpers reject malformed input gracefully', () => {
const { _darken } = loadColorMath();
assert.equal(_darken('not-a-color', 0.4), 'not-a-color', 'invalid hex passes through unchanged');
});
// ── Executable: slot↔index translation + share-code codec ─────────────────
function loadManager() {
const src = fs.readFileSync(appJs, 'utf8');
const constLines = [
"const HWC_HEX_RE = /^#[0-9a-fA-F]{6}$/;",
"const HWC_SLOT_KEYS = ['highE','B','G','D','A','lowE','low7','low8'];",
].join('\n');
const snippet = [
constLines,
extractBlock(src, 'function _hwcSlotKeysForChart(sc, isBass)'),
extractBlock(src, 'function _hwcMappingIsIdentity(sc, isBass)'),
extractBlock(src, 'function _hwcNormalize(slotMap)'),
extractBlock(src, 'function encodeHighwayColorShare(name, slotMap)'),
extractBlock(src, 'function decodeHighwayColorShare(code)'),
'return { _hwcSlotKeysForChart, _hwcMappingIsIdentity, _hwcNormalize, encodeHighwayColorShare, decodeHighwayColorShare };',
].join('\n');
const ctx = { btoa, atob, escape, unescape, encodeURIComponent, decodeURIComponent, JSON, Math };
return new Function(...Object.keys(ctx), snippet)(...Object.values(ctx));
}
test('translation table keeps Low E stable across string counts', () => {
const { _hwcSlotKeysForChart } = loadManager();
// 6-string guitar: index 0 = Low E.
assert.equal(_hwcSlotKeysForChart(6, false)[0], 'lowE');
// 7-string guitar: index 0 = Low B, index 1 = Low E (Low E keeps its slot).
assert.deepEqual(_hwcSlotKeysForChart(7, false).slice(0, 2), ['low7', 'lowE']);
// 8-string: index 0 = Low F#, index 2 = Low E.
assert.equal(_hwcSlotKeysForChart(8, false)[2], 'lowE');
// 4-string bass: index 0 = Low E (shares the low strings with guitar).
assert.deepEqual(_hwcSlotKeysForChart(4, true), ['lowE', 'A', 'D', 'G']);
// 5-string bass: index 0 = Low B, then Low E A D G.
assert.deepEqual(_hwcSlotKeysForChart(5, true), ['low7', 'lowE', 'A', 'D', 'G']);
// High E is the top guitar slot for 6/7/8-string.
for (const sc of [6, 7, 8]) {
const keys = _hwcSlotKeysForChart(sc, false);
assert.equal(keys[keys.length - 1], 'highE', `${sc}-string top = High E`);
}
});
test('identity mapping = 4-string bass and ≤6-string guitar only', () => {
const { _hwcMappingIsIdentity, _hwcSlotKeysForChart } = loadManager();
// Identity cases: name order == index order (so defaults stay stock).
for (const [sc, bass] of [[6, false], [4, false], [4, true], [1, false]]) {
assert.ok(_hwcMappingIsIdentity(sc, bass), `${sc}/${bass ? 'bass' : 'gtr'} should be identity`);
assert.equal(_hwcSlotKeysForChart(sc, bass)[0], 'lowE', 'identity charts start at Low E');
}
// Non-identity: extended-range guitar AND bass prepend lower strings.
for (const [sc, bass] of [[7, false], [8, false], [5, true], [6, true]]) {
assert.ok(!_hwcMappingIsIdentity(sc, bass), `${sc}/${bass ? 'bass' : 'gtr'} should remap`);
assert.notEqual(_hwcSlotKeysForChart(sc, bass)[0], 'lowE', 'extended charts start below Low E');
}
});
test('_hwcNormalize keeps only valid named slots', () => {
const { _hwcNormalize } = loadManager();
const out = _hwcNormalize({ lowE: '#ABCDEF', A: 'garbage', B: '#123', highE: '#00ff00', bogus: '#111111' });
assert.equal(out.lowE, '#abcdef', 'valid hex lowercased');
assert.equal(out.highE, '#00ff00');
assert.ok(!('A' in out), 'non-hex dropped');
assert.ok(!('B' in out), '3-digit hex rejected by strict regex');
assert.ok(!('bogus' in out), 'unknown slot dropped');
});
test('share code round-trips name + named slot colors', () => {
const { encodeHighwayColorShare, decodeHighwayColorShare, _hwcNormalize } = loadManager();
const colors = { lowE: '#ff0000', A: '#00ff00', highE: '#0000ff' };
const code = encodeHighwayColorShare('Neon Test', colors);
assert.match(code, /^SLOPHWY2\./, 'code must carry the versioned prefix');
assert.ok(!/[+/=]/.test(code), 'code must be base64url (no +, /, =)');
const out = decodeHighwayColorShare(code);
assert.equal(out.name, 'Neon Test');
assert.deepEqual(out.colors, _hwcNormalize(colors));
});
test('decodeHighwayColorShare returns null on garbage / wrong shape', () => {
const { decodeHighwayColorShare, encodeHighwayColorShare } = loadManager();
assert.equal(decodeHighwayColorShare('not a real code'), null);
assert.equal(decodeHighwayColorShare(''), null);
assert.equal(decodeHighwayColorShare(null), null);
// A legacy array-shaped payload (old index-based format) is rejected.
const arrayPayload = 'SLOPHWY2.' + btoa(JSON.stringify({ n: 'x', c: ['#ff0000'] })).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
assert.equal(decodeHighwayColorShare(arrayPayload), null, 'array c is rejected (named-map only)');
// A non-v2 prefix is rejected even with an otherwise-valid v2-shaped payload.
const v1ish = 'SLOPHWY1.' + btoa(JSON.stringify({ n: 'x', c: { lowE: '#ff0000' } })).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
assert.equal(decodeHighwayColorShare(v1ish), null, 'non-v2 prefix is rejected');
});
+192
View File
@@ -0,0 +1,192 @@
// Source-level guards for the visibility-aware rAF skip and the
// highway:visibility event (slopsmith#246). The createHighway closure
// owns the canvas + WebGL context lifecycle that's too heavy to
// reproduce in a vm sandbox — these checks lock in the wiring instead.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');
const highway3dJs = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
// Brace-balanced extraction so a future method that grows guards or
// nested blocks doesn't get truncated by a naive `[^}]*\}` regex.
function extractBlock(src, signature) {
const start = src.indexOf(signature);
assert.ok(start !== -1, `signature '${signature}' not found`);
const openBrace = src.indexOf('{', start);
assert.ok(openBrace !== -1, `opening brace after '${signature}' not found`);
let depth = 1;
let i = openBrace + 1;
while (i < src.length && depth > 0) {
const ch = src[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
assert.ok(depth === 0, `unbalanced braces after '${signature}'`);
return src.slice(start, i);
}
test('highway declares visibility state (_visibleOverride + _lastVisible)', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(src, /let\s+_visibleOverride\s*=\s*null/, 'missing _visibleOverride (override sentinel)');
assert.match(src, /let\s+_lastVisible\s*=\s*null/, 'missing _lastVisible (last-emitted state)');
});
test('_isHighwayVisible respects _visibleOverride and falls back to offsetParent', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const fn = extractBlock(src, 'function _isHighwayVisible()');
assert.match(fn, /_visibleOverride\s*!==\s*null/, 'must check the override before the DOM');
assert.match(fn, /canvas\.offsetParent\s*!==\s*null/, 'DOM fallback must use offsetParent !== null');
});
test('_emitVisibilityIfChanged is transition-only (no per-frame spam)', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const fn = extractBlock(src, 'function _emitVisibilityIfChanged()');
// Must short-circuit when the current state equals the cached one.
assert.match(fn, /v\s*===\s*_lastVisible/, 'must compare current vs _lastVisible and bail when equal');
// Must update the cache and emit the event with the documented payload shape.
assert.match(fn, /_lastVisible\s*=\s*v/, 'must update _lastVisible after a transition');
assert.match(
fn,
/window\.slopsmith\.emit\(\s*['"]highway:visibility['"][\s\S]*?visible:\s*v[\s\S]*?canvas/,
'must emit highway:visibility with { visible, canvas }',
);
});
test('rAF draw() loop calls _emitVisibilityIfChanged and skips when hidden', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const fn = extractBlock(src, 'function draw()');
assert.match(fn, /_emitVisibilityIfChanged\(\)/, 'rAF draw() must call _emitVisibilityIfChanged each tick');
// Ordering: emit → skip-when-hidden → ready gate → renderer.draw.
// The emit must run BEFORE the !ready bail so visibility
// transitions during loading/reconnect windows still propagate.
const emitIdx = fn.search(/_emitVisibilityIfChanged\(\)/);
const skipIdx = fn.search(/if\s*\(\s*!_rendering\s*\)\s*return/);
const readyIdx = fn.search(/if\s*\(\s*!ready\s*\)\s*return/);
const drawIdx = fn.search(/_renderer\.draw\(/);
assert.ok(emitIdx !== -1 && skipIdx !== -1 && readyIdx !== -1 && drawIdx !== -1, 'all four landmarks must be present');
assert.ok(emitIdx < readyIdx, 'emit must run BEFORE the !ready gate (transitions during loading must still fire)');
assert.ok(skipIdx < readyIdx, 'skip-when-hidden must short-circuit before the ready gate');
assert.ok(readyIdx < drawIdx, 'ready gate must run before renderer.draw');
});
test('draw() keeps an active custom renderer painting through an override-hide (slopsmith#819)', () => {
// The `_rendering` decision must distinguish a renderer-set override-hide
// (setVisible(false) — canvas occluded by an opaque overlay, but the
// active custom renderer still paints its own surface, e.g. Tab View's
// DOM) from genuine off-screen (offsetParent === null). An active custom
// renderer keeps getting draw() ONLY while still in layout; the default
// 2D renderer and the genuine off-screen case still bail.
const src = fs.readFileSync(highwayJs, 'utf8');
const fn = extractBlock(src, 'function draw()');
// Single render decision drives both the perf-HUD reset and the gate.
assert.match(fn, /let\s+_rendering\s*=\s*_lastVisible/, 'must derive a single _rendering decision from _lastVisible');
// Assert the exact boolean RELATIONSHIP, not just the tokens (CodeRabbit):
// the exemption must AND together override-hide, an active custom renderer,
// and the canvas still in layout. A weakened guard (e.g. `||`, or a dropped
// offsetParent clause) must fail this — that's the regression being fixed.
assert.match(
fn,
/!_rendering\s*&&\s*_visibleOverride\s*===\s*false\s*&&\s*_renderer\s*!==\s*_defaultRenderer\s*&&\s*canvas\s*&&\s*canvas\.offsetParent\s*!==\s*null/,
'exemption must AND override-hide + active custom renderer + canvas-in-layout (genuine off-screen still pauses, #246)',
);
// Both the HUD reset and the gate key off _rendering, not _lastVisible,
// so the HUD doesn\'t churn while the custom renderer is actually drawing.
assert.match(fn, /_perfHud\s*&&\s*\(\s*!_rendering/, 'perf-HUD reset must key off _rendering, not _lastVisible');
assert.match(fn, /if\s*\(\s*!_rendering\s*\)\s*return/, 'the draw gate must bail on !_rendering');
});
test('api.isVisible() exposes a snapshot for late subscribers', () => {
// The event is transition-only, so renderers that bind after the
// initial frame need a way to sync. isVisible() returns the same
// value _isHighwayVisible() would return on the next tick.
const src = fs.readFileSync(highwayJs, 'utf8');
const fn = extractBlock(src, 'isVisible()');
assert.match(fn, /return\s+_isHighwayVisible\(\)/, 'isVisible() must return _isHighwayVisible()');
});
test('canvas-replace resets _lastVisible so the new canvas re-emits', () => {
// _lastVisible is per-canvas-lifecycle: a fresh canvas could
// be in a different displayed state than the one it replaced.
// Without the reset, _emitVisibilityIfChanged would suppress
// the first transition on the new canvas.
const src = fs.readFileSync(highwayJs, 'utf8');
const fn = extractBlock(src, 'function _replaceCanvas(newType)');
assert.match(fn, /_lastVisible\s*=\s*null/, '_replaceCanvas must reset _lastVisible so the new canvas re-emits');
});
test('api.setVisible accepts bool / null and re-emits inline', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const fn = extractBlock(src, 'setVisible(v)');
// null/undefined → clears the override
assert.match(fn, /v\s*===\s*null\s*\|\|\s*v\s*===\s*undefined/, 'null/undefined must clear the override');
// Non-null → coerce to boolean
assert.match(fn, /_visibleOverride\s*=.*\?\s*null\s*:\s*!!v/, 'non-null must coerce to !!v');
// Re-evaluate immediately so the transition fires on the call, not the next rAF.
assert.match(fn, /_emitVisibilityIfChanged\(\)/, 'setVisible must call _emitVisibilityIfChanged inline');
});
test('3D Highway subscribes to highway:visibility and toggles wrap on hide', () => {
const src = fs.readFileSync(highway3dJs, 'utf8');
// Scope to lifecycle blocks so unrelated / commented mentions
// elsewhere in screen.js can't cause false positives.
const initSceneBlock = extractBlock(src, 'function initScene()');
const teardownBlock = extractBlock(src, 'function teardown()');
// Listener registration with the documented event name (in init).
assert.match(
initSceneBlock,
/window\.slopsmith\.on\(\s*['"]highway:visibility['"]/,
'initScene must subscribe to highway:visibility',
);
// Handler filters by canvas identity so splitscreen panels don't
// hide each other's overlays — every instance receives every event
// on the shared slopsmith bus, so this gate is essential.
assert.match(
initSceneBlock,
/e\.detail\.canvas\s*!==\s*highwayCanvas/,
'handler must filter on event.detail.canvas !== highwayCanvas (splitscreen-safe)',
);
// Handler toggles wrap.style.display based on visible === false.
assert.match(
initSceneBlock,
/wrap\.style\.display\s*=\s*v\s*===\s*false\s*\?\s*['"]none['"]\s*:\s*['"]['"]/,
'handler must hide the wrap when visible === false',
);
// Initial-sync on bind so renderers that mount while the canvas
// is already hidden (e.g. plugin loaded mid-splitscreen) don't
// leave the wrap stuck in the wrong state.
assert.match(
initSceneBlock,
/highwayCanvas\.offsetParent\s*!==\s*null/,
'initScene must compute initial visibility from local highwayCanvas (splitscreen-safe)',
);
// Subscribes to highway:canvas-replaced so the identity gate
// (event.detail.canvas === highwayCanvas) survives core's
// context-type-driven canvas swap. Per CLAUDE.md plugin contract.
assert.match(
initSceneBlock,
/window\.slopsmith\.on\(\s*['"]highway:canvas-replaced['"]/,
'initScene must track canvas swaps so the visibility gate keeps matching',
);
assert.match(
initSceneBlock,
/highwayCanvas\s*=\s*e\.detail\.newCanvas/,
'canvas-replaced handler must update the local highwayCanvas reference',
);
// Teardown unbinds both listeners.
assert.match(
teardownBlock,
/window\.slopsmith\.off\(\s*['"]highway:visibility['"]/,
'teardown must unbind highway:visibility',
);
assert.match(
teardownBlock,
/window\.slopsmith\.off\(\s*['"]highway:canvas-replaced['"]/,
'teardown must unbind highway:canvas-replaced',
);
});
+365
View File
@@ -0,0 +1,365 @@
// Behavioral tests for the JUCE engine-reroute watcher in static/app.js.
//
// The watcher (an IIFE, `_installJuceEngineRoutingWatcher`) migrates a loaded
// song between the HTML5 <audio> element and the native JUCE backing transport
// whenever the audio engine is started/stopped after song-load. These tests
// extract that IIFE from source and exercise `window._reevaluateJuceRouting`
// against fakes, covering: the happy-path HTML5->JUCE and JUCE->HTML5 switches,
// the JUCE hard-reject memoisation, transient-failure retry, and the
// stale-song-snapshot abort.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
// Brace-balanced extraction of the watcher IIFE, starting at its `(function`
// and ending after the matching `})();`.
function extractWatcherIIFE(src) {
const marker = '(function _installJuceEngineRoutingWatcher() {';
const start = src.indexOf(marker);
assert.ok(start !== -1, 'watcher IIFE not found in app.js');
const openBrace = src.indexOf('{', start);
let depth = 1;
let i = openBrace + 1;
while (i < src.length && depth > 0) {
const ch = src[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
assert.ok(depth === 0, 'unbalanced braces in watcher IIFE');
// Include the trailing `)();` invocation.
const tail = src.slice(i, i + 5);
assert.match(tail, /^\)\(\)/, 'watcher IIFE not immediately invoked');
return src.slice(start, i) + ')();';
}
// Build a sandbox with fakes and run the watcher IIFE inside it. Returns the
// sandbox so tests can drive window._reevaluateJuceRouting and inspect state.
function makeSandbox({ isAudioRunning, loadBackingTrack }) {
const calls = { loadBackingTrack: [], jucePlay: 0, jucePause: 0, audioPlay: 0 };
const audio = {
currentTime: 12.5,
src: 'blob:original',
dataset: {},
readyState: 2,
pause() {},
play() { calls.audioPlay++; return Promise.resolve(); },
load() {},
addEventListener() {},
removeEventListener() {},
};
const jucePlayer = {
_dur: 0, _pos: 0, _pollAt: 0,
currentTime: 30,
play() { calls.jucePlay++; return Promise.resolve(true); },
pause() { calls.jucePause++; return Promise.resolve(); },
};
const juceApi = {
isAudioRunning: () => Promise.resolve(isAudioRunning()),
loadBackingTrack: (p) => { calls.loadBackingTrack.push(p); return Promise.resolve(loadBackingTrack()); },
getBackingDuration: () => Promise.resolve(180),
seekBacking: () => Promise.resolve(),
startBacking: () => Promise.resolve(),
stopBacking: () => Promise.resolve(),
};
const sandbox = {
console: { log() {}, warn() {}, error() {} },
performance: { now: () => 1000 },
setInterval: () => 0, // disable the live poll; tests call directly
setTimeout: (fn) => { fn(); return 0; },
clearInterval: () => {},
clearTimeout: () => {},
fetch: () => Promise.resolve({
ok: true,
json: () => Promise.resolve({ path: '/local/song.ogg' }),
}),
document: { hidden: false },
isPlaying: true,
audio,
jucePlayer,
__calls: calls,
};
sandbox.window = sandbox;
sandbox.window.jucePlayer = jucePlayer;
sandbox.window.slopsmithDesktop = { audio: juceApi };
sandbox.window.slopsmith = { audio: {} };
const src = fs.readFileSync(APP_JS, 'utf8');
const iife = extractWatcherIIFE(src);
vm.createContext(sandbox);
vm.runInContext(iife, sandbox);
return sandbox;
}
test('watcher IIFE exposes _reevaluateJuceRouting', () => {
const sb = makeSandbox({ isAudioRunning: () => false, loadBackingTrack: () => true });
assert.equal(typeof sb.window._reevaluateJuceRouting, 'function');
});
test('engine running while on HTML5 → migrates the song to JUCE', async () => {
const sb = makeSandbox({ isAudioRunning: () => true, loadBackingTrack: () => true });
sb.window._juceMode = false;
sb.window._currentSongAudio = { url: '/audio/song.ogg', juceEligible: true };
await sb.window._reevaluateJuceRouting();
assert.equal(sb.window._juceMode, true, 'should have switched into JUCE mode');
assert.equal(sb.window._juceAudioUrl, '/audio/song.ogg');
assert.equal(sb.__calls.loadBackingTrack.length, 1, 'loadBackingTrack called once');
assert.equal(sb.__calls.jucePlay, 1, 'jucePlayer.play called (song was playing)');
});
test('engine stopped while on JUCE → migrates the song back to HTML5', async () => {
const sb = makeSandbox({ isAudioRunning: () => false, loadBackingTrack: () => true });
sb.window._juceMode = true;
sb.window._juceAudioUrl = '/audio/song.ogg';
sb.window._currentSongAudio = { url: '/audio/song.ogg', juceEligible: true };
await sb.window._reevaluateJuceRouting();
assert.equal(sb.window._juceMode, false, 'should have switched out of JUCE mode');
assert.equal(sb.window._juceAudioUrl, null);
assert.equal(sb.audio.src, '/audio/song.ogg', 'HTML5 element re-pointed at the song');
});
test('routing already consistent → no-op', async () => {
const sb = makeSandbox({ isAudioRunning: () => false, loadBackingTrack: () => true });
sb.window._juceMode = false; // engine off, already HTML5
sb.window._currentSongAudio = { url: '/audio/song.ogg', juceEligible: true };
await sb.window._reevaluateJuceRouting();
assert.equal(sb.__calls.loadBackingTrack.length, 0, 'no switch attempted');
});
test('non-JUCE-eligible song (sloppak stems) is never rerouted', async () => {
const sb = makeSandbox({ isAudioRunning: () => true, loadBackingTrack: () => true });
sb.window._juceMode = false;
sb.window._currentSongAudio = { url: '/api/sloppak/stem.ogg', juceEligible: false };
await sb.window._reevaluateJuceRouting();
assert.equal(sb.window._juceMode, false, 'stems stay on HTML5');
assert.equal(sb.__calls.loadBackingTrack.length, 0);
});
test('JUCE hard-reject is memoised → not retried on the next poll', async () => {
const sb = makeSandbox({ isAudioRunning: () => true, loadBackingTrack: () => false });
sb.window._juceMode = false;
sb.window._currentSongAudio = { url: '/audio/song.ogg', juceEligible: true };
await sb.window._reevaluateJuceRouting();
assert.equal(sb.window._juceMode, false, 'stayed on HTML5 after reject');
assert.equal(sb.__calls.loadBackingTrack.length, 1);
// Second poll with the same song must NOT call loadBackingTrack again.
await sb.window._reevaluateJuceRouting();
assert.equal(sb.__calls.loadBackingTrack.length, 1, 'rejected URL not retried');
});
test('transient failure is NOT memoised → retried on the next poll', async () => {
const sb = makeSandbox({ isAudioRunning: () => true, loadBackingTrack: () => true });
// First attempt: fetch rejects (transient). Then make fetch succeed.
let firstCall = true;
sb.fetch = () => {
if (firstCall) { firstCall = false; return Promise.reject(new Error('network blip')); }
return Promise.resolve({ ok: true, json: () => Promise.resolve({ path: '/local/song.ogg' }) });
};
sb.window._juceMode = false;
sb.window._currentSongAudio = { url: '/audio/song.ogg', juceEligible: true };
await sb.window._reevaluateJuceRouting();
assert.equal(sb.window._juceMode, false, 'transient failure left song on HTML5');
// Next poll: transient cause cleared → switch should now succeed.
await sb.window._reevaluateJuceRouting();
assert.equal(sb.window._juceMode, true, 'transient failure was retried and succeeded');
});
test('jucePlayer.play() failure is transient → NOT memoised, retried next poll', async () => {
const sb = makeSandbox({ isAudioRunning: () => true, loadBackingTrack: () => true });
// First switch: JUCE transport start fails (play returns false). Second: succeeds.
let firstPlay = true;
sb.jucePlayer.play = () => {
sb.__calls.jucePlay++;
if (firstPlay) { firstPlay = false; return Promise.resolve(false); }
return Promise.resolve(true);
};
sb.window._juceMode = false;
sb.window._currentSongAudio = { url: '/audio/song.ogg', juceEligible: true };
await sb.window._reevaluateJuceRouting();
assert.equal(sb.window._juceMode, false, 'play() failure left song on HTML5');
// A play() failure must NOT be memoised as a hard reject — retry succeeds.
await sb.window._reevaluateJuceRouting();
assert.equal(sb.window._juceMode, true, 'transport-start failure was retried and succeeded');
});
test('stale-abort during a swap-then-restore is NOT memoised as a JUCE reject', async () => {
// _currentSongAudio is swapped to a different object and then back to the
// *same URL* (a new object) mid-flight. The post-await staleness check
// would pass, but the switch already aborted as 'stale' — and a 'stale'
// abort must never poison _rerouteRejectedUrl. A later poll must still
// be able to route the track.
const sb = makeSandbox({ isAudioRunning: () => true, loadBackingTrack: () => true });
const snapA = { url: '/audio/song.ogg', juceEligible: true };
sb.window._juceMode = false;
sb.window._currentSongAudio = snapA;
let firstLoad = true;
sb.window.slopsmithDesktop.audio.loadBackingTrack = (p) => {
sb.__calls.loadBackingTrack.push(p);
if (firstLoad) {
firstLoad = false;
// Swap away (makes the in-flight switch stale), then restore a NEW
// object with the same URL before _reevaluateJuceRouting's later check.
sb.window._currentSongAudio = { url: '/audio/other.ogg', juceEligible: true };
sb.window._currentSongAudio = { url: '/audio/song.ogg', juceEligible: true };
}
return Promise.resolve(true);
};
await sb.window._reevaluateJuceRouting(); // aborts 'stale' — must not memoise
// A fresh poll against the current song must still attempt the switch.
await sb.window._reevaluateJuceRouting();
assert.equal(sb.window._juceMode, true, 'track was not poisoned by the stale abort');
});
test('deferred JUCE→HTML5 loadedmetadata callback is a no-op once the song changed', async () => {
const sb = makeSandbox({ isAudioRunning: () => false, loadBackingTrack: () => true });
// Element not ready: the resume runs from a loadedmetadata listener.
sb.audio.readyState = 0;
let metadataCb = null;
sb.audio.addEventListener = (ev, cb) => { if (ev === 'loadedmetadata') metadataCb = cb; };
let seekedTo = null;
Object.defineProperty(sb.audio, 'currentTime', {
get() { return 0; },
set(v) { seekedTo = v; },
configurable: true,
});
sb.window._juceMode = true;
sb.window._juceAudioUrl = '/audio/song-a.ogg';
const snapshot = { url: '/audio/song-a.ogg', juceEligible: true };
sb.window._currentSongAudio = snapshot;
await sb.window._reevaluateJuceRouting(); // switches to HTML5, arms listener
assert.ok(typeof metadataCb === 'function', 'loadedmetadata listener was registered');
// Song changes before metadata arrives, then the stale callback fires.
sb.window._currentSongAudio = { url: '/audio/song-b.ogg', juceEligible: true };
metadataCb();
assert.equal(seekedTo, null, 'stale callback must not seek the newly loaded song');
});
test('reroute sets window._juceRerouteInProgress during the switch and clears it after', async () => {
// The <audio> play/pause listeners (outside this IIFE) suppress their
// song:play / song:pause emissions while this flag is truthy, keeping a
// transparent migration from desyncing plugin play-state. Verify the
// watcher raises the flag during the switch and releases it afterwards.
const sb = makeSandbox({ isAudioRunning: () => true, loadBackingTrack: () => true });
let flagSeenDuringPause = false;
sb.audio.pause = () => { flagSeenDuringPause = !!sb.window._juceRerouteInProgress; };
sb.window._juceMode = false;
sb.window._currentSongAudio = { url: '/audio/song.ogg', juceEligible: true };
await sb.window._reevaluateJuceRouting();
assert.equal(flagSeenDuringPause, true, 'flag must be set when audio.pause() runs');
// setTimeout is patched to run synchronously, so the deferred release has
// already happened by here.
assert.equal(sb.window._juceRerouteInProgress, 0, 'flag refcount released after the switch');
});
test('JUCE→HTML5 reroute releases the suppression refcount even if metadata never arrives', async () => {
// If the new HTML5 source never reaches loadedmetadata (bad URL / network
// error), the suppression refcount must still be released — otherwise
// song:play / song:pause stay silenced forever. The backstop timeout (and
// 'error' listener) guarantee release. setTimeout is patched to run
// synchronously here, so the backstop fires immediately.
const sb = makeSandbox({ isAudioRunning: () => false, loadBackingTrack: () => true });
sb.audio.readyState = 0; // metadata not ready → deferred path
sb.audio.addEventListener = () => {}; // loadedmetadata/error never fire
sb.window._juceMode = true;
sb.window._juceAudioUrl = '/audio/song.ogg';
sb.window._currentSongAudio = { url: '/audio/song.ogg', juceEligible: true };
await sb.window._reevaluateJuceRouting();
assert.equal(sb.window._juceRerouteInProgress, 0,
'suppression refcount must not leak when metadata never arrives');
});
test('_rerouteInFlight guard blocks an overlapping invocation past the first await', async () => {
// The flag must be claimed synchronously before isAudioRunning() so a
// second poll tick during a slow IPC cannot run a concurrent switch.
let resolveRunning;
const sb = makeSandbox({
isAudioRunning: () => new Promise((r) => { resolveRunning = r; }),
loadBackingTrack: () => true,
});
sb.window._juceMode = false;
sb.window._currentSongAudio = { url: '/audio/song.ogg', juceEligible: true };
// First call: parks on the pending isAudioRunning() promise.
const first = sb.window._reevaluateJuceRouting();
// Second call while the first is still awaiting — must early-return.
await sb.window._reevaluateJuceRouting();
assert.equal(sb.__calls.loadBackingTrack.length, 0,
'overlapping invocation must not start a switch while one is in flight');
// Let the first finish.
resolveRunning(true);
await first;
assert.equal(sb.__calls.loadBackingTrack.length, 1, 'the first switch ran exactly once');
});
test('_clearJuceRerouteMemo lets a rejected URL be retried after song teardown', async () => {
const sb = makeSandbox({ isAudioRunning: () => true, loadBackingTrack: () => false });
sb.window._juceMode = false;
sb.window._currentSongAudio = { url: '/audio/song.ogg', juceEligible: true };
await sb.window._reevaluateJuceRouting(); // hard reject → URL memoised
assert.equal(sb.__calls.loadBackingTrack.length, 1);
// Without a clear, the same URL is skipped.
await sb.window._reevaluateJuceRouting();
assert.equal(sb.__calls.loadBackingTrack.length, 1, 'memoised URL skipped');
// Song teardown clears the memo; a fresh load of the same URL retries.
assert.equal(typeof sb.window._clearJuceRerouteMemo, 'function');
sb.window._clearJuceRerouteMemo();
sb.window._currentSongAudio = { url: '/audio/song.ogg', juceEligible: true };
await sb.window._reevaluateJuceRouting();
assert.equal(sb.__calls.loadBackingTrack.length, 2, 'cleared memo allows a fresh attempt');
});
test('song change mid-flight aborts the switch without mutating routing', async () => {
const sb = makeSandbox({ isAudioRunning: () => true, loadBackingTrack: () => true });
sb.window._juceMode = false;
const original = { url: '/audio/song-a.ogg', juceEligible: true };
sb.window._currentSongAudio = original;
// Swap the current song the moment loadBackingTrack is consulted, so the
// post-await staleness check sees a different _currentSongAudio identity.
sb.window.slopsmithDesktop.audio.loadBackingTrack = () => {
sb.window._currentSongAudio = { url: '/audio/song-b.ogg', juceEligible: true };
return Promise.resolve(true);
};
await sb.window._reevaluateJuceRouting();
assert.equal(sb.window._juceMode, false, 'stale switch must not commit JUCE mode');
assert.notEqual(sb.window._juceAudioUrl, '/audio/song-a.ogg', 'stale URL not committed');
});
+122
View File
@@ -0,0 +1,122 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { loadAudioSession } = require('./audio_session_test_harness');
test('active audio domains expose expected legacy shim metadata', () => {
const window = loadAudioSession();
const shims = window.slopsmith.capabilities.snapshotDiagnostics().compatibilityShims;
for (const shimId of ['audio-mix.fader-registry', 'audio-mix.song-volume', 'audio-mix.analyser', 'audio-input.legacy-source', 'audio-monitoring.audio-barrier', 'stems.master-volume', 'stems.private-state']) {
assert.equal(shims.some(shim => shim.shimId === shimId && shim.status === 'active'), true, shimId);
}
});
test('legacy bridge hit counts are attributed to canonical audio domains', () => {
const window = loadAudioSession();
const audioSession = window.slopsmith.audioSession;
audioSession.recordBridgeHit({ domain: 'audio-mix', bridgeId: 'audio-mix.analyser', legacySurface: 'HTMLAudioElement analyser tap', participantId: 'highway_3d' });
audioSession.recordBridgeHit({ domain: 'audio-input', bridgeId: 'audio-input.legacy-source', legacySurface: 'navigator.mediaDevices.getUserMedia', participantId: 'note_detect' });
audioSession.recordBridgeHit({ domain: 'audio-monitoring', bridgeId: 'audio-monitoring.audio-barrier', legacySurface: 'window.slopsmithAudioBarrier', participantId: 'note_detect' });
const shims = window.slopsmith.capabilities.snapshotDiagnostics().compatibilityShims;
assert.equal(shims.find(shim => shim.shimId === 'audio-mix.analyser').hitCount, 1);
assert.equal(shims.find(shim => shim.shimId === 'audio-input.legacy-source').capability, 'audio-input');
assert.equal(shims.find(shim => shim.shimId === 'audio-monitoring.audio-barrier').hitCount, 1);
});
test('native audio-mix participant suppresses matching legacy fader and records overshadowed bridge hit', async () => {
const { runBrowserScript, installMixerDom } = require('./audio_session_test_harness');
const window = loadAudioSession();
installMixerDom(window);
runBrowserScript(window, 'static/audio-mixer.js');
window.slopsmith.audioSession.startSession({ sessionId: 'main:test-song' });
window.slopsmith.audio.registerFader({
id: 'delay.wet',
label: 'Delay Wet Legacy',
min: 0,
max: 1,
step: 0.1,
defaultValue: 0.2,
logicalFaderKey: 'delay:wet',
getValue: () => 0.2,
setValue: () => {},
});
window.slopsmith.audioSession.registerMixParticipant({
participantId: 'plugin.delay.native',
ownerPluginId: 'delay',
label: 'Delay Wet',
kind: 'plugin',
sourceMode: 'native',
logicalFaderKey: 'delay:wet',
fader: { id: 'wet', label: 'Delay Wet', min: 0, max: 1, step: 0.1, defaultValue: 0.4, currentValue: 0.4 },
operations: ['fader.get-value', 'fader.set-value'],
});
const listed = await window.slopsmith.capabilities.dispatch({ capability: 'audio-mix', command: 'list-faders', source: 'test' });
const snapshot = window.slopsmith.audioSession.snapshot();
const legacy = snapshot.domains['audio-mix'].participants.find(participant => participant.participantId === 'fader.delay.wet');
assert.equal(listed.payload.faders.some(fader => fader.participantId === 'plugin.delay.native'), true);
assert.equal(listed.payload.faders.some(fader => fader.participantId === 'fader.delay.wet'), false);
assert.equal(legacy.supersededBy, 'plugin.delay.native');
assert.equal(snapshot.domains['audio-mix'].bridges.some(bridge => bridge.status === 'overshadowed' && bridge.participantId === 'fader.delay.wet'), true);
});
// Source-level guards for PR1 runtime compatibility-shim hit accounting.
// Broader app/player/audio domains are reserved for follow-up PRs, so this
// file checks plugin attribution helpers and that library now uses the native
// capability module instead of legacy shim accounting.
const ROOT = path.join(__dirname, '..', '..');
const APP_JS = path.join(ROOT, 'static', 'app.js');
const LIBRARY_JS = path.join(ROOT, 'static', 'capabilities', 'library.js');
function source(file) {
return fs.readFileSync(file, 'utf8');
}
function region(src, needle, length = 1200) {
const start = src.indexOf(needle);
assert.ok(start !== -1, `missing source needle: ${needle}`);
return src.slice(start, start + length);
}
test('plugin script hydration exposes the current plugin id for legacy registrations', () => {
const src = source(APP_JS);
const block = region(src, 'script.src = `/api/plugins/${plugin.id}/screen.js');
assert.match(block, /window\.slopsmith\._loadingPluginId\s*=\s*plugin\.id/);
assert.match(block, /delete\s+window\.slopsmith\._loadingPluginId/);
});
test('library providers route through native library capability', () => {
const src = source(APP_JS);
const librarySrc = source(LIBRARY_JS);
const loader = region(src, 'async function loadLibraryProviders', 1800);
const selector = region(src, 'async function setLibraryProvider(providerId, options = {})', 1600);
const sync = region(src, 'async function syncLibrarySong(providerId, songId', 1600);
assert.match(librarySrc, /capabilities\.registerOwner\(['"]library['"]/);
assert.match(librarySrc, /kind:\s*['"]provider-coordinator['"]/);
assert.match(librarySrc, /'library\.read': \['query-page', 'query-artists', 'query-stats', 'tuning-names'\]/);
assert.match(librarySrc, /window\.slopsmith\.libraryProviders\s*=\s*providerApi/);
assert.match(loader, /api\.refresh\(\{ restoreSaved \}\)/);
assert.match(selector, /capabilityApi\.command\(['"]library['"],\s*['"]select-provider['"]/);
assert.match(sync, /capabilityApi\.command\(['"]library['"],\s*['"]sync-song['"]/);
assert.doesNotMatch(src, /_recordLegacyLibraryProviderShim/);
assert.doesNotMatch(src, /_recordLegacyLibraryCommand/);
assert.doesNotMatch(librarySrc, /registerCompatibilityShim|recordLegacyHit/);
});
test('visualization renderer installs preserve plugin attribution', () => {
const src = source(APP_JS);
const tagger = region(src, 'function _tagVizRenderer(renderer, id)', 700);
const setViz = region(src, 'function setViz(id)', 3600);
const autoViz = region(src, 'function _autoMatchViz()', 5200);
assert.match(tagger, /renderer\.pluginId\s*=\s*id/);
assert.match(tagger, /renderer\.source\s*=\s*id/);
assert.match(setViz, /_installVizRenderer\(renderer,\s*id\)|_installVizRenderer\(venueRenderer,\s*'highway_3d'\)/);
assert.match(autoViz, /_installVizRenderer\(renderer,\s*id,\s*'auto-match'\)/);
});
+70
View File
@@ -0,0 +1,70 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert');
const reg = require('../../static/capabilities/library-card-actions.js');
function freshIds() { reg.snapshot().actions.forEach((a) => reg.unregister(a.id)); }
test('register + list returns applicable actions sorted by order', () => {
freshIds();
reg.register({ id: 'b', label: 'B', order: 20, run() {} });
reg.register({ id: 'a', label: 'A', order: 10, run() {} });
const ids = reg.list({ filename: 'x.psarc' }).map((a) => a.id);
assert.deepStrictEqual(ids, ['a', 'b']);
});
test('applies() filters out non-applicable actions', () => {
freshIds();
reg.register({ id: 'bassonly', label: 'Bass', applies: (s) => s.format === 'sloppak', run() {} });
assert.strictEqual(reg.list({ filename: 'x.psarc', format: 'psarc' }).length, 0);
assert.strictEqual(reg.list({ filename: 'y.sloppak', format: 'sloppak' }).length, 1);
});
test('enabled() reflected in the summary but action still listed', () => {
freshIds();
reg.register({ id: 'maybe', label: 'Maybe', enabled: (s) => !!s.allow, run() {} });
const off = reg.list({ filename: 'x' })[0];
assert.strictEqual(off.enabled, false);
const on = reg.list({ filename: 'x', allow: true })[0];
assert.strictEqual(on.enabled, true);
});
test('run() invokes the handler and reports handled', async () => {
freshIds();
let got = null;
reg.register({ id: 'go', label: 'Go', run: (song) => { got = song.filename; return 'done'; } });
const r = await reg.run('go', { filename: 'song.psarc' }, {});
assert.strictEqual(r.ok, true);
assert.strictEqual(r.outcome, 'handled');
assert.strictEqual(got, 'song.psarc');
});
test('run() of a disabled / non-applicable / unknown action does not throw', async () => {
freshIds();
reg.register({ id: 'dis', label: 'Dis', enabled: () => false, run() { throw new Error('should not run'); } });
assert.strictEqual((await reg.run('dis', {})).outcome, 'disabled');
assert.strictEqual((await reg.run('nope', {})).outcome, 'no-action');
});
test('run() surfaces handler errors as failed (no throw)', async () => {
freshIds();
reg.register({ id: 'boom', label: 'Boom', run() { throw new Error('kaboom'); } });
const r = await reg.run('boom', {});
assert.strictEqual(r.ok, false);
assert.strictEqual(r.outcome, 'failed');
});
test('unregister removes the action', () => {
freshIds();
const off = reg.register({ id: 'temp', label: 'T', run() {} });
assert.strictEqual(reg.list({}).length, 1);
off();
assert.strictEqual(reg.list({}).length, 0);
});
test('bad specs are rejected (no id / no run)', () => {
freshIds();
reg.register({ label: 'no id', run() {} });
reg.register({ id: 'no-run' });
assert.strictEqual(reg.list({}).length, 0);
});
+54
View File
@@ -0,0 +1,54 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const toneSource = require('../../static/v3/live-guitar-tone-source.js');
const INDEX_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
test('default live guitar tone source is internal', () => {
assert.equal(toneSource.DEFAULT, 'internal');
assert.equal(toneSource.normalize(undefined), 'internal');
assert.equal(toneSource.normalize(''), 'internal');
assert.equal(toneSource.normalize('bogus'), 'internal');
});
test('normalize accepts external and spark values', () => {
assert.equal(toneSource.normalize('external_hardware'), 'external_hardware');
assert.equal(toneSource.normalize('spark_control_x'), 'spark_control_x');
});
test('shouldSuppressMonitorMuteHint only for external modes', () => {
assert.equal(toneSource.shouldSuppressMonitorMuteHint('internal'), false);
assert.equal(toneSource.shouldSuppressMonitorMuteHint('external_hardware'), true);
assert.equal(toneSource.shouldSuppressMonitorMuteHint('spark_control_x'), true);
assert.equal(toneSource.shouldSuppressMonitorMuteHint('invalid'), false);
});
test('labels include internal, external, and spark options', () => {
assert.match(toneSource.LABELS.internal, /feed\[dB\]ack internal tone/i);
assert.match(toneSource.LABELS.external_hardware, /External amp/i);
assert.match(toneSource.LABELS.spark_control_x, /Spark LIVE/i);
});
test('settings UI exposes tone source select with all options', () => {
const html = fs.readFileSync(INDEX_HTML, 'utf8');
assert.match(html, /id="setting-live-guitar-tone-source"/);
assert.match(html, /value="internal"/);
assert.match(html, /value="external_hardware"/);
assert.match(html, /value="spark_control_x"/);
assert.match(html, /Live guitar tone source/);
assert.match(html, /won&rsquo;t warn that no internal amp tone is loaded/);
});
test('player audio rail exposes tone source select', () => {
const html = fs.readFileSync(INDEX_HTML, 'utf8');
assert.match(html, /id="player-live-guitar-tone-source"/);
});
test('live-guitar-tone-source script is loaded in v3 shell', () => {
const html = fs.readFileSync(INDEX_HTML, 'utf8');
assert.match(html, /live-guitar-tone-source\.js/);
});
+173
View File
@@ -0,0 +1,173 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const hud = require('../../static/v3/live-performance-hud.js');
const INDEX_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
test('accuracy percentage matches server formula', () => {
assert.equal(hud.accuracyPct(84, 16), 84);
assert.equal(hud.calculateLivePerformanceState({ hits: 84, misses: 16 }).accuracyPct, 84);
});
test('divide by zero returns null accuracy and idle state', () => {
assert.equal(hud.accuracyPct(0, 0), null);
const stats = hud.calculateLivePerformanceState({ hits: 0, misses: 0, streak: 0 });
assert.equal(stats.accuracyPct, null);
assert.equal(stats.state, 'idle');
assert.equal(Number.isNaN(stats.accuracyPct), false);
});
test('fire state requires high accuracy and streak', () => {
assert.equal(hud.calculateLivePerformanceState({ hits: 90, misses: 10, streak: 10 }).state, 'fire');
assert.equal(hud.calculateLivePerformanceState({ hits: 95, misses: 5, streak: 9 }).state, 'strong');
});
test('smoke state for low accuracy', () => {
assert.equal(hud.calculateLivePerformanceState({ hits: 40, misses: 60, streak: 0 }).state, 'smoke');
});
test('steady and recovery thresholds', () => {
assert.equal(hud.calculateLivePerformanceState({ hits: 75, misses: 25, streak: 2 }).state, 'steady');
assert.equal(hud.calculateLivePerformanceState({ hits: 55, misses: 45, streak: 1 }).state, 'recovery');
});
test('reset counters via bindRuntime song lifecycle', () => {
const listeners = new Map();
const sm = {
on(event, fn) {
const list = listeners.get(event) || [];
list.push(fn);
listeners.set(event, list);
},
emit(event, detail) {
(listeners.get(event) || []).forEach((fn) => fn({ detail }));
},
};
const runtime = hud.bindRuntime(sm);
sm.emit('song:loading', { filename: 'song.psarc' });
assert.equal(runtime.isActive(), true);
runtime.onHit();
runtime.onHit();
runtime.onMiss();
assert.equal(runtime.getCounters().hits, 2);
assert.equal(runtime.getCounters().misses, 1);
assert.equal(runtime.getCounters().streak, 0);
sm.emit('song:arrangement-changed', { filename: 'song.psarc', arrangement: 1 });
assert.deepEqual(runtime.getCounters(), { hits: 0, misses: 0, streak: 0, bestStreak: 0 });
sm.emit('song:stop', { time: 12 });
assert.equal(runtime.isActive(), false);
assert.deepEqual(runtime.getCounters(), { hits: 0, misses: 0, streak: 0, bestStreak: 0 });
});
test('DOM text updates after hit and miss events', () => {
class El {
constructor(id) {
this.id = id;
this.textContent = '';
this.className = 'hidden is-idle';
this.attrs = {};
}
classList = {
add: (c) => { if (!this.className.includes(c)) this.className += (this.className ? ' ' : '') + c; },
remove: (c) => { this.className = this.className.split(/\s+/).filter((x) => x && x !== c).join(' '); },
};
setAttribute(k, v) { this.attrs[k] = String(v); }
}
const els = {
root: new El('v3-live-performance-hud'),
percent: new El('v3-live-performance-percent'),
hits: new El('v3-live-performance-hits'),
streak: new El('v3-live-performance-streak'),
state: new El('v3-live-performance-state'),
};
const listeners = new Map();
const sm = {
on(event, fn) {
const list = listeners.get(event) || [];
list.push(fn);
listeners.set(event, list);
},
emit(event, detail) {
(listeners.get(event) || []).forEach((fn) => fn({ detail }));
},
};
const runtime = hud.bindRuntime(sm, els);
sm.emit('song:loading', { filename: 'song.psarc' });
assert.equal(els.percent.textContent, '\u2014');
assert.equal(els.hits.textContent, 'Waiting for notes');
runtime.onHit();
runtime.onHit();
runtime.onMiss();
assert.equal(els.percent.textContent, '67%');
assert.equal(els.hits.textContent, 'Hits 2 / 3');
assert.equal(els.streak.textContent, 'Streak 0');
assert.match(els.state.textContent, /Recovering/);
});
test('HUD stays hidden until the first note arrives, then reveals', () => {
class El {
constructor(id) {
this.id = id;
this.textContent = '';
this.className = 'hidden is-idle';
}
classList = {
add: (c) => { if (!this.className.includes(c)) this.className += (this.className ? ' ' : '') + c; },
remove: (c) => { this.className = this.className.split(/\s+/).filter((x) => x && x !== c).join(' '); },
};
setAttribute() {}
}
const els = { root: new El('v3-live-performance-hud') };
const listeners = new Map();
const sm = {
on(event, fn) { const l = listeners.get(event) || []; l.push(fn); listeners.set(event, l); },
emit(event, detail) { (listeners.get(event) || []).forEach((fn) => fn({ detail })); },
};
const runtime = hud.bindRuntime(sm, els);
sm.emit('song:loading', { filename: 'song.psarc' });
// Primed (tallying) but not yet visible — a user without note detection
// never gets note:hit/note:miss, so the HUD must not show on load alone.
assert.equal(runtime.isActive(), true);
assert.ok(els.root.className.includes('hidden'));
runtime.onHit();
assert.ok(!els.root.className.includes('hidden'));
// A new song re-hides until the next note.
sm.emit('song:stop', { time: 1 });
assert.ok(els.root.className.includes('hidden'));
sm.emit('song:loading', { filename: 'song2.psarc' });
assert.ok(els.root.className.includes('hidden'));
});
test('idle state before judged notes', () => {
const stats = hud.calculateLivePerformanceState({ hits: 0, misses: 0, streak: 0 });
assert.equal(stats.state, 'idle');
assert.equal(hud.formatPercentText(stats), '\u2014');
assert.equal(hud.formatHitsText(stats), 'Waiting for notes');
});
test('v3 player markup includes live performance HUD', () => {
const html = fs.readFileSync(INDEX_HTML, 'utf8');
assert.match(html, /id="v3-live-performance-hud"/);
assert.match(html, /id="v3-live-performance-percent"/);
assert.match(html, /id="v3-live-performance-hits"/);
assert.match(html, /id="v3-live-performance-streak"/);
assert.match(html, /live-performance-hud\.js/);
});
+268
View File
@@ -0,0 +1,268 @@
// Verify the plugin-facing loop API: setLoop / clearLoop / getLoop on
// window.slopsmith, the input validation in setLoop, and the
// loadSavedLoop refactor that funnels through setLoop.
//
// Same isolation strategy as loop_restart.test.js — extract relevant
// functions by brace-matching and run them in a vm sandbox.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
function extractFunction(src, signature) {
const start = src.indexOf(signature);
if (start === -1) throw new Error(`extractFunction: '${signature}' not found in app.js`);
let scan = start + signature.length;
if (src[scan] === '(') {
let parenDepth = 1;
scan++;
while (scan < src.length && parenDepth > 0) {
const ch = src[scan];
if (ch === '(') parenDepth++;
else if (ch === ')') parenDepth--;
scan++;
}
}
const openBrace = src.indexOf('{', scan);
let depth = 1;
let i = openBrace + 1;
while (i < src.length && depth > 0) {
const ch = src[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
if (depth !== 0) throw new Error(`extractFunction: unbalanced braces after '${signature}'`);
return src.slice(start, i);
}
function buildSandbox() {
const seekCalls = [];
const sectionPracticeModeCalls = [];
const transportEvents = [];
const sandbox = {
seekCalls,
sectionPracticeModeCalls,
transportEvents,
// Mutable state (declared as `var` in eval prelude so it lives on
// the sandbox global and the extracted functions can read/write).
// The actual values are set below.
// DOM stub: every getElementById returns the same stand-in object.
// Writes to className/textContent/classList are absorbed silently;
// we don't assert on them here (the runtime would catch a missing
// element, the unit test cares about loop bookkeeping).
document: {
getElementById: () => ({
className: '',
textContent: '',
value: '',
// _syncSavedLoopSelection iterates over <select>.options.
// An empty option list is fine for these unit tests; the
// sync becomes a no-op (no matching option found).
options: [],
classList: { add() {}, remove() {}, toggle() {} },
}),
},
// _audioSeek spy — records every call so tests can assert seek
// happened with the right target.
// _audioSeek now resolves to { completed, from, to }; the
// stub mimics a successful seek that lands exactly on s so
// setLoop's off-target check passes.
_audioSeek: (s, reason) => {
seekCalls.push({ s, reason: reason ?? null });
return Promise.resolve({ completed: true, from: 0, to: s });
},
_audioTime: () => 0,
// updateLoopUI references formatTime for the label; we don't
// assert on the label text in these tests, so a stub is enough.
formatTime: (s) => String(s),
window: {
slopsmith: {
playback: {
transportEvent: (...args) => transportEvents.push(args),
},
},
},
};
vm.createContext(sandbox);
return sandbox;
}
function loadFunctions(sandbox, src) {
// Pull just the loop helpers — clearLoop, setLoop, updateLoopUI
// (called by setLoop), and the loop state vars.
const code = `
var loopA = null;
var loopB = null;
var _loopMutationGen = 0;
var _sectionPracticeSelected = -1;
var _sectionPracticeWholeSection = false;
var _sectionPracticeSavedPartIndex = 0;
function _setSectionPracticeMode(on, opts) {
sectionPracticeModeCalls.push({ on, opts: opts || {} });
}
function _updateSectionPracticeHighlight(ct) {}
${extractFunction(src, 'function clearLoop(')}
${extractFunction(src, 'function _syncSavedLoopSelection()')}
${extractFunction(src, 'async function setLoop(')}
${extractFunction(src, 'function updateLoopUI()')}
// Expose for the test runner.
globalThis.__setLoop = setLoop;
globalThis.__clearLoop = clearLoop;
globalThis.__getLoop = () => ({ loopA, loopB });
`;
vm.runInContext(code, sandbox);
}
test('setLoop mutates loopA/loopB and seeks to A', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const sandbox = buildSandbox();
loadFunctions(sandbox, src);
const result = await sandbox.__setLoop(5.5, 12.25);
assert.equal(result, true, 'successful seek must resolve to true (plugin contract)');
const { loopA, loopB } = sandbox.__getLoop();
assert.equal(loopA, 5.5);
assert.equal(loopB, 12.25);
assert.equal(sandbox.seekCalls.length, 1);
assert.equal(sandbox.seekCalls[0].s, 5.5);
});
test('setLoop returns false and leaves loopA/loopB untouched on cancelled seek', async () => {
// Plugin-facing contract: cancelled seek (teardown gen bump) returns
// false; the loop is NOT armed.
const src = fs.readFileSync(APP_JS, 'utf8');
const sandbox = buildSandbox();
sandbox._audioSeek = () => Promise.resolve({ completed: false, from: NaN, to: NaN });
loadFunctions(sandbox, src);
const before = sandbox.__getLoop();
const result = await sandbox.__setLoop(5, 10);
assert.equal(result, false, 'cancelled seek must resolve to false');
const after = sandbox.__getLoop();
assert.equal(after.loopA, before.loopA, 'loopA must not be committed on cancel');
assert.equal(after.loopB, before.loopB, 'loopB must not be committed on cancel');
});
test('setLoop returns false and leaves loopA/loopB untouched on off-target landing', async () => {
// JUCE rollback / HTML5 clamp: completed:true but to drifts > 50ms
// from the requested a. The loop is NOT armed.
const src = fs.readFileSync(APP_JS, 'utf8');
const sandbox = buildSandbox();
sandbox._audioSeek = (s) => Promise.resolve({ completed: true, from: 0, to: s + 0.5 });
loadFunctions(sandbox, src);
const before = sandbox.__getLoop();
const result = await sandbox.__setLoop(5, 10);
assert.equal(result, false, 'off-target seek must resolve to false');
const after = sandbox.__getLoop();
assert.equal(after.loopA, before.loopA, 'loopA must not be committed on off-target');
assert.equal(after.loopB, before.loopB, 'loopB must not be committed on off-target');
});
test('setLoop coerces string inputs (parseFloat-style)', async () => {
// loadSavedLoop passes parseFloat(dataset.start) — but the dataset
// values may already be strings. Number() coercion in setLoop must
// accept finite numeric strings.
const src = fs.readFileSync(APP_JS, 'utf8');
const sandbox = buildSandbox();
loadFunctions(sandbox, src);
await sandbox.__setLoop('3.0', '7.0');
const { loopA, loopB } = sandbox.__getLoop();
assert.equal(loopA, 3);
assert.equal(loopB, 7);
});
test('setLoop rejects non-finite inputs', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const sandbox = buildSandbox();
loadFunctions(sandbox, src);
await assert.rejects(() => sandbox.__setLoop(NaN, 5), /finite a and b/);
await assert.rejects(() => sandbox.__setLoop(1, Infinity), /finite a and b/);
await assert.rejects(() => sandbox.__setLoop('abc', 5), /finite a and b/);
});
test('setLoop rejects b <= a', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const sandbox = buildSandbox();
loadFunctions(sandbox, src);
await assert.rejects(() => sandbox.__setLoop(10, 10), /b > a/);
await assert.rejects(() => sandbox.__setLoop(10, 5), /b > a/);
});
test('clearLoop resets loopA/loopB to null', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const sandbox = buildSandbox();
loadFunctions(sandbox, src);
await sandbox.__setLoop(5, 10);
sandbox.__clearLoop();
const { loopA, loopB } = sandbox.__getLoop();
assert.equal(loopA, null);
assert.equal(loopB, null);
assert.equal(sandbox.sectionPracticeModeCalls.length, 1);
assert.equal(sandbox.sectionPracticeModeCalls[0].on, false);
// Field-wise: vm-context objects break deepStrictEqual across realms.
assert.equal(sandbox.sectionPracticeModeCalls[0].opts.skipClearLoop, true);
});
test('loop helpers emit transport snapshots by default and can suppress adapter echoes', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const sandbox = buildSandbox();
loadFunctions(sandbox, src);
await sandbox.__setLoop(5, 10);
sandbox.__clearLoop();
assert.equal(sandbox.transportEvents.length, 2);
assert.equal(sandbox.transportEvents[0][0], 'loop-set');
assert.equal(JSON.stringify(sandbox.transportEvents[0][1].loop), JSON.stringify({ startTime: 5, endTime: 10, enabled: true, state: 'active' }));
assert.equal(sandbox.transportEvents[1][0], 'loop-cleared');
assert.equal(JSON.stringify(sandbox.transportEvents[1][1].loop), JSON.stringify({ enabled: false, state: 'inactive' }));
sandbox.transportEvents.length = 0;
await sandbox.__setLoop(7, 11, { emitTransportEvent: false });
sandbox.__clearLoop({ emitTransportEvent: false });
assert.equal(sandbox.transportEvents.length, 0);
});
test('window.slopsmith API surface declares setLoop/clearLoop/getLoop', () => {
// Source-level assertion: the plugin-facing namespace must expose
// these three methods. Catches a future contributor moving them or
// renaming silently.
const src = fs.readFileSync(APP_JS, 'utf8');
// Find the slopsmith Object.assign block and check method presence.
const m = src.match(/window\.slopsmith\s*=\s*Object\.assign\(_slopsmithBus,\s*\{([\s\S]*?)\}\);\s*if \(_slopsmithExisting/);
assert.ok(m, 'slopsmith Object.assign block not found');
const block = m[1];
assert.match(block, /setLoop\s*\(/, 'setLoop method missing from slopsmith API');
assert.match(block, /clearLoop\s*\(/, 'clearLoop method missing from slopsmith API');
assert.match(block, /getLoop\s*\(/, 'getLoop method missing from slopsmith API');
});
test('loadSavedLoop funnels through setLoop (no duplicated UI mutation)', () => {
// After the refactor, the dropdown path must call setLoop rather than
// re-implementing the loopA/loopB assignment. Catches a future drift
// where someone "fixes" loadSavedLoop and forgets to keep setLoop in
// sync.
const src = fs.readFileSync(APP_JS, 'utf8');
const fn = extractFunction(src, 'async function loadSavedLoop(');
assert.match(fn, /await\s+setLoop\(/, 'loadSavedLoop must call setLoop');
// The pre-refactor body assigned loopA = parseFloat(...) directly;
// ensure that pattern is gone.
assert.doesNotMatch(
fn,
/loopA\s*=\s*parseFloat/,
'loadSavedLoop still has the pre-refactor loopA = parseFloat assignment',
);
});
+243
View File
@@ -0,0 +1,243 @@
// Verify static/app.js emits `loop:restart` exactly once when the A-B
// loop wraps, with the documented payload shape. Plugins (notedetect's
// drill-mode score capture) consume this contract.
//
// The test does not load the full app.js into a DOM — it extracts just
// the `startCountIn` function source via brace-matching and evaluates it
// in a vm sandbox with stubbed dependencies. This trades coverage of the
// surrounding script for isolation: a failure here points at the wrap
// path, not at unrelated DOM coupling.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
// Pull a function body by declaration prefix (e.g. `async function startCountIn`)
// and brace-matching to the closing brace. Skips an optional `( ... )` param
// list so `startCountIn(opts = {})` and `startCountIn()` both match the same
// prefix. Brittle by design: rename/restructure fails loudly, not silently.
function extractFunction(src, signature) {
const start = src.indexOf(signature);
if (start === -1) throw new Error(`extractFunction: '${signature}' not found in app.js`);
let scan = start + signature.length;
if (src[scan] === '(') {
let parenDepth = 1;
scan++;
while (scan < src.length && parenDepth > 0) {
const ch = src[scan];
if (ch === '(') parenDepth++;
else if (ch === ')') parenDepth--;
scan++;
}
}
const openBrace = src.indexOf('{', scan);
let depth = 1;
let i = openBrace + 1;
while (i < src.length && depth > 0) {
const ch = src[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
if (depth !== 0) throw new Error(`extractFunction: unbalanced braces after '${signature}'`);
return src.slice(start, i);
}
function buildSandbox() {
const emitCalls = [];
const sandbox = {
// Globals the function reads/writes via closure. Declared as `var`
// in the eval prelude so they attach to the sandbox.
loopA: 10,
loopB: 20,
_countingIn: false,
isPlaying: false,
lastAudioTime: 0,
// Browser-ish globals.
performance: { now: () => Date.now() },
// requestAnimationFrame: skip to t >= 1 in one tick so the rewind
// animation completes synchronously and we reach the `_audioSeek`
// continuation immediately.
requestAnimationFrame(fn) {
// Fire with `now` far enough in the future that
// (now - rewindStart) / rewindDuration >= 1.
queueMicrotask(() => fn(Date.now() + 10_000));
},
// setTimeout: swallow. beginCount schedules ticks via setTimeout;
// we don't need them to fire — the emit happens before beginCount.
setTimeout: () => 0,
// Stubbed slopsmith DOM dependencies.
audio: { pause() {} },
jucePlayer: { pause: () => Promise.resolve(), play: () => Promise.resolve(true) },
highway: { setTime() {}, getBPM: () => 120 },
// Stubbed app.js helpers.
// Resolve with the real shape `{ completed, from, to }` so
// startCountIn's loop-wrap callback sees completed=true and uses
// r.to for highway.setTime / lastAudioTime.
_audioSeek: (s) => Promise.resolve({ completed: true, from: 20, to: s }),
playClick: () => {},
showCountOverlay: () => {},
hideCountOverlay: () => {},
// Stubbed DOM access. Anything querying for a button just gets a
// permissive object that ignores writes.
document: {
getElementById: () => ({
textContent: '',
className: '',
classList: { add() {}, remove() {}, toggle() {} },
}),
},
// Spy: records every emit call so the test can assert.
window: {
slopsmith: {
emit(event, detail) { emitCalls.push({ event, detail }); },
isPlaying: false,
},
_juceMode: false,
},
// Capture for assertions.
__emitCalls: emitCalls,
queueMicrotask,
};
vm.createContext(sandbox);
return sandbox;
}
test('loop:restart fires once when wrap path runs', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const startCountInSrc = extractFunction(src, 'async function startCountIn');
// Sanity check: the change under test is present at all. Catches
// accidental revert before we even run the behavior assertion.
assert.match(
startCountInSrc,
/window\.slopsmith\.emit\(\s*['"]loop:restart['"]/,
'startCountIn is missing the loop:restart emit',
);
const sandbox = buildSandbox();
// Re-declare the closure-scoped lets as vars so the function can read
// them from the sandbox global, then define the function in-context.
const prelude = `
var loopA = ${sandbox.loopA};
var loopB = ${sandbox.loopB};
var _countingIn = false;
var _countInGen = 0;
var _countInTimer = null;
var _countInRaf = 0;
var isPlaying = false;
var lastAudioTime = 0;
${startCountInSrc}
globalThis.__startCountIn = startCountIn;
`;
vm.runInContext(prelude, sandbox);
await sandbox.__startCountIn();
// Allow the queued requestAnimationFrame microtask + the _audioSeek
// promise chain to settle. Two awaits is enough: rAF microtask -> rewind
// completion -> _audioSeek().then() -> emit.
await new Promise((r) => setImmediate(r));
await new Promise((r) => setImmediate(r));
const restarts = sandbox.__emitCalls.filter((c) => c.event === 'loop:restart');
assert.equal(restarts.length, 1, `expected 1 loop:restart emit, got ${restarts.length}`);
// Field-wise assertion: deepStrictEqual fails across vm-context object
// realms because Object.prototype identities differ even when contents
// match. Compare values, not prototype graphs.
const detail = restarts[0].detail;
assert.equal(detail.loopA, 10);
assert.equal(detail.loopB, 20);
assert.equal(detail.time, 10);
assert.equal(Object.keys(detail).length, 3, `unexpected extra keys in detail: ${Object.keys(detail)}`);
});
test('loop:restart aborts when seek lands far from loopA (JUCE rollback)', async () => {
// Regression: if jucePlayer.seek rolls back (currentTime stays put),
// _audioSeek resolves with completed:true but r.to !== loopA. The
// wrap handler must abort instead of running beginCount on the wrong
// position and emitting a misleading loop:restart.
const src = fs.readFileSync(APP_JS, 'utf8');
const startCountInSrc = extractFunction(src, 'async function startCountIn');
const sandbox = buildSandbox();
// Override _audioSeek to mimic JUCE rollback: completed but to=from,
// far from the requested loopA (10).
sandbox._audioSeek = (s) => Promise.resolve({ completed: true, from: 20, to: 20 });
const prelude = `
var loopA = ${sandbox.loopA};
var loopB = ${sandbox.loopB};
var _countingIn = false;
var _countInGen = 0;
var _countInTimer = null;
var _countInRaf = 0;
var isPlaying = false;
var lastAudioTime = 0;
${startCountInSrc}
globalThis.__startCountIn = startCountIn;
globalThis.__getCountingIn = () => _countingIn;
`;
vm.runInContext(prelude, sandbox);
await sandbox.__startCountIn();
await new Promise((r) => setImmediate(r));
await new Promise((r) => setImmediate(r));
const restarts = sandbox.__emitCalls.filter((c) => c.event === 'loop:restart');
assert.equal(restarts.length, 0, 'rollback must not emit loop:restart');
assert.equal(sandbox.__getCountingIn(), false, '_countingIn must be cleared on abort');
});
test('count-in cancellation token bails delayed callbacks (rewindStep + tick)', () => {
// Source-level assertion: the gen-capture pattern is in place so
// teardown can interrupt an in-flight count-in. Behavioral simulation
// of timer cancellation is out of scope for the static extractor; this
// verifies the contract is wired into the source.
const src = fs.readFileSync(APP_JS, 'utf8');
const fn = extractFunction(src, 'async function startCountIn');
// Captures gen at entry
assert.match(fn, /const gen = _countInGen/, 'startCountIn must capture _countInGen at entry');
// Each delayed callback bails on mismatch
const guards = [...fn.matchAll(/if \(gen !== _countInGen\) return/g)];
assert.ok(guards.length >= 4, `expected ≥4 gen-mismatch bails, found ${guards.length}`);
// RAF and timer handles tracked so _cancelCountIn can cancel them
assert.match(fn, /_countInRaf = requestAnimationFrame/, 'rewindStep must store its RAF handle in _countInRaf');
assert.match(fn, /_countInTimer = setTimeout/, 'tick scheduling must store its timer in _countInTimer');
});
test('loop:restart fires after highway.setTime, before beginCount', () => {
// Source-order assertion on the A-B wrap path only. Section-practice
// `opts.immediate` also emits loop:restart but is a separate entry path;
// the wrap handler lives inside the `_audioSeek(loopA, 'loop-wrap')` then.
const src = fs.readFileSync(APP_JS, 'utf8');
const fn = extractFunction(src, 'async function startCountIn');
const wrapMarker = "_audioSeek(loopA, 'loop-wrap')";
const wrapStart = fn.indexOf(wrapMarker);
assert.ok(wrapStart !== -1, 'loop-wrap _audioSeek call not found in startCountIn');
const wrapSlice = fn.slice(wrapStart);
const setTimeMatches = [...wrapSlice.matchAll(/highway\.setTime\(\s*[^)]+\)/g)];
const setTimeIdx = setTimeMatches.length
? wrapStart + setTimeMatches[setTimeMatches.length - 1].index
: -1;
const emitRel = wrapSlice.search(/window\.slopsmith\.emit\(\s*['"]loop:restart['"]/);
const emitIdx = emitRel === -1 ? -1 : wrapStart + emitRel;
const afterEmit = emitIdx === -1 ? '' : fn.slice(emitIdx);
const beginCallMatch = afterEmit.match(/(?<!function\s)\bbeginCount\s*\(/);
const beginCallIdx = beginCallMatch ? emitIdx + beginCallMatch.index : -1;
assert.ok(setTimeIdx !== -1, 'post-seek highway.setTime not found on wrap path');
assert.ok(emitIdx !== -1, 'loop:restart emit not found on wrap path');
assert.ok(beginCallIdx !== -1, 'beginCount() call not found after wrap emit');
assert.ok(setTimeIdx < emitIdx, 'wrap emit must come after highway.setTime');
assert.ok(emitIdx < beginCallIdx, 'wrap emit must come before beginCount()');
});
+395
View File
@@ -0,0 +1,395 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { createWindow, ROOT } = require('./capabilities_test_harness');
const CAPABILITIES_JS = path.join(ROOT, 'static', 'capabilities.js');
const NOTE_DETECTION_JS = path.join(ROOT, 'static', 'capabilities', 'note-detection.js');
function loadNoteDetection(options = {}) {
const window = createWindow(options);
const context = vm.createContext(window);
vm.runInContext(fs.readFileSync(CAPABILITIES_JS, 'utf8'), context, { filename: CAPABILITIES_JS });
vm.runInContext(fs.readFileSync(NOTE_DETECTION_JS, 'utf8'), context, { filename: NOTE_DETECTION_JS });
return window;
}
function captureEvents(api, eventNames) {
const events = [];
for (const name of eventNames) {
api.subscribe(name, (detail) => events.push(detail));
}
return events;
}
async function registerMidiProvider(api) {
return api.dispatch({
capability: 'note-detection', command: 'register-provider',
source: 'keys_highway_3d',
payload: { providerId: 'keys-midi', label: 'Keys MIDI', kind: 'midi', primitives: ['verify.target'] },
});
}
test('note-detection domain registers an active sensitive provider-coordinator', () => {
const window = loadNoteDetection();
const api = window.slopsmith.capabilities;
const pipeline = api.inspect('note-detection');
assert.ok(pipeline, 'note-detection pipeline exists');
const owner = (pipeline.participants || []).find(p => p.pluginId === 'core.note-detection');
assert.ok(owner, 'core.note-detection owner registered');
assert.equal(owner.safety, 'sensitive');
assert.ok(owner.commands.includes('open-binding'));
assert.equal(window.slopsmith.noteDetection.version, 1);
});
test('open-binding without a provider reports unavailable, never a silent verdict', async () => {
const window = loadNoteDetection();
const api = window.slopsmith.capabilities;
const result = await api.dispatch({
capability: 'note-detection', command: 'open-binding',
source: 'keys_highway_3d', payload: { context: { arrangement: 'keys' } },
});
assert.equal(result.outcome, 'unavailable');
assert.match(result.reason, /No note-detection provider/);
});
test('provider registration + binding lifecycle with per-binding context', async () => {
const window = loadNoteDetection();
const api = window.slopsmith.capabilities;
const events = captureEvents(api, [
'note-detection:provider-registered',
'note-detection:binding-opened',
'note-detection:binding-closed',
'note-detection:target-changed',
]);
const reg = await registerMidiProvider(api);
assert.equal(reg.outcome, 'handled');
assert.ok(api.inspect('note-detection').participants.some(p => p.pluginId === 'keys_highway_3d'));
const open = await api.dispatch({
capability: 'note-detection', command: 'open-binding',
source: 'keys_highway_3d',
payload: { providerId: 'keys-midi', context: { arrangement: 'keys', midiLow: 21, midiHigh: 108, capo: 0 } },
});
assert.equal(open.outcome, 'handled');
const bindingId = open.payload.bindingId;
assert.ok(bindingId);
const binding = open.payload.bindings.find(b => b.id === bindingId);
assert.equal(binding.context.arrangement, 'keys');
assert.equal(binding.context.midiLow, 21);
const target = await api.dispatch({
capability: 'note-detection', command: 'set-target',
source: 'keys_highway_3d',
payload: { bindingId, notes: [{ midi: 60 }, { midi: 64 }, { midi: 67 }] },
});
assert.equal(target.outcome, 'handled');
assert.equal(target.payload.targetSize, 3);
const close = await api.dispatch({
capability: 'note-detection', command: 'close-binding',
source: 'keys_highway_3d', payload: { bindingId },
});
assert.equal(close.outcome, 'handled');
const names = events.map(e => e.event);
assert.ok(names.includes('provider-registered'));
assert.ok(names.includes('binding-opened'));
assert.ok(names.includes('target-changed'));
assert.ok(names.includes('binding-closed'));
});
test('concurrent bindings keep independent contexts (FR-003)', async () => {
const window = loadNoteDetection();
const api = window.slopsmith.capabilities;
await registerMidiProvider(api);
const a = await api.dispatch({
capability: 'note-detection', command: 'open-binding',
source: 'highway', payload: { context: { arrangement: 'guitar', stringCount: 6, capo: 2 } },
});
const b = await api.dispatch({
capability: 'note-detection', command: 'open-binding',
source: 'slopscale', payload: { context: { arrangement: 'bass', stringCount: 4, capo: 0 } },
});
const snapshot = window.slopsmith.noteDetection.snapshot();
const ctxA = snapshot.bindings.find(x => x.id === a.payload.bindingId).context;
const ctxB = snapshot.bindings.find(x => x.id === b.payload.bindingId).context;
assert.equal(ctxA.arrangement, 'guitar');
assert.equal(ctxA.capo, 2);
assert.equal(ctxB.arrangement, 'bass');
assert.equal(ctxB.capo, 0);
});
test('unregistering a provider closes its bindings and flips availability', async () => {
const window = loadNoteDetection();
const api = window.slopsmith.capabilities;
const events = captureEvents(api, ['note-detection:availability-changed', 'note-detection:binding-closed']);
await registerMidiProvider(api);
const openResult = await api.dispatch({ capability: 'note-detection', command: 'open-binding', source: 'keys_highway_3d', payload: {} });
assert.equal(openResult.outcome, 'handled');
assert.equal(window.slopsmith.noteDetection.snapshot().bindings.length, 1);
const result = await api.dispatch({
capability: 'note-detection', command: 'unregister-provider',
source: 'keys_highway_3d', payload: { providerId: 'keys-midi' },
});
assert.equal(result.outcome, 'handled');
assert.equal(window.slopsmith.noteDetection.snapshot().bindings.length, 0);
const availability = events.filter(e => e.event === 'availability-changed').map(e => e.payload.available);
assert.deepEqual(JSON.parse(JSON.stringify(availability)), [true, false]);
assert.ok(events.some(e => e.event === 'binding-closed' && e.payload.reason === 'provider-unregistered'));
// Runtime participant must be removed from the pipeline so inspect() no
// longer lists the provider as active after it unregisters.
const participants = api.inspect('note-detection').participants || [];
assert.ok(!participants.some(p => p.pluginId === 'keys_highway_3d' && (p.roles || []).includes('provider')),
'provider participant should be removed from the pipeline on unregister');
});
test('hit/miss reports flow as observability events with bounded fields', () => {
const window = loadNoteDetection();
const api = window.slopsmith.capabilities;
const events = captureEvents(api, ['note-detection:hit', 'note-detection:miss']);
window.slopsmith.noteDetection.reportHit({ bindingId: 'ndb-1', providerId: 'keys-midi', midi: 64, hit: true, secretDevice: 'Yamaha P-125' });
window.slopsmith.noteDetection.reportMiss({ bindingId: 'ndb-1', providerId: 'keys-midi', midi: 65, hit: false });
assert.equal(events.length, 2);
assert.equal(events[0].payload.midi, 64);
// Unknown fields are dropped — payloads stay bounded and device-label free.
assert.equal(events[0].payload.secretDevice, undefined);
assert.equal(events[1].event, 'miss');
});
test('diagnostics contribution is redaction-safe', async () => {
const window = loadNoteDetection();
const api = window.slopsmith.capabilities;
await registerMidiProvider(api);
await api.dispatch({
capability: 'note-detection', command: 'open-binding',
source: 'keys_highway_3d', payload: { context: { arrangement: 'keys', deviceLabel: 'Yamaha P-125' } },
});
window.slopsmith.noteDetection.reportHit({ bindingId: 'ndb-1', midi: 60, hit: true });
const contribution = window.__diagnosticsContributions.get('note-detection-capability');
assert.equal(contribution.schema, 'slopsmith.note_detection_capability.v1');
const serialized = JSON.stringify(contribution);
assert.ok(!/Yamaha|deviceLabel|filename|\.sloppak|\.psarc/i.test(serialized), serialized);
});
test('legacy setNoteStateProvider surface is wrapped and accounted', () => {
const window = loadNoteDetection();
const api = window.slopsmith.capabilities;
// Simulate highway.js arriving after the host, then the notedetect
// plugin installing its chart-coupled provider.
let installed = null;
window.highway = { setNoteStateProvider(fn) { installed = fn; } };
window.slopsmith.emit('song:loaded', {});
const provider = () => ({ state: 'hit' });
window.highway.setNoteStateProvider(provider);
assert.equal(installed, provider, 'legacy behavior preserved');
const shims = api.snapshotDiagnostics().compatibilityShims
.filter(s => s.capability === 'note-detection');
assert.equal(shims.length, 1);
assert.equal(shims[0].shimId, 'note-detection:highway.setNoteStateProvider');
assert.equal(shims[0].status, 'used');
assert.ok(shims[0].hitCount >= 1);
});
test('unsupported binding/provider ids degrade with bounded reasons (FR-008)', async () => {
const window = loadNoteDetection();
const api = window.slopsmith.capabilities;
await registerMidiProvider(api);
const badProvider = await api.dispatch({
capability: 'note-detection', command: 'open-binding',
source: 'x', payload: { providerId: 'nope' },
});
assert.equal(badProvider.outcome, 'degraded');
const badBinding = await api.dispatch({
capability: 'note-detection', command: 'set-target',
source: 'x', payload: { bindingId: 'ndb-999', notes: [] },
});
assert.equal(badBinding.outcome, 'degraded');
});
test('_contextSummary whitelists arrangement kind — unknown values are dropped', async () => {
const window = loadNoteDetection();
const api = window.slopsmith.capabilities;
await registerMidiProvider(api);
// Known arrangement kinds pass through.
const open = await api.dispatch({
capability: 'note-detection', command: 'open-binding',
source: 'caller', payload: { context: { arrangement: 'keys', stringCount: 6 } },
});
assert.equal(open.outcome, 'handled');
const snap = window.slopsmith.noteDetection.snapshot();
const ctx = snap.bindings.find(b => b.id === open.payload.bindingId).context;
assert.equal(ctx.arrangement, 'keys');
// Arbitrary string must not appear in context summary.
const open2 = await api.dispatch({
capability: 'note-detection', command: 'open-binding',
source: 'caller', payload: { context: { arrangement: '/Users/victim/song.psarc' } },
});
assert.equal(open2.outcome, 'handled');
const snap2 = window.slopsmith.noteDetection.snapshot();
const ctx2 = snap2.bindings.find(b => b.id === open2.payload.bindingId).context;
assert.equal(ctx2.arrangement, undefined, 'path-bearing arrangement must be dropped');
});
test('snapshot primitives are deep-copied — caller cannot mutate provider internals', async () => {
const window = loadNoteDetection();
const api = window.slopsmith.capabilities;
await registerMidiProvider(api);
const snap1 = window.slopsmith.noteDetection.snapshot();
const providerEntry = snap1.providers.find(p => p.id === 'keys-midi');
assert.ok(Array.isArray(providerEntry.primitives));
// Mutate the copy — must not affect subsequent snapshots.
providerEntry.primitives.push('injected');
const snap2 = window.slopsmith.noteDetection.snapshot();
const providerEntry2 = snap2.providers.find(p => p.id === 'keys-midi');
assert.ok(!providerEntry2.primitives.includes('injected'), 'live primitives must not be mutated via snapshot');
});
test('close-binding and set-target enforce requester ownership', async () => {
const window = loadNoteDetection();
const api = window.slopsmith.capabilities;
await registerMidiProvider(api);
const open = await api.dispatch({
capability: 'note-detection', command: 'open-binding',
source: 'owner-plugin', payload: {},
});
const bindingId = open.payload.bindingId;
// A different requester must not close the binding.
const stealClose = await api.dispatch({
capability: 'note-detection', command: 'close-binding',
source: 'other-plugin', payload: { bindingId },
});
assert.equal(stealClose.outcome, 'degraded', 'non-owner close must be rejected');
// A different requester must not retarget the binding.
const stealTarget = await api.dispatch({
capability: 'note-detection', command: 'set-target',
source: 'other-plugin', payload: { bindingId, notes: [{ midi: 60 }] },
});
assert.equal(stealTarget.outcome, 'degraded', 'non-owner set-target must be rejected');
// The binding must still exist and be closeable by the original owner.
const ownerClose = await api.dispatch({
capability: 'note-detection', command: 'close-binding',
source: 'owner-plugin', payload: { bindingId },
});
assert.equal(ownerClose.outcome, 'handled');
});
test('register-provider rejects cross-owner re-registration', async () => {
const window = loadNoteDetection();
const api = window.slopsmith.capabilities;
// First registration by plugin-a.
const first = await api.dispatch({
capability: 'note-detection', command: 'register-provider',
source: 'plugin-a',
payload: { providerId: 'shared-provider', label: 'Shared', kind: 'js', primitives: [] },
});
assert.equal(first.outcome, 'handled');
// A different participant must not overwrite the same providerId.
const steal = await api.dispatch({
capability: 'note-detection', command: 'register-provider',
source: 'plugin-b',
payload: { providerId: 'shared-provider', label: 'Hijacked', kind: 'js', primitives: [] },
});
assert.equal(steal.outcome, 'degraded', 'cross-owner re-registration must be rejected');
// The original owner may still update its own registration.
const refresh = await api.dispatch({
capability: 'note-detection', command: 'register-provider',
source: 'plugin-a',
payload: { providerId: 'shared-provider', label: 'Refreshed', kind: 'js', primitives: [] },
});
assert.equal(refresh.outcome, 'handled', 'owner may refresh its own registration');
});
test('unregister-provider rejects cross-owner unregister', async () => {
const window = loadNoteDetection();
const api = window.slopsmith.capabilities;
await registerMidiProvider(api);
// A different participant must not unregister a provider it does not own.
const steal = await api.dispatch({
capability: 'note-detection', command: 'unregister-provider',
source: 'other-plugin', payload: { providerId: 'keys-midi' },
});
assert.equal(steal.outcome, 'degraded', 'cross-owner unregister must be rejected');
// Provider must still be present after the failed cross-owner attempt.
const snap = window.slopsmith.noteDetection.snapshot();
assert.ok(snap.providers.some(p => p.id === 'keys-midi'), 'provider must survive cross-owner unregister attempt');
// The original owner can still unregister.
const ownerUnreg = await api.dispatch({
capability: 'note-detection', command: 'unregister-provider',
source: 'keys_highway_3d', payload: { providerId: 'keys-midi' },
});
assert.equal(ownerUnreg.outcome, 'handled', 'owner must be able to unregister its own provider');
});
test('availability-changed fires only on 0→1 and 1→0 transitions', async () => {
const window = loadNoteDetection();
const api = window.slopsmith.capabilities;
const events = captureEvents(api, ['note-detection:availability-changed']);
// First registration: 0→1, should emit.
await registerMidiProvider(api);
// Second registration of a different provider by same owner: already available, must NOT emit.
await api.dispatch({
capability: 'note-detection', command: 'register-provider',
source: 'plugin-b',
payload: { providerId: 'engine-provider', label: 'Engine', kind: 'engine', primitives: [] },
});
// Unregister first provider: still one left, must NOT emit.
await api.dispatch({
capability: 'note-detection', command: 'unregister-provider',
source: 'keys_highway_3d', payload: { providerId: 'keys-midi' },
});
// Unregister last provider: 1→0, should emit false.
await api.dispatch({
capability: 'note-detection', command: 'unregister-provider',
source: 'plugin-b', payload: { providerId: 'engine-provider' },
});
const available = events.map(e => e.payload.available);
assert.deepEqual(JSON.parse(JSON.stringify(available)), [true, false],
'only the 0→1 and final 1→0 transitions should emit availability-changed');
});
test('unregistering one of two providers from the same participant keeps participant in the pipeline', async () => {
const window = loadNoteDetection();
const api = window.slopsmith.capabilities;
// One plugin registers two providers under the same participantId.
await api.dispatch({
capability: 'note-detection', command: 'register-provider',
source: 'multi-plugin',
payload: { providerId: 'multi-provider-a', label: 'A', kind: 'js', primitives: [] },
});
await api.dispatch({
capability: 'note-detection', command: 'register-provider',
source: 'multi-plugin',
payload: { providerId: 'multi-provider-b', label: 'B', kind: 'js', primitives: [] },
});
// Unregister one provider — the participant must remain in the pipeline
// because the second provider from the same plugin still exists.
const unreg = await api.dispatch({
capability: 'note-detection', command: 'unregister-provider',
source: 'multi-plugin', payload: { providerId: 'multi-provider-a' },
});
assert.equal(unreg.outcome, 'handled');
const snap = window.slopsmith.noteDetection.snapshot();
assert.ok(!snap.providers.some(p => p.id === 'multi-provider-a'), 'provider-a must be removed');
assert.ok(snap.providers.some(p => p.id === 'multi-provider-b'), 'provider-b must still be present');
const participants = api.inspect('note-detection').participants || [];
assert.ok(participants.some(p => p.pluginId === 'multi-plugin'),
'multi-plugin participant must remain in pipeline while it still has a live provider');
});
+129
View File
@@ -0,0 +1,129 @@
// Verify the decorative pedalboard patch-cable engine (static/v3/pedal-cables.js):
// pure geometry helpers (jack positions, point seeding, path building, static
// sag), the segment cap, and the prefers-reduced-motion branch — which must
// draw a STATIC sagged path and start NO requestAnimationFrame loop, while the
// normal path DOES start the loop.
const { test } = require('node:test');
// Non-strict assert: helpers return objects created inside the vm realm, whose
// Object.prototype differs from this realm's — strict deepEqual would fail the
// prototype check even when the structure matches.
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const SRC = path.join(__dirname, '..', '..', 'static', 'v3', 'pedal-cables.js');
function rect(l, t, r, b) { return { left: l, top: t, right: r, bottom: b, width: r - l, height: b - t }; }
function loadCables(opts) {
opts = opts || {};
const rafCalls = [];
const created = []; // every createElementNS node
const win = { slopsmith: null };
win.addEventListener = () => {};
win.matchMedia = () => ({ matches: !!opts.reduce });
const doc = {
createElementNS: (ns, tag) => {
const node = {
_tag: tag, attrs: {}, children: [],
setAttribute: (k, v) => { node.attrs[k] = v; },
appendChild: (c) => { node.children.push(c); },
insertBefore: (c) => { node.children.unshift(c); },
firstChild: null,
};
created.push(node);
return node;
},
};
const ctx = {
window: win, document: doc, console,
requestAnimationFrame: (cb) => { rafCalls.push(cb); return rafCalls.length; },
cancelAnimationFrame: () => {},
};
vm.createContext(ctx);
vm.runInContext(fs.readFileSync(SRC, 'utf8'), ctx, { filename: 'pedal-cables.js' });
return { api: win.v3PedalCables, t: win.v3PedalCables._test, rafCalls, created };
}
// A fake board containing two pedals; root yields the one board.
function fakeRoot() {
const pedalA = { getBoundingClientRect: () => rect(10, 10, 160, 210) };
const pedalB = { getBoundingClientRect: () => rect(220, 10, 370, 210) };
const board = {
firstChild: null,
scrollWidth: 800, scrollHeight: 420,
getBoundingClientRect: () => rect(0, 0, 800, 420),
setAttribute: () => {},
insertBefore: () => {},
querySelectorAll: (sel) => (sel === '.v3-pedal' ? [pedalA, pedalB] : []),
};
return { querySelectorAll: (sel) => (sel === '.v3-pedalboard' ? [board] : []) };
}
test('computeJacks: out = right side, in = left side, both at vertical centre', () => {
const { t } = loadCables();
const j = t.computeJacks(rect(100, 50, 250, 250), rect(0, 0, 800, 400), 10);
// pedal spans x[100,250] y[50,250]; jack y = top + JACK_FRAC*height to line
// up with the photo's side jacks.
const y = 50 + t.JACK_FRAC * 200;
assert.deepEqual(j.out, { x: 240, y }); // right-10
assert.deepEqual(j.in, { x: 110, y }); // left+10
});
test('seedPoints: N points, endpoints pinned to a and b', () => {
const { t } = loadCables();
const pts = t.seedPoints({ x: 0, y: 0 }, { x: 100, y: 0 }, 12);
assert.equal(pts.length, 12);
assert.deepEqual({ x: pts[0].x, y: pts[0].y }, { x: 0, y: 0 });
assert.deepEqual({ x: pts[11].x, y: pts[11].y }, { x: 100, y: 0 });
});
test('pointsToPath: M then L per subsequent point', () => {
const { t } = loadCables();
const d = t.pointsToPath([{ x: 0, y: 0 }, { x: 5, y: 5 }, { x: 9, y: 1 }]);
assert.ok(d.startsWith('M 0.0 0.0'));
assert.equal((d.match(/L /g) || []).length, 2);
});
test('staticCablePath: quadratic with a downward sag', () => {
const { t } = loadCables();
const d = t.staticCablePath({ x: 0, y: 100 }, { x: 100, y: 100 }, 0.2);
assert.ok(d.startsWith('M 0.0 100.0'));
assert.ok(d.includes('Q '));
// Control-point y is below the endpoints (larger y == lower on screen).
const cy = parseFloat(d.split('Q ')[1].split(' ')[1]);
assert.ok(cy > 100);
});
test('segment cap constants are sane', () => {
const { t } = loadCables();
assert.ok(t.SEGMENTS >= 2 && t.SEGMENTS <= 40);
assert.ok(t.MAX_CABLES >= 1);
});
test('attach draws each cable as a static curve with NO idle rAF loop', () => {
// The cable shape is a pure function of the endpoints (no Verlet/inertia),
// so attach renders once and schedules no continuous animation loop —
// there is nothing to settle, which is what kills the "moon gravity" drift.
const { api, rafCalls, created } = loadCables({ reduce: false });
api.attach(fakeRoot());
assert.equal(rafCalls.length, 0, 'no idle animation loop');
const paths = created.filter((n) => n._tag === 'path');
assert.equal(paths.length, 1, 'one cable between two pedals');
assert.ok((paths[0].attrs.d || '').includes('Q '), 'curved (quadratic) path drawn');
});
test('a drag schedules a short tracking loop; ending it stops the loop', () => {
const { api, rafCalls } = loadCables({ reduce: false });
api.attach(fakeRoot());
api.setDragging(true);
assert.ok(rafCalls.length >= 1, 'drag runs a per-frame tracking loop');
const before = rafCalls.length;
api.setDragging(false);
// Run the already-scheduled frame: with dragging off it renders once and
// does NOT reschedule.
rafCalls[rafCalls.length - 1]();
assert.equal(rafCalls.length, before, 'loop is not rescheduled after drag ends');
});
+88
View File
@@ -0,0 +1,88 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
function extractFunction(src, signature) {
const start = src.indexOf(signature);
if (start === -1) throw new Error(`extractFunction: '${signature}' not found in app.js`);
const openBrace = src.indexOf('{', start);
let depth = 1;
let i = openBrace + 1;
while (i < src.length && depth > 0) {
const ch = src[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
if (depth !== 0) throw new Error(`extractFunction: unbalanced braces after '${signature}'`);
return src.slice(start, i);
}
function buildReadySandbox() {
const listeners = new Map();
const sandbox = {
window: {
slopsmith: {
on(event, fn) { listeners.set(event, fn); },
off(event, fn) { if (listeners.get(event) === fn) listeners.delete(event); },
},
},
setTimeout,
clearTimeout,
Promise,
__emit(event) {
const fn = listeners.get(event);
if (fn) fn();
},
};
vm.createContext(sandbox);
return sandbox;
}
function loadReadyHelper(sandbox, src) {
const code = `
let _audioSeekGen = 10;
${extractFunction(src, 'function _waitForSongReady(')}
globalThis.__waitForSongReady = _waitForSongReady;
globalThis.__setAudioSeekGen = value => { _audioSeekGen = value; };
`;
vm.runInContext(code, sandbox);
}
test('_waitForSongReady rejects a ready event from a different audio generation', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const sandbox = buildReadySandbox();
loadReadyHelper(sandbox, src);
const stale = sandbox.__waitForSongReady(11, 1000);
sandbox.__emit('song:ready');
assert.equal(await stale, false);
sandbox.__setAudioSeekGen(11);
const current = sandbox.__waitForSongReady(11, 1000);
sandbox.__emit('song:ready');
assert.equal(await current, true);
});
test('playback adapter scopes startTime readiness and validates seek targets', () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const fn = extractFunction(src, 'function _installPlaybackTransportAdapter()');
assert.match(fn, /const expectedSeekGen\s*=\s*_audioSeekGen\s*\+\s*1;/);
assert.match(fn, /_waitForSongReady\(expectedSeekGen\)/);
assert.match(fn, /const seconds\s*=\s*Number\(time\);/);
assert.match(fn, /!Number\.isFinite\(seconds\)\s*\|\|\s*seconds\s*<\s*0/);
assert.match(fn, /throw new Error\(`Invalid seek time:/);
assert.match(fn, /return _audioSeek\(seconds, reason \|\| 'playback-command'\);/);
});
test('playback adapter suppresses duplicate HTML5 pause events before emitting canonical pause', () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const fn = extractFunction(src, 'function _installPlaybackTransportAdapter()');
assert.match(fn, /if \(!window\._juceMode && wasPlaying\) \{\s*isPlaying = false;\s*window\.slopsmith\.isPlaying = false;\s*audio\.pause\(\);\s*_markPlaybackPaused\(\);\s*\}/);
});
+101
View File
@@ -0,0 +1,101 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { loadPlayback, captureEvents, diagnosticsSnapshot, makeTarget, dispatch, makeAdapter, ROOT } = require('./playback_test_harness');
const { loadCapabilities } = require('./capabilities_test_harness');
test('requester and observer registration is idempotent and visible in diagnostics', async () => {
const window = loadPlayback();
await dispatch(window, 'register-requester', { requesterId: 'plugin.practice', kind: 'plugin', requests: ['pause'] });
await dispatch(window, 'register-requester', { requesterId: 'plugin.practice', kind: 'plugin', requests: ['seek'], status: 'available' });
await dispatch(window, 'register-observer', { observerId: 'plugin.hud', observes: ['ready', 'seeked'] });
const participants = diagnosticsSnapshot(window).participants;
assert.equal(participants.filter(item => item.requesterId === 'plugin.practice').length, 1);
assert.equal(participants.filter(item => item.observerId === 'plugin.hud').length, 1);
});
test('plugin fresh starts require user action and incompatible playback participants do not execute', async () => {
const window = loadPlayback();
const denied = await dispatch(window, 'start', { requesterId: 'plugin.remote', target: makeTarget() });
assert.equal(denied.status, 'user-action-required');
const incompatibleWindow = loadCapabilities();
const fixture = JSON.parse(fs.readFileSync(path.join(ROOT, 'tests', 'fixtures', 'plugin_capabilities', 'unsupported_capability_version.json'), 'utf8'));
fixture.id = 'future_playback';
fixture.capabilities = { playback: { roles: ['owner'], commands: ['inspect'], runtime: true, version: 999, handlers: { inspect: () => ({ outcome: 'handled' }) } } };
incompatibleWindow.slopsmith.capabilities.registerParticipants([fixture]);
const result = await incompatibleWindow.slopsmith.capabilities.dispatch({ capability: 'playback', command: 'inspect', requester: 'test' });
assert.equal(result.status, 'incompatible-version');
});
test('same-priority latest controls remain non-stale while user-priority commands deny background automation', async () => {
const window = loadPlayback();
window.slopsmith.playback.registerTransportAdapter(makeAdapter());
await dispatch(window, 'start', { authorization: 'user-action', requesterId: 'core.player.controls', target: makeTarget() });
const firstPause = await dispatch(window, 'pause', { requesterId: 'plugin.a', priority: 'normal' });
const normalResume = await dispatch(window, 'resume', { requesterId: 'plugin.b', priority: 'normal' });
await dispatch(window, 'pause', { requesterId: 'core.player.controls', priority: 'user' });
const blockedResume = await dispatch(window, 'resume', { requesterId: 'plugin.b', priority: 'normal' });
assert.equal(firstPause.status, 'paused');
assert.equal(normalResume.status, 'playing');
assert.equal(blockedResume.status, 'denied');
});
test('legacy bridge hits are attributed to playback compatibility shims', () => {
const window = loadPlayback();
const bridgeEvents = captureEvents(window, 'playback:bridge-hit');
window.slopsmith.playback.recordBridgeHit({
bridgeId: 'playback.window-play-song',
legacySurface: 'window.playSong',
source: 'core.app',
reason: 'legacy playSong entry point used',
});
const playback = diagnosticsSnapshot(window);
const runtime = window.slopsmith.capabilities.snapshotDiagnostics();
const shim = runtime.compatibilityShims.find(item => item.capability === 'playback' && item.legacySurface === 'window.playSong');
assert.equal(bridgeEvents.length, 1);
assert.equal(playback.bridges[0].bridgeId, 'playback.window-play-song');
assert.equal(playback.bridges[0].hitCount, 1);
assert.ok(shim);
assert.equal(shim.hitCount, 1);
});
test('legacy song events update playback state without exposing raw filenames', () => {
const window = loadPlayback();
window.slopsmith.emit('song:loading', { filename: '/Users/example/Secret Folder/Artist - Song_p.psarc', arrangement: 0 });
window.slopsmith.emit('song:loaded', makeTarget({ filename: '/Users/example/Secret Folder/Artist - Song_p.psarc' }));
window.slopsmith.emit('song:play', { time: 4, audioT: 4, chartT: 4 });
window.slopsmith.emit('song:seek', { from: 4, to: 12, reason: 'seek-by' });
const playback = diagnosticsSnapshot(window);
const encoded = JSON.stringify(playback);
assert.equal(playback.state.state, 'playing');
assert.equal(playback.state.media.currentTime, 12);
assert.match(playback.state.target.settingsKey, /^settings-v1-[a-z0-9]{7}$/);
assert.ok(playback.bridges.some(bridge => bridge.bridgeId === 'playback.song-events'));
assert.doesNotMatch(encoded, /Secret Folder/);
assert.doesNotMatch(encoded, /Artist - Song_p\.psarc/);
});
test('route changes are captured as redaction-safe playback lifecycle events', () => {
const window = loadPlayback();
const changing = captureEvents(window, 'playback:route-changing');
const changed = captureEvents(window, 'playback:route-changed');
window.slopsmith.playback.recordRouteChange({ routeKind: 'desktop-native', state: 'switching', preservedTime: true, safeReason: 'desktop engine active' });
window.slopsmith.playback.recordRouteChange({ routeKind: 'desktop-native', state: 'active', preservedTime: true, safeReason: 'desktop route active' });
const playback = diagnosticsSnapshot(window);
assert.equal(changing.length, 1);
assert.equal(changed.length, 1);
assert.equal(playback.state.route.routeKind, 'desktop-native');
assert.equal(playback.state.route.state, 'active');
assert.equal(playback.state.route.preservedTime, true);
});
+117
View File
@@ -0,0 +1,117 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { loadPlayback, captureEvents, dispatch, diagnosticsSnapshot, makeTarget, makeAdapter } = require('./playback_test_harness');
test('exported diagnostics pseudonymize targets while local inspector may show display names', async () => {
const window = loadPlayback();
window.slopsmith.playback.registerTransportAdapter(makeAdapter());
await dispatch(window, 'start', {
authorization: 'user-action',
requesterId: 'core.player.controls',
target: makeTarget({
filename: '/Users/example/DLC/Private Artist - Private Song_p.psarc',
title: 'Private Song',
artist: 'Private Artist',
arrangement: 'Lead',
}),
});
const exported = diagnosticsSnapshot(window);
const local = diagnosticsSnapshot(window, { exportMode: 'local-inspector' });
const exportedText = JSON.stringify(exported);
assert.match(exported.state.target.targetId, /^target-/);
assert.match(exported.state.target.settingsKey, /^settings-v1-[a-z0-9]{7}$/);
assert.equal(exported.state.target.localDisplay, undefined);
assert.doesNotMatch(exportedText, /Private Song/);
assert.doesNotMatch(exportedText, /Private Artist/);
assert.doesNotMatch(exportedText, /DLC/);
assert.equal(local.state.target.settingsKey, exported.state.target.settingsKey);
assert.equal(local.state.target.localDisplay.title, 'Private Song');
assert.equal(local.state.target.localDisplay.artist, 'Private Artist');
});
test('diagnostic history is bounded for current and stopped sessions', async () => {
const window = loadPlayback();
window.slopsmith.playback.registerTransportAdapter(makeAdapter());
for (let index = 0; index < 7; index += 1) {
await dispatch(window, 'start', { authorization: 'user-action', requesterId: 'core.player.controls', target: makeTarget({ filename: `song-${index}.psarc`, title: `Song ${index}` }) });
for (let seek = 0; seek < 12; seek += 1) {
await dispatch(window, 'seek', { requesterId: 'core.player.controls', time: seek });
}
await dispatch(window, 'stop', { requesterId: 'core.player.controls', priority: 'user' });
}
const snapshot = diagnosticsSnapshot(window);
assert.ok(snapshot.history.current.recentOutcomes.length <= 50);
assert.ok(snapshot.history.current.lifecycleEvents.length <= 50);
assert.ok(snapshot.history.stoppedSessions.length <= 5);
for (const session of snapshot.history.stoppedSessions) {
assert.ok(session.recentOutcomes.length <= 20);
assert.ok(session.lifecycleEvents.length <= 20);
}
});
test('diagnostics contribution is exported under playback schema', async () => {
const window = loadPlayback();
window.slopsmith.playback.registerTransportAdapter(makeAdapter());
await dispatch(window, 'start', { authorization: 'user-action', requesterId: 'core.player.controls', target: makeTarget() });
const contribution = window.slopsmith.diagnostics.snapshotContributions().playback;
assert.equal(contribution.schema, 'slopsmith.playback.diagnostics.v1');
assert.equal(contribution.domain, 'playback');
assert.equal(contribution.exportMode, 'exported');
assert.match(contribution.state.target.settingsKey, /^settings-v1-[a-z0-9]{7}$/);
assert.equal(contribution.state.target.localDisplay, undefined);
});
test('diagnostics redact caller-supplied route and stale session ids', async () => {
const window = loadPlayback();
window.slopsmith.playback.registerTransportAdapter(makeAdapter());
await dispatch(window, 'start', { authorization: 'user-action', requesterId: 'core.player.controls', target: makeTarget() });
await dispatch(window, 'pause', { sessionId: '/Users/example/private-session?token=secret' }, 'plugin.remote');
window.slopsmith.playback.recordRouteChange({ routeId: '/Users/example/native-route?token=secret', routeKind: 'desktop-native', state: 'active', safeReason: 'ok' });
const snapshot = diagnosticsSnapshot(window);
const encoded = JSON.stringify(snapshot);
const stale = snapshot.history.current.recentOutcomes.find(item => item.status === 'stale');
assert.match(snapshot.state.route.routeId, /^route-/);
assert.notEqual(snapshot.state.route.routeId, '/Users/example/native-route?token=secret');
assert.match(stale.sessionId, /^playback-/);
assert.notEqual(stale.sessionId, '/Users/example/private-session?token=secret');
assert.doesNotMatch(encoded, /private-session|native-route|token=secret|\/Users\/example/);
});
test('diagnostics redact requester ids and raw camel-case payload keys', async () => {
const window = loadPlayback();
window.slopsmith.playback.registerTransportAdapter(makeAdapter());
await dispatch(window, 'start', { authorization: 'user-action', target: makeTarget() }, '/Users/example/plugin token=secret');
const degradedEvents = captureEvents(window, 'playback:degraded');
window.slopsmith.playback.transportEvent('degraded', {
requesterId: '/Users/example/transport token=secret',
accessToken: 'plain-secret-token',
nativeHandleRef: 'native-secret-handle',
mediaStream: 'raw-stream-id',
reason: '/Users/example/private song.psarc token=secret',
safeDetail: 'safe value',
});
window.slopsmith.playback.recordBridgeHit({
bridgeId: '/Users/example/bridge token=secret',
legacySurface: 'window.playSong',
source: '/Users/example/source token=secret',
reason: '/Users/example/bridge path token=secret',
nativeHandleRef: 'native-secret-handle',
});
const snapshot = diagnosticsSnapshot(window);
const encoded = JSON.stringify({ snapshot, degradedEvents });
assert.match(snapshot.state.transport.requesterId, /path/);
assert.doesNotMatch(encoded, /plain-secret-token|native-secret-handle|raw-stream-id/);
assert.doesNotMatch(encoded, /private song|bridge path|\/Users\/example|token=secret|source-token-secret|bridge-token-secret/);
assert.match(encoded, /safe value/);
});
+237
View File
@@ -0,0 +1,237 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { loadPlayback, captureEvents, dispatch, makeTarget, makeAdapter, diagnosticsSnapshot } = require('./playback_test_harness');
const { loadCapabilities } = require('./capabilities_test_harness');
test('playback reports no-owner no-handler and unsupported command outcomes explicitly', async () => {
const noOwnerWindow = loadCapabilities();
const noOwner = await noOwnerWindow.slopsmith.capabilities.dispatch({ capability: 'playback', command: 'inspect', requester: 'test' });
assert.equal(noOwner.status, 'no-owner');
const noHandlerWindow = loadCapabilities();
noHandlerWindow.slopsmith.capabilities.registerOwner('playback', { pluginId: 'test-owner', commands: ['inspect'], events: [] });
const noHandler = await noHandlerWindow.slopsmith.capabilities.dispatch({ capability: 'playback', command: 'inspect', requester: 'test' });
assert.equal(noHandler.status, 'no-handler');
const unsupportedWindow = loadPlayback();
const unsupported = await dispatch(unsupportedWindow, 'teleport', { requesterId: 'test' });
assert.equal(unsupported.status, 'unsupported-command');
});
test('playback registers as an active core owner', async () => {
const window = loadPlayback();
const snapshot = window.slopsmith.capabilities.snapshotDiagnostics();
const playback = snapshot.pipelines.find(pipeline => pipeline.name === 'playback');
assert.ok(playback, 'playback pipeline exists');
assert.equal(playback.review.lifecycle, 'active');
assert.ok(playback.participants.some(participant => participant.pluginId === 'core.playback' && participant.roles.includes('owner')));
assert.ok(playback.participants[0].commands.includes('start'));
assert.ok(playback.participants[0].events.includes('seeked'));
});
test('start requires a target and explicit user authorization for fresh audible playback', async () => {
const window = loadPlayback();
const missing = await dispatch(window, 'start', { requesterId: 'plugin.practice', authorization: 'user-action' });
assert.equal(missing.status, 'no-target');
const noGesture = await dispatch(window, 'start', { requesterId: 'plugin.practice', target: makeTarget() });
assert.equal(noGesture.status, 'user-action-required');
const adapter = makeAdapter();
window.slopsmith.playback.registerTransportAdapter(adapter);
const events = captureEvents(window, 'playback:ready');
const result = await dispatch(window, 'start', { requesterId: 'core.player.controls', authorization: 'user-action', target: makeTarget() });
assert.equal(result.status, 'ready');
assert.ok(adapter.calls.some(call => call[0] === 'start'));
assert.equal(events.length, 1);
const state = diagnosticsSnapshot(window).state;
assert.equal(state.state, 'ready');
assert.equal(state.target.targetId.startsWith('target-'), true);
assert.match(state.target.settingsKey, /^settings-v1-[a-z0-9]{7}$/);
assert.equal(events[0].payload.target.settingsKey, state.target.settingsKey);
});
test('settings key is stable across arrangements while target id remains arrangement scoped', async () => {
const window = loadPlayback();
window.slopsmith.playback.registerTransportAdapter(makeAdapter());
const base = makeTarget({ filename: '/Users/example/DLC/Artist - Song_p.psarc', arrangement: 'Lead', arrangementIndex: 0 });
await dispatch(window, 'start', { requesterId: 'core.player.controls', authorization: 'user-action', target: base });
const leadTarget = diagnosticsSnapshot(window).state.target;
await dispatch(window, 'start', { requesterId: 'core.player.controls', authorization: 'user-action', target: { ...base, arrangement: 'Bass', arrangementIndex: 1 } });
const bassTarget = diagnosticsSnapshot(window).state.target;
assert.match(leadTarget.settingsKey, /^settings-v1-[a-z0-9]{7}$/);
assert.equal(bassTarget.settingsKey, leadTarget.settingsKey);
assert.notEqual(bassTarget.targetId, leadTarget.targetId);
});
test('unsafe caller-supplied settings keys are hashed before exposure', async () => {
const window = loadPlayback();
window.slopsmith.playback.registerTransportAdapter(makeAdapter());
await dispatch(window, 'start', {
requesterId: 'core.player.controls',
authorization: 'user-action',
target: makeTarget({ settingsKey: 'settings-private-song-name' }),
});
const encoded = JSON.stringify(diagnosticsSnapshot(window));
assert.match(diagnosticsSnapshot(window).state.target.settingsKey, /^settings-v1-[a-z0-9]{7}$/);
assert.doesNotMatch(encoded, /private-song-name/);
});
test('unsafe caller-supplied target ids are hashed before exposure', async () => {
const window = loadPlayback();
window.slopsmith.playback.registerTransportAdapter(makeAdapter());
await dispatch(window, 'start', {
requesterId: 'core.player.controls',
authorization: 'user-action',
target: makeTarget({ targetId: 'target-private-song-name' }),
});
const encoded = JSON.stringify(diagnosticsSnapshot(window));
assert.match(diagnosticsSnapshot(window).state.target.targetId, /^target-[a-z0-9]+$/);
assert.doesNotMatch(encoded, /private-song-name/);
});
test('dispatch requester owns playback command attribution', async () => {
const window = loadPlayback();
window.slopsmith.playback.registerTransportAdapter(makeAdapter());
const started = await dispatch(window, 'start', { requesterId: 'core.player.controls', authorization: 'user-action', target: makeTarget() }, 'plugin.remote');
const paused = await dispatch(window, 'pause', { requesterId: 'core.player.controls' }, 'plugin.remote');
const snapshot = diagnosticsSnapshot(window);
const outcomeIds = snapshot.history.current.recentOutcomes.map(item => item.requesterId).join(',');
assert.equal(started.status, 'ready');
assert.equal(paused.status, 'paused');
assert.equal(snapshot.state.transport.requesterId, 'plugin.remote');
assert.equal(outcomeIds.includes('core.player.controls'), false);
assert.equal(outcomeIds.includes('plugin.remote'), true);
});
test('transport commands emit ordered lifecycle events and normalize outcomes', async () => {
const window = loadPlayback();
const adapter = makeAdapter({ duration: 10 });
window.slopsmith.playback.registerTransportAdapter(adapter);
const events = [];
for (const eventName of ['playback:requested', 'playback:loading', 'playback:ready', 'playback:paused', 'playback:resumed', 'playback:seeking', 'playback:seeked', 'playback:loop-set', 'playback:loop-cleared']) {
window.slopsmith.on(eventName, event => events.push(event.type.replace('playback:', '')));
}
await dispatch(window, 'start', { authorization: 'user-action', requesterId: 'core.player.controls', target: makeTarget() });
await dispatch(window, 'pause', { requesterId: 'core.player.controls', priority: 'user' });
await dispatch(window, 'resume', { requesterId: 'core.player.controls', priority: 'user' });
const seek = await dispatch(window, 'seek', { requesterId: 'core.player.controls', time: 999 });
const loop = await dispatch(window, 'set-loop', { requesterId: 'core.player.controls', startTime: 2, endTime: 4 });
const cleared = await dispatch(window, 'clear-loop', { requesterId: 'core.player.controls' });
assert.deepEqual(events.slice(0, 3), ['requested', 'loading', 'ready']);
assert.ok(events.indexOf('seeking') < events.indexOf('seeked'));
assert.equal(seek.status, 'clamped');
assert.equal(seek.payload.landedTime, 10);
assert.equal(loop.status, 'active');
assert.equal(cleared.status, 'cleared');
});
test('seek preserves pre-seek playback state and external seek events update state', async () => {
const window = loadPlayback();
window.slopsmith.playback.registerTransportAdapter(makeAdapter({ startPlaying: true, seekResult: { completed: true, from: 1, to: 5 } }));
await dispatch(window, 'start', { authorization: 'user-action', requesterId: 'core.player.controls', target: makeTarget() });
const seek = await dispatch(window, 'seek', { requesterId: 'core.player.controls', time: 5 });
assert.equal(seek.status, 'completed');
assert.equal(diagnosticsSnapshot(window).state.state, 'playing');
window.slopsmith.playback.transportEvent('seeking', { requesterId: 'core.player.controls', media: { currentTime: 5 }, isPlaying: true });
assert.equal(diagnosticsSnapshot(window).state.state, 'seeking');
window.slopsmith.playback.transportEvent('seeked', { requesterId: 'core.player.controls', media: { currentTime: 9 }, isPlaying: true });
assert.equal(diagnosticsSnapshot(window).state.state, 'playing');
});
test('clear-loop requires an active playback session', async () => {
const window = loadPlayback();
window.slopsmith.playback.registerTransportAdapter(makeAdapter());
const cleared = await dispatch(window, 'clear-loop', { requesterId: 'core.player.controls' });
assert.equal(cleared.status, 'no-target');
});
test('ended transport events and seek failure/rollback outcomes are distinguishable', async () => {
const window = loadPlayback();
window.slopsmith.playback.registerTransportAdapter(makeAdapter({ seekResult: { completed: true, from: 5, to: 4.5 } }));
await dispatch(window, 'start', { authorization: 'user-action', requesterId: 'core.player.controls', target: makeTarget() });
const rolledBack = await dispatch(window, 'seek', { requesterId: 'core.player.controls', time: 8 });
assert.equal(rolledBack.status, 'rolled-back');
const failedWindow = loadPlayback();
failedWindow.slopsmith.playback.registerTransportAdapter(makeAdapter({ seekError: 'seek failed' }));
await dispatch(failedWindow, 'start', { authorization: 'user-action', requesterId: 'core.player.controls', target: makeTarget() });
const failed = await dispatch(failedWindow, 'seek', { requesterId: 'core.player.controls', time: 3 });
assert.equal(failed.status, 'failed');
assert.equal(failed.outcome, 'failed');
assert.equal(diagnosticsSnapshot(failedWindow).state.state, 'paused');
const unsupportedWindow = loadPlayback();
unsupportedWindow.slopsmith.playback.registerTransportAdapter({ inspect: () => ({ currentTime: 0, duration: 120, isPlaying: true }), start: () => ({ currentTime: 0, duration: 120, isPlaying: true }) });
await dispatch(unsupportedWindow, 'start', { authorization: 'user-action', requesterId: 'core.player.controls', target: makeTarget() });
const unsupported = await dispatch(unsupportedWindow, 'seek', { requesterId: 'core.player.controls', time: 3 });
assert.equal(unsupported.status, 'unsupported-command');
assert.equal(diagnosticsSnapshot(unsupportedWindow).state.state, 'playing');
const malformedWindow = loadPlayback();
malformedWindow.slopsmith.playback.registerTransportAdapter(makeAdapter({ seekResult: { completed: true, from: 2, to: NaN } }));
await dispatch(malformedWindow, 'start', { authorization: 'user-action', requesterId: 'core.player.controls', target: makeTarget() });
const malformed = await dispatch(malformedWindow, 'seek', { requesterId: 'core.player.controls', time: 6 });
assert.equal(malformed.status, 'failed');
assert.match(malformed.reason, /malformed seek result/i);
window.slopsmith.playback.transportEvent('ended', { requesterId: 'core.player.controls', currentTime: 120 });
assert.equal(diagnosticsSnapshot(window).state.state, 'ended');
});
test('invalid loop boundaries do not mutate an active loop', async () => {
const window = loadPlayback();
window.slopsmith.playback.registerTransportAdapter(makeAdapter());
await dispatch(window, 'start', { authorization: 'user-action', requesterId: 'core.player.controls', target: makeTarget() });
await dispatch(window, 'set-loop', { requesterId: 'core.player.controls', startTime: 2, endTime: 4 });
const rejected = await dispatch(window, 'set-loop', { requesterId: 'core.player.controls', startTime: 8, endTime: 4 });
const loop = diagnosticsSnapshot(window).state.loop;
assert.equal(rejected.status, 'rejected');
assert.equal(loop.state, 'active');
assert.equal(loop.startTime, 2);
assert.equal(loop.endTime, 4);
});
test('normal resume is denied after a user-priority pause until a user action resumes', async () => {
const window = loadPlayback();
window.slopsmith.playback.registerTransportAdapter(makeAdapter());
await dispatch(window, 'start', { authorization: 'user-action', requesterId: 'core.player.controls', target: makeTarget() });
await dispatch(window, 'pause', { requesterId: 'core.player.controls', priority: 'user' });
const blocked = await dispatch(window, 'resume', { requesterId: 'plugin.remote', priority: 'normal' });
assert.equal(blocked.status, 'denied');
const accepted = await dispatch(window, 'resume', { requesterId: 'core.player.controls', priority: 'user' });
assert.equal(accepted.status, 'playing');
});
test('stale and cancelled operations are reported distinctly', async () => {
const window = loadPlayback();
window.slopsmith.playback.registerTransportAdapter(makeAdapter({ seekResult: { completed: false, from: 2, to: NaN } }));
await dispatch(window, 'start', { authorization: 'user-action', requesterId: 'core.player.controls', target: makeTarget() });
const sessionId = diagnosticsSnapshot(window).state.sessionId;
const stale = await dispatch(window, 'pause', { requesterId: 'plugin.remote', sessionId: `${sessionId}-old` });
const cancelled = await dispatch(window, 'seek', { requesterId: 'core.player.controls', time: 8 });
assert.equal(stale.status, 'stale');
assert.equal(cancelled.status, 'cancelled');
});
+166
View File
@@ -0,0 +1,166 @@
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { createWindow, ROOT } = require('./capabilities_test_harness');
const CAPABILITIES_JS = path.join(ROOT, 'static', 'capabilities.js');
const PLAYBACK_JS = path.join(ROOT, 'static', 'capabilities', 'playback.js');
const INSPECTOR_JS = path.join(ROOT, 'plugins', 'capability_inspector', 'screen.js');
function loadPlayback(options = {}) {
const window = createWindow(options);
const context = vm.createContext(window);
vm.runInContext(fs.readFileSync(CAPABILITIES_JS, 'utf8'), context, { filename: CAPABILITIES_JS });
vm.runInContext(fs.readFileSync(PLAYBACK_JS, 'utf8'), context, { filename: PLAYBACK_JS });
window.__vmContext = context;
return window;
}
function runBrowserScript(window, filePath) {
vm.runInContext(fs.readFileSync(filePath, 'utf8'), window.__vmContext, { filename: filePath });
}
function loadInspector(window) {
runBrowserScript(window, INSPECTOR_JS);
return window;
}
function captureEvents(window, eventName) {
const events = [];
window.slopsmith.on(eventName, event => events.push(event.detail));
return events;
}
function diagnosticsSnapshot(window, options = {}) {
return window.slopsmith.playback.snapshot(options);
}
function dispatch(window, command, payload = {}, requester = 'test') {
return window.slopsmith.capabilities.dispatch({ capability: 'playback', command, args: payload, requester });
}
function makeTarget(overrides = {}) {
return {
filename: overrides.filename || '/Users/example/DLC/Secret Artist - Song_p.psarc',
title: overrides.title || 'Visible Title',
artist: overrides.artist || 'Visible Artist',
arrangement: overrides.arrangement || 'Lead',
arrangementIndex: overrides.arrangementIndex ?? 0,
format: overrides.format || 'psarc',
sourceKind: overrides.sourceKind || 'local',
...overrides,
};
}
function makeAdapter(overrides = {}) {
const calls = [];
let currentTime = overrides.currentTime ?? 0;
let duration = overrides.duration ?? 120;
let isPlaying = false;
let loop = { enabled: false, state: 'inactive' };
let route = { routeKind: 'browser-media', state: 'active', preservedTime: true };
const adapter = {
calls,
inspect() {
calls.push(['inspect']);
return overrides.inspectResult || { currentTime, mediaTime: currentTime, chartTime: currentTime, duration, playbackRate: 1, isPlaying, route, loop };
},
async start(request) {
calls.push(['start', request]);
isPlaying = !!overrides.startPlaying;
if (overrides.startError) throw new Error(overrides.startError);
return overrides.startResult || { currentTime, duration, isPlaying, route, loop };
},
async pause(request) {
calls.push(['pause', request]);
isPlaying = false;
if (overrides.pauseError) throw new Error(overrides.pauseError);
return overrides.pauseResult || { currentTime, duration, isPlaying, route, loop };
},
async resume(request) {
calls.push(['resume', request]);
if (overrides.resumeUnavailable) return { unavailable: true, reason: 'unavailable' };
if (overrides.resumeError) throw new Error(overrides.resumeError);
isPlaying = true;
return overrides.resumeResult || { currentTime, duration, isPlaying, route, loop };
},
async stop(request) {
calls.push(['stop', request]);
isPlaying = false;
if (overrides.stopError) throw new Error(overrides.stopError);
return overrides.stopResult || { currentTime, duration, isPlaying, route, loop };
},
async seek(request) {
calls.push(['seek', request]);
if (overrides.seekError) throw new Error(overrides.seekError);
if (overrides.seekResult) {
currentTime = overrides.seekResult.to ?? overrides.seekResult.landedTime ?? currentTime;
return overrides.seekResult;
}
const to = Math.max(0, Math.min(duration, Number(request.time)));
const from = currentTime;
currentTime = to;
return { completed: true, from, to };
},
async setLoop(request) {
calls.push(['setLoop', request]);
if (overrides.setLoopError) throw new Error(overrides.setLoopError);
if (overrides.setLoopResult !== undefined) return overrides.setLoopResult;
loop = { startTime: request.startTime, endTime: request.endTime, enabled: true, state: 'active' };
return true;
},
async clearLoop(request) {
calls.push(['clearLoop', request]);
loop = { enabled: false, state: 'cleared' };
return true;
},
};
return adapter;
}
function installInspectorDom(window) {
const elements = new Map();
function element(id) {
const item = {
id,
value: '',
textContent: '',
innerHTML: '',
className: '',
dataset: {},
style: {},
classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } },
addEventListener() {},
removeEventListener() {},
querySelectorAll() { return []; },
querySelector() { return null; },
closest() { return null; },
getBoundingClientRect() { return { width: 1000, height: 400, left: 0, top: 0 }; },
appendChild(child) { return child; },
};
elements.set(id, item);
return item;
}
element('capability-inspector-filter');
element('capability-inspector-content');
element('capability-inspector-empty');
element('capability-inspector-summary');
window.document.getElementById = id => elements.get(id) || null;
window.document.querySelectorAll = () => [];
window.document.addEventListener = () => {};
window.document.createElement = () => element(`created-${elements.size}`);
window.requestAnimationFrame = callback => callback();
return elements;
}
module.exports = {
ROOT,
loadPlayback,
loadInspector,
captureEvents,
diagnosticsSnapshot,
dispatch,
makeTarget,
makeAdapter,
installInspectorDom,
};
+230
View File
@@ -0,0 +1,230 @@
// Verify the plugin `styles` capability in static/app.js: _injectPluginStyles
// adds exactly one versioned <link rel="stylesheet"> per plugin, swaps it on a
// version upgrade (no duplicates, no stale tags), injects nothing for a plugin
// without `styles`, and routes the URL through the sandboxed asset endpoint.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
// Brace-balanced extraction of a `const NAME = (...) => { ... }` arrow, so a
// nested object/template literal can't make a naive regex stop early.
function extractConstArrow(src, name) {
const sig = `const ${name} = `;
const start = src.indexOf(sig);
assert.ok(start !== -1, `const arrow '${name}' not found`);
const openBrace = src.indexOf('{', src.indexOf('=>', start));
assert.ok(openBrace !== -1, `arrow body for '${name}' not found`);
let depth = 1;
let i = openBrace + 1;
while (i < src.length && depth > 0) {
const ch = src[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
assert.ok(depth === 0, `unbalanced braces in '${name}'`);
// include the trailing `;`
return src.slice(start, src.indexOf(';', i) + 1);
}
// Fresh sandbox per test so head state and the loadedStyles Map can't leak
// across cases. Returns the hoisted _injectPluginStyles plus the head array.
function setupSandbox() {
const headLinks = [];
const makeLink = () => ({
dataset: {},
rel: '',
href: '',
remove() {
const idx = headLinks.indexOf(this);
if (idx >= 0) headLinks.splice(idx, 1);
},
});
// Seed a stand-in for core's prebuilt <link href="/static/tailwind.min.css">
// so the ordering test can assert plugin sheets are inserted before it.
const seedCore = () => {
const core = makeLink();
core.rel = 'stylesheet';
core.href = '/static/tailwind.min.css';
headLinks.push(core);
return core;
};
// Minimal but faithful <head>: appendChild pushes to the end, insertBefore
// splices before the reference node, and querySelector resolves the two
// anchor selectors _injectPluginStyles uses to find core's stylesheet
// (the tailwind-specific one, then any stylesheet as a fallback).
const head = {
appendChild: (node) => { headLinks.push(node); },
insertBefore: (node, ref) => {
const i = ref ? headLinks.indexOf(ref) : -1;
if (i >= 0) headLinks.splice(i, 0, node);
else headLinks.push(node);
},
querySelector: (sel) => {
if (sel.includes('tailwind.min.css')) {
return headLinks.find((l) => (l.href || '').includes('tailwind.min.css')) || null;
}
return headLinks.find((l) => l.rel === 'stylesheet') || null;
},
};
const sandbox = {
console: { warn() {} },
encodeURIComponent,
document: {
head,
createElement: () => makeLink(),
// Production only ever queries 'link[data-plugin-id]'; return a
// copy so a remove() splice during forEach is safe.
querySelectorAll: () => headLinks.slice(),
},
};
vm.createContext(sandbox);
const src = fs.readFileSync(APP_JS, 'utf8');
const removeSrc = extractConstArrow(src, '_removePluginStyleTags');
const injectSrc = extractConstArrow(src, '_injectPluginStyles');
const reconcileSrc = extractConstArrow(src, '_reconcilePluginStyles');
// One script so all share a lexical scope (the arrows close over
// loadedStyles + _removePluginStyleTags), then hoist for the test to call.
vm.runInContext(
`const loadedStyles = new Map();\n${removeSrc}\n${injectSrc}\n${reconcileSrc}\n` +
`globalThis.__inject = _injectPluginStyles;\n` +
`globalThis.__reconcile = _reconcilePluginStyles;\n` +
`globalThis.__head = () => null;`,
sandbox,
);
return { inject: sandbox.__inject, reconcile: sandbox.__reconcile, headLinks, seedCore };
}
const plug = (over = {}) => ({
id: 'demo', version: '1', has_styles: true, styles: 'assets/plugin.css', ...over,
});
test('injects exactly one <link> for a plugin with styles', () => {
const { inject, headLinks } = setupSandbox();
inject(plug());
assert.equal(headLinks.length, 1);
const link = headLinks[0];
assert.equal(link.rel, 'stylesheet');
assert.equal(link.dataset.pluginId, 'demo');
assert.equal(link.dataset.pluginVersion, '1');
// Root-relative styles → routes through /api/plugins/{id}/assets/... (no
// doubled "assets/"), with the version as a cache-busting query.
assert.equal(link.href, '/api/plugins/demo/assets/plugin.css?v=1');
});
test('inserts the plugin <link> before core tailwind.min.css so core wins equal-specificity collisions', () => {
const { inject, headLinks, seedCore } = setupSandbox();
const core = seedCore();
inject(plug());
assert.equal(headLinks.length, 2, 'core link plus the plugin link');
const pluginIdx = headLinks.findIndex((l) => l.dataset.pluginId === 'demo');
const coreIdx = headLinks.indexOf(core);
assert.ok(pluginIdx >= 0, 'plugin <link> was injected');
assert.ok(pluginIdx < coreIdx, 'plugin <link> must precede core tailwind.min.css');
});
test('falls back to appendChild when no stylesheet <link> anchor exists in <head>', () => {
// With an empty <head>, both anchor queries (tailwind-specific, then any
// stylesheet) miss, so coreSheet is null and the link is appended.
const { inject, headLinks } = setupSandbox();
inject(plug());
assert.equal(headLinks.length, 1, 'still injects when there is no anchor to insert before');
assert.equal(headLinks[0].dataset.pluginId, 'demo');
});
test('is idempotent — re-activation does not duplicate the tag', () => {
const { inject, headLinks } = setupSandbox();
inject(plug());
inject(plug());
inject(plug());
assert.equal(headLinks.length, 1);
});
test('swaps the <link> on a version upgrade (no stale duplicates)', () => {
const { inject, headLinks } = setupSandbox();
inject(plug({ version: '1' }));
inject(plug({ version: '2' }));
assert.equal(headLinks.length, 1, 'old version <link> must be removed');
assert.equal(headLinks[0].dataset.pluginVersion, '2');
assert.equal(headLinks[0].href, '/api/plugins/demo/assets/plugin.css?v=2');
});
test('injects nothing for a plugin without styles (regression)', () => {
const { inject, headLinks } = setupSandbox();
inject({ id: 'plain', version: '1', has_styles: false });
inject({ id: 'plain2', version: '1' }); // has_styles undefined
assert.equal(headLinks.length, 0);
});
test('skips an unsafe styles path (not under assets/, traversal, backslash, query/fragment)', () => {
const { inject, headLinks } = setupSandbox();
const bad = [
'plugin.css', // not under assets/
'../routes.py', // not under assets/, traversal
'assets/../routes.py', // starts with assets/ but escapes via ..
'assets/..', // trailing traversal
'assets\\plugin.css', // backslash
'assets/plugin.css?x=1', // query char would collide with our ?v=
'assets/plugin.css#frag',// fragment
];
bad.forEach((styles, i) => inject(plug({ id: `bad${i}`, styles })));
assert.equal(headLinks.length, 0, 'no unsafe path should inject a <link>');
});
test('removes the stale <link> when a plugin upgrade drops styles', () => {
const { inject, headLinks } = setupSandbox();
inject(plug({ version: '1' }));
assert.equal(headLinks.length, 1);
// Same plugin re-processed after an in-session upgrade that no longer
// declares styles — the old stylesheet must be torn down, not left active.
inject({ id: 'demo', version: '2', has_styles: false });
assert.equal(headLinks.length, 0, 'stale stylesheet must be removed when styles disappear');
});
test('removes the stale <link> when a plugin upgrade points styles outside assets/', () => {
const { inject, headLinks } = setupSandbox();
inject(plug({ version: '1' }));
assert.equal(headLinks.length, 1);
// Upgrade to an unusable path → the prior valid <link> is torn down rather
// than left applying stale CSS.
inject(plug({ version: '2', styles: '../routes.py' }));
assert.equal(headLinks.length, 0);
});
test('does not collide tags across two different plugins', () => {
const { inject, headLinks } = setupSandbox();
inject(plug({ id: 'a' }));
inject(plug({ id: 'b' }));
assert.equal(headLinks.length, 2);
assert.deepEqual(headLinks.map((l) => l.dataset.pluginId).sort(), ['a', 'b']);
});
test('reconcile removes the <link> of a plugin that vanished from /api/plugins', () => {
const { inject, reconcile, headLinks } = setupSandbox();
inject(plug({ id: 'a' }));
inject(plug({ id: 'b' }));
assert.equal(headLinks.length, 2);
// `a` is no longer returned (uninstalled) — its stylesheet must be dropped.
reconcile([plug({ id: 'b' })]);
assert.equal(headLinks.length, 1);
assert.equal(headLinks[0].dataset.pluginId, 'b');
});
test('reconcile removes the <link> of a plugin that is no longer ready', () => {
const { inject, reconcile, headLinks } = setupSandbox();
inject(plug({ id: 'a' }));
reconcile([plug({ id: 'a', status: 'installing' })]);
assert.equal(headLinks.length, 0);
});
test('reconcile keeps a still-ready, still-styled plugin', () => {
const { inject, reconcile, headLinks } = setupSandbox();
inject(plug({ id: 'a' }));
reconcile([plug({ id: 'a' })]);
assert.equal(headLinks.length, 1);
});
+150
View File
@@ -0,0 +1,150 @@
// Verify the v3 Pedalboard Plugins page (static/v3/plugins-page.js) pure
// helpers: category resolution (manifest override > curated > derive > other),
// thumbnail URL building, click-target selection (settings vs screen vs none),
// drag clamping, default-flow slots, and the localStorage layout round-trip.
//
// The page exposes these helpers on window.v3PluginsPage._test, so we load the
// IIFE in a vm sandbox with a minimal window/document and read them back —
// no brace-extraction needed.
const { test } = require('node:test');
// Non-strict assert on purpose: helpers return objects created inside the vm
// realm, whose Object.prototype differs from this realm's — strict deepEqual
// would fail the prototype check even when the structure matches.
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const SRC = path.join(__dirname, '..', '..', 'static', 'v3', 'plugins-page.js');
function loadPage(opts) {
opts = opts || {};
const store = opts.store || {};
const win = { slopsmith: null };
win.localStorage = {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { if (opts.throwOnSet) throw new Error('quota'); store[k] = String(v); },
removeItem: (k) => { delete store[k]; },
};
win.addEventListener = () => {};
win.matchMedia = () => ({ matches: false });
const doc = {
readyState: 'complete',
getElementById: () => null, // render() bails: no #v3-plugins
querySelector: () => null,
addEventListener: () => {},
};
const ctx = {
window: win, document: doc, console,
fetch: () => Promise.reject(new Error('no fetch in test')),
requestAnimationFrame: () => 0,
setTimeout: () => 0, clearTimeout: () => {},
};
vm.createContext(ctx);
vm.runInContext(fs.readFileSync(SRC, 'utf8'), ctx, { filename: 'plugins-page.js' });
return { api: win.v3PluginsPage, t: win.v3PluginsPage._test, store, win };
}
test('categoryOf: manifest category is authoritative (lowercased)', () => {
const { t } = loadPage();
assert.equal(t.categoryOf({ id: 'whatever', category: 'Audio' }), 'audio');
// Even an unknown manifest category wins over the curated map.
assert.equal(t.categoryOf({ id: 'nam_tone', category: 'Custom' }), 'custom');
});
test('categoryOf: curated map, then type-derive, then other', () => {
const { t } = loadPage();
assert.equal(t.categoryOf({ id: 'nam_tone' }), 'audio');
assert.equal(t.categoryOf({ id: 'flappy_bend' }), 'game');
assert.equal(t.categoryOf({ id: 'editor' }), 'tools');
assert.equal(t.categoryOf({ id: 'unknown_x', type: 'visualization' }), 'creation');
assert.equal(t.categoryOf({ id: 'unknown_x' }), 'other');
assert.equal(t.categoryOf(null), 'other');
});
test('thumbUrl: manifest icon routes through the asset endpoint; else default', () => {
const { t } = loadPage();
assert.equal(t.thumbUrl({ id: 'flappy_bend', icon: 'assets/thumb.png' }),
'/api/plugins/flappy_bend/assets/thumb.png');
// Leading assets/ is stripped (route path is relative to assets/).
assert.equal(t.thumbUrl({ id: 'x', icon: 'assets/img/p.svg' }), '/api/plugins/x/assets/img/p.svg');
assert.equal(t.thumbUrl({ id: 'x' }), '/static/v3/pedal-default.svg');
assert.equal(t.thumbUrl({ id: 'x', icon: '' }), '/static/v3/pedal-default.svg');
});
test('settingsTarget: settings > screen > none', () => {
const { t } = loadPage();
assert.deepEqual(t.settingsTarget({ id: 'a', has_settings: true, nav: true }), { kind: 'settings', id: 'a' });
assert.deepEqual(t.settingsTarget({ id: 'b', has_settings: false, nav: true }), { kind: 'screen', id: 'b' });
assert.deepEqual(t.settingsTarget({ id: 'c', has_settings: false, has_screen: true }), { kind: 'screen', id: 'c' });
assert.deepEqual(t.settingsTarget({ id: 'd' }), { kind: 'none', id: 'd' });
});
test('clampToBoard: x clamped to [0, boardW-pedalW]; y floored at 0', () => {
const { t } = loadPage();
assert.deepEqual(t.clampToBoard({ x: 999, y: 50 }, 800, 150), { x: 650, y: 50 });
assert.deepEqual(t.clampToBoard({ x: -40, y: -10 }, 800, 150), { x: 0, y: 0 });
// Board narrower than a pedal → maxX floors at 0.
assert.deepEqual(t.clampToBoard({ x: 30, y: 5 }, 100, 150), { x: 0, y: 5 });
});
test('defaultSlot: padding origin; column count adapts to board width', () => {
const { t } = loadPage();
const s0 = t.defaultSlot(0, 1200);
assert.equal(s0.x, 24);
assert.equal(s0.y, 24);
// 2nd pedal is to the right on the same row (≥2 columns at this width).
const s1 = t.defaultSlot(1, 1200);
assert.ok(s1.x > s0.x);
assert.equal(s1.y, s0.y);
// Number of columns = index where the row first wraps. Wider board → more.
const cols = (bw) => { const y0 = t.defaultSlot(0, bw).y; let i = 1; while (i < 50 && t.defaultSlot(i, bw).y === y0) i++; return i; };
assert.ok(cols(2200) > cols(700), 'more columns on a wider board');
assert.ok(cols(700) >= 1);
});
test('loadLayout/saveLayout: round-trip + corruption tolerance', () => {
const { t, store } = loadPage();
assert.deepEqual(t.loadLayout(), {});
t.saveLayout({ audio: { nam_tone: { x: 10, y: 20 } } });
assert.deepEqual(t.loadLayout(), { audio: { nam_tone: { x: 10, y: 20 } } });
assert.ok(store[t.LS_KEY]);
// Corrupt JSON → empty object, no throw.
store[t.LS_KEY] = '{not json';
assert.deepEqual(t.loadLayout(), {});
});
test('saveLayout: swallows storage exceptions (quota / private mode)', () => {
const { t } = loadPage({ throwOnSet: true });
assert.doesNotThrow(() => t.saveLayout({ a: 1 }));
});
test('frameFor: assigns a pool skin, persists it, and sticks across calls', () => {
const { t } = loadPage();
const frames = {};
const f1 = t.frameFor('alpha', frames);
assert.ok(t.PEDAL_FRAMES.includes(f1), 'assigned skin is from the pool');
assert.equal(frames.alpha, f1, 'assignment recorded for persistence');
assert.equal(t.frameFor('alpha', frames), f1, 'same plugin keeps the same skin');
// A stale/invalid saved skin (e.g. removed from the pool) is re-picked.
frames.beta = 'gone.png';
const f3 = t.frameFor('beta', frames);
assert.ok(t.PEDAL_FRAMES.includes(f3), 're-picked a valid skin');
assert.ok(t.frameUrl('pedal-002.png').startsWith('/static/v3/pedals/pedal-002.png'));
});
test('loadCollapsed/saveCollapsed: round-trip + corruption tolerance', () => {
const { t, store } = loadPage();
assert.deepEqual(t.loadCollapsed(), {});
t.saveCollapsed({ audio: true });
assert.deepEqual(t.loadCollapsed(), { audio: true });
store[t.COLLAPSE_KEY] = '{bad';
assert.deepEqual(t.loadCollapsed(), {});
});
test('DRAG_THRESHOLD is a small positive pixel budget', () => {
const { t } = loadPage();
assert.equal(typeof t.DRAG_THRESHOLD, 'number');
assert.ok(t.DRAG_THRESHOLD > 0 && t.DRAG_THRESHOLD < 20);
});
+241
View File
@@ -0,0 +1,241 @@
// Lightweight contract tests for v3 Progress calibration retry/success modals.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { ROOT } = require('./capabilities_test_harness');
const PROGRESS_JS = path.join(ROOT, 'static', 'v3', 'progress.js');
function createProgressWindow(progressionState) {
class CustomEvent {
constructor(type, init = {}) {
this.type = type;
this.detail = init.detail;
}
}
const listeners = new Map();
const byId = new Map();
function wireElement(el) {
if (el.id) byId.set(el.id, el);
el.querySelector = (sel) => {
const m = sel.match(/^\[data-([^\]]+)\]$/);
if (!m) return null;
const attr = `data-${m[1]}`;
const walk = (node) => {
if (node.getAttribute && node.getAttribute(attr) != null) return node;
for (const child of (node.children || [])) {
const hit = walk(child);
if (hit) return hit;
}
return null;
};
return walk(el);
};
el.querySelectorAll = (sel) => {
const one = el.querySelector(sel);
return one ? [one] : [];
};
el.addEventListener = (type, handler) => {
const list = el.__handlers || (el.__handlers = {});
(list[type] || (list[type] = [])).push(handler);
};
el.remove = () => {
if (el.id) byId.delete(el.id);
const idx = bodyChildren.indexOf(el);
if (idx !== -1) bodyChildren.splice(idx, 1);
};
return el;
}
const bodyChildren = [];
const body = wireElement({
id: '',
tagName: 'BODY',
children: bodyChildren,
appendChild(child) {
wireElement(child);
bodyChildren.push(child);
child.parentNode = body;
return child;
},
});
const window = {
console,
CustomEvent,
setTimeout,
clearTimeout,
performance: { now: () => Date.now() },
document: {
readyState: 'complete',
body,
getElementById(id) { return byId.get(id) || null; },
createElement(tag) {
const el = {
id: '',
tagName: String(tag || '').toUpperCase(),
children: [],
_html: '',
className: '',
};
Object.defineProperty(el, 'innerHTML', {
get() { return el._html; },
set(html) {
el._html = html;
el.children.length = 0;
const btnRe = /<button[^>]*data-([^=\s]+)[^>]*>/g;
let m;
while ((m = btnRe.exec(html))) {
const attr = m[1];
const btn = wireElement({
tagName: 'BUTTON',
getAttribute(name) {
if (name === `data-${attr}`) return '';
return null;
},
addEventListener(type, handler) {
const list = this.__handlers || (this.__handlers = {});
(list[type] || (list[type] = [])).push(handler);
},
click() {
for (const h of (this.__handlers || {}).click || []) h();
},
disabled: false,
});
el.children.push(btn);
}
},
appendChild(child) {
wireElement(child);
el.children.push(child);
return child;
},
});
return wireElement(el);
},
addEventListener(type, handler) {
const list = listeners.get(type) || [];
list.push(handler);
listeners.set(type, list);
},
},
v3Progression: {
get() { return progressionState; },
refresh() { return Promise.resolve(progressionState); },
},
playSong() {},
slopsmith: {
on(type, handler) {
const list = listeners.get(type) || [];
list.push(handler);
listeners.set(type, list);
},
emit(type, detail) {
for (const handler of (listeners.get(type) || []).slice()) {
handler({ detail });
}
},
},
showScreen() {},
__listeners: listeners,
__byId: byId,
__bodyChildren: bodyChildren,
};
window.window = window;
window.globalThis = window;
const progressRoot = window.document.createElement('div');
progressRoot.id = 'v3-progress';
window.document.body.appendChild(progressRoot);
const context = vm.createContext(window);
vm.runInContext(fs.readFileSync(PROGRESS_JS, 'utf8'), context, { filename: PROGRESS_JS });
return window;
}
test('progression:calibration-attempt with 0.92 shows retry overlay and So close!', () => {
const win = createProgressWindow({
mastery_rank: 0,
onboarding: {
calibration_status: 'pending',
diagnostic_filename: 'diagnostics-builtin/slopsmith-diagnostic-basic-guitar.sloppak',
},
paths: [],
wallet: { balance: 0, lifetime_db: 0 },
quests: {},
});
win.slopsmith.emit('progression:calibration-attempt', { accuracy: 0.92 });
const overlay = win.document.getElementById('v3-calibration-retry');
assert.ok(overlay, 'retry overlay should exist');
assert.match(overlay.innerHTML, /So close!/);
assert.match(overlay.innerHTML, /92%/);
assert.equal(win.document.getElementById('v3-calibration-success'), null);
});
test('progression:calibration-completed shows success overlay and Setup verified!', () => {
const win = createProgressWindow({
mastery_rank: 0,
onboarding: {
calibration_status: 'pending',
diagnostic_filename: 'diagnostics-builtin/slopsmith-diagnostic-basic-guitar.sloppak',
},
paths: [],
wallet: { balance: 0, lifetime_db: 0 },
quests: {},
});
win.slopsmith.emit('progression:calibration-completed', {});
const overlay = win.document.getElementById('v3-calibration-success');
assert.ok(overlay, 'success overlay should exist');
assert.match(overlay.innerHTML, /Setup verified!/);
assert.match(overlay.innerHTML, /Mastery Rank 1 is ready/);
assert.equal(win.document.getElementById('v3-calibration-retry'), null);
});
test('success overlay for skipped state does not claim rank-up', () => {
const win = createProgressWindow({
mastery_rank: 1,
onboarding: {
calibration_status: 'skipped',
diagnostic_filename: 'diagnostics-builtin/slopsmith-diagnostic-basic-guitar.sloppak',
},
paths: [],
wallet: { balance: 0, lifetime_db: 0 },
quests: {},
});
win.slopsmith.emit('progression:calibration-completed', {});
const overlay = win.document.getElementById('v3-calibration-success');
assert.ok(overlay);
assert.match(overlay.innerHTML, /Your input and note detection setup is verified/);
assert.doesNotMatch(overlay.innerHTML, /Mastery Rank 1/);
assert.match(overlay.innerHTML, /Play again/);
});
test('calibration-completed does not stack duplicate success overlays', () => {
const win = createProgressWindow({
mastery_rank: 1,
onboarding: {
calibration_status: 'skipped',
diagnostic_filename: 'diagnostics-builtin/slopsmith-diagnostic-basic-guitar.sloppak',
},
paths: [],
wallet: { balance: 0, lifetime_db: 0 },
quests: {},
});
win.slopsmith.emit('progression:calibration-completed', {});
win.slopsmith.emit('progression:calibration-completed', {});
const overlays = win.__bodyChildren.filter((el) => el.id === 'v3-calibration-success');
assert.equal(overlays.length, 1);
});
+120
View File
@@ -0,0 +1,120 @@
// Verify the Settings-dropdown autosave path in static/app.js:
// persistSetting() must funnel one-field POSTs through a single chain so
// they hit the server one at a time, in call order, and a failed save
// must not poison the chain for later saves.
//
// Same isolation strategy as loop_api.test.js — extract the relevant
// functions by brace-matching and run them in a vm sandbox with a
// controllable fetch stub.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
function extractFunction(src, signature) {
const start = src.indexOf(signature);
if (start === -1) throw new Error(`extractFunction: '${signature}' not found in app.js`);
const openBrace = src.indexOf('{', start);
let depth = 1;
let i = openBrace + 1;
while (i < src.length && depth > 0) {
const ch = src[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
if (depth !== 0) throw new Error(`extractFunction: unbalanced braces after '${signature}'`);
return src.slice(start, i);
}
// Drain enough microtask hops for the promise chain (persistSetting →
// _settingSaveChain.then → _postSetting → await fetch → await resp.json)
// to settle. vm-sandbox promises share the host V8 microtask queue, so
// awaiting here advances them too.
async function flush() {
for (let i = 0; i < 30; i++) await Promise.resolve();
}
function buildSandbox() {
// Every fetch() call parks here as { body, resolve, reject } so the
// test controls exactly when each request settles.
const pending = [];
const status = { textContent: '' };
const sandbox = {
pending,
status,
document: {
getElementById: () => status,
},
fetch: (url, opts) => new Promise((resolve, reject) => {
pending.push({ body: JSON.parse(opts.body), resolve, reject });
}),
};
vm.createContext(sandbox);
return sandbox;
}
function loadFunctions(sandbox, src) {
const code = `
var _settingSaveChain = Promise.resolve();
${extractFunction(src, 'function persistSetting(')}
${extractFunction(src, 'async function _postSetting(')}
globalThis.__persistSetting = persistSetting;
`;
vm.runInContext(code, sandbox);
}
// Resolve a parked fetch as a successful /api/settings response.
function ok(entry, message = 'Settings saved') {
entry.resolve({ json: async () => ({ message }) });
}
test('persistSetting sends one POST at a time, in call order', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const sandbox = buildSandbox();
loadFunctions(sandbox, src);
sandbox.__persistSetting('default_arrangement', 'Lead');
sandbox.__persistSetting('demucs_server_url', 'http://example:7865');
await flush();
// The second POST must not be in flight until the first resolves.
assert.equal(sandbox.pending.length, 1, 'only the first POST should be in flight');
assert.deepEqual(sandbox.pending[0].body, { default_arrangement: 'Lead' });
ok(sandbox.pending[0]);
await flush();
assert.equal(sandbox.pending.length, 2, 'second POST runs after the first settles');
assert.deepEqual(sandbox.pending[1].body, { demucs_server_url: 'http://example:7865' });
ok(sandbox.pending[1]);
await flush();
});
test('a failed save does not block later saves on the chain', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const sandbox = buildSandbox();
loadFunctions(sandbox, src);
sandbox.__persistSetting('default_arrangement', 'Bass');
sandbox.__persistSetting('demucs_server_url', 'http://example:9000');
await flush();
assert.equal(sandbox.pending.length, 1);
// First request fails outright (network error).
sandbox.pending[0].reject(new Error('network down'));
await flush();
assert.equal(sandbox.pending.length, 2, 'second save still proceeds after the first fails');
assert.deepEqual(sandbox.pending[1].body, { demucs_server_url: 'http://example:9000' });
assert.match(sandbox.status.textContent, /Save failed/, 'failure surfaces in the status line');
ok(sandbox.pending[1]);
await flush();
assert.equal(sandbox.status.textContent, 'Settings saved', 'later save still reports success');
});
+101
View File
@@ -0,0 +1,101 @@
// Verify closeCurrentSong() exits via origin-aware showScreen without
// restart, seek, playSong reload, or direct audio mutation.
//
// Same isolation strategy as song_restart.test.js — extract the function
// from app.js by brace-matching and run it in a vm sandbox.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { extractFunction } = require('./test_utils');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
function buildSandbox({ playerOriginScreen = 'home' } = {}) {
const sandbox = {
_playerOriginScreen: playerOriginScreen,
__showScreenCalls: [],
__restartCalls: 0,
__seekCalls: 0,
__playSongCalls: 0,
__clearLoopCalls: 0,
__audioCurrentTimeSets: [],
audio: {
_t: 42,
get currentTime() { return sandbox.audio._t; },
set currentTime(v) { sandbox.__audioCurrentTimeSets.push(v); sandbox.audio._t = v; },
},
};
vm.createContext(sandbox);
return sandbox;
}
function loadClose(sandbox, src) {
const closeSrc = extractFunction(src, 'function closeCurrentSong(');
const code = `
var _playerOriginScreen = ${JSON.stringify(sandbox._playerOriginScreen)};
globalThis.__showScreenCalls = [];
globalThis.__restartCalls = 0;
globalThis.__seekCalls = 0;
globalThis.__playSongCalls = 0;
globalThis.__clearLoopCalls = 0;
globalThis.__audioCurrentTimeSets = [];
var audio = {
_t: 42,
get currentTime() { return this._t; },
set currentTime(v) { globalThis.__audioCurrentTimeSets.push(v); this._t = v; }
};
function showScreen(id) {
globalThis.__showScreenCalls.push(id);
return Promise.resolve();
}
function restartCurrentSong() { globalThis.__restartCalls++; }
async function _audioSeek() { globalThis.__seekCalls++; }
function playSong() { globalThis.__playSongCalls++; }
function clearLoop() { globalThis.__clearLoopCalls++; }
${closeSrc}
globalThis.__closeCurrentSong = closeCurrentSong;
`;
vm.runInContext(code, sandbox);
}
test('closeCurrentSong is exported on window and window.slopsmith', () => {
const src = fs.readFileSync(APP_JS, 'utf8');
assert.match(src, /window\.closeCurrentSong\s*=\s*closeCurrentSong/);
assert.match(src, /window\.slopsmith\.closeCurrentSong\s*=\s*closeCurrentSong/);
});
test('closeCurrentSong uses _playerOriginScreen when set', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const sandbox = buildSandbox({ playerOriginScreen: 'favorites' });
loadClose(sandbox, src);
await sandbox.__closeCurrentSong();
assert.equal(sandbox.__showScreenCalls.length, 1);
assert.equal(sandbox.__showScreenCalls[0], 'favorites');
assert.equal(sandbox.__restartCalls, 0);
assert.equal(sandbox.__seekCalls, 0);
assert.equal(sandbox.__playSongCalls, 0);
assert.equal(sandbox.__clearLoopCalls, 0);
assert.equal(sandbox.__audioCurrentTimeSets.length, 0);
});
test('closeCurrentSong falls back to home when origin missing', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const sandbox = buildSandbox({ playerOriginScreen: null });
loadClose(sandbox, src);
await sandbox.__closeCurrentSong();
assert.equal(sandbox.__showScreenCalls.length, 1);
assert.equal(sandbox.__showScreenCalls[0], 'home');
});
test('closeCurrentSong falls back to home when origin is empty string', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const sandbox = buildSandbox({ playerOriginScreen: '' });
loadClose(sandbox, src);
await sandbox.__closeCurrentSong();
assert.equal(sandbox.__showScreenCalls.length, 1);
assert.equal(sandbox.__showScreenCalls[0], 'home');
});
+147
View File
@@ -0,0 +1,147 @@
// Verify song:play / song:pause / song:ended carry the enriched
// payload { time, audioT, chartT, perfNow } so plugins can anchor
// their own clocks without a follow-up highway.getTime() call.
//
// Same isolation strategy as the other plugin-API tests — extract
// `_songEventPayload` and `_audioTime` from app.js and run them in a
// vm sandbox.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
function extractFunction(src, signature) {
const start = src.indexOf(signature);
if (start === -1) throw new Error(`extractFunction: '${signature}' not found in app.js`);
const openBrace = src.indexOf('{', start);
let depth = 1;
let i = openBrace + 1;
while (i < src.length && depth > 0) {
const ch = src[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
if (depth !== 0) throw new Error(`extractFunction: unbalanced braces after '${signature}'`);
return src.slice(start, i);
}
function buildSandbox({ juceMode = false, audioT = 12.5, chartT = 11.8, juceT } = {}) {
// When juceT is omitted in JUCE mode, derive a value distinct from
// audioT so the JUCE-mode test actually proves _audioTime() reads
// from jucePlayer rather than the html5 audio element.
const jt = juceT !== undefined ? juceT : (juceMode ? audioT + 100 : audioT);
const sandbox = {
audio: { currentTime: audioT },
jucePlayer: { currentTime: jt, duration: 200 },
window: { _juceMode: juceMode },
highway: {
getTime: () => chartT,
},
performance: { now: () => 1000.123 },
};
vm.createContext(sandbox);
return sandbox;
}
function loadFunctions(sandbox, src) {
const code = `
${extractFunction(src, 'function _audioTime()')}
${extractFunction(src, 'function _audioDuration()')}
${extractFunction(src, 'function _songEventPayload()')}
globalThis.__payload = _songEventPayload;
`;
vm.runInContext(code, sandbox);
}
test('_songEventPayload returns { time, audioT, chartT, perfNow } (HTML5)', () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const sandbox = buildSandbox({ juceMode: false, audioT: 12.5, chartT: 11.8 });
loadFunctions(sandbox, src);
const p = sandbox.__payload();
assert.equal(p.audioT, 12.5);
assert.equal(p.chartT, 11.8);
assert.equal(p.perfNow, 1000.123);
assert.equal(p.time, 12.5, 'time must be an alias for audioT');
assert.equal(Object.keys(p).length, 4);
});
test('_songEventPayload reads from JUCE in juce mode', () => {
const src = fs.readFileSync(APP_JS, 'utf8');
// audioT (audio.currentTime) and juceT (jucePlayer.currentTime) are
// distinct so the assertion proves we read from JUCE, not from the
// html5 audio element.
const sandbox = buildSandbox({ juceMode: true, audioT: 5, juceT: 42, chartT: 41 });
loadFunctions(sandbox, src);
const p = sandbox.__payload();
assert.equal(p.audioT, 42, 'JUCE mode must read jucePlayer.currentTime, not audio.currentTime');
assert.equal(p.time, 42);
assert.equal(p.chartT, 41);
});
test('time and audioT are the same number (not duplicated computation)', () => {
// Cache invariant: audioT is read once and assigned to both fields.
// If the implementation read _audioTime() twice and the underlying
// value drifted between reads, time !== audioT. Guard the cache.
const src = fs.readFileSync(APP_JS, 'utf8');
let reads = 0;
const sandbox = {
audio: { get currentTime() { reads++; return 5 + reads * 0.001; } },
jucePlayer: { currentTime: 0 },
window: { _juceMode: false },
highway: { getTime: () => 4.9 },
performance: { now: () => 1000 },
};
vm.createContext(sandbox);
loadFunctions(sandbox, src);
const p = sandbox.__payload();
assert.equal(p.time, p.audioT, 'time must equal audioT');
});
test('every song:play/pause/ended emit uses _songEventPayload', () => {
// Source-level guard: catch a future contributor adding a new emit
// site with a literal { time: x } payload — that would silently drop
// chartT/perfNow and break plugins that depend on the enriched shape.
// Accepts either a direct _songEventPayload() call or a captured
// `payload` var (used by JUCE teardown sites that snapshot before
// jucePlayer.stop() resets _pos to 0).
const src = fs.readFileSync(APP_JS, 'utf8');
const lines = src.split('\n');
// Accept aliased calls like `sm.emit(...)` (the JUCE shim caches
// window.slopsmith in `sm`) — not just literal `window.slopsmith.emit`.
const emitRe = /(?:window\.slopsmith|\w+)\.emit\(\s*['"]song:(play|pause|ended)['"]/;
const okRe = /_songEventPayload\(\)|,\s*payload\s*\)/;
const offending = [];
for (const line of lines) {
if (!emitRe.test(line)) continue;
if (!okRe.test(line)) {
offending.push(line.trim());
}
}
assert.equal(
offending.length,
0,
`song:* emits not using _songEventPayload():\n${offending.join('\n')}`,
);
});
test('there are at least 8 song:* emit sites threaded through the helper', () => {
// Sanity-check that the helper actually got wired everywhere. If the
// count drops, someone removed an emit (regression) or refactored an
// event away (intentional — this test then needs updating).
const src = fs.readFileSync(APP_JS, 'utf8');
const matches = src.match(/(?:window\.slopsmith|\w+)\.emit\(\s*['"]song:(play|pause|ended)['"][^)]*\)/g) || [];
assert.ok(
matches.length >= 8,
`expected ≥8 song:* emits, found ${matches.length}`,
);
// Same dual-form acceptance as the per-line check: either a direct
// _songEventPayload() call or a captured `payload` var.
for (const m of matches) {
assert.match(m, /_songEventPayload\(\)|,\s*payload\s*\)/, `emit not using helper: ${m}`);
}
});
+147
View File
@@ -0,0 +1,147 @@
// Verify restartCurrentSong() uses the canonical _audioSeek / togglePlay /
// startCountIn paths without clearing loops or reloading the song.
//
// Same isolation strategy as song_seek.test.js — extract the function from
// app.js by brace-matching and run it in a vm sandbox with stubbed deps.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { extractFunction } = require('./test_utils');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
const V3_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
const V2_HTML = path.join(__dirname, '..', '..', 'static', 'index.html');
function buildSandbox({ loopA = null, loopB = null, isPlaying = false } = {}) {
const sandbox = {
loopA,
loopB,
isPlaying,
__cancelCountInCalls: 0,
__seekCalls: [],
__startCountInCalls: [],
__togglePlayCalls: 0,
__clearLoopCalls: 0,
window: {
slopsmith: {
getLoop() {
return { loopA: sandbox.loopA, loopB: sandbox.loopB };
},
},
},
__audioSeek(s, reason) {
sandbox.__seekCalls.push({ s, reason });
return Promise.resolve({ completed: true, from: 30, to: s });
},
__startCountIn(opts) {
sandbox.__startCountInCalls.push(opts);
return Promise.resolve();
},
__togglePlay() {
sandbox.__togglePlayCalls++;
sandbox.isPlaying = true;
return Promise.resolve();
},
};
vm.createContext(sandbox);
return sandbox;
}
function loadRestart(sandbox, src, { audioSeekImpl } = {}) {
const restartSrc = extractFunction(src, 'async function restartCurrentSong(');
const code = `
var isPlaying = ${sandbox.isPlaying};
function _cancelCountIn() { __cancelCountInCalls++; }
async function _audioSeek(s, reason) {
return (${audioSeekImpl || '__audioSeek'})(s, reason);
}
async function startCountIn(opts) { return __startCountIn(opts); }
async function togglePlay() { return __togglePlay(); }
function clearLoop() { __clearLoopCalls++; }
${restartSrc}
globalThis.__restartCurrentSong = restartCurrentSong;
`;
vm.runInContext(code, sandbox);
}
test('restartCurrentSong is exported on window and window.slopsmith', () => {
const src = fs.readFileSync(APP_JS, 'utf8');
assert.match(src, /window\.restartCurrentSong\s*=\s*restartCurrentSong/);
assert.match(src, /window\.slopsmith\.restartCurrentSong\s*=\s*restartCurrentSong/);
});
test('no loop: seeks to 0 with song-restart and starts playback when stopped', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const sandbox = buildSandbox({ isPlaying: false });
loadRestart(sandbox, src);
const ok = await sandbox.__restartCurrentSong();
assert.equal(ok, true);
assert.equal(sandbox.__cancelCountInCalls, 1);
assert.equal(sandbox.__seekCalls.length, 1);
assert.equal(sandbox.__seekCalls[0].s, 0);
assert.equal(sandbox.__seekCalls[0].reason, 'song-restart');
assert.equal(sandbox.__togglePlayCalls, 1);
assert.equal(sandbox.__startCountInCalls.length, 0);
assert.equal(sandbox.__clearLoopCalls, 0);
});
test('already playing, no loop: seeks to 0 and does not toggle play', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const sandbox = buildSandbox({ isPlaying: true });
loadRestart(sandbox, src);
const ok = await sandbox.__restartCurrentSong();
assert.equal(ok, true);
assert.equal(sandbox.__seekCalls[0].s, 0);
assert.equal(sandbox.__togglePlayCalls, 0);
assert.equal(sandbox.__clearLoopCalls, 0);
});
test('loop armed: seeks to loopA, preserves loop, re-enters via startCountIn immediate', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const sandbox = buildSandbox({ loopA: 12.5, loopB: 48, isPlaying: false });
loadRestart(sandbox, src);
const ok = await sandbox.__restartCurrentSong();
assert.equal(ok, true);
assert.equal(sandbox.__seekCalls.length, 1);
assert.equal(sandbox.__seekCalls[0].s, 12.5);
assert.equal(sandbox.__seekCalls[0].reason, 'song-restart');
assert.equal(sandbox.__startCountInCalls.length, 1);
assert.equal(sandbox.__startCountInCalls[0].immediate, true);
assert.equal(sandbox.__togglePlayCalls, 0);
assert.equal(sandbox.__clearLoopCalls, 0);
assert.equal(sandbox.loopB, 48, 'loopB must be preserved');
});
test('failed/incomplete seek: does not start playback or count-in', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const sandbox = buildSandbox({ isPlaying: false });
loadRestart(sandbox, src, {
audioSeekImpl: '(s, reason) => Promise.resolve({ completed: false, from: NaN, to: NaN })',
});
const ok = await sandbox.__restartCurrentSong();
assert.equal(ok, false);
assert.equal(sandbox.__togglePlayCalls, 0);
assert.equal(sandbox.__startCountInCalls.length, 0);
});
test('V3 transport restart button exists with correct attributes', () => {
const html = fs.readFileSync(V3_HTML, 'utf8');
assert.match(html, /v3-transport-mid[\s\S]*onclick="restartCurrentSong\(\)"/);
assert.match(html, /title="Restart song"/);
assert.match(html, /aria-label="Restart song"/);
});
test('V2 transport restart button exists with correct attributes', () => {
const html = fs.readFileSync(V2_HTML, 'utf8');
assert.match(html, /#player-controls|player-controls[\s\S]*onclick="restartCurrentSong\(\)"/);
assert.match(html, /title="Restart song"/);
assert.match(html, /aria-label="Restart song"/);
});
+305
View File
@@ -0,0 +1,305 @@
// Verify static/app.js emits `song:seek` for every audio repositioning,
// with `{ from, to, reason }` payload. Plugins (notedetect detection-
// suppression during seek transients) consume this contract.
//
// Same isolation strategy as loop_restart.test.js — extract the relevant
// functions from app.js by brace-matching and run them in a vm sandbox.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
function extractFunction(src, signature) {
const start = src.indexOf(signature);
if (start === -1) throw new Error(`extractFunction: '${signature}' not found in app.js`);
const openBrace = src.indexOf('{', start);
let depth = 1;
let i = openBrace + 1;
while (i < src.length && depth > 0) {
const ch = src[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
if (depth !== 0) throw new Error(`extractFunction: unbalanced braces after '${signature}'`);
return src.slice(start, i);
}
function buildSandbox({ juceMode = false, currentTime = 10, duration = Infinity } = {}) {
const emitCalls = [];
const audio = {
duration,
get currentTime() { return audio._t; },
// Clamp to [0, duration] like a real <audio> element so the
// post-seek readback for `to` reflects the landed position
// (the browser snaps out-of-range writes to the seekable range).
set currentTime(v) {
audio._t = Math.max(0, Math.min(v, audio.duration));
},
_t: currentTime,
};
const jucePlayer = {
currentTime: currentTime,
seek(s) {
// Async like the real one; mutate currentTime so subsequent
// _audioTime() reads see the new value.
return Promise.resolve().then(() => { jucePlayer.currentTime = s; });
},
};
const sandbox = {
audio,
jucePlayer,
window: {
_juceMode: juceMode,
slopsmith: {
emit(event, detail) { emitCalls.push({ event, detail }); },
},
},
__emitCalls: emitCalls,
// _juceSeekWithTimeout uses setTimeout for its Promise.race;
// expose Node's setTimeout to the vm context.
setTimeout,
clearTimeout,
Promise,
};
vm.createContext(sandbox);
return sandbox;
}
function loadFunctions(sandbox, src) {
const code = `
let _audioSeekChain = Promise.resolve();
let _audioSeekGen = 0;
// _audioSeek now syncs the jump-fix tracker so far seeks don't
// trigger an immediate revert; declare it here so the sandbox
// assignment lands on a real binding rather than an implicit global.
let lastAudioTime = 0;
// _audioSeek wraps jucePlayer.seek in a timeout race; pull in the
// helper + constant. Tests can override jucePlayer.seek to vary
// behavior; the timeout (2 s) is well above any test setTimeout.
const _JUCE_SEEK_TIMEOUT_MS = 2000;
${extractFunction(src, 'function _juceSeekWithTimeout(')}
${extractFunction(src, 'function _audioTime()')}
${extractFunction(src, 'function _audioDuration()')}
${extractFunction(src, 'async function _audioSeek(')}
${extractFunction(src, 'async function seekBy(')}
globalThis.__audioSeek = _audioSeek;
globalThis.__seekBy = seekBy;
// Mirror _resetAudioSeekState exactly: bump only — chain stays so
// new seeks queue behind in-flight ones and don't race the IPC.
globalThis.__bumpGen = () => { _audioSeekGen++; };
`;
vm.runInContext(code, sandbox);
}
test('_audioSeek emits song:seek with from/to/reason (HTML5 path)', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const sandbox = buildSandbox({ juceMode: false, currentTime: 10 });
loadFunctions(sandbox, src);
await sandbox.__audioSeek(42, 'unit-test');
const seeks = sandbox.__emitCalls.filter((c) => c.event === 'song:seek');
assert.equal(seeks.length, 1);
assert.equal(seeks[0].detail.from, 10);
assert.equal(seeks[0].detail.to, 42);
assert.equal(seeks[0].detail.reason, 'unit-test');
assert.equal(sandbox.audio._t, 42, 'HTML5 audio.currentTime must be assigned');
});
test('_audioSeek emits song:seek (JUCE path) after seek promise resolves', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const sandbox = buildSandbox({ juceMode: true, currentTime: 5 });
loadFunctions(sandbox, src);
await sandbox.__audioSeek(99, 'juce-test');
const seeks = sandbox.__emitCalls.filter((c) => c.event === 'song:seek');
assert.equal(seeks.length, 1);
assert.equal(seeks[0].detail.from, 5);
assert.equal(seeks[0].detail.to, 99);
assert.equal(seeks[0].detail.reason, 'juce-test');
assert.equal(sandbox.jucePlayer.currentTime, 99, 'JUCE player position must be advanced');
});
test('concurrent _audioSeek calls serialize and capture from atomically', async () => {
// Without serialization, two overlapping JUCE seeks would both read
// `from` before either resolved, making the second emit's `from` stale.
// The chain ensures each call's from/to bracket only its own seek.
const src = fs.readFileSync(APP_JS, 'utf8');
const sandbox = buildSandbox({ juceMode: true, currentTime: 0 });
// Make jucePlayer.seek slow so the second call genuinely overlaps the
// first if there's no serialization.
sandbox.jucePlayer.seek = (s) => new Promise((resolve) => setTimeout(() => {
sandbox.jucePlayer.currentTime = s;
resolve();
}, 5));
loadFunctions(sandbox, src);
// Fire two seeks back-to-back without awaiting the first.
const p1 = sandbox.__audioSeek(10, 'first');
const p2 = sandbox.__audioSeek(20, 'second');
await Promise.all([p1, p2]);
const seeks = sandbox.__emitCalls.filter((c) => c.event === 'song:seek');
assert.equal(seeks.length, 2);
// First seek: from=0 (initial), to=10
assert.equal(seeks[0].detail.from, 0);
assert.equal(seeks[0].detail.to, 10);
assert.equal(seeks[0].detail.reason, 'first');
// Second seek: from=10 (post-first, captured INSIDE the chain), to=20
assert.equal(seeks[1].detail.from, 10, 'second seek must capture from after first resolved');
assert.equal(seeks[1].detail.to, 20);
assert.equal(seeks[1].detail.reason, 'second');
});
test('queued seeks cancel cleanly when generation bumps mid-flight', async () => {
// Simulates song teardown: a seek is enqueued, then the generation
// bumps before the seek's chain callback runs. The pending callback
// must bail out — no song:seek emit, no mutation of the new session's
// currentTime.
const src = fs.readFileSync(APP_JS, 'utf8');
const sandbox = buildSandbox({ juceMode: true, currentTime: 5 });
sandbox.jucePlayer.seek = (s) => new Promise((resolve) => setTimeout(() => {
sandbox.jucePlayer.currentTime = s;
resolve();
}, 5));
loadFunctions(sandbox, src);
// Enqueue a seek but bump the generation before the chain's microtask runs.
const p = sandbox.__audioSeek(99, 'cancel-test');
sandbox.__bumpGen();
const result = await p;
const seeks = sandbox.__emitCalls.filter((c) => c.event === 'song:seek');
assert.equal(seeks.length, 0, 'cancelled seek must not emit song:seek');
assert.equal(sandbox.jucePlayer.currentTime, 5, 'cancelled seek must not advance currentTime');
assert.equal(result.completed, false, 'cancelled seek must resolve to {completed: false} so callers can bail');
});
test('queued seek bails when generation bumps DURING the JUCE seek', async () => {
// Covers the second gen-check (the one after `await jucePlayer.seek`).
// The previous test bumps before the chain callback starts; this one
// lets the callback enter, reach the seek await, and then bumps so the
// post-await guard is what catches the cancellation.
const src = fs.readFileSync(APP_JS, 'utf8');
const sandbox = buildSandbox({ juceMode: true, currentTime: 5 });
let bumpedDuringSeek = false;
sandbox.jucePlayer.seek = (s) => new Promise((resolve) => setTimeout(() => {
sandbox.jucePlayer.currentTime = s;
// Bump while the seek is mid-flight: we're past the first guard
// (it ran when the chain callback entered), but before the second.
if (!bumpedDuringSeek) { sandbox.__bumpGen(); bumpedDuringSeek = true; }
resolve();
}, 5));
loadFunctions(sandbox, src);
const result = await sandbox.__audioSeek(99, 'mid-seek-cancel');
assert.equal(bumpedDuringSeek, true, 'sanity: bump must have fired inside the seek');
const seeks = sandbox.__emitCalls.filter((c) => c.event === 'song:seek');
assert.equal(seeks.length, 0, 'mid-seek cancel must not emit song:seek');
assert.equal(result.completed, false, 'mid-seek cancel must resolve to {completed: false}');
});
test('_audioSeek resolves to {completed, from, to} on a successful run', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const sandbox = buildSandbox({ juceMode: false, currentTime: 5 });
loadFunctions(sandbox, src);
const result = await sandbox.__audioSeek(10, 'success-test');
assert.equal(result.completed, true, 'completed seek must resolve to completed:true');
assert.equal(result.from, 5, 'from must be the pre-seek clock');
assert.equal(result.to, 10, 'to must be the verified post-seek clock');
});
test('_audioSeek emits the landed clock when HTML5 clamps to duration', async () => {
// Regression: the HTML5 path's `to` must reflect the actual landed
// position (clamped to seekable range), not the requested target.
const src = fs.readFileSync(APP_JS, 'utf8');
const sandbox = buildSandbox({ juceMode: false, currentTime: 10, duration: 30 });
loadFunctions(sandbox, src);
await sandbox.__audioSeek(99, 'html5-clamp');
const seek = sandbox.__emitCalls.find((c) => c.event === 'song:seek');
assert.equal(seek.detail.from, 10);
assert.equal(seek.detail.to, 30, 'to must be the clamped landed position, not the requested target');
});
test('_audioSeek emits the verified post-seek clock when JUCE rolls back', async () => {
// Regression: `to` must reflect the actual position after seek, not
// the requested `s`. JUCE may clamp or no-op a seek (engine state
// mismatch, end-of-track, etc.); plugins that act on `to` would
// otherwise see a phantom jump.
const src = fs.readFileSync(APP_JS, 'utf8');
const sandbox = buildSandbox({ juceMode: true, currentTime: 7 });
sandbox.jucePlayer.seek = (s) => Promise.resolve(); // no-op: currentTime stays at 7
loadFunctions(sandbox, src);
await sandbox.__audioSeek(42, 'rollback-test');
const seek = sandbox.__emitCalls.find((c) => c.event === 'song:seek');
assert.equal(seek.detail.from, 7);
assert.equal(seek.detail.to, 7, 'to must equal post-seek clock, not requested s');
assert.equal(seek.detail.reason, 'rollback-test');
});
test('_audioSeek without reason emits reason: null', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const sandbox = buildSandbox();
loadFunctions(sandbox, src);
await sandbox.__audioSeek(20);
const seek = sandbox.__emitCalls.find((c) => c.event === 'song:seek');
assert.equal(seek.detail.reason, null);
});
test('seekBy routes through _audioSeek with reason "seek-by"', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const sandbox = buildSandbox({ juceMode: false, currentTime: 10 });
loadFunctions(sandbox, src);
await sandbox.__seekBy(5);
const seeks = sandbox.__emitCalls.filter((c) => c.event === 'song:seek');
assert.equal(seeks.length, 1, 'seekBy must trigger exactly one song:seek emit');
assert.equal(seeks[0].detail.from, 10);
assert.equal(seeks[0].detail.to, 15);
assert.equal(seeks[0].detail.reason, 'seek-by');
});
test('seekBy floors at zero (does not seek to negative time)', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const sandbox = buildSandbox({ juceMode: false, currentTime: 2 });
loadFunctions(sandbox, src);
await sandbox.__seekBy(-10);
const seek = sandbox.__emitCalls.find((c) => c.event === 'song:seek');
assert.equal(seek.detail.to, 0);
});
test('every documented seek callsite passes a reason', () => {
// Source-order assertion: every _audioSeek call outside the
// implementation must pass a kebab-case reason string. Catches a
// future contributor adding a new seek path without threading the
// reason. Line-based — regex argument capture can't balance parens
// through Math.max/_audioTime calls.
const src = fs.readFileSync(APP_JS, 'utf8');
const fnSrc = extractFunction(src, 'async function _audioSeek(');
const withoutImpl = src.replace(fnSrc, '');
const callLines = withoutImpl.split('\n').filter((l) => /_audioSeek\(/.test(l));
assert.ok(callLines.length >= 5, `expected ≥5 _audioSeek call lines, found ${callLines.length}`);
for (const line of callLines) {
assert.match(
line,
/['"][a-z]+(?:-[a-z]+)+['"]/,
`_audioSeek call missing kebab-case reason arg: ${line.trim()}`,
);
}
});
+195
View File
@@ -0,0 +1,195 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
function extractFunction(src, signature) {
const start = src.indexOf(signature);
if (start === -1) throw new Error(`extractFunction: '${signature}' not found in app.js`);
let scan = start + signature.length;
if (src[scan] === '(') {
let parenDepth = 1;
scan++;
while (scan < src.length && parenDepth > 0) {
const ch = src[scan];
if (ch === '(') parenDepth++;
else if (ch === ')') parenDepth--;
scan++;
}
}
const openBrace = src.indexOf('{', scan);
let depth = 1;
let i = openBrace + 1;
while (i < src.length && depth > 0) {
const ch = src[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
if (depth !== 0) throw new Error(`extractFunction: unbalanced braces after '${signature}'`);
return src.slice(start, i);
}
function buildSandbox({ juceMode = false } = {}) {
const elements = new Map();
const makeElement = (id) => ({
id,
value: id === 'speed-slider' ? 135 : '',
textContent: id === 'speed-label' ? '1.35x' : '',
style: {},
});
for (const id of ['speed-slider', 'speed-label', 'quality-select', 'highway']) {
elements.set(id, makeElement(id));
}
elements.set('speed-presets', {
id: 'speed-presets',
querySelectorAll() { return []; },
});
const backingCalls = [];
const sliderInputs = [];
const audio = {
playbackRate: 1.35,
pause() {},
};
const jucePlayer = {
_speed: 1.35,
setRate(rate) { this._speed = rate; },
stop() { return Promise.resolve(); },
};
const sandbox = {
console: { log() {}, warn() {}, error() {} },
audio,
jucePlayer,
highway: {
stop() {},
init() {},
connect(url) { sandbox.__connectedUrl = url; },
getRenderScale: () => 1,
},
window: {
_juceMode: juceMode,
_juceAudioUrl: juceMode ? '/audio/old-song.ogg' : null,
_currentSongAudio: { url: '/audio/old-song.ogg' },
_clearJuceRerouteMemo() {},
slopsmith: {
isPlaying: true,
emit() {},
},
slopsmithDesktop: {
audio: {
setBackingSpeed(rate) {
backingCalls.push(['setBackingSpeed', rate]);
return Promise.resolve();
},
setBackingPreservePitch(value) {
backingCalls.push(['setBackingPreservePitch', value]);
return Promise.resolve();
},
},
},
},
document: {
getElementById(id) {
if (!elements.has(id)) elements.set(id, makeElement(id));
return elements.get(id);
},
querySelector(selector) {
if (selector === '.screen.active') return { id: 'home' };
return null;
},
},
location: {
protocol: 'http:',
host: 'localhost:9999',
},
URLSearchParams,
setTimeout(fn) { fn(); return 0; },
clearTimeout() {},
Promise,
decodeURIComponent,
__elements: elements,
__backingCalls: backingCalls,
__sliderInputs: sliderInputs,
};
sandbox.window.jucePlayer = jucePlayer;
sandbox.handleSliderInput = (el) => {
if (el) sliderInputs.push(el.id);
};
vm.createContext(sandbox);
return sandbox;
}
function extractConstLine(src, name) {
const match = src.match(new RegExp(`const ${name} = [^;]+;`));
if (!match) throw new Error(`extractConstLine: '${name}' not found in app.js`);
return match[0];
}
function loadPlaySong(sandbox) {
const src = fs.readFileSync(APP_JS, 'utf8');
const resetHelper = src.includes('function _resetPlaybackSpeedForNewSong')
? extractFunction(src, 'function _resetPlaybackSpeedForNewSong')
: '';
const speedPresetHelpers = src.includes('function _updateSpeedPresetButtons')
? `
${extractConstLine(src, 'SPEED_PRESET_PCTS')}
${extractConstLine(src, 'SPEED_SNAP_THRESHOLD')}
${extractFunction(src, 'function _speedPresetPctFromActive')}
${extractFunction(src, 'function _updateSpeedPresetButtons')}
`
: '';
const code = `
var artAbortController = null;
var isPlaying = true;
var currentFilename = null;
var _playerOriginScreen = null;
function _recordPlaybackBridge() {}
function _cancelCountIn() {}
function _resetJuceAudioShimChain() {}
function _resetAudioSeekState() {}
function setPlayButtonState() {}
function clearLoop() {}
function _resetSectionPracticeLog() {}
function _hideSectionPracticeBar() {}
function showScreen() {}
function _getArrangementNamingMode() { return 'default'; }
function _scheduleSectionPracticeRetries() {}
function loadSavedLoops() {}
function _songEventPayload() { return { time: 7, audioT: 7, chartT: 7, perfNow: 7 }; }
${extractFunction(src, 'function setSpeed')}
${speedPresetHelpers}
${resetHelper}
${extractFunction(src, 'async function playSong')}
globalThis.__playSong = playSong;
`;
vm.runInContext(code, sandbox);
}
test('new song load resets the HTML audio rate, not only the visible speed controls', async () => {
const sandbox = buildSandbox();
loadPlaySong(sandbox);
await sandbox.__playSong('next-song.psarc');
assert.equal(sandbox.__elements.get('speed-slider').value, 100);
assert.match(sandbox.__elements.get('speed-label').textContent, /^1\.0{1,2}x$/);
assert.equal(sandbox.audio.playbackRate, 1);
assert.deepEqual(sandbox.__sliderInputs, ['speed-slider']);
});
test('new song load resets the desktop backing rate when the API is available', async () => {
const sandbox = buildSandbox({ juceMode: true });
loadPlaySong(sandbox);
await sandbox.__playSong('next-song.psarc');
await new Promise((resolve) => setImmediate(resolve));
assert.equal(sandbox.jucePlayer._speed, 1);
assert.deepEqual(sandbox.__backingCalls, [
['setBackingSpeed', 1],
['setBackingPreservePitch', true],
]);
});
+36
View File
@@ -0,0 +1,36 @@
'use strict';
// Shared test helpers for the brace-matching app.js extraction strategy used
// by song_restart / song_close (and friends): pull a single top-level function
// out of app.js by name so it can run in an isolated vm sandbox with stubbed
// deps, without executing the whole module.
function extractFunction(src, signature) {
const start = src.indexOf(signature);
if (start === -1) throw new Error(`extractFunction: '${signature}' not found`);
let scan = start + signature.length;
if (src[scan] === '(') {
let parenDepth = 1;
scan++;
while (scan < src.length && parenDepth > 0) {
const ch = src[scan];
if (ch === '(') parenDepth++;
else if (ch === ')') parenDepth--;
scan++;
}
}
const openBrace = src.indexOf('{', scan);
if (openBrace === -1) throw new Error(`extractFunction: no '{' after '${signature}'`);
let depth = 1;
let i = openBrace + 1;
while (i < src.length && depth > 0) {
const ch = src[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
if (depth !== 0) throw new Error(`extractFunction: unbalanced braces after '${signature}'`);
return src.slice(start, i);
}
module.exports = { extractFunction };
+172
View File
@@ -0,0 +1,172 @@
// Source-level guards for the consolidated tour menu (slopsmith#272).
// The engine lives in a DOMContentLoaded handler that wires window.slopsmith,
// localStorage, and Shepherd — too much browser surface to reproduce cleanly
// in a vm sandbox. These checks lock in the contract (viz relevance gating,
// complete-vs-cancel semantics, waitFor validation, focus management,
// dedup, etc.) instead, so regressions land as failed assertions rather
// than silently-broken UX.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const tourJs = path.join(__dirname, '..', '..', 'static', 'tour-engine.js');
const SRC = fs.readFileSync(tourJs, 'utf8');
function extractBlock(src, signature) {
const start = src.indexOf(signature);
assert.ok(start !== -1, `signature '${signature}' not found`);
const openBrace = src.indexOf('{', start);
assert.ok(openBrace !== -1, `opening brace after '${signature}' not found`);
let depth = 1;
let i = openBrace + 1;
while (i < src.length && depth > 0) {
const ch = src[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
assert.ok(depth === 0, `unbalanced braces after '${signature}'`);
return src.slice(start, i);
}
test('viz tours on player are gated on the currently active viz', () => {
const fn = extractBlock(SRC, 'function _isRelevant(pluginId, screenId, activeVizId)');
// Gate must check is_viz on player, not just screen membership.
assert.match(fn, /meta\.is_viz/, '_isRelevant must read meta.is_viz');
assert.match(fn, /screenId\s*===\s*'player'/, '_isRelevant must gate the viz check on the player screen');
assert.match(fn, /activeVizId\s*===\s*pluginId/, '_isRelevant must compare activeVizId to pluginId');
});
test('_relevantPlugins computes the active viz id once per refresh', () => {
const fn = extractBlock(SRC, 'function _relevantPlugins(screenId)');
// _currentVizPluginId must be called exactly once at the top, not
// inside the per-plugin filter callback.
const callMatches = fn.match(/_currentVizPluginId\(\)/g) || [];
assert.equal(callMatches.length, 1, '_currentVizPluginId() must be called exactly once per refresh');
// And gated on the player screen — no need to evaluate matchesArrangement
// for irrelevant screens.
assert.match(fn, /screenId\s*===\s*'player'/, 'active viz lookup must be gated on the player screen');
});
test('_currentVizPluginId reads #viz-picker before localStorage', () => {
const fn = extractBlock(SRC, 'function _currentVizPluginId()');
const pickerIdx = fn.search(/getElementById\(\s*['"]viz-picker['"]/);
const lsIdx = fn.search(/localStorage\.getItem\(\s*['"]vizSelection['"]/);
assert.ok(pickerIdx !== -1, 'must read #viz-picker');
assert.ok(lsIdx !== -1, 'must read localStorage.vizSelection');
assert.ok(pickerIdx < lsIdx, '#viz-picker must be consulted before localStorage (app.js treats picker as source of truth)');
});
test('tour completion → markSeen, cancel → markDismissed', () => {
const fn = extractBlock(SRC, 'async function start(pluginId)');
// complete handler must call _markSeen, cancel handler must call _markDismissed.
// The two paths must not collapse into a single shared cleanup (else cancel
// would silently mark the tour as completed, mis-labeling the badge).
assert.match(fn, /tour\.on\(\s*['"]complete['"][\s\S]*?_markSeen\(\s*pluginId\s*\)/,
'complete handler must _markSeen');
assert.match(fn, /tour\.on\(\s*['"]cancel['"][\s\S]*?_markDismissed\(\s*pluginId\s*\)/,
'cancel handler must _markDismissed (not _markSeen)');
});
test('register() ignores legacy injectTriggerInto / injectTriggerOpts with a deduped warning', () => {
const fn = extractBlock(SRC, 'function register(pluginId, opts)');
assert.match(fn, /'injectTriggerInto'\s+in\s+opts/, 'must detect legacy injectTriggerInto');
assert.match(fn, /'injectTriggerOpts'\s+in\s+opts/, 'must detect legacy injectTriggerOpts');
assert.match(fn, /_deprecationWarned\.has\(pluginId\)/, 'must check dedup Set before warning');
assert.match(fn, /_deprecationWarned\.add\(pluginId\)/, 'must add to dedup Set so we warn once per plugin');
assert.match(fn, /console\.warn/, 'must emit a console.warn');
// The deprecated options must NOT be re-introduced anywhere — they were
// explicitly dropped from _registry storage and from injectTrigger calls.
assert.doesNotMatch(fn, /injectTriggerInto\s*:/, 'register() must not re-introduce injectTriggerInto storage');
});
test('waitFor validates string + try/catch protects querySelector', () => {
// Find the step-mapping block that handles waitFor.
const map = extractBlock(SRC, 'function _mapSteps(rawSteps, tourInstance)');
assert.match(map, /typeof\s+raw\.waitFor\s*===\s*['"]string['"]/, 'must validate waitFor is a string');
assert.match(map, /raw\.waitFor/, 'must reference raw.waitFor');
// The selector must be probed inside a try/catch before beforeShowPromise
// is installed — a malformed selector should warn + skip the wait, not
// hang the tour.
assert.match(map, /try\s*\{[^}]*document\.querySelector\(\s*sel\s*\)[^}]*\}\s*catch/,
'must try/catch the upfront querySelector probe');
assert.match(map, /_WAIT_FOR_TIMEOUT_MS/, 'must use the timeout constant');
});
test('_maybeShowToast guards against active tour and open popover', () => {
const fn = extractBlock(SRC, 'function _maybeShowToast()');
assert.match(fn, /if\s*\(_activeTour\)\s*return/, 'must early-return when a tour is running');
assert.match(fn, /_menuPopover\.style\.display\s*!==\s*'none'/,
'must early-return when the popover is already visible');
});
test('_updateMenuVisibility dismisses orphan toast when relevance drops to zero', () => {
const fn = extractBlock(SRC, 'function _updateMenuVisibility()');
// When plugins.length === 0 we hide the button AND must dismiss any
// active toast (otherwise it'd float at the now-vacant button anchor).
assert.match(fn, /_hideMenu\(\)[\s\S]*_dismissToast\(\)/,
'must call _dismissToast() alongside _hideMenu() when relevance drops to zero');
// And rebuild the open popover when relevance is still non-zero, so
// NEW/✓ badges flip live without a close-and-reopen.
assert.match(fn, /_rebuildMenuItems\(\)/, 'must rebuild open popover on visibility refresh');
});
test('popover has role=dialog with aria-controls wired from the trigger', () => {
const fn = extractBlock(SRC, 'function _ensureMenu()');
assert.match(fn, /setAttribute\(\s*['"]aria-controls['"]\s*,\s*['"]slopsmith-tour-menu-popover['"]/,
'trigger must wire aria-controls to the popover id');
assert.match(fn, /_menuPopover\.id\s*=\s*['"]slopsmith-tour-menu-popover['"]/,
'popover must carry the matching id');
assert.match(fn, /setAttribute\(\s*['"]role['"]\s*,\s*['"]dialog['"]/,
'popover must use role=dialog (not the menu role we don\'t implement)');
});
test('toast Yes handler defers persistence to start() — no double-marking', () => {
const fn = extractBlock(SRC, 'function _maybeShowToast()');
// Find just the yesBtn click handler within the toast. We must NOT
// see _markDismissed or _markSeen inside the Yes path — start()'s
// own Shepherd handlers own that state transition. Calling
// _markDismissed here would falsely flip hasDismissed() to true
// while the tour is still running and after a successful complete.
const yesIdx = fn.search(/yesBtn\.addEventListener/);
const noIdx = fn.search(/noBtn\.addEventListener/);
assert.ok(yesIdx !== -1 && noIdx !== -1, 'must find both yes and no handlers');
const yesBlock = fn.slice(yesIdx, noIdx);
assert.doesNotMatch(yesBlock, /_markDismissed\s*\(/,
'Yes handler must not call _markDismissed (start() does it on cancel)');
assert.doesNotMatch(yesBlock, /_markSeen\s*\(/,
'Yes handler must not call _markSeen (start() does it on complete)');
});
test('_hideMenu skips focus return when the trigger button is hidden', () => {
const fn = extractBlock(SRC, 'function _hideMenu()');
// The refocus path must check _menuBtn.style.display !== 'none'
// so we don't try to focus a hidden trigger (no-op, leaves focus
// stuck on the about-to-be-hidden popover).
assert.match(fn, /_menuBtn\.style\.display\s*!==\s*['"]none['"]/,
'_hideMenu must skip focus return when the button itself is hidden');
});
test('_showMenu moves focus into the dialog; _hideMenu returns it to the trigger', () => {
const show = extractBlock(SRC, 'function _showMenu()');
assert.match(show, /\.tour-menu-item['"]?\s*\)?[\s\S]*\.focus\(\)/,
'_showMenu must focus the first tour item');
const hide = extractBlock(SRC, 'function _hideMenu()');
assert.match(hide, /_menuBtn\.focus\(\)/, '_hideMenu must return focus to the trigger button');
// The return-focus path must be gated on focus actually being inside
// the dialog, so a programmatic _hideMenu doesn't steal focus from
// elsewhere on the page.
assert.match(hide, /_menuPopover\.contains\(\s*document\.activeElement\s*\)/,
'_hideMenu must only return focus when focus was inside the dialog');
});
test('esc() actually HTML-escapes — Shepherd renders title via innerHTML', () => {
const fn = extractBlock(SRC, 'function esc(s)');
// The pre-existing String() coercion was a no-op; the live esc() must
// map &<>"' to entities.
assert.match(fn, /replace\(/, 'esc() must call replace() to escape characters');
assert.match(SRC, /_ESC_MAP\s*=\s*\{[^}]*'&':\s*'&amp;'[^}]*'<':\s*'&lt;'[^}]*'>':\s*'&gt;'[^}]*'"':\s*'&quot;'[^}]*"'":\s*'&#39;'/,
'must map all five HTML-significant characters');
});
+330
View File
@@ -0,0 +1,330 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
const TUNER_SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'screen.js');
function loadTuningHelpers() {
const src = fs.readFileSync(APP_JS, 'utf8');
const start = src.indexOf('function isBassArrangement(');
const endMarker = 'window.slopsmith.parseRawTuningOffsets = parseRawTuningOffsets;';
const end = src.indexOf(endMarker);
if (start === -1 || end === -1) throw new Error('tuning helper block not found in app.js');
const sandbox = { window: { slopsmith: {} }, exports: {} };
vm.createContext(sandbox);
vm.runInContext(
src.slice(start, end + endMarker.length),
sandbox
);
return sandbox.window.slopsmith;
}
const slopsmithHelpers = loadTuningHelpers();
function createTunerSandbox() {
const enableCalls = [];
let playerActive = true;
let songInfo = null;
const sandbox = {
console,
Promise,
queueMicrotask,
setTimeout(fn) { fn(); return 0; },
clearTimeout() {},
fetch(url) {
if (String(url).includes('/config')) {
return Promise.resolve({
json: () => Promise.resolve({
showFloatingButton: true,
visualizationMode: 'default',
audioInputMode: 'auto',
lastInstrument: 'guitar-6',
lastTuning: 'Standard',
freeTune: false,
disabledTunings: [],
customTunings: {},
}),
});
}
return Promise.resolve({
json: () => Promise.resolve({
tunings: { 'guitar-6': { Standard: [82.41, 110, 146.83, 196, 246.94, 329.63] } },
referencePitch: 440,
}),
});
},
localStorage: {
getItem: () => null,
setItem() {},
},
document: {
getElementById(id) {
if (id === 'player') {
return { classList: { contains: () => playerActive } };
}
if (id === 'v3-tuner-wrap') return null;
return null;
},
querySelector() { return null; },
createElement(tag) {
const el = {
tagName: tag.toUpperCase(),
src: '',
classList: { add() {}, remove() {}, contains: () => false },
className: '',
style: {},
appendChild() {},
remove() {},
addEventListener() {},
removeEventListener() {},
querySelector: () => null,
setAttribute() {},
onload: null,
onerror: null,
};
if (tag === 'script') {
queueMicrotask(() => { if (el.onload) el.onload(); });
}
return el;
},
head: { appendChild() {} },
body: { appendChild() {} },
addEventListener() {},
removeEventListener() {},
},
__setPlayerActive(v) { playerActive = v; },
__setSongInfo(info) {
songInfo = info;
sandbox.window.slopsmith.currentSong = info ? {
filename: info.filename || 'song.sloppak',
arrangementIndex: info.arrangement_index,
tuning: info.tuning,
} : null;
},
__enableCalls: enableCalls,
};
sandbox.window = sandbox;
sandbox.window.slopsmith = {
...slopsmithHelpers,
on() {},
off() {},
currentSong: null,
};
sandbox.window.highway = {
getSongInfo: () => songInfo,
};
sandbox.window._tunerUtils = {
preferFlats: () => false,
offsetsToFreqs: (offsets) => offsets.map((o, i) => 80 + i * 10),
freqToMidi: () => 40,
midiToNote: () => 'E',
};
sandbox.window._tunerUI = () => ({
addButton() {},
initUI() {},
renderInstrumentOptions() {},
renderTuningOptions() {},
renderStringNotes() {},
updateSaveAsCustomVisibility() {},
updateFreeTuneUI() {},
updateFloatingButton() {},
updatePlayerButton() {},
updateFloatingButtonVisibility() {},
updateInstrumentDisplay() {},
positionPanel() {},
updateUI() {},
});
sandbox.window._tunerAudio = {
start: async () => {},
stop() {},
restart: async () => {},
};
sandbox.window._tunerViz_default = () => ({
update() {},
destroy() {},
});
vm.createContext(sandbox);
vm.runInContext(fs.readFileSync(TUNER_SCREEN_JS, 'utf8'), sandbox);
const realEnable = sandbox.window.tuner.enable.bind(sandbox.window.tuner);
sandbox.window.tuner.enable = async () => {
enableCalls.push(1);
return realEnable();
};
return sandbox;
}
const CUSTOM_GUITAR = {
filename: 'amnesia.sloppak',
arrangement: 'Lead',
arrangement_index: 0,
stringCount: 6,
tuning: [-2, 0, 0, 0, -2, -2],
};
const E_STANDARD = {
filename: 'standard.sloppak',
arrangement: 'Lead',
arrangement_index: 0,
stringCount: 6,
tuning: [0, 0, 0, 0, 0, 0],
};
const DROP_D = {
filename: 'dropd.sloppak',
arrangement: 'Lead',
arrangement_index: 0,
stringCount: 6,
tuning: [-2, 0, 0, 0, 0, 0],
};
const BASS_EADG = {
filename: 'bass.sloppak',
arrangement: 'Bass',
arrangement_index: 0,
stringCount: 4,
tuning: [0, 0, 0, 0],
};
async function ready(sandbox, song) {
sandbox.__setSongInfo(song);
await sandbox.window._tunerAutoOpen.maybeAutoOpenOnTuningChange();
}
test('tuning identity: same effective tuning returns same key', () => {
const sandbox = createTunerSandbox();
const key = sandbox.window._tunerAutoOpen.tuningIdentityKey(CUSTOM_GUITAR);
assert.equal(key, sandbox.window._tunerAutoOpen.tuningIdentityKey({ ...CUSTOM_GUITAR }));
assert.match(key, /^g:6:-2,0,0,0,-2,-2$/);
});
test('tuning identity: DADGAD custom vs E Standard differ', () => {
const sandbox = createTunerSandbox();
const custom = sandbox.window._tunerAutoOpen.tuningIdentityKey(CUSTOM_GUITAR);
const standard = sandbox.window._tunerAutoOpen.tuningIdentityKey(E_STANDARD);
assert.notEqual(custom, standard);
});
test('tuning identity: E Standard vs Drop D differ', () => {
const sandbox = createTunerSandbox();
const standard = sandbox.window._tunerAutoOpen.tuningIdentityKey(E_STANDARD);
const dropD = sandbox.window._tunerAutoOpen.tuningIdentityKey(DROP_D);
assert.notEqual(standard, dropD);
});
test('tuning identity: bass 4-string vs guitar 6-string differ', () => {
const sandbox = createTunerSandbox();
const bass = sandbox.window._tunerAutoOpen.tuningIdentityKey(BASS_EADG);
const guitar = sandbox.window._tunerAutoOpen.tuningIdentityKey({
...BASS_EADG,
arrangement: 'Lead',
stringCount: 6,
tuning: [0, 0, 0, 0, 0, 0],
});
assert.notEqual(bass, guitar);
assert.match(bass, /^b:4:/);
assert.match(guitar, /^g:6:/);
});
test('tuning identity: missing tuning returns null', () => {
const sandbox = createTunerSandbox();
assert.equal(sandbox.window._tunerAutoOpen.tuningIdentityKey(null), null);
assert.equal(sandbox.window._tunerAutoOpen.tuningIdentityKey({ tuning: [] }), null);
});
test('first song load sets lastTuningKey but does not auto-open', async () => {
const sandbox = createTunerSandbox();
sandbox.window._tunerAutoOpen.resetState();
await ready(sandbox, E_STANDARD);
assert.equal(sandbox.__enableCalls.length, 0);
assert.equal(sandbox.window._tunerAutoOpen.getState().lastTuningKey, 'g:6:0,0,0,0,0,0');
});
test('custom tuning then E Standard triggers one auto-open', async () => {
const sandbox = createTunerSandbox();
sandbox.window._tunerAutoOpen.resetState();
await ready(sandbox, CUSTOM_GUITAR);
await ready(sandbox, E_STANDARD);
assert.equal(sandbox.__enableCalls.length, 1);
});
test('E Standard then Drop D triggers one auto-open', async () => {
const sandbox = createTunerSandbox();
sandbox.window._tunerAutoOpen.resetState();
await ready(sandbox, E_STANDARD);
await ready(sandbox, DROP_D);
assert.equal(sandbox.__enableCalls.length, 1);
});
test('same tuning twice does not auto-open', async () => {
const sandbox = createTunerSandbox();
sandbox.window._tunerAutoOpen.resetState();
await ready(sandbox, E_STANDARD);
await ready(sandbox, { ...E_STANDARD, filename: 'other.sloppak' });
assert.equal(sandbox.__enableCalls.length, 0);
});
test('duplicate song:ready for same tuning does not auto-open repeatedly', async () => {
const sandbox = createTunerSandbox();
sandbox.window._tunerAutoOpen.resetState();
await ready(sandbox, CUSTOM_GUITAR);
await ready(sandbox, E_STANDARD);
await ready(sandbox, E_STANDARD);
assert.equal(sandbox.__enableCalls.length, 1);
});
test('if tuner already enabled, no duplicate enable call', async () => {
const sandbox = createTunerSandbox();
sandbox.window._tunerAutoOpen.resetState();
await ready(sandbox, CUSTOM_GUITAR);
sandbox.window._tunerAutoOpen.setEnabledForTests(true);
const before = sandbox.__enableCalls.length;
await ready(sandbox, E_STANDARD);
assert.equal(sandbox.__enableCalls.length, before);
});
test('user dismiss prevents reopen for same session', async () => {
const sandbox = createTunerSandbox();
sandbox.window._tunerAutoOpen.resetState();
await ready(sandbox, CUSTOM_GUITAR);
await ready(sandbox, { ...E_STANDARD, filename: 'amnesia.sloppak', arrangement_index: 0 });
assert.equal(sandbox.__enableCalls.length, 1);
sandbox.window._tunerAutoOpen.setEnabledForTests(true);
sandbox.window.tuner.disable();
await ready(sandbox, { ...DROP_D, filename: 'amnesia.sloppak', arrangement_index: 0 });
assert.equal(sandbox.__enableCalls.length, 1);
});
test('song:loading clears dismiss state for next load', async () => {
const sandbox = createTunerSandbox();
sandbox.window._tunerAutoOpen.resetState();
await ready(sandbox, CUSTOM_GUITAR);
await ready(sandbox, E_STANDARD);
sandbox.window.tuner.disable();
sandbox.window._tunerAutoOpen.onSongLoading();
await ready(sandbox, DROP_D);
assert.equal(sandbox.__enableCalls.length, 2);
});
test('screen.js registers song:loading and song:ready auto-open listeners at boot', () => {
const src = fs.readFileSync(TUNER_SCREEN_JS, 'utf8');
assert.match(src, /function _installAutoOpenListeners/);
assert.match(src, /window\.slopsmith\.on\('song:loading', _onAutoOpenSongLoading\)/);
assert.match(src, /window\.slopsmith\.on\('song:ready', _onAutoOpenSongReady\)/);
assert.match(src, /function _tuningIdentityKey/);
assert.doesNotMatch(src, /restartCurrentSong/);
});
test('auto-open does not require app.js changes', () => {
const appSrc = fs.readFileSync(APP_JS, 'utf8');
assert.doesNotMatch(appSrc, /_tunerAutoOpen|maybeAutoOpenOnTuningChange/);
});

Some files were not shown because too many files have changed in this diff Show More