refactor: migrate the frontend from AngularJS to Vue 3

This commit is contained in:
tdurieux
2026-09-09 13:19:53 +02:00
parent dc0ef022fb
commit 4a18f94631
65 changed files with 6164 additions and 8425 deletions
+3
View File
@@ -20,6 +20,8 @@ describe("asset build", function () {
beforeEach(function () {
directory = fs.mkdtempSync(path.join(os.tmpdir(), "anonymous-assets-"));
fs.mkdirSync(path.join(directory, "public/script"), { recursive: true });
fs.writeFileSync(path.join(directory, "public/script/main.js"), 'globalThis.appLoaded = true;');
for (const file of Object.values(groups).flat()) {
const target = path.join(directory, file);
fs.mkdirSync(path.dirname(target), { recursive: true });
@@ -49,6 +51,7 @@ describe("asset build", function () {
const context = { assetOrder: [] };
vm.runInNewContext(fs.readFileSync(path.join(directory, `public/script/${bundle}.min.js`), "utf8"), context);
expect(context.assetOrder).to.deep.equal(groups[group]);
if (bundle === "vendor") expect(context.appLoaded).to.equal(true);
}
expect(fs.readFileSync(path.join(directory, "public/css/all.min.css"), "utf8")).to.equal(".cascade{color:#00f}".repeat(groups.cssFiles.length - 1) + ".cascade{color:red}");
const manifest = JSON.parse(fs.readFileSync(path.join(directory, "public/asset-manifest.json"), "utf8"));
+1 -1
View File
@@ -44,6 +44,6 @@ describe("dashboard .cell-conf overflow fix", function () {
it("exposes the full conference value via a title attribute for truncated text", function () {
const html = fs.readFileSync(htmlPath, "utf8");
expect(html).to.match(/cell-conf[\s\S]*?title="\{\{item\.conference\}\}"/);
expect(html).to.match(/cell-conf[\s\S]*?:title="\(item\?\.conference\)"/);
});
});
+9 -41
View File
@@ -12,48 +12,16 @@ const vm = require("vm");
*/
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.
// Exercise the same formatting functions used by the Vue templates.
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;
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) {
@@ -125,14 +93,14 @@ describe("dashboard UI", function () {
expect(divider).to.be.greaterThan(-1);
});
it("labels hidden statuses as hidden, not as active filters", function () {
expect(html).to.match(/Hiding \{\{statusKeyLabels\[f\]\}\}/);
expect(html).to.match(/Hiding \{\{\s*statusKeyLabels\[f\]\s*\}\}/);
});
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.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"[^>]*ng-click="setSort\('pageView'\)"/);
expect(html).to.match(/class="num"[^>]*>\s*<button type="button" class="sortable"[^>]*@click="setSort\(&#x27;pageView&#x27;\)"/);
});
});
+27 -49
View File
@@ -8,15 +8,7 @@ const source = fs.readFileSync(path.join(__dirname, "../public/script/app.js"),
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,
const context = { reactive: value => value, 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("/"),
@@ -25,9 +17,17 @@ function harness(date) {
setTimeout: fn => { timers.set(++timerId, fn); return timerId; },
clearTimeout: id => timers.delete(id), setInterval: () => 0, clearInterval() {},
};
vm.runInNewContext(source, context);
context.createTimers = () => ({ timeout: Object.assign(context.setTimeout, { cancel: context.clearTimeout }), interval: Object.assign(context.setInterval, { cancel: context.clearInterval }) });
context.createListeners = () => (target, name, callback) => target.addEventListener(name, callback);
const names = [...source.matchAll(/export const (\w+)/g)].map(match => match[1]);
vm.runInNewContext(source.replace(/^import .*;$/gm, "").replace(/export const/g, "var") + "\nthis.pageSetups = {" + names.join(",") + "};", context);
Object.assign(defs, context.pageSetups);
const routeSource = fs.readFileSync(path.join(__dirname, "../public/script/routes.js"), "utf8");
const routeContext = { pages: defs, admin: {} };
vm.runInNewContext(routeSource.replace(/^import .*;$/gm, "").replace("export const pageRoutes", "this.pageRoutes"), routeContext);
routeContext.pageRoutes.forEach(route => { routes[route.path] = route; });
const events = {}, watches = {};
const scope = { $new() { return { $destroy() {} }; }, $on: (key, fn) => { (events[key] ||= []).push(fn); }, $watch: (key, fn) => { watches[key] = fn; }, $apply() {}, $applyAsync() {} };
const scope = { $new() { return { dispose() {} }; }, 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 }));
@@ -39,33 +39,12 @@ function harness(date) {
}
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"); };
h.defs.exploreController(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: {} };
@@ -87,21 +66,20 @@ describe("frontend production regressions", function () {
});
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");
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);
for (const route of ["/pr/:pullRequestId/:path(.*)*", "/gist/:gistId/:path(.*)*"]) {
expect(h.routes[route].preserveExplorer).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.defs.anonymizeController(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]();
@@ -111,7 +89,7 @@ describe("frontend production regressions", function () {
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" }, {});
const h = harness(); h.defs.pullRequestController(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");
@@ -126,17 +104,17 @@ describe("frontend production regressions", function () {
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" });
const h = harness(); h.defs.statusController(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);
h.emit("dispose"); expect(h.timers.size).to.equal(0); callback(); expect(h.requests).to.have.length(1);
const late = harness(); late.defs.statusController(late.scope, late.http, { repoId: "repo" });
late.emit("dispose"); 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 h = harness(); expect(h.defs.profileController.toString()).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.defs.profileController(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");
});
@@ -147,7 +125,7 @@ describe("frontend production regressions", function () {
const focused = []; h.focused = focused;
const win = { document: { getElementById: id => ({ focus: () => focused.push(id) }) } };
const timeout = fn => fn();
h.defs.homeController.at(-1)(h.scope, h.http, { url() {} }, win, timeout);
h.defs.homeController(h.scope, h.http, { url() {} }, win, timeout);
return h;
}
it("selects the first feature and switches on click", function () {
@@ -179,7 +157,7 @@ describe("frontend production regressions", function () {
it("marks the tabs up as tabs and keeps links out of the buttons", function () {
expect(home).to.match(/<button[^>]*class="paper-feature-tab"[^>]*role="tab"/);
expect(home).to.match(/role="tablist"/);
expect(home).to.match(/role="tabpanel"[^>]*aria-labelledby="feature-tab-\{\{f\.key\}\}"/);
expect(home).to.match(/role="tabpanel"[^>]*:aria-labelledby=/);
const button = home.slice(home.indexOf('class="paper-feature-tab"'), home.indexOf("</button>"));
expect(button).to.not.include("<a ");
expect(home).to.not.match(/href="#"/);
@@ -188,7 +166,7 @@ describe("frontend production regressions", function () {
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, {}, {});
h.defs.newConferenceController(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());
});
+264
View File
@@ -0,0 +1,264 @@
const { expect } = require("chai");
const { JSDOM, VirtualConsole } = require("jsdom");
const fs = require("fs");
const path = require("path");
const { URL } = require("node:url");
const { setTimeout: delay } = require("node:timers/promises");
const publicDir = path.join(__dirname, "../public");
const bundles = ["core.min.js", "vendor.min.js"].map(name => fs.readFileSync(path.join(publicDir, "script", name), "utf8"));
async function browser(route = "/", overrides = {}) {
const errors = [], requests = [];
const virtualConsole = new VirtualConsole();
virtualConsole.on("jsdomError", error => {
if (!error.message.includes("navigation (except hash changes)")) errors.push(error.message);
});
virtualConsole.on("error", error => errors.push(error?.message || String(error)));
const dom = new JSDOM('<!doctype html><html><head></head><body><div id="app"></div></body></html>', {
url: "http://localhost" + route, runScripts: "dangerously", pretendToBeVisual: true, virtualConsole,
});
const window = dom.window;
window.matchMedia = () => ({ matches: false });
window.HTMLCanvasElement.prototype.getContext = () => null;
window.fetch = async (url, options = {}) => {
const target = new URL(url);
const request = { url: target, ...options, payload: options.body ? JSON.parse(options.body) : undefined };
requests.push(request);
let data;
const custom = overrides[target.pathname];
if (custom !== undefined) data = typeof custom === "function" ? await custom(request) : custom;
else if (target.pathname === "/api/user") data = null;
else if (target.pathname === "/api/user/default") data = { options: {}, terms: [] };
else if (/\/files\/$/.test(target.pathname)) data = [{ name: "hello.js", path: "", sha: "1", size: 8 }];
else if (/\/files\/counts$/.test(target.pathname)) data = { "": 1 };
else if (target.pathname === "/api/admin/errors") data = { entries: [] };
else if (target.pathname === "/api/conferences/plans") data = [];
else if (/\/options$|\/quota$|\/stats$|\/content$|\/api\/conferences\/[^/]+$/.test(target.pathname)) data = {};
else if (/\/file\//.test(target.pathname)) data = "hello";
else data = [];
const status = data?.__status || 200;
if (data?.__status) data = data.body;
return { ok: status < 400, status, headers: { get: () => typeof data === "string" ? "text/plain" : "application/json" }, text: async () => typeof data === "string" ? data : JSON.stringify(data) };
};
bundles.forEach(bundle => window.eval(bundle));
const app = window.anonymousApp;
await app.router.isReady();
await delay(30);
return {
window, app, errors, requests,
async go(path) { await app.router.push(path); await delay(30); },
async input(selector, value, event = "input") {
const input = window.document.querySelector(selector);
expect(input, selector).not.to.equal(null);
input.value = value;
input.dispatchEvent(new window.Event(event, { bubbles: true }));
await delay(10);
return input;
},
close() { app.app.unmount(); window.close(); },
};
}
describe("Vue 3 UI", function () {
this.timeout(15000);
let ui;
afterEach(() => ui?.close());
it("renders every public and administrative route", async function () {
ui = await browser();
for (const route of ["/faq", "/anonymize", "/gist-anonymize", "/pull-request-anonymize", "/status/test", "/404", "/r/test/", "/repository/test/", "/pr/test/", "/gist/test/"]) {
await ui.go(route);
expect(ui.window.document.querySelector(".app-view").textContent, route + JSON.stringify(ui.errors)).not.to.equal("");
}
ui.app.state.user = { username: "tester", status: "ready", isAdmin: true };
for (const route of ["/dashboard", "/claim", "/profile", "/conferences", "/conference/new", "/conference/test", "/conference/test/edit", "/admin/", "/admin/users", "/admin/users/test", "/admin/repositories", "/admin/conferences", "/admin/queues", "/admin/errors"]) {
await ui.go(route);
expect(ui.window.document.querySelector(".app-view").textContent, route + JSON.stringify(ui.errors)).not.to.equal("");
}
expect(ui.errors).to.deep.equal([]);
});
it("validates a claim, binds input values and renders server validation failures", async function () {
ui = await browser("/claim", { "/api/repo/claim": { __status: 404, body: {} } });
const form = ui.window.document.querySelector("form");
form.dispatchEvent(new ui.window.Event("submit", { bubbles: true, cancelable: true }));
expect(ui.requests.filter(r => r.method === "POST")).to.have.length(0);
await ui.input("#repoUrl", "https://github.com/example/repository");
await ui.input("#repoId", "anonymous-id");
form.dispatchEvent(new ui.window.Event("submit", { bubbles: true, cancelable: true }));
await delay(20);
const request = ui.requests.find(r => r.method === "POST");
expect(form.querySelector("#repoUrl").classList.contains("is-invalid")).to.equal(true);
expect(request.payload).to.deep.equal({ repoUrl: "https://github.com/example/repository", repoId: "anonymous-id" });
expect(ui.errors).to.deep.equal([]);
});
it("debounces model updates and flushes them on blur", async function () {
ui = await browser("/profile", { "/api/user": { username: "tester" } });
const input = await ui.input("#terms", "private-name");
const form = ui.window.document.querySelector("form");
input.dispatchEvent(new ui.window.Event("blur"));
form.dispatchEvent(new ui.window.Event("submit", { bubbles: true, cancelable: true }));
await delay(20);
const request = ui.requests.find(r => r.method === "POST");
expect(request.payload.terms).to.deep.equal(["private-name"]);
expect(ui.errors).to.deep.equal([]);
});
it("keeps date values as local Dates and rejects out-of-range input", async function () {
ui = await browser("/conference/new", { "/api/user": { username: "tester" } });
const input = await ui.input("#startDate", "2027-02-15", "change");
const bound = input._field.binding.value;
expect(bound.getFullYear()).to.equal(2027);
expect(bound.getMonth()).to.equal(1);
expect(bound.getDate()).to.equal(15);
input.min = "2027-03-01";
input.dispatchEvent(new ui.window.Event("change", { bubbles: true }));
expect(input._field.validation.errors.min).to.equal(true);
expect(ui.errors).to.deep.equal([]);
});
it("renders hostile filenames as text and updates the explorer without losing the tree", async function () {
const filename = '{{constructor.constructor("window.probe=1")()}}.txt';
ui = await browser("/r/test/hello.txt", {
"/api/repo/test/files/": [{ name: filename, path: "", size: 1 }, { name: "constructor", path: "" }, { name: "index.js", path: "constructor", size: 2 }, { name: "hello.txt", path: "", size: 3 }],
});
const tree = ui.window.document.querySelector("tree");
expect(tree.textContent).to.include(filename).and.include("constructor/index.js");
expect(ui.window.probe).to.equal(undefined);
await ui.go("/r/test/constructor/index.js");
expect(ui.window.document.querySelector("tree")).to.equal(tree);
expect(ui.requests.some(r => r.url.pathname === "/api/repo/test/file/constructor/index.js")).to.equal(true);
await ui.go("/r/other/hello.txt");
expect(ui.requests.some(r => r.url.pathname === "/api/repo/other/options")).to.equal(true);
expect(ui.errors).to.deep.equal([]);
});
it("starts folders collapsed and opens the selected file's ancestors", async function () {
ui = await browser("/r/test/hello.txt", {
"/api/repo/test/files/": [
{ name: "hello.txt", path: "", size: 3 },
{ name: "src", path: "" },
{ name: "a.js", path: "src", size: 2 },
{ name: "b.js", path: "src", size: 2 },
{ name: "lazy", path: "" },
],
});
const folder = name => ui.window.document.querySelector(`tree a[data-path="/${name}"]`).parentElement;
expect(folder("src").classList.contains("open")).to.equal(false);
expect(folder("lazy").classList.contains("open")).to.equal(false);
expect(folder("src").querySelector("ul")).to.equal(null);
folder("src").querySelector("a").click();
await delay(10);
expect(folder("src").textContent).to.include("a.js");
folder("src").querySelector("a").click();
await delay(10);
expect(folder("src").querySelector("ul")).to.equal(null);
await ui.go("/r/test/src/a.js");
expect(folder("src").classList.contains("open")).to.equal(true);
expect(folder("lazy").classList.contains("open")).to.equal(false);
folder("lazy").querySelector("a").click();
await delay(10);
expect(ui.requests.filter(r => r.url.pathname === "/api/repo/test/files/" && r.url.searchParams.get("path") === "lazy")).to.have.length(1);
folder("lazy").querySelector("a").click();
await delay(10);
expect(ui.requests.filter(r => r.url.pathname === "/api/repo/test/files/" && r.url.searchParams.get("path") === "lazy")).to.have.length(1);
expect(ui.errors).to.deep.equal([]);
});
it("reuses query-only routes but reloads PR and gist data when their IDs change", async function () {
ui = await browser("/pr/first/");
await ui.go("/pr/second/");
await ui.go("/gist/first/");
await ui.go("/gist/second/");
expect(ui.requests.some(r => r.url.pathname === "/api/pr/second/content")).to.equal(true);
expect(ui.requests.some(r => r.url.pathname === "/api/gist/second/content")).to.equal(true);
const count = ui.requests.length;
await ui.go("/gist/second/?tab=comments");
expect(ui.requests).to.have.length(count);
expect(ui.errors).to.deep.equal([]);
});
it("keeps rendered HTML in an opaque sandbox when scripts are enabled", async function () {
ui = await browser("/r/test/report.html", { "/api/repo/test/file/report.html": '<h1>Report</h1><script>window.probe=1</script>' });
const frame = ui.window.document.querySelector("html-doc iframe");
expect(frame).not.to.equal(null);
expect(frame.srcdoc).to.include("Report");
expect(frame.getAttribute("sandbox")).not.to.include("allow-scripts");
ui.window.document.querySelector('[aria-label="Allow this document to run JavaScript"]').click();
await delay(20);
const enabled = ui.window.document.querySelector("html-doc iframe");
expect(enabled).not.to.equal(frame);
expect(enabled.getAttribute("sandbox")).to.include("allow-scripts").and.not.include("allow-same-origin");
expect(ui.window.probe).to.equal(undefined);
expect(ui.errors).to.deep.equal([]);
});
it("filters loaded dashboard rows through search and status controls", async function () {
ui = await browser("/dashboard", {
"/api/user": { username: "tester" },
"/api/user/anonymized_repositories": [
{ repoId: "alpha", status: "ready", source: { fullName: "owner/alpha" }, options: {}, pageView: 3 },
{ repoId: "beta", status: "ready", source: { fullName: "owner/beta" }, options: {}, pageView: 8 },
],
});
const rows = () => [...ui.window.document.querySelectorAll(".paper-table-row:not(.paper-table-skeleton)")];
expect(rows()).to.have.length(2);
await ui.input('input[type="search"]', "beta");
expect(rows()).to.have.length(1);
expect(rows()[0].textContent).to.include("beta");
await ui.input('input[type="search"]', "");
ui.window.document.querySelector("#status-ready").click();
await delay(10);
expect(rows()).to.have.length(0);
expect(ui.errors).to.deep.equal([]);
});
it("locks edit identifiers and reactively displays the anonymized PR preview", async function () {
ui = await browser("/pull-request-anonymize/test", {
"/api/pr/test": { source: { repositoryFullName: "owner/repo", pullRequestId: 1 }, options: { terms: ["Alice"] } },
"/api/pr/owner/repo/1": { pullRequest: { title: "Alice patch", body: "Alice body", comments: [] } },
"/api/anonymize-preview": request => ({ contents: request.payload.contents.map(text => text.replaceAll("Alice", "MASKED")) }),
});
expect(ui.window.document.querySelector("#pullRequestId").disabled).to.equal(true);
expect(ui.window.document.querySelector("#sourceUrl").disabled).to.equal(true);
await delay(260);
expect(ui.window.document.querySelector(".anonymize-preview-col").textContent).to.include("MASKED patch");
expect(ui.errors).to.deep.equal([]);
});
it("loads PDF pages, changes documents and releases the previous document", async function () {
ui = await browser("/faq");
const loaded = [], destroyed = [];
ui.window.pdfjsLib = { getDocument({ url }) {
loaded.push(url);
return { promise: Promise.resolve({
numPages: 2,
getPage: async () => ({ getViewport: ({ scale }) => ({ width: 600 * scale, height: 800 * scale }), render: () => ({ promise: Promise.resolve() }) }),
destroy() { destroyed.push(url); },
}) };
} };
await ui.go("/r/test/first.pdf");
expect(ui.window.document.querySelectorAll(".pdf-viewer-page")).to.have.length(2);
await ui.go("/r/test/second.pdf");
expect(loaded).to.have.length(2);
expect(destroyed).to.deep.equal([loaded[0]]);
await ui.go("/faq");
expect(destroyed).to.deep.equal(loaded);
expect(ui.errors).to.deep.equal([]);
});
it("leaves server routes and external links to the browser", async function () {
ui = await browser();
for (const href of ["/w/test/", "/api/repo/test/file/a", "/github/login", "https://example.org/"]) {
const link = ui.window.document.createElement("a");
link.href = href;
ui.window.document.body.append(link);
const event = new ui.window.MouseEvent("click", { bubbles: true, cancelable: true, button: 0 });
link.dispatchEvent(event);
expect(event.defaultPrevented, href).to.equal(false);
}
expect(ui.app.router.currentRoute.value.path).to.equal("/");
});
});