Files
houseplan-card/test/bundle-freshness.test.mjs
T
Claudeandclaude[bot] 063de0eef4 Let the panel reach the card by its hashed name, not by the one address without a version
The sidebar page could serve a previous card for hours. It imported the
card through the stable facade, `./houseplan-card.js` — a relative
specifier, and relative resolution does not inherit a query. A dashboard
reaches the same file as `houseplan-card.js?v=1.74.0`, so an upgrade
changes its URL and the browser must refetch. The panel always asked for
the same address, and entries are served with no Cache-Control at all —
only ETag and Last-Modified — so the browser applies heuristic freshness
and may answer from cache without asking. A stale 1164-byte loader names
a stale chunk, chunks are immutable for a year, and the panel then ran a
previous card against the current backend without a single error. The
version banner was telling the truth; reloading could not help, because
the address never changed.

Rollup already emits the right edge: the panel's side-effect import
points straight at the shared implementation. The rewrite in
entryFallbackPlugin replaced it with the facade for a fallback that the
hashed name gives anyway — and better: a chunk the manifest no longer
serves now raises the panel's own "House Plan was updated" notice
instead of silently working on old code.

Two #486 assertions change meaning and are rewritten, not adjusted: the
panel no longer routes through the facade, and its initial graph no
longer contains it. The invariant they defended — the panel reuses the
exact card graph, never a second copy — is now stated over the
implementation, and a new test pins that no built entry reaches the card
by an address without a version.

Issue: #535
User-Visible: yes
2026-09-11 22:12:11 +00:00

198 lines
8.0 KiB
JavaScript

import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { resolve } from 'node:path';
import test from 'node:test';
import {
ALLOW_STALE_BUNDLE,
assertFreshDemoBundle,
assertFreshDemoBundleUnlessAllowed,
} from '../demo/bundle-freshness.mjs';
import { sourceFingerprint } from '../scripts/source-fingerprint.mjs';
const fixtureRoot = () => {
const root = mkdtempSync(resolve(tmpdir(), 'houseplan-bundle-freshness-'));
mkdirSync(resolve(root, 'src'), { recursive: true });
writeFileSync(resolve(root, 'src/card.ts'), 'export const card = true;\n', 'utf8');
return root;
};
test('bundle freshness accepts the exact embedded build fingerprint', async () => {
const root = fixtureRoot();
try {
const expected = sourceFingerprint(root);
const page = { evaluate: async () => expected };
assert.equal(await assertFreshDemoBundle(page, root), expected);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('bundle freshness rejects a missing or stale fingerprint', async () => {
const root = fixtureRoot();
try {
await assert.rejects(
assertFreshDemoBundle({ evaluate: async () => null }, root),
/stale.*Expected.*no fingerprint/is,
);
await assert.rejects(
assertFreshDemoBundle({ evaluate: async () => 'stale' }, root),
/stale.*Expected/is,
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
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');
const manifest = {
schema: 1,
fingerprint: expected,
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-panel.js',
],
initialPanelGzipBytes: 10,
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',
})),
};
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 });
}
});
test('bundle freshness uses the target tree fingerprint contract', async () => {
const root = fixtureRoot();
try {
mkdirSync(resolve(root, 'scripts'), { recursive: true });
writeFileSync(
resolve(root, 'scripts/source-fingerprint.mjs'),
"export const sourceFingerprint = () => 'legacy-tree-fingerprint';\n",
'utf8',
);
const page = { evaluate: async () => 'legacy-tree-fingerprint' };
assert.equal(await assertFreshDemoBundle(page, root), 'legacy-tree-fingerprint');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('bundle freshness includes deterministic golden harness inputs (#244)', () => {
const root = fixtureRoot();
try {
const golden = resolve(root, 'demo/golden');
mkdirSync(golden, { recursive: true });
writeFileSync(resolve(golden, 'matrix.mjs'), 'export const version = 1;\n', 'utf8');
const before = sourceFingerprint(root);
writeFileSync(resolve(golden, 'matrix.mjs'), 'export const version = 2;\n', 'utf8');
assert.notEqual(sourceFingerprint(root), before);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('the launcher gate refuses a stale bundle by default (#236)', async () => {
// Смок против несвежего бандла не падает честно: на #234 три проверки
// покраснели, а четвёртая ПРОШЛА, потому что старый код одинаково врал в двух
// местах, которые сверялись друг с другом. Такой результат читается как
// дефект логики и отправляет искать причину не туда.
const root = fixtureRoot();
try {
const stale = { evaluate: async () => 'fingerprint-of-an-older-tree' };
await assert.rejects(
() => assertFreshDemoBundleUnlessAllowed(stale, root, {}),
/stale/,
);
const fresh = { evaluate: async () => sourceFingerprint(root) };
assert.equal(
await assertFreshDemoBundleUnlessAllowed(fresh, root, {}),
sourceFingerprint(root),
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('the escape hatch skips the gate but never silently (#236)', async () => {
const root = fixtureRoot();
const warnings = [];
const realWarn = console.warn;
console.warn = (...args) => warnings.push(args.join(' '));
try {
const stale = { evaluate: async () => 'stale' };
const result = await assertFreshDemoBundleUnlessAllowed(
stale, root, { [ALLOW_STALE_BUNDLE]: '1' },
);
assert.equal(result, null, 'пропуск возвращает null, а не выдуманный фингерпринт');
assert.equal(warnings.length, 1, 'пропуск обязан быть слышен');
assert.match(warnings[0], new RegExp(ALLOW_STALE_BUNDLE));
assert.match(warnings[0], /#236/);
} finally {
console.warn = realWarn;
rmSync(root, { recursive: true, force: true });
}
});
test('the smoke launcher enforces the gate on the repository root (#236)', async () => {
// Контракт места вызова: гейт живёт в общем лаунчере, поэтому защита есть у
// всех ~128 смоков без правки каждого. И считает фингерпринт по корню
// репозитория, а не по каталогу раздачи demo/srv, где нет src/**.
const source = readFileSync(
new URL('../demo/serve.mjs', import.meta.url), 'utf8',
);
assert.match(source, /repoRoot = REPO_ROOT/);
assert.match(source, /assertFreshDemoBundleUnlessAllowed\(page, repoRoot\)/);
assert.match(source, /const REPO_ROOT = dirname\(dirname\(/);
});
test('comparative benchmarks pass the target repository root to the launcher', () => {
for (const file of ['benchmark_large_house.mjs', 'benchmark_glow.mjs']) {
const source = readFileSync(new URL(`../demo/${file}`, import.meta.url), 'utf8');
assert.match(source, /resolve\(targetRoot, 'demo\/srv'\)[\s\S]{0,40}targetRoot/);
}
});