fix: make card resource recovery observable

Issue: #462
User-Visible: yes
This commit is contained in:
Matysh
2026-09-05 16:04:54 +03:00
parent a4fe939f63
commit fcf0ce80eb
74 changed files with 4739 additions and 450 deletions
+2 -1
View File
@@ -443,5 +443,6 @@ test('#438 CLI действительно применяет потолок, а
// становится потолок, а не бюджет — но и бюджет обязан уметь падать.
const overBudget = runBudgetCli(INITIAL_VIEW_GZIP_BUDGET + 1);
assert.equal(overBudget.status, 1, overBudget.output);
assert.match(overBudget.output, /exceeds 300000 B budget/);
assert.match(overBudget.output,
new RegExp(`exceeds ${INITIAL_VIEW_GZIP_BUDGET} B budget`));
});
+24 -1
View File
@@ -136,6 +136,29 @@ test('golden matrix has stable unique ids and bounded comparison thresholds', ()
}
});
test('issue 462 has reviewed desktop, narrow-touch and attempted-kiosk notices', () => {
const scenarios = GOLDEN_SCENARIOS.filter((scenario) =>
scenario.id.startsWith('version-mismatch-'));
assert.deepEqual(scenarios.map((scenario) => scenario.id), [
'version-mismatch-desktop-dark-en',
'version-mismatch-touch-light-ru',
'version-mismatch-kiosk-attempted-dark-de',
]);
assert.deepEqual(new Set(scenarios.map((scenario) => scenario.language)),
new Set(['en', 'ru', 'de']));
assert.equal(scenarios.every((scenario) => scenario.mode === 'view'
&& scenario.capture === 'page'
&& scenario.integrationVersion === '0.0.0-golden-backend'), true);
const touch = scenarios.find((scenario) => scenario.touchViewport);
assert.deepEqual(touch?.viewport, { width: 390, height: 760 });
const kiosk = scenarios.find((scenario) => scenario.versionRecoveryAttempted);
assert.equal(kiosk?.kiosk, true);
assert.equal(kiosk?.theme, 'dark');
const harness = readFileSync(new URL('../demo/golden/harness.mjs', import.meta.url), 'utf8');
assert.match(harness, /sessionStorage\.setItem\(attemptKey, attemptedTarget\)/);
assert.match(harness, /integration_version: scenario\.integrationVersion/);
});
test('golden matrix covers required geometry, rendering and adaptive surfaces', () => {
const ids = GOLDEN_SCENARIOS.map((scenario) => scenario.id).join(' ');
for (const token of ['geometry', 'diagonal-45-opening', 'openings', 'openings-hidden',
@@ -375,7 +398,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
assert.ok(scenario);
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.equal(GOLDEN_MATRIX_VERSION, 54);
assert.equal(GOLDEN_MATRIX_VERSION, 55);
assert.equal(space.settings.sun_rays, true);
assert.equal(scenario.northDeg, 90,
'the sign-sensitive golden must keep a non-zero north direction');
+1
View File
@@ -470,6 +470,7 @@ test('i18n: German values equal to English are explicitly reviewed', () => {
'vac.diag_platform',
'vac.diag_status',
'vac.diag_position',
'version_mismatch.backend',
]);
const equalKeys = Object.keys(en).filter((key) => en[key] === de[key]);
assert.deepEqual(new Set(equalKeys), allowed);
+4 -1
View File
@@ -81,7 +81,10 @@ test('empty render keeps create/import affordances without spatial layers', () =
assert.match(wrapper, /if \(body === nothing\) return body;/);
assert.match(wrapper, /return this\._renderRoot\(body\);/);
const shell = methodBody('_renderRoot');
assert.match(shell, /return html`\$\{body\}\$\{this\._renderDangerConfirm\(\)\}`;/);
assert.match(shell,
/return html`\$\{body\}\$\{this\._renderVersionBanner\(\)\}\$\{this\._renderDangerConfirm\(\)\}`;/);
assert.equal(render.includes('_renderVersionBanner'), false,
'version recovery must remain available beside every non-empty body branch');
assert.equal(render.includes('_renderDangerConfirm'), false,
'подтверждение не должно возвращаться внутрь ветки — это и есть дефект #402');
});
@@ -0,0 +1,61 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const read = (path) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8');
const card = read('src/houseplan-card.ts');
const adapter = read('src/version-recovery-card.ts');
const editor = read('src/houseplan-editor-runtime.ts');
const onboarding = read('src/houseplan-onboarding-runtime.ts');
const controller = read('src/version-recovery.ts');
const styles = read('src/styles/base.styles.ts');
const occurrences = (source, fragment) => source.split(fragment).length - 1;
test('one host seam owns every full-card config/get request', () => {
const direct = "callWS({ type: 'houseplan/config/get'";
assert.equal(occurrences(adapter, direct), 1, 'the adapter is the only WS request owner');
assert.equal(occurrences(card, direct), 0, 'card flows must use the authoritative seam');
assert.equal(occurrences(editor, direct), 0, 'lazy editor flows must use the host seam');
assert.equal(occurrences(onboarding, direct), 0, 'onboarding flows must use the host seam');
assert.match(card, /private _getAuthoritativeConfig\(\): Promise<any>/);
assert.equal(occurrences(card, 'this._getAuthoritativeConfig()'), 2,
'initial load and config-only reload both use the seam');
assert.equal(occurrences(editor, 'this.host._getAuthoritativeConfig()'), 3,
'delete, optimization undo and backup import use the seam');
assert.equal(occurrences(onboarding, 'this.host._getAuthoritativeConfig()'), 1,
'empty-install space deletion uses the seam');
});
test('config fulfillment adopts capabilities before aggregate consumers continue', () => {
const responseAt = controller.indexOf('const response = await request();');
const adoptAt = controller.indexOf('adopt(response);', responseAt);
const returnAt = controller.indexOf('return response;', adoptAt);
assert.ok(responseAt >= 0 && responseAt < adoptAt && adoptAt < returnAt);
assert.match(adapter,
/return fetchAuthoritativeConfig\([\s\S]*adoptCardConfigCapabilities\(host, response\)/);
assert.match(adapter,
/host\._haIntegrationVersion = normalizeRuntimeVersion\(capabilities\.integration_version\)/);
assert.match(adapter, /host\._haSupportApi = [\s\S]*\? supportApi : null;/);
assert.match(adapter, /host\._haDecorAssetsApi = [\s\S]*: null;/);
});
test('banner insets are symmetric and cannot escape the full-card host', () => {
assert.match(styles,
/:host\(houseplan-card\) \{\s*display: block;\s*position: relative;\s*\}/);
const sharedHost = styles.slice(styles.indexOf(' :host {'),
styles.indexOf(' :host(houseplan-card)'));
assert.doesNotMatch(sharedHost, /\b(?:display|position):/,
'space-card shares variables, not the full-card positioning contract');
const rule = styles.slice(
styles.indexOf(' .version-recovery {'),
styles.indexOf(' .version-recovery.phase-visible'),
);
assert.match(rule, /left: max\(var\(--sp-5\), env\(safe-area-inset-left\)\);/);
assert.match(rule, /right: max\(var\(--sp-5\), env\(safe-area-inset-right\)\);/);
assert.match(rule, /width: auto;/);
assert.match(rule, /max-width: 430px;/);
assert.match(rule, /margin-left: auto;/);
assert.doesNotMatch(rule, /calc\(100%/);
});
+293
View File
@@ -0,0 +1,293 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
VERSION_RECOVERY_CHECK_MS,
VERSION_RELOAD_ATTEMPT_KEY,
VersionRecoveryController,
compareRuntimeVersions,
fetchAuthoritativeConfig,
isVersionReloadSafe,
normalizeRuntimeVersion,
} from '../test-build/version-recovery.js';
class FakeClock {
at = 10_000;
nextId = 1;
timers = new Map();
setTimeout = (callback, delay) => {
const id = this.nextId++;
this.timers.set(id, { at: this.at + delay, callback });
return id;
};
clearTimeout = (id) => this.timers.delete(id);
advance(ms) {
const end = this.at + ms;
while (true) {
const due = [...this.timers.entries()]
.filter(([, timer]) => timer.at <= end)
.sort((a, b) => a[1].at - b[1].at || a[0] - b[0])[0];
if (!due) break;
this.at = due[1].at;
this.timers.delete(due[0]);
due[1].callback();
}
this.at = end;
}
}
const allSafe = () => ({
connected: true,
initialFrameSettled: true,
viewOnly: true,
surfacesIdle: true,
configWritesIdle: true,
physicalWritesIdle: true,
layoutWritesIdle: true,
gesturesIdle: true,
interactionPauseElapsed: true,
baseZoom: true,
});
function memoryStorage(initial = null) {
const values = new Map();
if (initial !== null) values.set(VERSION_RELOAD_ATTEMPT_KEY, initial);
return {
values,
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, value),
};
}
function harness({ storage = memoryStorage(), safety = allSafe() } = {}) {
const clock = new FakeClock();
const events = [];
let snapshot = safety;
const controller = new VersionRecoveryController({
clock,
storage: () => storage,
safety: () => snapshot,
reload: () => events.push({ kind: 'reload', stored: storage?.values?.get(VERSION_RELOAD_ATTEMPT_KEY) }),
changed: () => events.push({ kind: 'changed' }),
});
return {
clock,
storage,
events,
controller,
setSafety: (value) => { snapshot = value; },
};
}
const update = (controller, overrides = {}) => controller.update({
frontendVersion: '1.72.0',
backendVersion: '1.73.0',
kiosk: false,
reducedMotion: true,
...overrides,
});
test('versions are trimmed, exact and symmetric; malformed values stay unknown', () => {
assert.equal(normalizeRuntimeVersion(' 1.72.0 '), '1.72.0');
for (const value of [undefined, null, 1, '', ' ']) {
assert.equal(normalizeRuntimeVersion(value), null);
}
assert.deepEqual(compareRuntimeVersions(' A ', 'A'), {
kind: 'equal', frontend: 'A', backend: 'A',
});
assert.deepEqual(compareRuntimeVersions('A', 'B'), {
kind: 'mismatch', frontend: 'A', backend: 'B',
});
assert.deepEqual(compareRuntimeVersions('B', 'A'), {
kind: 'mismatch', frontend: 'B', backend: 'A',
});
assert.deepEqual(compareRuntimeVersions('A', ' '), { kind: 'unknown' });
});
test('a fulfilled config is adopted before a sibling request or later preparation fails', async () => {
for (const response of [
{ integration_version: 'new-target' },
{},
]) {
let rejectSibling;
let adoptedVersion = 'stale-target';
const sibling = new Promise((_, reject) => { rejectSibling = reject; });
const config = fetchAuthoritativeConfig(
() => Promise.resolve(response),
(value) => { adoptedVersion = normalizeRuntimeVersion(value.integration_version); },
);
const aggregate = Promise.all([config, sibling]);
await config;
const expected = response.integration_version || null;
assert.equal(adoptedVersion, expected,
'the individual config fulfillment sets or clears the runtime version');
rejectSibling(new Error('layout or asset preparation failed'));
await assert.rejects(aggregate, /layout or asset preparation failed/);
assert.equal(adoptedVersion, expected,
'an aggregate failure cannot restore a stale runtime version');
}
});
test('every named kiosk safety category independently blocks reload', () => {
const safe = allSafe();
assert.equal(isVersionReloadSafe(safe), true);
for (const key of Object.keys(safe)) {
assert.equal(isVersionReloadSafe({ ...safe, [key]: false }), false, key);
}
});
test('ordinary mode always shows the manual notice and never reloads itself', () => {
const h = harness();
update(h.controller);
h.controller.connect();
assert.equal(h.controller.relation.kind, 'mismatch');
assert.equal(h.controller.banner?.phase, 'visible');
assert.equal(h.controller.hasCurrentMismatchNotice, true);
assert.equal(h.clock.timers.size, 0);
h.clock.advance(VERSION_RECOVERY_CHECK_MS * 100);
assert.equal(h.events.some((event) => event.kind === 'reload'), false);
assert.equal(h.storage.values.size, 0, 'normal mode does not consume the kiosk attempt');
});
test('kiosk waits for a safe frame, marks the exact target before one reload, then falls back to banner', () => {
const h = harness({ safety: { ...allSafe(), surfacesIdle: false } });
update(h.controller, { kiosk: true });
h.controller.connect();
assert.equal(h.controller.banner, null, 'fresh kiosk mismatch stays quiet');
assert.equal(h.clock.timers.size, 1);
h.clock.advance(VERSION_RECOVERY_CHECK_MS * 3);
assert.equal(h.events.some((event) => event.kind === 'reload'), false);
assert.equal(h.clock.timers.size, 1, 'polling owns at most one timer');
h.setSafety(allSafe());
h.clock.advance(VERSION_RECOVERY_CHECK_MS);
const reloads = h.events.filter((event) => event.kind === 'reload');
assert.deepEqual(reloads, [{ kind: 'reload', stored: '1.73.0' }]);
assert.equal(h.storage.values.get(VERSION_RELOAD_ATTEMPT_KEY), '1.73.0');
assert.equal(h.controller.banner?.phase, 'visible');
assert.equal(h.clock.timers.size, 0);
h.clock.advance(VERSION_RECOVERY_CHECK_MS * 10);
assert.equal(h.events.filter((event) => event.kind === 'reload').length, 1);
});
test('the same target is once per tab across cards and alternating frontend versions', () => {
const storage = memoryStorage();
const first = harness({ storage });
update(first.controller, { kiosk: true, frontendVersion: 'old-A', backendVersion: 'target-B' });
first.controller.connect();
first.clock.advance(VERSION_RECOVERY_CHECK_MS);
assert.equal(first.events.filter((event) => event.kind === 'reload').length, 1);
const second = harness({ storage });
update(second.controller, { kiosk: true, frontendVersion: 'old-C', backendVersion: 'target-B' });
second.controller.connect();
assert.equal(second.controller.banner?.backend, 'target-B');
assert.equal(second.clock.timers.size, 0);
second.clock.advance(VERSION_RECOVERY_CHECK_MS * 5);
assert.equal(second.events.some((event) => event.kind === 'reload'), false);
update(second.controller, { kiosk: true, frontendVersion: 'old-C', backendVersion: 'target-D' });
assert.equal(second.controller.banner, null, 'a new backend target gets a fresh quiet attempt');
second.clock.advance(VERSION_RECOVERY_CHECK_MS);
assert.equal(second.events.filter((event) => event.kind === 'reload').length, 1);
assert.equal(storage.values.get(VERSION_RELOAD_ATTEMPT_KEY), 'target-D');
});
test('a second card losing the claim observes the stored target and does not reload', () => {
const storage = memoryStorage();
const h = harness({ storage });
update(h.controller, { kiosk: true });
h.controller.connect();
// Another controller/tab card wins between the initial read and safe tick.
storage.setItem(VERSION_RELOAD_ATTEMPT_KEY, '1.73.0');
h.clock.advance(VERSION_RECOVERY_CHECK_MS);
assert.equal(h.events.some((event) => event.kind === 'reload'), false);
assert.equal(h.controller.banner?.phase, 'visible');
assert.equal(h.clock.timers.size, 0);
});
test('session storage provider/get/set failures are manual-only and never loop', () => {
const cases = [
() => { throw new Error('getter denied'); },
() => ({ getItem: () => { throw new Error('read denied'); }, setItem: () => undefined }),
() => ({ getItem: () => null, setItem: () => { throw new Error('write denied'); } }),
];
for (const provider of cases) {
const clock = new FakeClock();
let reloads = 0;
const controller = new VersionRecoveryController({
clock,
storage: provider,
safety: allSafe,
reload: () => reloads++,
changed: () => undefined,
});
update(controller, { kiosk: true });
controller.connect();
if (!controller.banner) clock.advance(VERSION_RECOVERY_CHECK_MS);
assert.equal(reloads, 0);
assert.equal(controller.banner?.phase, 'visible');
assert.equal(clock.timers.size, 0);
}
});
test('a synchronous navigation failure keeps the claimed target manual-only', () => {
const clock = new FakeClock();
const storage = memoryStorage();
const controller = new VersionRecoveryController({
clock,
storage: () => storage,
safety: allSafe,
reload: () => { throw new Error('navigation denied'); },
changed: () => undefined,
});
update(controller, { kiosk: true });
controller.connect();
assert.doesNotThrow(() => clock.advance(VERSION_RECOVERY_CHECK_MS));
assert.equal(storage.values.get(VERSION_RELOAD_ATTEMPT_KEY), '1.73.0');
assert.equal(controller.banner?.phase, 'visible');
assert.equal(clock.timers.size, 0);
});
test('equal/unknown and disconnect cancel timers; reconnect re-reads shared session state', () => {
const h = harness();
update(h.controller, { kiosk: true });
h.controller.connect();
assert.equal(h.clock.timers.size, 1);
update(h.controller, { kiosk: true, backendVersion: '1.72.0' });
assert.equal(h.controller.relation.kind, 'equal');
assert.equal(h.clock.timers.size, 0);
update(h.controller, { kiosk: true, backendVersion: ' ' });
assert.equal(h.controller.relation.kind, 'unknown');
assert.equal(h.clock.timers.size, 0);
update(h.controller, { kiosk: true });
assert.equal(h.clock.timers.size, 1);
h.controller.disconnect();
assert.equal(h.clock.timers.size, 0);
h.storage.setItem(VERSION_RELOAD_ATTEMPT_KEY, '1.73.0');
h.controller.connect();
assert.equal(h.controller.banner?.phase, 'visible');
assert.equal(h.clock.timers.size, 0);
});
test('banner exit is token-safe and reduced motion removes it immediately', () => {
const h = harness();
update(h.controller, { reducedMotion: false });
h.controller.connect();
const shown = h.controller.banner;
assert.equal(shown?.phase, 'visible');
update(h.controller, { backendVersion: '1.72.0', reducedMotion: false });
const leaving = h.controller.banner;
assert.equal(leaving?.phase, 'leaving');
h.controller.finishBannerExit(shown.token);
assert.equal(h.controller.banner?.phase, 'leaving', 'stale animation cannot remove a newer phase');
h.controller.finishBannerExit(leaving.token);
assert.equal(h.controller.banner, null);
update(h.controller, { backendVersion: '1.73.0', reducedMotion: true });
assert.equal(h.controller.banner?.phase, 'visible');
update(h.controller, { backendVersion: '1.72.0', reducedMotion: true });
assert.equal(h.controller.banner, null);
});