Commit Graph
2359 Commits
Author SHA1 Message Date
Alexandre Teixeira bdccb1ddb1 Merge lab into test/95-release-smoke-suite 2026-10-01 02:45:49 +01:00
Alexandre Teixeira 95416cdbfb Merge pull request #27 from o3LL/audit/ref-parity
chore(tools): add a read-only ref-to-ref parity audit
2026-10-01 02:45:38 +01:00
Alexandre Teixeira ef0d96a3ae Merge lab into audit/ref-parity 2026-10-01 02:45:10 +01:00
Alexandre Teixeira fe7297547c Merge pull request #38 from o3LL/test/runtime-behavior-regressions
test(runtime): pin negative capability wording, test-only
2026-10-01 02:42:50 +01:00
Alexandre Teixeira 8c7e3a9411 Merge lab into test/runtime-behavior-regressions 2026-10-01 02:42:22 +01:00
Alexandre Teixeira 1dca85cb07 Merge pull request #37 from o3LL/fix/token-cache-atomic-swap
fix(auth): swap the API token cache atomically instead of clearing it
2026-10-01 02:42:09 +01:00
Alexandre Teixeira 45e1e2e7d9 Merge lab into fix/token-cache-atomic-swap 2026-10-01 02:41:41 +01:00
Alexandre Teixeira bf76c9c608 Merge pull request #36 from o3LL/fix/6174-singleflight-cancel
fix(tasks): clean up the singleflight cache on cancellation
2026-10-01 02:28:18 +01:00
Alexandre Teixeira b0bc0b8c40 Merge lab into fix/6174-singleflight-cancel 2026-10-01 02:27:49 +01:00
Alexandre Teixeira 236baea232 Merge pull request #33 from o3LL/refactor/email-library-package
refactor(email): decompose emailLibrary.js into a package behind a re-export wrapper
2026-10-01 02:23:56 +01:00
Alexandre Teixeira 799adbbde6 Merge lab into refactor/email-library-package 2026-10-01 02:22:23 +01:00
Alexandre Teixeira eef390b9ae Merge pull request #22 from o3LL/refactor/routes-email-subpackage
refactor(routes): move the email modules into routes/email/
2026-10-01 02:17:29 +01:00
Alexandre Teixeira 9477616e05 Merge lab into refactor/routes-email-subpackage 2026-10-01 02:15:36 +01:00
Alexandre Teixeira b35010c3d1 Merge pull request #24 from o3LL/refactor/settings-shell-modules
refactor(settings): move the shell out of settings.js into settings/
2026-10-01 01:42:18 +01:00
Alexandre Teixeira 33387d4a4c Merge lab into refactor/settings-shell-modules 2026-10-01 01:41:27 +01:00
Alexandre Teixeira 290bb0d61d Merge pull request #23 from o3LL/refactor/settings-panel-modules
refactor(settings): extract four panels into settings/ modules
2026-10-01 01:08:58 +01:00
Alexandre Teixeira 86212bfe99 Merge lab into refactor/settings-panel-modules 2026-10-01 01:03:49 +01:00
Alexandre Teixeira 0f23439a31 Merge pull request #20 from o3LL/refactor/split-style-css
refactor(css): split style.css into ordered fragments
2026-10-01 00:55:35 +01:00
Alexandre Teixeira 23fdc26079 Merge lab into refactor/split-style-css 2026-10-01 00:48:58 +01:00
Alexandre Teixeira 0a88b3c322 Merge pull request #31 from o3LL/test/document-module-set-helper
test(document): read the editor through a module-set helper before it is split
2026-09-30 22:59:49 +01:00
Alexandre Teixeira 40dbe17a95 Merge lab into test/document-module-set-helper
Resolve the overlap with #19 by preserving the whole-cascade stylesheet
helpers alongside #31's document-source/module-set helpers.

