mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-15 03:12:21 +02:00
refactor(frontend): establish source ownership and architecture gates (#6701)
This commit is contained in:
@@ -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, []);
|
||||
});
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user