Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

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();
});
});