Maintainer validation:
- document/module contract and composition guards: 15 passed
- all 54 touched Python test modules: 366 passed
- py_compile: clean
- git diff --check: clean
2026-09-30 22:56:56 +01:00
Alexandre Teixeira 741f5dff2b Merge pull request #19 from o3LL/refactor/tests-read-the-whole-cascade
refactor(tests): read the whole cascade instead of style.css alone
2026-09-30 19:27:58 +01:00
Alexandre Teixeira 6ee51fee72 Merge pull request #18 from o3LL/fix/duplicate-keyframes
fix(css): collapse duplicate @keyframes names to the definition that wins
2026-09-30 19:18:13 +01:00
Alexandre Teixeira a969a68016 Merge pull request #34 from o3LL/test/macos-failures-and-stub-leak-guard
test: fix environment-dependent failures and guard module-stub leaks
2026-09-30 19:16:43 +01:00
Alexandre Teixeira 56484df737 test(media): detect ffmpeg encoders by codec alias 2026-09-30 19:16:29 +01:00
Alexandre Teixeira eaddc03729 Merge pull request #26 from o3LL/fix/tmp-realpath-test-bugs
fix(tests): resolve temp paths consistently on macOS
2026-09-30 18:21:04 +01:00
Alexandre Teixeira bfae249120 Merge pull request #28 from o3LL/fix/cookbook-stop-procfs-guard
fix(cookbook): skip the pid sweep when the host has no procfs
2026-09-30 18:07:07 +01:00
Alexandre Teixeira 054df080af Merge pull request #32 from o3LL/fix/6215-mcp-args-validation
fix(mcp): reject malformed Args on Add MCP Server instead of defaulting to []
2026-09-30 17:18:51 +01:00
Alexandre Teixeira 11ecf46abb Merge pull request #30 from o3LL/fix/6228-tailscale-empty-lookup-cache
fix(discovery): cache a successful but empty Tailscale lookup
2026-09-30 17:05:16 +01:00
Léo bf5d8e4001 test(runtime): cover the remaining four requested behaviours
Completes the lane alteixeira20 asked for. Test-only: tests/ and test helpers,
no production runtime code, no benchmark fixtures or allowlists.

Supplied workspace context must not produce a clarification. Pins
_looks_like_unattended_clarification on four shapes that hand the decision
back ("could you please share", "shall I", "which approach do you prefer")
and three ordinary answers that must not trip it.

Repeated update_plan is not the turn's work. ask_user and update_plan are
permitted on nearly every turn, so if they counted as execution a model could
loop on them and look busy. Pins that _tool_rejection_reason does not
advertise either as an available tool, and that update_plan is permitted
without ever being in required.

Request-scoped tool authority. _request_scoped_allowed_tool_names must not
make an undeclared tool executable; the native-terminal widening is pinned
separately so it stays opt-in rather than drifting into the default.

Foreign-process safety. The Chrome sweep matches this runtime's own profile
prefix, so a fake procfs with our pid, a user's ordinary Chrome and another
worktree's agent browser must leave exactly two of the three alone.

14 passed, 2 xfailed. The xfails are the negative-wording cases from the first
commit that do not hold yet.
2026-09-30 17:38:57 +02:00
Léo 9738405310 fix(tests): bind the docker-socket fixtures somewhere sun_path fits
Four more tests in the same family as the /tmp ones this change already
fixes, and they hide for the same reason: the failure depends on how
long $TMPDIR happens to be.

tests/test_shell_routes.py::TestHostDockerAccess (three) and
tests/test_cookbook_docker_access.py::test_container_opt_in_with_unix_
socket_is_allowed each bind an AF_UNIX socket at tmp_path/"docker.sock".
macOS gives sun_path 104 bytes including the terminator. pytest roots
tmp_path at $TMPDIR, which on a stock Mac is a 49-character
/var/folders/<2>/<30>/T/; add pytest-of-<user>/pytest-<n>/ and the
test's own name and the bind path is 115 bytes before the filename.

    OSError: AF_UNIX path too long

Linux allows 108 and roots $TMPDIR at /tmp, so CI never sees it. Under a
shortened $TMPDIR the path lands at exactly 103 and passes — until
pytest's run counter reaches two digits and it becomes 104. That is why
the ledger's counts did not include these: they were measured somewhere
the path fit.

Adds tests/helpers/unix_sockets.bound_unix_socket, which binds under a
short directory and asserts the length before it tries, so the next
socket fixture fails with a sentence rather than an errno. Records the
trap in KNOWN_FAILURES.md along with the instruction to re-measure with
the default $TMPDIR.
2026-09-30 17:35:06 +02:00
Léo d423632559 test(runtime): scope the negative-wording claim to the inferred path
Verified end to end against a local Qwen3.5-9B Q4_K_M that when the user
explicitly enables web for the turn, none of the three phrasings withholds
web_search, web_fetch or private_browser, including the one these tests record
as held. The held case holds on the inferred path only, where no toggle is set
and the runtime decides from intent.

