mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
test: prove three defensive contracts fail red
Issue: #421 User-Visible: no
This commit is contained in:
@@ -234,8 +234,13 @@ def _send_support_error(connection, msg_id: int, code: str) -> None:
|
||||
connection.send_error(msg_id, code, code)
|
||||
|
||||
|
||||
def _support_monotonic() -> float:
|
||||
"""Clock seam for deterministic support-preview lifecycle tests."""
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
def _prune_support_previews(rt: HouseplanData, now: float | None = None) -> None:
|
||||
current = time.monotonic() if now is None else now
|
||||
current = _support_monotonic() if now is None else now
|
||||
for token, preview in list(rt.support_previews.items()):
|
||||
if float(preview.get("expires", 0)) <= current:
|
||||
rt.support_previews.pop(token, None)
|
||||
@@ -2183,7 +2188,7 @@ async def ws_support_preview(
|
||||
_send_support_error(connection, msg["id"], code)
|
||||
return
|
||||
|
||||
now = time.monotonic()
|
||||
now = _support_monotonic()
|
||||
owner = _connection_user_id(connection)
|
||||
_prune_support_previews(rt, now)
|
||||
# A refresh replaces only this card instance's draft. Other cards keep
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// Probe #421: reportPageErrors() owns a separate delivery round-trip from finish().
|
||||
//
|
||||
// The page error is scheduled at the tail of the browser turn and there is no
|
||||
// page interaction afterwards. The verdict must therefore flush the page by
|
||||
// itself. This probe deliberately never calls finish(): doing so would prove
|
||||
// the older branch while leaving reportPageErrors() blind.
|
||||
import { launch, reportPageErrors } from '../serve.mjs';
|
||||
|
||||
const { browser, page } = await launch();
|
||||
await page.evaluate(() => {
|
||||
setTimeout(() => { throw new Error('guard-report-page-errors'); }, 0);
|
||||
});
|
||||
await reportPageErrors();
|
||||
await browser.close();
|
||||
@@ -38,6 +38,12 @@ const PROBES = [
|
||||
expectOutput: /OK/,
|
||||
because: 'round-trip к закрытой странице не имеет права ронять вердикт',
|
||||
},
|
||||
{
|
||||
file: 'guard_report_page_errors.mjs',
|
||||
expectExit: 1,
|
||||
expectOutput: /uncaught exception\(s\) inside the card/,
|
||||
because: 'reportPageErrors() обязан сам дождаться хвостового pageerror, не полагаясь на finish()',
|
||||
},
|
||||
];
|
||||
|
||||
let failed = 0;
|
||||
|
||||
+24
-11
@@ -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`,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -3,7 +3,7 @@ import test from 'node:test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { verifyDocsCandidate } from '../scripts/docs-accept.mjs';
|
||||
import { acceptedDocsManifest, verifyDocsCandidate } from '../scripts/docs-accept.mjs';
|
||||
import { DOC_SCREENSHOT_VERSION, DOC_SCREENSHOTS } from '../demo/docs/screenshots.mjs';
|
||||
|
||||
// Приёмка — единственное место, где картинки попадают в репозиторий, поэтому
|
||||
@@ -146,3 +146,49 @@ test('правило среды в шапках совпадает с реали
|
||||
assert.equal(/снимать только в CI|только из артефакта CI/.test(workflow), false,
|
||||
'старое правило про место съёмки осталось в тексте');
|
||||
});
|
||||
|
||||
test('#421 acceptance trace records a new pixel review exactly', () => {
|
||||
const manifest = candidate();
|
||||
const decision = {
|
||||
replace: ['view-desktop'], witnesses: ['view-touch', 'editor'], floor: 2,
|
||||
};
|
||||
const accepted = acceptedDocsManifest({
|
||||
manifest,
|
||||
previousAcceptance: { declared: ['old'], future: 'preserve only on refresh' },
|
||||
decision,
|
||||
skipWitnesses: true,
|
||||
skipReason: 'reviewed renderer transition',
|
||||
});
|
||||
assert.deepEqual(accepted.acceptance, {
|
||||
declared: ['view-desktop'],
|
||||
witnesses: 2,
|
||||
floor: 2,
|
||||
witnessesSkippedBecause: 'reviewed renderer transition',
|
||||
});
|
||||
assert.equal('acceptance' in manifest, false, 'candidate manifest is not mutated');
|
||||
assert.notEqual(accepted, manifest);
|
||||
});
|
||||
|
||||
test('#421 fingerprint-only refresh preserves the complete previous acceptance trace', () => {
|
||||
const manifest = candidate();
|
||||
const previousAcceptance = {
|
||||
declared: ['view-desktop'], witnesses: 7, floor: 1, future: { kept: true },
|
||||
};
|
||||
const previousSnapshot = structuredClone(previousAcceptance);
|
||||
const accepted = acceptedDocsManifest({
|
||||
manifest,
|
||||
previousAcceptance,
|
||||
decision: { replace: [], witnesses: ['unchanged'], floor: 1 },
|
||||
});
|
||||
assert.deepEqual(accepted.acceptance, {
|
||||
...previousSnapshot, lastWriteWasFingerprintOnly: true,
|
||||
});
|
||||
assert.deepEqual(previousAcceptance, previousSnapshot, 'previous trace is not mutated');
|
||||
assert.equal('acceptance' in manifest, false, 'candidate manifest is not mutated');
|
||||
|
||||
const firstRefresh = acceptedDocsManifest({
|
||||
manifest, previousAcceptance: undefined,
|
||||
decision: { replace: [], witnesses: [], floor: 0 },
|
||||
});
|
||||
assert.deepEqual(firstRefresh.acceptance, { lastWriteWasFingerprintOnly: true });
|
||||
});
|
||||
|
||||
@@ -39,7 +39,12 @@ test('пробы гарда лежат вне маски смоков и вне
|
||||
const guard = readdirSync(new URL('demo/guard/', `file://${ROOT}`));
|
||||
assert.ok(guard.includes('verify-guard.mjs'));
|
||||
const probes = guard.filter((name) => name.startsWith('guard_'));
|
||||
assert.equal(probes.length, 3, 'три пробы: хвостовое исключение, отклонение, закрытая страница');
|
||||
assert.deepEqual(probes.sort(), [
|
||||
'guard_closed_page.mjs',
|
||||
'guard_report_page_errors.mjs',
|
||||
'guard_tail_exception.mjs',
|
||||
'guard_tail_rejection.mjs',
|
||||
], 'четыре адресные пробы гарда обязаны оставаться обнаружимыми');
|
||||
|
||||
// Маска demo/smoke_*.mjs — то, что гоняют шарды CI. Проба, попавшая туда,
|
||||
// покрасит шард по построению: она обязана падать.
|
||||
@@ -65,12 +70,16 @@ test('пробы вызываются в job с браузером и служа
|
||||
assert.match(smoke, /if: matrix\.shard == 1/, 'один раз, а не в каждом шарде');
|
||||
|
||||
const mutants = read('scripts/mutation-gate.mjs');
|
||||
for (const id of ['smoke-guard-blind-to-tail', 'smoke-guard-forgets-to-register-pages']) {
|
||||
for (const id of [
|
||||
'smoke-guard-blind-to-tail',
|
||||
'smoke-guard-forgets-to-register-pages',
|
||||
'report-page-errors-skips-round-trip',
|
||||
]) {
|
||||
assert.match(mutants, new RegExp(`id: '${id}'`), `мутант ${id} не зарегистрирован`);
|
||||
}
|
||||
// Правка состоит из двух половин, и мутант на одну оставил бы другую
|
||||
// недоказанной.
|
||||
assert.equal((mutants.match(/node demo\/guard\/verify-guard\.mjs/g) || []).length, 2);
|
||||
assert.equal((mutants.match(/node demo\/guard\/verify-guard\.mjs/g) || []).length, 3);
|
||||
});
|
||||
|
||||
test('страницы, созданные вне launch(), подписаны общим гардом (#404, Medium-1)', () => {
|
||||
|
||||
@@ -71,3 +71,13 @@ test('serve.mjs остаётся единственным владельцем
|
||||
const entryStale = read('smoke_entry_stale.mjs');
|
||||
assert.match(entryStale, /await finish\(/, 'smoke_entry_stale обязан выносить вердикт');
|
||||
});
|
||||
|
||||
test('#421 dedicated guard probe reaches reportPageErrors without finish', () => {
|
||||
const probe = readFileSync(
|
||||
new URL('../demo/guard/guard_report_page_errors.mjs', import.meta.url), 'utf8',
|
||||
);
|
||||
assert.match(probe, /await reportPageErrors\(\)/,
|
||||
'отрицательная проба обязана пройти через отдельный verdict path');
|
||||
assert.doesNotMatch(probe, /\bawait\s+finish\s*\(/,
|
||||
'finish() замаскирует сломанный round-trip внутри reportPageErrors()');
|
||||
});
|
||||
|
||||
@@ -16,7 +16,12 @@ from homeassistant.core import HomeAssistant
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
from pytest_homeassistant_custom_component.typing import WebSocketGenerator
|
||||
|
||||
from custom_components.houseplan.const import CONF_ADMIN_ONLY, DOMAIN, VERSION
|
||||
from custom_components.houseplan.const import (
|
||||
CONF_ADMIN_ONLY,
|
||||
DOMAIN,
|
||||
SUPPORT_PREVIEW_TTL_S,
|
||||
VERSION,
|
||||
)
|
||||
from custom_components.houseplan.websocket_api import (
|
||||
_space_delete_candidate, _space_marker_dependencies,
|
||||
)
|
||||
@@ -1278,6 +1283,16 @@ def _support_preview_request(draft_id: str = "draft-browser-one") -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def _support_submit_request(client, token: str, idempotency_key: str) -> dict:
|
||||
await client.send_json_auto_id({
|
||||
"type": "houseplan/support/submit",
|
||||
"message": "Support preview lifecycle proof",
|
||||
"preview_token": token,
|
||||
"idempotency_key": idempotency_key,
|
||||
})
|
||||
return await client.receive_json()
|
||||
|
||||
|
||||
async def test_support_preview_is_authorized_exact_and_consumed_only_after_success(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
@@ -1327,8 +1342,18 @@ async def test_support_preview_is_authorized_exact_and_consumed_only_after_succe
|
||||
|
||||
async def test_support_preview_replacement_and_discard_are_draft_local(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
await _setup(hass)
|
||||
submitted: list[dict] = []
|
||||
|
||||
async def _submit(_hass, **kwargs):
|
||||
submitted.append(kwargs)
|
||||
return "hpr-preview-lifecycle"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"custom_components.houseplan.websocket_api.async_submit_report", _submit,
|
||||
)
|
||||
client = await hass_ws_client(hass)
|
||||
await client.send_json_auto_id(_support_preview_request("draft-same-card"))
|
||||
first = (await client.receive_json())["result"]
|
||||
@@ -1338,7 +1363,18 @@ async def test_support_preview_replacement_and_discard_are_draft_local(
|
||||
replacement = (await client.receive_json())["result"]
|
||||
assert len({first["token"], other["token"], replacement["token"]}) == 3
|
||||
|
||||
# Replaced/discarded tokens cannot be submitted; another card's token stays.
|
||||
# Replacement invalidates only the older token from the same card draft.
|
||||
replaced = await _support_submit_request(client, first["token"], "replaced-token-proof")
|
||||
assert not replaced["success"]
|
||||
assert replaced["error"]["code"] == "support_preview_expired"
|
||||
|
||||
other_sent = await _support_submit_request(client, other["token"], "other-draft-proof")
|
||||
assert other_sent["success"]
|
||||
assert other_sent["result"]["report_id"] == "hpr-preview-lifecycle"
|
||||
assert len(submitted) == 1
|
||||
assert submitted[0]["attachment"] == other["text"].encode("utf-8")
|
||||
|
||||
# Discard stays idempotent, but its effect is proved by a subsequent submit.
|
||||
await client.send_json_auto_id({
|
||||
"type": "houseplan/support/preview/discard", "token": first["token"],
|
||||
})
|
||||
@@ -1347,10 +1383,48 @@ async def test_support_preview_replacement_and_discard_are_draft_local(
|
||||
"type": "houseplan/support/preview/discard", "token": replacement["token"],
|
||||
})
|
||||
assert (await client.receive_json())["success"]
|
||||
await client.send_json_auto_id({
|
||||
"type": "houseplan/support/preview/discard", "token": other["token"],
|
||||
})
|
||||
assert (await client.receive_json())["success"]
|
||||
discarded = await _support_submit_request(client, replacement["token"], "discarded-token-proof")
|
||||
assert not discarded["success"]
|
||||
assert discarded["error"]["code"] == "support_preview_expired"
|
||||
assert len(submitted) == 1
|
||||
|
||||
|
||||
async def test_support_preview_token_expires_at_ttl_without_transport(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
await _setup(hass)
|
||||
clock = {"now": 1_000.0}
|
||||
monkeypatch.setattr(
|
||||
"custom_components.houseplan.websocket_api._support_monotonic",
|
||||
lambda: clock["now"],
|
||||
)
|
||||
submitted: list[dict] = []
|
||||
|
||||
async def _submit(_hass, **kwargs):
|
||||
submitted.append(kwargs)
|
||||
return "hpr-preview-ttl"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"custom_components.houseplan.websocket_api.async_submit_report", _submit,
|
||||
)
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
await client.send_json_auto_id(_support_preview_request("draft-before-ttl"))
|
||||
fresh = (await client.receive_json())["result"]
|
||||
clock["now"] += SUPPORT_PREVIEW_TTL_S - 1
|
||||
before_ttl = await _support_submit_request(client, fresh["token"], "before-ttl-proof")
|
||||
assert before_ttl["success"]
|
||||
assert submitted[0]["attachment"] == fresh["text"].encode("utf-8")
|
||||
|
||||
await client.send_json_auto_id(_support_preview_request("draft-at-ttl"))
|
||||
expiring = (await client.receive_json())["result"]
|
||||
clock["now"] += SUPPORT_PREVIEW_TTL_S
|
||||
at_ttl = await _support_submit_request(client, expiring["token"], "at-ttl-proof")
|
||||
assert not at_ttl["success"]
|
||||
assert at_ttl["error"]["code"] == "support_preview_expired"
|
||||
assert len(submitted) == 1
|
||||
|
||||
|
||||
async def test_support_text_only_submit_carries_safe_versions_without_plan_data(
|
||||
|
||||
Reference in New Issue
Block a user