210 lines
10 KiB
JavaScript
210 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* ObsiGate — Settings section order + profile avatar (#113).
|
|
*
|
|
* Static checks (no jsdom — runs in the "Frontend unit tests" CI step):
|
|
* - Order: the #config-nav TOC and the page sections must list the same
|
|
* anchors in the same order, with #cfg-profile first and #cfg-about last
|
|
* (regression guard for the reorganization).
|
|
* - Avatar UI: the Profil section must carry the picker (preview, file
|
|
* input, choose/remove buttons), config.js the resize/PATCH flow, auth.js
|
|
* the sidebar image rendering, style.css the preview/img rules, and the
|
|
* i18n keys must exist in FR and EN.
|
|
* - Backend: backend/auth/router.py must validate the avatar data-URL.
|
|
*
|
|
* Usage: node tests/frontend/settings-order-avatar.test.mjs
|
|
*/
|
|
|
|
import { strict as assert } from "node:assert";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = path.join(__dirname, "..", "..");
|
|
|
|
const indexHtml = readFileSync(path.join(ROOT, "frontend", "index.html"), "utf8");
|
|
const configJs = readFileSync(path.join(ROOT, "frontend", "js", "config.js"), "utf8");
|
|
const authJs = readFileSync(path.join(ROOT, "frontend", "js", "auth.js"), "utf8");
|
|
const css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8");
|
|
const routerPy = readFileSync(path.join(ROOT, "backend", "auth", "router.py"), "utf8");
|
|
const fr = JSON.parse(readFileSync(path.join(ROOT, "frontend", "locales", "fr.json"), "utf8"));
|
|
const en = JSON.parse(readFileSync(path.join(ROOT, "frontend", "locales", "en.json"), "utf8"));
|
|
|
|
function test(label, fn) {
|
|
try {
|
|
fn();
|
|
console.log(" ✓ " + label);
|
|
} catch (err) {
|
|
console.error(" ✗ " + label + "\n " + err.message);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
function tocHrefs() {
|
|
const nav = indexHtml.match(/<nav class="help-nav" id="config-nav">([\s\S]*?)<\/nav>/);
|
|
assert.ok(nav, "#config-nav not found");
|
|
return [...nav[1].matchAll(/href="#([^"]+)"/g)].map((m) => m[1]);
|
|
}
|
|
|
|
// ── #113a: order — TOC and page in sync ─────────────────────────────────────
|
|
test("index.html — #cfg-profile is the first TOC entry, #cfg-about the last", () => {
|
|
const hrefs = tocHrefs();
|
|
assert.ok(hrefs.length >= 10, "TOC must list the settings sections");
|
|
assert.equal(hrefs[0], "cfg-profile", "Profile must come first");
|
|
assert.equal(hrefs[hrefs.length - 1], "cfg-about", "About must come last");
|
|
});
|
|
|
|
test("index.html — page sections appear in the exact TOC order", () => {
|
|
const hrefs = tocHrefs();
|
|
const start = indexHtml.indexOf('id="config-scroll"');
|
|
assert.ok(start > 0, "#config-scroll not found");
|
|
const region = indexHtml.slice(start);
|
|
let prev = -1;
|
|
for (const id of hrefs) {
|
|
const pos = region.indexOf(`id="${id}"`);
|
|
assert.ok(pos > 0, `section #${id} missing from the page`);
|
|
assert.ok(pos > prev, `section #${id} is out of TOC order`);
|
|
prev = pos;
|
|
}
|
|
});
|
|
|
|
test("index.html — TOC has no dead anchors and page has no orphan cfg sections", () => {
|
|
const hrefs = tocHrefs();
|
|
const missing = hrefs.filter((id) => !indexHtml.includes(`id="${id}"`));
|
|
assert.deepEqual(missing, [], `dead TOC anchors: ${missing.join(", ")}`);
|
|
// Every cfg-* id declared on a <section> must be reachable from the TOC
|
|
// (form-control ids like #cfg-debounce are not sections).
|
|
const start = indexHtml.indexOf('id="config-scroll"');
|
|
const region = indexHtml.slice(start, indexHtml.indexOf("Graph View Modal"));
|
|
const pageIds = [...region.matchAll(/<section[^>]*id="(cfg-[^"]+)"/g)].map((m) => m[1]);
|
|
const orphans = [...new Set(pageIds)].filter((id) => !hrefs.includes(id));
|
|
assert.deepEqual(orphans, [], `page sections missing from the TOC: ${orphans.join(", ")}`);
|
|
});
|
|
|
|
// ── #113b: avatar UI in the Profil section ──────────────────────────────────
|
|
test("index.html — Profil section carries the avatar picker", () => {
|
|
const section = indexHtml.match(
|
|
/<section[^>]*id="cfg-profile"[\s\S]*?<\/section>/,
|
|
);
|
|
assert.ok(section, "#cfg-profile section not found");
|
|
const html = section[0];
|
|
for (const id of [
|
|
"profile-avatar-field",
|
|
"profile-avatar-preview",
|
|
"profile-avatar-img",
|
|
"profile-avatar-initials",
|
|
"profile-avatar-input",
|
|
"profile-avatar-choose",
|
|
"profile-avatar-remove",
|
|
"profile-avatar-error",
|
|
]) {
|
|
assert.match(html, new RegExp(`id="${id}"`), `#${id} missing from #cfg-profile`);
|
|
}
|
|
assert.match(html, /accept="image\/png,image\/jpeg,image\/webp"/,
|
|
"the file input must restrict accepted image types");
|
|
});
|
|
|
|
test("config.js — avatar flow: type/size guards, resize, PATCH, sidebar refresh", () => {
|
|
assert.match(configJs, /_AVATAR_TYPES/, "accepted MIME types must be declared");
|
|
assert.match(configJs, /image\/png[\s\S]{0,80}image\/jpeg[\s\S]{0,80}image\/webp/,
|
|
"PNG/JPEG/WebP must be the accepted types");
|
|
assert.match(configJs, /_AVATAR_MAX_FILE_BYTES/, "raw file size guard missing");
|
|
assert.match(configJs, /_resizeAvatarFile/, "client-side resize helper missing");
|
|
assert.match(configJs, /toDataURL\(["']image\/jpeg["']/, "resize must export a JPEG data-URL");
|
|
assert.match(configJs, /method:\s*["']PATCH["'][\s\S]{0,200}avatar/,
|
|
"avatar must be persisted via PATCH /api/auth/me");
|
|
assert.match(configJs, /avatar:\s*value|JSON\.stringify\(\{\s*avatar/,
|
|
"the PATCH body must carry the avatar field");
|
|
assert.match(configJs, /renderUserSection\(\)/,
|
|
"the sidebar must be re-rendered after an avatar change");
|
|
assert.match(configJs, /config\.avatar_invalid_type/, "invalid-type error missing");
|
|
assert.match(configJs, /config\.avatar_too_large/, "too-large error missing");
|
|
});
|
|
|
|
test("auth.js — sidebar circle renders the avatar image, initials as fallback", () => {
|
|
assert.match(authJs, /user\.avatar/, "renderUserSection must read user.avatar");
|
|
assert.match(authJs, /sidebar-user-avatar-img/, "the <img> must carry its CSS class");
|
|
assert.match(authJs, /userInitials\(name\)/, "initials fallback removed");
|
|
assert.match(authJs, /updateCachedUser/, "cached-user merge helper missing");
|
|
assert.match(authJs, /isAuthEnabled\(/, "public auth-status helper missing");
|
|
});
|
|
|
|
test("style.css — preview circle and sidebar image rules exist", () => {
|
|
assert.match(css, /\.profile-avatar-preview\s*\{/, "no .profile-avatar-preview rule");
|
|
assert.match(css, /\.profile-avatar-overlay\s*\{/, "no hover overlay rule");
|
|
assert.match(css, /\.sidebar-user-avatar-img\s*\{/, "no .sidebar-user-avatar-img rule");
|
|
});
|
|
|
|
// ── #117: preset avatar gallery ─────────────────────────────────────────────
|
|
test("index.html — #cfg-profile exposes 12 bundled preset avatars", () => {
|
|
const section = indexHtml.match(/<section[^>]*id="cfg-profile"[\s\S]*?<\/section>/);
|
|
assert.ok(section, "#cfg-profile section not found");
|
|
const html = section[0];
|
|
assert.match(html, /id="profile-avatar-presets"/, "preset gallery container missing");
|
|
const buttons = [...html.matchAll(/class="profile-avatar-preset"[^>]*data-avatar="([^"]+)"/g)];
|
|
assert.equal(buttons.length, 12, `expected 12 preset avatars, found ${buttons.length}`);
|
|
const names = buttons.map((m) => m[1]);
|
|
assert.equal(new Set(names).size, 12, "preset avatar names must be unique");
|
|
for (const name of names) {
|
|
const rel = path.join(ROOT, "frontend", "icons", "avatar", name);
|
|
assert.ok(existsSync(rel), `missing avatar file: ${rel}`);
|
|
assert.ok(html.includes(`/static/icons/avatar/${name}`),
|
|
`#${name} must be served from /static/icons/avatar/`);
|
|
}
|
|
});
|
|
|
|
test("config.js — preset avatars reuse the upload pipeline", () => {
|
|
assert.match(configJs, /profile-avatar-presets/, "preset container lookup missing");
|
|
assert.match(configJs, /\.profile-avatar-preset/, "preset button selector missing");
|
|
assert.match(configJs, /\/static\/icons\/avatar\//, "preset image base URL missing");
|
|
assert.match(configJs, /_resizeAvatarFile\(blob\)/, "preset must go through the resize pipeline");
|
|
assert.match(configJs, /_patchProfileAvatar\(dataUrl\)/, "preset must be persisted via PATCH");
|
|
assert.match(configJs, /obsigate-avatar-preset/, "active preset marker missing");
|
|
assert.match(configJs, /markActivePreset/, "active preset highlight missing");
|
|
});
|
|
|
|
test("style.css — preset avatar rules exist", () => {
|
|
assert.match(css, /\.profile-avatar-presets\s*\{/, "no .profile-avatar-presets rule");
|
|
assert.match(css, /\.profile-avatar-preset\s*\{/, "no .profile-avatar-preset rule");
|
|
assert.match(css, /\.profile-avatar-preset\.active\s*\{/, "no active preset rule");
|
|
});
|
|
|
|
// ── #113c: i18n + backend ───────────────────────────────────────────────────
|
|
test("i18n — avatar keys exist in FR and EN", () => {
|
|
const keys = [
|
|
"config.avatar_label",
|
|
"config.avatar_hint",
|
|
"config.avatar_presets_label",
|
|
"config.avatar_choose",
|
|
"config.avatar_remove",
|
|
"config.avatar_updated",
|
|
"config.avatar_removed",
|
|
"config.avatar_too_large",
|
|
"config.avatar_invalid_type",
|
|
"config.avatar_upload_failed",
|
|
];
|
|
for (const key of keys) {
|
|
assert.ok(fr[key], `fr.json missing ${key}`);
|
|
assert.ok(en[key], `en.json missing ${key}`);
|
|
assert.notEqual(fr[key], key, `fr.json ${key} is untranslated`);
|
|
assert.notEqual(en[key], key, `en.json ${key} is untranslated`);
|
|
}
|
|
});
|
|
|
|
test("backend — /api/auth/me validates the avatar data-URL", () => {
|
|
assert.match(routerPy, /_AVATAR_DATA_URL_RE/, "data-URL regex missing");
|
|
assert.match(routerPy, /def _validate_avatar/, "validation helper missing");
|
|
assert.match(routerPy, /image\/\(\?:png\|jpeg\|webp\)/, "only PNG/JPEG/WebP allowed (no SVG)");
|
|
assert.match(routerPy, /_AVATAR_MAX_CHARS/, "size cap missing");
|
|
assert.match(routerPy, /"avatar": current_user\.get\("avatar"\)/, "GET /me must return the avatar");
|
|
assert.match(routerPy, /avatar: str \| None = None/, "UpdateMeRequest must expose avatar");
|
|
assert.match(routerPy, /updates\["avatar"\] = _validate_avatar/, "PATCH /me must validate before store");
|
|
});
|
|
|
|
if (process.exitCode) {
|
|
console.error("\nSettings order & avatar tests FAILED");
|
|
} else {
|
|
console.log("\nAll settings order & avatar tests passed.");
|
|
}
|