That distinction was missing and the file read as a stronger claim than the
measurement supports. An explicit toggle beating an inferred negative may be
the intended semantics, so it is recorded rather than asserted.
2026-09-30 17:19:07 +02:00
Léo 1408bce0ab docs(email): point the specs at the canonical module paths
The move left three documents naming `routes/email_routes.py`,
`routes/email_helpers.py` and `routes/email_pollers.py` as where the
code is. The shims keep those import paths working, so nothing breaks —
but each of those files is now seventeen lines that redirect, and a
reader sent there finds no email code at all.

Follows the phrasing specs/persistence.md already uses for the
contacts and vault subpackages: name the canonical path and note the
shims.
2026-09-30 17:17:39 +02:00
Léo 5f18767528 fix(mcp): show the route's rejection reason on the Integrations form too
The route now answers 400 with a message naming the expected shape.
admin.js was taught to print `data.detail`; the Unified Integrations
form in settings.js still printed `Failed (400)` and dropped it.

That gap is exactly where the new validation bites. The client-side
JSON.parse guard added alongside it catches unparseable input, so the
only values that reach the route's 400 are ones that parse but are not
a list — `"npx"`, `{}`, `null` — and for those the status code alone
tells the user nothing about what is wrong with what they typed.

Adds source-level coverage for both forms; the PR changed two JS files
with no test on either.
2026-09-30 17:15:52 +02:00
Léo 51e09a1e32 fix(css): repoint the two stylesheet links the split left behind
Splitting style.css deleted it, and two files outside static/ still
named it:

- scripts/verify_background_research_cards.mjs injected
  `<link rel="stylesheet" href="/static/style.css">` into the page it
  builds. A stylesheet that 404s does not fail — the script kept
  checking card layout against an unstyled page and kept reporting
  pass. It now reads the shell's <link> tags out of index.html, the way
  tests/css_snapshot/capture.mjs already does, so the set cannot drift
  out from under it again.
- tests/css_snapshot/bench.html's hand-open fallback linked the same
  deleted file. Replaced with the ordered set index.html ships.

