summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_hook_ordering.py
blob: d5ffcfea0a5d2c3bd052e659c2f3489588812ac5 (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
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
"""
A hook cannot depend on one declared below it.

`const a = useCallback(fn, [b])` evaluates `[b]` where it is written. If `b` is
another `const` further down the component, it is still in its temporal dead
zone and the array throws `ReferenceError: Cannot access 'b' before
initialization` — during render, every render, before anything the component
does can run.

The symptom is the component simply not appearing. Clicking a video did
nothing at all: no picture, no error on screen, nothing in the node's log
because nothing was ever requested. It reached production.

Nothing else catches it. `node --check` validates syntax and this is
well-formed. The MSE harness runs the same functions but extracts them into a
list of its own choosing, so it *reorders* them and cannot see an ordering
fault — it is now ordered by position in the file for that reason, and this
test covers the case directly.
"""

import re
from pathlib import Path

import pytest

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

# One monolithic app.js used to hold every component; the group-page refactor
# split it into one file per "application" (chat-app.js, files-app.js,
# video-player.js, group-settings.js) plus the group shell (group-page.js).
# A future Videos/Music/Photos app lands in its own file the same way — add it
# here so this test keeps seeing it, since `_all_components` below only walks
# the files named in this list.
STATIC_FILES = [
    "app.js", "group-page.js", "chat-app.js", "files-app.js",
    "video-player.js", "video-app.js", "music-app.js", "music-player.js",
    "photos-app.js", "pager.js",
    "group-settings.js",
    # The per-app settings architecture (docs/MESHBAY_DESIGN.md §9.4). Reached
    # through the apps.js registry rather than imported by name, so a file
    # left out of this list is one nothing checks — the failure is silent.
    "settings-ui.js", "folder-tree.js",
    "chat-app-settings.js", "video-app-settings.js",
    "music-app-settings.js", "photos-app-settings.js",
    "helloworld-app.js", "helloworld-app-settings.js",
    "auth-page.js", "explore-page.js", "create-group-page.js",
    # The Search page was missing from this list while being the densest
    # `useMemo` chain in the tree — a dozen derived lists, each depending on
    # the one above it, which is precisely the shape this checks.
    "search-page.js",
    # The shared pop-up menu (docs/playlists.md §10.1), reached from the media
    # views rather than imported by the shell.
    "menu.js", "playlist-menu.js",
    # The page's own confirm/alert, mounted outside the app tree.
    "ask.js",
]

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

# `const NAME = useCallback(` / `useMemo(` — the declarations that both define a
# binding and take a dependency array.
DECL = re.compile(r"^  const (\w+) = (?:useCallback|useMemo)\(", re.M)
# The closing `}, [a, b]);` of such a declaration.
DEPS = re.compile(r"^  \}, \[([^\]]*)\]\);", re.M)


@pytest.fixture(scope="module")
def app():
    return APP.read_text()


def _components(src: str):
    """Each top-level component in one file, with the offset it starts at."""
    for m in re.finditer(r"^function ([A-Z]\w*)\(", src, re.M):
        start = m.start()
        nxt = src.find("\nfunction ", start + 1)
        yield m.group(1), src[start:nxt if nxt > 0 else len(src)]


def _all_components():
    """Every top-level component across every static file that can hold one."""
    for name in STATIC_FILES:
        path = STATIC / name
        if not path.exists():
            continue
        for cname, body in _components(path.read_text()):
            yield f"{name}:{cname}", body


def test_no_hook_depends_on_something_declared_below_it():
    """Every static file that can hold a component, not just app.js."""
    problems = []
    for name, body in _all_components():
        # Where each hook binding becomes usable.
        declared_at = {m.group(1): m.start() for m in DECL.finditer(body)}
        for deps in DEPS.finditer(body):
            for dep in (d.strip() for d in deps.group(1).split(",")):
                if not dep or dep not in declared_at:
                    continue
                if declared_at[dep] > deps.start():
                    problems.append(
                        f"{name}: a hook at offset {deps.start()} lists `{dep}` "
                        f"as a dependency, but `{dep}` is declared below it")
    assert not problems, (
        "a dependency array is evaluated where it is written, so this throws "
        "on every render and the component never appears:\n  "
        + "\n  ".join(problems))


def test_the_check_would_notice():
    """A test that cannot fail proves nothing — so make it fail on purpose.

    Swaps two declarations in the real file and confirms the rule fires. If
    this stops working the rule above has quietly become decoration.
    """
    body = next(b for n, b in _all_components() if n == "video-player.js:VideoPlayer")
    decls = list(DECL.finditer(body))
    assert len(decls) >= 2, "VideoPlayer has too few hooks to test the check"

    # Build a body where the first hook depends on the last one, declared after.
    first, last = decls[0].group(1), decls[-1].group(1)
    broken = body.replace(decls[0].group(0),
                          decls[0].group(0), 1)
    # Inject a dependency on `last` into the first declaration's dep array.
    end = broken.index("\n  }, [", decls[0].start())
    close = broken.index("]", end)
    broken = (broken[:close] + (", " if broken[end + 7:close].strip() else "")
              + last + broken[close:])

    declared_at = {m.group(1): m.start() for m in DECL.finditer(broken)}
    caught = False
    for deps in DEPS.finditer(broken):
        for dep in (d.strip() for d in deps.group(1).split(",")):
            if dep in declared_at and declared_at[dep] > deps.start():
                caught = True
    assert caught, (
        f"made `{first}` depend on `{last}` which is declared after it, and the "
        "rule did not fire — it is not checking what it claims to")


def test_the_mse_harness_reads_functions_in_source_order():
    """Otherwise it hides exactly this fault.

    The harness exists to run the shipped code rather than a paraphrase of it.
    Extracting into an order of its own quietly repairs an ordering bug before
    running it, which is the one class of defect it would otherwise be well
    placed to catch.
    """
    harness = (Path(__file__).parent / "harness" / "mse_harness.mjs").read_text()
    assert "sort" in harness and "indexOf" in harness, (
        "the harness still extracts the player functions in a hardcoded order, "
        "so it cannot see one declared before its own dependency")