test: prove three defensive contracts fail red

Issue: #421
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-09-02 20:13:58 +03:00
parent 03cc606396
commit f4b425f3c0
9 changed files with 269 additions and 23 deletions
+24 -11
View File
@@ -91,6 +91,23 @@ export function verifyDocsCandidate({
return { manifest, files };
}
/** Build the manifest written by the CLI without erasing an earlier review trace. */
export function acceptedDocsManifest({
manifest, previousAcceptance, decision, skipWitnesses = false, skipReason = '',
}) {
return {
...manifest,
acceptance: decision.replace.length
? {
declared: [...decision.replace],
witnesses: decision.witnesses.length,
floor: decision.floor,
...(skipWitnesses ? { witnessesSkippedBecause: skipReason } : {}),
}
: { ...(previousAcceptance || {}), lastWriteWasFingerprintOnly: true },
};
}
const list = (argv, name) => argv
.filter((arg) => arg.startsWith(`--${name}=`))
.map((arg) => arg.slice(name.length + 3))
@@ -145,17 +162,13 @@ function main(argv) {
// пустым списком, и история терялась при первом же refresh.
const previous = JSON.parse(readFileSync(resolve(ROOT, 'docs/images/screenshots.json'), 'utf8'))
.acceptance;
const accepted = {
...manifest,
acceptance: decision.replace.length
? {
declared: [...decision.replace],
witnesses: decision.witnesses.length,
floor: decision.floor,
...(skipWitnesses ? { witnessesSkippedBecause: skipReason } : {}),
}
: { ...(previous || {}), lastWriteWasFingerprintOnly: true },
};
const accepted = acceptedDocsManifest({
manifest,
previousAcceptance: previous,
decision,
skipWitnesses,
skipReason,
});
writeFileSync(
resolve(ROOT, 'docs/images/screenshots.json'),
`${JSON.stringify(accepted, null, 2)}\n`,
+69
View File
@@ -139,6 +139,75 @@ const MUTANT_DEFINITIONS = [
replace: '',
}],
},
{
id: 'support-preview-replacement-keeps-old-token',
guard: 'node scripts/backend-test-guard.mjs '
+ 'support_preview_replacement_and_discard_are_draft_local '
+ 'tests_backend/test_ha_websocket.py',
because: 'refreshing one support draft must invalidate its previous token while leaving '
+ 'another draft usable; discard success alone cannot prove either fact (#421 AC1)',
patches: [{
file: 'custom_components/houseplan/websocket_api.py',
find: ' for old_token, record in list(rt.support_previews.items()):\n'
+ ' if record.get("owner") == owner and record.get("draft_id") == msg["draft_id"]:\n'
+ ' rt.support_previews.pop(old_token, None)\n',
replace: '',
}],
},
{
id: 'support-preview-discard-keeps-token',
guard: 'node scripts/backend-test-guard.mjs '
+ 'support_preview_replacement_and_discard_are_draft_local '
+ 'tests_backend/test_ha_websocket.py',
because: 'an idempotent discard response is not proof of invalidation; a later submit must '
+ 'fail because the owned token was actually removed (#421 AC2)',
patches: [{
file: 'custom_components/houseplan/websocket_api.py',
find: ' rt.support_previews.pop(msg["token"], None)\n'
+ ' connection.send_result(msg["id"], {"ok": True})',
replace: ' connection.send_result(msg["id"], {"ok": True})',
}],
},
{
id: 'support-preview-submit-skips-ttl-prune',
guard: 'node scripts/backend-test-guard.mjs '
+ 'support_preview_token_expires_at_ttl_without_transport '
+ 'tests_backend/test_ha_websocket.py',
because: 'the exact TTL boundary must reject an expired attachment before relay transport; '
+ 'without submit-time pruning the stale bytes remain usable (#421 AC3)',
patches: [{
file: 'custom_components/houseplan/websocket_api.py',
find: ' _prune_support_previews(rt)\n'
+ ' token = msg.get("preview_token")',
replace: ' token = msg.get("preview_token")',
}],
},
{
id: 'report-page-errors-skips-round-trip',
guard: 'node demo/guard/verify-guard.mjs',
because: 'reportPageErrors() is a verdict path separate from finish(); removing only its '
+ 'delivery round-trip must leave the dedicated tail-error probe visibly red (#421 AC4/AC5)',
patches: [{
file: 'demo/serve.mjs',
find: 'export async function reportPageErrors() {\n'
+ ' await roundTripLivePages();\n'
+ ' if (!_pageErrors) return false;',
replace: 'export async function reportPageErrors() {\n'
+ ' if (!_pageErrors) return false;',
}],
},
{
id: 'docs-fingerprint-refresh-erases-acceptance-trace',
guard: 'node --test --test-name-pattern="fingerprint-only refresh" '
+ 'test/docs-accept.test.mjs',
because: 'a source-fingerprint refresh changes no pixels and must preserve the earlier '
+ 'human acceptance trace instead of manufacturing an empty one (#421 AC6/AC7)',
patches: [{
file: 'scripts/docs-accept.mjs',
find: ' : { ...(previousAcceptance || {}), lastWriteWasFingerprintOnly: true },',
replace: ' : { declared: [], witnesses: 0, floor: 0 },',
}],
},
{
id: 'i18n-dead-key-returns',
guard: 'node --test test/i18n-dead-keys.test.mjs',