profiler: check a profile against a stored card snapshot

Each profile row gets a Check card snapshot button next to Check card.
It opens a picker listing the stored snapshots (name, ICCID, date, file
count) and runs the profile rules offline against the selected snapshot,
showing the usual report titled 'profile — snapshot'.

profilerRunRule() accepts an optional data source: profilerLiveSource()
(default) wraps /api/select + /api/read, profilerSnapshotSource() serves
select/read from the snapshot files. Contents missing from the snapshot
are reported as unverifiable errors ('Content not captured in snapshot')
rather than mismatches. The shared check loop moved into
profilerRunProfile().

Tests for the snapshot source and snapshot-based checks; help docs
updated; SW cache v84 -> v85.
This commit is contained in:
2026-09-11 23:16:14 +03:00
parent 612f267349
commit f64f2dac7c
5 changed files with 163 additions and 11 deletions
+2 -1
View File
@@ -371,7 +371,7 @@
<li><strong>Новый профиль</strong> — создаёт пустой набор правил, запросив имя.</li> <li><strong>Новый профиль</strong> — создаёт пустой набор правил, запросив имя.</li>
<li><strong>Профиль с карты</strong> — сканирует подключённую карту и создаёт по одному правилу на каждый существующий файл (см. ниже), затем открывает редактор.</li> <li><strong>Профиль с карты</strong> — сканирует подключённую карту и создаёт по одному правилу на каждый существующий файл (см. ниже), затем открывает редактор.</li>
<li><strong>Импорт профиля</strong> — загружает набор правил из JSON-файла (имя хранится внутри JSON).</li> <li><strong>Импорт профиля</strong> — загружает набор правил из JSON-файла (имя хранится внутри JSON).</li>
<li>В каждой строке профиля показаны имя и время создания, а также действия <strong>Проверить карту ▶</strong>, <strong>Редактировать</strong>, <strong>Экспорт</strong> (скачать JSON) и <strong>Удалить</strong>.</li> <li>В каждой строке профиля показаны имя и время создания, а также действия <strong>Проверить карту ▶</strong>, <strong>Проверить снимок карты</strong>, <strong>Редактировать</strong>, <strong>Экспорт</strong> (скачать JSON) и <strong>Удалить</strong>.</li>
</ul> </ul>
<h4 class="font-medium mb-1">Правила файловой системы</h4> <h4 class="font-medium mb-1">Правила файловой системы</h4>
<p class="text-sm mb-2">Правила выполняются последовательно. Редактор показывает символьное имя файла pySim (если известно) рядом с путём правила; <strong>Добавить правило</strong> добавляет правило, <strong>Сохранить</strong> сохраняет изменения. Правило файловой системы задаётся:</p> <p class="text-sm mb-2">Правила выполняются последовательно. Редактор показывает символьное имя файла pySim (если известно) рядом с путём правила; <strong>Добавить правило</strong> добавляет правило, <strong>Сохранить</strong> сохраняет изменения. Правило файловой системы задаётся:</p>
@@ -391,6 +391,7 @@
<li><strong>Новый снимок</strong> &mdash; запрашивает имя и сканирует карту, затем возвращает к списку.</li> <li><strong>Новый снимок</strong> &mdash; запрашивает имя и сканирует карту, затем возвращает к списку.</li>
<li><strong>Импорт снимка</strong> &mdash; загружает снимок из JSON-файла.</li> <li><strong>Импорт снимка</strong> &mdash; загружает снимок из JSON-файла.</li>
<li>В каждой строке снимка — <strong>Открыть</strong>, <strong>Экспорт</strong> и <strong>Удалить</strong>. <strong>Открыть</strong> показывает все захваченные данные только для чтения (сырой FCI с декодированным FCI, содержимое); редактируется только имя снимка.</li> <li>В каждой строке снимка — <strong>Открыть</strong>, <strong>Экспорт</strong> и <strong>Удалить</strong>. <strong>Открыть</strong> показывает все захваченные данные только для чтения (сырой FCI с декодированным FCI, содержимое); редактируется только имя снимка.</li>
<li><strong>Проверить снимок карты</strong> в строке профиля выполняет правила профиля на выбранном из списка снимке, без картридера. Отчёт такой же, как при проверке карты; файлы, содержимое которых не было захвачено при сканировании, помечаются как непроверяемые ошибки.</li>
</ul> </ul>
<h3 id="usage-scenarios" class="text-lg font-medium mb-2">5.7 Сценарии использования</h3> <h3 id="usage-scenarios" class="text-lg font-medium mb-2">5.7 Сценарии использования</h3>
+2 -1
View File
@@ -371,7 +371,7 @@
<li><strong>New profile</strong> — creates an empty ruleset after prompting for a name.</li> <li><strong>New profile</strong> — creates an empty ruleset after prompting for a name.</li>
<li><strong>Profile from card</strong> — scans the equipped card and generates one rule per existing file (see below), then opens the editor.</li> <li><strong>Profile from card</strong> — scans the equipped card and generates one rule per existing file (see below), then opens the editor.</li>
<li><strong>Import profile</strong> — loads a ruleset from a JSON file (the name is stored inside the JSON).</li> <li><strong>Import profile</strong> — loads a ruleset from a JSON file (the name is stored inside the JSON).</li>
<li>Each profile row shows its name and creation time, with <strong>Check card ▶</strong>, <strong>Edit</strong>, <strong>Export</strong> (download JSON), and <strong>Delete</strong> actions.</li> <li>Each profile row shows its name and creation time, with <strong>Check card ▶</strong>, <strong>Check card snapshot</strong>, <strong>Edit</strong>, <strong>Export</strong> (download JSON), and <strong>Delete</strong> actions.</li>
</ul> </ul>
<h4 class="font-medium mb-1">Filesystem rules</h4> <h4 class="font-medium mb-1">Filesystem rules</h4>
<p class="text-sm mb-2">Rules run sequentially. The editor shows the symbolic pySim name (when known) next to each rule&rsquo;s path; use <strong>Add rule</strong> to append one and <strong>Save</strong> to keep the changes. A filesystem rule is defined by:</p> <p class="text-sm mb-2">Rules run sequentially. The editor shows the symbolic pySim name (when known) next to each rule&rsquo;s path; use <strong>Add rule</strong> to append one and <strong>Save</strong> to keep the changes. A filesystem rule is defined by:</p>
@@ -391,6 +391,7 @@
<li><strong>New snapshot</strong> &mdash; asks for a name and scans the card, then returns to the list.</li> <li><strong>New snapshot</strong> &mdash; asks for a name and scans the card, then returns to the list.</li>
<li><strong>Import snapshot</strong> &mdash; loads a snapshot from a JSON file.</li> <li><strong>Import snapshot</strong> &mdash; loads a snapshot from a JSON file.</li>
<li>Each snapshot row has <strong>Open</strong>, <strong>Export</strong>, and <strong>Delete</strong>. <strong>Open</strong> shows all captured data read-only (raw FCI with the decoded FCI, contents); only the snapshot name is editable.</li> <li>Each snapshot row has <strong>Open</strong>, <strong>Export</strong>, and <strong>Delete</strong>. <strong>Open</strong> shows all captured data read-only (raw FCI with the decoded FCI, contents); only the snapshot name is editable.</li>
<li><strong>Check card snapshot</strong> on a profile row runs the profile rules against a snapshot you pick from the list, without a card reader. The report is the same as a live check; files whose contents were not captured during the scan are reported as unverifiable errors.</li>
</ul> </ul>
<h3 id="usage-scenarios" class="text-lg font-medium mb-2">5.7 Usage scenarios</h3> <h3 id="usage-scenarios" class="text-lg font-medium mb-2">5.7 Usage scenarios</h3>
+89 -7
View File
@@ -961,6 +961,16 @@
</div> </div>
</div> </div>
<div id="snapshot-pick-modal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center hidden">
<div class="bg-white dark:bg-slate-800 rounded-lg p-6 max-w-sm w-full mx-4 max-h-[85vh] overflow-auto">
<h3 class="text-lg font-semibold mb-4 text-gray-800 dark:text-slate-200" data-l10n="Select card snapshot">Select card snapshot</h3>
<div id="snapshot-pick-list" class="space-y-1 mb-4"></div>
<div class="flex gap-2 justify-end">
<button onclick="document.getElementById('snapshot-pick-modal').classList.add('hidden')" class="px-3 py-1.5 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-100 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Cancel">Cancel</button>
</div>
</div>
</div>
<div id="stk-menu-panel" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center hidden"> <div id="stk-menu-panel" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center hidden">
<div class="bg-white dark:bg-slate-800 rounded-lg p-4 max-w-xs w-full max-h-[80vh] flex flex-col"> <div class="bg-white dark:bg-slate-800 rounded-lg p-4 max-w-xs w-full max-h-[80vh] flex flex-col">
<div class="flex justify-between items-center mb-3"> <div class="flex justify-between items-center mb-3">
@@ -7129,6 +7139,7 @@ function profilerRenderList() {
html += '<span class="text-xs text-gray-400 dark:text-slate-500">(' + p.rules.length + ' ' + esc(t('rules')) + ')</span>'; html += '<span class="text-xs text-gray-400 dark:text-slate-500">(' + p.rules.length + ' ' + esc(t('rules')) + ')</span>';
html += '<span class="ml-auto flex gap-1">'; html += '<span class="ml-auto flex gap-1">';
html += '<button onclick="profilerCheck(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700">' + esc(t('Check card')) + ' ▶</button>'; html += '<button onclick="profilerCheck(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700">' + esc(t('Check card')) + ' ▶</button>';
html += '<button onclick="profilerCheckSnapshotPick(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-slate-600 text-white hover:bg-slate-700">' + esc(t('Check card snapshot')) + '</button>';
html += '<button onclick="profilerEdit(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-blue-600 text-white hover:bg-blue-700">' + esc(t('Edit')) + '</button>'; html += '<button onclick="profilerEdit(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-blue-600 text-white hover:bg-blue-700">' + esc(t('Edit')) + '</button>';
html += '<button onclick="profilerExport(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-gray-600 text-white hover:bg-gray-700">' + esc(t('Export')) + '</button>'; html += '<button onclick="profilerExport(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-gray-600 text-white hover:bg-gray-700">' + esc(t('Export')) + '</button>';
html += '<button onclick="profilerDelete(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-red-600 text-white hover:bg-red-700">' + esc(t('Delete')) + '</button>'; html += '<button onclick="profilerDelete(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-red-600 text-white hover:bg-red-700">' + esc(t('Delete')) + '</button>';
@@ -7878,8 +7889,12 @@ function profilerSaveProfile() {
async function profilerCheck(i) { async function profilerCheck(i) {
const p = profiles[i]; const p = profiles[i];
await profilerRunProfile(p, null, p.name);
}
async function profilerRunProfile(p, source, title) {
profilerSetView('results'); profilerSetView('results');
document.getElementById('profiler-results-title').textContent = p.name; document.getElementById('profiler-results-title').textContent = title;
const prog = document.getElementById('profiler-progress'); const prog = document.getElementById('profiler-progress');
document.getElementById('profiler-summary').innerHTML = ''; document.getElementById('profiler-summary').innerHTML = '';
document.getElementById('profiler-report').innerHTML = ''; document.getElementById('profiler-report').innerHTML = '';
@@ -7887,13 +7902,36 @@ async function profilerCheck(i) {
for (let ri = 0; ri < p.rules.length; ri++) { for (let ri = 0; ri < p.rules.length; ri++) {
const r = p.rules[ri]; const r = p.rules[ri];
prog.textContent = t('Checking') + ' ' + (ri + 1) + '/' + p.rules.length + ' — ' + r.path; prog.textContent = t('Checking') + ' ' + (ri + 1) + '/' + p.rules.length + ' — ' + r.path;
results.push(await profilerRunRule(r)); results.push(await profilerRunRule(r, source));
} }
prog.textContent = ''; prog.textContent = '';
profilerResults = results; profilerResults = results;
profilerRenderResultsView(); profilerRenderResultsView();
} }
function profilerCheckSnapshotPick(i) {
if (!snapshots.length) {
alert(t('No snapshots defined.'));
return;
}
let html = '';
for (let si = 0; si < snapshots.length; si++) {
const s = snapshots[si];
html += '<button onclick="profilerCheckSnapshot(' + i + ',' + si + ')" class="w-full text-left px-3 py-2 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-100 dark:hover:bg-slate-700">' +
'<span class="font-semibold text-gray-800 dark:text-slate-200">' + esc(s.name) + '</span>' +
'<span class="block text-gray-500 dark:text-slate-400">' + esc(s.iccid || '') + ' · ' + esc(new Date(s.created).toLocaleString()) + ' · ' + (s.files ? s.files.length : 0) + ' ' + t('files') + '</span></button>';
}
document.getElementById('snapshot-pick-list').innerHTML = html;
document.getElementById('snapshot-pick-modal').classList.remove('hidden');
}
async function profilerCheckSnapshot(i, si) {
document.getElementById('snapshot-pick-modal').classList.add('hidden');
const p = profiles[i];
const s = snapshots[si];
await profilerRunProfile(p, profilerSnapshotSource(s), p.name + ' — ' + s.name);
}
function profilerRenderResultsView() { function profilerRenderResultsView() {
if (!profilerResults) return; if (!profilerResults) return;
let passed = 0, failed = 0, errors = 0; let passed = 0, failed = 0, errors = 0;
@@ -7908,12 +7946,46 @@ function profilerRenderResultsView() {
document.getElementById('profiler-report').innerHTML = profilerRenderReport(profilerResults); document.getElementById('profiler-report').innerHTML = profilerRenderReport(profilerResults);
} }
async function profilerRunRule(r) { function profilerLiveSource() {
return {
select: path => pysimFetch('/api/select', { path: path }),
read: path => pysimFetch('/api/read', { path: path, mode: 'raw' }),
};
}
function profilerSnapshotSource(snapshot) {
const find = path => (snapshot.files || []).find(f => (f.path || '').toUpperCase() === (path || '').toUpperCase());
return {
select: async path => {
const f = find(path);
if (!f) return { exists: false };
return {
name: f.name,
file_type: f.fileType,
file_size: f.fileSize,
record_len: f.recordLen,
num_of_rec: f.numRecords,
fci_hex: f.fciHex,
exists: true,
};
},
read: async path => {
const f = find(path);
if (!f) return { success: false };
if (!f.content) return { success: false, sw: 'not-captured' };
if (f.content.kind === 'record') return { success: true, records: f.content.records };
return { success: true, data: f.content.data };
},
};
}
async function profilerRunRule(r, source) {
source = source || profilerLiveSource();
const res = { path: r.path, name: null, status: 'pass', checks: [] }; const res = { path: r.path, name: null, status: 'pass', checks: [] };
let sizeMismatch = false; let sizeMismatch = false;
let sel; let sel;
try { try {
sel = await pysimFetch('/api/select', { path: r.path }); sel = await source.select(r.path);
} catch (e) { } catch (e) {
res.status = 'error'; res.status = 'error';
res.error = e.message; res.error = e.message;
@@ -7960,15 +8032,21 @@ async function profilerRunRule(r) {
if (r.content) { if (r.content) {
let rd; let rd;
try { try {
rd = await pysimFetch('/api/read', { path: r.path, mode: 'raw' }); rd = await source.read(r.path);
} catch (e) { } catch (e) {
res.status = 'error'; res.status = 'error';
res.error = e.message; res.error = e.message;
return res; return res;
} }
if (!rd || !rd.success) { if (!rd || !rd.success) {
res.status = 'fail'; if (rd && rd.sw === 'not-captured') {
res.checks.push({ label: 'content', expected: 'readable', actual: 'read failed (' + (rd && rd.sw ? rd.sw : '?') + ')', ok: false }); res.status = 'error';
res.error = t('Content not captured in snapshot');
res.checks.push({ label: 'content', expected: 'captured', actual: t('not captured in snapshot'), ok: false });
} else {
res.status = 'fail';
res.checks.push({ label: 'content', expected: 'readable', actual: 'read failed (' + (rd && rd.sw ? rd.sw : '?') + ')', ok: false });
}
} else if (r.content.kind === 'record') { } else if (r.content.kind === 'record') {
const exp = r.content.records || []; const exp = r.content.records || [];
const act = rd.records || []; const act = rd.records || [];
@@ -8592,6 +8670,10 @@ const LANG_RU = {
'Import profile': 'Импорт профиля', 'Import profile': 'Импорт профиля',
'Profiles': 'Профили', 'Profiles': 'Профили',
'Card snapshots': 'Снимки карт', 'Card snapshots': 'Снимки карт',
'Check card snapshot': 'Проверить снимок карты',
'Select card snapshot': 'Выберите снимок карты',
'Content not captured in snapshot': 'Содержимое не захвачено в снимок',
'not captured in snapshot': 'не захвачено в снимок',
'New snapshot': 'Новый снимок', 'New snapshot': 'Новый снимок',
'Import snapshot': 'Импорт снимка', 'Import snapshot': 'Импорт снимка',
'Snapshot name': 'Имя снимка', 'Snapshot name': 'Имя снимка',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'otaman-v84'; const CACHE = 'otaman-v85';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+69 -1
View File
@@ -21,7 +21,7 @@ function extractFunc(src, name, asyncFn) {
return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1); return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
} }
const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport', 'parseBerLen', 'parseTlvList', 'fcpInt', 'fcpParseTlvs', 'fcpFileDescriptor', 'fcpLifeCycle', 'fcpSfi', 'fcpDo', 'fcpDecode', 'fcpDiffHtml', 'profilerFciPreviewItems', 'profilerUpdateFciPreview', 'profilerUpdateRule', 'profilerFciInput', 'profilerScanToggleAll', 'profilerScanIgnoreAllState', 'swapNibbles', 'decIccid', 'profilerSnapshotIccid', 'profilerValidateSnapshot', 'profilerListSwitch', 'profilerScanRefreshOptions']; const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport', 'parseBerLen', 'parseTlvList', 'fcpInt', 'fcpParseTlvs', 'fcpFileDescriptor', 'fcpLifeCycle', 'fcpSfi', 'fcpDo', 'fcpDecode', 'fcpDiffHtml', 'profilerFciPreviewItems', 'profilerUpdateFciPreview', 'profilerUpdateRule', 'profilerFciInput', 'profilerScanToggleAll', 'profilerScanIgnoreAllState', 'swapNibbles', 'decIccid', 'profilerSnapshotIccid', 'profilerValidateSnapshot', 'profilerListSwitch', 'profilerScanRefreshOptions', 'profilerLiveSource', 'profilerSnapshotSource'];
let code = ''; let code = '';
for (const f of FNS) code += extractFunc(html, f) + '\n'; for (const f of FNS) code += extractFunc(html, f) + '\n';
code += extractFunc(html, 'profilerBuildFileRule', true) + '\n'; code += extractFunc(html, 'profilerBuildFileRule', true) + '\n';
@@ -961,3 +961,71 @@ test('profilerScanRefreshOptions re-translates mask labels without touching chec
delete global.document; delete global.document;
delete global.t; delete global.t;
}); });
function snapFile(path, extra) {
return {
path: path, name: 'EF.X', fileType: 'transparent', fileSize: 4,
recordLen: null, numRecords: null, fciHex: '621082024021',
content: { kind: 'transparent', data: 'AABB' }, ...(extra || {}),
};
}
test('profilerSnapshotSource selects file metadata by path (case-insensitive)', async () => {
const src = profilerSnapshotSource({ files: [snapFile('MF/7F20/6F3A')] });
const sel = await src.select('mf/7f20/6f3a');
assert.strictEqual(sel.exists, true);
assert.strictEqual(sel.file_type, 'transparent');
assert.strictEqual(sel.file_size, 4);
assert.strictEqual(sel.fci_hex, '621082024021');
});
test('profilerSnapshotSource reports a missing file as not existing', async () => {
const src = profilerSnapshotSource({ files: [snapFile('MF/7F20/6F3A')] });
assert.deepStrictEqual(await src.select('MF/6F07'), { exists: false });
});
test('profilerSnapshotSource read returns captured transparent data and records', async () => {
const src = profilerSnapshotSource({ files: [
snapFile('MF/6F3A'),
snapFile('MF/6F3B', { fileType: 'linear_fixed', fileSize: null, recordLen: 2, numRecords: 1, content: { kind: 'record', records: [{ num: 1, data: 'AABB' }] } }),
] });
assert.strictEqual((await src.read('MF/6F3A')).data, 'AABB');
assert.deepStrictEqual((await src.read('MF/6F3B')).records, [{ num: 1, data: 'AABB' }]);
});
test('profilerSnapshotSource flags uncaptured contents', async () => {
const src = profilerSnapshotSource({ files: [snapFile('MF/6F3A', { content: null })] });
assert.deepStrictEqual(await src.read('MF/6F3A'), { success: false, sw: 'not-captured' });
});
test('profilerRunRule against a snapshot passes on matching metadata and content', async () => {
const src = profilerSnapshotSource({ files: [snapFile('MF/6F3A')] });
const res = await profilerRunRule({ path: 'MF/6F3A', fileType: 'transparent', fileSize: 4, fciMode: 'type_size', content: { mode: 'exact', kind: 'transparent', expected: 'AABB' } }, src);
assert.strictEqual(res.status, 'pass');
});
test('profilerRunRule against a snapshot fails on a missing file', async () => {
const src = profilerSnapshotSource({ files: [] });
const res = await profilerRunRule({ path: 'MF/6F3A' }, src);
assert.strictEqual(res.status, 'fail');
assert.ok(res.checks.some(c => c.label === 'exists' && c.ok === false));
});
test('profilerRunRule against a snapshot fails on metadata mismatch', async () => {
const src = profilerSnapshotSource({ files: [snapFile('MF/6F3A', { fileSize: 9, fciHex: '621082024022' })] });
const res = await profilerRunRule({ path: 'MF/6F3A', fileType: 'transparent', fileSize: 4, fciMode: 'exact', fciHex: '621082024021' }, src);
assert.strictEqual(res.status, 'fail');
assert.ok(res.checks.some(c => c.label === 'fileSize' && c.ok === false));
assert.ok(res.checks.some(c => c.label === 'fci' && c.ok === false));
});
test('profilerRunRule against a snapshot reports uncaptured content as an error', async () => {
global.t = s => s;
const src = profilerSnapshotSource({ files: [snapFile('MF/6F3A', { content: null })] });
const res = await profilerRunRule({ path: 'MF/6F3A', fileType: 'transparent', fileSize: 4, content: { mode: 'exact', kind: 'transparent', expected: 'AABB' } }, src);
assert.strictEqual(res.status, 'error');
assert.strictEqual(res.error, 'Content not captured in snapshot');
assert.ok(res.checks.some(c => c.label === 'content' && c.ok === false));
delete global.t;
});