aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_spa_imports.py
blob: 8be70cb4d1fe406d480fb0c2a4ce1c6841f72f2f (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
"""
Every import in the SPA points at a file that exports what it names.

The failure this catches has a distinctive shape: nothing errors at build time,
because there is no build; the browser resolves the module graph at load, finds
a binding that is not there, and the page renders blank or the component simply
does not appear. `node --check` cannot see it — it parses one file at a time —
and neither can the source-reading guards, which look inside a file rather than
between two.

Written after the settings-page split, which moved two shared components into a
new module and rewired eight files to import them from there. That is exactly
the change where a rename lands in one file and not the other.

It is a static check, not a load: it says the name is exported, not that the
value is what the caller expects. `test_spa_syntax` covers parsing;
`test_hook_ordering` covers the ordering fault that also presents as a missing
component.
"""

import re
from pathlib import Path

import pytest

STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"

pytestmark = pytest.mark.skipif(not STATIC.exists(),
                                reason="SPA sources unavailable")

# `import { a, b as c } from './x.js';` / `import * as p from ...` / default.
IMPORT = re.compile(
    r"^import\s+(?:\{([^}]*)\}|(\*\s+as\s+\w+)|(\w+))\s+from\s+'([^']+)';", re.M)
EXPORT_BLOCK = re.compile(r"^export\s+\{([^}]*)\};", re.M)
EXPORT_DECL = re.compile(
    r"^export\s+(?:default\s+)?(?:async\s+)?(?:function|const|class|let|var)\s+(\w+)",
    re.M)


def _exported(source: str) -> set[str]:
    names = set(EXPORT_DECL.findall(source))
    for block in EXPORT_BLOCK.findall(source):
        for raw in block.split(","):
            name = raw.strip().split(" as ")[-1].strip()
            if name:
                names.add(name)
    return names


def test_every_named_import_resolves():
    problems: list[str] = []
    for path in sorted(STATIC.glob("*.js")):
        source = path.read_text(encoding="utf-8")
        for names, star, default, spec in IMPORT.findall(source):
            # vendor/ is third-party, bundled, and does not use a form this
            # reads. Its exports are covered by the app failing to start.
            if not spec.startswith("./") or "vendor/" in spec:
                continue
            target = (path.parent / spec[2:]).resolve()
            if not target.exists():
                problems.append(f"{path.name}: imports {spec} — no such file")
                continue
            if star or default or not names.strip():
                continue
            available = _exported(target.read_text(encoding="utf-8"))
            for raw in names.split(","):
                name = raw.strip().split(" as ")[0].strip()
                if name and name not in available:
                    problems.append(
                        f"{path.name}: imports {{{name}}} from {spec}, "
                        f"which does not export it")

    assert not problems, "unresolved imports:\n" + "\n".join(problems)


def test_the_check_can_see_a_real_module():
    """
    Guard against the parser quietly matching nothing — a regex that stopped
    finding imports would make the test above pass over an empty set, which
    looks exactly like success.
    """
    source = (STATIC / "group-page.js").read_text(encoding="utf-8")
    found = IMPORT.findall(source)
    assert len(found) >= 5, (
        "the import pattern no longer matches group-page.js — this test is then "
        "checking nothing")
    assert "GroupPage" in _exported(source)


# `lazy(() => import('./x.js'), 'Name')` (lazy.js), and the hand-written form
# `import('./x.js').then(m => { _Page = m.Name; ...`.
LAZY = re.compile(r"lazy\(\s*\(\)\s*=>\s*import\('\./([^']+)'\),\s*'(\w+)'")
THEN = re.compile(r"import\('\./([^']+)'\)\.then\(\s*m\s*=>\s*\{\s*\w+\s*=\s*m\.(\w+)")


def test_every_module_loaded_on_demand_exports_what_is_asked_for():
    """
    The same fault as above, one step later: a component fetched on demand
    under a name its module does not export renders a spinner that never
    ends — no error, since the fetch itself succeeded.
    """
    problems, seen = [], 0
    for path in sorted(STATIC.glob("*.js")):
        source = path.read_text(encoding="utf-8")
        for spec, name in LAZY.findall(source) + THEN.findall(source):
            seen += 1
            target = STATIC / spec
            if not target.exists():
                problems.append(f"{path.name}: loads ./{spec} — no such file")
            elif name not in _exported(target.read_text(encoding="utf-8")):
                problems.append(f"{path.name}: loads {{{name}}} from ./{spec}, "
                                f"which does not export it")
    assert seen >= 15, f"only {seen} on-demand loads found — is the pattern stale?"
    assert not problems, "unresolved on-demand loads:\n" + "\n".join(problems)