import { expect, test, type Page } from '@playwright/test'; async function login(page: Page, username = 'mobile-admin', target = '/') { await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru')); await page.goto(`/login?redirect=${encodeURIComponent(target)}`); await page.getByLabel('Логин').fill(username); await page.getByRole('textbox', { name: 'Пароль' }).fill(`${username}-password`); await page.getByRole('button', { name: 'Войти' }).click(); await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible(); } test('job log follows new output and pauses while reading older lines', async ({ page }) => { await login(page); await page.evaluate(() => { let lines = 100; window.addEventListener('test-job-lines', (event) => { lines = (event as CustomEvent).detail; }); const original = window.fetch; window.fetch = async (input, init) => { const response = await original(input, init); const request = new Request(input, init); if (request.method === 'GET' && new URL(request.url).pathname === '/api/v1/admin/server/updates') { const data = await response.json(); data.job = { id: 'scroll-job', kind: 'update', state: 'running', target: 'main', started_at: '2026-10-22T09:00:00Z', finished_at: '', log: Array.from({ length: lines }, (_, i) => `Build line ${i + 1}`).join('\n'), }; return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } }); } return response; }; history.pushState({}, '', '/admin/updates'); window.dispatchEvent(new PopStateEvent('popstate')); }); const log = page.getByLabel('Журнал выполнения'); const bottomGap = () => log.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop); await expect(log).toContainText('Build line 100'); expect(await log.evaluate((el) => el.scrollHeight > el.clientHeight)).toBe(true); await expect.poll(bottomGap).toBeLessThanOrEqual(4); await page.evaluate(() => window.dispatchEvent(new CustomEvent('test-job-lines', { detail: 120 }))); await expect(log).toContainText('Build line 120'); await expect.poll(bottomGap).toBeLessThanOrEqual(4); await log.evaluate((el) => { el.scrollTop = 100; }); await expect.poll(() => log.evaluate((el) => el.scrollTop)).toBe(100); await page.evaluate(() => window.dispatchEvent(new CustomEvent('test-job-lines', { detail: 140 }))); await expect(log).toContainText('Build line 140'); expect(await log.evaluate((el) => el.scrollTop)).toBe(100); await log.evaluate((el) => { el.scrollTop = el.scrollHeight; }); await expect.poll(bottomGap).toBeLessThanOrEqual(4); await page.evaluate(() => window.dispatchEvent(new CustomEvent('test-job-lines', { detail: 160 }))); await expect(log).toContainText('Build line 160'); await expect.poll(bottomGap).toBeLessThanOrEqual(4); }); test('admin selects repository and version, confirms and sees persistent job log', async ({ page }) => { await login(page, 'mobile-admin', '/admin/updates'); await expect(page.getByRole('heading', { name: 'Обновления', exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: 'Последний релиз', exact: true })).toHaveAttribute('aria-pressed', 'true'); await page.getByRole('button', { name: 'Источники обновлений', exact: true }).click(); await page.getByRole('button', { name: 'Добавить источник' }).click(); await page.getByLabel('Название', { exact: true }).last().fill('Тестовая репа'); await page.getByLabel('URL Git-репозитория').last().fill('https://git.example.org/pipu/testing.git'); await page.getByRole('button', { name: 'Сохранить', exact: true }).click(); await expect(page.getByRole('dialog')).not.toBeVisible(); await page.getByLabel('Источник', { exact: true }).selectOption({ label: 'Тестовая репа' }); await page.getByRole('button', { name: 'Выбрать версию', exact: true }).click(); await page.getByLabel('Версия', { exact: true }).selectOption('v1.10.0'); await page.getByRole('button', { name: 'Установить обновление', exact: true }).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toContainText('Тестовая репа'); await expect(dialog).toContainText('v1.10.0'); await dialog.getByRole('button', { name: 'Установить обновление' }).click(); await expect(page.getByRole('status').filter({ hasText: /^Выполняется$/ })).toBeVisible(); await expect(page.getByLabel('Журнал выполнения')).toContainText('build and restart'); await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).not.toBeVisible(); await page.screenshot({ path: 'test-results/server-updates.png', fullPage: true }); }); test('config keeps secret unchanged, saves local override and applies separately', async ({ page }) => { await login(page, 'mobile-admin', '/admin/config'); await expect(page.getByRole('heading', { name: 'Конфигурация', exact: true })).toBeVisible(); await expect(page.getByLabel('GITEA_TOKEN · config.local', { exact: true })).toHaveValue(''); await expect(page.getByRole('button', { name: 'Сохранить', exact: true })).not.toBeVisible(); await page.getByLabel('LOG_LEVEL · config.local', { exact: true }).fill('debug'); await expect(page.getByText('В .env есть значение:', { exact: false })).toBeVisible(); await page.getByLabel('TRAIN_HOUR · config.local', { exact: true }).fill('6'); await expect(page.getByRole('button', { name: 'Применить и перезапустить', exact: true })).not.toBeVisible(); await page.getByRole('button', { name: 'Сохранить', exact: true }).click(); await expect(page.getByText('Изменения сохранены в config.local', { exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: 'Сохранить', exact: true })).not.toBeVisible(); await expect(page.getByRole('button', { name: 'Отменить правки', exact: true })).not.toBeVisible(); await expect(page.getByLabel('GITEA_TOKEN · config.local', { exact: true })).toHaveAttribute('placeholder', 'Секрет сохранён'); await expect(page.getByText('Выполняется', { exact: true })).not.toBeVisible(); await page.getByRole('main').evaluate((element) => { element.scrollTop = 0; }); await page.screenshot({ path: 'test-results/server-config.png', fullPage: true }); await page.getByRole('button', { name: 'Применить и перезапустить', exact: true }).click(); await page.getByRole('dialog').getByRole('button', { name: 'Применить и перезапустить' }).click(); await expect(page.getByLabel('Журнал выполнения')).toContainText('build and restart'); }); test('update delegate can update but cannot open configuration', async ({ page }) => { await login(page, 'update-manager', '/admin/updates'); await expect(page.getByRole('heading', { name: 'Обновления', exact: true })).toBeVisible(); await page.evaluate(() => { history.pushState({}, '', '/admin/config'); window.dispatchEvent(new PopStateEvent('popstate')); }); await expect(page).toHaveURL('/'); await expect(page.getByRole('heading', { name: 'Конфигурация', exact: true })).not.toBeVisible(); }); test('configuration masks every stored secret even if an older API returns plaintext', async ({ page }) => { await login(page); await page.evaluate(() => { const original = window.fetch; window.fetch = async (input, init) => { const response = await original(input, init); const request = new Request(input, init); if (request.method === 'GET' && new URL(request.url).pathname === '/api/v1/admin/server/config') { const data = await response.json(); const common = { group: 'api', type: 'string', secret: true, readonly: false }; data.entries = [ { ...common, key: 'ENV_SECRET', env: 'env-canary', local: 'local-canary', effective: 'env-canary', default_value: 'default-canary', source: 'env' }, { ...common, key: 'DEFAULT_SECRET', env: null, local: null, effective: 'default-canary', default_value: 'default-canary', source: 'default' }, { ...common, key: 'READONLY_SECRET', readonly: true, env: 'readonly-canary', local: null, effective: 'readonly-canary', default_value: '', source: 'env' }, ]; return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } }); } return response; }; history.pushState({}, '', '/admin/config'); window.dispatchEvent(new PopStateEvent('popstate')); }); const env = page.getByLabel('ENV_SECRET · config.local', { exact: true }); const defaults = page.getByLabel('DEFAULT_SECRET · config.local', { exact: true }); await expect(env).toHaveValue(''); await expect(env).toHaveAttribute('type', 'password'); await expect(defaults).toHaveValue(''); await expect(defaults).toHaveAttribute('placeholder', ''); await expect(page.getByText('READONLY_SECRET', { exact: true })).toBeVisible(); for (const canary of ['env-canary', 'local-canary', 'default-canary', 'readonly-canary']) { expect(await page.getByRole('main').innerHTML()).not.toContain(canary); } await page.getByRole('button', { name: 'Показать пароль', exact: true }).first().click(); await expect(env).toHaveValue(''); }); test('a replacement secret is hidden and cleared from the form after saving', async ({ page }) => { await login(page, 'mobile-admin', '/admin/config'); const token = page.getByLabel('GITEA_TOKEN · config.local', { exact: true }); await token.fill('replacement-canary'); await expect(token).toHaveAttribute('type', 'password'); await page.getByRole('button', { name: 'Сохранить', exact: true }).click(); await expect(page.getByText('Изменения сохранены в config.local', { exact: true })).toBeVisible(); await expect(token).toHaveValue(''); await expect(token).toHaveAttribute('placeholder', 'Секрет сохранён'); expect(await page.getByRole('main').innerHTML()).not.toContain('replacement-canary'); }); test('ordinary user is redirected away from server management', async ({ page }) => { await login(page, 'alice', '/admin/updates'); await expect(page).toHaveURL('/'); }); test('installed main and release show no install action; another version can be installed', async ({ page }) => { await login(page, 'mobile-admin', '/admin/updates'); await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).toBeVisible(); await page.getByRole('button', { name: 'main', exact: true }).click(); await expect(page.getByText('Эта версия уже установлена — обновлений нет', { exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).not.toBeVisible(); await page.getByRole('button', { name: 'Выбрать версию', exact: true }).click(); await expect(page.getByText('Выберите версию для проверки', { exact: true })).toBeVisible(); await page.getByLabel('Версия', { exact: true }).selectOption('v1.9.0'); await expect(page.getByText('Эта версия уже установлена — обновлений нет', { exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).not.toBeVisible(); await page.getByLabel('Версия', { exact: true }).selectOption('v1.10.0'); await expect(page.getByText('Доступно обновление', { exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).toBeVisible(); }); test('explicit check bypasses cache and hides install action until the check finishes', async ({ page }) => { await login(page, 'mobile-admin', '/admin/updates'); await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).toBeVisible(); await page.evaluate(() => { const original = window.fetch; window.fetch = async (input, init) => { const request = new Request(input, init); if (request.url.includes('/api/v1/admin/server/updates') && request.url.includes('fresh=true')) { await new Promise((resolve) => setTimeout(resolve, 1000)); const response = await original(input, init); const data = await response.json(); return new Response(JSON.stringify({ ...data, update_state: 'current', target_commit: data.current_commit }), { headers: { 'Content-Type': 'application/json' } }); } return original(input, init); }; }); await page.getByRole('button', { name: 'Проверить обновления', exact: true }).click(); await expect(page.getByText('Проверяем выбранную версию…', { exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).not.toBeVisible(); await expect(page.getByText('Эта версия уже установлена — обновлений нет', { exact: true })).toBeVisible(); }); test('saving panel disappears when all edited values return to their original state', async ({ page }) => { await login(page, 'mobile-admin', '/admin/config'); await expect(page.getByRole('heading', { name: 'Конфигурация', exact: true })).toBeVisible(); const save = page.getByRole('button', { name: 'Сохранить', exact: true }); const apply = page.getByRole('button', { name: 'Применить и перезапустить', exact: true }); await expect(save).not.toBeVisible(); await expect(apply).not.toBeVisible(); const field = page.getByLabel('TRAIN_HOUR · config.local', { exact: true }); await field.fill('6'); await expect(save).toBeVisible(); await field.clear(); await expect(save).not.toBeVisible(); await expect(apply).not.toBeVisible(); await field.fill('7'); await page.getByRole('button', { name: 'Отменить правки', exact: true }).click(); await expect(save).not.toBeVisible(); await expect(field).toHaveValue(''); });