Every script a role ships now lives under `files/<language>/`, and the unit
suite is split the same way: `tests/unit/python/` beside new sibling trees for
the other languages. `tests/lint/filesystem/test_role_files_layout.py` keeps it
that way; directories whose shape is dictated from outside carry a `.nocheck`
naming the constraint (Docker build contexts, WordPress mu-plugins, the Joomla
extension, Odoo addons, Playwright suites, the `files/test/test.sh` harness
that `test-e2e-cli` resolves generically).
This cannot be applied in halves. Moving a file and repointing what names it
are the same change: 14 references resolve only at deploy time, so the tree is
green either before or after, never between.
Those 14 were found by static resolution over every reference class, not by the
test suite -- a full `make quality-high` was green while all of them were
broken:
* 7 `src:` values built from a role variable holding a bare filename
(svc-bkp-remote-2-local, decidim, espocrm, friendica, joomla, pixelfed x2).
The variable doubles as the destination name inside the container, so the
language segment belongs at the call site, not in the variable.
* `sys-service/tasks/04_base.yml` resolved every consumer's systemd script
through `first_found(..., errors='strict')` over `files/script.{sh,py}`.
18 roles ship exactly that and have no template fallback: the play would
have died on each.
* `sys-front-inj-javascript` fell back to `<role>/files/javascript.js`; the
interface now reads `files/javascript/javascript.js`.
* `sys-front-inj-logout/templates/head_sub.j2` built the cache-busting mtime
path through `path_absolute(... 'files', ...)`.
* 4 `ansible.builtin.script:` invocations naming a bare command
(pull.py, prune.sh, set_postgres_superuser_password.sh, ganesha-serve.sh).
* `dev-python/files/shell/install.sh` pointed at a sibling role's script.
Depth-dependent paths inside the moved trees were bumped accordingly: 59
`parents[N]` computations, 12 relative climbs, and the per-role recover CLI,
whose path `cli/administration/recover/recoverers.py` still assembled from the
pre-move location.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
b6e3368a2 added the click on Funkwhale's own confirmation ("Yes, log me out!"), which fires POST /api/v2/users/logout. c7e5b0a27 then added the return to appBaseUrl so the assertion lands on Funkwhale rather than on the logout page. Each is right on its own; together the navigation aborts the request 9ms after it starts.
Measured in all three traces of job 95883448598: the POST carries status -1 with send/wait/receive all -1 - never answered, aborted. Click at 59744ms, POST at 59876ms, goto at 59885ms. The WebSocket that follows still carries sessionid=bam0ska8z0qkbtegsp10iot028cbcsr9, and openresty logs the matching 499. Only swarm+tor is slow enough to lose the race; the other seven Funkwhale variants of that run are green.
The catch keeps those seven safe: whether their confirm click fires the same XHR is unverified, and without it a missing POST would turn them red. Promise.all rather than sequential awaits because waitForResponse has to register before the click.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Funkwhale's sign-in input carries id=username-field, name=username and autocomplete=username -- and no type attribute at all. An attribute selector does not match an element whose attribute is absent, so input[type='text'], input[type='email'] found zero nodes and locator.fill burned its full 30s timeout, three times, for both personas. The password field beside it does carry type=password, which is why the assertion one line above passed and only this one failed.
Adding an autocomplete=username arm matches the field without widening into input:not([type]): Funkwhale's header search box is typeless too, and .first() would then be decided by DOM order alone.
Transport does not enter into it: a selector that matches no node matches none over any route, and the run was not starved either -- the trace's slowest request is 360ms with the /login navigation at 107ms. The locator has been iterated twice before, scoped to the form and unscoped again, without anyone reaching the missing attribute.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Playwright declares the timeout option on locator.isVisible() and isHidden() as deprecated and ignored: both return immediately and never wait. Passing resolveTimeout(10_000) to one therefore changes nothing at runtime and only tells the reader a wait happened that never did. That reading is what let 7538b8a0b conclude two genuine call sites were fine, and what hid the false negative b97cf8888 had to fix.
Sixty call sites across fifty-eight role-local specs lose the argument and nothing else. Every added line in this commit is a bare isVisible(); a grep for added lines that are not one returns zero, so a behaviour change cannot have entered here. Files whose diff also alters waiting semantics are deliberately not part of this commit.
A repository lint added in b97cf8888 keeps the construct out. It matches isVisible and isHidden only, because isChecked, isDisabled, isEditable and isEnabled take a real timeout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Variant 2 of the swarm matrix fails the sign-out assertion for both personas,
and variant 0 fails the same assertion for a different reason. The artefacts of
run 31658241728 separate the two states. Variant 0's snapshot at failure is the
universal-logout terminal page (`- text: You have been logged out.`): the
session is gone and the test only had to return to the app, which f683d18b8
does.
Variant 2 stops one step earlier, for administrator and for biber alike:
- heading "Are you sure you want to log out?" [level=2]
- paragraph: You are currently logged in as administrator
- button "Yes, log me out!"
Clicking the sidebar control routes Funkwhale to its own confirmation view, and
the session survives until that button is pressed. Returning to the app base and
asserting a sign-in offer therefore fails, and it would keep failing with
f683d18b8 alone -- that commit navigates away from the dialog without answering
it.
The new block presses the confirmation when it appears. One click covers both
personas, and the artefact lists no other failing test in the job.
Variant 0 is unaffected: its terminal page carries no button at all, so the probe
expires and the flow continues as before, at the cost of ten idle seconds.
The name regex is measured against the observed strings rather than assumed. It
matches "Yes, log me out!", "Log out", "Sign out" and "Logout", and rejects
"Log in" and "Sign in", so it cannot re-select the sign-in affordance that the
next assertion looks for.
Not verified: the click has not been run against a live instance. What is proven
is that the button is there and that the selector selects it, not that Funkwhale
ends the session afterwards.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The spec signed out and then asserted, on whatever page it had landed on, that
Funkwhale offers sign-in again. That page is never Funkwhale.
Both exits leave the app by design. The fallback navigates to
${appBaseUrl}/logout, but on every app vhost that exact path belongs to the
universal-logout service: roles/web-svc-logout/templates/logout-proxy.conf.j2
defines `location = /logout` with a proxy_pass, included through
sys-front-inj-all into the shared vhost. The in-app control is no better --
initLogoutPatch rewrites it to Keycloak's end-session URL with redirect_uri set
to the primary domain.
Measured in the run's trace rather than inferred:
https://audio.infinito.example/logout 200, content-length 25
body "You have been logged out."
clear-site-data "cache","cookies","storage"
set-cookie sessionid=, csrftoken=, _oauth2_proxy=,
_pk_* -- all Expires 1970, across the
bare, app and parent domain scopes
So the sign-out itself works; the session really is gone. Only the assertion sat
on the wrong page, where no sign-in affordance can appear at any timeout.
Returning to appBaseUrl before the expectation puts the claim where it can be
true or false: the app, freshly anonymous. The locator is untouched -- 8aaac027d
and bfe7741c6 already settled how signInAffordance matches Funkwhale's bare-div
control, and this change does not revisit it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
75 commits: the socialhome role reimplemented against the project it actually
deploys, native-login specs for funkwhale and shopware, a window.open IdP
handoff and a settings-link fallback in the shared persona helpers, a swarm
converge budget that gives up on a terminal task, and a long tail of role
fixes.
Eleven conflicts. The shared persona helpers (keycloak.js, logout.js) take the
incoming structure — they gained real behaviour this branch never had — with
this branch's resolveTimeout convention re-applied over it and
confirmKeycloakLogoutIfPrompted re-attached at every return point; without it
the Keycloak SSO session outlives the logout. playwright.config.js keeps this
branch's version: both sides set actionTimeout, but ours routes through an env
var and 15s is too short behind an onion. socialhome's spec takes the incoming
content — 2dbd6eb10 found the role described the wrong upstream project
entirely — and keeps gotoOnion and expectHstsWhenTls, since demanding HSTS over
a plaintext onion is a false failure. Both READMEs were regenerated rather than
merged by hand.
Three collisions arrived with no conflict marker at all, because each side
changed a different place in a different file:
- group_vars/all/18_swarm.yml declared swarm_stack_converge_retries twice.
Same value, so nothing breaks today; editing the documented one would have
silently changed nothing.
- svc-net-tor and web-app-socialhome both claimed 192.168.74.0/24. Socialhome,
the newer role, moves to 192.168.75.0/24.
- web-app-socialhome/meta/schema.yml arrived as a new file, so ef5ea7cb5's
rename never saw it. Its credential would not have resolved and the role
would have deployed with an unusable administrator account.
The incoming specs also needed this branch's conventions: raw timeouts wrapped,
four page.goto calls routed through gotoOnion, a request.get given an
onion-aware timeout, and 21 roles marked playwright-service-flag over the tor
block this branch added to them.
make quality green on the merge result.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The role had no migration step anywhere in its history
(git log --all -S migrate over the role is empty). The api container runs
'command: gunicorn', which the image entrypoint execs straight into
without touching the database, so the schema was never created and
POST /api/v2/users/login died with
django.db.utils.ProgrammingError: relation "users_user" does not exist.
Adds a one-shot migrate service using the image's own documented
bootstrap. Verified against funkwhale/api:2.0.6 on an empty postgres:
'migrate' runs funkwhale-manage migrate without exec, applies the full
graph and exits 0, so the container reaches Shutdown/Complete and the
swarm converge gate's completed-oneshot carve-out accepts it. The same
probe showed the api container boots and stays up against an empty
schema rather than exiting, so it cannot trip the gate's fail-fast path
while migrate is still running.
CACHE_URL is the second blocker on the same request path: funkwhale keeps
sessions in the cache, so without it auth.login() writes to the image
default redis://127.0.0.1:6379/0 where nothing listens - the celeryworker
already proved that with ECONNREFUSED against that exact URL. Migrate
alone would have moved the 500 from the database layer to the session
layer.
LDAP_USER_ATTR_MAP was single-quoted JSON while funkwhale parses it with
env.list() then split(":"), which yielded keys like '{"username' and
populated no profile field.
The native-login spec asserted on the canonical domain as a substring.
Keycloak's authorization URL carries that domain inside redirect_uri and
oauth2-proxy serves /oauth2/* on the canonical host, so the poll could
never become true; it now requires the host AND the absence of the auth
chain.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bfe7741c6 anchored both credential fields to form:has(input[type=password]) to keep the username out of a header search box. Funkwhale renders no form element at all: the login chunk of funkwhale/front:2.0.6 emits four div and two span tags and nothing else, the inputs coming from a shared component. The locator therefore matched nothing and the spec would have failed on its first assertion, blaming the application for the author's mistake.
Return to the visible-scoped selectors the shopware spec uses, which did run green against a live stack. The search box that motivated the scoping was never evidenced: the failure artifact shows navigation links on the landing page, no visible text input. The URL poll added by the same commit is unaffected and stays.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The spec picked its username field with a global input[type=text]:visible .first(). Funkwhale's header carries a search field, so whenever that renders before the login input in DOM order the credentials would go into the search box and the run would fail later, at the password field, with a misleading message. Anchor both fields to form:has(input[type=password]) instead, letting the password field define which form is meant.
Also wait for the URL to leave the canonical domain before driving the Keycloak form. Without it a proxy that fails to intercept /login surfaces as a bare 60 second timeout inside the shared Keycloak helper, with nothing pointing at the missing redirect. The pattern is taken from roles/web-app-n8n/files/playwright/_shared.js, which runs the same chain and is green in CI.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The shared persona helper cannot drive this role's sign-in in any matrix variant. The oauth2 ACL blacklists only /login, and sys-svc-proxy renders a blacklist as expose-everything-then-protect-the-listed-paths, so the helper's landing on / is public and the proxy never intercepts it. Funkwhale renders its own sign-in control as a bare div, which the helper's link and button role query cannot select. And application-level authentication is Funkwhale's LDAP form rather than OIDC, so even a completed Keycloak round trip at the proxy leaves the Funkwhale session anonymous.
The previous gate derived both blocks from services.sso.enabled, which unblocked the personas in exactly the variant where the chain does not work. The sibling jobs of run 31341444049 show it without interpretation: Funkwhale 0 with sso on fails, Funkwhale 2 with sso off passes, and it passes only because the block fires there.
The journey is not dropped. files/playwright/test-native-login.js drives both personas through Funkwhale's own chain: /login, the Keycloak round trip whenever the proxy gates that route, then Funkwhale's own sign-in form, an authenticated-surface assertion and sign-out back to the anonymous state. It skips when services.ldap.enabled is false, because no persona holds an account in that variant. Stage one reuses performKeycloakLoginForm from the shared helper without modifying it.
The selectors are structural rather than Funkwhale-specific: the upstream front-end source is not reachable from this environment, and the CI traces only ever captured the landing page, so the spec keys on input types the way the shared Keycloak helper does. The anonymous-state assertion is evidence-backed: the failure artifact shows the sidebar offering sign-in while unauthenticated. The spec has not yet run against a live stack.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Over Tor the redirect chain behind a login/logout/OIDC click routinely exceeds Playwright's fixed 30s action timeout, so the click throws before the flow's scaled waits run. Sweep all role Playwright suites and the shared persona helpers: give every navigation/auth-transition click an onion-scaled timeout (.click({ timeout: resolveTimeout(30_000) })) and route the remaining raw millisecond literals (timeout options, waitForTimeout, sleep-promise setTimeout, positional wait args) through resolveTimeout. Local UI clicks (menus, toggles, modal dismissals, DOM clicks inside evaluate) stay untouched.
Two new lints keep it that way: test_no_raw_timeout_literals forbids raw numeric timeout literals in Playwright specs (opt-out: nocheck: raw-timeout), test_persona_clicks_scaled requires every click in the shared persona auth flows to carry a resolveTimeout timeout (opt-out: nocheck: unscaled-click).
Verified: node --check on all 133 changed JS files, all 13 playwright lints green, make autoformat clean, make test green (2745+196+163+4 tests OK).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New lint test_no_raw_goto.py forbids raw `page.goto(...)` in role playwright specs + persona flows (the gotoOnion wrapper in personas/utils is exempt) so navigation retries transient Tor-transport errors over onion. Migrates every raw call across ~75 roles (343 sites) to `gotoOnion(page, ...)` and teaches gotoOnion to detect relative onion URLs (`isOnionCanonical`). gotoOnion is a behaviour-identical drop-in on clearnet (single attempt, same return). Verified: no-raw-goto lint green, eslint 0 errors, make test green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the global TIMEOUT_FACTOR and a shared timeouts.js helper: resolveTimeout(base) scales by TIMEOUT_FACTOR and, over a .onion target, an extra multiplier, so Tor's per-request latency no longer trips hard-coded timeouts. Migrate every raw timeout in the specs (timeout:/timeout=/setTimeout/waitForTimeout) to resolveTimeout, stage the helper in all three staging paths, and add test_no_raw_timeouts to forbid raw numeric timeouts. Clearnet behaviour is unchanged (factor 1: resolveTimeout(N) === N).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
HSTS (Strict-Transport-Security) is an HTTPS-only header: a browser ignores it
over plain http (RFC 6797) and OpenResty only emits it on the TLS vhost. On an
onion node TLS is off and every domain is served over http, so the inline
`expect(headers["strict-transport-security"]).toBeTruthy()` in 33 specs failed
with `Received: undefined`.
Route all 33 through the shared expectHstsWhenTls(headers, baseUrl, label)
helper (personas/utils/tls.js), which requires HSTS over https and asserts its
absence over http. On a TLS node the base URL is https, so the assertion is
identical to before; on an onion node the same spec now passes. HSTS logic
lives in one place (SPOT) instead of 34 inline copies.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>