feat: add the House Plan sidebar panel

Issue: #486
User-Visible: yes
This commit is contained in:
Matysh
2026-09-08 00:31:25 +03:00
parent bf4c1bb674
commit 8a97d4e30d
104 changed files with 3643 additions and 564 deletions
+289 -11
View File
@@ -8,19 +8,61 @@ import test from 'node:test';
import {
buildBundleManifest, buildFingerprintPlugin, editorRuntimeRetryUrlPlugin,
entryFallbackPlugin,
} from '../scripts/bundle-manifest.mjs';
import {
INITIAL_VIEW_CEILING_BAND, INITIAL_VIEW_GZIP_BUDGET, INITIAL_VIEW_GZIP_CEILING,
INITIAL_PANEL_ONLY_GZIP_BUDGET, INITIAL_VIEW_CEILING_BAND,
INITIAL_VIEW_GZIP_BUDGET, INITIAL_VIEW_GZIP_CEILING,
LOW_HEADROOM_ACKNOWLEDGED_CEILING, LOW_HEADROOM_WARNING_BYTES,
SUPPORT_LAZY_MARKERS,
assertBundleBudget, assertSupportBundleOwnership, initialViewCeilingViolation,
lowHeadroomWarning,
} from '../scripts/bundle-budget.mjs';
import { compareBundleTrees, sha256Bytes, verifyBundleTree } from '../scripts/bundle-tree.mjs';
import {
assertBundleManifest, compareBundleTrees, orderedBundlePayload, sha256Bytes,
verifyBundleTree,
} from '../scripts/bundle-tree.mjs';
import {
minifyCssText, minifyStaticCssTemplates,
} from '../scripts/css-template-minifier.mjs';
const minimalTwoEntryBundle = () => ({
'houseplan-panel.js': {
type: 'chunk', fileName: 'houseplan-panel.js', code: 'panel', isEntry: true,
facadeModuleId: '/repo/src/houseplan-panel.ts', imports: ['houseplan-card.js'],
dynamicImports: [], modules: { '/repo/src/houseplan-panel.ts': {} },
},
'houseplan-card.js': {
type: 'chunk', fileName: 'houseplan-card.js', code: 'card', isEntry: true,
facadeModuleId: '/repo/src/houseplan-card.ts', imports: ['houseplan-assets/card-HASH.js'],
dynamicImports: [], modules: { '/repo/src/houseplan-card.ts': {} },
},
'houseplan-assets/card-HASH.js': {
type: 'chunk', fileName: 'houseplan-assets/card-HASH.js', code: 'implementation',
isEntry: false, imports: [], dynamicImports: [], modules: {},
},
});
const minimalTwoEntryManifest = () => ({
schema: 1,
fingerprint: 'f'.repeat(64),
entry: 'houseplan-card.js',
panelEntry: 'houseplan-panel.js',
initialViewFiles: ['houseplan-assets/card-HASH.js', 'houseplan-card.js'],
initialViewGzipBytes: 11,
initialPanelFiles: [
'houseplan-assets/card-HASH.js', 'houseplan-card.js', 'houseplan-panel.js',
],
initialPanelGzipBytes: 14,
initialPanelOnlyFiles: ['houseplan-panel.js'],
initialPanelOnlyGzipBytes: 3,
files: [
{ path: 'houseplan-assets/card-HASH.js', gzipBytes: 6, isEntry: false },
{ path: 'houseplan-card.js', gzipBytes: 5, isEntry: true },
{ path: 'houseplan-panel.js', gzipBytes: 3, isEntry: true },
],
});
test('CSS template minifier preserves semantic whitespace, strings and functions', () => {
const css = `
/* owner note */
@@ -49,8 +91,14 @@ test('CSS template minifier fails closed on interpolation and malformed input',
test('bundle manifest separates static initial graph from dynamic editor graph', () => {
const manifest = buildBundleManifest({
'houseplan-panel.js': {
type: 'chunk', fileName: 'houseplan-panel.js', code: 'panel', isEntry: true,
facadeModuleId: '/repo/src/houseplan-panel.ts',
imports: ['houseplan-card.js'], dynamicImports: [],
},
'houseplan-card.js': {
type: 'chunk', fileName: 'houseplan-card.js', code: 'entry', isEntry: true,
facadeModuleId: '/repo/src/houseplan-card.ts',
imports: ['shared.js'], dynamicImports: [
'houseplan-assets/editor.js', 'houseplan-assets/houseplan-onboarding-runtime-HASH.js',
'houseplan-assets/de-HASH.js', 'houseplan-assets/iso-scene-render-HASH.js',
@@ -94,7 +142,18 @@ test('bundle manifest separates static initial graph from dynamic editor graph',
modules: { '/repo/src/pdf/pdf-export.ts': {} },
},
}, 'fingerprint');
assert.equal(manifest.entry, 'houseplan-card.js');
assert.equal(manifest.panelEntry, 'houseplan-panel.js');
assert.deepEqual(manifest.initialViewFiles, ['houseplan-card.js', 'shared.js']);
assert.deepEqual(
manifest.initialPanelFiles,
['houseplan-card.js', 'houseplan-panel.js', 'shared.js'],
);
assert.deepEqual(manifest.initialPanelOnlyFiles, ['houseplan-panel.js']);
assert.equal(
manifest.initialPanelOnlyGzipBytes,
manifest.files.find((file) => file.path === 'houseplan-panel.js').gzipBytes,
);
assert.deepEqual(manifest.lazyOnboardingFiles, [
'houseplan-assets/houseplan-onboarding-runtime-HASH.js',
]);
@@ -112,6 +171,141 @@ test('bundle manifest separates static initial graph from dynamic editor graph',
]);
assert.doesNotThrow(() => assertBundleBudget(manifest, 1_000_000));
assert.throws(() => assertBundleBudget(manifest, 1), /exceeds/);
assert.throws(
() => assertBundleBudget(manifest, 1_000_000, manifest.initialPanelOnlyGzipBytes - 1),
/panel-only graph.*exceeds/,
);
});
test('#486 Rollup names both stable entries explicitly', async () => {
const { default: config } = await import('../rollup.config.mjs');
assert.deepEqual(config.input, {
'houseplan-card': 'src/houseplan-card.ts',
'houseplan-panel': 'src/houseplan-panel.ts',
});
assert.equal(config.output.entryFileNames, '[name].js');
});
test('#486 manifest root selection is exact and independent of entry enumeration order', () => {
const bundle = minimalTwoEntryBundle();
const reversed = Object.fromEntries(Object.entries(bundle).reverse());
const manifest = buildBundleManifest(reversed, 'fingerprint');
assert.equal(manifest.entry, 'houseplan-card.js');
assert.equal(manifest.panelEntry, 'houseplan-panel.js');
assert.deepEqual(manifest.initialViewFiles, [
'houseplan-assets/card-HASH.js', 'houseplan-card.js',
]);
assert.deepEqual(manifest.initialPanelFiles, [
'houseplan-assets/card-HASH.js', 'houseplan-card.js', 'houseplan-panel.js',
]);
const wrongFacade = minimalTwoEntryBundle();
wrongFacade['houseplan-panel.js'].facadeModuleId = '/repo/src/not-the-panel.ts';
assert.throws(
() => buildBundleManifest(wrongFacade, 'fingerprint'),
/houseplan-panel\.js facade.*expected src\/houseplan-panel\.ts/,
);
const extraEntry = minimalTwoEntryBundle();
extraEntry['unexpected.js'] = {
type: 'chunk', fileName: 'unexpected.js', code: '', isEntry: true,
facadeModuleId: '/repo/src/unexpected.ts', imports: [], dynamicImports: [], modules: {},
};
assert.throws(() => buildBundleManifest(extraEntry, 'fingerprint'), /entry count is 3/);
});
test('#486 manifest graph validator rejects missing panel roots and duplicated card graphs', () => {
const valid = minimalTwoEntryManifest();
assert.equal(assertBundleManifest(valid), valid);
const missingPanel = structuredClone(valid);
delete missingPanel.panelEntry;
assert.throws(() => assertBundleManifest(missingPanel), /expected entries/);
const wrongEntryFlags = structuredClone(valid);
wrongEntryFlags.files.find((file) => file.path === 'houseplan-panel.js').isEntry = false;
assert.throws(() => assertBundleManifest(wrongEntryFlags), /isEntry inventory/);
const duplicateGraph = structuredClone(valid);
duplicateGraph.files.push({
path: 'houseplan-assets/card-copy-HASH.js', gzipBytes: 6, isEntry: false,
});
duplicateGraph.initialPanelFiles = [
'houseplan-assets/card-copy-HASH.js', 'houseplan-panel.js',
];
duplicateGraph.initialPanelGzipBytes = 9;
duplicateGraph.initialPanelOnlyFiles = [
'houseplan-assets/card-copy-HASH.js', 'houseplan-panel.js',
];
duplicateGraph.initialPanelOnlyGzipBytes = 9;
assert.throws(
() => assertBundleManifest(duplicateGraph),
/initial panel graph must contain both stable entries|not a subset/,
);
});
test('#486 sync payload orders dependencies before both stable entries', () => {
assert.deepEqual(orderedBundlePayload(minimalTwoEntryManifest()), [
'houseplan-assets/card-HASH.js', 'houseplan-card.js', 'houseplan-panel.js',
]);
});
test('#486 both stable entries install a visible stale-load fallback', async () => {
const bundle = minimalTwoEntryBundle();
bundle['houseplan-card.js'].code =
'export{x}from"./houseplan-assets/card-HASH.js";';
bundle['houseplan-panel.js'].code =
'import"./houseplan-assets/card-HASH.js";customElements.define("real-panel",class{});';
entryFallbackPlugin().generateBundle({}, bundle);
const card = bundle['houseplan-card.js'].code;
const panel = bundle['houseplan-panel.js'].code;
assert.match(card, /try\{await import\("\.\/houseplan-assets\/card-HASH\.js"\)\}catch/);
assert.match(card, /customElements\.define\("houseplan-card"/);
assert.doesNotMatch(card, /export\{/);
assert.match(panel, /try\{await import\("\.\/houseplan-card\.js"\)\}catch/);
assert.match(panel, /customElements\.define\("houseplan-panel"/);
assert.doesNotMatch(panel, /(?:^|;)import["']/);
assert.deepEqual(bundle['houseplan-panel.js'].imports, ['houseplan-card.js']);
const priorCustomElements = Object.getOwnPropertyDescriptor(globalThis, 'customElements');
const priorHTMLElement = Object.getOwnPropertyDescriptor(globalThis, 'HTMLElement');
const priorNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
const priorConsoleError = console.error;
const registry = new Map();
Object.defineProperty(globalThis, 'customElements', {
configurable: true,
value: { get: (name) => registry.get(name), define: (name, ctor) => registry.set(name, ctor) },
});
Object.defineProperty(globalThis, 'HTMLElement', {
configurable: true,
value: class { constructor() { this.style = {}; } },
});
Object.defineProperty(globalThis, 'navigator', {
configurable: true, value: { language: 'en' },
});
console.error = () => {};
try {
await import(`data:text/javascript;base64,${Buffer.from(card).toString('base64')}`);
assert.ok(registry.has('houseplan-card'));
const staleCard = new (registry.get('houseplan-card'))();
staleCard.connectedCallback();
assert.match(staleCard.textContent, /reload the page/);
await import(`data:text/javascript;base64,${Buffer.from(panel).toString('base64')}`);
assert.ok(registry.has('houseplan-panel'));
const stalePanel = new (registry.get('houseplan-panel'))();
stalePanel.connectedCallback();
assert.match(stalePanel.textContent, /reload the page/);
} finally {
console.error = priorConsoleError;
for (const [name, descriptor] of [
['customElements', priorCustomElements],
['HTMLElement', priorHTMLElement],
['navigator', priorNavigator],
]) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else delete globalThis[name];
}
}
});
test('#423 support form copy belongs only to the lazy editor graph', () => {
@@ -215,12 +409,30 @@ test('bundle tree verification fails for a missing or tampered manifest asset',
for (const root of [source, target]) {
mkdirSync(join(root, 'houseplan-assets'), { recursive: true });
writeFileSync(join(root, 'houseplan-card.js'), 'entry');
writeFileSync(join(root, 'houseplan-panel.js'), 'panel');
writeFileSync(join(root, 'houseplan-assets', 'editor-hash.js'), 'editor');
const files = ['houseplan-card.js', 'houseplan-assets/editor-hash.js'].map((path) => ({
path, sha256: sha256Bytes(readFileSync(join(root, path))),
const files = [
'houseplan-card.js', 'houseplan-panel.js', 'houseplan-assets/editor-hash.js',
].map((path) => ({
path,
sha256: sha256Bytes(readFileSync(join(root, path))),
gzipBytes: path === 'houseplan-panel.js' ? 2 : 3,
isEntry: path === 'houseplan-card.js' || path === 'houseplan-panel.js',
}));
writeFileSync(join(root, 'houseplan-assets.json'), `${JSON.stringify({
schema: 1, fingerprint: 'fixture', entry: 'houseplan-card.js', files,
schema: 1,
fingerprint: 'fixture',
entry: 'houseplan-card.js',
panelEntry: 'houseplan-panel.js',
initialViewFiles: ['houseplan-assets/editor-hash.js', 'houseplan-card.js'],
initialViewGzipBytes: 6,
initialPanelFiles: [
'houseplan-assets/editor-hash.js', 'houseplan-card.js', 'houseplan-panel.js',
],
initialPanelGzipBytes: 8,
initialPanelOnlyFiles: ['houseplan-panel.js'],
initialPanelOnlyGzipBytes: 2,
files,
})}\n`);
}
assert.doesNotThrow(() => compareBundleTrees(source, target));
@@ -254,6 +466,32 @@ test('entry facade fails loudly when the main chunk is unavailable (#353 AC3a)',
);
});
test('#486 panel entry routes through the card facade and fails loudly too', () => {
const panel = readFileSync(new URL('../dist/houseplan-panel.js', import.meta.url), 'utf8');
assert.match(
panel,
/^globalThis\.__HOUSEPLAN_BUILD_FINGERPRINT__="[0-9a-f]{64}";/,
);
assert.match(panel, /try\{await import\("\.\/houseplan-card\.js"\)\}catch\(/);
assert.match(panel, /customElements\.define\("houseplan-panel",/);
assert.match(panel, /reload the page/);
assert.doesNotMatch(
panel,
/(?:^|;)import[\s{"']/m,
'the panel must not keep a static edge that can abort before its fallback runs',
);
const manifest = JSON.parse(
readFileSync(new URL('../dist/houseplan-assets.json', import.meta.url), 'utf8'),
);
assert.ok(!manifest.initialViewFiles.includes(manifest.panelEntry));
assert.ok(
manifest.initialViewFiles.every((path) => manifest.initialPanelFiles.includes(path)),
'the panel must reuse the exact card graph',
);
assert.ok(manifest.initialPanelOnlyGzipBytes <= INITIAL_PANEL_ONLY_GZIP_BUDGET);
});
test('bundle tree verification rejects orphan chunks (#353 AC4)', async () => {
const { mkdtempSync, writeFileSync, mkdirSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
@@ -262,24 +500,51 @@ test('bundle tree verification rejects orphan chunks (#353 AC4)', async () => {
const root = mkdtempSync(join(tmpdir(), 'hp-tree-'));
mkdirSync(join(root, 'houseplan-assets'));
const entryCode = 'try{await import("./houseplan-assets/main-abc.js")}catch(e){}';
const panelCode = 'try{await import("./houseplan-card.js")}catch(e){}';
const chunkCode = 'export const x = 1;';
writeFileSync(join(root, 'houseplan-card.js'), entryCode);
writeFileSync(join(root, 'houseplan-panel.js'), panelCode);
writeFileSync(join(root, 'houseplan-assets', 'main-abc.js'), chunkCode);
writeFileSync(join(root, 'houseplan-assets.json'), JSON.stringify({
schema: 1,
fingerprint: 'f'.repeat(64),
entry: 'houseplan-card.js',
panelEntry: 'houseplan-panel.js',
initialViewFiles: ['houseplan-assets/main-abc.js', 'houseplan-card.js'],
initialViewGzipBytes: 12,
initialPanelFiles: [
'houseplan-assets/main-abc.js', 'houseplan-card.js', 'houseplan-panel.js',
],
initialPanelGzipBytes: 14,
initialPanelOnlyFiles: ['houseplan-panel.js'],
initialPanelOnlyGzipBytes: 2,
files: [
{ path: 'houseplan-card.js', sha256: sha256Bytes(Buffer.from(entryCode)) },
{ path: 'houseplan-assets/main-abc.js', sha256: sha256Bytes(Buffer.from(chunkCode)) },
{
path: 'houseplan-card.js', sha256: sha256Bytes(Buffer.from(entryCode)),
gzipBytes: 5, isEntry: true,
},
{
path: 'houseplan-panel.js', sha256: sha256Bytes(Buffer.from(panelCode)),
gzipBytes: 2, isEntry: true,
},
{
path: 'houseplan-assets/main-abc.js', sha256: sha256Bytes(Buffer.from(chunkCode)),
gzipBytes: 7, isEntry: false,
},
],
}));
assert.equal(verifyBundleTree(root).files.length, 2, 'a clean tree verifies');
assert.equal(verifyBundleTree(root).files.length, 3, 'a clean tree verifies');
writeFileSync(join(root, 'houseplan-assets', 'junk-old.js'), 'stale');
assert.throws(
() => verifyBundleTree(root),
/orphan bundle asset: houseplan-assets\/junk-old\.js/,
);
rmSync(join(root, 'houseplan-assets', 'junk-old.js'));
writeFileSync(join(root, 'houseplan-obsolete.js'), 'stale root entry');
assert.throws(
() => verifyBundleTree(root),
/orphan bundle asset: houseplan-obsolete\.js/,
);
});
// #367. Рекалибровка после того, как запас ушёл с 26 КБ до 8.3 КБ за сутки.
@@ -448,7 +713,8 @@ const runBudgetCli = (initialViewGzipBytes) => {
const dir = mkdtempSync(join(tmpdir(), 'houseplan-budget-cli-'));
try {
mkdirSync(join(dir, 'dist'));
writeFileSync(join(dir, 'dist/initial.js'), 'view graph without support copy');
writeFileSync(join(dir, 'dist/houseplan-card.js'), 'view graph without support copy');
writeFileSync(join(dir, 'dist/houseplan-panel.js'), 'panel shell');
writeFileSync(join(dir, 'dist/editor.js'), SUPPORT_LAZY_MARKERS.join('\n'));
writeFileSync(join(dir, 'dist/locale.js'), 'lazy locale dictionary');
writeFileSync(join(dir, 'dist/isometric.js'), 'lazy isometric runtime');
@@ -456,9 +722,21 @@ const runBudgetCli = (initialViewGzipBytes) => {
writeFileSync(join(dir, 'dist/pdf.js'), 'lazy pdf writer');
writeFileSync(join(dir, 'dist/houseplan-assets.json'), JSON.stringify({
schema: 1,
files: [],
initialViewFiles: ['initial.js'],
fingerprint: 'f'.repeat(64),
entry: 'houseplan-card.js',
panelEntry: 'houseplan-panel.js',
files: [
{
path: 'houseplan-card.js', gzipBytes: initialViewGzipBytes, isEntry: true,
},
{ path: 'houseplan-panel.js', gzipBytes: 1, isEntry: true },
],
initialViewFiles: ['houseplan-card.js'],
initialViewGzipBytes,
initialPanelFiles: ['houseplan-card.js', 'houseplan-panel.js'],
initialPanelGzipBytes: initialViewGzipBytes + 1,
initialPanelOnlyFiles: ['houseplan-panel.js'],
initialPanelOnlyGzipBytes: 1,
lazyEditorFiles: ['editor.js'],
lazyEditorGzipBytes: 1000,
lazyLocaleFiles: ['locale.js'],
+34 -3
View File
@@ -48,27 +48,58 @@ test('bundle freshness rejects a missing or stale fingerprint', async () => {
test('bundle freshness verifies every manifest-listed demo asset', async () => {
const root = fixtureRoot();
try {
writeFileSync(
resolve(root, 'src/houseplan-panel.ts'),
"import './card'; export const panel = true;\n",
'utf8',
);
const expected = sourceFingerprint(root);
const assets = resolve(root, 'demo/srv/assets');
mkdirSync(resolve(assets, 'houseplan-assets'), { recursive: true });
const files = [
['houseplan-card.js', 'entry'],
['houseplan-panel.js', 'panel'],
['houseplan-assets/editor-HASH.js', 'editor'],
];
for (const [name, contents] of files) writeFileSync(resolve(assets, name), contents, 'utf8');
writeFileSync(resolve(assets, 'houseplan-assets.json'), JSON.stringify({
const manifest = {
schema: 1,
fingerprint: expected,
files: files.map(([path, contents]) => ({
entry: 'houseplan-card.js',
panelEntry: 'houseplan-panel.js',
initialViewFiles: ['houseplan-assets/editor-HASH.js', 'houseplan-card.js'],
initialViewGzipBytes: 11,
initialPanelFiles: [
'houseplan-assets/editor-HASH.js', 'houseplan-card.js', 'houseplan-panel.js',
],
initialPanelGzipBytes: 16,
initialPanelOnlyFiles: ['houseplan-panel.js'],
initialPanelOnlyGzipBytes: 5,
files: files.map(([path, contents], index) => ({
path,
sha256: createHash('sha256').update(contents).digest('hex'),
gzipBytes: [6, 5, 5][index],
isEntry: path === 'houseplan-card.js' || path === 'houseplan-panel.js',
})),
}), 'utf8');
};
writeFileSync(resolve(assets, 'houseplan-assets.json'), JSON.stringify(manifest), 'utf8');
const page = { evaluate: async () => expected };
assert.equal(await assertFreshDemoBundle(page, root), expected);
writeFileSync(resolve(assets, 'houseplan-assets/editor-HASH.js'), 'tampered', 'utf8');
await assert.rejects(assertFreshDemoBundle(page, root), /hash mismatch/);
writeFileSync(resolve(assets, 'houseplan-assets/editor-HASH.js'), 'editor', 'utf8');
rmSync(resolve(assets, 'houseplan-panel.js'));
await assert.rejects(
assertFreshDemoBundle(page, root),
/manifest asset is missing: houseplan-panel\.js/,
);
writeFileSync(resolve(assets, 'houseplan-panel.js'), 'panel', 'utf8');
delete manifest.panelEntry;
writeFileSync(resolve(assets, 'houseplan-assets.json'), JSON.stringify(manifest), 'utf8');
await assert.rejects(assertFreshDemoBundle(page, root), /expected entries/);
} finally {
rmSync(root, { recursive: true, force: true });
}
+113
View File
@@ -0,0 +1,113 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import {
existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync,
} from 'node:fs';
import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
import test from 'node:test';
const sha256 = (contents) => createHash('sha256').update(contents).digest('hex');
const writeCurrentBundle = (root) => {
const contents = new Map([
['houseplan-card.js', 'card facade'],
['houseplan-panel.js', 'panel shell'],
['houseplan-assets/card-HASH.js', 'shared card implementation'],
]);
for (const [name, value] of contents) {
const path = join(root, 'dist', name);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, value);
}
const files = [...contents].map(([path, value]) => ({
path,
sha256: sha256(value),
rawBytes: Buffer.byteLength(value),
gzipBytes: path === 'houseplan-card.js' ? 5 : path === 'houseplan-panel.js' ? 3 : 7,
isEntry: path === 'houseplan-card.js' || path === 'houseplan-panel.js',
imports: path === 'houseplan-panel.js'
? ['houseplan-card.js']
: path === 'houseplan-card.js' ? ['houseplan-assets/card-HASH.js'] : [],
dynamicImports: [],
}));
const manifest = {
schema: 1,
fingerprint: 'f'.repeat(64),
entry: 'houseplan-card.js',
panelEntry: 'houseplan-panel.js',
initialViewFiles: ['houseplan-assets/card-HASH.js', 'houseplan-card.js'],
initialViewGzipBytes: 12,
initialPanelFiles: [
'houseplan-assets/card-HASH.js', 'houseplan-card.js', 'houseplan-panel.js',
],
initialPanelGzipBytes: 15,
initialPanelOnlyFiles: ['houseplan-panel.js'],
initialPanelOnlyGzipBytes: 3,
files,
};
writeFileSync(join(root, 'dist/houseplan-assets.json'), `${JSON.stringify(manifest)}\n`);
return manifest;
};
const writeLegacyTarget = (root, target) => {
const targetRoot = join(root, target);
mkdirSync(join(targetRoot, 'houseplan-assets'), { recursive: true });
writeFileSync(join(targetRoot, 'houseplan-card.js'), 'old card');
writeFileSync(join(targetRoot, 'houseplan-legacy.js'), 'old root entry');
writeFileSync(join(targetRoot, 'houseplan-assets/old-HASH.js'), 'old chunk');
writeFileSync(join(targetRoot, 'houseplan-assets.json'), JSON.stringify({
schema: 1,
fingerprint: 'legacy',
entry: 'houseplan-card.js',
files: [
{ path: 'houseplan-card.js' },
{ path: 'houseplan-legacy.js' },
{ path: 'houseplan-assets/old-HASH.js' },
],
}));
};
test('#486 bundle sync materializes both entries and removes the legacy inventory', () => {
const root = mkdtempSync(join(tmpdir(), 'houseplan-bundle-sync-'));
try {
mkdirSync(join(root, 'scripts'), { recursive: true });
for (const name of ['bundle-sync.mjs', 'bundle-tree.mjs']) {
writeFileSync(
join(root, 'scripts', name),
readFileSync(new URL(`../scripts/${name}`, import.meta.url)),
);
}
const manifest = writeCurrentBundle(root);
for (const target of [
'custom_components/houseplan/frontend', 'demo/srv/assets',
]) writeLegacyTarget(root, target);
const run = spawnSync(process.execPath, ['scripts/bundle-sync.mjs'], {
cwd: root, encoding: 'utf8',
});
assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
for (const target of [
'custom_components/houseplan/frontend', 'demo/srv/assets',
]) {
assert.equal(readFileSync(join(root, target, 'houseplan-card.js'), 'utf8'), 'card facade');
assert.equal(readFileSync(join(root, target, 'houseplan-panel.js'), 'utf8'), 'panel shell');
assert.deepEqual(
JSON.parse(readFileSync(join(root, target, 'houseplan-assets.json'), 'utf8')),
manifest,
);
assert.equal(existsSync(join(root, target, 'houseplan-legacy.js')), false);
assert.equal(existsSync(join(root, target, 'houseplan-assets/old-HASH.js')), false);
}
writeFileSync(join(root, 'dist/houseplan-orphan.js'), 'unlisted entry');
const orphan = spawnSync(process.execPath, ['scripts/bundle-sync.mjs'], {
cwd: root, encoding: 'utf8',
});
assert.notEqual(orphan.status, 0);
assert.match(`${orphan.stdout}${orphan.stderr}`, /orphan bundle asset: houseplan-orphan\.js/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
+27 -1
View File
@@ -136,6 +136,32 @@ test('golden matrix has stable unique ids and bounded comparison thresholds', ()
}
});
test('issue 486 golden matrix has exactly the three focused panel-host surfaces', () => {
const scenarios = GOLDEN_SCENARIOS.filter((scenario) => scenario.panelHost);
assert.deepEqual(scenarios.map((scenario) => scenario.id), [
'panel-wide-view-light-en',
'panel-wide-plan-editor-dark-en',
'panel-narrow-read-only-empty-light-en',
]);
assert.deepEqual(scenarios.map((scenario) => scenario.viewport), [
{ width: 1280, height: 800 },
{ width: 1280, height: 800 },
{ width: 320, height: 720 },
]);
assert.equal(scenarios.every((scenario) => scenario.capture === 'page'), true);
assert.deepEqual(scenarios.map((scenario) => scenario.mode), ['view', 'plan', 'view']);
const readOnly = scenarios.at(-1);
assert.equal(readOnly.emptyPlan, true);
assert.equal(readOnly.canWrite, false);
assert.equal(readOnly.userIsAdmin, false);
assert.equal(readOnly.preloadEditorRuntime, false);
const fixture = prepareGoldenFixture(readOnly);
assert.deepEqual(fixture.config.spaces, []);
assert.deepEqual(fixture.config.markers, []);
assert.deepEqual(fixture.layout, {});
});
test('issue 462 has reviewed desktop, narrow-touch and attempted-kiosk notices', () => {
const scenarios = GOLDEN_SCENARIOS.filter((scenario) =>
scenario.id.startsWith('version-mismatch-'));
@@ -415,7 +441,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, 59);
assert.equal(GOLDEN_MATRIX_VERSION, 60);
assert.equal(space.settings.sun_rays, true);
assert.equal(scenario.northDeg, 90,
'the sign-sensitive golden must keep a non-zero north direction');
+47
View File
@@ -0,0 +1,47 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const source = (path) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8');
test('#486 panel shell keeps one full card and forwards HA properties without inventing card config', () => {
const panel = source('src/houseplan-panel.ts');
assert.match(panel, /import '\.\/houseplan-card';/);
assert.match(panel, /if \(this\._card \|\| !this\.shadowRoot\) return;/);
assert.match(panel, /card\.setConfig\(\{ type: 'custom:houseplan-card' \}\);/);
assert.match(panel, /if \(this\._card\) this\._card\.hass = value;/);
assert.match(panel, /public set narrow\(value: boolean\)/);
assert.match(panel, /public set route\(value: unknown\)/);
assert.match(panel, /public set panel\(value: unknown\)/);
assert.doesNotMatch(panel, /kiosk\s*:/, 'HA shell state must not enable card kiosk mode');
});
test('#486 menu and container-owned panel layout have explicit accessibility and overflow contracts', () => {
const panel = source('src/houseplan-panel.ts');
assert.match(panel, /new CustomEvent\('hass-toggle-menu', \{[\s\S]*bubbles: true,[\s\S]*composed: true/);
assert.match(panel, /width: 44px;[\s\S]*height: 44px;/);
assert.match(panel, /setAttribute\('aria-label', label\)/);
assert.match(panel, /grid-template-rows: auto minmax\(0, 1fr\)/);
assert.match(panel, /\.content \{[\s\S]*min-width: 0;[\s\S]*min-height: 0;[\s\S]*overflow: hidden;/);
assert.doesNotMatch(panel, /100dvh|100vh|safe-area-inset/,
'the shell must use the HA-owned content slot rather than viewport or duplicated safe-area math');
});
test('#486 only the full card advertises a full-width Sections default', () => {
const card = source('src/houseplan-card.ts');
const spaceCard = source('src/space-card.ts');
assert.match(card, /getGridOptions\(\): \{ columns: 'full' \} \{\s*return \{ columns: 'full' \};\s*\}/);
assert.match(card, /getCardSize\(\): number/);
assert.doesNotMatch(spaceCard, /getGridOptions/);
});
test('#486 read-only empty state is localized in every shipped frontend dictionary', () => {
for (const locale of ['en', 'ru', 'de', 'fr']) {
const dictionary = JSON.parse(source(`src/i18n/${locale}.json`));
assert.equal(typeof dictionary['empty.read_only'], 'string');
assert.ok(dictionary['empty.read_only'].trim().length > 0);
}
const card = source('src/houseplan-card.ts');
assert.match(card, /this\._serverStorage && this\._canManageConfiguration/);
assert.match(card, /this\._serverStorage \? 'empty\.read_only' : 'empty\.install'/);
});
+111
View File
@@ -0,0 +1,111 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import {
mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync,
} from 'node:fs';
import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
import test from 'node:test';
import { verifyHouseplanZip } from '../scripts/verify-houseplan-zip.mjs';
const sha256 = (contents) => createHash('sha256').update(contents).digest('hex');
const git = (root, args) => {
const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr || result.stdout);
};
const write = (root, name, contents) => {
const path = join(root, name);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, contents);
};
test('#486 HACS zip contains both entries and rejects stale managed roots', () => {
const root = mkdtempSync(join(tmpdir(), 'houseplan-zip-panel-'));
const component = join(root, 'component');
const frontend = join(component, 'frontend');
const zip = join(root, 'houseplan.zip');
try {
const payload = new Map([
['houseplan-card.js', 'card entry'],
['houseplan-panel.js', 'panel entry'],
['houseplan-assets/card-HASH.js', 'shared card'],
]);
for (const [name, contents] of payload) write(frontend, name, contents);
const files = [...payload].map(([path, contents]) => ({
path,
sha256: sha256(contents),
rawBytes: Buffer.byteLength(contents),
gzipBytes: path === 'houseplan-card.js' ? 5 : path === 'houseplan-panel.js' ? 3 : 7,
isEntry: path === 'houseplan-card.js' || path === 'houseplan-panel.js',
imports: path === 'houseplan-panel.js'
? ['houseplan-card.js']
: path === 'houseplan-card.js' ? ['houseplan-assets/card-HASH.js'] : [],
dynamicImports: [],
}));
write(frontend, 'houseplan-assets.json', `${JSON.stringify({
schema: 1,
fingerprint: 'f'.repeat(64),
entry: 'houseplan-card.js',
panelEntry: 'houseplan-panel.js',
initialViewFiles: ['houseplan-assets/card-HASH.js', 'houseplan-card.js'],
initialViewGzipBytes: 12,
initialPanelFiles: [
'houseplan-assets/card-HASH.js', 'houseplan-card.js', 'houseplan-panel.js',
],
initialPanelGzipBytes: 15,
initialPanelOnlyFiles: ['houseplan-panel.js'],
initialPanelOnlyGzipBytes: 3,
files,
})}\n`);
write(component, 'manifest.json', '{"version":"1.2.3"}\n');
git(root, ['init', '-q']);
git(root, ['config', 'user.name', 'House Plan test']);
git(root, ['config', 'user.email', 'test@example.invalid']);
git(root, ['config', 'core.autocrlf', 'false']);
git(root, ['add', 'component']);
git(root, ['commit', '-qm', 'fixture']);
git(root, ['archive', '--format=zip', `--output=${zip}`, 'HEAD:component']);
assert.deepEqual(verifyHouseplanZip(zip, frontend, '1.2.3'), {
version: '1.2.3', files: 3,
});
unlinkSync(join(frontend, 'houseplan-panel.js'));
git(root, ['add', '-A', 'component']);
git(root, ['commit', '-qm', 'missing panel']);
unlinkSync(zip);
git(root, ['archive', '--format=zip', `--output=${zip}`, 'HEAD:component']);
assert.throws(
() => verifyHouseplanZip(zip, frontend, '1.2.3'),
/missing frontend\/houseplan-panel\.js/,
);
write(frontend, 'houseplan-panel.js', 'panel entry');
write(frontend, 'houseplan-obsolete.js', 'stale root entry');
git(root, ['add', '-A', 'component']);
git(root, ['commit', '-qm', 'orphan']);
unlinkSync(zip);
git(root, ['archive', '--format=zip', `--output=${zip}`, 'HEAD:component']);
assert.throws(
() => verifyHouseplanZip(zip, frontend, '1.2.3'),
/orphan bundle asset: frontend\/houseplan-obsolete\.js/,
);
unlinkSync(join(frontend, 'houseplan-obsolete.js'));
write(frontend, 'houseplan-panel.js', 'tampered panel entry');
git(root, ['add', '-A', 'component']);
git(root, ['commit', '-qm', 'tamper']);
unlinkSync(zip);
git(root, ['archive', '--format=zip', `--output=${zip}`, 'HEAD:component']);
assert.throws(
() => verifyHouseplanZip(zip, frontend, '1.2.3'),
/frontend hash mismatch: houseplan-panel\.js/,
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
+29
View File
@@ -56,6 +56,35 @@ test('every mutant explains itself', () => {
assert.ok(MUTANTS.length >= 6, 'стартовый набор — шесть мутантов по дырам из #85');
});
test('#486 panel registration and cleanup protections have mutation witnesses', () => {
const ids = new Set(MUTANTS.map(({ id }) => id));
for (const id of [
'panel-registers-wrong-route',
'panel-registers-card-static-url',
'panel-module-url-loses-version',
'panel-becomes-admin-only',
'panel-bypasses-panel-custom-api',
'panel-static-legacy-boolean-claims-both-urls',
'panel-cleanup-drops-generation-guard',
'panel-cleanup-drops-identity-guard',
'panel-cleanup-uses-new-remove-keyword',
'panel-accepts-unverifiable-ownership',
]) {
assert.ok(ids.has(id), `${id}: отсутствует защитный свидетель #486`);
}
});
test('#486 bundle topology and both stale entries have mutation witnesses', () => {
const ids = new Set(MUTANTS.map(({ id }) => id));
for (const id of [
'entry-fallback-rewrite-skipped',
'panel-entry-fallback-rewrite-skipped',
'panel-entry-bypasses-card-graph',
]) {
assert.ok(ids.has(id), `${id}: отсутствует защитный свидетель bundle-контракта #486`);
}
});
test('applyPatches rewrites the anchor and refuses a stale one', () => {
const dir = mkdtempSync(join(tmpdir(), 'hp-mg-'));
try {
+3
View File
@@ -61,6 +61,9 @@ test('full performance is isolated to stable, scheduled and manual entry points'
const release = readWorkflow('release.yml');
assert.ok(release.includes('if: ${{ !github.event.release.prerelease }}'));
assert.ok(release.includes('--workflow=performance.yml --label="Полные бенчмарки производительности"'));
assert.ok(release.includes('test -s dist/houseplan-panel.js'));
assert.ok(release.includes('files: dist/houseplan-card.js'),
'the standalone release asset remains card-only; the panel ships through HACS zip');
});
test('#160 Stage 3 dense fixture extends rather than mutates the historical witness', () => {
+1
View File
@@ -226,6 +226,7 @@ test('manual publish workflow is draft-first, exact-SHA gated and self-contained
'node scripts/release-gate.mjs',
'--draft --prerelease',
"'houseplan-card.js', 'houseplan.zip'",
'test -s dist/houseplan-panel.js',
'--draft=false --prerelease',
'Verify HACS prerelease discovery order',
'group: publish-prerelease-${{ inputs.tag }}',
+14 -3
View File
@@ -29,9 +29,20 @@ test('the tree carries exactly one *manifest.json: HACS rejects a repository wit
test('копия бандла для стенда не коммитится (#255)', () => {
const tracked = execFileSync('git', ['ls-files', '-z', 'demo/srv/assets'], { cwd: ROOT })
.toString('utf8').split('\0').filter(Boolean);
assert.ok(!tracked.includes('demo/srv/assets/houseplan-card.js'),
'demo/srv/assets/houseplan-card.js снова в индексе: соберите его `npm run bundle:sync`, '
+ 'а из коммита уберите');
const ignored = execFileSync('git', [
'check-ignore',
'demo/srv/assets/houseplan-card.js',
'demo/srv/assets/houseplan-panel.js',
], { cwd: ROOT }).toString('utf8').split(/\r?\n/).filter(Boolean);
assert.deepEqual(ignored, [
'demo/srv/assets/houseplan-card.js',
'demo/srv/assets/houseplan-panel.js',
], 'оба стабильных demo entry должны оставаться локальными build-артефактами');
for (const entry of ['houseplan-card.js', 'houseplan-panel.js']) {
assert.ok(!tracked.includes(`demo/srv/assets/${entry}`),
`demo/srv/assets/${entry} снова в индексе: соберите его \`npm run bundle:sync\`, `
+ 'а из коммита уберите');
}
// Остальное содержимое стенда (иконки, страницы) коммитится и должно остаться.
assert.ok(tracked.length > 0, 'каталог стенда пуст: проверьте, что убрали только бандл');
});