test(js): add node:test unit tests for countdown and pleroma adapter

This commit is contained in:
2026-07-26 20:22:12 +04:00
parent c02180fb4d
commit 54a4368ea8
3 changed files with 546 additions and 0 deletions
+190
View File
@@ -0,0 +1,190 @@
/**
* Tests unitaires pour js/countdown.js (classe CountdownTimer).
*
* Le script est chargé dans un contexte vm avec un DOM simulé :
* la date courante est figée (FakeDate) et setInterval est factice,
* ce qui rend les calculs de temps restant déterministes.
*/
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const COUNTDOWN_PATH = path.join(__dirname, '..', '..', 'js', 'countdown.js');
const COUNTDOWN_SOURCE = fs.readFileSync(COUNTDOWN_PATH, 'utf8');
// Date courante figée pour tous les tests
const FIXED_NOW = new Date('2030-01-01T00:00:00Z').getTime();
function makeElements() {
return {
'countdown-days': { textContent: '' },
'countdown-hours': { textContent: '' },
'countdown-minutes': { textContent: '' },
'countdown-seconds': { textContent: '' }
};
}
/**
* Charge countdown.js dans un contexte vm isolé et retourne la classe.
*
* @param {object|null} options.elements Éléments DOM simulés (null = absents)
* @param {number} options.now Timestamp courant simulé
*/
function loadCountdown({ elements = null, now = FIXED_NOW } = {}) {
class FakeDate extends Date {
constructor(...args) {
if (args.length === 0) {
super(now);
} else {
super(...args);
}
}
static now() {
return now;
}
}
const windowMock = {
addEventListener() {},
location: { href: '' }
};
// Timers factices : le timer ne tourne jamais tout seul,
// on enregistre simplement les appels pour vérification.
const timers = { intervals: [], cleared: [] };
let nextIntervalId = 1;
const sandbox = {
document: {
getElementById: (id) => (elements ? elements[id] || null : null),
querySelectorAll: () => [],
addEventListener() {}
},
window: windowMock,
Date: FakeDate,
setInterval: (fn, delay) => {
const id = nextIntervalId++;
timers.intervals.push({ id, fn, delay });
return id;
},
clearInterval: (id) => {
timers.cleared.push(id);
},
setTimeout: () => 0,
console
};
vm.createContext(sandbox);
const CountdownTimer = vm.runInContext(COUNTDOWN_SOURCE + '\nCountdownTimer;', sandbox);
return { CountdownTimer, window: windowMock, timers };
}
describe('CountdownTimer', () => {
test('formatNumber complète les nombres sur deux chiffres', () => {
const { CountdownTimer } = loadCountdown();
assert.strictEqual(CountdownTimer.prototype.formatNumber(0), '00');
assert.strictEqual(CountdownTimer.prototype.formatNumber(5), '05');
assert.strictEqual(CountdownTimer.prototype.formatNumber(42), '42');
assert.strictEqual(CountdownTimer.prototype.formatNumber(123), '123');
});
test('calcule correctement le temps restant (jours/heures/minutes/secondes)', () => {
const distance =
2 * 24 * 60 * 60 * 1000 + // 2 jours
3 * 60 * 60 * 1000 + // 3 heures
4 * 60 * 1000 + // 4 minutes
5 * 1000; // 5 secondes
const elements = makeElements();
const { CountdownTimer } = loadCountdown({ elements });
const timer = new CountdownTimer(FIXED_NOW + distance);
try {
assert.strictEqual(elements['countdown-days'].textContent, '02');
assert.strictEqual(elements['countdown-hours'].textContent, '03');
assert.strictEqual(elements['countdown-minutes'].textContent, '04');
assert.strictEqual(elements['countdown-seconds'].textContent, '05');
} finally {
timer.stop();
}
});
test('reporte les heures au-delà de 24h dans les jours', () => {
const distance = 36 * 60 * 60 * 1000; // 36 heures = 1 jour + 12 heures
const elements = makeElements();
const { CountdownTimer } = loadCountdown({ elements });
const timer = new CountdownTimer(FIXED_NOW + distance);
try {
assert.strictEqual(elements['countdown-days'].textContent, '01');
assert.strictEqual(elements['countdown-hours'].textContent, '12');
assert.strictEqual(elements['countdown-minutes'].textContent, '00');
assert.strictEqual(elements['countdown-seconds'].textContent, '00');
} finally {
timer.stop();
}
});
test('une date passée arrête le compte à rebours et redirige vers /', () => {
const elements = makeElements();
const { CountdownTimer, window } = loadCountdown({ elements });
const timer = new CountdownTimer(FIXED_NOW - 1000);
try {
// onComplete() redirige vers la page principale
assert.strictEqual(window.location.href, '/');
// Les éléments ne sont pas mis à jour quand le compte est terminé
assert.strictEqual(elements['countdown-days'].textContent, '');
assert.strictEqual(elements['countdown-hours'].textContent, '');
assert.strictEqual(elements['countdown-minutes'].textContent, '');
assert.strictEqual(elements['countdown-seconds'].textContent, '');
} finally {
timer.stop();
}
});
test('une distance nulle affiche zéro partout sans rediriger', () => {
const elements = makeElements();
const { CountdownTimer, window } = loadCountdown({ elements });
const timer = new CountdownTimer(FIXED_NOW);
try {
assert.strictEqual(elements['countdown-days'].textContent, '00');
assert.strictEqual(elements['countdown-hours'].textContent, '00');
assert.strictEqual(elements['countdown-minutes'].textContent, '00');
assert.strictEqual(elements['countdown-seconds'].textContent, '00');
assert.strictEqual(window.location.href, '');
} finally {
timer.stop();
}
});
test('fonctionne sans éléments DOM présents', () => {
const { CountdownTimer } = loadCountdown({ elements: null });
const timer = new CountdownTimer(FIXED_NOW + 60 * 1000);
// Aucune erreur attendue malgré l'absence des éléments
timer.stop();
});
test('démarre un intervalle d\'une seconde et stop() le nettoie', () => {
const { CountdownTimer, timers } = loadCountdown();
const timer = new CountdownTimer(FIXED_NOW + 60 * 1000);
assert.strictEqual(timers.intervals.length, 1);
assert.strictEqual(timers.intervals[0].delay, 1000);
assert.strictEqual(timers.cleared.length, 0);
timer.stop();
assert.deepStrictEqual(timers.cleared, [timer.interval]);
});
});
+311
View File
@@ -0,0 +1,311 @@
/**
* Unit tests for js/pleroma-adapter.js.
*
* The adapter is an IIFE that wraps window.fetch. It is loaded in a vm
* context with a mocked window object, so tests can call the wrapped
* fetch and inspect how Pleroma API responses are mapped to the
* Mastodon format expected by mastodon-timeline.umd.js.
*/
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const ADAPTER_PATH = path.join(__dirname, '..', '..', 'js', 'pleroma-adapter.js');
const ADAPTER_SOURCE = fs.readFileSync(ADAPTER_PATH, 'utf8');
const TIMELINE_URL = 'https://pleroma.example/api/v1/timelines/public';
const ACCOUNT_STATUSES_URL = 'https://pleroma.example/api/v1/accounts/42/statuses';
/**
* Loads the adapter in an isolated vm context.
*
* @param {Function} handler Fake backend: receives the URL, returns a Response
* @returns {{fetch: Function, warnings: string[]}} The wrapped fetch and logged warnings
*/
function loadAdapter(handler) {
const warnings = [];
const windowMock = {
fetch: async (url) => handler(url)
};
const sandbox = {
window: windowMock,
Response,
console: {
log() {},
warn: (...args) => warnings.push(args.join(' ')),
error() {}
}
};
vm.createContext(sandbox);
vm.runInContext(ADAPTER_SOURCE, sandbox);
return { fetch: windowMock.fetch, warnings };
}
function jsonResponse(data, init = {}) {
return new Response(JSON.stringify(data), {
status: 200,
headers: { 'Content-Type': 'application/json' },
...init
});
}
describe('Pleroma adapter', () => {
test('adds default meta to timeline attachments missing meta', async () => {
const posts = [
{
id: '1',
content: 'hello',
media_attachments: [
{ id: 'a1', type: 'image', url: 'https://pleroma.example/img.png' }
]
},
{ id: '2', content: 'no media' }
];
const { fetch } = loadAdapter(() => jsonResponse(posts));
const res = await fetch(TIMELINE_URL);
const data = await res.json();
assert.strictEqual(res.status, 200);
assert.strictEqual(data.length, 2);
const attachment = data[0].media_attachments[0];
assert.deepStrictEqual(attachment.meta.original, {
width: 1280,
height: 720,
aspect: 1280 / 720
});
assert.deepStrictEqual(attachment.meta.small, {
width: 640,
height: 360,
aspect: 1280 / 720
});
// Other fields are preserved
assert.strictEqual(attachment.url, 'https://pleroma.example/img.png');
assert.strictEqual(data[0].content, 'hello');
assert.strictEqual(data[1].content, 'no media');
assert.strictEqual(data[1].media_attachments, undefined);
});
test('uses 1920x1080 for video attachments (pleroma mime_type)', async () => {
const posts = [
{
id: '9',
media_attachments: [
{ id: 'v1', pleroma: { mime_type: 'video/mp4' } }
]
}
];
const { fetch } = loadAdapter(() => jsonResponse(posts));
const res = await fetch(ACCOUNT_STATUSES_URL);
const data = await res.json();
const meta = data[0].media_attachments[0].meta;
assert.deepStrictEqual(meta.original, {
width: 1920,
height: 1080,
aspect: 1920 / 1080
});
assert.deepStrictEqual(meta.small, {
width: 960,
height: 540,
aspect: 1920 / 1080
});
});
test('uses 1200x800 for image attachments (pleroma mime_type)', async () => {
const post = {
id: '10',
media_attachments: [
{ id: 'i1', pleroma: { mime_type: 'image/jpeg' } }
]
};
const { fetch } = loadAdapter(() => jsonResponse(post));
const res = await fetch(TIMELINE_URL);
const data = await res.json();
const meta = data.media_attachments[0].meta;
assert.deepStrictEqual(meta.original, {
width: 1200,
height: 800,
aspect: 1200 / 800
});
assert.deepStrictEqual(meta.small, {
width: 600,
height: 400,
aspect: 1200 / 800
});
});
test('adapts a single (non-array) post object', async () => {
const post = {
id: 'single',
media_attachments: [{ id: 's1' }]
};
const { fetch } = loadAdapter(() => jsonResponse(post));
const res = await fetch(TIMELINE_URL);
const data = await res.json();
assert.strictEqual(Array.isArray(data), false);
assert.strictEqual(data.id, 'single');
assert.deepStrictEqual(data.media_attachments[0].meta.original, {
width: 1280,
height: 720,
aspect: 1280 / 720
});
});
test('leaves attachments with complete meta untouched', async () => {
const completeMeta = {
original: { width: 640, height: 480, aspect: 640 / 480 },
small: { width: 320, height: 240, aspect: 640 / 480 }
};
const posts = [
{ id: 'm1', media_attachments: [{ id: 'ok', meta: completeMeta }] }
];
const { fetch } = loadAdapter(() => jsonResponse(posts));
const res = await fetch(TIMELINE_URL);
const data = await res.json();
assert.deepStrictEqual(data[0].media_attachments[0].meta, completeMeta);
});
test('keeps existing partial meta.original and fills meta.small', async () => {
const posts = [
{
id: 'p1',
media_attachments: [
{
id: 'partial',
meta: { original: { width: 640, height: 480, aspect: 640 / 480 } }
}
]
}
];
const { fetch } = loadAdapter(() => jsonResponse(posts));
const res = await fetch(TIMELINE_URL);
const data = await res.json();
const meta = data[0].media_attachments[0].meta;
assert.deepStrictEqual(meta.original, { width: 640, height: 480, aspect: 640 / 480 });
// meta.small is created from the default dimensions
assert.deepStrictEqual(meta.small, {
width: 640,
height: 360,
aspect: 1280 / 720
});
});
test('adapts media attachments inside reblogs recursively', async () => {
const posts = [
{
id: 'r1',
reblog: {
id: 'r2',
media_attachments: [
{ id: 'rb', pleroma: { mime_type: 'image/png' } }
]
}
}
];
const { fetch } = loadAdapter(() => jsonResponse(posts));
const res = await fetch(TIMELINE_URL);
const data = await res.json();
const meta = data[0].reblog.media_attachments[0].meta;
assert.deepStrictEqual(meta.original, {
width: 1200,
height: 800,
aspect: 1200 / 800
});
});
test('does not mutate the original payload objects', async () => {
const attachment = { id: 'orig' };
const posts = [{ id: 'n1', media_attachments: [attachment] }];
const { fetch } = loadAdapter(() => jsonResponse(posts));
const res = await fetch(TIMELINE_URL);
await res.json();
assert.strictEqual(attachment.meta, undefined);
assert.deepStrictEqual(posts[0].media_attachments, [attachment]);
});
test('passes non-object array entries through unchanged', async () => {
const posts = [null, 'not-a-post', { id: 'real', media_attachments: [{ id: 'x' }] }];
const { fetch } = loadAdapter(() => jsonResponse(posts));
const res = await fetch(TIMELINE_URL);
const data = await res.json();
assert.strictEqual(data[0], null);
assert.strictEqual(data[1], 'not-a-post');
assert.ok(data[2].media_attachments[0].meta);
});
test('intercepts account statuses URLs only when they contain /statuses', async () => {
const posts = [{ id: 'a', media_attachments: [{ id: 'x' }] }];
const { fetch } = loadAdapter(() => jsonResponse(posts));
// /api/v1/accounts/42/statuses is adapted
const resStatuses = await fetch(ACCOUNT_STATUSES_URL);
const dataStatuses = await resStatuses.json();
assert.ok(dataStatuses[0].media_attachments[0].meta);
// /api/v1/accounts/42 (no /statuses) is left as-is
const resAccount = await fetch('https://pleroma.example/api/v1/accounts/42');
const dataAccount = await resAccount.json();
assert.strictEqual(dataAccount[0].media_attachments[0].meta, undefined);
});
test('returns non-API responses untouched (same Response object)', async () => {
let original;
const { fetch } = loadAdapter(() => {
original = jsonResponse({ ok: true });
return original;
});
const res = await fetch('https://example.com/about');
assert.strictEqual(res, original);
assert.deepStrictEqual(await res.json(), { ok: true });
});
test('falls back to the original response when JSON parsing fails', async () => {
let original;
const { fetch, warnings } = loadAdapter(() => {
original = new Response('not-json', { status: 200 });
return original;
});
const res = await fetch(TIMELINE_URL);
assert.strictEqual(res, original);
assert.strictEqual(await res.text(), 'not-json');
assert.strictEqual(warnings.length, 1);
});
test('preserves response status on adapted responses', async () => {
const posts = [{ id: 's', media_attachments: [] }];
const { fetch } = loadAdapter(() => jsonResponse(posts, { status: 201 }));
const res = await fetch(TIMELINE_URL);
assert.strictEqual(res.status, 201);
assert.deepStrictEqual(await res.json(), posts);
});
});
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env node
/**
* Lanceur des tests unitaires JS.
*
* Utilise le runner natif de Node (node:test), aucune dépendance npm.
* Usage : node tests/js/run.js
*/
'use strict';
const { run } = require('node:test');
const { spec } = require('node:test/reporters');
const fs = require('node:fs');
const path = require('node:path');
const testDir = __dirname;
const files = fs
.readdirSync(testDir)
.filter((name) => name.endsWith('-test.js'))
.sort()
.map((name) => path.join(testDir, name));
if (files.length === 0) {
console.error('Aucun fichier *-test.js trouvé dans ' + testDir);
process.exit(1);
}
const stream = run({ files });
let failures = 0;
stream.on('test:fail', () => {
failures += 1;
});
stream.on('error', (err) => {
console.error(err);
process.exitCode = 1;
});
stream.compose(spec).pipe(process.stdout);
stream.on('end', () => {
if (failures > 0) {
process.exitCode = 1;
}
});