* test(legacy): snapshot the routes and the settings response Pins every registered route and the key paths of GET /prompt_manager/settings while System S13 (#330) moves the legacy pages' tools to native System. Recorded on the unchanged main; cutover (#334) removes it. * test(legacy): prove each removed legacy control has native coverage TESTING.md gains the "Legacy System UI removed by S13" table (no rows yet). legacy-coverage.test.mjs fails when a removal cites a capability that is not covered, copies another native test, lists an id or method twice, or leaves the id or method on its page. legacy-system-links.spec.mjs holds the System link table and guards the kept admin flows. * refactor(admin): open System for Diagnostics and logs The admin page's Diagnostics and View Logs buttons become links that open System › Diagnostics & logs in a new tab. The legacy Diagnostics and System Logs dialogs and their methods go; TESTING.md lists them against their covered capabilities (rows B1–B7). * refactor(admin): open System for settings and keep page options Settings opens System › Overview in a new tab. The settings dialog becomes Page options (opened from the list header) with only the Infinite Scrolling default, and its Save posts {"infinite_scroll": …} alone, so a stale legacy form can no longer overwrite scan folders or other values edited in System (#330 S13-9). Scan folders, monitoring status, auto-hide, display mode, worker threads, LoraManager and the LoRA import leave the page (rows B8–B14). * refactor(admin): open System for maintenance Maintenance opens System › Maintenance in a new tab, where each operation runs as a reviewed Activity job. The one-click legacy Database Maintenance dialog and its methods go (rows B15–B18); formatFileSize stays for the image modal. * refactor(admin): open System for backup and restore Backup DB and Restore DB open System › Backup & export in a new tab, where a restore is staged, reviewed and run as an Activity job. The one-click Restore dialog and its methods go (rows B19–B20); the scan dialog's quick backup stays. The removed browser tests' data checks live on at the route: a new round trip test restores every prompt with its tags and image links. * test(legacy): find a removed modal id passed to showModal and settle the links spec The removal check also finds an id passed as a string to a ...Modal() call and no longer reads data-id= as id=. The no-request test waits for the page's own start-up reads to finish, the sort check compares as the server sorts, and the kept-flow test checks the added prompt shows in the list. * test(admin): open Page options after the saved default is read Drops the leftover LoraManager section comment above the Page options save. * test(legacy): a lazy thumbnail load is not a System link's request
506 lines
32 KiB
JavaScript
506 lines
32 KiB
JavaScript
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||
import { test, expect } from './fixtures.mjs';
|
||
import { seedConcept } from './concept-inventory.mjs';
|
||
|
||
// System › Backup & export (#249 System S9 Task 24) on the S8 backend and the concept-shaped fixture: Back up and its
|
||
// status line (D103), the restore review (D105) and its job, the exports, Import / inspect and the recovery policy
|
||
// (D104). A restore here replaces this worker's own temp database; every test starts from a fresh concept fixture.
|
||
const workspace = '/prompt_manager/workspace';
|
||
const STATUS_URL = '/prompt_manager/backup/status';
|
||
const PREVIEW_URL = '/prompt_manager/restore/preview';
|
||
const APPLY_URL = '/prompt_manager/restore/apply';
|
||
const WHEN = { dateStyle: 'medium', timeStyle: 'short' };
|
||
const SAFETY_DESCRIPTION = 'Keep a verified copy of the database on disk before maintenance, cleanups and LoRA imports change ' +
|
||
'records. A restore always keeps one.';
|
||
|
||
test.use({ locale: 'en-US', timezoneId: 'UTC' });
|
||
|
||
test.beforeEach(async ({ page, request, browserName }) => {
|
||
await startCoverage(page, browserName);
|
||
await seedConcept(request);
|
||
await expect(await request.post('/__test__/system/reset')).toBeOK();
|
||
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
|
||
page.systemErrors = [];
|
||
page.on('pageerror', error => page.systemErrors.push(error.message));
|
||
});
|
||
|
||
test.afterEach(async ({ page, request, browserName }) => {
|
||
await saveCoverage(page, browserName);
|
||
await request.post('/__test__/release');
|
||
await request.post('/__test__/jobs/restart');
|
||
await request.post('/__test__/system/reset');
|
||
expect(page.systemErrors).toEqual([]);
|
||
});
|
||
|
||
const toast = page => page.locator('#workspace-toast');
|
||
const layoutOf = page => page.locator('#workspace-main .settings-layout');
|
||
const dialog = page => page.locator('dialog#modal[open]');
|
||
const statusLine = page => layoutOf(page).locator('#backup-status');
|
||
const settingsStatus = page => page.locator('#settings-status');
|
||
const save = page => page.getByRole('button', { name: 'Save preferences', exact: true });
|
||
const buttonOf = (page, name) => layoutOf(page).getByRole('button', { name, exact: true });
|
||
const settingOf = (page, label) => layoutOf(page).locator('.setting-row').filter({ has: page.locator(`label:text-is("${label}")`) });
|
||
|
||
async function serverJson(request, url) {
|
||
const response = await request.get(url);
|
||
await expect(response).toBeOK();
|
||
return response.json();
|
||
}
|
||
|
||
/**
|
||
* The restore job's own end (#361): its progress stream closes when the job takes its final state, however long the
|
||
* disk-bound publish takes (bounded by the test, not by a 5 s expect), and the job finished. The toast the page shows
|
||
* for it is then the page's reaction alone, checked with the default timeout.
|
||
*/
|
||
async function restoreFinished(request, id) {
|
||
await expect(await request.get(`/prompt_manager/jobs/${id}/events`)).toBeOK();
|
||
const { job } = await serverJson(request, `/prompt_manager/jobs/${id}`);
|
||
expect([job.kind, job.state]).toEqual(['restore', 'finished']);
|
||
}
|
||
|
||
async function openBackups(page) {
|
||
// A fresh page each time: the drafts live for the page session (Q10), and a hash-only goto would not reload.
|
||
if (page.url().endsWith('#/system/backups')) await page.reload();
|
||
else await page.goto(`${workspace}#/system/backups`);
|
||
await expect(layoutOf(page).locator(':scope > .two-col > section.surface')).toHaveCount(2);
|
||
await expect(statusLine(page)).not.toHaveText('Checking backups…');
|
||
// The policy row is painted once the server settings were read.
|
||
await expect(settingOf(page, 'Create a safety backup before maintenance').locator('input')).toBeEnabled();
|
||
return layoutOf(page);
|
||
}
|
||
|
||
/** The status line the page should show for the server's current status (its own wording, in this locale). */
|
||
async function expectedStatus(page, request) {
|
||
const status = await serverJson(request, STATUS_URL);
|
||
return page.evaluate(([answer, options]) => {
|
||
const when = value => new Date(value).toLocaleString(undefined, options);
|
||
const parts = [];
|
||
if (answer.last_download) parts.push(`Downloaded backup · ${when(answer.last_download.at)} · ${answer.last_download.verified ? 'verified' : 'not verified'}.`);
|
||
if (answer.safety_backups.length) parts.push(`Safety backup · ${when(answer.safety_backups[0].modified_at)} · ${answer.safety_backups.length} on disk.`);
|
||
return parts.join(' ') || 'No backup recorded.';
|
||
}, [status, WHEN]);
|
||
}
|
||
|
||
/** A verified copy of the library as it is now (this records the session's last download, as Back up does). */
|
||
async function backupBytes(request) {
|
||
const response = await request.get('/prompt_manager/backup');
|
||
await expect(response).toBeOK();
|
||
expect(response.headers()['x-promptmanager-verify']).toBe('ok');
|
||
return response.body();
|
||
}
|
||
|
||
/** Pick `buffer` as the backup file through Review restore…'s file chooser; resolves with the preview's answer. */
|
||
async function chooseBackup(page, buffer, name = 'prompts_backup_20261025_093000.db') {
|
||
const chooser = page.waitForEvent('filechooser');
|
||
const answer = page.waitForResponse(response => response.url().endsWith(PREVIEW_URL) && response.request().method() === 'POST');
|
||
await buttonOf(page, 'Review restore…').click();
|
||
await (await chooser).setFiles({ name, mimeType: 'application/octet-stream', buffer });
|
||
return answer;
|
||
}
|
||
|
||
const gridOf = page => dialog(page).locator('dl.meta-grid > div').evaluateAll(items => items.map(item =>
|
||
[item.querySelector('dt').textContent, item.querySelector('dd').textContent]));
|
||
|
||
const libraryTotal = async request => (await serverJson(request, '/prompt_manager/library?limit=1')).total;
|
||
|
||
test('Back up downloads through the api client and revokes the object URL (D103)', async ({ page, request }) => {
|
||
await page.addInitScript(() => {
|
||
window.objectUrls = { created: [], revoked: [] };
|
||
const create = URL.createObjectURL.bind(URL), revoke = URL.revokeObjectURL.bind(URL);
|
||
URL.createObjectURL = blob => { const url = create(blob); window.objectUrls.created.push(url); return url; };
|
||
URL.revokeObjectURL = url => { window.objectUrls.revoked.push(url); revoke(url); };
|
||
});
|
||
await openBackups(page);
|
||
await expect(statusLine(page)).toHaveText('No backup recorded.');
|
||
const download = page.waitForEvent('download');
|
||
const answer = page.waitForResponse(response => new URL(response.url()).pathname === '/prompt_manager/backup');
|
||
await buttonOf(page, 'Back up').click();
|
||
expect((await answer).headers()['x-promptmanager-verify']).toBe('ok');
|
||
expect((await download).suggestedFilename()).toMatch(/^prompts_backup_\d{8}_\d{6}\.db$/);
|
||
await expect(toast(page)).toHaveText('Database backup downloaded.');
|
||
// The status line is read again: this session's verified download, as the server records it.
|
||
await expect(statusLine(page)).toHaveText(/^Downloaded backup · .+ · verified\.$/);
|
||
await expect(statusLine(page)).toHaveText(await expectedStatus(page, request));
|
||
await expect(buttonOf(page, 'Back up')).not.toHaveAttribute('aria-disabled', 'true');
|
||
const urls = await page.evaluate(() => window.objectUrls.created);
|
||
expect(urls).toHaveLength(1);
|
||
await expect.poll(() => page.evaluate(() => window.objectUrls.revoked)).toEqual(urls);
|
||
});
|
||
|
||
test('status line shows only server-reported backups (D103)', async ({ page, request }) => {
|
||
await openBackups(page);
|
||
await expect(statusLine(page)).toHaveText('No backup recorded.');
|
||
await expect(await request.post('/__test__/system/safety-backup')).toBeOK();
|
||
await openBackups(page);
|
||
await expect(statusLine(page)).toHaveText(/^Safety backup · .+ · 1 on disk\.$/);
|
||
await expect(statusLine(page)).toHaveText(await expectedStatus(page, request));
|
||
// A status the server could not report is never shown as "no backup".
|
||
await page.route(`**${STATUS_URL}`, route => route.fulfill({ status: 500, json: { success: false, error: 'Failed' } }));
|
||
await page.reload();
|
||
await expect(statusLine(page)).toHaveText('The backup status could not be read.');
|
||
});
|
||
|
||
test('Overview backup row uses the same status (D93)', async ({ page, request }) => {
|
||
await expect(await request.post('/__test__/system/safety-backup')).toBeOK();
|
||
await backupBytes(request);
|
||
await page.goto(`${workspace}#/system/overview`);
|
||
const row = layoutOf(page).locator('.operation').filter({ has: page.locator('h3', { hasText: 'Backup status' }) });
|
||
const expected = await expectedStatus(page, request);
|
||
expect(expected).toMatch(/^Downloaded backup · .+ · verified\. Safety backup · .+ · 1 on disk\.$/);
|
||
await expect(row.locator('p')).toHaveText(expected);
|
||
await row.getByRole('button', { name: 'View backups', exact: true }).click();
|
||
await expect(page).toHaveURL(`${workspace}#/system/backups`);
|
||
await expect(statusLine(page)).toHaveText(expected);
|
||
});
|
||
|
||
test('Review restore stages the chosen file and shows the real summary with the backup box (D105)', async ({ page, request }) => {
|
||
const bytes = await backupBytes(request);
|
||
await expect(await request.post('/prompt_manager/save', { data: { text: 'S9 prompt saved after the backup' } })).toBeOK();
|
||
const before = await serverJson(request, '/__test__/system/digest');
|
||
await openBackups(page);
|
||
const answer = await chooseBackup(page, bytes);
|
||
const preview = await answer.json();
|
||
expect(preview.integrity).toBe('ok');
|
||
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
|
||
const counts = summary => `${summary.prompts.toLocaleString('en-US')} ${summary.prompts === 1 ? 'prompt' : 'prompts'} · ` +
|
||
`${summary.image_links} ${summary.image_links === 1 ? 'image link' : 'image links'} · ` +
|
||
`${summary.pairs} ${summary.pairs === 1 ? 'saved pair' : 'saved pairs'} · ${summary.runs} ${summary.runs === 1 ? 'run' : 'runs'}`;
|
||
const rows = await gridOf(page);
|
||
expect(rows.map(([term]) => term)).toEqual(['Snapshot', 'Integrity', 'Migration', 'Safety backup', 'In the backup', 'In the library now']);
|
||
expect(rows[0][1]).toMatch(/^prompts_backup_20261025_093000.db · (\d+ KB|\d+\.\d MB) · .+$/);
|
||
expect(rows.slice(1)).toEqual([['Integrity', 'Verified'], ['Migration', 'Current schema'],
|
||
['Safety backup', `Created before replacing (${preview.safety_backup.destination_label})`],
|
||
['In the backup', counts(preview.summary)], ['In the library now', counts(preview.live)]]);
|
||
// The prompt saved after the backup is the difference the review shows.
|
||
expect(preview.live.prompts).toBe(preview.summary.prompts + 1);
|
||
await expect(dialog(page).locator('#modal-body > p.small')).toHaveText('Restore replaces the whole library with this backup ' +
|
||
'and runs alone in Activity. Nothing changes until you restore. This review holds for 15 minutes.');
|
||
await expect(dialog(page).locator('.notice h3')).toHaveText('Back up the database first (recommended)');
|
||
await expect(dialog(page).locator('.notice p')).toHaveText('Downloads a copy of the prompts database before the restore replaces it.');
|
||
const actions = dialog(page).locator('.dialog-actions > button');
|
||
await expect(actions).toHaveText(['Cancel', 'Restore']);
|
||
await expect(actions.nth(1)).toHaveClass('danger');
|
||
// The backup box downloads a copy and keeps the review open.
|
||
const download = page.waitForEvent('download');
|
||
await dialog(page).getByRole('button', { name: 'Back up now', exact: true }).click();
|
||
await download;
|
||
await expect(toast(page)).toHaveText('Database backup downloaded.');
|
||
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
|
||
// A review writes nothing: the rows and the media are as they were.
|
||
expect(await serverJson(request, '/__test__/system/digest')).toEqual(before);
|
||
expect((await serverJson(request, '/__test__/system/backups')).stages).toBe(1);
|
||
});
|
||
|
||
test('Cancel discards the stage; Escape too', async ({ page, request }) => {
|
||
const bytes = await backupBytes(request);
|
||
const before = await serverJson(request, '/__test__/system/digest');
|
||
await openBackups(page);
|
||
await chooseBackup(page, bytes);
|
||
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
|
||
expect((await serverJson(request, '/__test__/system/backups')).stages).toBe(1);
|
||
let discarded = page.waitForResponse(response => response.url().includes(`${PREVIEW_URL}/`) && response.request().method() === 'DELETE');
|
||
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
|
||
expect((await discarded).status()).toBe(200);
|
||
await expect(dialog(page)).toHaveCount(0);
|
||
await expect(buttonOf(page, 'Review restore…')).toBeFocused();
|
||
expect((await serverJson(request, '/__test__/system/backups')).stages).toBe(0);
|
||
await chooseBackup(page, bytes);
|
||
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
|
||
discarded = page.waitForResponse(response => response.url().includes(`${PREVIEW_URL}/`) && response.request().method() === 'DELETE');
|
||
await page.keyboard.press('Escape');
|
||
expect((await discarded).status()).toBe(200);
|
||
expect((await serverJson(request, '/__test__/system/backups')).stages).toBe(0);
|
||
expect(await serverJson(request, '/__test__/system/digest')).toEqual(before);
|
||
});
|
||
|
||
test('Restore starts the job, opens Activity; views reload after it finishes', async ({ page, request }) => {
|
||
const bytes = await backupBytes(request);
|
||
const restoredTotal = await libraryTotal(request);
|
||
await expect(await request.post('/prompt_manager/save', { data: { text: 'S9 prompt the restore removes' } })).toBeOK();
|
||
expect(await libraryTotal(request)).toBe(restoredTotal + 1);
|
||
await openBackups(page);
|
||
const libraryCount = page.locator('aside.sidebar a[data-route="library"] .nav-count');
|
||
await expect(libraryCount).toHaveText((restoredTotal + 1).toLocaleString('en-US'));
|
||
await chooseBackup(page, bytes);
|
||
const applied = page.waitForResponse(response => response.url().endsWith(APPLY_URL));
|
||
await dialog(page).getByRole('button', { name: 'Restore', exact: true }).click();
|
||
const { job } = await (await applied).json();
|
||
expect([job.kind, job.title, job.cancellable]).toEqual(['restore', 'Database restore', false]);
|
||
await expect(page).toHaveURL(`${workspace}#/activity/${job.id}`);
|
||
await restoreFinished(request, job.id);
|
||
await expect(toast(page)).toHaveText(/^Database restore finished · Restored \d+ prompts?, /);
|
||
// The library is the backup's again, and the page says so without a reload: the sidebar counts were read again.
|
||
expect(await libraryTotal(request)).toBe(restoredTotal);
|
||
await expect(libraryCount).toHaveText(restoredTotal.toLocaleString('en-US'));
|
||
expect((await serverJson(request, '/__test__/system/backups')).safety).toHaveLength(1);
|
||
// The Activity job view stayed, and Backup & export now names the safety backup the restore kept.
|
||
await expect(page).toHaveURL(`${workspace}#/activity/${job.id}`);
|
||
await page.goto(`${workspace}#/system/backups`);
|
||
await expect(statusLine(page)).toHaveText(/Safety backup · .+ · 1 on disk\.$/);
|
||
});
|
||
|
||
test('a finished restore shows the open page again from the restored data', async ({ page, request }) => {
|
||
const bytes = await backupBytes(request);
|
||
const restoredTotal = await libraryTotal(request);
|
||
await expect(await request.post('/prompt_manager/save', { data: { text: 'S9 prompt the restore removes' } })).toBeOK();
|
||
await openBackups(page);
|
||
await chooseBackup(page, bytes);
|
||
const preview = page.locator('dialog#modal[open] #modal-title');
|
||
await expect(preview).toHaveText('Restore preview');
|
||
// The restore is held at its start, so the reader is on the Library (showing the prompt) when it finishes.
|
||
await expect(await request.post('/__test__/hold', { data: { path: APPLY_URL } })).toBeOK();
|
||
const applied = page.waitForResponse(response => response.url().endsWith(APPLY_URL));
|
||
await dialog(page).getByRole('button', { name: 'Restore', exact: true }).click();
|
||
await expect.poll(async () => (await serverJson(request, '/__test__/hold')).held[APPLY_URL]).toBe(1);
|
||
await page.evaluate(() => { window.location.hash = '#/library'; });
|
||
const card = page.locator('#workspace-main article.prompt-card').filter({ hasText: 'S9 prompt the restore removes' });
|
||
await expect(card).toHaveCount(1);
|
||
await expect(await request.post('/__test__/release')).toBeOK();
|
||
const { job } = await (await applied).json();
|
||
await restoreFinished(request, job.id);
|
||
await expect(toast(page)).toHaveText(/^Database restore finished · /);
|
||
// The shelf was read again from the restored library, on the same route.
|
||
await expect(card).toHaveCount(0);
|
||
await expect(page).toHaveURL(`${workspace}#/library`);
|
||
expect(await libraryTotal(request)).toBe(restoredTotal);
|
||
});
|
||
|
||
test('invalid file shows the validator message; nothing is staged', async ({ page, request }) => {
|
||
const before = await serverJson(request, '/__test__/system/digest');
|
||
await openBackups(page);
|
||
const answer = await chooseBackup(page, Buffer.from('this is not a database'), 'notes.db');
|
||
expect(answer.status()).toBe(400);
|
||
const body = await answer.json();
|
||
expect(body.code).toBe('invalid-backup');
|
||
await expect(toast(page)).toHaveText(`This file can't be restored: ${body.error.replace(/\.?$/, '.')}`);
|
||
await expect(dialog(page)).toHaveCount(0);
|
||
await expect(buttonOf(page, 'Review restore…')).not.toHaveAttribute('aria-disabled', 'true');
|
||
expect((await serverJson(request, '/__test__/system/backups')).stages).toBe(0);
|
||
expect(await serverJson(request, '/__test__/system/digest')).toEqual(before);
|
||
});
|
||
|
||
test('Export JSON downloads /library/export; secondary buttons download all prompts (/export) and /pairs/export (D103)', async ({ page }) => {
|
||
const layout = await openBackups(page);
|
||
const portable = layout.locator('section.surface').filter({ has: page.locator('h2', { hasText: 'Portable export' }) });
|
||
expect(await portable.locator('.row-actions > button').allTextContents())
|
||
.toEqual(['Export JSON', 'Import / inspect file', 'Export all prompts JSON', 'Export saved pairs JSON']);
|
||
for (const [label, url, name, kind] of [
|
||
['Export JSON', '/prompt_manager/library/export', /^prompt-manager-library-v1\.json$/, 'prompt-manager-library'],
|
||
['Export all prompts JSON', '/prompt_manager/export', /^prompt_manager_\d{8}_\d{6}\.json$/, null],
|
||
['Export saved pairs JSON', '/prompt_manager/pairs/export', /^prompt-pairs\.json$/, 'prompt-manager-pairs']]) {
|
||
const download = page.waitForEvent('download');
|
||
const answer = page.waitForResponse(response => new URL(response.url()).pathname === url);
|
||
await portable.getByRole('button', { name: label, exact: true }).click();
|
||
expect((await answer).status()).toBe(200);
|
||
const file = await download;
|
||
expect(file.suggestedFilename()).toMatch(name);
|
||
const parsed = JSON.parse(await (await file.createReadStream()).toArray().then(chunks => Buffer.concat(chunks).toString('utf8')));
|
||
if (kind) expect(parsed.kind).toBe(kind);
|
||
else expect(Array.isArray(parsed.prompts)).toBe(true);
|
||
}
|
||
// A failed export says so and saves nothing.
|
||
await page.route('**/prompt_manager/pairs/export', route => route.fulfill({ status: 500, json: { success: false } }));
|
||
await portable.getByRole('button', { name: 'Export saved pairs JSON', exact: true }).click();
|
||
await expect(toast(page)).toHaveText('The export could not be downloaded. Nothing was saved to this device.');
|
||
});
|
||
|
||
test('Import / inspect lists Inspect, Rescan and the shared scan review (D15, D55)', async ({ page, request }) => {
|
||
await openBackups(page);
|
||
const open = async () => {
|
||
await buttonOf(page, 'Import / inspect file').click();
|
||
await expect(dialog(page).locator('#modal-title')).toHaveText('Import & inspect');
|
||
};
|
||
await open();
|
||
const rows = dialog(page).locator('.operation-list > .operation');
|
||
expect(await rows.locator('h3').allTextContents()).toEqual(['Inspect a local image', 'Scan an output folder', 'Scan for metadata']);
|
||
expect(await rows.locator('button').allTextContents()).toEqual(['Open inspector', 'Rescan', 'Review scan']);
|
||
// Rescan reads the output folders again and says what it found; while it runs, a reopened dialog says so.
|
||
await expect(await request.post('/__test__/hold', { data: { path: '/prompt_manager/gallery/rescan' } })).toBeOK();
|
||
const rescan = page.waitForResponse(response => response.url().endsWith('/prompt_manager/gallery/rescan'));
|
||
await rows.getByRole('button', { name: 'Rescan', exact: true }).click();
|
||
await expect(dialog(page)).toHaveCount(0);
|
||
await expect(toast(page)).toHaveText('Rescanning output folders…');
|
||
await open();
|
||
await expect(rows.nth(1).locator('button')).toHaveText('Rescanning…');
|
||
await expect(rows.nth(1).locator('button')).toHaveAttribute('aria-disabled', 'true');
|
||
await expect(await request.post('/__test__/release')).toBeOK();
|
||
expect((await rescan).status()).toBe(200);
|
||
await expect(toast(page)).toHaveText(/^Rescan finished\. \d+ files? in \d+ output folders?\.$/);
|
||
await expect(rows.nth(1).locator('button')).toHaveText('Rescan');
|
||
// Scan for metadata opens the shared review over the server's output folders; Cancel starts nothing.
|
||
await dialog(page).getByRole('button', { name: 'Review scan', exact: true }).click();
|
||
await expect(dialog(page).locator('#modal-title')).toHaveText('Review metadata scan');
|
||
await expect(dialog(page).locator('dl.meta-grid dd').first()).toHaveText(/^\d+ output folders?$/);
|
||
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
|
||
await expect(dialog(page)).toHaveCount(0);
|
||
// Open inspector goes to Inspect a file.
|
||
await open();
|
||
await dialog(page).getByRole('button', { name: 'Open inspector', exact: true }).click();
|
||
await expect(page).toHaveURL(`${workspace}#/inspect`);
|
||
await expect(dialog(page)).toHaveCount(0);
|
||
});
|
||
|
||
test('safety backup toggle saves through Save preferences with revision (D104)', async ({ page, request }) => {
|
||
const before = await serverJson(request, '/prompt_manager/settings');
|
||
expect(before.settings.safety_backup).toBe(true);
|
||
await openBackups(page);
|
||
const row = settingOf(page, 'Create a safety backup before maintenance');
|
||
// D104: what the policy really does (the concept's "Preview a verified backup…" line does not say it).
|
||
await expect(row.locator('p').first()).toHaveText(SAFETY_DESCRIPTION);
|
||
const toggle = row.locator('input');
|
||
await expect(toggle).toBeChecked();
|
||
await toggle.uncheck();
|
||
await expect(settingsStatus(page)).toHaveText('Unsaved changes.');
|
||
const saved = page.waitForRequest(item => item.url().endsWith('/prompt_manager/settings') && item.method() === 'POST');
|
||
await save(page).click();
|
||
const body = (await saved).postDataJSON();
|
||
expect(body).toEqual({ safety_backup: false, expected_revision: before.revision });
|
||
await expect(settingsStatus(page)).toHaveText('Saved to the server configuration.');
|
||
expect((await serverJson(request, '/prompt_manager/settings')).settings.safety_backup).toBe(false);
|
||
// A maintenance review now says so.
|
||
const plan = await request.post('/prompt_manager/maintenance/preview', { data: { operation: 'optimize', options: {} } });
|
||
expect((await plan.json()).backup.policy).toBe('off');
|
||
await openBackups(page);
|
||
await expect(toggle).not.toBeChecked();
|
||
// A configuration changed elsewhere is a conflict, never a blind write.
|
||
await toggle.check();
|
||
await expect(await request.post('/prompt_manager/settings', { data: { result_timeout: 7,
|
||
expected_revision: (await serverJson(request, '/prompt_manager/settings')).revision } })).toBeOK();
|
||
await save(page).click();
|
||
await expect(settingsStatus(page)).toHaveText('Settings changed elsewhere. Reload this section to see them.');
|
||
expect((await serverJson(request, '/prompt_manager/settings')).settings.safety_backup).toBe(false);
|
||
});
|
||
|
||
test('schedule offers Manual only; Daily/Weekly disabled with reason (D104)', async ({ page }) => {
|
||
await openBackups(page);
|
||
const schedule = settingOf(page, 'Scheduled database backups');
|
||
expect(await schedule.locator('option').evaluateAll(options => options.map(option => [option.textContent, option.disabled])))
|
||
.toEqual([['Manual only', false], ['Daily', true], ['Weekly', true]]);
|
||
await expect(schedule.locator('select')).toHaveValue('Manual only');
|
||
await expect(schedule.locator('p')).toHaveText('Daily and Weekly are not available yet: back up with Back up whenever you choose.');
|
||
const policy = layoutOf(page).locator(':scope > section.surface');
|
||
await expect(policy.locator('h2')).toHaveText('Recovery policy Proposed');
|
||
await expect(policy.locator('h2 > span.chip.blue')).toHaveText('Proposed');
|
||
await expect(policy.locator('p.warning-note')).toHaveText('Restores are staged, migrated and validated before replacing live data.');
|
||
// Nothing on the schedule is ever drafted.
|
||
await expect(settingsStatus(page)).toHaveText('No unsaved changes.');
|
||
});
|
||
|
||
test('a failed settings read leaves the policy disabled with the reason', async ({ page }) => {
|
||
await page.route('**/prompt_manager/settings', route => (route.request().method() === 'GET'
|
||
? route.fulfill({ status: 500, json: { success: false, error: 'Failed' } }) : route.continue()));
|
||
await page.goto(`${workspace}#/system/backups`);
|
||
const row = settingOf(page, 'Create a safety backup before maintenance');
|
||
await expect(row.locator('p.small')).toHaveText('Server settings could not be read.');
|
||
await expect(row.locator('input')).toBeDisabled();
|
||
});
|
||
|
||
test('the legacy Backup and Restore routes keep working and the admin page links to System', async ({ page, request }) => {
|
||
const bytes = await backupBytes(request);
|
||
const restored = await request.post('/prompt_manager/restore', { multipart: { database_file: {
|
||
name: 'prompts_backup.db', mimeType: 'application/octet-stream', buffer: bytes } } });
|
||
await expect(restored).toBeOK();
|
||
expect((await restored.json()).success).toBe(true);
|
||
// #330: the admin page's Backup DB and Restore DB open System › Backup & export.
|
||
await page.goto('/prompt_manager/admin');
|
||
for (const id of ['#backupBtn', '#restoreBtn']) await expect(page.locator(id)).toHaveAttribute('href', 'workspace#/system/backups');
|
||
});
|
||
|
||
/** Start a fake running job (a metadata scan another tab started), which an exclusive restore must wait for. */
|
||
async function runningScan(request) {
|
||
const response = await request.post('/__test__/jobs/start', { data: { kind: 'scan', title: 'Metadata scan', total: 4 } });
|
||
await expect(response).toBeOK();
|
||
return (await response.json()).job;
|
||
}
|
||
|
||
const stagesOf = async request => (await serverJson(request, '/__test__/system/backups')).stages;
|
||
const deleted = page => page.waitForResponse(response => response.url().includes(`${PREVIEW_URL}/`) &&
|
||
response.request().method() === 'DELETE');
|
||
|
||
test('a refused restore reopens its review: Restore retries it, Discard releases it (I1)', async ({ page, request }) => {
|
||
const bytes = await backupBytes(request);
|
||
await openBackups(page);
|
||
await chooseBackup(page, bytes);
|
||
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
|
||
const scan = await runningScan(request);
|
||
const refused = page.waitForResponse(response => response.url().endsWith(APPLY_URL));
|
||
await dialog(page).getByRole('button', { name: 'Restore', exact: true }).click();
|
||
expect((await refused).status()).toBe(409);
|
||
await expect(toast(page)).toHaveText('Another job is running: Metadata scan.');
|
||
// The same review is open again, its stage still held: Restore retries, Discard releases it.
|
||
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
|
||
await expect(dialog(page).locator('.dialog-actions > button')).toHaveText(['Discard', 'Restore']);
|
||
expect(await stagesOf(request)).toBe(1);
|
||
await expect(await request.post(`/__test__/jobs/${scan.id}/finish`, { data: { state: 'finished' } })).toBeOK();
|
||
const applied = page.waitForResponse(response => response.url().endsWith(APPLY_URL));
|
||
await dialog(page).getByRole('button', { name: 'Restore', exact: true }).click();
|
||
const { job } = await (await applied).json();
|
||
await expect(page).toHaveURL(`${workspace}#/activity/${job.id}`);
|
||
await restoreFinished(request, job.id);
|
||
await expect(toast(page)).toHaveText(/^Database restore finished · /);
|
||
expect(await stagesOf(request)).toBe(0);
|
||
});
|
||
|
||
test('two refused restores in a row, then a third Review restore… still opens the preview (I1)', async ({ page, request }) => {
|
||
const bytes = await backupBytes(request);
|
||
await openBackups(page);
|
||
await runningScan(request);
|
||
for (const leave of ['discard', 'escape']) {
|
||
await chooseBackup(page, bytes);
|
||
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
|
||
const refused = page.waitForResponse(response => response.url().endsWith(APPLY_URL));
|
||
await dialog(page).getByRole('button', { name: 'Restore', exact: true }).click();
|
||
expect((await refused).status()).toBe(409);
|
||
await expect(dialog(page).locator('.dialog-actions > button')).toHaveText(['Discard', 'Restore']);
|
||
const released = deleted(page);
|
||
if (leave === 'discard') await dialog(page).getByRole('button', { name: 'Discard', exact: true }).click();
|
||
else await page.keyboard.press('Escape');
|
||
expect((await released).status()).toBe(200);
|
||
await expect(dialog(page)).toHaveCount(0);
|
||
expect(await stagesOf(request)).toBe(0);
|
||
}
|
||
const answer = await chooseBackup(page, bytes);
|
||
expect((await answer).status()).toBe(200);
|
||
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
|
||
expect(await stagesOf(request)).toBe(1);
|
||
});
|
||
|
||
test('× and leaving the section or the page discard the stage (M1)', async ({ page, request }) => {
|
||
const bytes = await backupBytes(request);
|
||
await openBackups(page);
|
||
const leaves = [
|
||
['×', () => page.locator('#modal-close').click()],
|
||
// The modal covers the page, so the section changes as a link or the Back button changes it: by its hash.
|
||
['section', () => page.evaluate(() => { window.location.hash = '#/system/maintenance'; })],
|
||
['route', () => page.evaluate(() => { window.location.hash = '#/images'; })],
|
||
];
|
||
for (const [name, leave] of leaves) {
|
||
if (!page.url().endsWith('#/system/backups')) {
|
||
await page.goto(`${workspace}#/system/backups`);
|
||
await expect(statusLine(page)).not.toHaveText('Checking backups…');
|
||
}
|
||
await chooseBackup(page, bytes);
|
||
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
|
||
expect(await stagesOf(request), name).toBe(1);
|
||
// A keepalive DELETE (it may outlive the page) is asserted by its request and the server's stage count.
|
||
const released = page.waitForRequest(item => item.url().includes(`${PREVIEW_URL}/`) && item.method() === 'DELETE');
|
||
await leave();
|
||
await released;
|
||
await expect(dialog(page)).toHaveCount(0);
|
||
await expect.poll(() => stagesOf(request), { message: name }).toBe(0);
|
||
}
|
||
});
|
||
|
||
test('a safety backup file (no .db extension) can be picked and reviewed (M3)', async ({ page, request }) => {
|
||
await expect(await request.post('/__test__/system/safety-backup')).toBeOK();
|
||
const bytes = await backupBytes(request);
|
||
await openBackups(page);
|
||
// The picker lists every file; the server decides what is a database.
|
||
await expect(page.locator('#restore-file')).not.toHaveAttribute('accept', /./);
|
||
const answer = await chooseBackup(page, bytes, 'prompts.db.backup_20261025_093000_000000');
|
||
expect((await answer).status()).toBe(200);
|
||
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
|
||
// The copy's size depends on what earlier tests on this server wrote: KB or MB.
|
||
expect((await gridOf(page))[0][1]).toMatch(/^prompts\.db\.backup_20261025_093000_000000 · [\d,.]+ (KB|MB) · .+$/);
|
||
});
|