mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 04:09:17 +00:00
@@ -2,6 +2,7 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { assertBundleManifest } from './bundle-tree.mjs';
|
||||
|
||||
// #352: the budget guards the CLASS of regression — tens of kilobytes from
|
||||
// an accidentally imported dependency or an eagerly bundled dictionary —
|
||||
@@ -40,6 +41,7 @@ import { pathToFileURL } from 'node:url';
|
||||
// отдельный adapter. Новый бюджет — целая часть прежней верхней границы 10%
|
||||
// над фактом #367 и оставляет 976 Б запаса; следующий рост обязан решать #367.
|
||||
export const INITIAL_VIEW_GZIP_BUDGET = 301_066;
|
||||
export const INITIAL_PANEL_ONLY_GZIP_BUDGET = 8 * 1024;
|
||||
|
||||
/**
|
||||
* Порог, ниже которого запас перестаёт быть запасом (#367).
|
||||
@@ -273,10 +275,12 @@ export function lowHeadroomWarning(headroom, {
|
||||
+ ' смотрите ленивые графы (#367).' + stale;
|
||||
}
|
||||
|
||||
export function assertBundleBudget(manifest, budget = INITIAL_VIEW_GZIP_BUDGET) {
|
||||
if (manifest?.schema !== 1 || !Array.isArray(manifest.files)) {
|
||||
throw new Error('invalid houseplan-assets.json');
|
||||
}
|
||||
export function assertBundleBudget(
|
||||
manifest,
|
||||
budget = INITIAL_VIEW_GZIP_BUDGET,
|
||||
panelOnlyBudget = INITIAL_PANEL_ONLY_GZIP_BUDGET,
|
||||
) {
|
||||
assertBundleManifest(manifest);
|
||||
if (!manifest.lazyEditorFiles?.length) {
|
||||
throw new Error('bundle has no lazy editor graph');
|
||||
}
|
||||
@@ -318,8 +322,16 @@ export function assertBundleBudget(manifest, budget = INITIAL_VIEW_GZIP_BUDGET)
|
||||
`initial View graph ${manifest.initialViewGzipBytes} B gzip exceeds ${budget} B budget`,
|
||||
);
|
||||
}
|
||||
if (manifest.initialPanelOnlyGzipBytes > panelOnlyBudget) {
|
||||
throw new Error(
|
||||
`initial panel-only graph ${manifest.initialPanelOnlyGzipBytes} B gzip exceeds`
|
||||
+ ` ${panelOnlyBudget} B budget`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
initialViewGzipBytes: manifest.initialViewGzipBytes,
|
||||
initialPanelGzipBytes: manifest.initialPanelGzipBytes,
|
||||
initialPanelOnlyGzipBytes: manifest.initialPanelOnlyGzipBytes,
|
||||
lazyEditorGzipBytes: manifest.lazyEditorGzipBytes,
|
||||
lazyLocaleGzipBytes: manifest.lazyLocaleGzipBytes,
|
||||
lazyIsometricGzipBytes: manifest.lazyIsometricGzipBytes,
|
||||
@@ -340,6 +352,10 @@ if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
|
||||
`initial View: ${result.initialViewGzipBytes} B gzip`
|
||||
+ ` (потолок ${INITIAL_VIEW_GZIP_CEILING} B ±${INITIAL_VIEW_CEILING_BAND},`
|
||||
+ ` budget ${INITIAL_VIEW_GZIP_BUDGET} B, headroom ${headroom} B)`,
|
||||
`initial panel: ${result.initialPanelGzipBytes} B gzip`,
|
||||
`initial panel-only: ${result.initialPanelOnlyGzipBytes} B gzip`
|
||||
+ ` (budget ${INITIAL_PANEL_ONLY_GZIP_BUDGET} B,`
|
||||
+ ` headroom ${INITIAL_PANEL_ONLY_GZIP_BUDGET - result.initialPanelOnlyGzipBytes} B)`,
|
||||
`lazy editor: ${result.lazyEditorGzipBytes} B gzip`,
|
||||
`lazy locale: ${result.lazyLocaleGzipBytes} B gzip`,
|
||||
`lazy isometric: ${result.lazyIsometricGzipBytes} B gzip`,
|
||||
@@ -356,6 +372,9 @@ if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
|
||||
`| граф | gzip | бюджет | запас |`,
|
||||
`|---|---|---|---|`,
|
||||
`| initial View | ${result.initialViewGzipBytes} B | ${INITIAL_VIEW_GZIP_BUDGET} B | ${headroom} B |`,
|
||||
`| initial panel-only | ${result.initialPanelOnlyGzipBytes} B |`
|
||||
+ ` ${INITIAL_PANEL_ONLY_GZIP_BUDGET} B |`
|
||||
+ ` ${INITIAL_PANEL_ONLY_GZIP_BUDGET - result.initialPanelOnlyGzipBytes} B |`,
|
||||
`| потолок (#438) | ${INITIAL_VIEW_GZIP_CEILING} B | полоса ${INITIAL_VIEW_CEILING_BAND} B |`
|
||||
+ ` ${INITIAL_VIEW_GZIP_CEILING - result.initialViewGzipBytes} B до потолка |`,
|
||||
...(warning ? ['', `> ${warning}`] : []),
|
||||
|
||||
+128
-36
@@ -12,8 +12,51 @@ const FR_RETRY_ASSET_TOKEN = '__HOUSEPLAN_FR_RETRY_ASSET__';
|
||||
const FURNITURE_ART_RETRY_ASSET_TOKEN = '__HOUSEPLAN_FURNITURE_ART_RETRY_ASSET__';
|
||||
const PDF_RETRY_ASSET_TOKEN = '__HOUSEPLAN_PDF_RETRY_ASSET__';
|
||||
|
||||
export const CARD_ENTRY_FILE = 'houseplan-card.js';
|
||||
export const PANEL_ENTRY_FILE = 'houseplan-panel.js';
|
||||
|
||||
const ENTRY_CONTRACTS = [
|
||||
{
|
||||
fileName: CARD_ENTRY_FILE,
|
||||
facade: 'src/houseplan-card.ts',
|
||||
element: 'houseplan-card',
|
||||
cardFallback: true,
|
||||
},
|
||||
{
|
||||
fileName: PANEL_ENTRY_FILE,
|
||||
facade: 'src/houseplan-panel.ts',
|
||||
element: 'houseplan-panel',
|
||||
cardFallback: false,
|
||||
},
|
||||
];
|
||||
|
||||
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
|
||||
|
||||
const normalizedId = (value) => String(value || '').replaceAll('\\', '/');
|
||||
|
||||
const hasExactFacade = (chunk, facade) => {
|
||||
const id = normalizedId(chunk.facadeModuleId);
|
||||
return id === facade || id.endsWith(`/${facade}`);
|
||||
};
|
||||
|
||||
const exactEntryChunk = (bundle, contract) => {
|
||||
const matches = Object.values(bundle).filter((item) => item.type === 'chunk'
|
||||
&& item.isEntry
|
||||
&& normalizedId(item.fileName) === contract.fileName);
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(
|
||||
`${contract.fileName} entry count is ${matches.length}, expected exactly 1`,
|
||||
);
|
||||
}
|
||||
if (!hasExactFacade(matches[0], contract.facade)) {
|
||||
throw new Error(
|
||||
`${contract.fileName} facade is ${normalizedId(matches[0].facadeModuleId) || 'missing'},`
|
||||
+ ` expected ${contract.facade}`,
|
||||
);
|
||||
}
|
||||
return matches[0];
|
||||
};
|
||||
|
||||
const reachable = (entry, byPath, edge) => {
|
||||
const seen = new Set();
|
||||
const visit = (path) => {
|
||||
@@ -28,8 +71,14 @@ const reachable = (entry, byPath, edge) => {
|
||||
};
|
||||
|
||||
export function buildBundleManifest(bundle, fingerprint) {
|
||||
const files = Object.values(bundle)
|
||||
.filter((item) => item.type === 'chunk')
|
||||
const chunks = Object.values(bundle).filter((item) => item.type === 'chunk');
|
||||
const entries = chunks.filter((item) => item.isEntry);
|
||||
if (entries.length !== ENTRY_CONTRACTS.length) {
|
||||
throw new Error(`bundle entry count is ${entries.length}, expected ${ENTRY_CONTRACTS.length}`);
|
||||
}
|
||||
const cardEntry = exactEntryChunk(bundle, ENTRY_CONTRACTS[0]);
|
||||
const panelEntry = exactEntryChunk(bundle, ENTRY_CONTRACTS[1]);
|
||||
const files = chunks
|
||||
.map((chunk) => {
|
||||
const contents = Buffer.from(chunk.code, 'utf8');
|
||||
const modules = Object.keys(chunk.modules || {}).map((id) => id.replaceAll('\\', '/'));
|
||||
@@ -60,10 +109,14 @@ export function buildBundleManifest(bundle, fingerprint) {
|
||||
};
|
||||
})
|
||||
.sort((left, right) => left.path.localeCompare(right.path));
|
||||
const entry = files.find((file) => file.isEntry)?.path;
|
||||
if (!entry) throw new Error('bundle manifest has no entry chunk');
|
||||
const entry = normalizedId(cardEntry.fileName);
|
||||
const panelEntryPath = normalizedId(panelEntry.fileName);
|
||||
const byPath = new Map(files.map((file) => [file.path, file]));
|
||||
const initial = reachable(entry, byPath, 'imports');
|
||||
const initialPanel = reachable(panelEntryPath, byPath, 'imports');
|
||||
const initialPanelOnly = new Set(
|
||||
[...initialPanel].filter((path) => !initial.has(path)),
|
||||
);
|
||||
const dynamicRoots = [...initial]
|
||||
.flatMap((path) => byPath.get(path)?.dynamicImports || []);
|
||||
const lazy = new Set();
|
||||
@@ -101,8 +154,13 @@ export function buildBundleManifest(bundle, fingerprint) {
|
||||
schema: 1,
|
||||
fingerprint,
|
||||
entry,
|
||||
panelEntry: panelEntryPath,
|
||||
initialViewFiles: [...initial].sort(),
|
||||
initialViewGzipBytes: sum(initial),
|
||||
initialPanelFiles: [...initialPanel].sort(),
|
||||
initialPanelGzipBytes: sum(initialPanel),
|
||||
initialPanelOnlyFiles: [...initialPanelOnly].sort(),
|
||||
initialPanelOnlyGzipBytes: sum(initialPanelOnly),
|
||||
lazyFiles: [...lazy].sort(),
|
||||
lazyGzipBytes: sum(lazy),
|
||||
lazyEditorFiles: [...lazyEditor].sort(),
|
||||
@@ -243,11 +301,13 @@ export function editorRuntimeRetryUrlPlugin() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite the entry facade so a stale cached `houseplan-card.js` fails loudly
|
||||
* instead of killing the card silently (#353 К3). Rollup emits the facade as a
|
||||
* STATIC re-export of the content-hashed main chunk; after an update a proxy-
|
||||
* cached facade points at a chunk the manifest-gated server no longer serves,
|
||||
* and a static import failure aborts the whole module before any code runs.
|
||||
* Rewrite both stable entry facades so a stale cached entry fails loudly
|
||||
* instead of killing the card or panel silently (#353 K3, #486). Rollup emits
|
||||
* the card facade as a STATIC re-export of a content-hashed implementation
|
||||
* chunk, while the panel entry statically imports that same shared chunk.
|
||||
* After an update a proxy-cached entry can point at a chunk the manifest-gated
|
||||
* server no longer serves, and a static import failure would abort the whole
|
||||
* module before any code runs.
|
||||
*
|
||||
* The rewrite keeps the happy path intact via top-level await: an importer's
|
||||
* `await import(entry)` does not resolve until the inner import settles, so
|
||||
@@ -258,37 +318,69 @@ export function editorRuntimeRetryUrlPlugin() {
|
||||
* Must run BEFORE bundleManifestPlugin so the manifest hashes the final code.
|
||||
*/
|
||||
export function entryFallbackPlugin() {
|
||||
const fallbackDefinition = (contract) => `if(!customElements.get("${contract.element}")){`
|
||||
+ 'const l=String(navigator.language||"en").toLowerCase();'
|
||||
+ 'const m=l.startsWith("ru")'
|
||||
+ '?"House Plan обновился — перезагрузите страницу (Ctrl+F5)."'
|
||||
+ ':l.startsWith("de")'
|
||||
+ '?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5)."'
|
||||
+ ':l.startsWith("fr")'
|
||||
+ '?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5)."'
|
||||
+ ':"House Plan was updated — please reload the page (Ctrl+F5).";'
|
||||
+ `customElements.define("${contract.element}",class extends HTMLElement{`
|
||||
+ (contract.cardFallback ? 'setConfig(){}getCardSize(){return 1}' : '')
|
||||
+ 'connectedCallback(){'
|
||||
+ 'this.style.cssText="display:block;box-sizing:border-box;padding:16px;'
|
||||
+ (contract.cardFallback
|
||||
? 'border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);'
|
||||
: 'min-height:100%;')
|
||||
+ 'background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);'
|
||||
+ 'font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";'
|
||||
+ 'this.textContent=m}})}';
|
||||
return {
|
||||
name: 'houseplan-entry-fallback',
|
||||
generateBundle(_options, bundle) {
|
||||
const entry = Object.values(bundle)
|
||||
.find((item) => item.type === 'chunk' && item.isEntry);
|
||||
if (!entry) throw new Error('entry chunk was not emitted');
|
||||
const pattern = /export\{[^}]*\}from(["'])(\.\/houseplan-assets\/[^"']+\.js)\1;?/g;
|
||||
const matches = [...entry.code.matchAll(pattern)];
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(`entry facade re-export count is ${matches.length}, expected 1`);
|
||||
const cardContract = ENTRY_CONTRACTS[0];
|
||||
const cardEntry = exactEntryChunk(bundle, cardContract);
|
||||
const cardPattern = /export\{[^}]*\}from(["'])(\.\/houseplan-assets\/[^"']+\.js)\1;?/g;
|
||||
const cardMatches = [...cardEntry.code.matchAll(cardPattern)];
|
||||
if (cardMatches.length !== 1) {
|
||||
throw new Error(
|
||||
`${cardContract.fileName} facade re-export count is ${cardMatches.length}, expected 1`,
|
||||
);
|
||||
}
|
||||
const asset = matches[0][2];
|
||||
const fallback = 'try{await import("' + asset + '")}'
|
||||
+ 'catch(e){if(!customElements.get("houseplan-card")){'
|
||||
+ 'const l=String(navigator.language||"en").toLowerCase();'
|
||||
+ 'const m=l.startsWith("ru")'
|
||||
+ '?"House Plan обновился — перезагрузите страницу (Ctrl+F5)."'
|
||||
+ ':l.startsWith("de")'
|
||||
+ '?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5)."'
|
||||
+ ':l.startsWith("fr")'
|
||||
+ '?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5)."'
|
||||
+ ':"House Plan was updated — please reload the page (Ctrl+F5).";'
|
||||
+ 'customElements.define("houseplan-card",class extends HTMLElement{'
|
||||
+ 'setConfig(){}getCardSize(){return 1}connectedCallback(){'
|
||||
+ 'this.style.cssText="display:block;box-sizing:border-box;padding:16px;'
|
||||
+ 'border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);'
|
||||
+ 'background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);'
|
||||
+ 'font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";'
|
||||
+ 'this.textContent=m}})}'
|
||||
+ 'console.error("[houseplan] stale entry: the main chunk is unavailable",e)}';
|
||||
entry.code = entry.code.replace(pattern, fallback);
|
||||
const cardAsset = cardMatches[0][2];
|
||||
cardEntry.code = cardEntry.code.replace(
|
||||
cardPattern,
|
||||
`try{await import("${cardAsset}")}`
|
||||
+ `catch(e){${fallbackDefinition(cardContract)}`
|
||||
+ 'console.error("[houseplan] stale houseplan-card.js: the implementation chunk is unavailable",e)}',
|
||||
);
|
||||
|
||||
// Rollup folds the small panel shell into its stable entry and points its
|
||||
// side-effect import directly at the shared card implementation. Route it
|
||||
// through the stable card facade instead: the real startup closure then
|
||||
// contains the exact card graph (not a second copy), and a stale card
|
||||
// implementation renders the existing card fallback inside the panel.
|
||||
const panelContract = ENTRY_CONTRACTS[1];
|
||||
const panelEntry = exactEntryChunk(bundle, panelContract);
|
||||
const panelPattern = new RegExp(
|
||||
`import(["'])${cardAsset.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\1;?`,
|
||||
'g',
|
||||
);
|
||||
const panelMatches = [...panelEntry.code.matchAll(panelPattern)];
|
||||
if (panelMatches.length !== 1) {
|
||||
throw new Error(
|
||||
`${panelContract.fileName} card import count is ${panelMatches.length}, expected 1`,
|
||||
);
|
||||
}
|
||||
panelEntry.code = panelEntry.code.replace(
|
||||
panelPattern,
|
||||
`try{await import("./${CARD_ENTRY_FILE}")}`
|
||||
+ `catch(e){${fallbackDefinition(panelContract)}`
|
||||
+ 'console.error("[houseplan] stale houseplan-panel.js: the card entry is unavailable",e)}',
|
||||
);
|
||||
panelEntry.imports = [CARD_ENTRY_FILE];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+20
-14
@@ -15,6 +15,9 @@ import {
|
||||
} from 'node:fs';
|
||||
import { dirname, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
assertBundleManifest, orderedBundlePayload, verifyBundleTree,
|
||||
} from './bundle-tree.mjs';
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const SOURCE_ROOT = resolve(ROOT, 'dist');
|
||||
@@ -30,14 +33,11 @@ if (!existsSync(manifestPath)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const readManifest = (path) => {
|
||||
const parseManifest = (path) => {
|
||||
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
||||
if (parsed?.schema !== 1 || !Array.isArray(parsed.files) || !parsed.entry) {
|
||||
throw new Error(`${path}: invalid House Plan bundle manifest`);
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
const sourceManifest = readManifest(manifestPath);
|
||||
const sourceManifest = assertBundleManifest(parseManifest(manifestPath), manifestPath);
|
||||
const managedFiles = [MANIFEST_NAME, ...sourceManifest.files.map((file) => file.path)];
|
||||
const sha256 = (path) => createHash('sha256').update(readFileSync(path)).digest('hex');
|
||||
const contained = (root, name) => {
|
||||
@@ -54,21 +54,27 @@ for (const file of sourceManifest.files) {
|
||||
if (!existsSync(path)) throw new Error(`manifest asset is missing: ${file.path}`);
|
||||
if (sha256(path) !== file.sha256) throw new Error(`manifest hash mismatch: ${file.path}`);
|
||||
}
|
||||
verifyBundleTree(SOURCE_ROOT);
|
||||
|
||||
for (const target of TARGETS) {
|
||||
const targetRoot = resolve(ROOT, target);
|
||||
const oldManifestPath = resolve(targetRoot, MANIFEST_NAME);
|
||||
let old = null;
|
||||
if (existsSync(oldManifestPath)) {
|
||||
old = readManifest(oldManifestPath);
|
||||
// The first two-entry sync necessarily replaces a valid legacy one-entry
|
||||
// manifest. Read only its old inventory here; the copied result below is
|
||||
// validated against the complete current contract.
|
||||
old = parseManifest(oldManifestPath);
|
||||
if (old?.schema !== 1 || !Array.isArray(old.files)) {
|
||||
throw new Error(`${oldManifestPath}: invalid previous House Plan bundle manifest`);
|
||||
}
|
||||
}
|
||||
// Content-hashed dependencies first, stable entry second, manifest last.
|
||||
// At every observable point the current manifest therefore names a complete
|
||||
// tree; an interrupted copy cannot publish an allowlist for absent chunks.
|
||||
const payload = sourceManifest.files
|
||||
.map((file) => file.path)
|
||||
.sort((left, right) => (left === sourceManifest.entry ? 1 : 0)
|
||||
- (right === sourceManifest.entry ? 1 : 0) || left.localeCompare(right));
|
||||
// Content-hashed dependencies first, both stable entries second, manifest
|
||||
// last. This is offline materialization, not a promise of live atomicity;
|
||||
// either stale entry has its own visible reload fallback for the copy window.
|
||||
// The new manifest is never published before its complete payload, while an
|
||||
// interrupted pre-manifest copy can still expose a cross-generation entry.
|
||||
const payload = orderedBundlePayload(sourceManifest);
|
||||
for (const name of payload) {
|
||||
const source = contained(SOURCE_ROOT, name);
|
||||
const destination = contained(targetRoot, name);
|
||||
@@ -79,7 +85,7 @@ for (const target of TARGETS) {
|
||||
for (const file of old?.files || []) {
|
||||
if (!managedFiles.includes(file.path)) rmSync(contained(targetRoot, file.path), { force: true });
|
||||
}
|
||||
const copied = readManifest(resolve(targetRoot, MANIFEST_NAME));
|
||||
const copied = verifyBundleTree(targetRoot);
|
||||
for (const file of copied.files) {
|
||||
if (sha256(contained(targetRoot, file.path)) !== file.sha256) {
|
||||
throw new Error(`${target}/${file.path}: copied hash mismatch`);
|
||||
|
||||
+93
-12
@@ -5,6 +5,8 @@ import { dirname, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const BUNDLE_MANIFEST = 'houseplan-assets.json';
|
||||
export const CARD_ENTRY = 'houseplan-card.js';
|
||||
export const PANEL_ENTRY = 'houseplan-panel.js';
|
||||
|
||||
export const sha256Bytes = (contents) => createHash('sha256').update(contents).digest('hex');
|
||||
|
||||
@@ -20,19 +22,92 @@ export function containedBundlePath(root, name) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const assertUniqueGraph = (manifest, field, listed, label) => {
|
||||
const graph = manifest[field];
|
||||
if (!Array.isArray(graph) || graph.some((path) => typeof path !== 'string')) {
|
||||
throw new Error(`${label}: ${field} must be an array of bundle paths`);
|
||||
}
|
||||
if (new Set(graph).size !== graph.length) {
|
||||
throw new Error(`${label}: ${field} contains duplicate assets`);
|
||||
}
|
||||
const missing = graph.filter((path) => !listed.has(path));
|
||||
if (missing.length) throw new Error(`${label}: ${field} references missing asset ${missing[0]}`);
|
||||
return graph;
|
||||
};
|
||||
|
||||
/** Validate the additive two-entry manifest contract independently of disk I/O. */
|
||||
export function assertBundleManifest(manifest, label = BUNDLE_MANIFEST) {
|
||||
if (manifest?.schema !== 1 || typeof manifest.fingerprint !== 'string'
|
||||
|| !Array.isArray(manifest.files)) {
|
||||
throw new Error(`${label}: invalid House Plan bundle manifest`);
|
||||
}
|
||||
if (manifest.entry !== CARD_ENTRY || manifest.panelEntry !== PANEL_ENTRY) {
|
||||
throw new Error(`${label}: expected entries ${CARD_ENTRY} and ${PANEL_ENTRY}`);
|
||||
}
|
||||
const names = manifest.files.map((file) => file?.path);
|
||||
const listed = new Set(names);
|
||||
if (listed.size !== names.length || !listed.has(CARD_ENTRY) || !listed.has(PANEL_ENTRY)) {
|
||||
throw new Error(`${label}: duplicate assets or missing stable entry`);
|
||||
}
|
||||
const declaredEntries = manifest.files
|
||||
.filter((file) => file?.isEntry === true)
|
||||
.map((file) => file.path)
|
||||
.sort();
|
||||
if (JSON.stringify(declaredEntries) !== JSON.stringify([CARD_ENTRY, PANEL_ENTRY].sort())) {
|
||||
throw new Error(`${label}: isEntry inventory must contain exactly both stable entries`);
|
||||
}
|
||||
|
||||
const initialView = assertUniqueGraph(manifest, 'initialViewFiles', listed, label);
|
||||
const initialPanel = assertUniqueGraph(manifest, 'initialPanelFiles', listed, label);
|
||||
const initialPanelOnly = assertUniqueGraph(manifest, 'initialPanelOnlyFiles', listed, label);
|
||||
if (!initialView.includes(CARD_ENTRY) || initialView.includes(PANEL_ENTRY)) {
|
||||
throw new Error(`${label}: initial View graph must contain only the card stable entry`);
|
||||
}
|
||||
if (!initialPanel.includes(CARD_ENTRY) || !initialPanel.includes(PANEL_ENTRY)) {
|
||||
throw new Error(`${label}: initial panel graph must contain both stable entries`);
|
||||
}
|
||||
if (initialView.some((path) => !initialPanel.includes(path))) {
|
||||
throw new Error(`${label}: initial View graph is not a subset of initial panel graph`);
|
||||
}
|
||||
const expectedPanelOnly = initialPanel
|
||||
.filter((path) => !initialView.includes(path))
|
||||
.sort();
|
||||
if (JSON.stringify([...initialPanelOnly].sort()) !== JSON.stringify(expectedPanelOnly)) {
|
||||
throw new Error(`${label}: initialPanelOnlyFiles is not panel minus View`);
|
||||
}
|
||||
const gzip = (paths) => paths.reduce((total, path) => {
|
||||
const value = manifest.files.find((file) => file.path === path)?.gzipBytes;
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new Error(`${label}: missing gzip size for ${path}`);
|
||||
}
|
||||
return total + value;
|
||||
}, 0);
|
||||
for (const [field, graph] of [
|
||||
['initialViewGzipBytes', initialView],
|
||||
['initialPanelGzipBytes', initialPanel],
|
||||
['initialPanelOnlyGzipBytes', initialPanelOnly],
|
||||
]) {
|
||||
if (manifest[field] !== gzip(graph)) {
|
||||
throw new Error(`${label}: ${field} does not match its graph inventory`);
|
||||
}
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
/** Copy immutable dependencies first, then both replaceable stable entries. */
|
||||
export function orderedBundlePayload(manifest) {
|
||||
const stableEntries = new Set([manifest.entry, manifest.panelEntry]);
|
||||
return manifest.files
|
||||
.map((file) => file.path)
|
||||
.sort((left, right) => (stableEntries.has(left) ? 1 : 0)
|
||||
- (stableEntries.has(right) ? 1 : 0) || left.localeCompare(right));
|
||||
}
|
||||
|
||||
export function readBundleManifest(root) {
|
||||
const path = resolve(root, BUNDLE_MANIFEST);
|
||||
if (!existsSync(path)) throw new Error(`${path}: bundle manifest is missing`);
|
||||
const manifest = JSON.parse(readFileSync(path, 'utf8'));
|
||||
if (manifest?.schema !== 1 || typeof manifest.entry !== 'string'
|
||||
|| typeof manifest.fingerprint !== 'string' || !Array.isArray(manifest.files)) {
|
||||
throw new Error(`${path}: invalid House Plan bundle manifest`);
|
||||
}
|
||||
const names = manifest.files.map((file) => file?.path);
|
||||
if (new Set(names).size !== names.length || !names.includes(manifest.entry)) {
|
||||
throw new Error(`${path}: duplicate assets or missing entry`);
|
||||
}
|
||||
return manifest;
|
||||
return assertBundleManifest(manifest, path);
|
||||
}
|
||||
|
||||
export function verifyBundleTree(root) {
|
||||
@@ -49,11 +124,17 @@ export function verifyBundleTree(root) {
|
||||
throw new Error(`manifest hash mismatch: ${file.path} (${actual} != ${file.sha256})`);
|
||||
}
|
||||
}
|
||||
// #353 К5: a chunk on disk that the manifest does not name is dead weight —
|
||||
// it would ride into the release zip and mask sync bugs. Fail loudly.
|
||||
// #353 K5 / #486: a managed chunk on disk that the manifest does not name is
|
||||
// dead weight. Stable-looking root entries are just as dangerous as old
|
||||
// hashed chunks: both ride into the HACS zip and can mask a stale sync.
|
||||
const listed = new Set(manifest.files.map((file) => file.path));
|
||||
for (const name of readdirSync(root)) {
|
||||
if (/^houseplan-.*\.js$/.test(name) && !listed.has(name)) {
|
||||
throw new Error(`orphan bundle asset: ${name}`);
|
||||
}
|
||||
}
|
||||
const assetDir = resolve(root, 'houseplan-assets');
|
||||
if (existsSync(assetDir)) {
|
||||
const listed = new Set(manifest.files.map((file) => file.path));
|
||||
for (const name of readdirSync(assetDir)) {
|
||||
if (name.endsWith('.js') && !listed.has(`houseplan-assets/${name}`)) {
|
||||
throw new Error(`orphan bundle asset: houseplan-assets/${name}`);
|
||||
|
||||
+164
-3
@@ -595,6 +595,132 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: ' data={**entry.data}, # mutant: one-shot flag is not persisted',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'panel-registers-wrong-route',
|
||||
guard: 'python3 -m pytest tests_backend/test_ha_panel_registration.py -q -p no:cacheprovider '
|
||||
+ '-k "registers_exact_public_panel_contract"',
|
||||
because: 'the application must own exactly /houseplan; registering another route breaks the '
|
||||
+ 'stable entry point while a superficially valid panel still exists (#486 AC1)',
|
||||
patches: [{
|
||||
file: 'custom_components/houseplan/panel_registration.py',
|
||||
find: ' frontend_url_path=PANEL_URL_PATH,',
|
||||
replace: ' frontend_url_path="houseplan-mutant",',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'panel-registers-card-static-url',
|
||||
guard: 'python3 -m pytest tests_backend/test_ha_panel_registration.py -q -p no:cacheprovider '
|
||||
+ '-k "registers_exact_public_panel_contract"',
|
||||
because: 'the panel has a separate stable entry and static route; silently reusing the card URL '
|
||||
+ 'would load no houseplan-panel custom element (#486 AC1)',
|
||||
patches: [{
|
||||
file: 'custom_components/houseplan/panel_registration.py',
|
||||
find: ' hass, PANEL_FRONTEND_URL, panel_path\n',
|
||||
replace: ' hass, "/houseplan_files/houseplan-card.js", panel_path\n',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'panel-module-url-loses-version',
|
||||
guard: 'python3 -m pytest tests_backend/test_ha_panel_registration.py -q -p no:cacheprovider '
|
||||
+ '-k "registers_exact_public_panel_contract"',
|
||||
because: 'the panel module must share the integration version cache boundary; an unversioned '
|
||||
+ 'entry can retain an obsolete shell after update (#486 AC1)',
|
||||
patches: [{
|
||||
file: 'custom_components/houseplan/panel_registration.py',
|
||||
find: ' module_url=module_url,',
|
||||
replace: ' module_url=PANEL_FRONTEND_URL,',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'panel-becomes-admin-only',
|
||||
guard: 'python3 -m pytest tests_backend/test_ha_panel_registration.py -q -p no:cacheprovider '
|
||||
+ '-k "registers_exact_public_panel_contract or visible_to_admin_and_read_only"',
|
||||
because: 'sidebar visibility is not write authorization; require_admin=True would hide the '
|
||||
+ 'read-only View from household members promised by #486 AC1/AC9',
|
||||
patches: [{
|
||||
file: 'custom_components/houseplan/panel_registration.py',
|
||||
find: ' require_admin=False,',
|
||||
replace: ' require_admin=True,',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'panel-bypasses-panel-custom-api',
|
||||
guard: 'python3 -m pytest tests_backend/test_ha_panel_registration.py -q -p no:cacheprovider '
|
||||
+ '-k "registers_exact_public_panel_contract"',
|
||||
because: 'only panel_custom.async_register_panel supplies the supported custom-element/module '
|
||||
+ 'contract across the minimum HA version (#486 AC1/AC14)',
|
||||
patches: [{
|
||||
file: 'custom_components/houseplan/panel_registration.py',
|
||||
find: ' await panel_custom.async_register_panel(',
|
||||
replace: ' await frontend.async_register_built_in_panel(',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'panel-static-legacy-boolean-claims-both-urls',
|
||||
guard: 'python3 -m pytest tests_backend/test_ha_frontend_registration.py '
|
||||
+ 'tests_backend/test_ha_panel_registration.py -q -p no:cacheprovider '
|
||||
+ '-k "legacy_card_boolean_becomes_per_url"',
|
||||
because: 'the pre-panel boolean proves only the card route; treating it as both routes leaves '
|
||||
+ 'the panel registry pointing at an unserved module after reload (#486 AC1/AC3)',
|
||||
patches: [{
|
||||
file: 'custom_components/houseplan/frontend_registration.py',
|
||||
find: ' urls = {FRONTEND_URL}\n'
|
||||
+ ' domain_data[FRONTEND_STATIC_REGISTERED_KEY] = urls',
|
||||
replace: ' urls = {FRONTEND_URL, "/houseplan_files/houseplan-panel.js"}\n'
|
||||
+ ' domain_data[FRONTEND_STATIC_REGISTERED_KEY] = urls',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'panel-cleanup-drops-generation-guard',
|
||||
guard: 'python3 -m pytest tests_backend/test_ha_panel_registration.py -q -p no:cacheprovider '
|
||||
+ '-k "generation_and_identity_guards"',
|
||||
because: 'a callback from an obsolete setup generation must not remove the current generation '
|
||||
+ 'even when it still retains an owned object reference (#486 AC2)',
|
||||
patches: [{
|
||||
file: 'custom_components/houseplan/panel_registration.py',
|
||||
find: ' and domain_data.get(PANEL_GENERATION_KEY) == state.generation\n',
|
||||
replace: ' and True # mutant: setup generation is ignored\n',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'panel-cleanup-drops-identity-guard',
|
||||
guard: 'python3 -m pytest tests_backend/test_ha_panel_registration.py -q -p no:cacheprovider '
|
||||
+ '-k "generation_and_identity_guards"',
|
||||
because: 'route ownership is the exact registry object, not the route name; otherwise an '
|
||||
+ 'external replacement is deleted during unload (#486 AC2)',
|
||||
patches: [{
|
||||
file: 'custom_components/houseplan/panel_registration.py',
|
||||
find: ' if panels.get(PANEL_URL_PATH) is not state._owned_panel:',
|
||||
replace: ' if PANEL_URL_PATH not in panels: # mutant: foreign identity accepted',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'panel-cleanup-uses-new-remove-keyword',
|
||||
guard: 'python3 -m pytest tests_backend/test_ha_panel_registration.py -q -p no:cacheprovider '
|
||||
+ '-k "generation_and_identity_guards"',
|
||||
because: 'the supported minimum exposes only the two-argument remove call; adding a newer '
|
||||
+ 'keyword makes unload leave the owned sidebar panel behind (#486 AC2/AC14)',
|
||||
patches: [{
|
||||
file: 'custom_components/houseplan/panel_registration.py',
|
||||
find: ' # Keep the HA 2024.6-compatible two-argument call. In particular, do\n'
|
||||
+ ' # not pass the newer warn_if_unknown keyword.\n'
|
||||
+ ' frontend.async_remove_panel(hass, PANEL_URL_PATH)',
|
||||
replace: ' # mutant: use an API shape unavailable on minimum HA\n'
|
||||
+ ' frontend.async_remove_panel(hass, PANEL_URL_PATH, warn_if_unknown=False)',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'panel-accepts-unverifiable-ownership',
|
||||
guard: 'python3 -m pytest tests_backend/test_ha_panel_registration.py -q -p no:cacheprovider '
|
||||
+ '-k "unreadable_registry_removes_just_registered"',
|
||||
because: 'a successful public registration without a captured registry identity must be '
|
||||
+ 'removed immediately, never left for a later blind route cleanup (#486 AC2)',
|
||||
patches: [{
|
||||
file: 'custom_components/houseplan/panel_registration.py',
|
||||
find: ' if owned_panel is None:',
|
||||
replace: ' if False: # mutant: unverifiable ownership is accepted',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'version-recovery-treats-unknown-as-mismatch',
|
||||
guard: 'node --test --test-name-pattern="malformed values stay unknown" '
|
||||
@@ -2772,13 +2898,48 @@ const MUTANT_DEFINITIONS = [
|
||||
},
|
||||
{
|
||||
id: 'entry-fallback-rewrite-skipped',
|
||||
guard: 'node demo/smoke_entry_stale.mjs',
|
||||
guard: 'node --test --test-name-pattern="#486 both stable entries" '
|
||||
+ 'test/bundle-assets.test.mjs',
|
||||
because: 'without the rewrite the entry keeps a STATIC re-export: after an update a cached '
|
||||
+ 'entry aborts before any code runs and the card dies silently (#353 К3)',
|
||||
patches: [{
|
||||
file: 'scripts/bundle-manifest.mjs',
|
||||
find: ' entry.code = entry.code.replace(pattern, fallback);',
|
||||
replace: ' void fallback;',
|
||||
find: ' cardEntry.code = cardEntry.code.replace(\n'
|
||||
+ ' cardPattern,\n'
|
||||
+ ' `try{await import("${cardAsset}")}`\n'
|
||||
+ ' + `catch(e){${fallbackDefinition(cardContract)}`\n'
|
||||
+ " + 'console.error(\"[houseplan] stale houseplan-card.js: the implementation chunk is unavailable\",e)}',\n"
|
||||
+ ' );',
|
||||
replace: ' void cardEntry; // mutant: cached card facade keeps its static re-export',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'panel-entry-fallback-rewrite-skipped',
|
||||
guard: 'node --test --test-name-pattern="#486 both stable entries" '
|
||||
+ 'test/bundle-assets.test.mjs',
|
||||
because: 'without its rewrite a stale panel entry aborts on the shared implementation '
|
||||
+ 'before either a card or panel fallback can render (#486 AC4)',
|
||||
patches: [{
|
||||
file: 'scripts/bundle-manifest.mjs',
|
||||
find: ' panelEntry.code = panelEntry.code.replace(\n'
|
||||
+ ' panelPattern,\n'
|
||||
+ ' `try{await import("./${CARD_ENTRY_FILE}")}`\n'
|
||||
+ ' + `catch(e){${fallbackDefinition(panelContract)}`\n'
|
||||
+ " + 'console.error(\"[houseplan] stale houseplan-panel.js: the card entry is unavailable\",e)}',\n"
|
||||
+ ' );',
|
||||
replace: ' void panelEntry; // mutant: cached panel keeps its static shared-chunk import',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'panel-entry-bypasses-card-graph',
|
||||
guard: 'node --test --test-name-pattern="#486 both stable entries" '
|
||||
+ 'test/bundle-assets.test.mjs',
|
||||
because: 'routing panel startup straight to a hashed implementation bypasses the stable '
|
||||
+ 'card root and breaks the required exact card-graph subset (#486 AC5)',
|
||||
patches: [{
|
||||
file: 'scripts/bundle-manifest.mjs',
|
||||
find: ' panelEntry.imports = [CARD_ENTRY_FILE];',
|
||||
replace: ' panelEntry.imports = [cardAsset.slice(2)]; // mutant: direct hashed edge',
|
||||
}],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -131,10 +131,12 @@ if (flag('no-smokes')) {
|
||||
}
|
||||
if (picked.length) {
|
||||
// Стенд читает свою копию бандла; без раскладки смок врёт согласованно (#236).
|
||||
if (existsSync(resolve(ROOT, 'dist/houseplan-card.js'))) {
|
||||
const missingEntries = ['houseplan-card.js', 'houseplan-panel.js']
|
||||
.filter((name) => !existsSync(resolve(ROOT, 'dist', name)));
|
||||
if (!missingEntries.length) {
|
||||
steps.push(run('Раскладка бандла', 'node', ['scripts/bundle-sync.mjs']));
|
||||
} else {
|
||||
skipped.push('смоки — нет dist/houseplan-card.js, нужен `npm run build`');
|
||||
skipped.push(`смоки — нет dist/${missingEntries.join(', dist/')}, нужен \`npm run build\``);
|
||||
picked = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { createInterface } from 'node:readline/promises';
|
||||
import { stdin, stdout } from 'node:process';
|
||||
import { assertReleaseContract } from './release-contract.mjs';
|
||||
import { classifyValidateRuns } from './release-gate.mjs';
|
||||
import { assertBundleManifest } from './bundle-tree.mjs';
|
||||
|
||||
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
||||
const SUBPROCESS_MAX_BUFFER = 64 * 1024 * 1024;
|
||||
@@ -99,11 +100,12 @@ export function prereleaseWorkflowSucceeded(label, conclusion) {
|
||||
* compression methods are rejected deliberately: release archives are small
|
||||
* and are produced by either `git archive` or Info-ZIP.
|
||||
*/
|
||||
export function readZipEntries(zipPath, requiredNames) {
|
||||
const inspectZip = (zipPath, requiredNames) => {
|
||||
const archive = readFileSync(zipPath);
|
||||
if (!archive.length) throw new Error('houseplan.zip is empty');
|
||||
const required = new Set(requiredNames);
|
||||
const found = new Map();
|
||||
const names = [];
|
||||
const eocdSignature = 0x06054b50;
|
||||
const centralSignature = 0x02014b50;
|
||||
const localSignature = 0x04034b50;
|
||||
@@ -147,6 +149,7 @@ export function readZipEntries(zipPath, requiredNames) {
|
||||
if (next > archive.length) throw new Error('houseplan.zip has a truncated central entry');
|
||||
const name = archive.subarray(cursor + 46, cursor + 46 + nameLength)
|
||||
.toString('utf8').replace(/^\.\//, '');
|
||||
names.push(name);
|
||||
cursor = next;
|
||||
if (!required.has(name)) continue;
|
||||
if (found.has(name)) throw new Error(`houseplan.zip contains duplicate ${name}`);
|
||||
@@ -175,7 +178,15 @@ export function readZipEntries(zipPath, requiredNames) {
|
||||
throw new Error('houseplan.zip central directory size is inconsistent');
|
||||
const missing = [...required].filter((name) => !found.has(name));
|
||||
if (missing.length) throw new Error(`houseplan.zip is missing ${missing.join(', ')}`);
|
||||
return found;
|
||||
return { found, names };
|
||||
};
|
||||
|
||||
export function readZipEntries(zipPath, requiredNames) {
|
||||
return inspectZip(zipPath, requiredNames).found;
|
||||
}
|
||||
|
||||
export function listZipEntries(zipPath) {
|
||||
return inspectZip(zipPath, []).names;
|
||||
}
|
||||
|
||||
const invokedDirectly = process.argv[1]
|
||||
@@ -259,10 +270,18 @@ if (invokedDirectly) {
|
||||
if (!distManifestBytes.equals(frontendManifestBytes)) {
|
||||
throw new Error('Committed bundle manifests differ');
|
||||
}
|
||||
const manifest = JSON.parse(distManifestBytes.toString('utf8'));
|
||||
if (manifest?.schema !== 1 || !Array.isArray(manifest.files)
|
||||
|| manifest.entry !== 'houseplan-card.js') {
|
||||
throw new Error('Committed bundle manifest is invalid');
|
||||
const manifest = assertBundleManifest(
|
||||
JSON.parse(distManifestBytes.toString('utf8')),
|
||||
'committed bundle manifest',
|
||||
);
|
||||
const listed = new Set(manifest.files.map((file) => file.path));
|
||||
for (const tree of ['dist', 'custom_components/houseplan/frontend']) {
|
||||
const rootNames = run('git', ['ls-tree', '-r', '--name-only', `${sha}:${tree}`]).stdout
|
||||
.split(/\r?\n/).filter(Boolean);
|
||||
const orphan = rootNames.find((name) => (
|
||||
/^houseplan-.*\.js$/.test(name) || /^houseplan-assets\/.*\.js$/.test(name)
|
||||
) && !listed.has(name));
|
||||
if (orphan) throw new Error(`Committed bundle has orphan asset: ${tree}/${orphan}`);
|
||||
}
|
||||
for (const file of manifest.files) {
|
||||
if (typeof file?.path !== 'string' || !file.path.endsWith('.js')
|
||||
@@ -303,6 +322,13 @@ if (invokedDirectly) {
|
||||
'manifest.json', 'frontend/houseplan-assets.json',
|
||||
...bundleSnapshot.manifest.files.map((file) => `frontend/${file.path}`),
|
||||
];
|
||||
const requiredSet = new Set(required);
|
||||
const orphan = listZipEntries(zipPath)
|
||||
.find((name) => (
|
||||
/^frontend\/houseplan-.*\.js$/.test(name)
|
||||
|| /^frontend\/houseplan-assets\/.*\.js$/.test(name)
|
||||
) && !requiredSet.has(name));
|
||||
if (orphan) throw new Error(`houseplan.zip contains orphan bundle asset: ${orphan}`);
|
||||
const entries = readZipEntries(zipPath, required);
|
||||
const manifest = JSON.parse(entries.get('manifest.json').toString('utf8'));
|
||||
if (manifest.version !== version)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readZipEntries } from './release-prerelease.mjs';
|
||||
import { listZipEntries, readZipEntries } from './release-prerelease.mjs';
|
||||
import { readBundleManifest, sha256Bytes } from './bundle-tree.mjs';
|
||||
|
||||
export function verifyHouseplanZip(zipPath, bundleRoot, expectedVersion = '') {
|
||||
@@ -17,6 +17,13 @@ export function verifyHouseplanZip(zipPath, bundleRoot, expectedVersion = '') {
|
||||
if (JSON.stringify(bundled) !== JSON.stringify(expected)) {
|
||||
throw new Error('houseplan.zip frontend manifest differs from source tree');
|
||||
}
|
||||
const listed = new Set(expected.files.map((file) => `frontend/${file.path}`));
|
||||
const orphan = listZipEntries(zipPath)
|
||||
.find((name) => (
|
||||
/^frontend\/houseplan-.*\.js$/.test(name)
|
||||
|| /^frontend\/houseplan-assets\/.*\.js$/.test(name)
|
||||
) && !listed.has(name));
|
||||
if (orphan) throw new Error(`houseplan.zip contains orphan bundle asset: ${orphan}`);
|
||||
const entries = readZipEntries(zipPath, expected.files.map((file) => `frontend/${file.path}`));
|
||||
for (const file of expected.files) {
|
||||
if (sha256Bytes(entries.get(`frontend/${file.path}`)) !== file.sha256) {
|
||||
|
||||
Reference in New Issue
Block a user