mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-21 09:08:43 +00:00
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:
parent
982067a3c1
commit
95f753c805
14 changed files with 231 additions and 363 deletions
|
|
@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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'});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue