fix(vitest): alias ep_etherpad-lite/* to source to avoid double-loading

When internal code imported via the exports map (ep_etherpad-lite/node/x)
AND via a relative path (../../node/x), vite-node resolved two distinct
module instances. Prom-client top-level Counter() calls ran twice and
threw "metric already registered", cascading ~35 test failures. Fix adds a
resolve.alias in vitest.config.ts that rewrites ep_etherpad-lite/<subpath>
to the .ts source, so the two spellings collapse to one module instance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SamTV12345 2026-05-25 13:23:34 +02:00
parent ac98496be3
commit ca4b015fcf
20 changed files with 51 additions and 30 deletions

View file

@ -40,6 +40,11 @@
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
"resolveJsonModule": true, /* Enable importing .json files. */
"baseUrl": ".",
"paths": {
"ep_etherpad-lite/*.js": ["../src/*.ts"],
"ep_etherpad-lite/*": ["../src/*.ts", "../src/*"]
},
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */

View file

@ -24,6 +24,7 @@ import {deserializeOps} from '../../static/js/Changeset.js';
import ChatMessage from '../../static/js/ChatMessage.js';
import {Builder} from "../../static/js/Builder.js";
import {Attribute} from "../../static/js/types/Attribute.js";
import AttributeMap from "../../static/js/AttributeMap.js";
import settings from '../utils/Settings.js';
import CustomError from '../utils/customError.js';
import * as padManager from './PadManager.js';

View file

