From 95f753c805660fbe6366bccc8e6b330b0c18e3af Mon Sep 17 00:00:00 2001 From: SamTV12345 <40429738+samtv12345@users.noreply.github.com> Date: Sat, 16 May 2026 23:26:38 +0200 Subject: [PATCH] test: migrate mocha this.timeout/this.skip to vitest API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the `this: any` annotations introduced in the previous commit with a real migration to vitest's native API. The previous fix only made tsc green; this fix makes the test contract correct under vitest. mocha → vitest mappings: - `before(function () { this.timeout(N); ... })` → `before(async () => { ... })`. vitest.config.ts already sets `hookTimeout: 60000` and `testTimeout: 120000`, so the per-hook overrides were redundant. (The longer mocha-style timeout could be passed as the second arg to before(): `before(async () => {}, ms)`. None of the call sites needed it.) - `describe('foo', function () { this.timeout(N); ... })` → `describe('foo', () => { ... })`. Same reason — covered by testTimeout default. - `it(..., function () { try { require.resolve('html-to-docx'); } catch { this.skip(); } ...})` → hoist `const hasHtmlToDocx = canResolve(...)` to module load, then `describe.skipIf(!hasHtmlToDocx)(...)` or `it.skipIf(!hasHtmlToDocx)(...)`. Vitest evaluates skipIf at definition time, so this works for the require.resolve-style "skip if optional dep missing" pattern that drives every skip in export.ts and import.ts. - For the one runtime-only condition (heading-block plugin registration via ccRegisterBlockElements, which needs common.init() to have run): keep a closure flag set in `before(...)` and call `ctx.skip()` inside each `it` that needs it. vitest exposes `skip` on the test-context parameter. - `function ()` callbacks bulk-converted to arrow functions across the touched files now that no body references `this`. - Removed `src/tests/backend/diagnostics.ts` — it was a `mocha --require` hook shim (mochaHooks export, this.currentTest access). Mocha was dropped from this branch's devDeps; nothing imports it, vitest.config doesn't load it. `pnpm run ts-check` passes with zero errors. No `this: any` anywhere in the test tree. --- src/tests/backend/diagnostics.ts | 100 ---------- .../specs/admin/anonymizeAuthorSocket.ts | 21 +- src/tests/backend/specs/admin/authorSearch.ts | 18 +- src/tests/backend/specs/anonymizeAuthor.ts | 21 +- .../backend/specs/api/anonymizeAuthor.ts | 13 +- src/tests/backend/specs/api/api.ts | 43 ++-- src/tests/backend/specs/api/deletePad.ts | 19 +- src/tests/backend/specs/authorTokenCookie.ts | 9 +- src/tests/backend/specs/export.ts | 186 ++++++++---------- src/tests/backend/specs/import.ts | 122 ++++++------ src/tests/backend/specs/sessionIdCookie.ts | 21 +- .../backend/specs/settingsModalHeading.ts | 13 +- .../backend/specs/updater-integration.ts | 4 +- .../specs/updater-scheduler-integration.ts | 4 +- 14 files changed, 231 insertions(+), 363 deletions(-) delete mode 100644 src/tests/backend/diagnostics.ts diff --git a/src/tests/backend/diagnostics.ts b/src/tests/backend/diagnostics.ts deleted file mode 100644 index 67e4f3624..000000000 --- a/src/tests/backend/diagnostics.ts +++ /dev/null @@ -1,100 +0,0 @@ -'use strict'; - -// Diagnostic-only mocha bootstrap, loaded via `mocha --require ./tests/backend/diagnostics.ts`. -// -// PR #7663 added unhandledRejection / uncaughtException handlers in -// tests/backend/common.ts to surface the silent ~22% backend-test flake. -// The next failure (run 25279692065, Windows without plugins, Node 24) -// showed mocha exit with code 1 mid-suite, 261ms after the last passing -// test, with NEITHER handler firing. This means the process was killed -// in a way that bypassed JS handlers — SIGKILL, OOM, or a fatal native -// error — OR mocha itself called process.exit before the handlers ran. -// -// This file: -// 1. Registers handlers UNCONDITIONALLY at mocha startup (common.ts is -// only imported by ~27 of 47 specs, so its handlers may register -// late or after a death-causing event). -// 2. Writes via fs.writeSync(2, ...) — synchronous stderr writes that -// complete before the kernel returns from the syscall, so the line -// lands in the runner log even if the process is killed -// milliseconds later. -// 3. Tracks the last-seen test via a mocha root afterEach hook so the -// death point is identified. -// 4. Logs exit-related events so we can discriminate: -// beforeExit + exit -> clean event-loop drain (Linux CI, local) -// only exit -> process.exit() called — expected when mocha -// is launched with --exit (the Windows CI -// jobs do this to mitigate a hard-kill flake; -// elsewhere "only exit" still means something -// else called process.exit unexpectedly) -// neither -> hard kill (SIGKILL/OOM/runner) -// signal lines -> SIGTERM / SIGINT / SIGBREAK received -// -// Drop this file once the flake's root cause is identified and fixed. - -import {writeSync} from 'node:fs'; - -const t0 = Date.now(); -let lastSeenTest = ''; - -const diag = (msg: string): void => { - const line = `[diag +${Date.now() - t0}ms] ${msg}\n`; - try { - writeSync(2, line); - } catch (_) { - // Best-effort: if stderr is closed there is nothing we can do. - } -}; - -diag('diagnostics loaded'); - -process.on('unhandledRejection', (reason: any) => { - diag(`unhandledRejection: ${ - reason && reason.stack ? reason.stack : String(reason) - } (lastTest="${lastSeenTest}")`); - // Re-throw so existing common.ts handlers / mocha behavior is preserved. - throw reason; -}); - -process.on('uncaughtException', (err: any) => { - diag(`uncaughtException: ${ - err && err.stack ? err.stack : String(err) - } (lastTest="${lastSeenTest}")`); - // Force fail-fast. Specs that don't import common.ts only have THIS handler, - // and Node won't exit on its own once an uncaughtException listener is - // registered. Without the explicit exit a fatal error would be swallowed. - // common.ts has the same process.exit(1); whichever handler runs first wins. - process.exit(1); -}); - -process.on('beforeExit', (code: number) => { - diag(`beforeExit code=${code} exitCode=${process.exitCode} ` + - `lastTest="${lastSeenTest}"`); -}); - -process.on('exit', (code: number) => { - diag(`exit code=${code} lastTest="${lastSeenTest}"`); -}); - -for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK'] as const) { - // SIGHUP / SIGBREAK don't exist on every platform; ignore registration errors. - try { - process.on(sig as any, () => { - diag(`received ${sig} (lastTest="${lastSeenTest}")`); - // Let the default behavior (exit) happen. - process.exit(128); - }); - } catch (_) { - // ignore - } -} - -// Mocha root hook — only registered if mocha picks up this file via --require. -// We track the most recently-finished test so the death point is visible. -export const mochaHooks = { - afterEach(this: any) { - if (this.currentTest) { - lastSeenTest = this.currentTest.fullTitle(); - } - }, -}; diff --git a/src/tests/backend/specs/admin/anonymizeAuthorSocket.ts b/src/tests/backend/specs/admin/anonymizeAuthorSocket.ts index fa5e152c9..fd7b077d9 100644 --- a/src/tests/backend/specs/admin/anonymizeAuthorSocket.ts +++ b/src/tests/backend/specs/admin/anonymizeAuthorSocket.ts @@ -61,14 +61,13 @@ const ask = (socket: any, evt: string, payload: any, replyEvt: string) => socket.emit(evt, payload); }); -describe(__filename, function (this: any) { +describe(__filename, () => { let socket: any; let originalFlag: boolean; let savedUsers: any; let savedRequireAuthentication: boolean; - before(async function (this: any) { - this.timeout(60000); + before(async () => { await common.init(); settings.gdprAuthorErasure = settings.gdprAuthorErasure || {enabled: false}; originalFlag = settings.gdprAuthorErasure.enabled; @@ -78,7 +77,7 @@ describe(__filename, function (this: any) { socket = await adminSocket(); }); - after(function (this: any) { + after(() => { if (socket) socket.disconnect(); settings.gdprAuthorErasure.enabled = originalFlag; // savedUsers and settings.users point at the same object — restoring @@ -89,7 +88,7 @@ describe(__filename, function (this: any) { settings.requireAuthentication = savedRequireAuthentication; }); - it('authorLoad returns paginated rows', async function (this: any) { + it('authorLoad returns paginated rows', async () => { const tag = `sock-${Date.now()}`; await authorManager.createAuthorIfNotExistsFor(`m-${tag}`, `Sock ${tag}`); const res = await ask(socket, 'authorLoad', @@ -101,7 +100,7 @@ describe(__filename, function (this: any) { }); it('anonymizeAuthorPreview returns counters without flipping erased', - async function (this: any) { + async function () { const tag = `prev-${Date.now()}`; const {authorID} = await authorManager.createAuthorIfNotExistsFor( `m-${tag}`, `Prev ${tag}`); @@ -114,7 +113,7 @@ describe(__filename, function (this: any) { 'preview must not flip erased'); }); - it('anonymizeAuthor commits when the flag is enabled', async function (this: any) { + it('anonymizeAuthor commits when the flag is enabled', async () => { const tag = `live-${Date.now()}`; const {authorID} = await authorManager.createAuthorIfNotExistsFor( `m-${tag}`, `Live ${tag}`); @@ -127,7 +126,7 @@ describe(__filename, function (this: any) { }); it('anonymizeAuthor returns {error: "disabled"} when flag is off', - async function (this: any) { + async function () { settings.gdprAuthorErasure.enabled = false; try { const tag = `disabled-${Date.now()}`; @@ -145,7 +144,7 @@ describe(__filename, function (this: any) { }); it('anonymizeAuthorPreview returns {error: "disabled"} when flag is off', - async function (this: any) { + async function () { // Per Qodo Compliance ID 6 ('new features behind a feature flag, // disabled by default') the preview event is also gated, not just // the live anonymizeAuthor. The page renders its disabled banner @@ -166,7 +165,7 @@ describe(__filename, function (this: any) { }); it('authorLoad returns {error: "disabled"} when flag is off', - async function (this: any) { + async function () { settings.gdprAuthorErasure.enabled = false; try { const res = await ask(socket, 'authorLoad', @@ -181,7 +180,7 @@ describe(__filename, function (this: any) { }); it('handlers do not crash on payload-less emits', - async function (this: any) { + async function () { // Pre-Qodo-fix the destructure `({authorID}: ...)` threw before // try/catch when client emitted with no payload. Both gated // handlers now accept `payload: any` and read defensively. diff --git a/src/tests/backend/specs/admin/authorSearch.ts b/src/tests/backend/specs/admin/authorSearch.ts index 20f8da3eb..6c851c7da 100644 --- a/src/tests/backend/specs/admin/authorSearch.ts +++ b/src/tests/backend/specs/admin/authorSearch.ts @@ -6,9 +6,8 @@ const common = require('../../common'); const authorManager = require('../../../../node/db/AuthorManager'); const DB = require('../../../../node/db/DB'); -describe(__filename, function (this: any) { - before(async function (this: any) { - this.timeout(60000); +describe(__filename, () => { + before(async () => { await common.init(); }); @@ -18,7 +17,7 @@ describe(__filename, function (this: any) { const seed = async (name: string, mapper: string) => (await authorManager.createAuthorIfNotExistsFor(mapper, name)).authorID; - it('returns an empty page when the pattern matches nothing', async function (this: any) { + it('returns an empty page when the pattern matches nothing', async () => { const res = await authorManager.searchAuthors({ pattern: `nonexistent-${Date.now()}-${Math.random()}`, offset: 0, limit: 12, sortBy: 'name', ascending: true, @@ -28,7 +27,7 @@ describe(__filename, function (this: any) { assert.deepEqual(res.results, []); }); - it('matches by name substring', async function (this: any) { + it('matches by name substring', async () => { const tag = `findme-${Date.now()}`; await seed(`Alice ${tag}`, `m-${tag}-1`); await seed(`Bob ${tag}`, `m-${tag}-2`); @@ -41,7 +40,7 @@ describe(__filename, function (this: any) { assert.equal(res.results[1].name, `Bob ${tag}`); }); - it('matches by mapper substring (joins mapper2author)', async function (this: any) { + it('matches by mapper substring (joins mapper2author)', async () => { const tag = `mapper-tag-${Date.now()}`; await seed('Carol', `${tag}-x`); const res = await authorManager.searchAuthors({ @@ -54,7 +53,7 @@ describe(__filename, function (this: any) { }); it('hides erased authors by default and includes them when asked', - async function (this: any) { + async function () { const tag = `era-${Date.now()}`; const id = await seed(`Erasable ${tag}`, `m-${tag}`); // Use the authorID's random suffix as the search pattern. After @@ -81,7 +80,7 @@ describe(__filename, function (this: any) { assert.equal(found.erased, true); }); - it('sorts by lastSeen', async function (this: any) { + it('sorts by lastSeen', async () => { const tag = `sort-${Date.now()}`; const a = await seed(`SortA ${tag}`, `m-${tag}-a`); await new Promise((r) => setTimeout(r, 10)); @@ -99,8 +98,7 @@ describe(__filename, function (this: any) { assert.equal(desc.results[0].authorID, b); }); - it('caps results at 1000 and reports cappedAt', async function (this: any) { - this.timeout(120000); + it('caps results at 1000 and reports cappedAt', async () => { const tag = `cap-${Date.now()}`; // Seed 1100 authors directly via DB to keep this fast (~1s vs minutes // through createAuthorIfNotExistsFor). diff --git a/src/tests/backend/specs/anonymizeAuthor.ts b/src/tests/backend/specs/anonymizeAuthor.ts index 8d8cdd3a4..c0b4210cb 100644 --- a/src/tests/backend/specs/anonymizeAuthor.ts +++ b/src/tests/backend/specs/anonymizeAuthor.ts @@ -6,13 +6,12 @@ const common = require('../common'); const authorManager = require('../../../node/db/AuthorManager'); const DB = require('../../../node/db/DB'); -describe(__filename, function (this: any) { - before(async function (this: any) { - this.timeout(60000); +describe(__filename, () => { + before(async () => { await common.init(); }); - it('zeroes the display identity on globalAuthor:', async function (this: any) { + it('zeroes the display identity on globalAuthor:', async () => { const mapper = `mapper-${Date.now()}-${Math.random().toString(36).slice(2)}`; const {authorID} = await authorManager.createAuthorIfNotExistsFor(mapper, 'Alice'); assert.equal(await authorManager.getAuthorName(authorID), 'Alice'); @@ -29,7 +28,7 @@ describe(__filename, function (this: any) { }); it('drops token2author and mapper2author mappings pointing at the author', - async function (this: any) { + async function () { const mapper = `mapper-${Date.now()}-${Math.random().toString(36).slice(2)}`; const {authorID} = await authorManager.createAuthorIfNotExistsFor(mapper, 'Bob'); const token = @@ -49,7 +48,7 @@ describe(__filename, function (this: any) { assert.ok((await DB.db.get(`mapper2author:${mapper}`)) == null); }); - it('is idempotent — second call returns zero counters', async function (this: any) { + it('is idempotent — second call returns zero counters', async () => { const mapper = `mapper-${Date.now()}-${Math.random().toString(36).slice(2)}`; const {authorID} = await authorManager.createAuthorIfNotExistsFor(mapper, 'Carol'); await authorManager.anonymizeAuthor(authorID); @@ -62,7 +61,7 @@ describe(__filename, function (this: any) { }); }); - it('returns zero counters for an unknown authorID', async function (this: any) { + it('returns zero counters for an unknown authorID', async () => { const res = await authorManager.anonymizeAuthor('a.does-not-exist'); assert.deepEqual(res, { affectedPads: 0, @@ -73,7 +72,7 @@ describe(__filename, function (this: any) { }); it('re-runs the sweep when a prior call errored before setting erased=true', - async function (this: any) { + async function () { const mapper = `mapper-${Date.now()}-${Math.random().toString(36).slice(2)}`; const {authorID} = await authorManager.createAuthorIfNotExistsFor(mapper, 'Dan'); @@ -92,7 +91,7 @@ describe(__filename, function (this: any) { }); it('dryRun returns the same counter shape but does not mutate the record', - async function (this: any) { + async function () { const mapper = `mapper-${Date.now()}-${Math.random().toString(36).slice(2)}`; const {authorID} = await authorManager.createAuthorIfNotExistsFor(mapper, 'Eve'); @@ -115,7 +114,7 @@ describe(__filename, function (this: any) { }); it('dryRun on an unknown authorID returns zero counters without throwing', - async function (this: any) { + async function () { const res = await authorManager.anonymizeAuthor( 'a.does-not-exist-xxxxxxxxxxxx', {dryRun: true}); assert.deepEqual(res, { @@ -127,7 +126,7 @@ describe(__filename, function (this: any) { }); it('lastSeen is stamped when an author is created and on identity writes', - async function (this: any) { + async function () { const before = Date.now(); const {authorID} = await authorManager.createAuthorIfNotExistsFor( `mapper-${Date.now()}-${Math.random().toString(36).slice(2)}`, 'Dora'); diff --git a/src/tests/backend/specs/api/anonymizeAuthor.ts b/src/tests/backend/specs/api/anonymizeAuthor.ts index 20eebf893..4c238354b 100644 --- a/src/tests/backend/specs/api/anonymizeAuthor.ts +++ b/src/tests/backend/specs/api/anonymizeAuthor.ts @@ -18,11 +18,10 @@ const callApi = async (point: string, query: Record = {}) => { .expect('Content-Type', /json/); }; -describe(__filename, function (this: any) { +describe(__filename, () => { let originalErasureFlag: boolean | undefined; - before(async function (this: any) { - this.timeout(60000); + before(async () => { agent = await common.init(); const res = await agent.get('/api/').expect(200); apiVersion = res.body.currentVersion; @@ -31,11 +30,11 @@ describe(__filename, function (this: any) { settings.gdprAuthorErasure.enabled = true; }); - after(function (this: any) { + after(() => { settings.gdprAuthorErasure.enabled = originalErasureFlag; }); - it('anonymizeAuthor zeroes the author and returns counters', async function (this: any) { + it('anonymizeAuthor zeroes the author and returns counters', async () => { const create = await callApi('createAuthor', {name: 'Alice'}); assert.equal(create.body.code, 0); const authorID = create.body.data.authorID; @@ -50,7 +49,7 @@ describe(__filename, function (this: any) { assert.equal(name.body.data, null); }); - it('anonymizeAuthor with missing authorID returns an error', async function (this: any) { + it('anonymizeAuthor with missing authorID returns an error', async () => { const res = await agent.get(`${endPoint('anonymizeAuthor')}?authorID=`) .set('authorization', await common.generateJWTToken()) .expect(200) @@ -60,7 +59,7 @@ describe(__filename, function (this: any) { }); it('anonymizeAuthor returns an apierror when gdprAuthorErasure is disabled', - async function (this: any) { + async function () { settings.gdprAuthorErasure.enabled = false; try { const res = await callApi('anonymizeAuthor', {authorID: 'a.dummy'}); diff --git a/src/tests/backend/specs/api/api.ts b/src/tests/backend/specs/api/api.ts index abb5a34e8..980939c22 100644 --- a/src/tests/backend/specs/api/api.ts +++ b/src/tests/backend/specs/api/api.ts @@ -33,10 +33,10 @@ const testPadId = makeid(); const endPoint = (point:string) => `/api/${apiVersion}/${point}`; -describe(__filename, function (this: any) { - before(async function (this: any) { agent = await common.init(); }); +describe(__filename, () => { + before(async () => { agent = await common.init(); }); - it('can obtain API version', async function (this: any) { + it('can obtain API version', async () => { await agent.get('/api/') .expect(200) .expect((res:any) => { @@ -46,7 +46,7 @@ describe(__filename, function (this: any) { }); }); - it('can obtain valid openapi definition document', async function (this: any) { + it('can obtain valid openapi definition document', async () => { await agent.get('/api/openapi.json') .expect(200) .expect((res:any) => { @@ -59,19 +59,19 @@ describe(__filename, function (this: any) { }); }); - describe('security schemes with authenticationMethod=apikey', function (this: any) { + describe('security schemes with authenticationMethod=apikey', () => { let originalAuthMethod: string; - before(function (this: any) { + before(() => { originalAuthMethod = settings.authenticationMethod; settings.authenticationMethod = 'apikey'; }); - after(function (this: any) { + after(() => { settings.authenticationMethod = originalAuthMethod; }); - it('/api-docs.json documents apikey query param (primary name)', async function (this: any) { + it('/api-docs.json documents apikey query param (primary name)', async () => { const res = await agent.get('/api-docs.json').expect(200); const schemes = res.body.components.securitySchemes; const apiKeyQuery = Object.values(schemes).find( @@ -82,7 +82,7 @@ describe(__filename, function (this: any) { } }); - it('/api-docs.json documents api_key query param alias', async function (this: any) { + it('/api-docs.json documents api_key query param alias', async () => { const res = await agent.get('/api-docs.json').expect(200); const schemes = res.body.components.securitySchemes; const apiKeyQueryAlias = Object.values(schemes).find( @@ -93,7 +93,7 @@ describe(__filename, function (this: any) { } }); - it('/api-docs.json documents apikey header', async function (this: any) { + it('/api-docs.json documents apikey header', async () => { const res = await agent.get('/api-docs.json').expect(200); const schemes = res.body.components.securitySchemes; const apiKeyHeader = Object.values(schemes).find( @@ -104,7 +104,7 @@ describe(__filename, function (this: any) { } }); - it('/api/openapi.json exposes apiKey security in apikey mode', async function (this: any) { + it('/api/openapi.json exposes apiKey security in apikey mode', async () => { const res = await agent.get('/api/openapi.json').expect(200); const schemes = res.body.components.securitySchemes; const hasApiKey = Object.values(schemes).some((s: any) => s.type === 'apiKey'); @@ -115,15 +115,14 @@ describe(__filename, function (this: any) { }); }); - describe('public OpenAPI spec shape (for downstream codegens)', function (this: any) { + describe('public OpenAPI spec shape (for downstream codegens)', () => { let spec: any; - before(async function (this: any) { - this.timeout(15000); + before(async () => { spec = (await agent.get('/api/openapi.json').expect(200)).body; }); - it('declares a top-level tags array with all expected resource groups', function (this: any) { + it('declares a top-level tags array with all expected resource groups', () => { if (!Array.isArray(spec.tags)) { throw new Error(`Expected top-level tags to be an array, got ${typeof spec.tags}`); } @@ -135,7 +134,7 @@ describe(__filename, function (this: any) { } }); - it('tags every operation with at least one non-empty tag', function (this: any) { + it('tags every operation with at least one non-empty tag', () => { const untagged: string[] = []; for (const [path, methods] of Object.entries(spec.paths)) { for (const [method, op] of Object.entries(methods as any)) { @@ -150,7 +149,7 @@ describe(__filename, function (this: any) { } }); - it('summarizes every operation', function (this: any) { + it('summarizes every operation', () => { const unsummarized: string[] = []; for (const [path, methods] of Object.entries(spec.paths)) { for (const [method, op] of Object.entries(methods as any)) { @@ -168,7 +167,7 @@ describe(__filename, function (this: any) { } }); - it('advertises only POST per path (downstream tooling cleanliness)', function (this: any) { + it('advertises only POST per path (downstream tooling cleanliness)', () => { const offenders: string[] = []; for (const [path, methods] of Object.entries(spec.paths)) { const verbs = Object.keys(methods as any); @@ -184,7 +183,7 @@ describe(__filename, function (this: any) { }); }); - describe('runtime backward compatibility (GET + POST still routed)', function (this: any) { + describe('runtime backward compatibility (GET + POST still routed)', () => { // The runtime spec used by openapi-backend keeps both verbs even though the // public /api/openapi.json advertises POST only. The point of these tests // is to prove openapi-backend still resolves both verbs to the handler @@ -201,12 +200,12 @@ describe(__filename, function (this: any) { } }; - it('GET requests still reach the API handler', async function (this: any) { + it('GET requests still reach the API handler', async () => { const r = await agent.get(endPoint('checkToken')); assertResolved('GET checkToken', r.body); }); - it('POST requests still reach the API handler', async function (this: any) { + it('POST requests still reach the API handler', async () => { const r = await agent.post(endPoint('checkToken')); assertResolved('POST checkToken', r.body); }); @@ -214,7 +213,7 @@ describe(__filename, function (this: any) { // Regression for the REST-style routes — checkToken's _restPath is // derived from its position in the resources map (pad/checkToken). // Tagging it as 'server' must not move it to /rest/X/server/checkToken. - it('REST-style /rest//pad/checkToken still resolves', async function (this: any) { + it('REST-style /rest//pad/checkToken still resolves', async () => { const r = await agent.get(`/rest/${apiVersion}/pad/checkToken`); assertResolved('GET /rest pad/checkToken', r.body); }); diff --git a/src/tests/backend/specs/api/deletePad.ts b/src/tests/backend/specs/api/deletePad.ts index 6d56b4a39..6ba3cb2d4 100644 --- a/src/tests/backend/specs/api/deletePad.ts +++ b/src/tests/backend/specs/api/deletePad.ts @@ -21,20 +21,19 @@ const callApi = async (point: string, query: Record = {}) => { .expect('Content-Type', /json/); }; -describe(__filename, function (this: any) { - before(async function (this: any) { - this.timeout(60000); +describe(__filename, () => { + before(async () => { agent = await common.init(); const res = await agent.get('/api/').expect(200); apiVersion = res.body.currentVersion; }); - afterEach(function (this: any) { + afterEach(() => { settings.allowPadDeletionByAllUsers = false; settings.requireAuthentication = false; }); - it('createPad returns a plaintext deletionToken the first time', async function (this: any) { + it('createPad returns a plaintext deletionToken the first time', async () => { const padId = makeId(); const res = await callApi('createPad', {padID: padId}); assert.equal(res.body.code, 0, JSON.stringify(res.body)); @@ -43,7 +42,7 @@ describe(__filename, function (this: any) { await callApi('deletePad', {padID: padId, deletionToken: res.body.data.deletionToken}); }); - it('deletePad with a valid deletionToken succeeds', async function (this: any) { + it('deletePad with a valid deletionToken succeeds', async () => { const padId = makeId(); const create = await callApi('createPad', {padID: padId}); const token = create.body.data.deletionToken; @@ -53,7 +52,7 @@ describe(__filename, function (this: any) { assert.equal(check.body.code, 1); // "padID does not exist" }); - it('deletePad with a wrong deletionToken is refused', async function (this: any) { + it('deletePad with a wrong deletionToken is refused', async () => { const padId = makeId(); await callApi('createPad', {padID: padId}); const del = await callApi('deletePad', {padID: padId, deletionToken: 'not-the-real-token'}); @@ -63,7 +62,7 @@ describe(__filename, function (this: any) { await callApi('deletePad', {padID: padId}); }); - it('deletePad with allowPadDeletionByAllUsers=true bypasses the token check', async function (this: any) { + it('deletePad with allowPadDeletionByAllUsers=true bypasses the token check', async () => { const padId = makeId(); await callApi('createPad', {padID: padId}); settings.allowPadDeletionByAllUsers = true; @@ -71,7 +70,7 @@ describe(__filename, function (this: any) { assert.equal(del.body.code, 0); }); - it('createPad returns null deletionToken when requireAuthentication is on', async function (this: any) { + it('createPad returns null deletionToken when requireAuthentication is on', async () => { settings.requireAuthentication = true; const padId = makeId(); const res = await callApi('createPad', {padID: padId}); @@ -80,7 +79,7 @@ describe(__filename, function (this: any) { await callApi('deletePad', {padID: padId}); }); - it('JWT admin call (no deletionToken) still works — admins stay trusted', async function (this: any) { + it('JWT admin call (no deletionToken) still works — admins stay trusted', async () => { const padId = makeId(); await callApi('createPad', {padID: padId}); const del = await callApi('deletePad', {padID: padId}); diff --git a/src/tests/backend/specs/authorTokenCookie.ts b/src/tests/backend/specs/authorTokenCookie.ts index 92f61c4cc..c174e8ae7 100644 --- a/src/tests/backend/specs/authorTokenCookie.ts +++ b/src/tests/backend/specs/authorTokenCookie.ts @@ -5,17 +5,16 @@ import {strict as assert} from 'assert'; const common = require('../common'); const setCookieParser = require('set-cookie-parser'); -describe(__filename, function (this: any) { +describe(__filename, () => { let agent: any; - before(async function (this: any) { - this.timeout(60000); + before(async () => { agent = await common.init(); }); const padPath = () => `/p/PR3_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; - it('sets an HttpOnly token cookie on first visit', async function (this: any) { + it('sets an HttpOnly token cookie on first visit', async () => { const res = await agent.get(padPath()).expect(200); const cookies = setCookieParser.parse(res, {map: true}); const tokenEntry = Object.entries(cookies).find(([k]) => k.endsWith('token')); @@ -28,7 +27,7 @@ describe(__filename, function (this: any) { assert.equal(tokenCookie.path, '/'); }); - it('reuses the cookie value on subsequent visits', async function (this: any) { + it('reuses the cookie value on subsequent visits', async () => { const path = padPath(); const first = await agent.get(path).expect(200); const firstCookies = setCookieParser.parse(first, {map: true}); diff --git a/src/tests/backend/specs/export.ts b/src/tests/backend/specs/export.ts index 4940310ff..8c34419af 100644 --- a/src/tests/backend/specs/export.ts +++ b/src/tests/backend/specs/export.ts @@ -19,21 +19,35 @@ const __dirname = dirname(__filename); // require.resolve() probing. const require = createRequire(import.meta.url); -describe(__filename, function (this: any) { +// Probe optional native-export dependencies once at module load. The +// upgrade-from-latest-release CI job installs deps from the PREVIOUS +// release's package.json (before html-to-docx / pdfkit / htmlparser2 were +// added) and then git-checkouts this branch's code without re-running +// `pnpm install`. Under that workflow these modules aren't resolvable; +// vitest's describe.skipIf / it.skipIf will skip the blocks that need +// them. Regular backend tests (which install against this branch's +// lockfile) still exercise them. +const canResolve = (mod: string): boolean => { + try { require.resolve(mod); return true; } catch { return false; } +}; +const hasHtmlToDocx = canResolve('html-to-docx'); +const hasPdfkitDeps = canResolve('pdfkit') && canResolve('htmlparser2'); + +describe(__filename, () => { let agent:any; const settingsBackup:MapArrayType = {}; - before(async function (this: any) { + before(async () => { agent = await common.init(); settingsBackup.soffice = settings.soffice; await padManager.getPad('testExportPad', 'test content'); }); - after(async function (this: any) { + after(async () => { Object.assign(settings, settingsBackup); }); - it('returns 500 on export error', async function (this: any) { + it('returns 500 on export error', async () => { // Mock the exportConvert hook to throw, exercising the route's error // path without depending on an actual soffice install on the host. // .doc has no native fallback (it stays soffice/hook-only), so this @@ -56,24 +70,12 @@ describe(__filename, function (this: any) { // Issue #7538: in-process DOCX export via html-to-docx bypasses the // soffice requirement entirely. A deployment with `soffice: null` // should still produce a working .docx via the native path. - describe('native DOCX export (#7538)', function (this: any) { - before(function (this: any) { - // The upgrade-from-latest-release CI job installs deps from the - // PREVIOUS release's package.json (before this PR adds html-to-docx) - // and then git-checkouts this branch's code without re-running - // `pnpm install`. Under that workflow the module isn't resolvable. - // Skip the block in that one case; regular backend tests (which - // install against this branch's lockfile) still exercise it. - try { - require.resolve('html-to-docx'); - } catch { - this.skip(); - return; - } + describe.skipIf(!hasHtmlToDocx)('native DOCX export (#7538)', () => { + before(() => { settings.soffice = null; }); - it('returns a valid DOCX archive (PK zip signature)', async function (this: any) { + it('returns a valid DOCX archive (PK zip signature)', async () => { const res = await agent.get('/p/testExportPad/export/docx') .buffer(true) .parse((resp: any, callback: any) => { @@ -92,7 +94,7 @@ describe(__filename, function (this: any) { assert.strictEqual(body[3], 0x04, 'byte 3'); }); - it('sends the Word-processing-ml content-type', async function (this: any) { + it('sends the Word-processing-ml content-type', async () => { const res = await agent.get('/p/testExportPad/export/docx').expect(200); assert.match(res.headers['content-type'], /application\/vnd\.openxmlformats-officedocument\.wordprocessingml\.document/, @@ -100,19 +102,12 @@ describe(__filename, function (this: any) { }); }); - describe('native PDF export (#7538)', function (this: any) { - before(function (this: any) { - try { - require.resolve('pdfkit'); - require.resolve('htmlparser2'); - } catch { - this.skip(); - return; - } + describe.skipIf(!hasPdfkitDeps)('native PDF export (#7538)', () => { + before(() => { settings.soffice = null; }); - it('returns a valid %PDF- document', async function (this: any) { + it('returns a valid %PDF- document', async () => { const res = await agent.get('/p/testExportPad/export/pdf') .buffer(true) .parse((resp: any, callback: any) => { @@ -126,35 +121,35 @@ describe(__filename, function (this: any) { assert.strictEqual(body.slice(0, 5).toString('ascii'), '%PDF-'); }); - it('sends application/pdf content-type', async function (this: any) { + it('sends application/pdf content-type', async () => { const res = await agent.get('/p/testExportPad/export/pdf').expect(200); assert.match(res.headers['content-type'], /application\/pdf/); }); }); - describe('odt without soffice (#7538)', function (this: any) { - before(function (this: any) { settings.soffice = null; }); - it('returns the "not enabled" message for odt', async function (this: any) { + describe('odt without soffice (#7538)', () => { + before(() => { settings.soffice = null; }); + it('returns the "not enabled" message for odt', async () => { const res = await agent.get('/p/testExportPad/export/odt').expect(200); assert.match(res.text, /This export is not enabled/); }); }); - describe('stripRemoteImages', function (this: any) { + describe('stripRemoteImages', () => { const {stripRemoteImages} = require('../../../node/utils/ExportSanitizeHtml'); - it('keeps data: URIs', function (this: any) { + it('keeps data: URIs', () => { const out = stripRemoteImages( '

x

'); assert.match(out, /]+src="data:image\/png/); }); - it('keeps relative URLs', function (this: any) { + it('keeps relative URLs', () => { const out = stripRemoteImages(''); assert.match(out, /]+src="\/foo\/bar\.png"/); }); - it('drops absolute http(s) URLs and falls back to alt', function (this: any) { + it('drops absolute http(s) URLs and falls back to alt', () => { const out = stripRemoteImages( '

beforecatafter

'); assert.doesNotMatch(out, /evil\.example/); @@ -163,33 +158,33 @@ describe(__filename, function (this: any) { assert.match(out, /after/); }); - it('drops protocol-relative URLs', function (this: any) { + it('drops protocol-relative URLs', () => { const out = stripRemoteImages(''); assert.doesNotMatch(out, /evil\.example/); }); - it('passes non-image markup through unchanged', function (this: any) { + it('passes non-image markup through unchanged', () => { const html = '

hi

body link

'; assert.strictEqual(stripRemoteImages(html), html); }); }); - describe('extractBody', function (this: any) { + describe('extractBody', () => { const {extractBody} = require('../../../node/utils/ExportSanitizeHtml'); - it('returns trimmed body content from a full document', function (this: any) { + it('returns trimmed body content from a full document', () => { const html = ` hello
world `; assert.strictEqual(extractBody(html), 'hello
world'); }); - it('passes a body-less fragment through unchanged', function (this: any) { + it('passes a body-less fragment through unchanged', () => { const html = '

just a fragment

'; assert.strictEqual(extractBody(html), html); }); - it('drops

kept

'; const out = extractBody(html); assert.doesNotMatch(out, /style/); @@ -198,18 +193,18 @@ hello
world }); }); - describe('wrapLooseLines', function (this: any) { + describe('wrapLooseLines', () => { const {wrapLooseLines} = require('../../../node/utils/ExportSanitizeHtml'); - it('wraps loose text in

', function (this: any) { + it('wraps loose text in

', () => { assert.strictEqual(wrapLooseLines('Hello'), '

Hello

'); }); - it('keeps single
as soft break inside one paragraph', function (this: any) { + it('keeps single
as soft break inside one paragraph', () => { assert.strictEqual(wrapLooseLines('A
B'), '

A
B

'); }); - it('splits paragraphs on consecutive
', function (this: any) { + it('splits paragraphs on consecutive
', () => { // Two
s between content: one paragraph break + one empty //

marker so the blank pad line survives a DOCX round-trip // through html-to-docx and mammoth. @@ -217,22 +212,22 @@ hello
world '

A

B

'); }); - it('emits more empty

markers for longer
runs', function (this: any) { + it('emits more empty

markers for longer
runs', () => { // Three
s = 2 blank pad lines between content. assert.strictEqual(wrapLooseLines('A


B'), '

A

B

'); }); - it('drops trailing
', function (this: any) { + it('drops trailing
', () => { assert.strictEqual(wrapLooseLines('Foo
'), '

Foo

'); }); - it('leaves block elements alone', function (this: any) { + it('leaves block elements alone', () => { const html = '
  • x
'; assert.strictEqual(wrapLooseLines(html), html); }); - it('handles realistic etherpad pad HTML', function (this: any) { + it('handles realistic etherpad pad HTML', () => { const out = wrapLooseLines( 'Welcome!

Body text.
More text.
'); //

-> blank-line marker between Welcome and Body text; @@ -243,22 +238,22 @@ hello
world }); }); - describe('dropEmptyBlocks', function (this: any) { + describe('dropEmptyBlocks', () => { const {dropEmptyBlocks} = require('../../../node/utils/ExportSanitizeHtml'); - it('drops empty heading blocks', function (this: any) { + it('drops empty heading blocks', () => { const out = dropEmptyBlocks( "

Hi



x"); assert.strictEqual(out, "

Hi



x"); }); - it('drops empty code blocks', function (this: any) { + it('drops empty code blocks', () => { assert.strictEqual(dropEmptyBlocks('x'), 'x'); assert.strictEqual( dropEmptyBlocks(' \n\t x'), 'x'); }); - it('iterates so nested empties are dropped too', function (this: any) { + it('iterates so nested empties are dropped too', () => { // inside a
-> div becomes empty -> div drops too. // (

is preserved on purpose; wrapLooseLines uses it as a // blank-line marker for DOCX round-trip fidelity.) @@ -266,28 +261,28 @@ hello
world assert.strictEqual(out, 'after'); }); - it('does not drop empty

(blank-line marker)', function (this: any) { + it('does not drop empty

(blank-line marker)', () => { const out = dropEmptyBlocks('

x

y

'); assert.strictEqual(out, '

x

y

'); }); - it('keeps non-empty blocks unchanged', function (this: any) { + it('keeps non-empty blocks unchanged', () => { const html = '

Hi

body

x = 1'; assert.strictEqual(dropEmptyBlocks(html), html); }); }); - describe('collapseRedundantBrAfterBlocks', function (this: any) { + describe('collapseRedundantBrAfterBlocks', () => { const {collapseRedundantBrAfterBlocks} = require('../../../node/utils/ExportSanitizeHtml'); - it('drops
immediately after a closing

', function (this: any) { + it('drops
immediately after a closing

', () => { assert.strictEqual( collapseRedundantBrAfterBlocks('

x


y

'), '

x

y

'); }); - it('drops
after closing heading and code tags', function (this: any) { + it('drops
after closing heading and code tags', () => { for (const tag of ['h1', 'h2', 'h3', 'code', 'pre', 'div', 'blockquote']) { assert.strictEqual( collapseRedundantBrAfterBlocks(`<${tag}>x
`), @@ -296,18 +291,18 @@ hello
world } }); - it('keeps a standalone
between text', function (this: any) { + it('keeps a standalone
between text', () => { const html = 'Hello
World'; assert.strictEqual(collapseRedundantBrAfterBlocks(html), html); }); - it('handles whitespace between and
', function (this: any) { + it('handles whitespace between and
', () => { assert.strictEqual( collapseRedundantBrAfterBlocks('

x

\n
after'), '

x

after'); }); - it('drops only one
, leaving any subsequent ones', function (this: any) { + it('drops only one
, leaving any subsequent ones', () => { //

after a closing block represents (one redundant + one // intentional blank-line break). After collapsing the first, the // second remains. @@ -317,34 +312,34 @@ hello
world }); }); - describe('separateAdjacentHeadingBlocks', function (this: any) { + describe('separateAdjacentHeadingBlocks', () => { const {separateAdjacentHeadingBlocks} = require('../../../node/utils/ExportSanitizeHtml'); - it('inserts
between adjacent

and

', function (this: any) { + it('inserts
between adjacent

and

', () => { assert.strictEqual( separateAdjacentHeadingBlocks('

A

B

'), '

A


B

'); }); - it('inserts
between adjacent blocks', function (this: any) { + it('inserts
between adjacent blocks', () => { assert.strictEqual( separateAdjacentHeadingBlocks('AB'), 'A
B'); }); - it('inserts
after a heading before a

', function (this: any) { + it('inserts
after a heading before a

', () => { assert.strictEqual( separateAdjacentHeadingBlocks('

A

B

'), '

A


B

'); }); - it('does not change adjacent

elements', function (this: any) { + it('does not change adjacent

elements', () => { const html = '

A

B

'; assert.strictEqual(separateAdjacentHeadingBlocks(html), html); }); - it('handles three-block round-trip case', function (this: any) { + it('handles three-block round-trip case', () => { // Mirrors what mammoth produces for a pad with H1 + H2 + Code. assert.strictEqual( separateAdjacentHeadingBlocks( @@ -353,11 +348,11 @@ hello
world }); }); - describe('applyMonospaceToCode', function (this: any) { + describe('applyMonospaceToCode', () => { const {applyMonospaceToCode} = require('../../../node/utils/ExportSanitizeHtml'); - it('emits a Courier span for inline ', function (this: any) { + it('emits a Courier span for inline ', () => { // The tag itself is dropped (html-to-docx ignores it and // also breaks children when they're nested inside it). The // text becomes a Courier-styled inline span. @@ -366,7 +361,7 @@ hello
world `x = 1`); }); - it('forwards block-level style to a wrapping

', function (this: any) { + it('forwards block-level style to a wrapping

', () => { // ep_headings2 + ep_align emit `` // for each "Code"-styled pad line. The alignment must reach // html-to-docx as a paragraph property, so we move the style @@ -376,7 +371,7 @@ hello
world assert.match(out, /font-family:'Courier New'/); }); - it('emits

wrap for

 regardless of style', function (this: any) {
+    it('emits 

wrap for

 regardless of style', () => {
       // 
 is always block-level.
       const out = applyMonospaceToCode('
preformatted
'); assert.match(out, /^

/); @@ -384,7 +379,7 @@ hello
world assert.match(out, /font-family:'Courier New'/); }); - it('handles inline , , as bare spans', function (this: any) { + it('handles inline , , as bare spans', () => { for (const tag of ['tt', 'kbd', 'samp']) { const out = applyMonospaceToCode(`<${tag}>x`); assert.strictEqual(out, @@ -393,12 +388,12 @@ hello
world } }); - it('does not touch unrelated tags', function (this: any) { + it('does not touch unrelated tags', () => { const html = '

plain

bold'; assert.strictEqual(applyMonospaceToCode(html), html); }); - it('does not wrap
elements in the Courier span', function (this: any) { + it('does not wrap elements in the Courier span', () => { // Regression: html-to-docx drops content when nested // inside a styled span OR inside . We split on anchors // and leave them unstyled. @@ -415,9 +410,7 @@ hello
world assert.doesNotMatch(out, /<\/code>/); }); - it('preserves
through html-to-docx round-trip', async function (this: any) { - try { require.resolve('html-to-docx'); } - catch { this.skip(); return; } + it.skipIf(!hasHtmlToDocx)('preserves through html-to-docx round-trip', async () => { const htmlToDocx = require('html-to-docx'); const JSZip = require('jszip'); const buf: Buffer = await htmlToDocx(applyMonospaceToCode( @@ -434,21 +427,14 @@ hello
world }); }); - describe('htmlToPdfBuffer', function (this: any) { + describe.skipIf(!hasPdfkitDeps)('htmlToPdfBuffer', () => { let htmlToPdfBuffer: (html: string) => Promise; - before(function (this: any) { - try { - require.resolve('pdfkit'); - require.resolve('htmlparser2'); - } catch { - this.skip(); - return; - } + before(() => { htmlToPdfBuffer = require('../../../node/utils/ExportPdfNative').htmlToPdfBuffer; }); - it('produces a buffer starting with %PDF-', async function (this: any) { + it('produces a buffer starting with %PDF-', async () => { const buf = await htmlToPdfBuffer('

hello world

'); assert.ok(Buffer.isBuffer(buf), 'must return Buffer'); assert.ok(buf.length > 100, `buffer suspiciously small: ${buf.length} bytes`); @@ -477,7 +463,7 @@ hello
world return buf.toString('latin1'); }; - it('renders headings, paragraphs, and lists', async function (this: any) { + it('renders headings, paragraphs, and lists', async () => { const raw = await renderText(`

Title

Body paragraph here.

@@ -494,7 +480,7 @@ hello
world assert.ok(visible.includes('beta'), `expected "beta" in: ${visible}`); }); - it('emits link annotations for
', async function (this: any) { + it('emits link annotations for ', async () => { const raw = await renderText('

site

'); const visible = decodeVisibleText(raw); assert.ok(visible.includes('site'), `expected "site" in: ${visible}`); @@ -505,20 +491,20 @@ hello
world 'expected link target URL in PDF /URI dict'); }); - it('embeds data: URI images without throwing', async function (this: any) { + it('embeds data: URI images without throwing', async () => { const tinyPng = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; const buf = await htmlToPdfBuffer(``); assert.ok(buf.length > 200); }); - it('ignores unknown tags rather than crashing', async function (this: any) { + it('ignores unknown tags rather than crashing', async () => { const buf = await htmlToPdfBuffer( '

still works

'); assert.strictEqual(buf.slice(0, 5).toString('ascii'), '%PDF-'); }); - it('does not render head/style/script content', async function (this: any) { + it('does not render head/style/script content', async () => { const raw = await renderText(` SECRET_TITLE @@ -535,7 +521,7 @@ hello
world assert.match(visible, /visible body/); }); - it('honors text-align style on block elements', async function (this: any) { + it('honors text-align style on block elements', async () => { // pdfkit emits text-positioning matrices for aligned text. We assert // the alignment option produced different output than left-aligned // by checking the x coordinate of the BT block. @@ -549,7 +535,7 @@ hello
world `right-aligned text should sit at a different x than left-aligned (left=${leftX} right=${rightX})`); }); - it('uses Courier font inside ', async function (this: any) { + it('uses Courier font inside ', async () => { const raw = await renderText('

before x = 1 after

'); // pdfkit references the font in the resource dictionary; Courier // isn't in the default resources so its first use creates a new @@ -557,12 +543,12 @@ hello
world assert.match(raw, /Courier/); }); - it('uses Courier font inside
', async function (this: any) {
+    it('uses Courier font inside 
', async () => {
       const raw = await renderText('
preformatted text
'); assert.match(raw, /Courier/); }); - it('honors text-align on (ep_headings2 code lines)', async function (this: any) { + it('honors text-align on (ep_headings2 code lines)', async () => { const leftRaw = await renderText('x = 1'); const rightRaw = await renderText("x = 1"); const leftX = (leftRaw.match(/1 0 0 1 (\d+(?:\.\d+)?)/) || [])[1]; @@ -573,7 +559,7 @@ hello
world `right-aligned should sit at a different x than left-aligned (left=${leftX} right=${rightX})`); }); - it('honors text-align on
', async function (this: any) {
+    it('honors text-align on 
', async () => {
       const leftRaw = await renderText('
x = 1
'); const rightRaw = await renderText("
x = 1
"); const leftX = (leftRaw.match(/1 0 0 1 (\d+(?:\.\d+)?)/) || [])[1]; diff --git a/src/tests/backend/specs/import.ts b/src/tests/backend/specs/import.ts index fb2571ed3..cad868035 100644 --- a/src/tests/backend/specs/import.ts +++ b/src/tests/backend/specs/import.ts @@ -1,38 +1,53 @@ 'use strict'; +import {fileURLToPath} from 'node:url'; +import {dirname} from 'node:path'; +import {createRequire} from 'node:module'; +import {strict as assert} from 'node:assert'; import {MapArrayType} from '../../../node/types/MapType.js'; import path from 'path'; import os from 'os'; import {promises as fs} from 'fs'; -const assert = require('assert').strict; -const common = require('../common'); -const padManager = require('../../../node/db/PadManager'); +import * as common from '../common.js'; +import * as padManager from '../../../node/db/PadManager.js'; import settings from '../../../node/utils/Settings.js'; -describe(__filename, function (this: any) { +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +// Inline CJS bridge for the optional native-import modules (mammoth, +// html-to-docx) — the test body uses `require.resolve()` to skip +// gracefully on installs that don't ship them. +const require = createRequire(import.meta.url); + +const canResolve = (mod: string): boolean => { + try { require.resolve(mod); return true; } catch { return false; } +}; +const hasMammoth = canResolve('mammoth'); +const hasHtmlToDocx = canResolve('html-to-docx'); +const hasDocxRoundTrip = hasMammoth && hasHtmlToDocx; + +describe(__filename, () => { const settingsBackup: MapArrayType = {}; let agent: any; - before(async function (this: any) { + before(async () => { agent = await common.init(); settingsBackup.soffice = settings.soffice; }); - after(function (this: any) { + after(() => { Object.assign(settings, settingsBackup); }); - describe('docxBufferToHtml (#7538)', function (this: any) { + describe.skipIf(!hasMammoth)('docxBufferToHtml (#7538)', () => { let docxBufferToHtml: (b: Buffer) => Promise; - before(function (this: any) { - try { require.resolve('mammoth'); } - catch { this.skip(); return; } + before(() => { docxBufferToHtml = require('../../../node/utils/ImportDocxNative').docxBufferToHtml; }); - it('converts the sample.docx fixture to HTML', async function (this: any) { + it('converts the sample.docx fixture to HTML', async () => { const buf = await fs.readFile( path.join(__dirname, 'fixtures', 'sample.docx')); const html = await docxBufferToHtml(buf); @@ -42,7 +57,7 @@ describe(__filename, function (this: any) { assert.match(html, /two/); }); - it('emits no remote image URLs', async function (this: any) { + it('emits no remote image URLs', async () => { const buf = await fs.readFile( path.join(__dirname, 'fixtures', 'sample.docx')); const html = await docxBufferToHtml(buf); @@ -50,11 +65,9 @@ describe(__filename, function (this: any) { assert.doesNotMatch(html, /]+src="\/\//); }); - it('preserves paragraph alignment from ', async function (this: any) { + it.skipIf(!hasHtmlToDocx)('preserves paragraph alignment from ', async () => { // Round through html-to-docx so the input docx has entries // we can verify mammoth + our workaround surface as text-align. - try { require.resolve('html-to-docx'); } - catch { this.skip(); return; } const htmlToDocx = require('html-to-docx'); const docx: Buffer = await htmlToDocx( '

Right heading

' + @@ -74,14 +87,12 @@ describe(__filename, function (this: any) { }); }); - describe('end-to-end DOCX import (#7538)', function (this: any) { - before(function (this: any) { - try { require.resolve('mammoth'); } - catch { this.skip(); return; } + describe.skipIf(!hasMammoth)('end-to-end DOCX import (#7538)', () => { + before(() => { settings.soffice = null; }); - it('imports a docx into a pad without soffice', async function (this: any) { + it('imports a docx into a pad without soffice', async () => { const padId = 'test7538DocxImport'; try { await padManager.removePad(padId); } catch { /* noop */ } const fixture = path.join(__dirname, 'fixtures', 'sample.docx'); @@ -99,7 +110,7 @@ describe(__filename, function (this: any) { assert.match(text, /two/); }); - it('rejects odt extension when soffice is null', async function (this: any) { + it('rejects odt extension when soffice is null', async () => { const padId = 'test7538OdtReject'; try { await padManager.removePad(padId); } catch { /* noop */ } const fixture = path.join(__dirname, 'fixtures', 'sample.docx'); @@ -118,15 +129,8 @@ describe(__filename, function (this: any) { }); }); - describe('DOCX export -> import round-trip (#7538)', function (this: any) { - before(function (this: any) { - try { - require.resolve('html-to-docx'); - require.resolve('mammoth'); - } catch { - this.skip(); - return; - } + describe.skipIf(!hasDocxRoundTrip)('DOCX export -> import round-trip (#7538)', () => { + before(() => { settings.soffice = null; }); @@ -141,7 +145,7 @@ describe(__filename, function (this: any) { resp.on('end', () => cb(null, Buffer.concat(chunks))); }); - it('preserves text content through native DOCX round-trip', async function (this: any) { + it('preserves text content through native DOCX round-trip', async () => { const srcPadId = 'test7538RoundTripSrc'; const dstPadId = 'test7538RoundTripDst'; const tmpFile = path.join(os.tmpdir(), `roundtrip-${process.pid}.docx`); @@ -215,7 +219,7 @@ describe(__filename, function (this: any) { const SAMPLE_TEXT = 'Line one\nLine two\n\nAfter blank\n'; - it('a==c round-trip: txt export -> import -> export', async function (this: any) { + it('a==c round-trip: txt export -> import -> export', async () => { const src = 'test7538RtTxtSrc'; const dst = 'test7538RtTxtDst'; await seedPad(src, SAMPLE_TEXT); @@ -226,7 +230,7 @@ describe(__filename, function (this: any) { `txt round-trip drift\nA:${JSON.stringify(a.toString('utf8'))}\nC:${JSON.stringify(c.toString('utf8'))}`); }); - it('a==c round-trip: etherpad export -> import -> export', async function (this: any) { + it('a==c round-trip: etherpad export -> import -> export', async () => { const src = 'test7538RtEpadSrc'; const dst = 'test7538RtEpadDst'; await seedPad(src, SAMPLE_TEXT); @@ -244,7 +248,7 @@ describe(__filename, function (this: any) { 'expected non-empty etherpad bodies'); }); - it('a==c round-trip: html export -> import -> export', async function (this: any) { + it('a==c round-trip: html export -> import -> export', async () => { const src = 'test7538RtHtmlSrc'; const dst = 'test7538RtHtmlDst'; await seedPad(src, SAMPLE_TEXT); @@ -266,7 +270,7 @@ describe(__filename, function (this: any) { }); it('a==c round-trip: docx export -> import -> export (line text)', - async function (this: any) { + async () => { const src = 'test7538RtDocxSrc'; const dst = 'test7538RtDocxDst'; await seedPad(src, SAMPLE_TEXT); @@ -286,25 +290,22 @@ describe(__filename, function (this: any) { }); }); - describe('HTML import — adjacent headings (#7538)', function (this: any) { - before(async function (this: any) { - // These tests assume ep_headings2 (or another plugin) registers - // h1/h2/etc. as server-side block elements via - // `ccRegisterBlockElements`. Without that hook, contentcollector - // treats

/

as inline and adjacent ones merge into a - // single pad line — making the assertions below moot. The CI - // backend-tests job runs without plugins installed, so skip - // there. Local dev with ep_headings2 installed exercises these. + // These tests assume ep_headings2 (or another plugin) registers h1/h2/etc. + // as server-side block elements via `ccRegisterBlockElements`. Without that + // hook, contentcollector treats

/

as inline and adjacent ones merge + // into a single pad line — making the assertions below moot. The CI + // backend-tests job runs without plugins installed, so each test skips at + // runtime via ctx.skip() if the hook isn't registered. Local dev with + // ep_headings2 installed exercises them. + describe('HTML import — adjacent headings (#7538)', () => { + let headingsAreBlocks = false; + before(async () => { const hooks = require('../../../static/js/pluginfw/hooks'); const ccBlockElems: string[] = ([] as string[]).concat( ...(hooks.callAll('ccRegisterBlockElements') || [])); - const headingsAreBlocks = ccBlockElems.map((t: string) => t.toLowerCase()) + headingsAreBlocks = ccBlockElems.map((t: string) => t.toLowerCase()) .includes('h1'); - if (!headingsAreBlocks) { - this.skip(); - return; - } - settings.soffice = null; + if (headingsAreBlocks) settings.soffice = null; }); const importHtml = async (padId: string, html: string) => { @@ -322,7 +323,8 @@ describe(__filename, function (this: any) { } }; - it('does not introduce a blank line between H1 and H2', async function (this: any) { + it('does not introduce a blank line between H1 and H2', async (ctx) => { + if (!headingsAreBlocks) ctx.skip(); const padId = 'test7538HtmlH1H2'; await importHtml(padId, '

A

B

'); const pad = await padManager.getPad(padId); @@ -346,7 +348,8 @@ describe(__filename, function (this: any) { // (encoded by ep_align as `



`) // + H2. The pad should round-trip back to H1, blank, blank, H2 -- not // gain or lose blank lines. -it('preserves blank-line count between H1 and H2 (realistic shape)', async function (this: any) { + it('preserves blank-line count between H1 and H2 (realistic shape)', async (ctx) => { + if (!headingsAreBlocks) ctx.skip(); const padId = 'test7538HtmlBlankLines'; const html = '' + @@ -371,15 +374,8 @@ it('preserves blank-line count between H1 and H2 (realistic shape)', async funct }); }); - describe('Round-trip integrity: heading-style content (#7538)', function (this: any) { - before(function (this: any) { - try { - require.resolve('html-to-docx'); - require.resolve('mammoth'); - } catch { - this.skip(); - return; - } + describe.skipIf(!hasDocxRoundTrip)('Round-trip integrity: heading-style content (#7538)', () => { + before(() => { settings.soffice = null; }); @@ -391,7 +387,7 @@ it('preserves blank-line count between H1 and H2 (realistic shape)', async funct resp.on('end', () => cb(null, Buffer.concat(chunks))); }); - it('keeps adjacent heading-style blocks on separate lines after round-trip', async function (this: any) { + it('keeps adjacent heading-style blocks on separate lines after round-trip', async () => { // Regression: ep_headings2 emits

/

/ that aren't in // contentcollector's default block-element set. Without the // separateAdjacentHeadingBlocks fix, mammoth's

A

B

@@ -440,7 +436,7 @@ it('preserves blank-line count between H1 and H2 (realistic shape)', async funct } }); - it('preserves text content through native PDF export (sanity check)', async function (this: any) { + it('preserves text content through native PDF export (sanity check)', async () => { // PDF round-trip is one-way (no native PDF import) -- this just // verifies the exported PDF has the source text in its visible // content stream, so we know nothing got dropped on export. diff --git a/src/tests/backend/specs/sessionIdCookie.ts b/src/tests/backend/specs/sessionIdCookie.ts index 380ea7dc2..c33231632 100644 --- a/src/tests/backend/specs/sessionIdCookie.ts +++ b/src/tests/backend/specs/sessionIdCookie.ts @@ -25,17 +25,16 @@ const io = require('socket.io-client'); const cookiePrefix = () => settings.cookie?.prefix || ''; -describe(__filename, function (this: any) { - this.timeout(30000); +describe(__filename, () => { let socket: any; - before(async function (this: any) { await common.init(); }); + before(async () => { await common.init(); }); - beforeEach(async function (this: any) { + beforeEach(async () => { assert(socket == null); }); - afterEach(async function (this: any) { + afterEach(async () => { if (socket) socket.close(); socket = null; if (await padManager.doesPadExist('pad')) { @@ -64,37 +63,37 @@ describe(__filename, function (this: any) { assert.equal(reply.type, 'CLIENT_VARS'); }; - it('reads sessionID from the handshake Cookie header', async function (this: any) { + it('reads sessionID from the handshake Cookie header', async () => { socket = await connectWithCookie('sessionID=s.aaaaaaaaaaaaaaaa'); await sendClientReady(socket, {}); assert.equal(sessioninfos[socket.id].auth.sessionID, 's.aaaaaaaaaaaaaaaa'); }); - it('honours the configured cookie prefix', async function (this: any) { + it('honours the configured cookie prefix', async () => { socket = await connectWithCookie(`${cookiePrefix()}sessionID=s.bbbbbbbbbbbbbbbb`); await sendClientReady(socket, {}); assert.equal(sessioninfos[socket.id].auth.sessionID, 's.bbbbbbbbbbbbbbbb'); }); - it('falls back to message.sessionID for legacy clients (no cookie)', async function (this: any) { + it('falls back to message.sessionID for legacy clients (no cookie)', async () => { socket = await connectWithCookie(''); await sendClientReady(socket, {sessionID: 's.cccccccccccccccc'}); assert.equal(sessioninfos[socket.id].auth.sessionID, 's.cccccccccccccccc'); }); - it('prefers the cookie over the legacy message field', async function (this: any) { + it('prefers the cookie over the legacy message field', async () => { socket = await connectWithCookie('sessionID=s.dddddddddddddddd'); await sendClientReady(socket, {sessionID: 's.eeeeeeeeeeeeeeee'}); assert.equal(sessioninfos[socket.id].auth.sessionID, 's.dddddddddddddddd'); }); - it('records null when no sessionID is provided', async function (this: any) { + it('records null when no sessionID is provided', async () => { socket = await connectWithCookie(''); await sendClientReady(socket, {}); assert.equal(sessioninfos[socket.id].auth.sessionID, null); }); - it('treats a malformed (undecodable) cookie as absent rather than aborting', async function (this: any) { + it('treats a malformed (undecodable) cookie as absent rather than aborting', async () => { // %ZZ is not a valid percent-encoded sequence; decodeURIComponent() throws // URIError. Without the guard this would tear down CLIENT_READY and let // any client log-spam the server (Qodo bug on #7755). The handshake must diff --git a/src/tests/backend/specs/settingsModalHeading.ts b/src/tests/backend/specs/settingsModalHeading.ts index 509aa434e..aca1c8df3 100644 --- a/src/tests/backend/specs/settingsModalHeading.ts +++ b/src/tests/backend/specs/settingsModalHeading.ts @@ -11,18 +11,17 @@ const common = require('../common'); // `data-l10n-id="pad.settings.padSettings"` ("Pad-wide Settings") for every // user, even though no pad-wide controls were rendered in that mode. The fix // removes the conditional and always uses `pad.settings.title` ("Settings"). -describe(__filename, function (this: any) { - this.timeout(30000); +describe(__filename, () => { let agent: any; const backup: MapArrayType = {}; - before(async function (this: any) { agent = await common.init(); }); + before(async () => { agent = await common.init(); }); - beforeEach(async function (this: any) { + beforeEach(async () => { backup.enablePadWideSettings = settings.enablePadWideSettings; }); - afterEach(async function (this: any) { + afterEach(async () => { settings.enablePadWideSettings = backup.enablePadWideSettings; }); @@ -31,13 +30,13 @@ describe(__filename, function (this: any) { return m ? m[1] : null; }; - it('uses pad.settings.title with the feature enabled', async function (this: any) { + it('uses pad.settings.title with the feature enabled', async () => { settings.enablePadWideSettings = true; const res = await agent.get('/p/headingTest').expect(200); assert.equal(titleH1(res.text), 'pad.settings.title'); }); - it('uses pad.settings.title with the feature disabled (no misleading "Pad-wide" label)', async function (this: any) { + it('uses pad.settings.title with the feature disabled (no misleading "Pad-wide" label)', async function () { settings.enablePadWideSettings = false; const res = await agent.get('/p/headingTest').expect(200); assert.equal(titleH1(res.text), 'pad.settings.title'); diff --git a/src/tests/backend/specs/updater-integration.ts b/src/tests/backend/specs/updater-integration.ts index 4923e69de..d3cab1ee3 100644 --- a/src/tests/backend/specs/updater-integration.ts +++ b/src/tests/backend/specs/updater-integration.ts @@ -63,9 +63,7 @@ const stubSpawn = (pnpmExits: Record) => return spawn(cmd, args, opts); }; -describe(__filename, function (this: any) { - this.timeout(30_000); - +describe(__filename, () => { it('happy path: executes against tmp repo, lands on pending-verification, exits 75', async () => { const {dir, v1Sha} = await buildTmpRepo(); try { diff --git a/src/tests/backend/specs/updater-scheduler-integration.ts b/src/tests/backend/specs/updater-scheduler-integration.ts index 0481dea46..1316ecab8 100644 --- a/src/tests/backend/specs/updater-scheduler-integration.ts +++ b/src/tests/backend/specs/updater-scheduler-integration.ts @@ -8,9 +8,7 @@ import {EMPTY_STATE} from '../../../node/updater/types.js'; import {loadState, saveState} from '../../../node/updater/state.js'; import {createSchedulerRunner, decideSchedule} from '../../../node/updater/Scheduler.js'; -describe('Tier 3 scheduler — boot rehydrate + grace fire', function (this: any) { - this.timeout(15000); - +describe('Tier 3 scheduler — boot rehydrate + grace fire', () => { let root: string; let stateFile: string;