* 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>
- Require ADMIN_PASSWORD and the database password to be provided explicitly
(no implicit fallback).
- Default TRUST_PROXY to false.
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>
* docs: refresh docs for 3.2.0 — correct stale content, document recent features
The hand-maintained VitePress docs under doc/ had drifted behind a lot of
recent work. They are authored prose (not generated from the OpenAPI spec),
so they need manual upkeep. This pass corrects content that was actively
wrong and documents features shipped since they were last touched.
Corrections (was wrong / misleading):
- cli.md: every command used `node bin/foo.js`, but the scripts are
TypeScript run via pnpm — copy-paste failed. Rewrote to
`pnpm run --filter bin <script>`, documented ~13 previously-undocumented
operator tools, and split running-vs-stopped requirements. Registered the
missing `compactStalePads` script in bin/package.json so the documented
invocation actually works.
- stats.md: described a pre-Prometheus world. Rewrote for the gated
`/stats` (JSON) and `/stats/prometheus` endpoints, the live metric set,
the opt-in `scalingDiveMetrics` instruments (#7756), and `measured-core`.
- admin/updates.md: removed three false "SMTP not yet wired" claims (it is,
via nodemailer + the `mail.*` block), documented the `node-engine-mismatch`
preflight check and the rollback/preflight failure emails, and stripped
obsolete "PR 1 / PR 2" staging language now that all tiers ship.
- api/http_api.md: added the undocumented `anonymizeAuthor` (GDPR Art. 17)
call, fixed copyPad/movePad version annotations (1.2.8 → 1.2.9), corrected
getPadID's param name (readOnlyID → roID), and dropped a reference to a
non-existent `getEtherpad` API call.
- skins.md: colibris is the current default, not an "experimental" skin for
a future 2.0.
- localization.md: bare `window._('key')` is unbound and returns undefined;
recommend `window.html10n.get(...)` / data-l10n-id instead.
- README.md: bumped the v2.2.5 upgrade example to v3.2.0; fixed a
docker.adoc link to docker.md.
- docker.md: added MAIL_*, ENABLE_METRICS, GDPR_AUTHOR_ERASURE_ENABLED,
PRIVACY_BANNER_*, PUBLIC_URL, AUTHENTICATION_METHOD, ENABLE_DARK_MODE,
ENABLE_PAD_WIDE_SETTINGS; fixed the SOCKETIO_MAX_HTTP_BUFFER_SIZE default
(50000 → 1000000).
New documentation:
- configuration.md (new): how settings + `${VAR:default}` substitution work,
trustProxy, and — the previously-undocumented feature — running under a
subpath/ingress via x-proxy-path / X-Forwarded-Prefix / X-Ingress-Path,
with the sanitizer rules and Traefik/NGINX examples. Wired into the
VitePress sidebar and the index hero.
- hooks_server-side.md: ccRegisterBlockElements (the server-side companion
plugin authors miss), exportConvert, exportHTMLSend, createServer,
restartServer, and clientReady (marked deprecated).
- hooks_client-side.md: aceDrop, acePaste, handleClientTimesliderMessage_<name>.
VitePress build passes. The legacy .adoc set was intentionally left in place
— it still feeds the per-version doc archives published to ether.github.com
at release time (bin/release.ts), so it is not dead and is out of scope here.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: address Qodo review — drop .js invocations from configuration.md and CLI help
- configuration.md: the settings-override example referenced a nonexistent
`node src/node/server.js`. Use the supported launcher instead
(`bin/run.sh -s <file>`), and note the runtime is server.ts via tsx.
- compactStalePads.ts / compactPad.ts / compactAllPads.ts: their header
comments and runtime usage output still printed `node bin/*.js`, which
points at files that don't exist. Switched to the documented
`pnpm run --filter bin <script>` form so the --help text matches the docs.
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>
In-process diagnostics (diagnostics.ts heartbeat at 5 Hz + node-report
snapshots on every beforeEach and heartbeat tick) merged in #7838 and
#7842 reach a hard ceiling: during every captured death window the V8
main isolate is event-loop-starved for 200-400 ms before the process
is externally terminated, so any timer-driven probe (heartbeat,
setTimeout, --report-on-signal handler) never gets serviced and we
have zero JS-visible state from the actual moment of death.
To capture state during the starvation window we need a probe whose
own scheduling does not depend on the dying process's libuv event
loop. This commit adds a tiny bash background loop to the Windows
backend-test steps (both with- and without-plugins). Every 500 ms it
appends:
- netstat.log: localhost TCP socket state — surfaces TIME_WAIT /
CLOSE_WAIT accumulation or ephemeral-port exhaustion that the
in-process libuv handle list can't see (libuv only shows handles
Node currently knows about; the kernel may hold many more sockets
in disposal states).
- tasklist.log: node.exe process state from the Windows OS view
(handle count, working set, CPU time), independent of whether V8
is responsive.
Both files land in $GITHUB_WORKSPACE/node-report/ which is already
the artifact-upload target on failure, so they ride for free on
existing infrastructure. The watcher is killed cleanly after `pnpm
test` returns so it never holds the runner open.
On the next captured silent ELIFECYCLE we'll have, for the first
time, a 500 ms-resolution external observation of TCP and process
state across the death window.
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