@ -24,7 +24,7 @@ let ioI: { sockets: { sockets: any[]; }; } | null = null
// Shared sanitizer for the `x-proxy-path` header. See the helper for the
// allowed character class and the protocol-relative / traversal rejection
// rules. Reused by admin.ts so both call sites share one definition.
import {sanitizeProxyPath} from '../../utils/sanitizeProxyPath';
import {sanitizeProxyPath} from '../../utils/sanitizeProxyPath.js';
export const socketio = (hookName: string, {io}: any) => {

View file

@ -49,7 +49,7 @@ export const resolveRequestAuthor = async (req: any): Promise<string | null> =>
const cookiePrefix = (settings as any).cookie?.prefix ?? '';
const token = req?.cookies?.[`${cookiePrefix}token`];
if (typeof token !== 'string' || token === '') return null;
const authorManagerMod: any = await import('../../db/AuthorManager');
const authorManagerMod: any = await import('../../db/AuthorManager.js');
const authorManager = authorManagerMod.default ?? authorManagerMod;
if (typeof authorManager.getAuthorId !== 'function') return null;
const authorId = await authorManager.getAuthorId(token, req?.session?.user);
@ -91,7 +91,7 @@ const computeOutdated = async (
if (!isMinorOrMoreBehind(current, state.latest.version)) return EMPTY;
if (!padId || !authorId) return EMPTY;
// padManager is loaded via dynamic import to avoid circular-init w/ updater.
const padManagerMod: any = await import('../../db/PadManager');
const padManagerMod: any = await import('../../db/PadManager.js');
const padManager = padManagerMod.default ?? padManagerMod;
if (typeof padManager.isValidPadId !== 'function' || !padManager.isValidPadId(padId)) return EMPTY;
if (!(await padManager.doesPadExist(padId))) return EMPTY;

View file

@ -1,4 +1,4 @@
import settings from './Settings';
import settings from './Settings.js';
/**
* Sanitize the URL-path prefix Etherpad is being served under.

View file

@ -1,5 +1,5 @@
import {describe, expect, it} from 'vitest';
import {firstAuthorOf} from '../../../../../node/hooks/express/updateStatus';
import {firstAuthorOf} from '../../../../../node/hooks/express/updateStatus.js';
const makePad = (entries: Record<number, [string, string]>): any => ({
pool: {numToAttrib: entries},

View file

@ -17,10 +17,10 @@ import {describe, it, expect, vi, beforeAll, beforeEach, afterEach} from 'vitest
import express from 'express';
import supertest from 'supertest';
import type {Express} from 'express';
import type {UpdateState} from '../../../../../node/updater/types';
import {EMPTY_STATE} from '../../../../../node/updater/types';
import {getEpVersion} from '../../../../../node/utils/Settings';
import {parseSemver} from '../../../../../node/updater/versionCompare';
import type {UpdateState} from '../../../../../node/updater/types.js';
import {EMPTY_STATE} from '../../../../../node/updater/types.js';
import {getEpVersion} from '../../../../../node/utils/Settings.js';
import {parseSemver} from '../../../../../node/updater/versionCompare.js';
// ---------------------------------------------------------------------------
// Module mocks — must appear before any import that transitively imports them.
@ -67,13 +67,13 @@ vi.mock('../../../../../node/db/PadManager', () => {
// Import the SUT *after* vi.mock declarations so the mocks take effect.
// ---------------------------------------------------------------------------
import * as stateModule from '../../../../../node/updater/state';
import * as authorManagerModule from '../../../../../node/db/AuthorManager';
import * as stateModule from '../../../../../node/updater/state.js';
import * as authorManagerModule from '../../../../../node/db/AuthorManager.js';
import {
expressCreateServer,
_resetBadgeCacheForTests,
_setBadgeCacheCapForTests,
} from '../../../../../node/hooks/express/updateStatus';
} from '../../../../../node/hooks/express/updateStatus.js';
// ---------------------------------------------------------------------------
// Helpers
@ -181,7 +181,7 @@ afterEach(() => {
const getPadMap = async (): Promise<Map<string, any>> => {
// Dynamic import returns the mock factory's return value.
const mod: any = await import('../../../../../node/db/PadManager');
const mod: any = await import('../../../../../node/db/PadManager.js');
return mod.__pads__ as Map<string, any>;
};

View file

@ -9,7 +9,7 @@
* - rejects path-traversal segments.
*/
import {describe, it, expect} from 'vitest';
import {sanitizeProxyPath} from '../../../node/utils/sanitizeProxyPath';
import {sanitizeProxyPath} from '../../../node/utils/sanitizeProxyPath.js';
const mockReq = (val: string|undefined) => ({
header: (name: string) => name.toLowerCase() === 'x-proxy-path' ? val : undefined,
@ -27,7 +27,7 @@ describe('sanitizeProxyPath', () => {
it('returns "" when the req object has no header()', () => {
expect(sanitizeProxyPath(undefined)).toBe('');
// @ts-expect-error — exercising the defensive branch
// @ts-expect-error — exercising the defensive branch with an incompatible type
expect(sanitizeProxyPath({})).toBe('');
});
});
@ -189,7 +189,7 @@ describe('sanitizeProxyPath', () => {
});
it('defaults trustProxy from settings when opts not provided', async () => {
const settings = (await import('../../../node/utils/Settings')).default;
const settings = (await import('../../../node/utils/Settings.js')).default;
const original = settings.trustProxy;
try {
settings.trustProxy = true;

View file

@ -3,7 +3,7 @@ import {
parseWindow,
inWindow,
nextWindowStart,
} from '../../../../node/updater/MaintenanceWindow';
} from '../../../../node/updater/MaintenanceWindow.js';
describe('parseWindow', () => {
it('accepts a valid same-day window with tz=local', () => {

View file

@ -1,5 +1,5 @@
import {describe, it, expect} from 'vitest';
import {smtpTransportKey} from '../../../../node/updater/index';
import {smtpTransportKey} from '../../../../node/updater/index.js';
describe('smtpTransportKey', () => {
// Regression for Qodo PR #7753 review: the nodemailer transport cache was

View file

@ -1,7 +1,7 @@
'use strict';
import {strict as assert} from 'assert';
import {redactSettings} from '../../../../node/utils/AdminSettingsRedact';
import {redactSettings} from '../../../../node/utils/AdminSettingsRedact.js';
describe('AdminSettingsRedact', function () {
it('returns a deep clone, never mutates input', function () {

View file

@ -10,7 +10,7 @@
*/
const common = require('../../common');
import settings from '../../../../node/utils/Settings';
import settings from '../../../../node/utils/Settings.js';
let agent: any;

View file

@ -8,7 +8,7 @@
* plugin paths that call appendRevision directly).
*/
import {PadType} from '../../../node/types/PadType';
import {PadType} from '../../../node/types/PadType.js';
import {strict as assert} from 'assert';
const common = require('../common');

View file

@ -12,7 +12,7 @@
*/
const common = require('../common');
import settings from '../../../node/utils/Settings';
import settings from '../../../node/utils/Settings.js';
let agent: any;

View file

@ -6,7 +6,7 @@
*/
const common = require('../common');
import settings from '../../../node/utils/Settings';
import settings from '../../../node/utils/Settings.js';
const db = require('../../../node/db/DB');

View file

@ -4,9 +4,9 @@ import path from 'node:path';
import fs from 'node:fs/promises';
import os from 'node:os';
import {strict as assert} from 'assert';
import {EMPTY_STATE, MaintenanceWindow, PolicyResult, ReleaseInfo} from '../../../node/updater/types';
import {loadState, saveState} from '../../../node/updater/state';
import {decideSchedule, decideTriggerApply} from '../../../node/updater/Scheduler';
import {EMPTY_STATE, MaintenanceWindow, PolicyResult, ReleaseInfo} from '../../../node/updater/types.js';
import {loadState, saveState} from '../../../node/updater/state.js';
import {decideSchedule, decideTriggerApply} from '../../../node/updater/Scheduler.js';
const release: ReleaseInfo = {
tag: 'v9.9.9',
@ -24,7 +24,6 @@ const policyAutonomous: PolicyResult = {
const window: MaintenanceWindow = {start: '03:00', end: '05:00', tz: 'utc'};
describe('Tier 4 scheduler — maintenance-window boundary integration', function () {
this.timeout(15000);
let root: string;
let stateFile: string;

View file

@ -17,7 +17,7 @@
*/
const common = require('../common');
import settings from 'ep_etherpad-lite/node/utils/Settings';
import settings from 'ep_etherpad-lite/node/utils/Settings.js';
let agent: any;

View file

@ -85,7 +85,6 @@ const save = (socket: any, payload: string): Promise<{status: string; detail?: a
});
describe('admin /settings socket (Docker container) — #7819', function () {
this.timeout(20000);
let socket: any;
before(async function () {

View file

@ -15,7 +15,13 @@
/* Completeness */
"skipLibCheck": true /* Skip type checking all .d.ts files. */,
"resolveJsonModule": true,
"types": ["node", "jquery", "vitest/globals"]
"types": ["node", "jquery", "vitest/globals"],
"ignoreDeprecations": "6.0",
"baseUrl": ".",
"paths": {
"ep_etherpad-lite/*.js": ["./*.ts"],
"ep_etherpad-lite/*": ["./*.ts", "./*"]
}
},
"include": ["./**/*.ts"],
"exclude": ["plugin_packages", "node_modules", "../plugin_packages"]

View file

@ -1,6 +1,17 @@
import {defineConfig} from 'vitest/config';
import {fileURLToPath} from 'node:url';
const srcRoot = fileURLToPath(new URL('.', import.meta.url));
export default defineConfig({
resolve: {
alias: [
// Self-imports: route ep_etherpad-lite/<subpath>(.js)? → src/<subpath>.ts so we
// exercise the actual sources, not the dist/ twins. Plugins (outside src/)
// still hit the package.json exports map at runtime.
{ find: /^ep_etherpad-lite\/(.+?)(?:\.js)?$/, replacement: `${srcRoot}$1.ts` },
],
},
test: {
globals: true,
setupFiles: ['./tests/backend/vitest.setup.ts'],