Files
anonymous_github/test/dashboard-ui.test.js
T
Thomas DurieuxandClaude Opus 5.5 579ce970c7 feat(ui): redesign the viewer, landing, author and admin screens
Viewer: full-height app bar with a Help menu, search + ZIP sidebar, one
toolbar for README and code, reading mode with a scroll-tracking outline,
docked find for Markdown and code, and redaction slots (a blank stone slot
holding the term number) in prose, headings, code, the tree and the path.

Landing and FAQ: live redaction demo, serif display headings, teal accent.

Author screens: My work table with copy/open/edit actions, a usage meter,
a single-container New anonymization flow with a sticky submit bar, themed
form controls, and consistent GitHub connection cards.

Admin: underline tabs, severity-first errors chart and list, error tiles
that filter (older pages load until matches appear), queue and overview
health states, and shared toolbar heights and borders.

Fixes: guarded localStorage access, failed README images render a
placeholder, phone hover states no longer stick, and UI tests updated for
the new copy and markup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 10:52:10 +00:00

135 lines
6.1 KiB
JavaScript

const { expect } = require("chai");
const fs = require("fs");
const path = require("path");
const vm = require("vm");
/**
* Regression tests for the dashboard redesign (Sept 2026):
* - status codes and raw statuses are rendered as sentences,
* - dates spell the month so they are unambiguous across locales,
* - the template no longer uses href="#" anchors for actions,
* - the theme tokens keep muted text above WCAG AA contrast.
*/
const root = path.join(__dirname, "..");
const css = fs.readFileSync(path.join(root, "public", "css", "style.css"), "utf8");
const html = fs.readFileSync(path.join(root, "public", "partials", "dashboard.htm"), "utf8");
// Exercise the same formatting functions used by the Vue templates.
function loadFilters() {
const source = fs.readFileSync(path.join(__dirname, "../public/script/formatters.js"), "utf8");
const names = [...source.matchAll(/export const (\w+)/g)].map(match => match[1]);
const sandbox = { window: {}, console };
vm.runInNewContext(source.replace(/export const/g, "var").replace(/export function/g, "function") + "\nthis.formatters = {" + names.join(",") + "};", sandbox);
return Object.fromEntries(Object.entries(sandbox.formatters).map(([name, fn]) => [name, () => fn]));
}
function contrast(hexA, hexB) {
const lum = (hex) => {
const c = hex.replace("#", "").match(/../g).map((x) => parseInt(x, 16) / 255);
const f = (v) => (v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
return 0.2126 * f(c[0]) + 0.7152 * f(c[1]) + 0.0722 * f(c[2]);
};
const [l1, l2] = [lum(hexA), lum(hexB)];
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
function token(block, name) {
const m = block.match(new RegExp(`${name}:\\s*(#[0-9a-fA-F]{6})`));
expect(m, `token ${name} not found`).to.not.equal(null);
return m[1];
}
describe("dashboard UI", function () {
let filters;
before(function () {
filters = loadFilters();
});
describe("statusMsg filter", function () {
it("turns known machine codes into sentences", function () {
expect(filters.statusMsg()("branch_not_found")).to.equal("Branch not found on GitHub");
});
it("turns unknown snake_case codes into a sentence", function () {
expect(filters.statusMsg()("token_revoked_by_user")).to.equal("Token revoked by user");
});
it("leaves free text alone", function () {
expect(filters.statusMsg()("Something odd happened")).to.equal("Something odd happened");
});
});
describe("statusLabel filter", function () {
it("labels in-progress statuses as verbs", function () {
const f = filters.statusLabel();
expect(f("download")).to.equal("Downloading");
expect(f("queue")).to.equal("Queued");
expect(f("ready")).to.equal("Ready");
});
it("title-cases unknown statuses", function () {
expect(filters.statusLabel()("half_done")).to.equal("Half done");
});
});
describe("humanTime filter", function () {
it("spells the month for dates older than two days", function () {
const out = filters.humanTime()("2024-03-09T12:00:00Z");
expect(out).to.not.match(/^on /);
expect(out).to.match(/Mar/);
expect(out).to.not.match(/\d+\/\d+\/\d+/);
});
it("keeps relative wording for recent dates", function () {
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
expect(filters.humanTime()(twoHoursAgo)).to.equal("2 hours ago");
});
});
describe("template", function () {
it("uses buttons, not href=\"#\" anchors, for row actions", function () {
expect(html).to.not.match(/href="#"/);
expect(html).to.match(/<button type="button" class="dropdown-item dropdown-item-danger"/);
});
it("puts the destructive action after a divider", function () {
const divider = html.indexOf('<div class="dropdown-divider"></div>\n <button type="button" class="dropdown-item dropdown-item-danger"');
expect(divider).to.be.greaterThan(-1);
});
it("labels hidden statuses as hidden, not as active filters", function () {
expect(html).to.match(/Hiding \{\{\s*\(statusKeyLabels\[f\] \|\| ''\)\.toLowerCase\(\)\s*\}\}/);
});
it("shows an unlimited quota without a full bar", function () {
const quota = fs.readFileSync(path.join(__dirname, "../public/partials/quotaUsage.htm"), "utf8");
expect(quota).to.match(/quota-fill" v-if="!quota\[q\?\.key\]\.unlimited"/);
expect(html).to.not.match(/bg-success|bg-warning|bg-danger/);
});
it("right-aligns the Views column and marks it sortable", function () {
expect(html).to.match(/class="num"[^>]*>\s*<button type="button" class="sortable"[^>]*@click="setSort\(&#x27;pageView&#x27;\)"/);
});
});
describe("theme tokens", function () {
const light = css.slice(css.indexOf("\nbody {"), css.indexOf("--font-serif"));
const dark = css.slice(css.indexOf(".dark-mode {"), css.indexOf("\nbody {"));
it("keeps muted text at or above 4.5:1 in light mode", function () {
expect(contrast(token(light, "--ink-muted"), token(light, "--canvas-bg-color"))).to.be.at.least(4.5);
});
it("keeps muted text at or above 4.5:1 in dark mode", function () {
expect(contrast(token(dark, "--ink-muted"), token(dark, "--canvas-bg-color"))).to.be.at.least(4.5);
});
it("keeps status colours at or above 4.5:1 on both canvases", function () {
for (const [block, name] of [[light, "light"], [dark, "dark"]]) {
const canvas = token(block, "--canvas-bg-color");
for (const t of ["--status-ready", "--status-progress", "--status-error"]) {
expect(contrast(token(block, t), canvas), `${t} in ${name}`).to.be.at.least(4.5);
}
}
});
it("shows a keyboard focus ring instead of removing outlines", function () {
expect(css).to.match(/:focus-visible\s*\{[^}]*outline:\s*2px solid var\(--accent\)/);
expect(css).to.not.match(/\.btn:focus,\s*\.btn:active\s*\{[^}]*outline:\s*none/);
});
it("makes the paper palette win over Bootstrap's !important bg utilities", function () {
expect(css).to.match(/\.progress-bar\.bg-success \{ background: var\(--status-ready\) !important; \}/);
});
});
});