Files
houseplan-card/demo/helpers/ha-dialog-fixture.mjs
T
Claude 708ed850f3 docs(hygiene): снять чек-листы testing-notes и дизайн-материалы выпущенных задач (#681)
Волна 4 эпика #674.

docs/testing-notes/: восемь ручных чек-листов по поверхностям и индекс
удалены — ни одного отмеченного пункта, ручной фазы в PROCESS.md §2 нет.
Правило #650 (пустое совпадение --test-name-pattern) перенесено в
TESTING.md; пункты [manual] без автоматического свидетеля сведены в раздел
«Чего не проверяет автоматика» (реальный HA, сенсорное устройство, ресурсы
сервера, несколько клиентов, визуальная оценка, пользовательское
содержимое). В TESTING.md снят блок чек-листов v1.43–1.44 и приложения по
issue в разделе golden (#197/#249/#272/#275/#288/#261) — сцены объявлены в
demo/golden/matrix.mjs. Остался реестр браузерных гвардов #659
(mutation-browser-guards.md). test/testing-notes-index.test.mjs →
test/testing-doc.test.mjs: лимит 800 строк, правила #85, раздел ручных
проверок и живые ссылки TESTING.md; каталог testing-notes содержит только
реестр. Мутант testing-notes-index-drops-section (удалял строку индекса) →
testing-doc-drops-manual-section. golden-matrix: копия 67 id сцен #242/#250
в чек-листе снята, список и способ его измерения — в demo/golden/matrix.mjs.

docs/design/505-summary-panel удалён вместе с
demo/capture_summary_panel_505.mjs и маршрутом /reference/ фикстуры
диалога. docs/design/600-settings-dialogs: reference/, screenshots/,
pairs/, ARCHIVE-README, ISSUE-FORM, OPEN-POINTS удалены; SPEC,
IMPLEMENTATION-GUIDE, field-maps, ACCEPTANCE остаются; вывод
capture_design_pairs_600.mjs и verify_ha_form_shell_609.mjs --capture —
в artifacts/. docs/design: 68 файлов / 4,08 МБ → 13 / 0,70 МБ.
README-ha-dialog-505.md → README-ha-dialog.md (путь в release-review.yml);
demo/guard/README.md: запись гварда — в verify-guard.mjs, не в README.

docs/design/649-25d-stage6 не тронут — пункт после стабильного v1.78.0.

Issue: #681
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-27 21:27:57 +03:00

140 lines
8.3 KiB
JavaScript

/** #505: offline real HA dialog. No HA server, authentication or device access. */
import { createServer } from 'node:http';
import { readFileSync, existsSync, statSync } from 'node:fs';
import { dirname, extname, join, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
import { HA_DIALOG_PIN, prepareHaDialogAssets } from './ha-dialog-assets.mjs';
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript',
'.json': 'application/json', '.css': 'text/css', '.woff2': 'font/woff2', '.woff': 'font/woff',
'.svg': 'image/svg+xml', '.png': 'image/png' };
// Runs in the browser. core.* is deliberately absent: importing it starts auth.
async function bootstrap(pin) {
window.__ha505WebSockets ||= [];
window.WebSocket = class {
constructor(url) {
window.__ha505WebSockets.push(String(url));
void window.__ha505RecordWebSocket(String(url));
throw new Error('Production HA connections are forbidden in the #505 fixture');
}
};
window.hassConnection = new Promise(() => {});
window.frontendVersion = pin.version;
await import(`/frontend_latest/${pin.app}`);
const require = window.__ha505Require;
const index = await (await fetch('/ha505-module-index.json')).json();
const visited = new Set(), loaded = new Set();
async function prepare(id) {
if (visited.has(id)) return;
visited.add(id);
const info = index[id];
if (!info) {
if (!require.m[id]) throw new Error(`Missing official HA module ${id}`);
return;
}
if (!require.m[id] && !loaded.has(info.file)) {
const chunk = await import(`/frontend_latest/${info.file}`);
Object.assign(require.m, chunk.__webpack_modules__);
chunk.__rspack_esm_runtime?.(require);
loaded.add(info.file);
}
for (const dependency of info.dependencies) await prepare(dependency);
}
await prepare(pin.module);
await require(pin.module);
await customElements.whenDefined('ha-dialog');
window.__ha505Ready = { version: pin.version, chunks: [...loaded] };
}
/** Starts a synthetic loopback-only page; `authentic:false` keeps native hp-dialog. */
export async function launchHaDialogFixture({
authentic = true, viewport = { width: 1600, height: 1000 },
cacheDir, wheelPath, assets, colorScheme = 'light',
} = {}) {
const ha = authentic ? assets || await prepareHaDialogAssets({ cacheDir, wheelPath }) : null;
const errors = [], externalRequests = [], websocketAttempts = [];
const server = createServer((request, response) => {
let body, type = 'text/javascript';
try {
const pathname = decodeURIComponent(new URL(request.url, 'http://127.0.0.1').pathname);
if (pathname === '/ha505-bootstrap.mjs' && ha) body = `await (${bootstrap})(${JSON.stringify(HA_DIALOG_PIN)});`;
else if (pathname === '/ha505-probe.html' && ha) {
type = 'text/html';
body = '<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><script type="module">await import("/ha505-bootstrap.mjs"); const parent=document.createElement("section"); parent.id="parent"; const dialog=document.createElement("ha-dialog"); dialog.width="small"; dialog.headerTitle="Authentic HA dialog — offline diagnostic"; dialog.innerHTML="<p>Official pinned component; synthetic content; no HA server.</p>"; parent.append(dialog); document.body.append(parent); dialog.open=true;</script>';
}
else if (pathname === '/ha505-module-index.json' && ha) {
body = JSON.stringify(ha.manifest.modules); type = 'application/json';
} else {
let base = join(REPO, 'demo/srv'), relative = pathname;
if (pathname === '/product.html') relative = '/demo.html';
if (ha && (pathname.startsWith('/frontend_latest/') || pathname.startsWith('/static/'))) base = ha.root;
const file = resolve(base, '.' + relative);
if (!file.startsWith(resolve(base) + sep) || !existsSync(file) || !statSync(file).isFile()) {
response.writeHead(404).end('Not found'); return;
}
body = readFileSync(file);
type = MIME[extname(file)] || 'application/octet-stream';
if (ha && pathname === `/frontend_latest/${HA_DIALOG_PIN.app}`) {
const source = body.toString();
if (source.split(HA_DIALOG_PIN.entry).length !== 2) throw new Error('Pinned HA app bootstrap changed');
// The sole runtime instrumentation: expose its loader. The original
// wheel and every component factory/CSS remain byte-for-byte intact.
body = source.replace(HA_DIALOG_PIN.entry, `window.__ha505Require=o;${HA_DIALOG_PIN.entry}`);
}
if (ha && pathname === '/product.html') {
const original = body.toString();
if (!/<script>\r?\nclass HaIcon/.test(original) || !/<script type="module">\r?\nconst CFG/.test(original)) {
throw new Error('Synthetic demo bootstrap changed; update the explicit fixture adapter');
}
body = original
.replace(/<script>\r?\nclass HaIcon/, '<script type="module">\nawait import("/ha505-bootstrap.mjs");\nclass HaIcon')
.replace("customElements.define('ha-icon',HaIcon);", "if(!customElements.get('ha-icon'))customElements.define('ha-icon',HaIcon);")
.replace("customElements.define('ha-card',HaCard);", "if(!customElements.get('ha-card'))customElements.define('ha-card',HaCard);")
.replace(/<script type="module">\r?\nconst CFG/, '<script type="module">\nawait import("/ha505-bootstrap.mjs");\nconst CFG');
}
}
response.writeHead(200, { 'content-type': type.startsWith('text/') ? `${type}; charset=utf-8` : type, 'cache-control': 'no-store',
'content-security-policy': "default-src 'self' data: blob:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self' data:; img-src 'self' data: blob:; worker-src 'none'" }).end(body);
} catch (error) { errors.push(String(error)); response.writeHead(500).end('Diagnostic fixture error'); }
});
await new Promise((accept, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', accept); });
const url = `http://127.0.0.1:${server.address().port}`;
let browser;
try {
browser = await chromium.launch();
const context = await browser.newContext({ viewport, deviceScaleFactor: 1, colorScheme, timezoneId: 'Europe/Moscow', serviceWorkers: 'block' });
await context.route('**/*', async (route) => {
if (new URL(route.request().url()).origin !== url) {
externalRequests.push(route.request().url()); await route.abort();
} else await route.continue();
});
await context.exposeBinding('__ha505RecordWebSocket', (_, address) => websocketAttempts.push(address));
await context.exposeBinding('__ha505RecordPolicyViolation', (_, violation) => errors.push(`CSP blocked attempted request: ${violation}`));
await context.addInitScript(() => {
window.__ha505WebSockets = [];
window.WebSocket = class { constructor(url) { window.__ha505WebSockets.push(String(url)); void window.__ha505RecordWebSocket(String(url)); throw new Error('WebSocket forbidden in offline fixture'); } };
document.addEventListener('securitypolicyviolation', (event) => {
void window.__ha505RecordPolicyViolation(`${event.effectiveDirective}: ${event.blockedURI}`);
});
});
const page = await context.newPage();
page.on('pageerror', (error) => errors.push(error.stack || String(error)));
return { page, browser, context, url, errors, externalRequests, websocketAttempts,
provenance: ha ? { version: ha.manifest.version, sha256: ha.manifest.sha256,
runtimeExposure: 'App loader exposed; genuine component factories/CSS unmodified; no core/auth/HA host' } : null,
async assertClean() {
websocketAttempts.push(...await page.evaluate(() => window.__ha505WebSockets || []));
if (errors.length || externalRequests.length || websocketAttempts.length) {
throw new Error(JSON.stringify({ errors, externalRequests, websocketAttempts }));
}
},
async close() { await browser.close(); await new Promise((accept) => server.close(accept)); },
};
} catch (error) {
await browser?.close(); await new Promise((accept) => server.close(accept)); throw error;
}
}