382 lines
17 KiB
JavaScript
382 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* Browser regression check for UI assets that must remain local.
|
|
*
|
|
* This deliberately builds a throwaway Vite app from the production
|
|
* components. It does not inspect source strings: Chromium must load the
|
|
* bundled fonts, Monaco worker, and Leaflet marker images, edit and save a
|
|
* file, and render a map. OSM tiles are intercepted and fulfilled locally;
|
|
* every other cross-origin request is treated as a failure.
|
|
*/
|
|
|
|
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { dirname, join, resolve } from 'node:path';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
import { createRequire } from 'node:module';
|
|
|
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|
process.env.BROWSERSLIST_IGNORE_OLD_DATA = 'true';
|
|
const repoRoot = resolve(scriptDir, '../..');
|
|
const uiRoot = join(repoRoot, 'ui');
|
|
const cspMode = process.argv.includes('--csp');
|
|
const uiNodeModules = join(uiRoot, 'node_modules');
|
|
|
|
const importFrom = async (path) => import(pathToFileURL(path).href);
|
|
const vite = await importFrom(join(uiNodeModules, 'vite/dist/node/index.js'));
|
|
const reactPluginModule = await importFrom(join(uiNodeModules, '@vitejs/plugin-react/dist/index.js'));
|
|
const require = createRequire(import.meta.url);
|
|
const playwrightModule = await importFrom(require.resolve('playwright'));
|
|
const { chromium } = playwrightModule.chromium ? playwrightModule : playwrightModule.default;
|
|
const reactPlugin = reactPluginModule.default;
|
|
|
|
const localModule = (name) => join(uiNodeModules, name);
|
|
const MONACO = join(uiRoot, 'src/components/userfolder/MonacoFileEditor.tsx');
|
|
const MAP = join(uiRoot, 'src/components/embed/MapPlacesDetail.tsx');
|
|
const CSS = join(uiRoot, 'src/index.css');
|
|
const POSTCSS = join(uiRoot, 'postcss.config.js');
|
|
|
|
class ExternalRequestDetected extends Error {
|
|
constructor(urls) {
|
|
super(`external requests blocked: ${urls.join(', ')}`);
|
|
this.name = 'ExternalRequestDetected';
|
|
}
|
|
}
|
|
|
|
const EXPECTED_CDN_URL = 'https://fonts.googleapis.com/css2?family=Inter:wght@400&display=swap';
|
|
|
|
function assertNoExternal(urls) {
|
|
if (urls.length > 0) throw new ExternalRequestDetected(urls);
|
|
}
|
|
|
|
const TILE_PNG = Buffer.from(
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
|
'base64',
|
|
);
|
|
|
|
function fixtureSource() {
|
|
return `<!doctype html>
|
|
<html lang="ja">
|
|
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<style>#root{min-height:900px}</style>
|
|
</head>
|
|
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
|
|
</html>`;
|
|
}
|
|
|
|
function fixtureAppSource() {
|
|
return `import React, { useState } from 'react';
|
|
import { createRoot } from 'react-dom/client';
|
|
import { I18nextProvider } from 'react-i18next';
|
|
import i18next from 'i18next';
|
|
import { MonacoFileEditor } from ${JSON.stringify(MONACO)};
|
|
import { MapPlacesDetail } from ${JSON.stringify(MAP)};
|
|
import ${JSON.stringify(CSS)};
|
|
import jaUserfolder from ${JSON.stringify(join(uiRoot, "src/i18n/locales/ja/userfolder.json"))};
|
|
import enUserfolder from ${JSON.stringify(join(uiRoot, "src/i18n/locales/en/userfolder.json"))};
|
|
|
|
const i18n = i18next.createInstance();
|
|
await i18n.init({
|
|
lng: 'ja',
|
|
fallbackLng: 'en',
|
|
resources: {
|
|
ja: { userfolder: jaUserfolder, embed: { searchResults: { map: '地図検索結果: 「{{query}}」' }, viewOn: { osm: 'OpenStreetMap で開く' } } },
|
|
en: { userfolder: enUserfolder, embed: { searchResults: { map: 'Map search results: “{{query}}”' }, viewOn: { osm: 'Open in OpenStreetMap' } } },
|
|
},
|
|
});
|
|
|
|
const MAP_DATA = {
|
|
query: '東京駅',
|
|
places: [
|
|
{ name: '東京駅', address: '東京都千代田区', lat: 35.681236, lon: 139.767125, type: 'station', details: '中央駅', mapUrl: 'https://www.openstreetmap.org/?mlat=35.681236&mlon=139.767125#map=16/35.681236/139.767125' },
|
|
{ name: '皇居', address: '東京都千代田区', lat: 35.685175, lon: 139.752799, type: 'park', details: '公園', mapUrl: 'https://www.openstreetmap.org/' },
|
|
],
|
|
};
|
|
|
|
function App() {
|
|
const [saved, setSaved] = useState('');
|
|
const [dirty, setDirty] = useState(false);
|
|
return <I18nextProvider i18n={i18n}>
|
|
<main style={{ minHeight: '900px', padding: 16 }}>
|
|
<h1>Local UI asset probe</h1>
|
|
<div data-font="ibm-sans" style={{ fontFamily: '"IBM Plex Sans JP"' }}>日本語フォント</div>
|
|
<div data-font="ibm-mono" style={{ fontFamily: '"IBM Plex Mono"' }}>IBM mono 0123</div>
|
|
<div data-font="jetbrains" style={{ fontFamily: '"JetBrains Mono"' }}>JetBrains mono 0123</div>
|
|
<section style={{ height: 300, border: '1px solid #ddd' }}>
|
|
<MonacoFileEditor
|
|
subdir="agents-md"
|
|
filename="AGENTS.md"
|
|
content="# original\\n"
|
|
mtime="2026-01-01T00:00:00.000Z"
|
|
size={12}
|
|
onSave={async (value) => { setSaved(value); await new Promise(resolve => setTimeout(resolve, 150)); }}
|
|
onDirtyChange={setDirty}
|
|
/>
|
|
</section>
|
|
<output data-testid="dirty-state">{String(dirty)}</output>
|
|
<output data-testid="save-result">{saved}</output>
|
|
<MapPlacesDetail data={MAP_DATA} />
|
|
</main>
|
|
</I18nextProvider>;
|
|
}
|
|
|
|
createRoot(document.getElementById('root')).render(<App />);
|
|
`;
|
|
}
|
|
|
|
async function makeFixture() {
|
|
const root = await mkdtemp(join(tmpdir(), 'maestro-local-ui-assets-'));
|
|
await mkdir(join(root, 'src'), { recursive: true });
|
|
await writeFile(join(root, 'index.html'), fixtureSource());
|
|
await writeFile(join(root, 'src/main.tsx'), fixtureAppSource());
|
|
return root;
|
|
}
|
|
|
|
async function buildFixture(root, outDir) {
|
|
const previousCwd = process.cwd();
|
|
process.chdir(uiRoot);
|
|
try {
|
|
await vite.build({
|
|
configFile: false,
|
|
root,
|
|
base: '/ui/',
|
|
plugins: [reactPlugin()],
|
|
resolve: {
|
|
dedupe: ['react', 'react-dom'],
|
|
alias: [
|
|
{ find: /^react$/, replacement: localModule('react') },
|
|
{ find: /^react\/(.*)$/, replacement: `${localModule('react')}/$1` },
|
|
{ find: /^react-dom$/, replacement: localModule('react-dom') },
|
|
{ find: /^react-dom\/(.*)$/, replacement: `${localModule('react-dom')}/$1` },
|
|
{ find: /^react-i18next$/, replacement: localModule('react-i18next') },
|
|
{ find: /^i18next$/, replacement: localModule('i18next') },
|
|
],
|
|
},
|
|
css: { postcss: POSTCSS },
|
|
logLevel: 'silent',
|
|
build: {
|
|
outDir,
|
|
emptyOutDir: true,
|
|
rollupOptions: { input: join(root, 'index.html') },
|
|
},
|
|
});
|
|
} finally {
|
|
process.chdir(previousCwd);
|
|
}
|
|
}
|
|
|
|
async function closePreview(previewInfo) {
|
|
const preview = previewInfo?.preview ?? previewInfo;
|
|
if (!preview) return;
|
|
if (typeof preview.close === 'function') {
|
|
await preview.close();
|
|
return;
|
|
}
|
|
if (preview.httpServer) {
|
|
await new Promise((resolveClose) => preview.httpServer.close(resolveClose));
|
|
}
|
|
}
|
|
|
|
async function runPreview(root) {
|
|
const headers = {};
|
|
if (cspMode) {
|
|
const { buildBrowserCsp } = await importFrom(join(repoRoot, 'dist/bridge/egress-csp.js'));
|
|
headers['Content-Security-Policy'] = buildBrowserCsp({
|
|
mode: 'enforce', connectOrigins: [], imageOrigins: ['https://tile.openstreetmap.org'], frameOrigins: [],
|
|
});
|
|
}
|
|
const preview = await vite.preview({
|
|
configFile: false,
|
|
root,
|
|
base: '/ui/',
|
|
preview: { host: '127.0.0.1', port: 0, strictPort: false, headers },
|
|
});
|
|
const address = preview.httpServer.address();
|
|
if (!address || typeof address === 'string') throw new Error('Vite preview did not expose a TCP address');
|
|
return { preview, origin: `http://127.0.0.1:${address.port}` };
|
|
}
|
|
|
|
async function browserCheck() {
|
|
const root = await makeFixture();
|
|
const outDir = join(root, 'dist');
|
|
let preview;
|
|
let browser;
|
|
let context;
|
|
try {
|
|
browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'] });
|
|
context = await browser.newContext({ serviceWorkers: 'block' });
|
|
await buildFixture(root, outDir);
|
|
preview = await runPreview(root);
|
|
const page = await context.newPage();
|
|
const requests = [];
|
|
const external = [];
|
|
const osm = [];
|
|
const localFailures = [];
|
|
const pageErrors = [];
|
|
const consoleErrors = [];
|
|
const workerResponses = [];
|
|
const local = new URL(preview.origin);
|
|
context.on('requestfailed', (request) => {
|
|
const url = new URL(request.url());
|
|
if (url.origin === local.origin) localFailures.push(`request failed ${request.url()}: ${request.failure()?.errorText ?? 'unknown'}`);
|
|
});
|
|
context.on('response', (response) => {
|
|
const url = new URL(response.url());
|
|
if (url.origin === local.origin && response.status() >= 400) {
|
|
localFailures.push(`HTTP ${response.status()} ${response.url()}`);
|
|
}
|
|
if (url.origin === local.origin && /(?:worker|\.worker)[^/]*\.js/i.test(url.pathname) && response.ok()) {
|
|
workerResponses.push(response.url());
|
|
}
|
|
});
|
|
page.on('pageerror', (error) => pageErrors.push(String(error)));
|
|
page.on('console', (message) => {
|
|
if (message.type() === 'error') consoleErrors.push(message.text());
|
|
});
|
|
// This static fixture needs no WebSocket connection. Intercept before any
|
|
// server connection so a future dependency cannot bypass the HTTP guard.
|
|
await context.routeWebSocket('**/*', socket => {
|
|
external.push(socket.url());
|
|
socket.close();
|
|
});
|
|
await context.route('**/*', async (route) => {
|
|
const url = new URL(route.request().url());
|
|
requests.push(url.toString());
|
|
if (url.origin === local.origin) return route.continue();
|
|
if (url.hostname === 'tile.openstreetmap.org') {
|
|
osm.push(url.toString());
|
|
return route.fulfill({ status: 200, contentType: 'image/png', body: TILE_PNG });
|
|
}
|
|
external.push(url.toString());
|
|
return route.abort('blockedbyclient');
|
|
});
|
|
await page.goto(`${preview.origin}/ui/`, { waitUntil: 'domcontentloaded' });
|
|
assertNoExternal(external);
|
|
if (localFailures.length > 0 || pageErrors.length > 0 || consoleErrors.length > 0) {
|
|
throw new Error(`local UI asset failures: ${JSON.stringify({ localFailures, pageErrors, consoleErrors })}`);
|
|
}
|
|
await page.locator('[data-testid="monaco-file-editor"]').waitFor();
|
|
await page.locator('.leaflet-container').waitFor();
|
|
try {
|
|
await page.waitForFunction(() => Boolean(document.querySelector('.monaco-editor [role="textbox"]') || document.querySelector('[data-testid="monaco-load-error"]')), undefined, { timeout: 45000 });
|
|
} catch (error) {
|
|
const editorState = await page.locator('[data-testid="monaco-file-editor"]').evaluate(element => ({ html: element.innerHTML, text: element.textContent }));
|
|
throw new Error(`Monaco did not mount: ${JSON.stringify({ editorState, localFailures, pageErrors, consoleErrors, requests: requests.slice(-30) })}`, { cause: error });
|
|
}
|
|
if (await page.locator('[data-testid="monaco-load-error"]').count() > 0) {
|
|
throw new Error(`Monaco lazy load failed: ${JSON.stringify({ localFailures, pageErrors, consoleErrors })}`);
|
|
}
|
|
await page.waitForFunction(() => document.fonts.status === 'loaded', undefined, { timeout: 45000 });
|
|
await page.waitForTimeout(500);
|
|
|
|
const fontFamilies = await page.evaluate(async () => {
|
|
const names = ['IBM Plex Sans JP', 'IBM Plex Mono', 'JetBrains Mono'];
|
|
await Promise.all(names.map(name => document.fonts.load(`16px "${name}"`)));
|
|
return Object.fromEntries(names.map(name => [name, document.fonts.check(`16px "${name}"`)]));
|
|
});
|
|
if (Object.values(fontFamilies).some(value => value !== true)) {
|
|
throw new Error(`font faces did not load: ${JSON.stringify(fontFamilies)}`);
|
|
}
|
|
const fontRequests = requests.filter(url => /(?:plex|jetbrains)/i.test(url) && /\.(?:woff2?|ttf)(?:[?#]|$)/i.test(url));
|
|
if (new Set(fontRequests.map(url => /ibm-plex-sans-jp|ibm-plex-mono|jetbrains-mono/i.exec(url)?.[0])).size < 3) {
|
|
throw new Error(`expected all three local font families, got: ${fontRequests.join(', ')}`);
|
|
}
|
|
|
|
const editor = page.locator('.monaco-editor [role="textbox"]').first();
|
|
await editor.waitFor();
|
|
await editor.focus();
|
|
await page.keyboard.press('ControlOrMeta+A');
|
|
await page.keyboard.type('# edited locally\\n');
|
|
await page.getByTestId('monaco-save').click();
|
|
// onSave can report persisted content before the child's dirty-state
|
|
// notification reaches the parent. Wait for both observable outcomes.
|
|
await page.waitForFunction(() =>
|
|
document.querySelector('[data-testid="save-result"]')?.textContent?.includes('edited locally') &&
|
|
document.querySelector('[data-testid="dirty-state"]')?.textContent === 'false');
|
|
|
|
await page.waitForFunction(() => document.querySelectorAll('.leaflet-marker-icon').length === 2);
|
|
const markerCount = await page.locator('.leaflet-marker-icon').count();
|
|
if (markerCount !== 2) throw new Error(`expected two rendered map markers, got ${markerCount}`);
|
|
await page.waitForFunction(() => [...document.querySelectorAll('.leaflet-marker-icon')].every(element => element instanceof HTMLImageElement && element.naturalWidth > 0 && element.naturalHeight > 0), undefined, { timeout: 30000 });
|
|
const markerSources = await page.locator('.leaflet-marker-icon').evaluateAll(elements => elements.map(element => element.getAttribute('src') ?? ''));
|
|
const markerUrls = markerSources.map((src) => src.startsWith('data:') ? src : new URL(src, `${local.origin}/ui/`).href);
|
|
if (markerUrls.some(url => !url.startsWith(`${local.origin}/ui/`) && !url.startsWith('data:image/'))) {
|
|
throw new Error(`marker image escaped same-origin asset path: ${markerUrls.join(', ')}`);
|
|
}
|
|
const markerSizes = await page.locator('.leaflet-marker-icon').evaluateAll(elements => elements.map(element => ({
|
|
width: element instanceof HTMLImageElement ? element.naturalWidth : 0,
|
|
height: element instanceof HTMLImageElement ? element.naturalHeight : 0,
|
|
})));
|
|
if (markerSizes.some(size => size.width <= 0 || size.height <= 0)) {
|
|
throw new Error(`marker images did not decode: ${JSON.stringify(markerSizes)}`);
|
|
}
|
|
if (osm.length === 0) throw new Error('Leaflet did not request an OSM tile');
|
|
if (workerResponses.length === 0) {
|
|
await context.waitForEvent('response', {
|
|
predicate: response => {
|
|
const url = new URL(response.url());
|
|
return url.origin === local.origin && /(?:worker|\.worker)[^/]*\.js/i.test(url.pathname) && response.ok();
|
|
},
|
|
timeout: 10000,
|
|
});
|
|
}
|
|
assertNoExternal(external);
|
|
if (localFailures.length > 0 || pageErrors.length > 0 || consoleErrors.length > 0) {
|
|
throw new Error(`local UI asset failures: ${JSON.stringify({ localFailures, pageErrors, consoleErrors })}`);
|
|
}
|
|
const positive = {
|
|
requests: [...requests], external: [], osm: [...osm], fontRequests,
|
|
markerSources: markerUrls.map(url => url.startsWith('data:') ? 'data:image' : new URL(url).pathname),
|
|
};
|
|
|
|
// Inject only after the real components finish loading: this exercises
|
|
// the final guard, including requests that start after DOMContentLoaded.
|
|
await page.evaluate(url => {
|
|
document.addEventListener('securitypolicyviolation', event => {
|
|
if ((event.blockedURI === url || event.blockedURI === new URL(url).origin) && event.effectiveDirective === 'style-src-elem' && event.disposition === 'enforce') {
|
|
document.body.dataset.cspProbeBlocked = 'true';
|
|
}
|
|
});
|
|
const link = document.createElement('link');
|
|
link.rel = 'stylesheet';
|
|
link.href = url;
|
|
link.onload = link.onerror = () => { document.body.dataset.cdnProbeFinished = 'true'; };
|
|
document.head.append(link);
|
|
}, EXPECTED_CDN_URL);
|
|
await page.waitForFunction(() => document.body.dataset.cdnProbeFinished === 'true', undefined, { timeout: 10000 });
|
|
|
|
let negativeCaught = false;
|
|
if (cspMode) {
|
|
await page.waitForFunction(() => document.body.dataset.cspProbeBlocked === 'true', undefined, { timeout: 10000 });
|
|
assertNoExternal(external);
|
|
negativeCaught = true;
|
|
} else try {
|
|
assertNoExternal(external);
|
|
} catch (error) {
|
|
if (!(error instanceof ExternalRequestDetected && external.length === 1 &&
|
|
external[0] === EXPECTED_CDN_URL)) throw error;
|
|
negativeCaught = true;
|
|
}
|
|
if (!negativeCaught) throw new Error('external CDN negative fixture was not rejected by the browser network guard');
|
|
return { ...positive, negativeExternalCdnDetected: negativeCaught };
|
|
} finally {
|
|
await context?.close();
|
|
await browser?.close();
|
|
await closePreview(preview);
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
const positive = await browserCheck();
|
|
|
|
console.log(JSON.stringify({
|
|
ok: true,
|
|
cspEnforced: cspMode,
|
|
sameOriginRequests: positive.requests.length - positive.external.length - positive.osm.length,
|
|
localFontFiles: positive.fontRequests.length,
|
|
osmTilesMocked: positive.osm.length,
|
|
markerSources: positive.markerSources,
|
|
negativeExternalCdnDetected: positive.negativeExternalCdnDetected,
|
|
}, null, 2));
|