mirror of
https://github.com/jackwener/OpenCLI.git
synced 2026-09-14 18:25:42 +08:00
4de04c43ad
* fix(suno): harden generation adapter contracts * fix(suno): separate session auth and API failures --------- Co-authored-by: jackwener <jakevingoo@gmail.com>
224 lines
8.8 KiB
JavaScript
224 lines
8.8 KiB
JavaScript
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { describe, expect, it } from 'vitest';
|
|
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
import {
|
|
DEFAULT_FORMATS,
|
|
SUPPORTED_FORMATS,
|
|
SUNO_MODELS,
|
|
clampSlider,
|
|
normalizeBooleanFlag,
|
|
requireNonNegativeInt,
|
|
parseFormats,
|
|
requirePositiveInt,
|
|
resolveSunoOutputDir,
|
|
sanitizeTitleForFilename,
|
|
unwrapEvaluateResult,
|
|
pollSunoClips,
|
|
ensureSunoSession,
|
|
} from './utils.js';
|
|
|
|
describe('suno utils — parseFormats', () => {
|
|
it('returns the default format set when input is empty or missing', () => {
|
|
expect(parseFormats(undefined)).toEqual(DEFAULT_FORMATS);
|
|
expect(parseFormats(null)).toEqual(DEFAULT_FORMATS);
|
|
expect(parseFormats('')).toEqual(DEFAULT_FORMATS);
|
|
expect(parseFormats(' ')).toEqual(DEFAULT_FORMATS);
|
|
});
|
|
|
|
it('parses comma-separated input and trims whitespace', () => {
|
|
expect(parseFormats('mp3, wav, metadata')).toEqual(['mp3', 'wav', 'metadata']);
|
|
});
|
|
|
|
it('lowercases and deduplicates input', () => {
|
|
expect(parseFormats('MP3,Mp3,mp3,WAV')).toEqual(['mp3', 'wav']);
|
|
});
|
|
|
|
it('accepts array inputs (e.g. when caller passes pre-split values)', () => {
|
|
expect(parseFormats(['mp3', 'metadata'])).toEqual(['mp3', 'metadata']);
|
|
});
|
|
|
|
it('throws ArgumentError on unsupported format and lists the supported set', () => {
|
|
try {
|
|
parseFormats('mp3,flac');
|
|
throw new Error('should have thrown');
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(ArgumentError);
|
|
expect(err.message).toContain('flac');
|
|
expect(err.hint).toContain(SUPPORTED_FORMATS.join(', '));
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('suno utils — resolveSunoOutputDir', () => {
|
|
it('falls back to ~/Music/suno when no path is given', () => {
|
|
expect(resolveSunoOutputDir()).toBe(path.join(os.homedir(), 'Music', 'suno'));
|
|
expect(resolveSunoOutputDir('')).toBe(path.join(os.homedir(), 'Music', 'suno'));
|
|
});
|
|
|
|
it('expands ~ and ~/-prefixed relative paths to the home directory', () => {
|
|
expect(resolveSunoOutputDir('~')).toBe(os.homedir());
|
|
expect(resolveSunoOutputDir('~/Music/test')).toBe(path.join(os.homedir(), 'Music', 'test'));
|
|
});
|
|
|
|
it('absolute paths are returned as-is (resolved)', () => {
|
|
expect(resolveSunoOutputDir('/tmp/suno')).toBe('/tmp/suno');
|
|
});
|
|
});
|
|
|
|
describe('suno utils — sanitizeTitleForFilename', () => {
|
|
it('replaces filesystem-hostile characters with hyphens', () => {
|
|
expect(sanitizeTitleForFilename('foo/bar:baz?')).toBe('foo-bar-baz-');
|
|
});
|
|
|
|
it('collapses whitespace and trims', () => {
|
|
expect(sanitizeTitleForFilename(' hello world ')).toBe('hello world');
|
|
});
|
|
|
|
it('caps length at 60 characters', () => {
|
|
const long = 'a'.repeat(120);
|
|
expect(sanitizeTitleForFilename(long).length).toBe(60);
|
|
});
|
|
|
|
it('returns fallback for empty input', () => {
|
|
expect(sanitizeTitleForFilename('', 'untitled')).toBe('untitled');
|
|
expect(sanitizeTitleForFilename(null, 'fallback')).toBe('fallback');
|
|
});
|
|
});
|
|
|
|
describe('suno utils — clampSlider', () => {
|
|
it('returns the default when input is missing', () => {
|
|
expect(clampSlider(undefined, '--weirdness', 0.5)).toBe(0.5);
|
|
expect(clampSlider('', '--weirdness', 0.5)).toBe(0.5);
|
|
expect(clampSlider(null, '--weirdness', 0.5)).toBe(0.5);
|
|
});
|
|
|
|
it('parses numeric strings and accepts 0..1', () => {
|
|
expect(clampSlider('0', '--x', 0.5)).toBe(0);
|
|
expect(clampSlider('0.74', '--x', 0.5)).toBe(0.74);
|
|
expect(clampSlider('1', '--x', 0.5)).toBe(1);
|
|
});
|
|
|
|
it('rejects out-of-range or non-numeric values', () => {
|
|
expect(() => clampSlider('1.5', '--x', 0.5)).toThrowError(ArgumentError);
|
|
expect(() => clampSlider('-0.1', '--x', 0.5)).toThrowError(ArgumentError);
|
|
expect(() => clampSlider('hello', '--x', 0.5)).toThrowError(ArgumentError);
|
|
});
|
|
});
|
|
|
|
describe('suno utils — normalizeBooleanFlag', () => {
|
|
it('treats the canonical true-ish strings as true', () => {
|
|
for (const v of ['true', '1', 'yes', 'on', 'TRUE', 'On']) {
|
|
expect(normalizeBooleanFlag(v)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('treats unset / empty / unrecognized values as the fallback', () => {
|
|
expect(normalizeBooleanFlag(undefined)).toBe(false);
|
|
expect(normalizeBooleanFlag('', true)).toBe(true);
|
|
expect(normalizeBooleanFlag('maybe')).toBe(false);
|
|
});
|
|
|
|
it('passes through actual booleans', () => {
|
|
expect(normalizeBooleanFlag(true)).toBe(true);
|
|
expect(normalizeBooleanFlag(false)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('suno utils — requirePositiveInt', () => {
|
|
it('returns positive integers as numbers', () => {
|
|
expect(requirePositiveInt(5, '--limit')).toBe(5);
|
|
expect(requirePositiveInt('10', '--limit')).toBe(10);
|
|
});
|
|
|
|
it('rejects zero, negative, and non-integer values', () => {
|
|
expect(() => requirePositiveInt(0, '--limit')).toThrowError(ArgumentError);
|
|
expect(() => requirePositiveInt(-3, '--limit')).toThrowError(ArgumentError);
|
|
expect(() => requirePositiveInt(1.5, '--limit')).toThrowError(ArgumentError);
|
|
expect(() => requirePositiveInt('not a number', '--limit')).toThrowError(ArgumentError);
|
|
});
|
|
});
|
|
|
|
describe('suno utils — requireNonNegativeInt', () => {
|
|
it('returns zero and positive integers as numbers', () => {
|
|
expect(requireNonNegativeInt(0, '--page')).toBe(0);
|
|
expect(requireNonNegativeInt('3', '--page')).toBe(3);
|
|
});
|
|
|
|
it('rejects negative or non-integer values', () => {
|
|
expect(() => requireNonNegativeInt(-1, '--page')).toThrowError(ArgumentError);
|
|
expect(() => requireNonNegativeInt(1.5, '--page')).toThrowError(ArgumentError);
|
|
expect(() => requireNonNegativeInt('nope', '--page')).toThrowError(ArgumentError);
|
|
});
|
|
});
|
|
|
|
describe('suno utils — unwrapEvaluateResult', () => {
|
|
it('unwraps Browser Bridge envelopes at evaluate boundaries', () => {
|
|
const payload = { ok: true, clips: [] };
|
|
expect(unwrapEvaluateResult({ session: 'browser:default', data: payload })).toBe(payload);
|
|
expect(unwrapEvaluateResult(payload)).toBe(payload);
|
|
});
|
|
});
|
|
|
|
describe('suno utils — ensureSunoSession typed failures', () => {
|
|
function createSessionPage(sessionCheckResult) {
|
|
const evaluate = async (script) => {
|
|
if (script.includes('querySelectorAll')) return undefined;
|
|
if (script.includes('!!(window.Clerk && window.Clerk.session)')) return true;
|
|
if (script.includes('suno_device_id')) return 'device-id';
|
|
if (script.includes('/api/billing/info/')) return sessionCheckResult;
|
|
throw new Error(`unexpected evaluate script: ${script.slice(0, 80)}`);
|
|
};
|
|
return {
|
|
goto: async () => undefined,
|
|
wait: async () => undefined,
|
|
evaluate,
|
|
};
|
|
}
|
|
|
|
it('maps explicit logged-out session state to AuthRequiredError', async () => {
|
|
await expect(ensureSunoSession(createSessionPage({
|
|
ok: false,
|
|
auth: true,
|
|
error: 'Clerk session unavailable',
|
|
}))).rejects.toThrowError(AuthRequiredError);
|
|
});
|
|
|
|
it('does not classify billing/parser drift as logged out', async () => {
|
|
await expect(ensureSunoSession(createSessionPage({
|
|
ok: false,
|
|
error: 'Malformed billing/info JSON: Unexpected token <',
|
|
}))).rejects.toThrowError(CommandExecutionError);
|
|
});
|
|
});
|
|
|
|
describe('suno utils — model + format exports', () => {
|
|
it('exposes the four shipping models with chirp-fenix first', () => {
|
|
expect(SUNO_MODELS).toContain('chirp-fenix');
|
|
expect(SUNO_MODELS).toContain('chirp-bluejay');
|
|
expect(SUNO_MODELS[0]).toBe('chirp-fenix');
|
|
});
|
|
|
|
it('declares mp3 + metadata as the default download set', () => {
|
|
expect(DEFAULT_FORMATS).toEqual(['mp3', 'metadata']);
|
|
});
|
|
});
|
|
|
|
describe('suno utils — pollSunoClips', () => {
|
|
it('fails typed on malformed feed JSON while polling generation status', async () => {
|
|
const page = {
|
|
evaluate: async () => ({ status: 200, body: null }),
|
|
wait: async () => {},
|
|
};
|
|
await expect(pollSunoClips(page, ['clip-a'], 1, 'device-id', 0)).rejects.toThrowError(CommandExecutionError);
|
|
});
|
|
|
|
it('fails typed on non-auth HTTP feed failures while polling generation status', async () => {
|
|
const page = {
|
|
evaluate: async () => ({ status: 500, body: { clips: [] } }),
|
|
wait: async () => {},
|
|
};
|
|
await expect(pollSunoClips(page, ['clip-a'], 1, 'device-id', 0)).rejects.toThrowError(CommandExecutionError);
|
|
});
|
|
});
|