9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
253 lines
11 KiB
JavaScript
253 lines
11 KiB
JavaScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
ensureSunoSession: vi.fn(),
|
|
checkSunoCaptcha: vi.fn(),
|
|
submitSunoGeneration: vi.fn(),
|
|
pollSunoClips: vi.fn(),
|
|
downloadSunoClip: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('./utils.js', () => ({
|
|
DEFAULT_SUNO_MODEL: 'chirp-fenix',
|
|
SUNO_DOMAIN: 'suno.com',
|
|
SUNO_MODELS: ['chirp-fenix', 'chirp-bluejay', 'chirp-v4', 'chirp-v3-5'],
|
|
SUNO_URL: 'https://suno.com',
|
|
ensureSunoSession: mocks.ensureSunoSession,
|
|
checkSunoCaptcha: mocks.checkSunoCaptcha,
|
|
submitSunoGeneration: mocks.submitSunoGeneration,
|
|
pollSunoClips: mocks.pollSunoClips,
|
|
downloadSunoClip: mocks.downloadSunoClip,
|
|
clampSlider: (value, _label, def) => (value === undefined || value === '' || value === null ? def : Number(value)),
|
|
normalizeBooleanFlag: (value, fallback = false) => {
|
|
if (typeof value === 'boolean') return value;
|
|
if (value == null || value === '') return fallback;
|
|
const s = String(value).trim().toLowerCase();
|
|
return s === 'true' || s === '1' || s === 'yes' || s === 'on';
|
|
},
|
|
parseFormats: (value) => {
|
|
if (!value) return ['mp3', 'metadata'];
|
|
const raw = Array.isArray(value) ? value.join(',') : String(value);
|
|
return raw.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
|
|
},
|
|
requirePositiveInt: (value) => {
|
|
const n = Number(value);
|
|
if (!Number.isInteger(n) || n < 1) throw new Error('positive int required');
|
|
return n;
|
|
},
|
|
resolveSunoOutputDir: (value) => value || '/tmp/suno-test',
|
|
}));
|
|
|
|
const { generateCommand } = await import('./generate.js');
|
|
|
|
function createPage() {
|
|
return {
|
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
evaluate: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
}
|
|
|
|
const okSession = {
|
|
ok: true,
|
|
planId: '3eaebef3-ef46-446a-931c-3d50cd1514f1',
|
|
planKey: 'pro',
|
|
totalCreditsAvailable: 2000,
|
|
breakdown: { pack: 0, purchasedPacks: 0, monthlyRemaining: 2000, monthlyLimit: 2500, monthlyUsed: 500 },
|
|
deviceId: 'device-uuid',
|
|
};
|
|
const okCaptcha = { ok: true, required: false };
|
|
const okSubmission = { id: 'batch-id', clips: [{ id: 'clip-a-id', status: 'submitted' }, { id: 'clip-b-id', status: 'submitted' }] };
|
|
const okClips = [
|
|
{ id: 'clip-a-id', status: 'complete', title: 'Clip A', audio_url: 'https://cdn1.suno.ai/clip-a-id.mp3' },
|
|
{ id: 'clip-b-id', status: 'complete', title: 'Clip B', audio_url: 'https://cdn1.suno.ai/clip-b-id.mp3' },
|
|
];
|
|
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
mocks.ensureSunoSession.mockReset().mockResolvedValue(okSession);
|
|
mocks.checkSunoCaptcha.mockReset().mockResolvedValue(okCaptcha);
|
|
mocks.submitSunoGeneration.mockReset().mockResolvedValue(okSubmission);
|
|
mocks.pollSunoClips.mockReset().mockResolvedValue(okClips);
|
|
mocks.downloadSunoClip.mockReset().mockResolvedValue({ slug: 'clip', written: [{ format: 'mp3', file: '/tmp/x.mp3', ok: true }, { format: 'metadata', file: '/tmp/x.json', ok: true }] });
|
|
});
|
|
|
|
describe('suno generate argument validation', () => {
|
|
it('rejects calls with neither a Simple prompt nor --lyrics', async () => {
|
|
await expect(generateCommand.func(createPage(), { sd: true, timeout: 60 })).rejects.toMatchObject({
|
|
code: 'ARGUMENT',
|
|
message: expect.stringContaining('Either provide a Simple-mode prompt'),
|
|
});
|
|
});
|
|
|
|
it('rejects --tags / --negative-tags in Simple mode', async () => {
|
|
await expect(generateCommand.func(createPage(), { prompt: 'foo', tags: 'lo-fi', sd: true, timeout: 60 })).rejects.toMatchObject({
|
|
code: 'ARGUMENT',
|
|
message: expect.stringContaining('only apply in Custom mode'),
|
|
});
|
|
});
|
|
|
|
it('rejects unsupported --model values', async () => {
|
|
await expect(generateCommand.func(createPage(), { prompt: 'foo', model: 'chirp-vNEXT', sd: true, timeout: 60 })).rejects.toMatchObject({
|
|
code: 'ARGUMENT',
|
|
message: expect.stringContaining('Unsupported --model'),
|
|
});
|
|
});
|
|
|
|
it('refuses to submit when planId is null (free-tier without resolved user_tier) (#1704)', async () => {
|
|
mocks.ensureSunoSession.mockResolvedValue({ ...okSession, planId: null, planKey: 'free' });
|
|
await expect(generateCommand.func(createPage(), { prompt: 'foo', sd: true, timeout: 60 })).rejects.toMatchObject({
|
|
code: 'COMMAND_EXEC',
|
|
message: expect.stringContaining('plan id'),
|
|
});
|
|
expect(mocks.submitSunoGeneration).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('refuses to submit when credits are below the per-song minimum', async () => {
|
|
mocks.ensureSunoSession.mockResolvedValue({ ...okSession, totalCreditsAvailable: 5, breakdown: { ...okSession.breakdown, monthlyRemaining: 5 } });
|
|
await expect(generateCommand.func(createPage(), { prompt: 'foo', sd: true, timeout: 60 })).rejects.toMatchObject({
|
|
code: 'COMMAND_EXEC',
|
|
message: expect.stringContaining('needs ~10 credits'),
|
|
});
|
|
expect(mocks.submitSunoGeneration).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('refuses to submit when captcha is required (out-of-scope for headless flow)', async () => {
|
|
mocks.checkSunoCaptcha.mockResolvedValue({ ok: true, required: true });
|
|
await expect(generateCommand.func(createPage(), { prompt: 'foo', sd: true, timeout: 60 })).rejects.toMatchObject({
|
|
code: 'COMMAND_EXEC',
|
|
message: expect.stringContaining('CAPTCHA challenge'),
|
|
});
|
|
expect(mocks.submitSunoGeneration).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('refuses to submit when captcha pre-flight fails', async () => {
|
|
mocks.checkSunoCaptcha.mockResolvedValue({ ok: false, status: 500 });
|
|
await expect(generateCommand.func(createPage(), { prompt: 'foo', sd: true, timeout: 60 })).rejects.toMatchObject({
|
|
code: 'COMMAND_EXEC',
|
|
message: expect.stringContaining('captcha pre-flight failed'),
|
|
});
|
|
expect(mocks.submitSunoGeneration).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('suno generate Simple mode payload', () => {
|
|
it('routes the positional prompt through `description` (Simple mode)', async () => {
|
|
await generateCommand.func(createPage(), { prompt: 'lo-fi study beat', sd: true, timeout: 60 });
|
|
expect(mocks.submitSunoGeneration).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
|
mode: 'simple',
|
|
description: 'lo-fi study beat',
|
|
lyrics: '',
|
|
tags: '',
|
|
negativeTags: '',
|
|
userTier: okSession.planId,
|
|
deviceId: okSession.deviceId,
|
|
}));
|
|
});
|
|
});
|
|
|
|
describe('suno generate Custom mode payload', () => {
|
|
it('routes --lyrics through `lyrics` and --tags through `tags`', async () => {
|
|
await generateCommand.func(createPage(), {
|
|
lyrics: '[Verse]\\nfoo',
|
|
tags: 'synthwave, 95 BPM',
|
|
'negative-tags': 'vocals',
|
|
title: 'Night Rain',
|
|
sd: true,
|
|
timeout: 60,
|
|
});
|
|
expect(mocks.submitSunoGeneration).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
|
mode: 'custom',
|
|
lyrics: '[Verse]\\nfoo',
|
|
tags: 'synthwave, 95 BPM',
|
|
negativeTags: 'vocals',
|
|
title: 'Night Rain',
|
|
}));
|
|
});
|
|
|
|
it('threads --weirdness and --style-weight as numeric sliders', async () => {
|
|
await generateCommand.func(createPage(), {
|
|
lyrics: '[Verse]\\nfoo',
|
|
weirdness: '0.74',
|
|
'style-weight': '0.57',
|
|
sd: true,
|
|
timeout: 60,
|
|
});
|
|
expect(mocks.submitSunoGeneration).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
|
weirdness: 0.74,
|
|
styleWeight: 0.57,
|
|
}));
|
|
});
|
|
});
|
|
|
|
describe('suno generate paid-format guard', () => {
|
|
it('skips wav by default and surfaces the skip in the result row', async () => {
|
|
const out = await generateCommand.func(createPage(), {
|
|
prompt: 'foo',
|
|
formats: 'mp3,wav,metadata',
|
|
timeout: 60,
|
|
});
|
|
// downloadSunoClip should be called with mp3+metadata, NOT wav
|
|
expect(mocks.downloadSunoClip).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), ['mp3', 'metadata'], okSession.deviceId);
|
|
expect(out[0].files).toContain('skipped(needs --confirm-paid):wav');
|
|
});
|
|
|
|
it('includes wav when --confirm-paid is true', async () => {
|
|
await generateCommand.func(createPage(), {
|
|
prompt: 'foo',
|
|
formats: 'mp3,wav',
|
|
'confirm-paid': true,
|
|
timeout: 60,
|
|
});
|
|
expect(mocks.downloadSunoClip).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), ['mp3', 'wav'], okSession.deviceId);
|
|
});
|
|
|
|
it('rejects before generation when every requested format is paid and unconfirmed', async () => {
|
|
await expect(generateCommand.func(createPage(), {
|
|
prompt: 'foo',
|
|
formats: 'wav',
|
|
timeout: 60,
|
|
})).rejects.toMatchObject({
|
|
code: 'ARGUMENT',
|
|
message: expect.stringContaining('All requested formats require'),
|
|
});
|
|
expect(mocks.submitSunoGeneration).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('suno generate failure paths', () => {
|
|
it('reports all-failed clips with a typed error', async () => {
|
|
mocks.pollSunoClips.mockResolvedValue([
|
|
{ id: 'clip-a-id', status: 'error' },
|
|
{ id: 'clip-b-id', status: 'error' },
|
|
]);
|
|
await expect(generateCommand.func(createPage(), { prompt: 'foo', sd: true, timeout: 60 })).rejects.toMatchObject({
|
|
code: 'COMMAND_EXEC',
|
|
message: expect.stringContaining('All Suno clips failed'),
|
|
});
|
|
});
|
|
|
|
it('fails typed when generation response clips are malformed', async () => {
|
|
mocks.submitSunoGeneration.mockResolvedValue({ clips: [{ status: 'submitted' }] });
|
|
await expect(generateCommand.func(createPage(), { prompt: 'foo', sd: true, timeout: 60 })).rejects.toMatchObject({
|
|
code: 'COMMAND_EXEC',
|
|
message: expect.stringContaining('malformed clip identity'),
|
|
});
|
|
});
|
|
|
|
it('fails typed when download post-condition writes no files', async () => {
|
|
mocks.downloadSunoClip.mockResolvedValue({ slug: 'clip', written: [{ format: 'mp3', file: null, ok: false, reason: 'HTTP 500' }] });
|
|
await expect(generateCommand.func(createPage(), { prompt: 'foo', formats: 'mp3', timeout: 60 })).rejects.toMatchObject({
|
|
code: 'COMMAND_EXEC',
|
|
message: expect.stringContaining('wrote no files'),
|
|
});
|
|
});
|
|
|
|
it('skip-download mode returns generated rows without invoking downloadSunoClip', async () => {
|
|
const out = await generateCommand.func(createPage(), { prompt: 'foo', sd: true, timeout: 60 });
|
|
expect(out).toHaveLength(2);
|
|
expect(out[0].status).toBe('🎵 generated');
|
|
expect(mocks.downloadSunoClip).not.toHaveBeenCalled();
|
|
});
|
|
});
|