test_every_stylesheet_referenced_by_shipped_html_exists only walked
static/*.html, which is why neither showed up. Extend it over the bench
page: the bench's whole output is computed styles, so a dead link there
is worth more than an unstyled page nobody looks at.
2026-09-30 17:14:52 +02:00
Léo 5fd9114882 test(runtime): pin negative capability wording, test-only
First slice of the runtime regression lane. Drives stream_agent_loop with a
fake model and asserts on the tools the runtime offers, which is its decision
about what the turn may do. No production runtime code is touched and no
benchmark fixture or allowlist is imported.

The fake-model pattern is the one tests/test_tool_policy.py already uses:
patch stream_llm_with_fallback and inspect the tools kwarg.

Measured on lab@c499c01b, negative web wording is only partially detected:

  "Answer from memory only, don't search online."            web tools withheld
  "Summarise what you already know. Do not search the web."  web tools OFFERED
  "No web search please, just tell me what you know..."      web tools OFFERED

The case that holds is a plain regression guard. The two that do not are
xfail(strict=True): they run on every suite, document the target, and fail the
moment the behaviour lands so the marker gets removed rather than lingering.
A positive control keeps the guard from being satisfied by removing the web
tools altogether.
2026-09-30 17:11:10 +02:00
Léo 5e3e153fba fix(auth): swap the API token cache atomically instead of clearing it
`_refresh_token_cache` rebuilt the bearer-token map in two steps, `clear()`
then `update()`. Between them the dict a concurrent reader was already holding
was empty, so a valid token landing in that window found zero candidates and
got a 401. The refresh runs on a worker thread via `to_thread`, so the window
is real rather than theoretical.

Build the new map and rebind the name. The reader dereferences the global once
and then holds a map that is complete — the previous one if it read early, the
new one if it read late, never a half-built one. `app.state._token_cache` is
rebound with it, because it was bound once at startup and would otherwise point
at the abandoned dict.

Ported from public `dev` (`984337b3`), with its test.

The upstream test does not pin the fix: both of its concurrency cases pass
against the pre-fix code, because landing a GIL switch inside a window a few
bytecodes wide does not happen across 100 refreshes. They are kept as written
and `TestRefreshLeavesTheReadersMapAlone` is added next to them, stating the
same invariant at object level — a map a reader already holds is not mutated
by a later refresh — which fails on the pre-fix code without depending on
thread scheduling.
2026-09-30 16:07:12 +02:00
Léo 6e4b3aa5bd fix(tasks): clean up the singleflight cache on cancellation
`_cached` deduplicates the scheduler's outbound fetches — Miniflux unread
counts and MCP tool snapshots — by parking every concurrent caller on one
shared Future. Two cancellation paths left that Future stranded.

A waiter awaited the shared Future directly, so cancelling the waiter
cancelled the Future the owner and every other waiter were using. It now
awaits through `asyncio.shield`.

The owner removed its pending entry inside the success and `except Exception`
branches. `CancelledError` is a `BaseException`, so it took neither: the key
stayed in `_shared_cache_pending` pointing at a Future nobody would ever
resolve, and every later caller for that key waited forever. Cleanup moves to
a `finally` that is synchronous on purpose, and the owner cancels its own
Future so current waiters wake while a later caller can still retry.

Ported from public `dev` (`ce04dc1d`, #6174 upstream), with its test.
2026-09-30 16:05:09 +02:00
Léo fba6f73260 test: fail the test that leaks a bare src/core module stub
#25 fixed two sys.modules writes in test_auth_regressions.py that left empty
stub modules behind for the rest of the session, breaking 23 tests under one
collection order while the full suite stayed green. The class is wider than
that file, and an audit is the wrong answer to it: nothing stops the next one,
and the failure it causes lands on an unrelated test in a different file.

So this is a guard instead. An autouse fixture in the root conftest snapshots
which src.* / core.* names are bound to a bare ModuleType, and fails any test
that adds one. "Bare" is the same test the clear_fake_* helpers already use -
a plain types.ModuleType with no on-disk __file__. MagicMock stand-ins are out
of scope: they answer every attribute, so they fail at the point of use rather
than silently, and several files install them deliberately.

Three details that matter:

- It lives in the root conftest, so it is set up before any test-module
  fixture and torn down after all of them. A stub a test's own teardown
  removes is not reported.
- It drops the leaked entries as well as reporting them, so the failure stays
  on the test that introduced it instead of cascading through the rest of the
  run.
- It only reports stubs added during the test. Import state the session starts
  with, including this conftest's own src.database stub, is left alone.

It found one beyond #25 on the first full run: _stub_heavy in
test_scheduler_restart_doublefire.py leaks the same five src.* modules as the
test #25 fixed, via sys.modules.setdefault. It already receives monkeypatch,
so the fix is to register through it. Fixed here because the guard has to land
green.

Full suite, macOS, default collection order:

  this branch   10655 passed, 6 failed, 6 skipped   406s
  lab           10655 passed, 6 failed, 6 skipped   371s

Same six either way, which is the point - none of this is visible in the
default order. Four are pre-existing macOS environment failures:
test_glob_confined_e2e and the two test_code_nav_tools document cases resolve
/tmp to /private/tmp, and
test_real_socket_falls_back_from_dead_first_to_live_second is connect-refused
timing on real sockets. The other two are the rich-colour and ffmpeg items
from the same ledger, fixed on their own branches.

Not verified: Linux, and any collection order other than the default. The
guard is order-independent by construction - it compares before and after
within a single test - but I have only run the default order.
2026-09-30 13:02:34 +02:00
Léo eb98aa6dc2 test(media): stop asserting an optional ffmpeg webp encoder
test_inspect_media_exports_final_decodable_frame_at_exact_duration exported to
/workspace/final.webp and asserted exit_code == 0. WebP encoding is an ffmpeg
build option, not something this project requires - Homebrew's macOS ffmpeg is
built without it:

  ffmpeg -encoders | grep -ic webp   ->  0

so the tool returns "ffmpeg still extraction failed: ... Encoder not found"
and the test fails on the build rather than on the code under test.

The test's subject is the final frame being decodable at the exact duration,
which has nothing to do with the container. It now writes a PNG, and the two
things the WebP path was implicitly covering are split out and each guarded on
what is actually present:

- test_inspect_media_exports_a_webp_still - skipped unless ffmpeg reports a
  webp encoder, and now asserts the file really is WebP rather than merely
  non-empty.
- test_inspect_media_reports_a_missing_encoder_instead_of_crashing - runs only
  where the encoder is absent, pinning the behaviour that surfaced this: the
  tool reports ffmpeg's failure as a tool error and writes no partial file.

The product code has the same assumption and I left it alone. inspect_media
accepts any suffix in _IMAGE_SUFFIXES and hands the path to ffmpeg, so a .webp
request on a build without libwebp fails with ffmpeg's own message. That is a
poor message, not a crash or a corrupt file, and pre-validating the encoder
list is a separate change.

  pytest tests/test_inspect_media_tool.py
  68 passed, 2 skipped in 22.92s      (1 failed, 66 passed, 1 skipped before)

Not verified: the WebP success path. This machine has no webp encoder, so
test_inspect_media_exports_a_webp_still skips here and has only been checked
for collection, not for a passing run.
2026-09-30 13:02:34 +02:00
Léo 24e428d9cb test(document): use platform-correct input in the rich color test
test_rich_colors_follow_theme_and_undo_as_one_edit fails identically on every
macOS run, timing out after 30s waiting for a span that never appears. It was
written off as timing noise twice. It is not flaky - it is two Linux-only
input conventions, and the product code is fine.

Control+click: macOS delivers a Control-modified primary click as contextmenu,
not click. Instrumenting the Lemon swatch shows the button receiving
pointerdown, mousedown, contextmenu, pointerup, mouseup - and no click, so the
menu item's handler never runs and no highlight is applied. Control was never
meaningful here anyway; the palette item has no modifier behaviour. The two
calls now use a plain click.

Control+Z: the editor's undo accelerator is Cmd+Z on macOS. With the clicks
fixed, both undo assertions still failed until the presses became
ControlOrMeta+Z, which Playwright maps per platform.

Both fixes are portable - a plain click and ControlOrMeta are unchanged on
Linux, where this test already passes.

  pytest tests/test_document_rich_color_reset_and_contrast.py
  2 passed in 2.11s       (1 failed, 1 passed before)

Not verified: Linux. I only have macOS here, so the claim that this stays
green on CI rests on the modifier being a no-op there, not on a run.
2026-09-30 13:02:34 +02:00
Léo 01b8ac5fea fix(mcp): reject malformed Args on Add MCP Server instead of defaulting to []
`add_server` wrapped `json.loads(args)` in a bare `except` that fell back to
`[]`, so an Args value that is not JSON — a bare path, which is what the form's
placeholder invites people to type — registered the server and spawned the
stdio subprocess with an empty argv. Nothing surfaced the loss: the POST
returned 200 and the row persisted with `"args": []`.

The route now returns 400 for an unparseable value, and also for valid JSON of
the wrong shape: `args=5` reached `StdioServerParameters(args=5)` and raised an
unhandled TypeError in the error formatter's `" ".join(...)`.

Both form clients mirror the guard instead of leaving the user to read a 400
they cannot see. `settings.js` stops silently defaulting a bad Args value, and
`admin.js` gains the same client-side parse check plus a `res.ok` branch so a
server-side rejection is not reported as a connection failure.

Ported from public `dev` (`9d5c0319`, #6215 upstream, fixing #6211), with its
test. The `admin.js` hunks are inert on `lab` — `initMcpForm` early-returns
because that form's markup is not in this build — and are carried anyway to
keep the two lines from diverging further.
2026-09-30 12:54:09 +02:00
Léo f8269a829f fix(discovery): cache a successful but empty Tailscale lookup
The host cache was gated on the list being non-empty, so "queried fine, no
eligible peers" looked exactly like a cold cache and every caller paid for
another `tailscale status --json` — a subprocess with a 5s timeout.

Gate on the timestamp instead. Failures still leave the timestamp unset, so a
missing binary, a non-zero exit or unparseable output stays retryable rather
than being cached for the full TTL.

Ported from public `dev` (`affaee1e`, #6228 upstream), with its test.
2026-09-30 12:14:03 +02:00
Léo 30ef7652c0 fix(cookbook): skip the pid sweep when the host has no procfs
`_cookbook_kill_session` kills the tmux session, then sweeps /proc for
model servers that survived the SIGHUP. The sweep had no guard, so on
macOS and Windows `os.listdir("/proc")` raised FileNotFoundError after
the kill had already succeeded. The function's outer except turned that
into `{"error": "...No such file or directory: '/proc'", "exit_code": 1}`
and skipped the state write that marks the session stopped — the agent
is told a stop failed that actually worked.

Guard the sweep with a procfs check, the way `_scan_running_model_processes`
already does a few hundred lines up. The root and the check now live in
`core/platform_compat`, which is where OS differences belong and which
makes both branches patchable from a test on either kind of host.

Adds a class-level guard test: the third instance of this defect, and two
of the three were found by reading source rather than by a test.
2026-09-30 12:13:04 +02:00
Léo 12f74ec9ea test(smoke): add a release smoke suite over every advertised feature area
The decomposition lanes have two safety nets and neither covers the
product. The checkpoint benchmark measures the agent runtime; the
computed-style snapshot pins the CSS. Nothing checked that Notes,
Calendar, Documents, Email, Memory, Cookbook or Settings still worked
after a route package moved or a 17,000-line module was split - and the
unit suite does not, since a byte-identical file move can break tests
that pass on the base branch with CI green throughout. The 28 Playwright
specs we do have are all under tests/e2e/photo-editor/ and no workflow
runs them.

scripts/odysseus-smoke boots this worktree through `odysseus dev` and
runs tests/smoke/: one scenario per area, each asserting a user-visible
outcome rather than a status code. Models come from a deterministic
OpenAI-compatible stub on an ephemeral loopback port; email reuses the
existing ODYSSEUS_EMAIL_FIXTURE path rather than inventing a second
mechanism. No scenario touches a live endpoint or the network.

The report is a per-area table that prints the areas the suite does not
cover next to the ones it does, and builds its rows from the registry
rather than from what happened to run, so an area cannot go missing by
having its module deleted or renamed. Under a plain pytest with nothing
booted every scenario skips with the reason, so the full suite stays
green.
2026-09-30 12:12:58 +02:00
Léo 10cb8fc669 chore(tools): add a read-only ref-to-ref parity audit
`lab` and the public `dev` line share only the repository's first commit as a
merge base, so `git log lab..dev` lists two thousand commits that are almost
all already present on both sides under different SHAs. Nothing in that output
says which public fixes never reached `lab`, which is the only question that
matters before `lab` becomes a release.

`scripts/ref_parity_audit.py` samples the most distinctive added lines from
each commit in the range and searches the other tree for them with
`git grep -F`, then reports the file-level presence diff. The two complement
each other: a commit whose probes are all found while one of the files it added
is missing from the target is a fix whose production change was reproduced
without its test, which the line sampling alone cannot see.

Probes are stripped of indentation and searched for anywhere in the tree, so a
port that moved or was re-indented still reads as present. Verdicts are
absent / partial / present / no-probe, and the report says outright that the
commit verdicts are a heuristic while the two file lists are exact.

Read-only by construction: `git log`, `show`, `diff`, `grep`, `ls-tree` and
`merge-base` only, no remote access, and it does not import the app package.
2026-09-30 11:53:31 +02:00
Léo 32d9dbc267 fix(tests): resolve temp paths consistently on macOS
Three of the six recorded failures were the same test bug: an unresolved
/tmp path compared against a resolved /private/tmp one. macOS makes /tmp a
symlink, so a fixture built with tempfile.mkdtemp(dir="/tmp") and a code path
that resolves what it reports disagree about a file both found correctly.

test_code_nav_tools builds its fixture unresolved and compares it against the
reported path. One realpath fixes both of its failures.

test_glob_confined_e2e is the same cause through a longer route: it mixed
os.path.realpath(ws) with an unresolved secret directory, so relpath emitted
"../../../../tmp/<absolute path>" and the assertion that the absolute path was
absent from the output matched it as a substring. Resolving the secret
directory puts both sides in one tree and the relative path stays short.

macOS full suite goes from 6 failures to 3. The remaining three are an ffmpeg
build without a WebP encoder, a socket test that needs a fast connection
refusal, and the rich-text colour test that is still unexplained.

The ledger is updated in the same change so it does not describe failures that
no longer happen.
2026-09-30 10:56:58 +02:00
Léo eabdf84669 fix(tests): stop test_auth_regressions leaking stub modules
Running test_auth_regressions.py before the email modules failed 23 tests
that pass in isolation:

  pytest -p no:randomly tests/test_auth_regressions.py \
                        tests/test_email_urgency_checkpoint.py
  23 failed, 15 passed       (38 passed in the reverse order)

Every failure was ImportError "cannot import name X (unknown location)"
against a module already in sys.modules, which is what an empty stub module
looks like to a later import.

Two writes leaked, both in this file:

- test_pop_notifications_owner_filtered inserted five empty stub modules with
  a bare sys.modules[name] = mod and never removed them.
- _ensure_stub wrote its stub into sys.modules itself, so the autouse
  fixture's monkeypatch.setitem three lines later captured that stub as the
  value to restore. The fixture looked like it cleaned up and could not.

Both now go through monkeypatch, including the parent-package stub and the
attribute wiring, so everything is undone at teardown. The redundant setitem
calls in the fixture are gone: re-setting a key whose stub is already
installed is what made the leak invisible.

Pre-existing on lab, not introduced by any open PR. Verified against the
email subpackage branch too: identical numbers there before this fix.
2026-09-30 10:36:00 +02:00
Léo b7002c0fe3 fix(email): import spinnerModule in the modules that use it
Six of the seven modules call `spinnerModule.createWhirlpool(...)` and none
of them imported it. Everything still parsed, every module still loaded, and
the Email Settings page threw `ReferenceError: spinnerModule is not defined`
the moment `settings.js` mounted it — the panel rendered its loading spinner
and stopped there.

The cause is worth writing down because it is the same shape as the bug: the
import statements were collected with a pattern that was not anchored to the
start of a line, and the header comment says "the old import path keeps
resolving". That prose matched first and swallowed the real
`import spinnerModule from '../spinner.js';` that followed it.

`test_no_module_uses_a_package_name_it_never_bound` closes the class. Nothing
else here can: `node --check` parses without resolving scope, and loading a
module does not run the function body where the throw lives. It checks the
package's own vocabulary — every name any module in it binds — rather than
trying to model the browser's globals, so it has no false positives and still
catches the one mistake a split actually makes: the declaration stays behind
and the use moves.
2026-09-30 09:55:39 +02:00
Léo 1c5f60539f refactor(settings): move the shell out of settings.js into settings/
settings.js is 5,721 lines and the registry/navigation/search/sidebar/
lifecycle primitives already live in static/js/settings/. What was left
behind in the coordinator was the layer above them: what happens when a
panel becomes active, where an admin-managed tab is handed to admin.js,
which elements are admin-only, the Appearance window fade, and the
public open/close. That layer reached module-global `modalEl` and
`initialized` directly, so none of it could be exercised without
booting every panel in the file — and every panel I eventually move out
would have to route back through it.

Three modules, no behavior change:

  shell.js       panel-activation side effects, the admin handoff,
                 .admin-only visibility, open()/close(). Takes what it
                 needs from settings.js as injected callbacks, the same
                 shape bindSettingsNavigation() already uses, so it
                 holds no panel state.
  peek.js        the Appearance window fade and its toggle. It is window
                 chrome rather than Appearance panel data, and it has to
                 be cleared when the user leaves that panel.
  oauthReturn.js the once-per-load return path from the Google OAuth
                 redirect. It was an IIFE running at module evaluation
                 in the middle of a 5,700-line file.

settings.js keeps open/close/syncAdminVisibility as exports, so every
caller (app.js, calendar.js, chatStream.js, gallery.js, admin.js,
modelPicker.js, slashCommands.js, chatRenderer.js, emailLibrary.js) is
untouched. 5,721 -> 5,583 lines; the settings/ modules go 887 -> 1,107.

The real-ESM coordinator smoke now links the three new files and asserts
what moved: admin-only elements hidden for a non-admin and shown for an
admin, an admin-managed tab click handed to admin.js without a second
local activation, and the Peek fade applying on Appearance and clearing
when the user navigates away. The OAuth test follows its handler to the
new file and additionally pins the coordinator wiring, since "uses the
module-local open()" is now a property of the seam rather than of one
source slice.

No new module needs a cache-busting query or an sw.js precache entry:
the existing settings/ submodules have neither, they load transitively
from settings.js's versioned URL, and sw.js serves JS network-first.

admin.js stays where it is. It has no shell to extract — open() and
close() already delegate to settingsModule, and its 4,122 lines are all
panel code. That is a panel split, not this one.
2026-09-30 09:45:23 +02:00