* feat(system): back up, restore and export natively System > Backup & export replaces its hand-off with the concept's section on the S8 backend: Back up downloads the verified copy and the status line shows only what GET /backup/status reports (D103); Review restore stages a chosen file and shows the server's review in the shared review dialog, Cancel discards the stage and Restore runs the Database restore job, after which the open views reload (D105); Export JSON plus the components and saved-pairs exports (D103); Import / inspect opens the shared Import & inspect dialog; the safety-backup policy saves through the Save row and scheduled backups offer Manual only (D104). Overview's Backup status row reads the same status. Refs #326 * test(system): compare Backup & export with the concept system-backups-concept.spec compares the section, the Restore preview and the Import & inspect dialog with the concept in both themes at 1600 and 390 wide, mapping only D103, D104, D105 (and D15/D55 for the shared Import dialog), and proves a renamed label, a removed row, a lost danger mark and swapped tokens are noticed. A review's grid values now wrap anywhere (D105), so a long backup file name keeps the concept's two equal columns at phone width. Refs #326 * docs(testing): Backup & export contract (D103-D105) Refs #326 * test(coverage): raise the frontend floors S9 measured system-backups.mjs gets its measured floor in place of the provisional one; admin.js, activity-scan.mjs and maintenance-review.mjs branches go up. Refs #326 * fix(system): keep a refused or handed-back restore review usable A Restore refused as busy now opens the same review again with Discard / Restore, and a restore job whose leases did not drain hands its review back (scope.review_kept) to the page that started it, which reopens it; every other path that abandons a token after Restore sends its DELETE, so held reviews can no longer lock restore out. The kept-review line says where the review is, and a full review store says what to do (I1). applyPlan resolves with its outcome and its job tracking now has failing-before proof (N5). Also: the safety-backup toggle says what the policy does (D104, M2); the restore picker has no type filter so safety backups can be chosen (M3); "Export all prompts JSON" names what /export holds (N1); small snapshots show KB (N3); the duplicated holding and save helpers are shared (N4); a dismissal test covers Cancel, x, Escape, a replacing dialog and leaving (M1). Refs #326 * test(coverage): raise the system-backups branch floor Refs #326
490 lines
31 KiB
JavaScript
490 lines
31 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();
|
||
}
|
||
|
||
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 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();
|
||
await applied;
|
||
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 admin Backup and Restore keep working', 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);
|
||
await page.goto('/prompt_manager/admin');
|
||
await expect(page.locator('#restoreFileInput')).toHaveCount(1);
|
||
});
|
||
|
||
/** 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 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');
|
||
expect((await gridOf(page))[0][1]).toMatch(/^prompts\.db\.backup_20261025_093000_000000 · \d+ KB · .+$/);
|
||
});
|