Add backup recovery center
This commit is contained in:
+4
-4
@@ -28,12 +28,12 @@
|
|||||||
|
|
||||||
## v0.3 — Safe Repair
|
## v0.3 — Safe Repair
|
||||||
|
|
||||||
- [ ] Dry-run repair plan
|
- [x] Dry-run repair plan
|
||||||
- [ ] Backup before repair
|
- [x] Backup before repair
|
||||||
- [ ] Compatibility symlink creation
|
- [x] Compatibility symlink creation
|
||||||
- [ ] Compatibility copy creation
|
- [ ] Compatibility copy creation
|
||||||
- [ ] Rename repair
|
- [ ] Rename repair
|
||||||
- [ ] Rollback log
|
- [x] Rollback log and recovery center
|
||||||
|
|
||||||
## v0.4 — Migration Wizard
|
## v0.4 — Migration Wizard
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Backup recovery center
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
An immediate undo button is not enough. A DJ may restart Serato Doctor, notice
|
||||||
|
an issue later, or need to understand how much disk space safety snapshots use.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The recovery center reads the existing JSON manifests beneath
|
||||||
|
`_Serato_/.serato-doctor-backups/`. It reports creation time, selected keeper,
|
||||||
|
affected paths, restore status, and snapshot size. Invalid or incomplete backup
|
||||||
|
folders are ignored rather than presented as restorable.
|
||||||
|
|
||||||
|
Restore remains conservative: it only replaces a symbolic-link alias and
|
||||||
|
refuses to overwrite a real file. A successful restore records its timestamp in
|
||||||
|
the manifest so the UI cannot accidentally offer the same rollback twice.
|
||||||
|
|
||||||
|
## Edge cases
|
||||||
|
|
||||||
|
- No backup directory is treated as an empty history.
|
||||||
|
- Malformed manifests do not prevent valid backups from loading.
|
||||||
|
- Restored snapshots remain visible for audit purposes and disk accounting.
|
||||||
|
- Backups can only be restored through the Serato library that owns them.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- Backup history reports size and ready status.
|
||||||
|
- Restore changes the persistent status to restored.
|
||||||
|
- Static recovery assets are included in the package.
|
||||||
@@ -34,6 +34,20 @@ class RepairReceipt:
|
|||||||
replaced: Tuple[Path, ...]
|
replaced: Tuple[Path, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BackupSummary:
|
||||||
|
path: Path
|
||||||
|
created_at: str
|
||||||
|
keeper: Path
|
||||||
|
replaced: Tuple[Path, ...]
|
||||||
|
size_bytes: int
|
||||||
|
restored_at: Optional[str]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self) -> str:
|
||||||
|
return "restored" if self.restored_at else "ready"
|
||||||
|
|
||||||
|
|
||||||
def plan_duplicate_repair(
|
def plan_duplicate_repair(
|
||||||
keeper: Path, duplicates: Iterable[Path], serato_root: Path
|
keeper: Path, duplicates: Iterable[Path], serato_root: Path
|
||||||
) -> DuplicateRepairPlan:
|
) -> DuplicateRepairPlan:
|
||||||
@@ -124,9 +138,47 @@ def restore_backup(backup: Path) -> Tuple[Path, ...]:
|
|||||||
original.parent.mkdir(parents=True, exist_ok=True)
|
original.parent.mkdir(parents=True, exist_ok=True)
|
||||||
shutil.copy2(saved, original)
|
shutil.copy2(saved, original)
|
||||||
restored.append(original)
|
restored.append(original)
|
||||||
|
manifest["restored_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
||||||
return tuple(restored)
|
return tuple(restored)
|
||||||
|
|
||||||
|
|
||||||
|
def list_backups(serato_root: Path) -> Tuple[BackupSummary, ...]:
|
||||||
|
backup_root = serato_root.expanduser().resolve() / BACKUP_FOLDER
|
||||||
|
if not backup_root.is_dir():
|
||||||
|
return ()
|
||||||
|
summaries = []
|
||||||
|
for backup in backup_root.iterdir():
|
||||||
|
manifest_path = backup / "manifest.json"
|
||||||
|
if not manifest_path.is_file():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
replaced = tuple(
|
||||||
|
Path(entry["original"]) for entry in manifest["replaced"]
|
||||||
|
)
|
||||||
|
size = sum(
|
||||||
|
path.stat().st_size
|
||||||
|
for path in backup.rglob("*")
|
||||||
|
if path.is_file()
|
||||||
|
)
|
||||||
|
summaries.append(
|
||||||
|
BackupSummary(
|
||||||
|
path=backup.resolve(),
|
||||||
|
created_at=manifest["created_at"],
|
||||||
|
keeper=Path(manifest["keeper"]),
|
||||||
|
replaced=replaced,
|
||||||
|
size_bytes=size,
|
||||||
|
restored_at=manifest.get("restored_at"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except (KeyError, OSError, TypeError, json.JSONDecodeError):
|
||||||
|
continue
|
||||||
|
return tuple(
|
||||||
|
sorted(summaries, key=lambda item: item.created_at, reverse=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def rotate_backups(backup_root: Path, limit: Optional[int]) -> None:
|
def rotate_backups(backup_root: Path, limit: Optional[int]) -> None:
|
||||||
if limit is None or not backup_root.is_dir():
|
if limit is None or not backup_root.is_dir():
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from serato_doctor.scanner import scan_filesystem
|
|||||||
from serato_doctor.repair import (
|
from serato_doctor.repair import (
|
||||||
BACKUP_FOLDER,
|
BACKUP_FOLDER,
|
||||||
apply_duplicate_repair,
|
apply_duplicate_repair,
|
||||||
|
list_backups,
|
||||||
plan_duplicate_repair,
|
plan_duplicate_repair,
|
||||||
restore_backup,
|
restore_backup,
|
||||||
)
|
)
|
||||||
@@ -29,6 +30,7 @@ DETAIL_LIMIT = 50
|
|||||||
STATIC_FILES = {
|
STATIC_FILES = {
|
||||||
"/": ("index.html", "text/html; charset=utf-8"),
|
"/": ("index.html", "text/html; charset=utf-8"),
|
||||||
"/app.css": ("app.css", "text/css; charset=utf-8"),
|
"/app.css": ("app.css", "text/css; charset=utf-8"),
|
||||||
|
"/recovery.css": ("recovery.css", "text/css; charset=utf-8"),
|
||||||
"/app.js": ("app.js", "text/javascript; charset=utf-8"),
|
"/app.js": ("app.js", "text/javascript; charset=utf-8"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,6 +271,29 @@ def duplicate_repair(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def backup_history(serato: Path) -> dict:
|
||||||
|
serato = serato.expanduser().resolve()
|
||||||
|
if not serato.is_dir():
|
||||||
|
raise ValueError(f"Serato folder does not exist: {serato}")
|
||||||
|
backups = list_backups(serato)
|
||||||
|
return {
|
||||||
|
"total": len(backups),
|
||||||
|
"size_bytes": sum(backup.size_bytes for backup in backups),
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"path": str(backup.path),
|
||||||
|
"created_at": backup.created_at,
|
||||||
|
"keeper": str(backup.keeper),
|
||||||
|
"replaced": [str(path) for path in backup.replaced],
|
||||||
|
"size_bytes": backup.size_bytes,
|
||||||
|
"status": backup.status,
|
||||||
|
"restored_at": backup.restored_at,
|
||||||
|
}
|
||||||
|
for backup in backups
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class SeratoDoctorHandler(BaseHTTPRequestHandler):
|
class SeratoDoctorHandler(BaseHTTPRequestHandler):
|
||||||
def do_GET(self) -> None:
|
def do_GET(self) -> None:
|
||||||
asset = STATIC_FILES.get(self.path)
|
asset = STATIC_FILES.get(self.path)
|
||||||
@@ -293,6 +318,7 @@ class SeratoDoctorHandler(BaseHTTPRequestHandler):
|
|||||||
"/api/duplicates/preview",
|
"/api/duplicates/preview",
|
||||||
"/api/duplicates/apply",
|
"/api/duplicates/apply",
|
||||||
"/api/backups/restore",
|
"/api/backups/restore",
|
||||||
|
"/api/backups",
|
||||||
}
|
}
|
||||||
if self.path not in allowed:
|
if self.path not in allowed:
|
||||||
self._json_response(404, {"error": "Not found"})
|
self._json_response(404, {"error": "Not found"})
|
||||||
@@ -309,6 +335,8 @@ class SeratoDoctorHandler(BaseHTTPRequestHandler):
|
|||||||
result = analyze_paths(
|
result = analyze_paths(
|
||||||
Path(payload["serato"]), Path(payload["music"]), roots
|
Path(payload["serato"]), Path(payload["music"]), roots
|
||||||
)
|
)
|
||||||
|
elif self.path == "/api/backups":
|
||||||
|
result = backup_history(Path(payload["serato"]))
|
||||||
elif self.path == "/api/backups/restore":
|
elif self.path == "/api/backups/restore":
|
||||||
serato = Path(payload["serato"]).expanduser().resolve()
|
serato = Path(payload["serato"]).expanduser().resolve()
|
||||||
backup = Path(payload["backup"]).expanduser().resolve()
|
backup = Path(payload["backup"]).expanduser().resolve()
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ const repairMessage = document.querySelector('#repair-message');
|
|||||||
const previewRepairButton = document.querySelector('#preview-repair');
|
const previewRepairButton = document.querySelector('#preview-repair');
|
||||||
const applyRepairButton = document.querySelector('#apply-repair');
|
const applyRepairButton = document.querySelector('#apply-repair');
|
||||||
const restoreRepairButton = document.querySelector('#restore-repair');
|
const restoreRepairButton = document.querySelector('#restore-repair');
|
||||||
|
const loadBackupsButton = document.querySelector('#load-backups');
|
||||||
|
const backupSummary = document.querySelector('#backup-summary');
|
||||||
|
const backupList = document.querySelector('#backup-list');
|
||||||
let latestAnalysis = null;
|
let latestAnalysis = null;
|
||||||
let selectedDuplicateGroup = null;
|
let selectedDuplicateGroup = null;
|
||||||
let previewedRepair = null;
|
let previewedRepair = null;
|
||||||
@@ -34,6 +37,52 @@ function escapeHtml(value) {
|
|||||||
}[character]));
|
}[character]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (!bytes) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const unit = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||||
|
return `${(bytes / (1024 ** unit)).toFixed(unit ? 1 : 0)} ${units[unit]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.valueOf()) ? value : date.toLocaleString([], {dateStyle: 'medium', timeStyle: 'short'});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBackups() {
|
||||||
|
loadBackupsButton.disabled = true;
|
||||||
|
backupSummary.textContent = 'Looking for recovery snapshots…';
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/backups', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({serato: expandHome(document.querySelector('#serato-path').value)})});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) throw new Error(data.error || 'Could not load backups');
|
||||||
|
backupSummary.textContent = `${data.total} backup${data.total === 1 ? '' : 's'} · ${formatBytes(data.size_bytes)} on disk`;
|
||||||
|
backupList.innerHTML = data.items.length ? data.items.map((backup) => `
|
||||||
|
<article class="backup-item">
|
||||||
|
<div><span class="backup-status ${backup.status}">${backup.status === 'restored' ? 'Restored' : 'Ready to restore'}</span><strong>${formatDate(backup.created_at)}</strong><small>Kept: ${escapeHtml(backup.keeper)}</small><small>${backup.replaced.length} original file${backup.replaced.length === 1 ? '' : 's'} · ${formatBytes(backup.size_bytes)}</small></div>
|
||||||
|
<button type="button" data-restore-backup="${escapeHtml(backup.path)}" ${backup.status === 'restored' ? 'disabled' : ''}>${backup.status === 'restored' ? 'Already restored' : 'Restore'}</button>
|
||||||
|
</article>
|
||||||
|
`).join('') : '<div class="empty-detail">No repair backups found for this library yet.</div>';
|
||||||
|
} catch (error) {
|
||||||
|
backupSummary.textContent = error.message;
|
||||||
|
backupList.innerHTML = '';
|
||||||
|
} finally { loadBackupsButton.disabled = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
loadBackupsButton.addEventListener('click', loadBackups);
|
||||||
|
backupList.addEventListener('click', async (event) => {
|
||||||
|
const button = event.target.closest('[data-restore-backup]');
|
||||||
|
if (!button || button.disabled) return;
|
||||||
|
if (!window.confirm('Restore the original duplicate files from this backup? Existing real files will never be overwritten.')) return;
|
||||||
|
button.disabled = true; button.textContent = 'Restoring…';
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/backups/restore', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({serato: expandHome(document.querySelector('#serato-path').value), backup: button.dataset.restoreBackup})});
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok) throw new Error(result.error || 'Restore failed');
|
||||||
|
await loadBackups();
|
||||||
|
} catch (error) { backupSummary.textContent = error.message; button.disabled = false; button.textContent = 'Restore'; }
|
||||||
|
});
|
||||||
|
|
||||||
function detailLines(item) {
|
function detailLines(item) {
|
||||||
if (item.files) {
|
if (item.files) {
|
||||||
return item.files.map((file) => `<li>${escapeHtml(file)}</li>`).join('');
|
return item.files.map((file) => `<li>${escapeHtml(file)}</li>`).join('');
|
||||||
@@ -127,6 +176,7 @@ applyRepairButton.addEventListener('click', async () => {
|
|||||||
repairMessage.textContent = `Cleanup complete. Restore backup: ${result.backup}`;
|
repairMessage.textContent = `Cleanup complete. Restore backup: ${result.backup}`;
|
||||||
previewRepairButton.disabled = true;
|
previewRepairButton.disabled = true;
|
||||||
restoreRepairButton.hidden = false;
|
restoreRepairButton.hidden = false;
|
||||||
|
await loadBackups();
|
||||||
} catch (error) { repairMessage.textContent = error.message; applyRepairButton.disabled = false; }
|
} catch (error) { repairMessage.textContent = error.message; applyRepairButton.disabled = false; }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -139,6 +189,7 @@ restoreRepairButton.addEventListener('click', async () => {
|
|||||||
if (!response.ok) throw new Error(result.error || 'Restore failed');
|
if (!response.ok) throw new Error(result.error || 'Restore failed');
|
||||||
repairMessage.textContent = `Restore complete. ${result.restored.length} original file(s) returned.`;
|
repairMessage.textContent = `Restore complete. ${result.restored.length} original file(s) returned.`;
|
||||||
restoreRepairButton.hidden = true;
|
restoreRepairButton.hidden = true;
|
||||||
|
await loadBackups();
|
||||||
} catch (error) { repairMessage.textContent = error.message; restoreRepairButton.disabled = false; }
|
} catch (error) { repairMessage.textContent = error.message; restoreRepairButton.disabled = false; }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<meta name="color-scheme" content="dark">
|
<meta name="color-scheme" content="dark">
|
||||||
<title>Serato Doctor</title>
|
<title>Serato Doctor</title>
|
||||||
<link rel="stylesheet" href="/app.css">
|
<link rel="stylesheet" href="/app.css">
|
||||||
|
<link rel="stylesheet" href="/recovery.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="ambient ambient-one"></div>
|
<div class="ambient ambient-one"></div>
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
<a class="nav-item active" href="#dashboard"><span>⌁</span> Dashboard</a>
|
<a class="nav-item active" href="#dashboard"><span>⌁</span> Dashboard</a>
|
||||||
<a class="nav-item" href="#scan"><span>◎</span> New analysis</a>
|
<a class="nav-item" href="#scan"><span>◎</span> New analysis</a>
|
||||||
<a class="nav-item" href="#diagnostics"><span>◇</span> Diagnostics</a>
|
<a class="nav-item" href="#diagnostics"><span>◇</span> Diagnostics</a>
|
||||||
|
<a class="nav-item" href="#recovery"><span>↶</span> Recovery</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="safety-card">
|
<div class="safety-card">
|
||||||
<span class="safety-icon">✓</span>
|
<span class="safety-icon">✓</span>
|
||||||
@@ -55,6 +57,13 @@
|
|||||||
<div id="error-message" class="error" role="alert" hidden></div>
|
<div id="error-message" class="error" role="alert" hidden></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="recovery" class="recovery-panel panel">
|
||||||
|
<div class="section-heading"><div><p class="eyebrow">Safety net</p><h2>Backup recovery</h2></div><button id="load-backups" type="button">Check backups</button></div>
|
||||||
|
<p>Review every repair snapshot saved for this Serato library. Older backups remain available after restarting Serato Doctor.</p>
|
||||||
|
<div id="backup-summary" class="backup-summary">Enter your Serato folder above, then check backups.</div>
|
||||||
|
<div id="backup-list" class="backup-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="dashboard" class="results" aria-live="polite" hidden>
|
<section id="dashboard" class="results" aria-live="polite" hidden>
|
||||||
<div class="section-heading"><div><p class="eyebrow">Latest analysis</p><h2>Library health</h2></div><span id="analysis-time"></span></div>
|
<div class="section-heading"><div><p class="eyebrow">Latest analysis</p><h2>Library health</h2></div><span id="analysis-time"></span></div>
|
||||||
<div class="hero-grid">
|
<div class="hero-grid">
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
.recovery-panel{margin-top:18px;padding:25px}.recovery-panel>p{color:var(--muted);font-size:12px;line-height:1.6}.recovery-panel .section-heading button{border:1px solid rgba(155,135,245,.35);border-radius:10px;padding:9px 12px;background:rgba(155,135,245,.12);color:#ddd8fa;font:700 11px inherit;cursor:pointer}.recovery-panel button:disabled{opacity:.5;cursor:not-allowed}.backup-summary{padding:11px 13px;border-radius:11px;background:rgba(255,255,255,.03);color:#bfc4cf;font-size:11px;margin:15px 0 10px}.backup-list{display:grid;gap:9px}.backup-item{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:14px;border:1px solid var(--line);border-radius:13px;background:rgba(255,255,255,.025)}.backup-item>div{min-width:0}.backup-item strong,.backup-item small{display:block}.backup-item strong{font-size:12px;margin:7px 0}.backup-item small{font-size:10px;color:var(--muted);line-height:1.5;overflow-wrap:anywhere}.backup-item>button{flex:0 0 auto;border:0;border-radius:10px;padding:10px 13px;background:#6f5bd0;color:#fff;font:700 11px inherit;cursor:pointer}.backup-status{display:inline-block;padding:4px 7px;border-radius:999px;font-size:9px;text-transform:uppercase;letter-spacing:.08em;color:var(--cyan);background:rgba(102,217,232,.1)}.backup-status.restored{color:#aeb4c2;background:rgba(255,255,255,.06)}@media(max-width:520px){.backup-item{align-items:stretch;flex-direction:column}.backup-item>button{width:100%}}
|
||||||
@@ -5,6 +5,7 @@ import pytest
|
|||||||
from serato_doctor.repair import (
|
from serato_doctor.repair import (
|
||||||
BACKUP_FOLDER,
|
BACKUP_FOLDER,
|
||||||
apply_duplicate_repair,
|
apply_duplicate_repair,
|
||||||
|
list_backups,
|
||||||
plan_duplicate_repair,
|
plan_duplicate_repair,
|
||||||
restore_backup,
|
restore_backup,
|
||||||
rotate_backups,
|
rotate_backups,
|
||||||
@@ -39,10 +40,16 @@ def test_duplicate_repair_backs_up_then_preserves_old_path_as_link(tmp_path):
|
|||||||
assert any((receipt.backup / "serato-metadata").rglob("House.crate"))
|
assert any((receipt.backup / "serato-metadata").rglob("House.crate"))
|
||||||
assert any((receipt.backup / "serato-metadata").rglob("database V2"))
|
assert any((receipt.backup / "serato-metadata").rglob("database V2"))
|
||||||
|
|
||||||
|
history = list_backups(serato)
|
||||||
|
assert len(history) == 1
|
||||||
|
assert history[0].status == "ready"
|
||||||
|
assert history[0].size_bytes > 0
|
||||||
|
|
||||||
restored = restore_backup(receipt.backup)
|
restored = restore_backup(receipt.backup)
|
||||||
assert restored == (duplicate.resolve(),)
|
assert restored == (duplicate.resolve(),)
|
||||||
assert not duplicate.is_symlink()
|
assert not duplicate.is_symlink()
|
||||||
assert duplicate.read_bytes() == b"duplicate"
|
assert duplicate.read_bytes() == b"duplicate"
|
||||||
|
assert list_backups(serato)[0].status == "restored"
|
||||||
|
|
||||||
|
|
||||||
def test_plan_rejects_keeper_outside_duplicate_group(tmp_path):
|
def test_plan_rejects_keeper_outside_duplicate_group(tmp_path):
|
||||||
|
|||||||
+2
-1
@@ -40,12 +40,13 @@ def test_web_analysis_rejects_missing_folders(tmp_path):
|
|||||||
def test_web_static_assets_are_declared_and_packaged():
|
def test_web_static_assets_are_declared_and_packaged():
|
||||||
asset_root = Path(__file__).parents[1] / "serato_doctor" / "webui"
|
asset_root = Path(__file__).parents[1] / "serato_doctor" / "webui"
|
||||||
|
|
||||||
assert set(STATIC_FILES) == {"/", "/app.css", "/app.js"}
|
assert set(STATIC_FILES) == {"/", "/app.css", "/recovery.css", "/app.js"}
|
||||||
assert all((asset_root / filename).is_file() for filename, _ in STATIC_FILES.values())
|
assert all((asset_root / filename).is_file() for filename, _ in STATIC_FILES.values())
|
||||||
html = (asset_root / "index.html").read_text(encoding="utf-8")
|
html = (asset_root / "index.html").read_text(encoding="utf-8")
|
||||||
assert "Missing tracks in Serato" in html
|
assert "Missing tracks in Serato" in html
|
||||||
assert "Old crate references" in html
|
assert "Old crate references" in html
|
||||||
assert "Choose a diagnostic" in html
|
assert "Choose a diagnostic" in html
|
||||||
|
assert "Backup recovery" in html
|
||||||
assert 'data-detail="database_missing_tracks"' in html
|
assert 'data-detail="database_missing_tracks"' in html
|
||||||
assert 'data-detail="old_crate_references"' in html
|
assert 'data-detail="old_crate_references"' in html
|
||||||
assert html.count('class="info-button"') >= 10
|
assert html.count('class="info-button"') >= 10
|
||||||
|
|||||||
Reference in New Issue
Block a user