test: migrate mocha this.timeout/this.skip to vitest API

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.
This commit is contained in:
SamTV12345 2026-05-16 23:26:38 +02:00
parent 982067a3c1
commit 95f753c805
14 changed files with 231 additions and 363 deletions

View file

@ -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 = '<no test seen yet>';
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();
}
},
};

View file

@ -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.

View file

@ -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).

View file

@ -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:<id>', async function (this: any) {
it('zeroes the display identity on globalAuthor:<id>', 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');

View file

@ -18,11 +18,10 @@ const callApi = async (point: string, query: Record<string, string> = {}) => {
.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'});

View file

@ -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/<ver>/pad/checkToken still resolves', async function (this: any) {
it('REST-style /rest/<ver>/pad/checkToken still resolves', async () => {
const r = await agent.get(`/rest/${apiVersion}/pad/checkToken`);
assertResolved('GET /rest pad/checkToken', r.body);
});

View file

@ -21,20 +21,19 @@ const callApi = async (point: string, query: Record<string, string> = {}) => {
.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});

View file

@ -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});

View file

@ -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<any> = {};
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(
'<p>x</p><img src="data:image/png;base64,iVBORw0KGgo=">');
assert.match(out, /<img[^>]+src="data:image\/png/);
});
it('keeps relative URLs', function (this: any) {
it('keeps relative URLs', () => {
const out = stripRemoteImages('<img src="/foo/bar.png">');
assert.match(out, /<img[^>]+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(
'<p>before<img src="https://evil.example/x.png" alt="cat">after</p>');
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('<img src="//evil.example/x.png">');
assert.doesNotMatch(out, /evil\.example/);
});
it('passes non-image markup through unchanged', function (this: any) {
it('passes non-image markup through unchanged', () => {
const html = '<h1>hi</h1><p>body <a href="/x">link</a></p>';
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 = `<!doctype html><html><head><style>.x{color:red}</style></head><body>
hello<br>world
</body></html>`;
assert.strictEqual(extractBody(html), 'hello<br>world');
});
it('passes a body-less fragment through unchanged', function (this: any) {
it('passes a body-less fragment through unchanged', () => {
const html = '<p>just a fragment</p>';
assert.strictEqual(extractBody(html), html);
});
it('drops <head><style> contents', function (this: any) {
it('drops <head><style> contents', () => {
const html = '<html><head><style>.x{}</style></head><body><p>kept</p></body></html>';
const out = extractBody(html);
assert.doesNotMatch(out, /style/);
@ -198,18 +193,18 @@ hello<br>world
});
});
describe('wrapLooseLines', function (this: any) {
describe('wrapLooseLines', () => {
const {wrapLooseLines} = require('../../../node/utils/ExportSanitizeHtml');
it('wraps loose text in <p>', function (this: any) {
it('wraps loose text in <p>', () => {
assert.strictEqual(wrapLooseLines('Hello'), '<p>Hello</p>');
});
it('keeps single <br> as soft break inside one paragraph', function (this: any) {
it('keeps single <br> as soft break inside one paragraph', () => {
assert.strictEqual(wrapLooseLines('A<br>B'), '<p>A<br>B</p>');
});
it('splits paragraphs on consecutive <br>', function (this: any) {
it('splits paragraphs on consecutive <br>', () => {
// Two <br>s between content: one paragraph break + one empty
// <p></p> marker so the blank pad line survives a DOCX round-trip
// through html-to-docx and mammoth.
@ -217,22 +212,22 @@ hello<br>world
'<p>A</p><p></p><p>B</p>');
});
it('emits more empty <p> markers for longer <br> runs', function (this: any) {
it('emits more empty <p> markers for longer <br> runs', () => {
// Three <br>s = 2 blank pad lines between content.
assert.strictEqual(wrapLooseLines('A<br><br><br>B'),
'<p>A</p><p></p><p></p><p>B</p>');
});
it('drops trailing <br>', function (this: any) {
it('drops trailing <br>', () => {
assert.strictEqual(wrapLooseLines('Foo<br>'), '<p>Foo</p>');
});
it('leaves block elements alone', function (this: any) {
it('leaves block elements alone', () => {
const html = '<ul><li>x</li></ul>';
assert.strictEqual(wrapLooseLines(html), html);
});
it('handles realistic etherpad pad HTML', function (this: any) {
it('handles realistic etherpad pad HTML', () => {
const out = wrapLooseLines(
'Welcome!<br><br>Body text.<br>More text.<br>');
// <br><br> -> blank-line marker between Welcome and Body text;
@ -243,22 +238,22 @@ hello<br>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(
"<h1 style='text-align:right'>Hi</h1><br><h1 style='text-align:right'></h1><br>x");
assert.strictEqual(out, "<h1 style='text-align:right'>Hi</h1><br><br>x");
});
it('drops empty code blocks', function (this: any) {
it('drops empty code blocks', () => {
assert.strictEqual(dropEmptyBlocks('<code></code>x'), 'x');
assert.strictEqual(
dropEmptyBlocks('<code style="x"> \n\t </code>x'), 'x');
});
it('iterates so nested empties are dropped too', function (this: any) {
it('iterates so nested empties are dropped too', () => {
// <code></code> inside a <div> -> div becomes empty -> div drops too.
// (<p></p> is preserved on purpose; wrapLooseLines uses it as a
// blank-line marker for DOCX round-trip fidelity.)
@ -266,28 +261,28 @@ hello<br>world
assert.strictEqual(out, 'after');
});
it('does not drop empty <p></p> (blank-line marker)', function (this: any) {
it('does not drop empty <p></p> (blank-line marker)', () => {
const out = dropEmptyBlocks('<p>x</p><p></p><p>y</p>');
assert.strictEqual(out, '<p>x</p><p></p><p>y</p>');
});
it('keeps non-empty blocks unchanged', function (this: any) {
it('keeps non-empty blocks unchanged', () => {
const html = '<h1>Hi</h1><p>body</p><code>x = 1</code>';
assert.strictEqual(dropEmptyBlocks(html), html);
});
});
describe('collapseRedundantBrAfterBlocks', function (this: any) {
describe('collapseRedundantBrAfterBlocks', () => {
const {collapseRedundantBrAfterBlocks} =
require('../../../node/utils/ExportSanitizeHtml');
it('drops <br> immediately after a closing <p>', function (this: any) {
it('drops <br> immediately after a closing <p>', () => {
assert.strictEqual(
collapseRedundantBrAfterBlocks('<p>x</p><br><p>y</p>'),
'<p>x</p><p>y</p>');
});
it('drops <br> after closing heading and code tags', function (this: any) {
it('drops <br> after closing heading and code tags', () => {
for (const tag of ['h1', 'h2', 'h3', 'code', 'pre', 'div', 'blockquote']) {
assert.strictEqual(
collapseRedundantBrAfterBlocks(`<${tag}>x</${tag}><br>`),
@ -296,18 +291,18 @@ hello<br>world
}
});
it('keeps a standalone <br> between text', function (this: any) {
it('keeps a standalone <br> between text', () => {
const html = 'Hello<br>World';
assert.strictEqual(collapseRedundantBrAfterBlocks(html), html);
});
it('handles whitespace between </tag> and <br>', function (this: any) {
it('handles whitespace between </tag> and <br>', () => {
assert.strictEqual(
collapseRedundantBrAfterBlocks('<p>x</p> \n<br>after'),
'<p>x</p>after');
});
it('drops only one <br>, leaving any subsequent ones', function (this: any) {
it('drops only one <br>, leaving any subsequent ones', () => {
// <br><br> after a closing block represents (one redundant + one
// intentional blank-line break). After collapsing the first, the
// second remains.
@ -317,34 +312,34 @@ hello<br>world
});
});
describe('separateAdjacentHeadingBlocks', function (this: any) {
describe('separateAdjacentHeadingBlocks', () => {
const {separateAdjacentHeadingBlocks} =
require('../../../node/utils/ExportSanitizeHtml');
it('inserts <br> between adjacent <h1> and <h2>', function (this: any) {
it('inserts <br> between adjacent <h1> and <h2>', () => {
assert.strictEqual(
separateAdjacentHeadingBlocks('<h1>A</h1><h2>B</h2>'),
'<h1>A</h1><br><h2>B</h2>');
});
it('inserts <br> between adjacent <code> blocks', function (this: any) {
it('inserts <br> between adjacent <code> blocks', () => {
assert.strictEqual(
separateAdjacentHeadingBlocks('<code>A</code><code>B</code>'),
'<code>A</code><br><code>B</code>');
});
it('inserts <br> after a heading before a <p>', function (this: any) {
it('inserts <br> after a heading before a <p>', () => {
assert.strictEqual(
separateAdjacentHeadingBlocks('<h1>A</h1><p>B</p>'),
'<h1>A</h1><br><p>B</p>');
});
it('does not change adjacent <p> elements', function (this: any) {
it('does not change adjacent <p> elements', () => {
const html = '<p>A</p><p>B</p>';
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<br>world
});
});
describe('applyMonospaceToCode', function (this: any) {
describe('applyMonospaceToCode', () => {
const {applyMonospaceToCode} =
require('../../../node/utils/ExportSanitizeHtml');
it('emits a Courier span for inline <code>', function (this: any) {
it('emits a Courier span for inline <code>', () => {
// The <code> tag itself is dropped (html-to-docx ignores it and
// also breaks <a> children when they're nested inside it). The
// text becomes a Courier-styled inline span.
@ -366,7 +361,7 @@ hello<br>world
`<span style="font-family:'Courier New', monospace">x = 1</span>`);
});
it('forwards block-level style to a wrapping <p>', function (this: any) {
it('forwards block-level style to a wrapping <p>', () => {
// ep_headings2 + ep_align emit `<code style='text-align:right'>`
// 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<br>world
assert.match(out, /font-family:'Courier New'/);
});
it('emits <p> wrap for <pre> regardless of style', function (this: any) {
it('emits <p> wrap for <pre> regardless of style', () => {
// <pre> is always block-level.
const out = applyMonospaceToCode('<pre>preformatted</pre>');
assert.match(out, /^<p>/);
@ -384,7 +379,7 @@ hello<br>world
assert.match(out, /font-family:'Courier New'/);
});
it('handles inline <tt>, <kbd>, <samp> as bare spans', function (this: any) {
it('handles inline <tt>, <kbd>, <samp> as bare spans', () => {
for (const tag of ['tt', 'kbd', 'samp']) {
const out = applyMonospaceToCode(`<${tag}>x</${tag}>`);
assert.strictEqual(out,
@ -393,12 +388,12 @@ hello<br>world
}
});
it('does not touch unrelated tags', function (this: any) {
it('does not touch unrelated tags', () => {
const html = '<p>plain</p><strong>bold</strong>';
assert.strictEqual(applyMonospaceToCode(html), html);
});
it('does not wrap <a> elements in the Courier span', function (this: any) {
it('does not wrap <a> elements in the Courier span', () => {
// Regression: html-to-docx drops <a href> content when nested
// inside a styled span OR inside <code>. We split on anchors
// and leave them unstyled.
@ -415,9 +410,7 @@ hello<br>world
assert.doesNotMatch(out, /<\/code>/);
});
it('preserves <a> through html-to-docx round-trip', async function (this: any) {
try { require.resolve('html-to-docx'); }
catch { this.skip(); return; }
it.skipIf(!hasHtmlToDocx)('preserves <a> 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<br>world
});
});
describe('htmlToPdfBuffer', function (this: any) {
describe.skipIf(!hasPdfkitDeps)('htmlToPdfBuffer', () => {
let htmlToPdfBuffer: (html: string) => Promise<Buffer>;
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('<p>hello world</p>');
assert.ok(Buffer.isBuffer(buf), 'must return Buffer');
assert.ok(buf.length > 100, `buffer suspiciously small: ${buf.length} bytes`);
@ -477,7 +463,7 @@ hello<br>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(`
<h1>Title</h1>
<p>Body paragraph here.</p>
@ -494,7 +480,7 @@ hello<br>world
assert.ok(visible.includes('beta'), `expected "beta" in: ${visible}`);
});
it('emits link annotations for <a href>', async function (this: any) {
it('emits link annotations for <a href>', async () => {
const raw = await renderText('<p><a href="https://etherpad.org">site</a></p>');
const visible = decodeVisibleText(raw);
assert.ok(visible.includes('site'), `expected "site" in: ${visible}`);
@ -505,20 +491,20 @@ hello<br>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(`<img src="data:image/png;base64,${tinyPng}">`);
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(
'<custom-tag><p>still works</p></custom-tag>');
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(`
<html><head>
<title>SECRET_TITLE</title>
@ -535,7 +521,7 @@ hello<br>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<br>world
`right-aligned text should sit at a different x than left-aligned (left=${leftX} right=${rightX})`);
});
it('uses Courier font inside <code>', async function (this: any) {
it('uses Courier font inside <code>', async () => {
const raw = await renderText('<p>before <code>x = 1</code> after</p>');
// 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<br>world
assert.match(raw, /Courier/);
});
it('uses Courier font inside <pre>', async function (this: any) {
it('uses Courier font inside <pre>', async () => {
const raw = await renderText('<pre>preformatted text</pre>');
assert.match(raw, /Courier/);
});
it('honors text-align on <code> (ep_headings2 code lines)', async function (this: any) {
it('honors text-align on <code> (ep_headings2 code lines)', async () => {
const leftRaw = await renderText('<code>x = 1</code>');
const rightRaw = await renderText("<code style='text-align:right'>x = 1</code>");
const leftX = (leftRaw.match(/1 0 0 1 (\d+(?:\.\d+)?)/) || [])[1];
@ -573,7 +559,7 @@ hello<br>world
`right-aligned <code> should sit at a different x than left-aligned (left=${leftX} right=${rightX})`);
});
it('honors text-align on <pre>', async function (this: any) {
it('honors text-align on <pre>', async () => {
const leftRaw = await renderText('<pre>x = 1</pre>');
const rightRaw = await renderText("<pre style='text-align:right'>x = 1</pre>");
const leftX = (leftRaw.match(/1 0 0 1 (\d+(?:\.\d+)?)/) || [])[1];

View file

@ -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<any> = {};
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<string>;
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, /<img[^>]+src="\/\//);
});
it('preserves paragraph alignment from <w:jc>', async function (this: any) {
it.skipIf(!hasHtmlToDocx)('preserves paragraph alignment from <w:jc>', async () => {
// Round through html-to-docx so the input docx has <w:jc> 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(
'<h1 style="text-align:right">Right heading</h1>' +
@ -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 <h1>/<h2> 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 <h1>/<h2> 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, '<html><body><h1>A</h1><h2>B</h2></body></html>');
const pad = await padManager.getPad(padId);
@ -346,7 +348,8 @@ describe(__filename, function (this: any) {
// (encoded by ep_align as `<br><p style></p><br><p style></p><br>`)
// + 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 =
'<html><body>' +
@ -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 <h1>/<h2>/<code> that aren't in
// contentcollector's default block-element set. Without the
// separateAdjacentHeadingBlocks fix, mammoth's <h1>A</h1><h2>B</h2>
@ -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.

View file

@ -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

View file

@ -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<any> = {};
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');

View file

@ -63,9 +63,7 @@ const stubSpawn = (pnpmExits: Record<string, number>) =>
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 {

View file

@ -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;