aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness
Commit message (Collapse)AuthorAgeFilesLines
...
* fix(hub): a session that renews itselfChristophe Besson2026-08-171-0/+165
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Reported: after an hour of watching a film, every action answers "token expired or invalid", with signing out and back in as the only way on. Reopening the tab the next day did the same. The access token lasts an hour and the refresh token thirty days, and nothing used the second one. `hubFetch` reported a 401 like any other error, and watching a film is precisely an hour in which the hub hears nothing at all, because the video travels over WebRTC. So the token aged out with no request to notice, and a tab reopened the next morning presented a stale token with a perfectly good refresh token sitting beside it in localStorage. Underneath was the reason it could not be recovered from. The hub *rotates*: the refresh endpoint revokes the token presented, returns a replacement, and treats a revoked one presented again as theft, revoking the whole family. The client kept only the access token out of that response. So the refresh token was spent on first use and the second attempt did not merely fail — it destroyed the family. Which is exactly the reported symptom. Renewal now happens on a margin, on returning to the tab, on mount, and on a 401 with the request replayed. Concurrent renewals share one request: two 401s racing would otherwise present the same refresh token twice, and the hub cannot tell that from theft, so the remedy would have been worse than the fault. A refusal signs out cleanly rather than leaving a session that fails every call while looking signed in. The lifetime goes to four hours, which is not what makes long sessions work — renewal is — but is what someone has to notice by if renewal itself breaks. An hour was less than a feature film. Twelve was considered and declined: it widens the window in which a leaked token cannot be turned off, and it lets the renewal path go a whole day between uses, which is how it came to be broken here without anyone noticing. Production sets this in its own hub.toml, so both moved. The tests run the shipped code against a hub that enforces rotation, because a stub that accepted the same refresh token twice would have passed against the broken client. Checked that dropping the rotated token reproduces the revoked family, so the guard is guarding something. Also widens the orphan-setter rule to ignore `setX` functions declared in the module: `setAuth` is not a hook setter, and a rule that cries wolf is one somebody eventually silences. Verified it still catches a real orphan.
* fix(hub): the transfers panel hung off the side of a phoneChristophe Besson2026-08-171-0/+140
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Reported: on mobile you see only the right-hand edge of the panel, without the content. Measured, before anything was changed: 320 px viewport -> panel at -138..192, 138 px off the left 360 px -> -98..232 412 px -> -46..284 The panel is 330 px wide and anchored to the right edge of its button — but that button is not at the right edge of the screen, since the bell and the user menu come after it. What falls off is the left-hand side, which is where the file names are, so what stayed on screen was a strip of progress bars belonging to nothing. Narrowing it would not have helped: the overflow comes from where the right edge is pinned, not from the width. Below the existing 768 px breakpoint the panel is anchored to the viewport instead, full width on a phone and capped at 420 px on a tablet, where stretching two filenames across 750 px would be silly. Desktop keeps its 330 px against the button. The interesting part is how it was found. The responsive tests read numbers out of the stylesheet and said, in their own docstring, that a layout could not be measured because the suite had no browser. It has one now — Chrome, from the video work — so layout_probe.py renders the real stylesheet at a given width and returns rectangles. `width: 330px` was never the thing worth asserting on. An iframe carries the viewport, because a headless window will not go below about 500 px, and one browser measures every width: launching one per test put three minutes on the suite against twenty-six seconds for all of them. Checked that the new tests fail with the rule removed — three of them do — and that they pass with it back.
* test(hub): a hook that depends on one declared below it never runsChristophe Besson2026-08-172-7/+103
| | | | | | | | | | | | | | | | | | | | | | | `const a = useCallback(fn, [b])` evaluates `[b]` where it is written, so a `b` further down the component is still in its temporal dead zone. ReferenceError on every render, before anything the component does can run — and 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 caught it. `node --check` passes, the code is well-formed. Worse, the MSE harness extracts the player functions into an order of its own and therefore *reordered* them before running — quietly repairing the one class of defect it was best placed to catch. It sorts by position in the file now, and test_hook_ordering.py checks the property directly across the whole SPA. Both the rule and the harness are checked against the layout that actually shipped. test_video_seek.py covers the rest of seeking, and window_leak.mjs forces the race that made the third seek hang: the whole in-flight window arriving while `reinitAt` is still awaiting. Before, the player is left believing eight segments are in flight and grants nothing; after, the window comes back. A run that happens to work proves nothing about a race, which is the point of forcing the worst case rather than trusting a longer session.
* fix(hub): bound the video read-ahead by the playhead, not by the networkChristophe Besson2026-08-161-0/+159
A 500 MB film loaded about 100 MB and hung on "buffering" for good. 100 MB is not a number in our code: it is where the browser stops. ffmpeg remuxes with `-c copy`, so the bytes on the wire are the file's own, and credit granted per append meant taking them as fast as the network allowed — which for a film is very much faster than watching it. The SourceBuffer ceiling arrived in the first minute. Past it every append was refused, and the refusal was unrecoverable: a refused append fires no `updateend`, `updateend` was where credit was granted, so the node sent nothing and no segment arrived to retry the append. Every wakeup the pipeline had was downstream of the append that had just failed. Playback continuing — the one thing that frees room — woke nothing at all. Credit now follows the buffer instead of the writes. `pump()` is the only place it is granted, it keeps `STREAM_WINDOW` segments in flight while less than `BUFFER_AHEAD_S` of film is held past the playhead, and it is driven by a one-second clock and by playback progress, never by arriving data. Buffering by time makes a two-hour film cost what a two-minute clip costs. A window rather than a debt, and this took a second measurement to get right: accumulating a credit per append and releasing the balance when the buffer finally drained sent six megabytes in one burst, overshot by a minute of film, then said nothing for forty-six seconds. Measured in Chrome against real fragmented MP4. Two smaller things found on the way. `updateend` fires for `remove()` as well as `appendBuffer()`, so crediting from it paid the node for the player's own evictions. And a viewer that is deliberately far enough ahead grants nothing for minutes, which the node read as a closed tab — it now sends `stream_more` with n=0, which grants no room but proves someone is there. The first version of the test modelled the credit loop and passed while the player still hung: a model written by whoever wrote the fix agrees with it by construction. `tests/harness/mse_harness.mjs` lifts the real functions out of app.js as text and runs them against a SourceBuffer that has a ceiling. What is modelled is the browser.