mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
fix: stabilize prerelease signing checks
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -146,12 +146,10 @@ const out = await page.evaluate(async () => {
|
||||
// SHOWS»): the marker indicates the entity its tap ACTS ON — the cover
|
||||
// exactly when the owner has explicitly chosen «Открыть/закрыть».
|
||||
const devEl = (dev) => {
|
||||
const v = c._viewOr(c._baseVb());
|
||||
const p = c._pos(dev);
|
||||
const left = ((p.x - v.x) / v.w) * 100;
|
||||
const top = ((p.y - v.y) / v.h) * 100;
|
||||
return [...sr().querySelectorAll('.devlayer .dev')].find((e) =>
|
||||
Math.abs(parseFloat(e.style.left) - left) < 0.4 && Math.abs(parseFloat(e.style.top) - top) < 0.4);
|
||||
// The viewport may refit asynchronously after an editor transition. Use
|
||||
// the stable public styling hook instead of racing rendered percentages
|
||||
// against the next viewBox.
|
||||
return dev ? sr().querySelector(`.devlayer .dev[data-id="${CSS.escape(dev.id)}"]`) : null;
|
||||
};
|
||||
const clsOf = (dev) => [...(devEl(dev)?.classList || [])];
|
||||
const iconOf = (dev) => devEl(dev)?.querySelector('ha-icon')?.getAttribute('icon') || '';
|
||||
|
||||
+23
-5
@@ -11,7 +11,10 @@ const res = await page.evaluate(async () => {
|
||||
const batchSizes = [];
|
||||
let round = 0;
|
||||
|
||||
c.hass = { ...c.hass, callWS: async (m) => {
|
||||
// This probe owns its signer authority. Reusing the demo card's connection
|
||||
// would intentionally share its cache and make the batch contract depend on
|
||||
// whichever smoke content the main card has already rendered.
|
||||
c.hass = { ...c.hass, connection: Object.create(c.hass.connection), callWS: async (m) => {
|
||||
if (m.type === 'houseplan/content/sign') {
|
||||
batchSizes.push(m.paths.length);
|
||||
const urls = {};
|
||||
@@ -25,7 +28,12 @@ const res = await page.evaluate(async () => {
|
||||
// 201 вложение, разложенное по маркерам: столько же подписанных ссылок
|
||||
const pdfs = [];
|
||||
for (let i = 0; i < 201; i++) pdfs.push({ name: 'm' + i, url: '/api/houseplan/content/files/m/doc' + i + '.pdf' });
|
||||
c._serverCfg = { ...c._serverCfg, markers: [{ id: 'mk1', pdfs }] };
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
markers: [{
|
||||
id: 'mk1', binding: 'virtual', name: 'Attachment probe', space: c._space, pdfs,
|
||||
}],
|
||||
};
|
||||
c._cfgEpoch++;
|
||||
|
||||
round = 1;
|
||||
@@ -44,7 +52,13 @@ const res = await page.evaluate(async () => {
|
||||
out.allRefreshed = vals.length === 201 && vals.every((u) => u.endsWith('authSig=R2'));
|
||||
|
||||
// ссылка, исчезнувшая из конфига, выбывает из кэша и не занимает слот
|
||||
c._serverCfg = { ...c._serverCfg, markers: [{ id: 'mk1', pdfs: pdfs.slice(0, 5) }] };
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
markers: [{
|
||||
id: 'mk1', binding: 'virtual', name: 'Attachment probe', space: c._space,
|
||||
pdfs: pdfs.slice(0, 5),
|
||||
}],
|
||||
};
|
||||
c._cfgEpoch++;
|
||||
batchSizes.length = 0;
|
||||
round = 3;
|
||||
@@ -55,11 +69,15 @@ const res = await page.evaluate(async () => {
|
||||
|
||||
// протухшая подпись не отдаётся: она вернула бы 401 и «попытку входа»
|
||||
const one = pdfs[0].url;
|
||||
c._signer.entries[one] = { url: one + '?authSig=OLD', at: Date.now() - 25 * 3600 * 1000 };
|
||||
c._signer.shared.signed[one] = {
|
||||
url: one + '?authSig=OLD', at: Date.now() - 25 * 3600 * 1000, loaded: true,
|
||||
};
|
||||
out.expiredNotServed = c._display(one) === '';
|
||||
out.expiredDropped = c._signer.entries[one] === undefined;
|
||||
// а стареющая, но ещё живая — отдаётся, пока едет замена
|
||||
c._signer.entries[one] = { url: one + '?authSig=AGING', at: Date.now() - 20 * 3600 * 1000 };
|
||||
c._signer.shared.signed[one] = {
|
||||
url: one + '?authSig=AGING', at: Date.now() - 20 * 3600 * 1000, loaded: true,
|
||||
};
|
||||
out.agingStillServed = c._display(one) === one + '?authSig=AGING';
|
||||
return out;
|
||||
});
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
// каждом рендере получал 401. Проверяем весь контракт подписи для этой карточки.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
|
||||
await page.route('**/api/houseplan/content/plans/_/f1.tok.svg?authSig=*', (route) => route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'image/svg+xml',
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><rect width="16" height="16" fill="#789"/></svg>',
|
||||
}));
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
@@ -16,7 +21,13 @@ const res = await page.evaluate(async () => {
|
||||
let signCalls = 0;
|
||||
let failFirst = true;
|
||||
const requestedHrefs = [];
|
||||
const hass = { ...main.hass, callWS: async (m) => {
|
||||
// A separately mounted test card gets its own HA connection authority. In
|
||||
// production cards sharing one connection deliberately share config/signing
|
||||
// caches, which is not the behaviour this isolated failure probe exercises.
|
||||
const hass = {
|
||||
...main.hass,
|
||||
connection: Object.create(main.hass.connection),
|
||||
callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
|
||||
if (m.type === 'houseplan/layout/get') return { layout: {} };
|
||||
if (m.type === 'houseplan/content/sign') {
|
||||
@@ -27,7 +38,8 @@ const res = await page.evaluate(async () => {
|
||||
return { urls };
|
||||
}
|
||||
return { ok: true };
|
||||
} };
|
||||
},
|
||||
};
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
@@ -35,6 +47,10 @@ const res = await page.evaluate(async () => {
|
||||
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
|
||||
card.hass = hass;
|
||||
host.appendChild(card);
|
||||
// connectedCallback may synchronously adopt the module-level warm snapshot;
|
||||
// force this isolated probe's server candidate after that warm paint.
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
await card._load(true);
|
||||
|
||||
const stage = async () => {
|
||||
const t0 = Date.now();
|
||||
@@ -71,11 +87,11 @@ const res = await page.evaluate(async () => {
|
||||
out.noExtraSignOnRerender = signCalls === before;
|
||||
|
||||
// 5) протухшая подпись не отдаётся, стареющая — отдаётся, пока едет замена
|
||||
const ent = card._signer.entries;
|
||||
ent[raw] = { url: raw + '?authSig=OLD', at: Date.now() - 25 * 3600 * 1000 };
|
||||
const ent = card._signer.shared.signed;
|
||||
ent[raw] = { url: raw + '?authSig=OLD', at: Date.now() - 25 * 3600 * 1000, loaded: true };
|
||||
card.requestUpdate(); await card.updateComplete;
|
||||
out.hrefWhenExpired = await href();
|
||||
ent[raw] = { url: raw + '?authSig=AGING', at: Date.now() - 20 * 3600 * 1000 };
|
||||
ent[raw] = { url: raw + '?authSig=AGING', at: Date.now() - 20 * 3600 * 1000, loaded: true };
|
||||
card.requestUpdate(); await card.updateComplete;
|
||||
out.hrefWhenAging = await href();
|
||||
|
||||
@@ -86,7 +102,9 @@ const res = await page.evaluate(async () => {
|
||||
});
|
||||
// зафиксировано прогоном на v1.45.1 и сверено с кодом
|
||||
checkAll(res, {
|
||||
hrefAfterFailedSign: null,
|
||||
// #73 keeps the last complete frame until the replacement is signed and
|
||||
// decoded, so a transient signing failure must not blank the background.
|
||||
hrefAfterFailedSign: '/assets/f1.svg',
|
||||
noRetryStorm: true,
|
||||
hrefAfterRetry: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=SIG2',
|
||||
retried: true,
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
+2
-2
File diff suppressed because one or more lines are too long
+20
-4
@@ -20,11 +20,17 @@ type SharedSignerRuntime = {
|
||||
inFlight: Map<string, number>;
|
||||
retry: Map<string, { notBefore: number; delay: number }>;
|
||||
listeners: Set<() => void>;
|
||||
/** Internal terminal-attempt listeners used by prepareImage(). */
|
||||
settlers: Set<() => void>;
|
||||
references: Map<object, Set<string>>;
|
||||
};
|
||||
|
||||
const sharedSignerRuntimes = new WeakMap<object, SharedSignerRuntime>();
|
||||
const SHARED_SIGNED_MAX = 96;
|
||||
// A single marker may legitimately carry more than MAX_SIGN_PATHS attachments:
|
||||
// signing is chunked at the transport boundary, while the shared cache must
|
||||
// retain the complete referenced set. Keep it bounded, but comfortably above
|
||||
// the 201-item regression contract (and ordinary multi-card plans).
|
||||
const SHARED_SIGNED_MAX = 512;
|
||||
|
||||
const signerAuthority = (hass: any, fallback: object): object => {
|
||||
const authority = hass?.connection || hass;
|
||||
@@ -37,7 +43,7 @@ const signerRuntime = (authority: object): SharedSignerRuntime => {
|
||||
if (!runtime) {
|
||||
runtime = {
|
||||
signed: {}, queued: new Set(), inFlight: new Map(), retry: new Map(),
|
||||
listeners: new Set(), references: new Map(),
|
||||
listeners: new Set(), settlers: new Set(), references: new Map(),
|
||||
};
|
||||
sharedSignerRuntimes.set(authority, runtime);
|
||||
}
|
||||
@@ -237,6 +243,11 @@ export class ContentSigner {
|
||||
.finally(() => {
|
||||
// release only our own attempt: a later one may have superseded it
|
||||
for (const p of claimed) if (shared.inFlight.get(p) === sentAt) shared.inFlight.delete(p);
|
||||
// `prepareImage()` also listens for terminal failures. Without this
|
||||
// notification it waited for the full 15 s lost-request timeout even
|
||||
// when the websocket had already rejected, delaying the card's
|
||||
// bounded retry and keeping the previous frame far too long.
|
||||
for (const settle of [...shared.settlers]) settle();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -326,6 +337,11 @@ export class ContentSigner {
|
||||
return this.preloadCurrentImage(shared, path, entry);
|
||||
}
|
||||
this.display(hass, path);
|
||||
const retry = shared.retry.get(path);
|
||||
if (retry && retry.notBefore > this.now()
|
||||
&& !shared.inFlight.has(path) && !shared.queued.has(path)) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const immediate = attempt();
|
||||
@@ -337,7 +353,7 @@ export class ContentSigner {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
shared.listeners.delete(check);
|
||||
shared.settlers.delete(check);
|
||||
resolve(ready);
|
||||
};
|
||||
const check = (): void => {
|
||||
@@ -347,7 +363,7 @@ export class ContentSigner {
|
||||
active.then(finish).catch(() => finish(false));
|
||||
};
|
||||
const timer = setTimeout(() => finish(false), SIGN_INFLIGHT_MS);
|
||||
shared.listeners.add(check);
|
||||
shared.settlers.add(check);
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user