ci / module boundaries / boundaries (push) Successful in 7s
ci / db / db (push) Successful in 47s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m42s
ci / ts / ts (packages + web) (push) Failing after 5m24s
ci / api / api (push) Successful in 42s
189 lines
14 KiB
TypeScript
189 lines
14 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test';
|
|
|
|
async function setup(page: Page, scenario: 'queue' | 'template' | 'mine') {
|
|
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
|
|
await page.goto('/login');
|
|
await page.getByLabel('Логин', { exact: true }).fill('mobile-admin');
|
|
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('mobile-admin-password');
|
|
await page.getByRole('button', { name: 'Войти', exact: true }).click();
|
|
await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible();
|
|
await page.evaluate(async (scenario) => {
|
|
const modulePath = '/src/test/mobileFixtures.ts';
|
|
const { mobileFixtures } = await import(modulePath);
|
|
const source = structuredClone(mobileFixtures['/api/v1/admin/sources'].items[0]);
|
|
const file = structuredClone(mobileFixtures['/api/v1/admin/uploads'].items[0]);
|
|
const files = [
|
|
{ ...file, id: 'visible', original_name: 'visible.flac', target_source_name: source.name, target_rel_path: 'Artist/Album/01 - Song.flac', target_storage_path: 'nas:/music/Artist/Album/01 - Song.flac', stream_url: `/api/v1/admin/uploads/files/${file.id}/stream?exp=9999999999&sig=test` },
|
|
{ ...file, id: 'approved', original_name: 'approved.flac', status: 'approved' },
|
|
{ ...file, id: 'hidden', original_name: 'hidden.flac', status: 'processing' },
|
|
{ ...file, id: 'withdrawn', original_name: 'withdrawn.flac', metadata: null, status: 'withdrawn' },
|
|
{ ...file, id: 'rejected', original_name: 'rejected.flac', metadata: null, status: 'rejected' },
|
|
];
|
|
const missing = { ...file, id: 'missing', original_name: 'missing.flac', target_source_id: null, target_source_name: null, target_rel_path: null, status_reason: 'uploads.no_target' };
|
|
let failOnce = true;
|
|
const deleted: string[] = [];
|
|
const original = window.fetch;
|
|
const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } });
|
|
window.fetch = async (input, init) => {
|
|
const req = new Request(input, init);
|
|
const url = new URL(req.url);
|
|
if (scenario === 'queue' && url.pathname === '/api/v1/admin/uploads') return json({ items: [files[0], missing], next_cursor: null });
|
|
if (scenario === 'queue' && url.pathname === '/api/v1/admin/uploads/files/missing' && req.method === 'PATCH') {
|
|
const body = await req.json();
|
|
Object.assign(missing, { metadata: body.metadata, target_source_id: source.id, target_source_name: source.name, target_rel_path: 'Artist/Album/02 - Fixed.flac', status_reason: null });
|
|
return json(missing);
|
|
}
|
|
if (scenario === 'template') {
|
|
if (url.pathname === '/api/v1/admin/uploads') {
|
|
const path = document.documentElement.dataset.savedTemplate ? 'Artist/Album/01. Song.flac' : 'Artist/Album/Song.flac';
|
|
return json({ items: [{ ...files[0], target_rel_path: path, target_storage_path: `nas:/music/${path}` }], next_cursor: null });
|
|
}
|
|
if (url.pathname.endsWith('/write-template/suggest')) return json({ template: '{albumartist}/{album}/{track:02}. {title}.{ext}', matched: 180, total: 200, examples: ['Кино/Группа крови/01. Группа крови.flac'] });
|
|
if (url.pathname.endsWith('/write-template/check')) return json({ ok: false, total: 50, round_trip: 0, same_path: 45, mismatches: [] });
|
|
if (url.pathname === `/api/v1/admin/sources/${source.id}` && req.method === 'PATCH') {
|
|
const body = await req.json();
|
|
document.documentElement.dataset.savedTemplate = body.write_template;
|
|
Object.assign(source, body);
|
|
return json(source);
|
|
}
|
|
if (url.pathname === `/api/v1/admin/sources/${source.id}`) return json(source);
|
|
}
|
|
if (scenario === 'mine' && url.pathname === '/api/v1/uploads/mine') {
|
|
if (url.searchParams.has('batch')) {
|
|
document.documentElement.dataset.batchPages = String(Number(document.documentElement.dataset.batchPages ?? 0) + 1);
|
|
return json({ items: [url.searchParams.has('cursor') ? files[2] : files[0]], next_cursor: url.searchParams.has('cursor') ? null : 'next' });
|
|
}
|
|
const statuses = url.searchParams.getAll('status');
|
|
const visible = [files[0], files[1], files[3], files[4]].filter((f) => !statuses.length || statuses.includes(f.status));
|
|
return json({ items: visible, next_cursor: 'more' });
|
|
}
|
|
if (scenario === 'mine' && req.method === 'DELETE' && url.pathname.startsWith('/api/v1/uploads/files/')) {
|
|
const id = url.pathname.split('/').pop()!;
|
|
deleted.push(id);
|
|
document.documentElement.dataset.deleted = JSON.stringify(deleted);
|
|
if (id === 'hidden' && failOnce) { failOnce = false; return json({ code: 'uploads.wrong_status', status: 409 }, 409); }
|
|
files.find((f) => f.id === id)!.status = 'withdrawn';
|
|
return new Response(null, { status: 204 });
|
|
}
|
|
return original(input, init);
|
|
};
|
|
const target = scenario === 'queue' ? '/admin/uploads' : scenario === 'mine' ? '/uploads/mine' : `/admin/sources/${source.id}/patterns`;
|
|
history.pushState({}, '', target);
|
|
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
}, scenario);
|
|
}
|
|
|
|
test('queue shows the storage and path, and recalculates after fixing placement', async ({ page }, info) => {
|
|
await setup(page, 'queue');
|
|
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeHidden();
|
|
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
|
|
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeVisible();
|
|
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toHaveAttribute('title', 'nas:/music/Artist/Album/01 - Song.flac');
|
|
await expect(page.getByText('Хранилище не выбрано', { exact: true })).toBeVisible();
|
|
await page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true }).check();
|
|
await page.getByRole('button', { name: 'Свернуть пачку', exact: true }).click();
|
|
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeHidden();
|
|
await expect(page.getByRole('button', { name: 'Развернуть пачку', exact: true })).toHaveAttribute('aria-expanded', 'false');
|
|
await expect(page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true })).toBeChecked();
|
|
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).press('Enter');
|
|
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeVisible();
|
|
await page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true }).uncheck();
|
|
await page.getByRole('button', { name: 'Выбрать хранилище', exact: true }).click();
|
|
const dialog = page.getByRole('dialog');
|
|
await dialog.getByLabel('Исполнитель', { exact: true }).fill('Artist');
|
|
await dialog.getByLabel('Название', { exact: true }).fill('Fixed');
|
|
await dialog.getByRole('combobox').selectOption({ label: 'Музыкальное хранилище' });
|
|
await dialog.getByRole('button', { name: 'Сохранить', exact: true }).click();
|
|
await expect(page.getByText('Artist/Album/02 - Fixed.flac', { exact: true })).toBeVisible();
|
|
await page.screenshot({ path: info.outputPath('queue-placement.png'), fullPage: true });
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await expect(page.getByText('Artist/Album/02 - Fixed.flac', { exact: true })).toBeVisible();
|
|
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
|
|
});
|
|
|
|
test('automatically suggests a dominant template and saves a custom edit only on request', async ({ page }, info) => {
|
|
await setup(page, 'template');
|
|
const field = page.getByRole('textbox', { name: 'Шаблон пути', exact: true });
|
|
await expect(field).toHaveValue('{albumartist}/{album}/{track:02}. {title}.{ext}');
|
|
await expect(page.getByText(/180 из 200/)).toBeVisible();
|
|
await expect(page.getByText('Кино/Группа крови/01. Группа крови.flac', { exact: true })).toBeVisible();
|
|
await expect(page.locator('html')).not.toHaveAttribute('data-saved-template');
|
|
await field.fill('{albumartist}/[{year} - ]{album}/{track:02} - {title}.{ext}');
|
|
const section = page.getByRole('heading', { name: 'Приём загрузок и шаблон пути' }).locator('..');
|
|
await section.getByRole('checkbox').check();
|
|
await section.getByRole('button', { name: 'Сохранить', exact: true }).click();
|
|
await expect(page.locator('html')).toHaveAttribute('data-saved-template', '{albumartist}/[{year} - ]{album}/{track:02} - {title}.{ext}');
|
|
await page.screenshot({ path: info.outputPath('auto-template.png'), fullPage: true });
|
|
});
|
|
|
|
test('selects a whole own batch across pages and reports partial withdrawal failures', async ({ page }, info) => {
|
|
await setup(page, 'mine');
|
|
await page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true }).check();
|
|
await expect(page.locator('html')).toHaveAttribute('data-batch-pages', '2');
|
|
await expect(page.getByText(/Выбрано 2 файла/)).toBeVisible();
|
|
await page.getByRole('button', { name: 'Отозвать выбранные', exact: true }).click();
|
|
await expect(page.getByText(/Не удалось отозвать: 1/)).toBeVisible();
|
|
await expect(page.locator('html')).toHaveAttribute('data-deleted', '["visible","hidden"]');
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await expect.poll(() => page.evaluate(() => Math.max(document.documentElement.scrollWidth - innerWidth, document.querySelector('main')!.scrollWidth - document.querySelector('main')!.clientWidth))).toBeLessThanOrEqual(1);
|
|
await page.screenshot({ path: info.outputPath('bulk-withdraw-mobile.png'), fullPage: true });
|
|
await page.getByRole('button', { name: 'Отозвать выбранные', exact: true }).click();
|
|
await expect(page.locator('html')).toHaveAttribute('data-deleted', '["visible","hidden","hidden"]');
|
|
await expect(page.getByRole('region', { name: 'Массовый отзыв предложений' })).toHaveCount(0);
|
|
});
|
|
|
|
test('keeps withdrawn proposals in their own filter', async ({ page }) => {
|
|
await setup(page, 'mine');
|
|
await expect(page.getByText('visible.flac', { exact: true })).toBeVisible();
|
|
await expect(page.getByText('withdrawn.flac', { exact: true })).toHaveCount(0);
|
|
await page.getByRole('button', { name: 'Отозванные', exact: true }).click();
|
|
await expect(page.getByText('withdrawn.flac', { exact: true }).first()).toBeVisible();
|
|
await expect(page.getByText('visible.flac', { exact: true })).toHaveCount(0);
|
|
await page.getByRole('button', { name: 'Не приняты', exact: true }).click();
|
|
await expect(page.getByText('rejected.flac', { exact: true }).first()).toBeVisible();
|
|
await expect(page.getByText('withdrawn.flac', { exact: true })).toHaveCount(0);
|
|
await page.getByRole('button', { name: 'Все', exact: true }).click();
|
|
await expect(page.getByText('visible.flac', { exact: true })).toBeVisible();
|
|
await expect(page.getByText('withdrawn.flac', { exact: true })).toHaveCount(0);
|
|
});
|
|
|
|
test('queue shows the current full destination after saving a changed source template', async ({ page }) => {
|
|
await setup(page, 'template');
|
|
const sourcePath = new URL(page.url()).pathname;
|
|
const navigate = async (path: string) => page.evaluate((path) => {
|
|
history.pushState({}, '', path);
|
|
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
}, path);
|
|
await navigate('/admin/uploads');
|
|
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
|
|
await expect(page.getByText('Artist/Album/Song.flac', { exact: true })).toHaveAttribute('title', 'nas:/music/Artist/Album/Song.flac');
|
|
await navigate(sourcePath);
|
|
await page.getByRole('textbox', { name: 'Шаблон пути', exact: true }).fill('{albumartist}/{album}/{track:02}. {title}.{ext}');
|
|
const section = page.getByRole('heading', { name: 'Приём загрузок и шаблон пути' }).locator('..');
|
|
await section.getByRole('checkbox').check();
|
|
await section.getByRole('button', { name: 'Сохранить', exact: true }).click();
|
|
await expect(page.locator('html')).toHaveAttribute('data-saved-template', '{albumartist}/{album}/{track:02}. {title}.{ext}');
|
|
await navigate('/admin/uploads');
|
|
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
|
|
await expect(page.getByText('Artist/Album/01. Song.flac', { exact: true })).toHaveAttribute('title', 'nas:/music/Artist/Album/01. Song.flac');
|
|
await expect(page.getByText('Artist/Album/Song.flac', { exact: true })).toHaveCount(0);
|
|
});
|
|
|
|
test('queue preview loads duration before play and plays signed audio', async ({ page }) => {
|
|
const dataSize = 8000 * 2 * 2;
|
|
const wav = Buffer.alloc(44 + dataSize);
|
|
wav.write('RIFF', 0); wav.writeUInt32LE(36 + dataSize, 4); wav.write('WAVEfmt ', 8);
|
|
wav.writeUInt32LE(16, 16); wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22);
|
|
wav.writeUInt32LE(8000, 24); wav.writeUInt32LE(16000, 28); wav.writeUInt16LE(2, 32); wav.writeUInt16LE(16, 34);
|
|
wav.write('data', 36); wav.writeUInt32LE(dataSize, 40);
|
|
await page.route('**/api/v1/admin/uploads/files/*/stream?*', (route) => route.fulfill({ contentType: 'audio/wav', body: wav }));
|
|
await setup(page, 'queue');
|
|
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
|
|
const audio = page.locator('audio').first();
|
|
await expect(audio).toHaveAttribute('preload', 'metadata');
|
|
await expect.poll(() => audio.evaluate((el: HTMLAudioElement) => el.duration)).toBe(2);
|
|
await audio.evaluate((el: HTMLAudioElement) => el.play());
|
|
await expect.poll(() => audio.evaluate((el: HTMLAudioElement) => el.currentTime)).toBeGreaterThan(0);
|
|
expect(await audio.evaluate((el: HTMLAudioElement) => el.error)).toBeNull();
|
|
});
|