Implements the per-kind orchestration (clone @ pinned ref, inject core's
freshly-generated wire-vectors fixture via ETHERPAD_WIRE_VECTORS, run each
client's vectorTest + smokeCmd against the booted server) in a testable
run-clients.sh, and flips the three manifest entries to enabled, pinned to the
commits that carry their Phase 2 tests:
ether/pad#1, ether/etherpad-cli-client#136, ether/etherpad-desktop#78.
Validated end-to-end locally against a real core: all three clients' vectors +
live smoke pass. Refs should be bumped to the squash-merge commits once those
client PRs land.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: design for downstream client compatibility tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: Phase 1 implementation plan for downstream compat tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(downstream): add golden wire-vector generator
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(downstream): add committed golden wire-vectors fixture
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(downstream): assert wire-vectors fixture stability + consistency
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(downstream): pin socket.io handshake + USER_CHANGES sequence
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(downstream): snapshot client-facing HTTP API shapes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(downstream): add client manifest (entries disabled pending Phase 2)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci(downstream): add downstream-smoke workflow (boot/self-check/teardown + manifest scaffold)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci(downstream): validate settings rewrite + ignore docs/** (Qodo)
Fail fast if the template's port/auth literals drift so a no-op sed can't
silently boot the smoke server on the wrong port/auth. Also ignore docs/**
(not just doc/**) so docs-only PRs don't trigger the boot job.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(docker): don't let pnpm self-provision a pinned version on offline boot (#7911)
The official Docker image installs pnpm directly via npm (corepack was dropped
for Node 25+). Standalone pnpm still honours the "packageManager" pin in
package.json: the image's pnpm intentionally lags that pin (pnpm 11.1.x enforces
a minimum-release-age policy the frozen-lockfile build can't satisfy), so pnpm
treats every invocation — including the informational `pnpm --version` probe
Etherpad runs at startup — as a request to download and run the pinned build.
Behind a corporate firewall / in an air-gapped install that download fails:
[WARN] plugins - Failed to get pnpm version: Error: Command exited with
code 1: pnpm --version
which is what #7911 reported.
Fix — neutralise the gap instead of closing it (closing it would break the
frozen-lockfile build on 11.1.x):
- Dockerfile build stage sets `pnpm_config_pm_on_fail=ignore` (the pnpm 11
successor to managePackageManagerVersions), inherited by the development and
production runtime stages. pnpm then uses the installed pnpm instead of
fetching the pinned one. It does not change which pnpm runs the build-time
install, so the frozen-lockfile build is unaffected.
- plugins.ts startup probe and the updater's pnpm-on-PATH checks run with the
same flag, so the fix also covers non-Docker offline installs and the probe
can never fail-loud.
Add a backend spec that fails CI if the offline guard is dropped while the image
pnpm differs from the package.json pin.
Verified with a standalone (non-corepack) pnpm: a "packageManager" mismatch
makes `pnpm --version` exit 1 by default (tries to fetch the pinned build), and
exit 0 reading the local version with pm_on_fail=ignore.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test: scope pnpm offline-guard check to the runtime-inherited build stage
Address Qodo review: the regression spec matched ENV pnpm_config_pm_on_fail
anywhere in the Dockerfile, so it would still pass if the guard were removed
from the `build` stage (which the runtime stages inherit) but left in the
throwaway `adminbuild` stage — reintroducing the offline failure. Extract the
`build` stage block and assert the ENV is present there specifically.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Release workflow has failed on the last two dispatches with:
Error: No changelog record for 3.4.0, please create changelog record
at bin/release.ts:146
3.3.0 was never released (latest tag/release is v3.2.0). The repo already
carries a complete `# 3.3.0` changelog section, and the intent is to ship
3.3.0 next. A prior commit reverted the root and admin/ package.json back to
3.2.0 to set up a clean 3.2.0 -> 3.3.0 minor bump, but missed src/package.json
and bin/package.json, which were left at 3.3.0.
bin/release.ts reads the current version from src/package.json, so it computed
minor(3.3.0) = 3.4.0 and aborted on the missing 3.4.0 changelog. Syncing src
and bin back to 3.2.0 (matching root + admin) makes the next `minor` dispatch
compute 3.2.0 -> 3.3.0, which satisfies the existing changelog check.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(theme-color): emit media-scoped dark variant for iOS Safari (#7606)
The theme-color meta only had a single light value rendered server-side;
dark mode was applied purely by JS (skin_variants.ts) after page load.
iOS Safari colors the address bar at parse time and does not reliably
repaint when JS mutates the meta later, so dark-mode iPhone users kept a
white address bar above a dark toolbar (the green Chromium Playwright test
masked this because Chrome does honor the dynamic update).
Emit a prefers-color-scheme media-scoped pair server-side so the correct
color is chosen at first paint without JS:
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#485365" media="(prefers-color-scheme: dark)">
- Add SkinColors.darkToolbarColor() (reuses toolbarColorForTokens).
- Expose enableDarkMode via getPublicSettings so the templates can gate the
dark variant on it (no dark variant when dark mode can't be reached).
- Apply to both pad.html and timeslider.html.
- updateThemeColorMeta now updates every theme-color meta so a manual
#options-darkmode toggle still wins over the media scoping on
desktop/Android.
- Backend + frontend tests updated to assert the media-scoped pair and the
enableDarkMode-off case.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(theme-color): prevent the light-mode flash on dark-OS load (#7606)
The theme-color meta fix corrects the address-bar tint, but dark-OS users
still saw the whole page painted light before the JS bundle ran and applied
the dark skin classes in postAceInit — a visible flash on every browser,
not just the mobile address bar.
Add a tiny blocking inline script in <head>, before the stylesheet, that
applies the dark skin classes to <html> synchronously during parse when the
client is in dark mode (matchMedia + no localStorage white-mode override).
The condition mirrors pad.ts's auto-switch, which still runs on init to wire
up the #options-darkmode toggle and theme the editor iframes (those don't
exist yet at parse time). Gated on the same enableDarkMode + colibris check
as the dark theme-color variant. Applied to pad.html and timeslider.html.
Verified in Chromium: at domcontentloaded a dark-OS client's <html> already
carries super-dark-editor/dark-background/super-dark-toolbar (no flash), and
a light-OS client is unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(changelog): note the dark-mode address-bar + flash fix (#7606)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(theme-color): guard pre-paint script on #skinvariantsbuilder; ignore updater state
Address PR review:
- Copilot: the inline pre-paint dark-mode script must skip the auto-dark
switch on the #skinvariantsbuilder hash, matching pad.ts — otherwise it
forces super-dark classes on a dark-OS client and fights the variants
builder UI. Added the guard to pad.html and timeslider.html and a backend
assertion so it can't regress.
- Qodo: ignore var/update-state.json (runtime updater cache) so the server
run that regenerates it can't dirty the tree or be committed accidentally.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(editor): tag inserts with clientVars author until userAuthor propagates
Fixes the intermittent Firefox `clear_authorship_color` flake ("clear authorship
colors can be undone to restore author colors").
Root cause: the inner editor's `thisAuthor` starts '' and is only populated when
collab_client's `setProperty('userAuthor', userId)` reaches the inner frame —
that call is queued by the outer ace wrapper via pendingInit until the iframe
loads, so it is applied asynchronously. Under Firefox timing the first keystrokes
can beat it, so freshly typed text is tagged author=''. An empty author
canonicalizes to no-author, producing an unattributed insert (`Z:1>5+5$Hello`)
that the server's pad-corruption guard rejects ("submitted an insert without an
author attribute"), dropping the whole USER_CHANGES and losing authorship. Undo
then cannot restore an author color and the test sees `<span class="">`.
Fix: a `getLocalAuthor()` helper that falls back to `clientVars.userId` (the same
author id, available synchronously in the inner frame — already used as `myId`
elsewhere) whenever `thisAuthor` is still empty, applied at the three new-text
insert sites. The intentional clear-authorship path (`['author','']`) and the
server-side guard are unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(editor): also seed line-attribute author to close the same race
The first commit fixed text inserts, but line-attribute changes (lists, headings,
alignment) emit a line-marker insert tagged with AttributeManager.author, which
likewise starts '' and is only set when the async setProperty('userAuthor')
lands. An early list/heading could therefore emit an unattributed line-marker
insert and hit the same server-side rejection.
Seed documentAttributeManager.author from getLocalAuthor() (clientVars fallback)
right after it is constructed; the userauthor handler keeps it in sync after.
Audited the other author-tagging paths: all ace2_inner text-insert sites go
through getLocalAuthor()/authorizer now, and contentcollector (paste/import)
derives authorship from the pasted DOM's author- classes via className2Author —
a different mechanism, not this async race — and self-guards on `if (state.author)`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(editor): read the local author from the TOP pad window, not the inner frame
v1 of this fix fell back to `window.clientVars?.userId`, but that did NOT resolve
the flake — CI reproduced the identical failure and the server log still showed
the unattributed-insert rejection.
Ground truth via direct DOM measurement of a running pad: the inner editor
iframe's `window.clientVars` is NEVER populated (undefined at t=0/1/3/6s for the
life of the pad), so the v1 fallback always returned ''. The author id lives on
the TOP pad window (`window.top.clientVars.userId`), set by pad.ts when the
CLIENT_VARS message arrives — which necessarily precedes editor creation, so it
is reliably available from the inner frame the moment the editor can accept
input.
getLocalAuthor() now reads window.top.clientVars.userId (guarded with try/catch
for cross-origin embedded pads). thisAuthor still takes precedence once the
queued setProperty('userAuthor') lands. All text-insert sites and the
line-attribute seed already route through getLocalAuthor(), so both paths are
covered.
Verification: ts-check passes; fix source confirmed by direct measurement (the
top window reliably holds the author at editor-init time). Local end-to-end
repro was not possible — the dev server's require-kernel bundle did not reflect
working-tree edits — so this is validated against the measured source + CI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(editor): walk ancestor frames for the author instead of window.top
Addresses a correctness gap flagged in review: reading window.top.clientVars
fails for same-origin embeds, where window.top is the host page (accessible, so
no exception is thrown) and has no Etherpad clientVars — getLocalAuthor() then
returns '' and the unattributed-insert bug recurs.
Walk up the ancestor chain (inner -> ace_outer -> pad window) and return the
first frame that has clientVars.userId. This stops at the pad window and never
depends on window.top, so it is correct for normal pads, same-origin embeds, and
cross-origin embeds (the try/catch ends the walk if an ancestor is cross-origin,
though the pad window — always same-origin with the editor frames — is reached
first). Behavior in the non-embed case is unchanged (pad window is 2 hops up).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(collab): stamp the author onto outgoing inserts in collab_client
Replaces the earlier getLocalAuthor attempts (v1-v3, reverted from ace2_inner.ts),
which all failed because they tried to *read* an author that genuinely does not
exist yet: the local author id (clientVars.userId) only arrives with the
CLIENT_VARS socket message, and under load (Firefox + plugins) the editor can
become editable and the user can type before that message lands. At that instant
there is no author anywhere client-side, so the editor emits an unattributed
insert that the server's pad-corruption guard rejects — dropping the change and
losing authorship (the clear_authorship_color flake).
Fix where the author IS reliably available: collab_client only exists after
CLIENT_VARS has arrived, so its `userId` is always populated. A new pure helper
stampAuthorOnInserts() rewrites the outgoing wire changeset so every '+' op
carries an author, stamping userId onto any that lack one, just before it goes on
the wire (both the USER_CHANGES send and the reconnect/further-changeset path).
Independent of editor-init timing.
Verification:
- Unit test (stampAuthorOnInserts.test.ts, 5 cases): stamps the exact flake
changeset `Z:1>5+5$Hello` -> authored + checkRep-valid + text preserved; leaves
already-attributed inserts and keep/remove-only changesets unchanged; never
invents an author when userId is empty; preserves other attributes.
- ace2_inner.ts reverted to develop (the editor is unchanged; the fix is fully
localized to the send path).
- Local run of the clear_authorship_color spec with the fix: all pass, no
server-side unattributed-insert rejections; full backend-new vitest green
(the unrelated backend-tests-glob env-only failure aside).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Hardening: API request handling, token generation, and plugin loading
- pad_utils.randomString: generate the random IDs via crypto.getRandomValues
(CSPRNG) instead of Math.random.
- OAuth2Provider: constant-time password comparison and a uniform failure delay
on the OIDC interaction login; own-property user lookup.
- API.appendChatMessage: require the pad to already exist (getPadSafe),
consistent with the other content API methods.
- RestAPI /api/2: forward only the authorization header rather than merging all
request headers into the API field set.
- LinkInstaller: validate plugin dependency names before building filesystem
paths from them.
- admin file server: return a generic error message and log details server-side.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Address review: timingSafeEqual on raw bytes; align /api/2 auth fallback
- OAuth2Provider.constantTimeEquals: compare raw UTF-8 bytes with
crypto.timingSafeEqual instead of hashing them first. Resolves the CodeQL
"password hash with insufficient computational effort" alert while keeping a
content-independent comparison (length difference is covered by the uniform
failure delay).
- RestAPI /api/2: fall back to the authorization header whenever the field is
falsy (not only null), matching the openapi.ts handler so the two routers
authenticate identically (Qodo).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* appendChatMessage: throw explicit error instead of getPadSafe (review)
Per review: replace the getPadSafe(padID, true) existence check with an
explicit `throw new CustomError('padID does not exist', 'apierror')` so chat
messages can't create pads, without fetching the pad.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Escape exported data-* attributes; require explicit deploy credentials
- ExportHtml: escape the name and value of attributes emitted by the
exportHtmlAdditionalTagsWithData hook, consistent with the URL/text
escaping already applied when generating exported HTML.
- docker-compose: require ADMIN_PASSWORD and the database password to be set
explicitly (no default fallback); default TRUST_PROXY to false.
- Settings: log a warning (error level in production) when an account uses a
default/placeholder password from the shipped config.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Settings: also warn on default OIDC client secrets
Extend the placeholder-credential check to cover sso.clients[].client_secret,
so a deployment that enables SSO without setting ADMIN_SECRET / USER_SECRET is
flagged (error level under NODE_ENV=production) the same way default account
passwords are.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Move docker-compose changes to a separate PR
The deployment-default changes (required credentials, TRUST_PROXY) are being
discussed separately; this PR keeps only the non-breaking export escaping and
credential-warning changes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In-pad history mode positioned the timeslider iframe (#history-frame-mount)
as an absolute, inset:0 overlay over #editorcontainerbox. That filled the
editor area but took the iframe out of flow, so any in-flow side panel in
#editorcontainerbox — e.g. ep_webrtc's video column (#rtcbox) — ended up
hidden beneath the overlay. In live mode the same panel sits beside the
editor, so the two modes disagreed (ether/ep_webrtc rendered nothing in the
timeslider).
Make the iframe occupy the same in-flow flex slot the live editor uses
(flex: 1 1 auto in the row-flex #editorcontainerbox) so side panels lay out
identically in both modes.
This surfaced a latent bug: `body.history-mode #editorcontainer { display:
none }` (specificity 0-1-1) was being outranked by the two-id layout rule
`#editorcontainerbox #editorcontainer { display: flex }` (0-2-0), so the live
editor was never actually removed from flow — the absolute overlay merely
painted over it. With the iframe now in normal flow that no longer hides the
editor, so the hide rule is given matching specificity
(body.history-mode #editorcontainerbox #editorcontainer) to win the cascade.
Added a padmode.spec.ts regression test that injects a left-pinned in-flow
side panel, enters history mode, and asserts the live editor is display:none,
the iframe is not absolutely positioned, and the iframe lays out beside the
panel filling the editor's remaining width (no overlap).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: URL-encode pad names in admin 'Open' button and recent pads (#7865)
- encodeURIComponent in admin PadPage 'Open' button href
- decodeURIComponent when reading pad name from URL pathname
in pad_userlist.ts and colibris/pad.js (recent pads storage)
- encodeURIComponent in colibris/index.js recent pads href;
display text uses stored name directly (no double-decode)
- add recent_pads spec asserting encoded URLs
- add share dialog spec asserting URL encoding of special chars
* fix: address Qodo review on recent-pads encoding and admin Open button
- Normalize legacy URL-encoded recentPads names before re-encoding the
href in colibris/index.js, preventing double-encoding (%2F -> %252F)
of entries stored by older versions.
- Add noopener,noreferrer to the admin 'Open' window.open call to
prevent reverse tabnabbing, matching the pattern used elsewhere.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(pad): attribute default welcome text to the system author (#7885)
When a user opens a brand-new pad, CLIENT_READY calls
getPad(padId, null, session.author), and Pad.init attributed the
auto-generated default content (settings.defaultPadText or a
padDefaultContent hook substitution) to that author. The welcome text —
which the user never wrote — therefore carried the creator's `author`
attribute and rendered in their authorship colour.
Track whether the initial text came from the default-content path and,
if so, attribute the initial changeset to the stable system author
(Pad.SYSTEM_AUTHOR_ID) instead of the creating user. Explicitly provided
text (e.g. HTTP API createPad with text + author) keeps the real author.
The creating user becomes a listed author only once they actually type;
their "ownership" of the pad (pad-wide settings defaults, the author
token) does not depend on owning the default text, so it is unaffected.
listAuthorsOfPad already filters out the system author, so the public
API reports zero contributors for an untouched default pad.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(pad): keep creator as revision-0 author, only de-colour the text
CI (socketio "Pad-wide settings creator gate") and Qodo both caught that
attributing revision 0 to the system author stripped the creating user's
ownership: isPadCreator() / the pad-wide settings gate and the deletion
token all key off getRevisionAuthor(0).
Decouple the two: the initial revision's meta.author stays the real
creator (ownership preserved), while only the welcome text's `author`
*attribute* — the thing that colours it — becomes Pad.SYSTEM_AUTHOR_ID.
The system fallback for the revision author now applies solely when no
author was supplied at all.
Tests assert both halves: default text is system-coloured (not the
creator's colour) AND the creator remains the revision-0 author; explicit
text stays coloured with the creator.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(wcag): target the user-authored span, not the system welcome text
The wcag_author_color spec picked the first `span[class*="author-"]` on
the page to measure the user's colour contrast. With #7885 the default
welcome text is now owned by the system author and renders with no
background colour, so that first span is no longer the current user's —
the contrast read came back as transparent rgba(0,0,0,0) and the three
assertions failed.
Match the author span by the text we just typed ("contrast smoke")
instead, so the test measures the actual user-authored content it
intends to. Verified locally: 3/3 pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* css: restore URL wrapping in pad editor (#7894)
Long URLs in the pad editor overflow instead of wrapping because the
global a { white-space: nowrap } rule in pad.css overrides the wrapping
properties set on #innerdocbody. Add explicit white-space, word-wrap,
and overflow-wrap to #innerdocbody a so URLs wrap inside the editor
while preserving no-wrap behavior for links elsewhere in the UI.
Fixes#7894
* test: add regression test for long URL wrapping in pad editor (#7894)
* test: add regression test for long URL wrapping in pad editor (#7894)
* fix: pad-wide view settings apply to the creator's own view (#7900)
The pad creator is never "enforced upon themselves" (isPadSettingsEnforcedForMe
is false whenever canEditPadSettings is true), so getEffectivePadOptions always
merges their personal view overrides (cookies) on top of the pad-wide options.
As a result, a creator who had at some point toggled e.g. their personal "Read
content from right to left" carried a stale rtlIsTrue=false cookie that silently
masked the pad-wide value they later set — toggling the pad-wide control (and
then enforcing it) appeared to do nothing on the creator's own screen.
Fix: when the creator changes a pad-wide view option, sync their personal pref
to the chosen value so their own view adopts the pad-wide setting immediately.
This deliberately does NOT change the precedence model — the creator can still
override the setting afterwards via the "My view" controls, so the existing
behaviour where a creator keeps personal overrides while enforcing settings for
other users (pad_settings.spec.ts) is preserved.
Adds a frontend test reproducing the stale-personal-cookie scenario.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test: address Qodo review — clear cookies on the page's own context (#7900)
browser.newContext()+clearCookies() cleared cookies on a throwaway context
(not the page fixture under test) and leaked the context. Use
page.context().clearCookies() so the regression test reliably starts without a
stale rtlIsTrue pref.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* timeslider: respect author colors, font, line numbers from pad editor
- nice-select.ts: dispatch native change event after jQuery trigger so
addEventListener-based bridges (pad_mode.ts) fire (jQuery 3.7.1
trigger() does not dispatch native DOM events)
- timeslider.ts: fix font-family reset (jQuery 3 ignores null css value);
add showAuthorColors + showLineNumbers + padFontFamily support;
wire cookie read/write for all three settings
- broadcast.ts: gate author color CSS on .authorColors class
- pad_mode.ts: bridge author colors, font family, line numbers checkboxes
from pad settings into embedded timeslider iframe
- add Playwright test for showAuthorshipColors
* timeslider: tidy settings-bridge wiring
Refactor only — no behaviour change.
pad_mode.ts: collapse the five ad-hoc listener stores (playbackChange-
Listener, followChangeListener, authorColorsListeners, fontFamily-
Listeners, lineNumbersListeners) into the single outerControlListeners
list via a shared bindOuter() helper, so every outer-control listener is
registered and torn down through one uniform path. The three view-setting
bridges (author colours, font family, line numbers) become one
data-driven bridgeView() over their element ids instead of three
near-identical closures.
timeslider.ts: hoist applyPadFontFamily to a top-level helper next to
applyShowAuthorColors (keeping the '' reset for jQuery 3), and co-locate
the three BroadcastSlider view-setting methods that were split across the
function.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: RTL content option no longer flips the whole page (#7900)
The pad's RTL content option (rtlIsTrue) is a per-pad setting that should
only change the direction of the editor's text contents. The setter in
ace2_inner.ts wrote the direction to the top-level `document.documentElement`
(`document` there resolves to the main pad page, since the editor iframes are
derived from it), which flipped the entire page — toolbar and chrome included.
The page direction is owned solely by the UI language (l10n.ts sets it from
html10n.getDirection()). Apply the content direction to the inner editor
document (`targetDoc.documentElement`) instead, so enabling RTL content leaves
the surrounding interface untouched.
Adds a frontend test asserting the inner editor flips to RTL while the
top-level <html> dir stays ltr.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test: address Qodo review — robust frame access and locale-independent page-dir assertion (#7900)
- Use a frameLocator chain (Playwright auto-waits) instead of
page.frame('ace_inner')! which can race with inner-editor readiness.
- Don't hardcode dir='ltr' on the top-level <html> (the UI language can
legitimately pick rtl). Capture the initial page dir and assert it is
unchanged after toggling the pad RTL option — that is the real invariant.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(ci): remove the silent-ELIFECYCLE investigation scaffolding
The Windows backend flake is root-caused (server.ts handler gate for the
in-process process.exit path; Windows pinned to Node 24.16.0 for the libuv
TCP-connect overrun, tracked upstream as nodejs/node#63620). Remove the
temporary diagnostics added while hunting it:
- delete src/tests/backend/diagnostics.ts (per-test heartbeat + node-report
snapshots) and its `--require` from the backend `test` script;
- drop the `--report-on-fatalerror`/`-on-signal`/`-uncaught-exception`
`NODE_OPTIONS` and the "Upload Node diagnostic reports" steps;
- drop the Windows OS-level netstat/tasklist sidecar watcher.
Kept: the real fixes — the log-only unhandledRejection guard in
tests/backend/common.ts, the lowerCasePadIds socket-teardown tracking (comment
de-referenced from the deleted file), and `pnpm test -- --exit` on Windows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: drop the obsolete report-on-fatalerror NODE_OPTIONS guard assertion
The backend-tests-flake-mitigation source-lint guard required every backend
step to keep the --report-on-fatalerror NODE_OPTIONS + node-report upload. Those
diagnostics are removed in this PR now that the flake is root-caused, so drop
that assertion. Retain the Windows-only `--exit` checks (still a live invariant)
and reframe the file around the resolved root cause.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(test): stop a single leaked promise rejection from killing the whole backend suite
Root cause of the long-standing Windows backend-test "silent ELIFECYCLE"
flake (~22% of runs, rotating across random spec files, no mocha summary,
no JS-handler trace, bypassing --report-on-fatalerror / Defender / Windows
event log / AeDebug). Found by capturing a full-memory dump of the dying
node.exe with Sysinternals ProcDump (-t dump-on-terminate) and symbolizing
it against Node 24.15.0's node.pdb. The dying thread's stack:
exit_or_terminate_process / common_exit (CRT exit)
node::Exit
node::DefaultProcessExitHandlerInternal
node::Environment::Exit
node::ReallyExit (process.exit binding)
... v8 MicrotaskQueue::RunMicrotasks ...
node::InternalCallbackScope::Close
No exception stream — a *clean* ExitProcess, not a crash. The job log
pinned the trigger:
[INFO] server - Exiting...
AssertionError at tests/backend/specs/SessionStore.ts:235
at process.processTicksAndRejections
Mechanism: a timing-fragile test (SessionStore touch/expiry specs use real
setTimeout against a 200ms-expiry session; socket.io delay-race specs are
similar) gets timed out and abandoned by mocha, but its async body keeps
running. When its trailing assertion later throws, it surfaces as an ORPHAN
unhandled rejection belonging to no awaited test. Three handlers then
escalated that into a whole-process exit:
- server.ts installed process-global uncaughtException/unhandledRejection
handlers that call exports.exit() → process.reallyExit() (production
graceful-shutdown behaviour, catastrophic in-process under mocha)
- common.ts (PR #7663) and diagnostics.ts (PR #7838) rethrew the rejection
and process.exit(1)
Because it's a deliberate, clean exit it bypassed every forensic layer; it
rotated across files because the orphan rejection lands during whatever test
is running; it's Windows-mostly because event-loop timing makes the abandoned
test's assertion fire in a *later* test's window more often there.
Fix (two halves):
1. server.ts: gate the process-global uncaughtException / unhandledRejection
/ signal handlers behind `require.main === module`. They are correct for
a real Etherpad process but must not fire when server.start() is called
in-process by a test runner — mocha owns process-level error handling
there. Mirrors the existing `if (require.main === module) exports.start()`
idiom; production (node server.js) is unchanged.
2. common.ts + diagnostics.ts: the backend-test bootstraps now LOG unhandled
rejections instead of rethrowing / exiting. Orphan rejections cannot be
cleanly attributed to a test, so rethrowing only yields an
ERR_MOCHA_MULTIPLE_DONE abort. Real failures are unaffected — an assertion
in a test's own awaited path rejects that test's promise and mocha fails
it normally, never reaching this global handler.
Verified locally: a spec that leaks a delayed rejection during a later test
now reports `3 passing` / exit 0 with the rejection logged, instead of
aborting the run.
Follow-ups (separate PRs): harden the SessionStore / socket.io timing specs
to not leak (fake timers); remove the now-unneeded diagnostic scaffolding
(diagnostics.ts heartbeat/node-report, the #7846 OS sidecar) now that the
cause is known.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ci): run Windows backend tests on Node 25 to dodge the libuv connect overrun
Node 24.x's bundled libuv has a stack buffer overrun in the Windows TCP-connect
path (uv__tcp_connect / uv__tcp_try_connect), proven by a SilentProcessExit
full-memory dump of the dying mocha process: the main thread executes
__fastfail(FAST_FAIL_STACK_COOKIE_CHECK_FAILURE) from __report_gsfailure with
TCPWrap::Connect -> uv_tcp_connect on the stack. It fires under the backend
suite's heavy localhost connection churn, is address-family independent (occurs
on both sockaddr_in and sockaddr_in6, so an IPv4 pin does NOT help), and -- being
memory corruption -- bypasses all JS/Node observability, rotating across tests
as the "silent ELIFECYCLE" flake (~22% of Windows runs).
Empirically: Node 25 = 16/16 green; Node 24 (even with an IPv4 pin) = ~39% fail.
Node 25's newer bundled libuv does not overrun. Linux stays on Node 24 LTS (the
bug is Windows-specific). Revisit once the libuv fix is backported to 24.x.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ci): pin Windows backend to Node 24.16.0 (libuv fix) instead of 25
Bisect (standalone repro) pinpointed the fix to Node 24.16.0 (libuv 1.52.1):
24.15.0 (libuv 1.51.0) crashes the connect overrun 4/4 on 127.0.0.1, while
24.16.0 is clean 0/8. 24.16.0 stays on the Node 24 "Krypton" LTS line, so prefer
it over Node 25 (non-LTS). Pinned explicitly because setup-node's default
check-latest:false reuses the runner's pre-cached 24.15.0 for a bare "24".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(ci): reference upstream nodejs/node#63620 in the Windows Node-pin comment
Links the explicit 24.16.0 pin to the filed upstream issue so the pin can be
dropped back to plain "24" once the libuv connect-overrun fix is across the
supported 24.x baseline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 'enter is always visible after event' test asserted that the last
line was within the browser viewport using boundingBox().y + height vs
window.innerHeight. Those values live in different coordinate spaces
(boundingBox is outer-page; window is per-frame), and the comparison
is fundamentally unable to model what the editor's auto-scroll actually
guarantees: visibility inside the ace_outer iframe, not within the
outer browser viewport.
Any plugin that adds chrome above or below the editor (toolbar rows,
sidebars, etc.) pushes the iframe's bottom below the browser viewport
while auto-scroll has correctly placed the cursor at the iframe's
bottom — failures look like 'Expected: > 731, Received: 720'. An
earlier attempt to switch to toBeInViewport({ratio: 1}) traded the
false positives for false negatives under chromium + plugins because
the inner iframe's contents can report ratio 0 against the outer
viewport even when the line is visible inside the editor.
Drop the visibility assertion entirely. The test's real value — that
Enter keystrokes produce new lines and the editor's input pipeline
keeps up — is exercised by the per-iteration toHaveCount value-wait
in the loop above. Visibility under plugin chrome is a separate,
plugin-aware concern.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(oidc): fix OIDCAdapter broken flows
* fix(oidc): fix storage type to include string for userCode index
* test(oidc): add regression tests for OIDCAdapter broken flows
* fix(pad): URL view-option params lost to padeditor.init race (#7840)
`?showLineNumbers=false` and `?useMonospaceFont=true` were being
silently clobbered shortly after pad load. Same race that affected
`?rtl=false` before #7464:
1. _afterHandshake → getParams() sets settings.LineNumbersDisabled
(or useMonospaceFontGlobal, noColors).
2. _afterHandshake calls padeditor.init(view).then(postAceInit) —
async; ace iframes still loading.
3. Sync tail of _afterHandshake hits the URL-param overrides and
calls changeViewOption('showLineNumbers', false) etc. These
queue setProperty('showslinenumbers', false) in Ace2Editor's
actionsPendingInit queue (loaded=false).
4. ace.init resolves → loaded=true → queue flushes → URL-driven
value applied.
5. padeditor.init resumes past its own await and calls
setViewOptions(initialViewOptions) — initialViewOptions is
built from clientVars.initialOptions.view (server defaults
∨ cookie), which does NOT carry the URL preference. The
resulting setProperty('showslinenumbers', true) runs against
loaded=true ace and immediately re-shows the gutter.
#7464 noticed this race for RTL and moved the override into
postAceInit. The neighbouring blocks for showLineNumbers / noColors
/ useMonospaceFontGlobal were left at the synchronous-tail site —
generalise the same fix to all three.
Direct-browser users typically had a `prefs` cookie with
showLineNumbers=false from a prior in-pad toggle, so the
initialViewOptions value happened to match the URL param and the
race was unobservable. Cross-context iframe embeds (the reporter's
configuration) start with no cookie, so the server default true
fights the URL false and the race becomes visible.
Adds src/tests/frontend-new/specs/url_view_options.spec.ts covering
the showLineNumbers=false / =true / useMonospaceFont=true cases on
initial-load navigation (the path where the race actually fires).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(types): annotate navigateWithParam helper params
Fixes CI ts-check: parameters page/padId/param implicitly had `any`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This flag gates the ep_* passthrough on padoptions that shipped in 3.0.0
(PR #7698). It was introduced as opt-in, but the intent in shipping it
was to let plugins like ep_plugin_helpers' padToggle / padSelect ride
the existing broadcast/persist rail out of the box — flipping the
default closes the gap.
Why now
- ep_comments_page#422 (and sibling per-plugin reports discussed on
Discord): stock 3.x deployments console.warn on every pad load because
the helper detects clientVars.enablePluginPadOptions === false and
tells the admin to flip it. With the flag default-true, the warning
stops firing on fresh installs while still surfacing for operators
who have explicitly opted out.
- Plugins that already depend on ep_plugin_helpers >= 0.6 expect the
pad-wide path to work; the default-false gate silently no-op'd
pad.changePadOption('ep_*', …) and made the helper UI inert.
Scope
- Settings.ts default flipped to true; comment rewritten to describe
the new "operator opt-out" model rather than the old AGENTS.MD §52
opt-in framing (that policy still applies to *new* features; this
one has shipped and proven safe).
- settings.json.template env-var substitution default flipped to true
so docker / supervisor configs without an explicit value get the
new behavior.
- doc/plugins.md updated to match (default true, opt-out via
settings.json) and the PluginCapabilities source comment.
- Backend test describe-blocks relabeled — "true" is now "(default)",
"false" is now "(operator opt-out)". Both branches still cover the
same matrix so the size-cap / namespace-validation paths stay
exercised.
Compat
- Existing deployments with an explicit `"enablePluginPadOptions":
false` in settings.json keep that value — no migration needed.
- Older clients only read clientVars.enablePluginPadOptions; the
protocol shape is unchanged.
Closes ep_comments_page#422 (helper warning suppression for stock
deployments).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(ci): schedule a mid-test snapshot 150ms after every beforeEach
Run 26401801404 (PR #7841 after merging develop) captured the dying
test's beforeEach node-report — be-0258, written 75ms after
socketio.ts > "Pad-wide settings creator gate different browsers"
entered — but no further state. The kill landed 321ms into the test
body, between 1 Hz heartbeat ticks, and the 100ms boundary throttle
prevented further beforeEach writes inside the same test. The report
we have shows only the listening server socket; the connections that
the test body creates (and that presumably precede the kill) never
get snapshotted.
Schedule an unref'd setTimeout from beforeEach that fires 150ms after
the test entered. If it's still the running test at fire time (i.e.
slow enough that the death window applies), capture a node-report
from INSIDE the test body — the moment when the real TCP / socket.io
activity is in flight. Fast tests (<150ms) skip the write because
afterEach has already cleared currentTest by the time the timer
fires.
Result on the next reproduction of the death pattern:
- be-NNNN report at +75ms (beforeEach, body not yet started)
- mt-MMMM report at +150ms (mid-test, body in flight, before kill
at +320ms)
- kill, no further reports
Cost: only slow tests (>150ms) generate an mt report, so the
artifact size growth is bounded by the count of tests that take
longer than 150ms — typically a small minority. Locally verified
against a 3-test probe: 2 fast tests skipped, 1 300ms test produced
the expected mt-NNNN snapshot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(ci): bump heartbeat from 1Hz to 5Hz
Run 26402211271 produced be-0207 (the dying test's beforeEach
snapshot) but no mid-test snapshot, even though setTimeout(150ms)
was scheduled and the test body lived another 280 ms after that
deadline. setTimeout under Windows-runner load is being starved
past the deadline — we already saw the previous test's mt fire at
+252 ms (102 ms late) when the deadline was 150 ms, so the dying
test's timer was likely scheduled to fire well after the kill at
+425 ms.
setInterval has fired reliably throughout the investigation
(every heartbeat in every run lands within ~1 s of schedule, even
when setTimeout misses). Bump heartbeat to 200 ms (5 Hz) so any
death window ≥200 ms is sampled inside the test body, independent
of how starved setTimeout is.
Cost on the Windows runner: the existing log shows writeReport
completes in <1 ms (from "Writing Node.js report" to "Node.js
report completed" timestamps), so 5 Hz adds ~5 ms/s of overhead.
Artifact growth: ~500 reports for a 100 s test phase (~25 MB raw,
~5 MB compressed). The setTimeout mid-test snapshot stays — it's
belt-and-suspenders cheap and fires for slow tests where the
heartbeat alone might not align with the death window.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: isolate mt/hb writes from `be` throttle + gate timer on canWriteReport
Qodo flagged two real issues on #7842:
1. The single shared `lastReportT` made `mt` writes poison the `be`
throttle window. Slow tests trigger an `mt` write at +150 ms, then
the test ends a few ms later, and the NEXT test's `beforeEach`
landed within the 100 ms throttle from the `mt` write — so its
own `be` snapshot was suppressed. That's the exact boundary
coverage the throttle is supposed to PROTECT. Local repro with a
180 ms slow test followed by a fast one confirmed: the fast
test's `be-0004` is now captured instead of swallowed.
Fix: split into `lastBoundaryT` used and updated only by `be`
writes. `hb` and `mt` pass `updateThrottle=false` and never
advance the boundary timestamp.
2. `setTimeout` was being scheduled in `beforeEach` for every test
even when `canWriteReport` is false (Linux backend matrix, local
dev). That's a wasted timer per test for no possible diagnostic
output. Gate the schedule itself on `canWriteReport`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(ci): heartbeat + running-test pointer in backend test diagnostics
Backend tests silently die with code 255 mid-suite ~22% of the time on
develop (most often Windows-with-plugins, Node 24). Each kill lands
300±50 ms after the previous test's clean ✔ teardown line and produces
no failing-test marker, no error, no Mocha summary, and — despite the
unconditional handlers in `diagnostics.ts` — none of the JS-level death
events fire either. Recent example: run 26311025244 (`Windows with
Plugins (24)`); both attempts crashed at completely different "last
test" locations, so the dying test itself isn't to blame.
The existing diagnostics only set lastSeenTest in afterEach, so if the
kill lands during the NEXT test's setup or body — which is exactly the
~300ms gap we observe — the pointer reads as the previous (passing)
test. That hides whether we're between tests or inside one, and which
one.
Two changes:
1. Track currentTest in beforeEach as well as lastFinishedTest in
afterEach. Every diag line now carries both, so the death point is
bracketable regardless of which lifecycle phase the kill interrupts.
2. Add a 1Hz heartbeat that writeSyncs the running-test name plus
`process.memoryUsage()` (rss, heap) and the active-handle and
active-request counts. The interval is unref'd so it never holds the
event loop open by itself. Cost is roughly one extra log line per
second of mocha runtime (~60-120 lines per CI run).
When the next failure fires, the last heartbeat narrows the kill window
to ≤1s, the running pointer names the test on the rails at that moment,
and the handle/memory trace gives a sparkline that exposes sudden
spikes — a leaked socket, an unref'd timer, a runaway map — that
would otherwise be invisible at the runner-log level.
No behavior change on successful runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: heartbeat _getActiveHandles optional chain bug (Qodo #2)
Qodo correctly flagged `_getActiveHandles?.().length` as a latent
TypeError: `?.()` guards the call but the call's `undefined` return
on a missing method still hits `.length`, which throws. Since the
heartbeat fires on a setInterval inside the mocha bootstrap, a Node
build without the underscore-prefixed internals would take down the
whole backend test run.
Capture the array first, then read `.length` only when it actually
exists. -1 stays as the "API missing" sentinel.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(ci): per-test start diag + drop stray console.log noise
Follow-up to the heartbeat PR after run 26397693748 confirmed the
diagnostic works (the kill landed at importexportGetPost.ts
'Import authorization checks > authn anonymous !exist -> fail',
~300 ms after the previous test's ✔). Two cleanups so the next
failure pinpoints faster and reads cleaner:
1. diagnostics.ts: emit a `test start: <name>` diag line in the
mocha beforeEach hook, after setting the currentTest pointer.
The 1Hz heartbeat misses tests that take less than a second,
and the silent kills land ~300 ms after a test boundary —
precisely the gap where heartbeat resolution fails. A start
line per test gives sub-millisecond resolution on which test
was on the rails when the process died.
2. specs/api/importexportGetPost.ts: drop a stray
`console.log(importedPads)` debug leftover (and the duplicate
`await importEtherpad(records)` only present to feed it) in
the `malformed .etherpad files are rejected` block. The leftover
dumped a ~600-line reflection of a supertest Response object
to the CI log on every successful run, drowning the surrounding
test output and making the silent-kill window much harder to
read.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(ci): write node-report on every heartbeat tick
Run 26398054688 narrowed the kill to a specific test
(pad.ts > Gets text on a pad Id and doesn't have an excess newline)
but the test body is a trivial supertest GET — the kill bypasses
all JS handlers, so we can't capture stack state at death.
Two failures across two runs share the shape: an agent.{get,post}
+ common.generateJWTToken() call dies ~300-600 ms after test start,
with no JS-visible cause. The next step is V8 + native stack.
Hook into the existing 1Hz heartbeat to call
process.report.writeReport(path) whenever a report directory is set.
The Windows backend-tests workflow already wires up
`--report-directory=${{ github.workspace }}/node-report` via
NODE_OPTIONS and uploads that directory as an artifact on failure,
so the rolling snapshots ride for free on the existing upload step.
Each report (~50 KB) contains:
- V8 + native call stacks for all threads
- libuv active handles (open TCP, timers, file handles)
- JS heap statistics
- resourceUsage + system info
- shared-object list
On the next reproduction the latest report before ELIFECYCLE will
sit ~0-1 s before the kill — enough to see whether the V8 stack
is inside jose's WebCrypto sign path, inside supertest's TCP
roundtrip, or somewhere unexpected entirely.
NODE_REPORT_DIR is also honored as an explicit override for local
repro / non-workflow runs.
Cost: ~6 files (~300 KB) per Windows backend-test failure, plus
~50 ms event-loop pause per heartbeat. No-op when neither env var
is set.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: writeReport with bare filename, not mixed-slash absolute path
Run 26398830249 exposed the path-separator bug in the previous commit:
every heartbeat tick on the Windows runner logged
Failed to open Node.js report file:
D:\a\etherpad\etherpad/node-report/hb-NNNN-...json
directory: D:\a\etherpad\etherpad/node-report (errno: 22)
— EINVAL. The workflow sets --report-directory with forward-slash
separators on Windows, then this code concatenated another `/` plus
the filename, producing a path Node's report writer rejects.
writeReport(fileName) takes a BARE filename and resolves it against
the configured report directory using the platform-correct separator
internally. Switch to that. For local repro overrides via
NODE_REPORT_DIR, push the path into process.report.directory (the
documented config knob) instead of joining it into the call site.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(ci): write node-report on test boundaries, throttled to 4Hz
Run 26398985832 proved the heartbeat-only report cadence isn't tight
enough: the last report before the kill was hb-0013 at +16201ms,
~1.5 s before ELIFECYCLE at +17701ms — during which ~30 tests fired,
including the dying one (`authn anonymous !exist -> fail`). The
captured V8 stack is just our heartbeat code, not the dying test.
Move the writeReport call to a shared tryWriteReport() helper and
invoke it from BOTH the heartbeat AND mocha's beforeEach hook,
throttled to one report per 250 ms. That gives ≤250 ms resolution
on the kill window — close enough that the latest report captures
state from inside the dying test rather than from the test ~30
slots earlier. The heartbeat always writes (so we don't lose the
no-test-running ticks during setup); beforeEach only writes when
the throttle window has elapsed.
Cost ceiling: ~4 reports/sec × ~12 s test phase ≈ 48 reports
(~2.5 MB) per failing run. Each writeReport adds ~50 ms of
event-loop pause — at 4Hz that's 20% of wall time spent in
diagnostics, which is acceptable for a temporary debug-only
bootstrap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(ci): drop beforeEach report throttle from 250ms to 100ms
Run 26399285213's rerun captured a sixth death point on the new 4Hz
cadence (`socketio.ts > Duplicate-author handling > cookie identity:
same-author second socket kicks the first`, kill at +45953ms, 271ms
after test start). The throttle suppressed the dying test's own
beforeEach: previous boundary write landed 128 ms earlier and the
next 31 ms after that, both inside the 250 ms window. Last captured
report (be-0100) is from the previous test.
100 ms is still well above the inter-test cadence in fast burst
suites (tests fire 2-5 ms apart, so 20-50 of them get throttled to a
single write, ceiling ~10 writes/sec). But it's tight enough that
any death-window neighbour ≥100 ms after the previous report — the
shape we keep observing — gets its own boundary snapshot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(admin): explain env-var substitution in /settings, surface auth errors (#7819)
Three small, env-var-only UX improvements driven by issue #7819, where a
Docker operator saved an ep_oauth block in the admin /settings raw view
and reported it "disappeared" — but the underlying confusion was that
settings.json on disk is a *template*, not the effective config. None of
these changes is visible to installs that don't use ${VAR} placeholders.
* Banner above the editor explaining the template/env-substitution model,
only rendered when the loaded file contains a ${VAR} placeholder. Tells
the operator that the file is not env-substituted in place and that the
Effective tab shows the live values.
* Effective tab in the mode toggle, read-only, also gated on ${VAR}. The
backend was already emitting redacted runtime settings as `resolved`
alongside every `load`; the SPA now exposes them so an operator can
verify what Etherpad is actually using.
* admin_auth_error event from the /settings socket handler. The handler
previously silently returned when the connecting session wasn't admin,
which made misrouted Traefik+SSO auth look like "save did nothing" with
no error path in the UI. Emit a dedicated event before dropping the
socket so the SPA can show a clear toast.
Tests:
- src/tests/backend/specs/admin/adminSettingsAuthError.ts — new spec for
the auth_error/disconnect contract.
- src/tests/frontend-new/admin-spec/adminsettings.spec.ts — new Playwright
test asserting the banner + Effective tab only appear after a ${VAR}
is added to settings.json, and that the Effective view is read-only +
shows [REDACTED] for secrets.
No behaviour change for installs without ${VAR} placeholders — banner,
Effective tab, and auth-error contract are all the same as before.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(admin): drop fragile pre-condition + add reconnect-loop guard (#7819)
CI's admin-UI workflow seeds settings.json by copying settings.json.template
verbatim, which contains ~30 \${VAR} placeholders. The new Playwright
test asserted "banner not present before adding placeholder" — true on a
fresh dev machine, false in CI. Drop that assertion: the negative path
is covered by the SettingsPage ENV_VAR_PATTERN regex itself; what
matters at the UI level is the positive path (banner + Effective tab
render correctly when placeholders are present), which this test still
exercises.
Also: the server's admin_auth_error path calls socket.disconnect(),
which the SPA's existing disconnect handler interprets as "io server
disconnect" and immediately reconnects — creating a reject/reconnect
loop. Track an authErrored flag and suppress the reconnect once an
auth_error has been received. Reset on successful connect, so a
legitimate re-auth path still works.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>