Merge pull request #809 from tdurieux/feat/dashboard-redesign

feat: make the dashboard, menus and settings readable at a glance
This commit is contained in:
Thomas Durieux
2026-09-08 21:07:30 +02:00
committed by GitHub
20 changed files with 1949 additions and 625 deletions
+165
View File
@@ -0,0 +1,165 @@
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 appJs = fs.readFileSync(path.join(root, "public", "script", "app.js"), "utf8");
const css = fs.readFileSync(path.join(root, "public", "css", "style.css"), "utf8");
const html = fs.readFileSync(path.join(root, "public", "partials", "dashboard.htm"), "utf8");
// Load app.js with a stub `angular` that records filter factories, so the
// filters can be exercised without a browser.
function loadFilters() {
const filters = {};
const chain = new Proxy(
{},
{
get(_, prop) {
if (prop === "filter") {
return (name, factory) => {
filters[name] = Array.isArray(factory) ? factory[factory.length - 1] : factory;
return chain;
};
}
return () => chain;
},
}
);
// jQuery is used at the top level of app.js for a couple of global
// listeners; a self-returning proxy absorbs those calls.
const jq = new Proxy(function () {}, {
get: () => jq,
apply: () => jq,
});
const sandbox = {
angular: { module: () => chain, element: () => ({}) },
$: jq,
jQuery: jq,
window: {},
document: { addEventListener() {}, querySelector: () => null },
localStorage: { getItem: () => null, setItem() {} },
navigator: { language: "en-US" },
console,
};
sandbox.window = sandbox;
vm.createContext(sandbox);
vm.runInContext(appJs, sandbox, { filename: "app.js" });
return filters;
}
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.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 \{\{statusKeyLabels\[f\]\}\}/);
});
it("shows an unlimited quota without a full bar", function () {
expect(html).to.match(/quota-fill" ng-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"[^>]*ng-click="setSort\('pageView'\)"/);
});
});
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; \}/);
});
});
});
+150
View File
@@ -0,0 +1,150 @@
const { expect } = require("chai");
const fs = require("fs");
const vm = require("vm");
const path = require("path");
const { setImmediate } = require("timers");
const source = fs.readFileSync(path.join(__dirname, "../public/script/app.js"), "utf8");
function harness(date) {
const defs = {}, routes = {}, timers = new Map();
let timerId = 0;
const chain = new Proxy({}, { get: (_, method) => (...args) => {
if (["controller", "directive"].includes(method)) defs[args[0]] = args[1];
if (method === "config") {
const route = { when(p, opt) { routes[p] = opt; return route; }, otherwise() {} };
args[0].at(-1)(route, { html5Mode() {} }, { useStaticFilesLoader() {}, preferredLanguage() {} });
}
return chain;
} });
const context = { angular: { module: () => chain }, console, Map, Set, Date: date || Date,
navigator: { platform: "Linux" }, document: { location: { pathname: "/r/repo" }, addEventListener() {}, querySelector() {} },
window: {}, Prism: { highlightAll() {} }, encodeURIComponent,
encodePathForUrl: p => p.split("/").map(encodeURIComponent).join("/"),
humanFileSize: x => String(x), parseGithubUrl: () => ({ owner: "owner", repo: "repo" }),
$: () => ({ on() {}, tooltip() {} }),
setTimeout: fn => { timers.set(++timerId, fn); return timerId; },
clearTimeout: id => timers.delete(id), setInterval: () => 0, clearInterval() {},
};
vm.runInNewContext(source, context);
const events = {}, watches = {};
const scope = { $new() { return { $destroy() {} }; }, $on: (key, fn) => { (events[key] ||= []).push(fn); }, $watch: (key, fn) => { watches[key] = fn; }, $apply() {}, $applyAsync() {} };
const requests = [];
const http = {};
for (const method of ["get", "post"]) http[method] = (url, body) => new Promise((resolve, reject) => requests.push({ method, url, body, resolve, reject }));
const q = { resolve: () => Promise.resolve(), reject: e => Promise.reject(e), defer: () => { let resolve; const promise = new Promise(r => { resolve = r; }); return { promise, resolve }; } };
return { defs, routes, scope, http, requests, q, events, watches, timers, context,
emit: key => (events[key] || []).forEach(fn => fn()),
flush: () => new Promise(resolve => setImmediate(resolve)),
};
}
function explorer() {
const h = harness(); h.params = { repoId: "repo", path: "README.md" };
h.defs.exploreController.at(-1)(h.scope, h.http, { url: () => "/r/repo/README.md" }, h.params, { trustAsHtml: x => x }, h.q);
h.navigate = path => { h.params.path = path; h.emit("$routeUpdate"); };
return h;
}
describe("frontend production regressions", function () {
it("keeps filenames and folder paths out of compiled Angular templates", function () {
const h = harness(); let template;
const element = { html() {}, append() {}, 0: { addEventListener() {}, setAttribute() {} } };
h.scope.file = [{ name: '{{constructor.constructor("window.probe=1")()}}.txt', path: "", size: 1 }];
h.scope.$parent = {};
h.defs.tree[0]().controller.at(-1)(element, h.scope, {}, html => { template = html; return () => {}; });
h.watches.file(h.scope.file);
expect(template).not.to.include("constructor.constructor");
expect(template).to.include('ng-bind="treeNodes[0].name"');
expect(h.scope.treeNodes[0].name).to.equal(h.scope.file[0].name);
});
it("renders directories whose names collide with Object.prototype", function () {
const h = harness(); let template;
const element = { html() {}, 0: { addEventListener() {}, setAttribute() {} } };
h.scope.file = [{ name: "constructor", path: "" }, { name: "index.js", path: "constructor", size: 1 }];
h.scope.$parent = {};
h.defs.tree[0]().controller.at(-1)(element, h.scope, {}, html => { template = html; return () => {}; });
expect(() => h.watches.file(h.scope.file)).not.to.throw();
expect(template).to.include("treeNodes");
expect(h.scope.treeNodes[0].path).to.equal("/constructor/index.js");
});
it("sanitizes Org output before trusting it", async function () {
const h = explorer(); let untrusted;
h.context.Org = { Parser: function () { this.parse = () => ({ convert: () => ({ toString: () => '<img onerror="probe()">' }) }); }, ConverterHTML: {} };
h.context.contentAbs2Relative = x => x;
h.context.DOMPurify = { sanitize: html => { untrusted = html; return "sanitized"; } };
h.navigate("file.org"); h.requests.at(-1).resolve({ data: "org source", headers: () => "text/plain" }); await h.flush();
expect(untrusted).to.include("onerror"); expect(h.scope.content).to.equal("sanitized");
});
it("ignores stale successes and failures after selecting another file", async function () {
const h = explorer();
h.navigate("first.js"); const first = h.requests.at(-1);
h.navigate("second.js"); const second = h.requests.at(-1);
second.resolve({ data: "SECOND", headers: () => "text/plain" }); await h.flush();
first.resolve({ data: "FIRST", headers: () => "text/plain" }); await h.flush();
expect(h.scope.content).to.equal("SECOND");
h.navigate("third.js"); const third = h.requests.at(-1);
h.navigate("fourth.pdf"); third.reject({ status: 500 }); await h.flush();
expect(h.scope.type).to.equal("pdf");
});
it("reloads the repository when only its ID changes", function () {
const h = explorer(); h.scope.files = [{ name: "old", path: "old" }];
h.params.repoId = "new-repo"; h.emit("$routeUpdate");
expect(h.scope.repoId).to.equal("new-repo"); expect(h.scope.files).to.have.length(0);
expect(h.requests.at(-1).url).to.equal("/api/repo/new-repo/options");
});
it("reloads PR and Gist controllers for new resource IDs", function () {
const h = harness();
for (const route of ["/pr/:pullRequestId/:path*?", "/gist/:gistId/:path*?"]) {
expect(h.routes[route].reloadOnUrl).not.to.equal(false);
expect(h.routes[route].reloadOnSearch).to.equal(false);
}
});
it("includes PR comment authors and bodies in the preview batch", async function () {
const h = harness(); const pending = new Map(); let id = 0;
const timeout = fn => { pending.set(++id, fn); return id; }; timeout.cancel = id => pending.delete(id);
h.defs.anonymizeController.at(-1)(h.scope, h.http, {}, {}, {}, () => {}, timeout);
h.scope.detectedType = "pr"; h.scope.terms = "Alice";
h.scope.details = { pullRequest: { title: "title", comments: [{ author: "Alice", body: "Alice comment" }] } };
h.watches.terms(); [...pending.values()][0]();
const request = h.requests.at(-1);
expect(Array.from(request.body.contents)).to.deep.equal(["title", "Alice", "Alice comment"]);
request.resolve({ data: { contents: ["title", "MASK", "MASK comment"] } }); await h.flush();
expect(h.scope.anonymizePrContent("Alice")).to.equal("MASK");
});
it("selects the diff tab after asynchronous PR loading", async function () {
const h = harness(); h.defs.pullRequestController.at(-1)(h.scope, h.http, {}, { pullRequestId: "pr" }, {});
h.requests[0].resolve({ data: {} }); await h.flush();
h.requests[1].resolve({ data: { diff: "patch" } }); await h.flush();
expect(h.scope.tabState.active).to.equal("diff");
const template = fs.readFileSync(path.join(__dirname, "../public/partials/pullRequest.htm"), "utf8");
expect(template).not.to.include('ng-init="tabState');
});
it("finishes failed searches without letting canceled requests reset the next search", async function () {
const h = explorer(); h.scope.fileSearchQuery = "old"; h.scope.onFileSearchChange(); const old = h.requests.at(-1);
h.scope.fileSearchQuery = "new"; h.scope.onFileSearchChange(); const current = h.requests.at(-1);
old.reject({ status: -1 }); await h.flush(); expect(h.scope.fileSearchLoading).to.equal(true);
current.reject({ status: 500 }); await h.flush(); expect(h.scope.fileSearchLoading).to.equal(false);
expect(h.scope.fileSearchResults).to.have.length(0);
});
it("cancels status polling on destroy, including late responses", async function () {
const h = harness(); h.defs.statusController.at(-1)(h.scope, h.http, { repoId: "repo" });
h.requests[0].resolve({ data: { status: "preparing" } }); await h.flush();
expect(h.timers.size).to.equal(1); const callback = [...h.timers.values()][0];
h.emit("$destroy"); expect(h.timers.size).to.equal(0); callback(); expect(h.requests).to.have.length(1);
const late = harness(); late.defs.statusController.at(-1)(late.scope, late.http, { repoId: "repo" });
late.emit("$destroy"); late.requests[0].resolve({ data: { status: "preparing" } }); await late.flush(); expect(late.timers.size).to.equal(0);
});
it("translates profile save failures", async function () {
const h = harness(); expect(h.defs.profileController).to.include("$translate");
const timeout = Object.assign(() => 0, { cancel() {} });
h.defs.profileController.at(-1)(h.scope, h.http, key => Promise.resolve(key), timeout, { load: () => Promise.resolve({}) });
h.scope.saveDefault(); h.requests.at(-1).reject({ data: { error: "not_connected" } }); await h.flush();
expect(h.scope.error).to.equal("ERRORS.not_connected");
});
it("keeps the conference end date after the start across December", function () {
class December extends Date { constructor(...args) { super(...(args.length ? args : ["2026-12-15T12:00:00Z"])); } }
const h = harness(December); h.scope.user = {};
h.defs.newConferenceController.at(-1)(h.scope, h.http, {}, {});
expect(h.scope.options.startDate.getFullYear()).to.equal(2027);
expect(h.scope.options.endDate.getTime()).to.be.greaterThan(h.scope.options.startDate.getTime());
});
});