test(js): add node:test unit tests for countdown and pleroma adapter
This commit is contained in:
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user