-
-
Notifications
You must be signed in to change notification settings - Fork 3k
feat(admin): show "requires newer Etherpad" when installing incompatible plugin (#7763) #7771
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| 'use strict'; | ||
|
|
||
| // Tracks the in-flight batch of install/uninstall operations and decides when | ||
| // to fire the "all tasks finished" side effect (reload settings, restart | ||
| // server). Extracted so it can be unit-tested without dragging in the | ||
| // LinkInstaller circular import. | ||
| // | ||
| // Crucial invariant: onFinished MUST NOT run when every task in the batch | ||
| // failed — that path is reachable now that install() correctly propagates | ||
| // errors to its callback. Restarting the server on a failed install would | ||
| // disconnect every connected pad for no reason. | ||
| export class InstallerTaskQueue { | ||
| private tasks = 0; | ||
| private anyTaskSucceeded = false; | ||
|
|
||
| constructor(private readonly onFinished: () => unknown) {} | ||
|
|
||
| wrap(cb: Function | null): (...args: unknown[]) => void { | ||
| this.tasks++; | ||
| return (...args: unknown[]) => { | ||
| if (!args[0]) this.anyTaskSucceeded = true; | ||
| if (cb) cb(...args); | ||
| this.tasks--; | ||
| if (this.tasks === 0) { | ||
| const shouldFinish = this.anyTaskSucceeded; | ||
| this.anyTaskSucceeded = false; | ||
| if (shouldFinish) this.onFinished(); | ||
| } | ||
| }; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| 'use strict'; | ||
|
|
||
| import semver from 'semver'; | ||
|
|
||
| export type EngineCheckResult = | ||
| | {compatible: true} | ||
| | {compatible: false; required: string; current: string}; | ||
|
|
||
| /** | ||
| * Compares a plugin's `engines.node` range against the runtime Node version. | ||
| * | ||
| * Returned compatibility is used at plugin install time to short-circuit the | ||
| * install with a user-facing "requires a newer version of Etherpad" message | ||
| * instead of letting live-plugin-manager unpack a plugin that will fail at | ||
| * load time on the running Node. | ||
| * | ||
| * Unparseable ranges return `compatible: true` deliberately — this preflight | ||
| * is opportunistic, not a gatekeeper. If we can't make sense of the range | ||
| * we fall through to the existing install path. | ||
| */ | ||
| export const checkEngineCompatibility = ( | ||
| pluginEnginesNode: string | undefined, | ||
| currentNodeVersion: string, | ||
| ): EngineCheckResult => { | ||
| if (!pluginEnginesNode) return {compatible: true}; | ||
| const current = currentNodeVersion.replace(/^v/, ''); | ||
| if (!semver.validRange(pluginEnginesNode)) return {compatible: true}; | ||
| if (semver.satisfies(current, pluginEnginesNode, {includePrerelease: true})) { | ||
| return {compatible: true}; | ||
| } | ||
| return {compatible: false, required: pluginEnginesNode, current}; | ||
| }; | ||
|
|
||
| export class EngineIncompatibleError extends Error { | ||
| public readonly code = 'PLUGIN_REQUIRES_NEWER_ETHERPAD'; | ||
| constructor( | ||
| public readonly pluginName: string, | ||
| public readonly required: string, | ||
| public readonly current: string, | ||
| ) { | ||
| super( | ||
| `Plugin ${pluginName} requires a newer version of Etherpad. ` + | ||
| 'Please upgrade Etherpad and try again.', | ||
| ); | ||
| this.name = 'EngineIncompatibleError'; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| 'use strict'; | ||
|
|
||
| import {describe, it, expect, vi} from 'vitest'; | ||
| import {InstallerTaskQueue} from '../../../static/js/pluginfw/installerTasks'; | ||
|
|
||
| describe('InstallerTaskQueue', () => { | ||
| it('fires onFinished after a single successful task', () => { | ||
| const onFinished = vi.fn(); | ||
| const q = new InstallerTaskQueue(onFinished); | ||
| const cb = vi.fn(); | ||
| const wrapped = q.wrap(cb); | ||
|
|
||
| wrapped(null); | ||
|
|
||
| expect(cb).toHaveBeenCalledWith(null); | ||
| expect(onFinished).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('does NOT fire onFinished when the only task in the batch failed', () => { | ||
| // Regression: before this fix, a failed install(EngineIncompatibleError) | ||
| // would still trigger restartServer via onAllTasksFinished, kicking every | ||
| // connected pad off the server for no benefit. Reported by Qodo on | ||
| // PR #7771. | ||
| const onFinished = vi.fn(); | ||
| const q = new InstallerTaskQueue(onFinished); | ||
| const wrapped = q.wrap(vi.fn()); | ||
|
|
||
| wrapped(new Error('plugin requires a newer version of Etherpad')); | ||
|
|
||
| expect(onFinished).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('fires onFinished when at least one task in a mixed batch succeeded', () => { | ||
| const onFinished = vi.fn(); | ||
| const q = new InstallerTaskQueue(onFinished); | ||
| const ok = q.wrap(vi.fn()); | ||
| const bad = q.wrap(vi.fn()); | ||
|
|
||
| bad(new Error('boom')); | ||
| expect(onFinished).not.toHaveBeenCalled(); | ||
| ok(null); | ||
|
|
||
| expect(onFinished).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('does NOT fire onFinished when every task in a multi-task batch failed', () => { | ||
| const onFinished = vi.fn(); | ||
| const q = new InstallerTaskQueue(onFinished); | ||
| const a = q.wrap(vi.fn()); | ||
| const b = q.wrap(vi.fn()); | ||
|
|
||
| a(new Error('one')); | ||
| b(new Error('two')); | ||
|
|
||
| expect(onFinished).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('resets the success flag between batches', () => { | ||
| const onFinished = vi.fn(); | ||
| const q = new InstallerTaskQueue(onFinished); | ||
|
|
||
| const ok = q.wrap(vi.fn()); | ||
| ok(null); | ||
| expect(onFinished).toHaveBeenCalledTimes(1); | ||
|
|
||
| const bad = q.wrap(vi.fn()); | ||
| bad(new Error('next batch all failed')); | ||
| expect(onFinished).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('tolerates a null callback', () => { | ||
| const onFinished = vi.fn(); | ||
| const q = new InstallerTaskQueue(onFinished); | ||
| const wrapped = q.wrap(null); | ||
|
|
||
| expect(() => wrapped(null)).not.toThrow(); | ||
| expect(onFinished).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('only fires onFinished once all wrapped tasks have completed', () => { | ||
| const onFinished = vi.fn(); | ||
| const q = new InstallerTaskQueue(onFinished); | ||
| const a = q.wrap(vi.fn()); | ||
| const b = q.wrap(vi.fn()); | ||
|
|
||
| a(null); | ||
| expect(onFinished).not.toHaveBeenCalled(); | ||
| b(null); | ||
|
|
||
| expect(onFinished).toHaveBeenCalledTimes(1); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| 'use strict'; | ||
|
|
||
| import {describe, it, expect} from 'vitest'; | ||
| import { | ||
| checkEngineCompatibility, | ||
| EngineIncompatibleError, | ||
| } from '../../../static/js/pluginfw/pluginEngineCheck'; | ||
|
|
||
| describe('pluginEngineCheck', () => { | ||
| describe('checkEngineCompatibility', () => { | ||
| it('returns compatible when plugin declares no engines.node', () => { | ||
| expect(checkEngineCompatibility(undefined, 'v22.13.0').compatible).toBe(true); | ||
| }); | ||
|
|
||
| it('returns compatible when the current Node satisfies the range', () => { | ||
| expect(checkEngineCompatibility('>=22.13.0', 'v22.13.0').compatible).toBe(true); | ||
| expect(checkEngineCompatibility('>=22.13.0', 'v25.1.0').compatible).toBe(true); | ||
| }); | ||
|
|
||
| it('returns incompatible when the current Node is below the required range', () => { | ||
| const result = checkEngineCompatibility('>=25.0.0', 'v22.13.0'); | ||
| expect(result.compatible).toBe(false); | ||
| if (!result.compatible) { | ||
| expect(result.required).toBe('>=25.0.0'); | ||
| expect(result.current).toBe('22.13.0'); | ||
| } | ||
| }); | ||
|
|
||
| it('strips the leading "v" from process.version-style strings', () => { | ||
| const result = checkEngineCompatibility('>=25.0.0', 'v22.13.0'); | ||
| expect(result.compatible).toBe(false); | ||
| if (!result.compatible) expect(result.current).not.toMatch(/^v/); | ||
| }); | ||
|
|
||
| it('treats malformed engines strings as compatible (do not block install)', () => { | ||
| // If the plugin's engines field is garbage we should not block — let | ||
| // live-plugin-manager handle it. The whole point of this preflight is | ||
| // to give a useful message; if we can't parse the range, fall through. | ||
| expect(checkEngineCompatibility('not-a-range', 'v22.13.0').compatible).toBe(true); | ||
| }); | ||
| }); | ||
|
|
||
| describe('EngineIncompatibleError', () => { | ||
| it('carries a stable code and the plugin name', () => { | ||
| const err = new EngineIncompatibleError('ep_test', '>=25.0.0', '22.13.0'); | ||
| expect(err.code).toBe('PLUGIN_REQUIRES_NEWER_ETHERPAD'); | ||
| expect(err.pluginName).toBe('ep_test'); | ||
| expect(err.required).toBe('>=25.0.0'); | ||
| expect(err.current).toBe('22.13.0'); | ||
| }); | ||
|
|
||
| it('produces a user-facing message that does not mention Node', () => { | ||
| // Message reaches the admin UI. Per design, the admin is told to | ||
| // upgrade Etherpad — Node is an implementation detail. | ||
| const err = new EngineIncompatibleError('ep_test', '>=25.0.0', '22.13.0'); | ||
| expect(err.message).toMatch(/newer version of Etherpad/); | ||
| expect(err.message.toLowerCase()).not.toMatch(/node/); | ||
| }); | ||
|
|
||
| it('is an instance of Error', () => { | ||
| const err = new EngineIncompatibleError('ep_test', '>=25.0.0', '22.13.0'); | ||
| expect(err).toBeInstanceOf(Error); | ||
| }); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
1. No node.js 25 mentioned
📎 Requirement gap≡ CorrectnessAgent Prompt
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation toolsThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Disagreeing with this one — the absence of "Node.js 25" in the user-facing string is intentional, not an oversight.
Per the design discussion on #7763, the message is deliberately phrased in terms admins can act on directly: "upgrade Etherpad." Three reasons:
engines.nodeenforcement (enforceMinNodeVersionat startup) raises the Node-version concern at the right layer — with the right error message, in the right context.The test asserting the message doesn't mention Node is enforcing this design, not papering over a gap. The compliance checklist's "mention Node 25" rule conflicts with the UX intent agreed on the issue thread.
Happy to revisit if there's a separate concern (e.g. logs/error class —
EngineIncompatibleError.requiredalready carries the raw range for log forensics), but the admin-facing string stays as-is.