refactor(frontend): establish source ownership and architecture gates (#6701)

This commit is contained in:
Alexandre Teixeira
2026-10-13 21:03:48 +01:00
committed by GitHub
parent b5b76fe46d
commit 1cc56cb9d7
18 changed files with 1955 additions and 3 deletions
+142
View File
@@ -0,0 +1,142 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { allowsDependency, imports, checkArchitecture, inventory } from '../scripts/check_frontend.mjs';
import * as source from '../frontend/src/shared/actionMenuOrder.js';
import * as deployed from '../static/js/actionMenuOrder.js';
test('stateless helper preserves its public API, ordering, references and inputs', () => {
for (const module of [source, deployed]) {
assert.deepEqual(Object.keys(module).sort(), ['SELECT_MENU_ICON', 'actionMenuRank', 'orderActionMenuItems']);
const callback = () => {};
const items = ['Delete', 'Select', 'Copy', 'Rename', 'Archive', 'Open', 'Cancel'].map(label => Object.freeze({ label, action: callback }));
Object.freeze(items);
const sorted = module.orderActionMenuItems(items);
assert.deepEqual(sorted.map(i => i.label), ['Open', 'Rename', 'Copy', 'Select', 'Archive', 'Delete', 'Cancel']);
assert.ok(sorted.every(i => items.includes(i)));
assert.deepEqual(module.orderActionMenuItems([]), []);
assert.equal(module.actionMenuRank({ menuOrder: 12, label: 'Delete' }), 12);
assert.equal(module.actionMenuRank({ label: 'Unknown' }), 100);
const equalRank = [{ label: 'Open first' }, { label: 'Open second' }];
assert.deepEqual(module.orderActionMenuItems(equalRank), equalRank);
}
assert.equal(source.SELECT_MENU_ICON, deployed.SELECT_MENU_ICON);
});
test('dependency direction separates composition, workflow, adapters and views', () => {
const n = name => `features/notes/${name}.js`;
const allowed = [
['app/bootstrap.js', n('index')],
[n('index'), n('infrastructure/api')],
[n('application/crud'), n('domain/recurrence')],
[n('infrastructure/api'), 'shared/platform/http.js'],
[n('ui/panel'), n('application/crud')],
[n('application/reminders'), 'features/calendar/index.js'],
];
for (const [from, to] of allowed) assert.ok(allowsDependency(from, to), `${from} -> ${to}`);
const forbidden = [
[n('domain/recurrence'), n('infrastructure/api')],
[n('application/crud'), n('ui/panel')],
[n('application/crud'), n('infrastructure/api')],
[n('ui/panel'), 'features/calendar/index.js'],
[n('application/crud'), 'features/calendar/application/store.js'],
['app/bootstrap.js', n('domain/recurrence')],
['shared/platform/http.js', n('index')],
[n('domain/recurrence'), 'app/bootstrap.js'],
];
for (const [from, to] of forbidden) assert.equal(allowsDependency(from, to), false, `${from} -> ${to}`);
});
test('parser sees re-exports, multiline and dynamic imports without comment false positives', () => {
const code = `// import './false.js';
const text = "import './false.js'";
import { a }\n from './a.js';
export { b } from './b.js';
export * from './c.js';
const load = () => import('./d.js');`;
assert.deepEqual(imports(code).map(i => i.specifier), ['./a.js', './b.js', './c.js', './d.js']);
assert.throws(() => imports('const load = p => import(p);'), /computed import/);
assert.throws(() => imports('export const now = () => Date.now();', { domain: true }), /ambient clock/);
assert.throws(() => imports('export const load = () => fetch("/api/notes");', { domain: true }), /platform identifier/);
assert.doesNotThrow(() => imports('export const day = value => new Date(value);', { domain: true }));
assert.doesNotThrow(() => imports('export const copy = value => ({ document: value.document });', { domain: true }));
});
function fixture(t, modules, { cache = true } = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ody-frontend-gates-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const entries = Object.entries(modules).map(([name, code]) => {
const file = path.join(root, 'frontend/src', name);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, code);
return { source: name, deployment: `js/${name}` };
});
fs.mkdirSync(path.join(root, 'static'), { recursive: true });
fs.writeFileSync(path.join(root, 'frontend/modules.json'), JSON.stringify({ version: 1, modules: entries }));
const urls = cache ? entries.map(m => `/static/${m.deployment}`) : [];
fs.writeFileSync(path.join(root, 'static/sw.js'), `const PRECACHE = ${JSON.stringify(urls)}; const PANEL_PRECACHE = [];`);
return root;
}
test('complete mapped graph and exact precache pass', t => {
const root = fixture(t, { 'features/notes/index.js': "export { value } from './domain/value.js';", 'features/notes/domain/value.js': 'export const value = 1;' });
assert.equal(checkArchitecture(root), 2);
});
test('feature-private import fails through the full checker', t => {
const root = fixture(t, { 'features/notes/application/a.js': "import '../../calendar/domain/b.js';", 'features/calendar/domain/b.js': 'export const value = 1;' });
assert.throws(() => checkArchitecture(root), /forbidden dependency/);
});
test('undeclared legacy coupling and alternate identities fail', t => {
for (const specifier of ['/static/js/notes.js?v=legacy', './b.js?v=other', '/frontend/src/shared/b.js', 'some-package']) {
const root = fixture(t, { 'shared/a.js': `import '${specifier}';`, 'shared/b.js': 'export const value = 1;' });
assert.throws(() => checkArchitecture(root), /legacy import|one unversioned|unsupported|canonical static/);
}
});
test('a copy mapping cannot silently change relative import resolution', t => {
const root = fixture(t, { 'shared/a.js': "import './b.js';", 'shared/b.js': 'export const value = 1;' });
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'frontend/modules.json')));
manifest.modules[1].deployment = 'js/elsewhere/b.js';
fs.writeFileSync(path.join(root, 'frontend/modules.json'), JSON.stringify(manifest));
fs.writeFileSync(path.join(root, 'static/sw.js'), "const PRECACHE = ['/static/js/shared/a.js', '/static/js/elsewhere/b.js'];");
assert.throws(() => checkArchitecture(root), /resolve differently/);
});
test('new modules must join exact offline manifests and cannot introduce cycles', t => {
const missing = fixture(t, { 'shared/a.js': 'export const a = 1;' }, { cache: false });
assert.throws(() => checkArchitecture(missing), /absent from explicit precache/);
const cyclic = fixture(t, { 'shared/a.js': "import './b.js';", 'shared/b.js': "import './a.js';" });
assert.throws(() => checkArchitecture(cyclic), /dependency cycle/);
});
test('legacy adapters require exact, used exceptions and reserved names cannot bypass ownership', t => {
const root = fixture(t, { 'features/notes/infrastructure/api.js': "import '/static/js/legacy.js?v=existing';" });
fs.mkdirSync(path.join(root, 'static/js'), { recursive: true });
fs.writeFileSync(path.join(root, 'static/js/legacy.js'), 'export const legacy = true;');
const file = path.join(root, 'frontend/modules.json');
const manifest = JSON.parse(fs.readFileSync(file));
manifest.modules[0].legacyImports = [{ specifier: '/static/js/legacy.js?v=existing', reason: 'Temporary existing transport adapter; remove in N2.' }];
fs.writeFileSync(file, JSON.stringify(manifest));
assert.equal(checkArchitecture(root), 1);
manifest.modules[0].legacyImports.push({ specifier: '/static/js/unused.js', reason: 'stale' });
fs.writeFileSync(file, JSON.stringify(manifest));
assert.throws(() => checkArchitecture(root), /unused legacy import exception/);
assert.throws(() => allowsDependency('features/shared/index.js', 'app/bootstrap.js'), /reserved feature name/);
});
test('inventory reports executable static cycles and separates dynamic edges', t => {
const root = fixture(t, { 'shared/a.js': 'export const a = 1;' });
fs.mkdirSync(path.join(root, 'static/js'), { recursive: true });
fs.writeFileSync(path.join(root, 'static/js/a.js'), "import './b.js'; const load = () => import('./c.js');");
fs.writeFileSync(path.join(root, 'static/js/b.js'), "import './a.js';");
fs.writeFileSync(path.join(root, 'static/js/c.js'), 'export const c = 1;');
const result = inventory(root);
assert.equal(result.staticImports, 2);
assert.equal(result.dynamicLiteralImports, 1);
assert.deepEqual(result.staticCycles, [['js/a.js', 'js/b.js']]);
assert.deepEqual(result.missing, []);
});
+170
View File
@@ -0,0 +1,170 @@
"""Exercise publication failure modes without changing the real static tree."""
import json
import subprocess
import sys
from pathlib import Path
import pytest
from tests.helpers.cli_loader import load_script
ROOT = Path(__file__).resolve().parents[1]
build = load_script("build_frontend.py")
def project(tmp_path, entries=None):
source = tmp_path / "frontend/src/shared/helper.js"
source.parent.mkdir(parents=True)
source.write_bytes(b"export const value = 1;\n")
manifest = {"version": 1, "modules": entries if entries is not None else [
{"source": "shared/helper.js", "deployment": "js/helper.js"}
]}
(tmp_path / "frontend/modules.json").write_text(json.dumps(manifest), encoding="utf-8")
(tmp_path / ".gitattributes").write_text("* text=auto\nfrontend/src/** text eol=lf\nstatic/js/helper.js text eol=lf\n", encoding="utf-8")
return source, tmp_path / "static/js/helper.js"
def test_check_detects_missing_and_stale_output_without_writing(tmp_path):
source, output = project(tmp_path)
assert build.synchronize(tmp_path, check=True) == ["static/js/helper.js"]
assert not output.exists()
assert build.synchronize(tmp_path) == ["static/js/helper.js"]
assert output.read_bytes() == source.read_bytes()
source.write_bytes(b"export const value = 2;\n")
assert build.synchronize(tmp_path, check=True) == ["static/js/helper.js"]
assert output.read_bytes() == b"export const value = 1;\n"
def test_publication_is_idempotent_and_preserves_unowned_assets(tmp_path):
source, output = project(tmp_path)
unowned = tmp_path / "static/js/notes.js"
unowned.parent.mkdir(parents=True)
unowned.write_bytes(b"legacy state owner\n")
build.synchronize(tmp_path)
timestamp = output.stat().st_mtime_ns
assert build.synchronize(tmp_path) == []
assert output.stat().st_mtime_ns == timestamp
assert unowned.read_bytes() == b"legacy state owner\n"
assert output.read_bytes() == source.read_bytes()
@pytest.mark.parametrize("bad", ["../outside.js", "/absolute.js", "shared/../helper.js", "shared\\helper.js", "shared/helper.js?v=1", "shared/helper.js#fragment", "shared/helper.ts"])
def test_invalid_plan_does_not_partially_publish(tmp_path, bad):
project(tmp_path, [
{"source": "shared/helper.js", "deployment": "js/helper.js"},
{"source": bad, "deployment": "js/other.js"},
])
with pytest.raises(ValueError, match="invalid module path"):
build.synchronize(tmp_path)
assert not (tmp_path / "static").exists()
def test_duplicate_outputs_and_unmapped_sources_are_rejected(tmp_path):
source, output = project(tmp_path)
extra = source.with_name("extra.js")
extra.write_bytes(b"export const value = 2;\n")
with pytest.raises(ValueError, match="missing from manifest"):
build.synchronize(tmp_path)
manifest_path = tmp_path / "frontend/modules.json"
manifest = json.loads(manifest_path.read_text())
manifest["modules"].append({"source": "shared/extra.js", "deployment": "js/helper.js"})
manifest_path.write_text(json.dumps(manifest))
with pytest.raises(ValueError, match="must be unique"):
build.synchronize(tmp_path)
assert not output.exists()
def test_symlink_output_cannot_overwrite_another_file(tmp_path):
_, output = project(tmp_path)
outside = tmp_path / "outside.js"
outside.write_bytes(b"untouched\n")
output.parent.mkdir(parents=True)
output.symlink_to(outside)
with pytest.raises(ValueError, match="escapes|symlink"):
build.synchronize(tmp_path)
assert outside.read_bytes() == b"untouched\n"
def test_real_checkout_check_works_from_another_directory(tmp_path):
result = subprocess.run([sys.executable, str(ROOT / "scripts/build_frontend.py"), "--check"], cwd=tmp_path, capture_output=True, text=True, timeout=20)
assert result.returncode == 0, result.stdout + result.stderr
assert (ROOT / "frontend/src/shared/actionMenuOrder.js").read_bytes() == (ROOT / "static/js/actionMenuOrder.js").read_bytes()
def test_isolated_python_distribution_runs_without_source_or_node():
result = subprocess.run([sys.executable, str(ROOT / "scripts/verify_frontend_runtime.py")], cwd=ROOT, capture_output=True, text=True, timeout=90)
assert result.returncode == 0, result.stdout + result.stderr
assert "PASS: isolated Python lifespan" in result.stdout
@pytest.mark.parametrize("check", [False, True])
@pytest.mark.parametrize("path,rule", [
("frontend/src/shared/helper.js", "text"),
("static/js/helper.js", "text"),
("frontend/src/shared/helper.js", "text eol=crlf"),
("static/js/helper.js", "text=auto eol=lf"),
("static/js/helper.js", "-text eol=lf"),
])
def test_each_manifest_path_requires_lf_even_when_bytes_already_match(tmp_path, check, path, rule):
source, output = project(tmp_path)
output.parent.mkdir(parents=True)
output.write_bytes(source.read_bytes())
other = "static/js/helper.js" if path.startswith("frontend/") else "frontend/src/shared/helper.js"
(tmp_path / ".gitattributes").write_text(f"* text=auto\n{other} text eol=lf\n{path} {rule}\n", encoding="utf-8")
with pytest.raises(ValueError, match="require repository-owned.*" + path):
build.synchronize(tmp_path, check=check)
assert output.read_bytes() == source.read_bytes()
def test_future_manifest_pair_cannot_publish_without_output_attributes(tmp_path):
source, output = project(tmp_path)
extra = source.with_name("future.js")
extra.write_bytes(b"export const future = true;\n")
manifest_file = tmp_path / "frontend/modules.json"
manifest = json.loads(manifest_file.read_text())
manifest["modules"].append({"source": "shared/future.js", "deployment": "js/future.js"})
manifest_file.write_text(json.dumps(manifest))
with pytest.raises(ValueError, match="static/js/future.js"):
build.synchronize(tmp_path)
assert not output.exists()
assert not (tmp_path / "static/js/future.js").exists()
with (tmp_path / ".gitattributes").open("a", encoding="utf-8") as attributes:
attributes.write("static/js/future.js text eol=lf\n")
assert build.synchronize(tmp_path) == ["static/js/helper.js", "static/js/future.js"]
assert build.synchronize(tmp_path, check=True) == []
def test_nested_git_attribute_rules_and_paths_with_spaces_are_supported(tmp_path):
source, _ = project(tmp_path, [{"source": "shared/helper.js", "deployment": "js/feature space/helper.js"}])
attributes = tmp_path / "static/js/feature space/.gitattributes"
attributes.parent.mkdir(parents=True)
attributes.write_text("*.js text eol=lf\n", encoding="utf-8")
assert build.synchronize(tmp_path) == ["static/js/feature space/helper.js"]
assert (attributes.parent / "helper.js").read_bytes() == source.read_bytes()
attributes.write_text("*.js text eol=crlf\n", encoding="utf-8")
with pytest.raises(ValueError, match="eol=crlf"):
build.synchronize(tmp_path, check=True)
def test_global_and_local_git_settings_cannot_mask_missing_repository_rule(tmp_path, monkeypatch):
project(tmp_path)
(tmp_path / ".gitattributes").write_text("frontend/src/** text eol=lf\n", encoding="utf-8")
global_attributes = tmp_path / "global-attributes"
global_attributes.write_text("* text eol=lf\n", encoding="utf-8")
config = tmp_path / "global-config"
config.write_text(f'[core]\n attributesFile = "{global_attributes.as_posix()}"\n autocrlf = true\n', encoding="utf-8")
subprocess.run(["git", "init", "--quiet", str(tmp_path)], check=True, capture_output=True)
(tmp_path / ".git/info/attributes").write_text("* text eol=lf\n", encoding="utf-8")
monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(config))
monkeypatch.setenv("GIT_DIR", str(tmp_path / ".git"))
monkeypatch.setenv("GIT_WORK_TREE", str(tmp_path))
with pytest.raises(ValueError, match="static/js/helper.js"):
build.synchronize(tmp_path, check=True)
def test_attribute_enforcement_fails_clearly_when_development_git_is_missing(tmp_path, monkeypatch):
_, output = project(tmp_path)
monkeypatch.setattr(build.shutil, "which", lambda name: None)
with pytest.raises(ValueError, match="Git is required for frontend publication"):
build.synchronize(tmp_path)
assert not output.exists()
@@ -0,0 +1,63 @@
"""Real Chromium proof at the existing /static/ service-worker scope.
This deliberately does not widen SW scope or claim offline SPA root reload.
The helper is first evaluated offline, after install-time precaching only.
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
@pytest.mark.skipif(shutil.which("node") is None, reason="node binary not on PATH")
def test_precached_helper_imports_offline_in_a_fresh_page_and_panels_stay_lazy():
script = r'''
import assert from 'node:assert/strict';
import { chromium } from 'playwright';
const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN;
const browser = await chromium.launch({ headless: true });
try {
const context = await browser.newContext({ serviceWorkers: 'allow' });
const page = await context.newPage();
await page.goto(`${origin}/static/js/panels.js`);
const installed = await page.evaluate(async () => {
await navigator.serviceWorker.register('/static/sw.js?v=20260916autoscroll1');
const registration = await navigator.serviceWorker.ready;
if (!navigator.serviceWorker.controller) await new Promise(resolve => navigator.serviceWorker.addEventListener('controllerchange', resolve, { once: true }));
const response = await caches.match('/static/js/actionMenuOrder.js');
return { scope: registration.scope, cached: !!response && response.ok };
});
assert.equal(installed.scope, `${origin}/static/`);
assert.equal(installed.cached, true);
// New page gives a fresh ES-module map. No helper import occurred online.
await context.setOffline(true);
const offline = await context.newPage();
await offline.goto(`${origin}/static/js/panels.js`);
const requests = [];
offline.on('request', request => requests.push(request.url()));
const result = await offline.evaluate(async () => {
const helper = await import('/static/js/actionMenuOrder.js');
const again = await import('./actionMenuOrder.js');
const panels = await import('/static/js/panels.js?v=20260909movepicklayer1');
return {
sameModule: helper === again,
exports: Object.keys(helper).sort(),
order: helper.orderActionMenuItems([{ label: 'Delete' }, { label: 'Rename' }]).map(i => i.label),
panels: panels.panelNames(),
};
});
assert.equal(result.sameModule, true);
assert.deepEqual(result.order, ['Rename', 'Delete']);
assert.ok(result.panels.includes('editor'));
assert.equal(requests.some(url => url.includes('/galleryEditor.js')), false);
console.log(JSON.stringify({ ...result, ...installed }));
} finally { await browser.close(); }
'''
result = subprocess.run(["node", "--input-type=module", "--eval", script], cwd=ROOT, capture_output=True, text=True, timeout=90)
assert result.returncode == 0, result.stdout + result.stderr
data = json.loads(result.stdout)
assert data["exports"] == ["SELECT_MENU_ICON", "actionMenuRank", "orderActionMenuItems"]