mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-22 04:41:23 +00:00
* Update GitHub repo references from feedback* to feedBack* * rename: slopsmith -> feedBack, byron -> got-feedBack Renames across the entire codebase: - slopsmith/Slopsmith/SLOPSMITH/SlopSmith -> feedBack/FeedBack/FEEDBACK/FeedBack - byron/Byron/Byrongamatos -> got-feedBack/got-feedBack/got-feedBack - /home/byron/ -> /opt/got-feedBack/ - byron@ougsoft.com -> hi@got-feedBack.org - github.com/byrongamatos/ -> github.com/got-feedback/ - com.byron. -> com.got-feedback. - SLOPSMITH_ env vars -> FEEDBACK_ with backward-compat fallback - Protocol/storage strings migrated with read-old/write-new pattern - window.slopsmith JS API -> window.feedBack (canonical) + backward-compat alias Refs: #rename-slopsmith * rename: complete regen against current main + fix backward-compat alias Regenerated the slopsmith->feedBack / byron->got-feedBack rename on top of current main (3 commits had landed since the branch: #572/#554/#574), resolving the four content conflicts in favour of main's newer content (autoplay/auto-exit, accuracy-badge, Virtuoso re-home, feedpak badge). Completion fixes on top of the mechanical rename: - Re-apply rename to post-branch content the original rename never saw: window.slopsmith(.Tour) consumers in lessons.js / notifications.js / onboarding-tour.js, and the matching JS + python tests (autoplay_exit, progression_*, test_feedpak_extension FEEDBACK_* env vars). The test env vars now match server.py (which reads FEEDBACK_SYNC_STARTUP / FEEDBACK_SKIP_STARTUP_TASKS), so the sync-startup test exercises the real path again. - Restore the window.slopsmith backward-compat alias dropped during conflict resolution, and move the bus aliases to AFTER the _feedBackExisting merge block so they reference the fully-assembled object (also fixes the loop_api.test.js API-surface regex, which the original PR latently broke). - Drop the stray empty data/web_library.db (runtime DB lives in CONFIG_DIR) and gitignore it. - Fix stale tone-source test: feed[dB]ack -> fee[dB]ack to match shipped source labels. Verified locally (org CI billing-blocked): JS 819/819 pass; pytest 1669 passed / 1683 collected with 0 import errors; zero residual slopsmith/byron except the two intentional window.slopsmith aliases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * rename: implement advertised backward-compat + prune dead community plugins Address gaps where PR #537's "Backward compatibility" section was advertised but not implemented, and clean up the community plugin list. Env vars (FEEDBACK_* canonical, legacy SLOPSMITH_* honoured): - New lib/env_compat.py (getenv_compat / env_flag_compat) + tests. server.py (_env_flag + all FEEDBACK_* reads), diagnostics_hardware, gp2midi and tailwind_rebuild now resolve the legacy alias, so existing SLOPSMITH_UI / SLOPSMITH_PLUGINS_DIR / etc. deployments keep working. - Fix the rename collapsing plugins/__init__.py and minigames/routes.py from `FEEDBACK_PLUGINS_DIR or SLOPSMITH_PLUGINS_DIR` into a redundant `FEEDBACK_ or FEEDBACK_` (the fallback was silently lost). Storage (app.js update-channel): - Read feedBack-update-channel, fall back to legacy slopsmith-update-channel, and clear the legacy key on write — so a user's update-channel preference survives the rename instead of resetting to "stable". Community plugin list (README): the rename rewrote third-party repo URLs we don't own. Probed every one; their owners never renamed, so: - Restore the 13 live community plugins to their real slopsmith-* names. - Prune 6 that are 404 to the public (topkoa splitscreen/stems, OmikronApex tuner, Jafz2001 nam-rig-builder, DeathlySin song-preview, Erikcb91 shuffle). - Fix a pre-existing Guitar Theory clone-command typo (nam-tone -> guitar-theory). Verified: env_compat 7/7, JS 819/819, pytest 1690 collected / 0 import errors, rename-sensitive + startup suites green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: byrongamatos <xasiklas@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
755 lines
47 KiB
JavaScript
755 lines
47 KiB
JavaScript
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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.capabilities;
|
|
const audioSession = window.feedBack.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('feedBack.audioInput.selectedLogicalSourceKey', '/Users/me/evil token=zzz999');
|
|
const snap = window.feedBack.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.feedBack.capabilities;
|
|
const audioSession = window.feedBack.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.feedBack.capabilities;
|
|
|
|
// Seed storage with a stale key, then make subsequent writes fail.
|
|
window.localStorage.setItem('feedBack.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.feedBack.audioSession.snapshot().domains['audio-input'].storageStatus, 'failed');
|
|
|
|
const snap = window.feedBack.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.feedBack.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.feedBack.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.feedBack.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.feedBack.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');
|
|
});
|