mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-11-14 16:27:25 +01:00
The UI was uneven: the home page looked finished, but app pages used three different display typefaces, the anonymize flow changed frame between steps, and the dashboard, explorer and admin screens each had their own controls, status colours and edge-case handling. Make every screen follow one system: shared page header, grid, buttons, filters and status colours; a repository picker and step trail on anonymize; a "What reviewers will see" preview of the actual reviewer page; consistent file headers and viewers for every file type; truthful admin charts and tables. Fold the accumulated override blocks back into style.css (22% smaller, no visual change). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2310 lines
136 KiB
JavaScript
2310 lines
136 KiB
JavaScript
const { expect } = require("chai");
|
|
const { JSDOM, VirtualConsole, requestInterceptor } = require("jsdom");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const { URL } = require("node:url");
|
|
const { setTimeout: delay } = require("node:timers/promises");
|
|
const { Response } = globalThis;
|
|
|
|
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 = {}, storage = {}) {
|
|
const errors = [], requests = [], assets = [];
|
|
const virtualConsole = new VirtualConsole();
|
|
virtualConsole.on("jsdomError", error => {
|
|
if (error.type === "not-implemented" && error.message === "Not implemented: navigation to another Document") return;
|
|
errors.push(error.message);
|
|
});
|
|
virtualConsole.on("error", error => errors.push(error?.message || String(error)));
|
|
const base = fs.readFileSync(path.join(publicDir, "index.html"), "utf8").match(/<base\b[^>]*>/i)?.[0] || "";
|
|
const dom = new JSDOM(`<!doctype html><html><head>${base}</head><body><div id="app"></div></body></html>`, {
|
|
resources: {
|
|
interceptors: [requestInterceptor(request => {
|
|
const url = request.url;
|
|
assets.push(new URL(url).pathname);
|
|
if (/\/pdf\.[a-f0-9]+\.min\.js$/.test(new URL(url).pathname) && dom.window.pdfjsLib) return new Response("");
|
|
const pathname = new URL(url).pathname.replace(/\.[a-f0-9]{10}\.min\.js$/, ".min.js");
|
|
if (pathname.startsWith("/script/")) return new Response(fs.readFileSync(path.join(publicDir, pathname)), { headers: { "content-type": "application/javascript" } });
|
|
return new Response(null, { status: 204 });
|
|
})],
|
|
},
|
|
url: "http://localhost" + route, runScripts: "dangerously", pretendToBeVisual: true, virtualConsole,
|
|
});
|
|
const window = dom.window;
|
|
// Exercise the production bundle on browsers without this ES2022 builtin.
|
|
window.Object.hasOwn = undefined;
|
|
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) };
|
|
};
|
|
for (const [key, value] of Object.entries(storage)) window.sessionStorage.setItem(key, JSON.stringify(value));
|
|
bundles.forEach(bundle => window.eval(bundle));
|
|
const app = window.anonymousApp;
|
|
await app.router.isReady();
|
|
await delay(30);
|
|
return {
|
|
window, app, errors, requests, assets,
|
|
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("starts a deep-linked file and editor before directory listings finish", async () => {
|
|
const pendingDirectories = [];
|
|
let releaseFile;
|
|
ui = await browser("/r/access-test/src/nested/hello.js", {
|
|
"/api/repo/access-test/files/": () => new Promise(resolve => pendingDirectories.push(resolve)),
|
|
"/api/repo/access-test/file/src/nested/hello.js": () => new Promise(resolve => { releaseFile = resolve; }),
|
|
});
|
|
await delay(100);
|
|
expect(releaseFile, JSON.stringify({requests:ui.requests.map(r=>r.url.pathname),errors:ui.errors})).to.be.a("function");
|
|
expect(pendingDirectories).to.have.length(3);
|
|
expect(ui.assets.some(asset => /editor\.[a-f0-9]+\.min\.js$/.test(asset))).to.equal(true);
|
|
releaseFile("hello without waiting for the tree");
|
|
await delay(150);
|
|
expect(ui.window.document.querySelector(".viewer-loading")).to.equal(null);
|
|
// Navigation metadata failure must not replace a successful file preview.
|
|
pendingDirectories.forEach(resolve => resolve({__status:503,body:{error:"unreachable"}}));
|
|
await delay(80);
|
|
expect(ui.window.document.querySelector(".viewer-error")).to.equal(null);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("shows file context immediately and cancels a superseded load", async () => {
|
|
let release;
|
|
ui = await browser("/r/access-test/hello.js", {
|
|
"/api/repo/access-test/file/hello.js": () => new Promise(resolve => { release = resolve; }),
|
|
});
|
|
const loading = ui.window.document.querySelector(".viewer-loading");
|
|
expect(loading?.textContent).to.include("Loading file");
|
|
expect(loading.textContent).to.include("hello.js");
|
|
expect(loading.getAttribute("aria-busy")).to.equal("true");
|
|
await delay(1550);
|
|
expect(loading.textContent).to.include("You can select another file");
|
|
const pending = ui.requests.find(r => r.url.pathname.endsWith("/file/hello.js"));
|
|
await ui.go("/r/access-test/other.txt");
|
|
expect(pending.signal.aborted).to.equal(true);
|
|
release("outdated file content");
|
|
await delay(100);
|
|
expect(ui.window.document.querySelector(".viewer-loading")).to.equal(null);
|
|
expect(ui.window.document.body.textContent).not.to.include("outdated file content");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("keeps defaults across settings tabs and preserves the hidden fetch preference", async () => {
|
|
ui = await browser("/profile", {
|
|
"/api/user": { username: "owner" },
|
|
"/api/user/default": { terms: ["Original"], options: { mode: "GitHubDownload", link: true } },
|
|
});
|
|
expect(ui.window.document.querySelector("#mode")).to.equal(null);
|
|
await ui.input("#terms", "Changed Author");
|
|
ui.window.document.querySelectorAll(".settings-nav button")[1].click();
|
|
await delay(300);
|
|
ui.window.document.querySelectorAll(".settings-nav button")[0].click();
|
|
await delay(10);
|
|
expect(ui.window.document.querySelector("#terms").value).to.equal("Changed Author");
|
|
ui.window.document.querySelector("#save").click(); await delay(30);
|
|
const saved = ui.requests.find(r => r.method === "POST" && r.url.pathname === "/api/user/default");
|
|
expect(saved.payload.options.mode).to.equal("GitHubDownload");
|
|
expect(saved.payload.terms).to.deep.equal(["Changed Author"]);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("shows paid-through access and billing management without offering a duplicate subscription", async () => {
|
|
ui = await browser("/profile", {
|
|
"/api/user": { username: "owner" },
|
|
"/api/billing/": { enabled: true, csrf: "token", portal: true, allowance: { repositories: 20, storage: 10737418240 },
|
|
grants: [{ planId: "author_monthly", scope: "user:owner", status: "active", until: "2099-12-01", cancelAtPeriodEnd: true }],
|
|
plans: [{ id: "author_monthly", amount: 500, currency: "eur", interval: "month" }] },
|
|
});
|
|
const panel = ui.window.document.querySelector("#settings-billing");
|
|
expect(panel.textContent).to.include("Renewal cancelled");
|
|
expect(panel.textContent).to.include("Manage billing");
|
|
expect(panel.textContent).not.to.include("View Author plan");
|
|
expect(panel.querySelector(".billing-plan")).to.equal(null);
|
|
expect(ui.window.document.querySelector(".nav-upgrade")).to.equal(null);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("requires the account name before submitting account deletion", async () => {
|
|
ui = await browser("/profile", { "/api/user": { username: "owner" }, "/api/user/": {} });
|
|
ui.window.document.querySelectorAll(".settings-nav button")[2].click(); await delay(10);
|
|
ui.window.document.querySelector(".paper-danger-zone button").click(); await delay(10);
|
|
const confirmation = ui.window.document.querySelector("#delete-username");
|
|
const button = ui.window.document.querySelector(".settings-delete-confirm .btn-outline-danger");
|
|
expect(button.disabled).to.equal(true);
|
|
confirmation.value = "wrong"; confirmation.dispatchEvent(new ui.window.Event("input", { bubbles: true })); await delay(10);
|
|
expect(button.disabled).to.equal(true);
|
|
confirmation.value = "owner"; confirmation.dispatchEvent(new ui.window.Event("input", { bubbles: true })); await delay(10);
|
|
expect(button.disabled).to.equal(false);
|
|
expect(ui.requests.some(r => r.method === "DELETE")).to.equal(false);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
for (const scenario of [
|
|
{ name: "signed-out visitors", user: null, enabled: true, grants: [], visible: true },
|
|
{ name: "free accounts with billing enabled", enabled: true, grants: [], visible: true },
|
|
{ name: "free accounts with billing disabled", enabled: false, grants: [], visible: true },
|
|
...["author_monthly", "author_annual", "author_pass", "conference"].map(planId => ({
|
|
name: planId + " customers", enabled: true, grants: [{ planId, status: "paid", until: "2099-01-01" }], visible: false,
|
|
})),
|
|
{ name: "canceled subscriptions with remaining access", enabled: true, grants: [{ status: "active", cancelAtPeriodEnd: true, until: "2099-01-01" }], visible: false },
|
|
{ name: "past-due subscriptions with remaining access", enabled: true, grants: [{ status: "past_due", until: "2099-01-01" }], visible: false },
|
|
{ name: "expired customers", enabled: true, grants: [{ status: "paid", until: "2000-01-01" }], visible: true },
|
|
{ name: "revoked customers", enabled: true, grants: [{ status: "revoked", until: "2099-01-01" }], visible: true },
|
|
]) {
|
|
it("sets header donation visibility for " + scenario.name, async () => {
|
|
ui = await browser("/faq", {
|
|
"/api/user": scenario.user === null ? null : { username: "owner" },
|
|
"/api/billing/": { enabled: scenario.enabled, grants: scenario.grants, plans: [] },
|
|
});
|
|
await delay(100);
|
|
expect(!!ui.window.document.querySelector(".nav-support")).to.equal(scenario.visible);
|
|
const menuLink = ui.window.document.querySelector('.user-menu a[href="https://ko-fi.com/tdurieux"]');
|
|
expect(!!menuLink).to.equal(scenario.visible && scenario.user !== null);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
}
|
|
|
|
it("waits for billing before showing donation links and reacts to paid access changes", async () => {
|
|
let resolveBilling;
|
|
const billing = new Promise(resolve => { resolveBilling = resolve; });
|
|
ui = await browser("/faq", {
|
|
"/api/user": { username: "owner" },
|
|
"/api/billing/": () => billing,
|
|
});
|
|
expect(ui.window.document.querySelector(".nav-support")).to.equal(null);
|
|
resolveBilling({ enabled: true, grants: [], plans: [] });
|
|
await delay(100);
|
|
expect(ui.window.document.querySelector(".nav-support")).not.to.equal(null);
|
|
ui.app.state.emit("billing:updated", { enabled: true, grants: [{ status: "paid", until: "2099-01-01" }] });
|
|
await delay(30);
|
|
expect(ui.window.document.querySelector(".nav-support")).to.equal(null);
|
|
expect(ui.window.document.querySelector('.user-menu a[href="https://ko-fi.com/tdurieux"]')).to.equal(null);
|
|
ui.app.state.emit("billing:updated", { enabled: true, grants: [] });
|
|
await delay(30);
|
|
expect(ui.window.document.querySelector(".nav-support")).not.to.equal(null);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("opens the plan comparison from the global header on another page", async () => {
|
|
ui = await browser("/dashboard", {
|
|
"/api/user": { username: "author" },
|
|
"/api/billing/": { enabled: true, csrf: "token", grants: [], allowance: { repositories: 5, storage: 2147483648 }, plans: [{ id: "author_monthly", amount: 500, currency: "eur", interval: "month", repositories: 20, storage: 10737418240 }] },
|
|
});
|
|
const upgrade = ui.window.document.querySelector(".app-header .nav-upgrade");
|
|
expect(upgrade).not.to.equal(null);
|
|
expect(ui.window.document.querySelector(".nav-support")).not.to.equal(null);
|
|
upgrade.click(); await delay(100);
|
|
expect(ui.window.document.querySelector(".settings-comparison")).not.to.equal(null);
|
|
expect(ui.window.document.querySelectorAll(".settings-nav button")[1].getAttribute("aria-current")).to.equal("page");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("shows subscription cadence and sends a protected checkout request", async () => {
|
|
ui = await browser("/profile", {
|
|
"/api/user": { username: "author" },
|
|
"/api/billing/": { enabled: true, csrf: "billing-token", portal: true, grants: [], allowance: { repositories: 3, storage: 512 }, plans: [
|
|
{ id: "author_monthly", name: "Author monthly", description: "Renews monthly. Cancel renewal at any time.", amount: 500, currency: "eur", interval: "month", intervalCount: 1, repositories: 20, storage: 10737418240 },
|
|
] },
|
|
"/api/billing/checkout": { __status: 409, body: { error: "billing_subscription_exists" } },
|
|
});
|
|
ui.window.document.querySelector(".nav-upgrade").click();
|
|
await delay(10);
|
|
expect(ui.window.document.activeElement.id).to.equal("settings-plan-comparison");
|
|
expect(ui.window.document.querySelector(".settings-comparison").textContent).to.include("Latest expiration");
|
|
expect(ui.window.document.querySelector(".settings-comparison").textContent).to.include("Automatic updates");
|
|
expect(ui.window.document.querySelectorAll(".settings-nav button")[1].getAttribute("aria-current")).to.equal("page");
|
|
expect(ui.window.document.querySelector("#settings-billing").textContent).to.include("/ month");
|
|
expect(ui.window.document.querySelector("#settings-billing").textContent).to.include("10 GB");
|
|
[...ui.window.document.querySelectorAll(".billing-offer .btn")].find(b => b.textContent.trim() === "Subscribe").click();
|
|
await delay(30);
|
|
const request = ui.requests.find(r => r.url.pathname === "/api/billing/checkout");
|
|
expect(request.payload).to.deep.equal({ planId: "author_monthly", target: "" });
|
|
expect(request.headers["x-csrf-token"]).to.equal("billing-token");
|
|
expect(ui.window.document.querySelector("#settings-billing [role=alert]").textContent).to.include("already have a subscription");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("lists purchases in plain words, grouped as active and ended", async () => {
|
|
ui = await browser("/profile?billing=portal", {
|
|
"/api/user": { username: "author" },
|
|
"/api/billing/": { enabled: true, csrf: "billing-token", portal: true, plans: [], allowance: { repositories: 5, storage: 512 },
|
|
grants: [{ planId: "author_annual", status: "active", until: "2099-01-01", cancelAtPeriodEnd: true }],
|
|
purchases: [
|
|
{ id: "a", planId: "repo_pack", repoId: "paper-repo", status: "active", until: null, purchasedAt: "2026-01-02", amount: 500, currency: "eur",
|
|
parts: [{ planId: "repo_slot", until: null, status: "active" }, { planId: "author_pass", until: "2099-01-01", status: "active" }, { planId: "repo_extend", until: "2099-01-01", status: "active" }] },
|
|
{ id: "b", planId: "repo_never", repoId: "old-repo", status: "refunded", until: null, parts: [] },
|
|
] },
|
|
"/api/billing/refresh": { ok: true, pending: false },
|
|
});
|
|
await delay(100);
|
|
const text = ui.window.document.querySelector("#settings-billing").textContent;
|
|
expect(text).to.include("Paper pack");
|
|
expect(text).to.include("Extra repository · Live updates until");
|
|
expect(text).to.include("Refunded");
|
|
expect(text).not.to.include("9999");
|
|
expect(text).not.to.include("repo:");
|
|
// Returning from the billing portal is not a pending payment.
|
|
expect(text).not.to.include("Confirming your payment");
|
|
expect(text).to.include("Renewal cancelled. Your access continues until");
|
|
expect(ui.window.document.querySelector('a[href="/anonymize/paper-repo"]')).not.to.equal(null);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("refreshes the quota panel when checkout confirmation changes the allowance", async () => {
|
|
let confirmed = false;
|
|
let quotaReads = 0;
|
|
ui = await browser("/profile?billing=success", {
|
|
"/api/user": { username: "author" },
|
|
"/api/billing/": () => ({ enabled: true, csrf: "billing-token", grants: [], plans: [], allowance: { repositories: confirmed ? 20 : 5, storage: confirmed ? 10737418240 : 2147483648 } }),
|
|
"/api/user/quota": () => { quotaReads++; return { repository: { used: 1, total: confirmed ? 20 : 5 }, storage: { used: 128, total: confirmed ? 10737418240 : 2147483648 } }; },
|
|
"/api/billing/refresh": async () => { await delay(60); confirmed = true; return { ok: true, pending: false }; },
|
|
});
|
|
await delay(150);
|
|
expect(quotaReads).to.equal(2);
|
|
expect(ui.window.document.querySelector("#settings-billing").textContent).to.include("20 repositories");
|
|
expect(ui.window.document.querySelector("#settings-quota").textContent).to.match(/\/\s*20/);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("offers payment for a pending conference without activating it in the browser", async () => {
|
|
ui = await browser("/conference/CONF", {
|
|
"/api/user": { username: "chair" },
|
|
"/api/conferences/CONF": { conferenceID: "CONF", name: "Research conference", status: "pending_payment", repositories: [], plan: { planID: "conference" } },
|
|
"/api/billing/": { enabled: true, csrf: "billing-token", grants: [], plans: [{ id: "conference", amount: 19900, currency: "eur", repositories: 100, storage: 1000 }] },
|
|
"/api/billing/checkout": { __status: 400, body: { error: "billing_invalid_conference" } },
|
|
});
|
|
const section = ui.window.document.querySelector('[aria-labelledby="conference-payment-heading"]');
|
|
expect(section.textContent).to.include("waiting for payment");
|
|
section.querySelector("button").click();
|
|
await delay(30);
|
|
expect(ui.requests.find(r => r.url.pathname === "/api/billing/checkout").payload).to.deep.equal({ planId: "conference", target: "CONF" });
|
|
expect(section.textContent).to.include("Only a conference owner");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("opens account settings without leaving the profile page", async () => {
|
|
ui = await browser("/profile", { "/api/user": { username: "owner" } });
|
|
const account = [...ui.window.document.querySelectorAll('.settings-nav button')].find(button => button.textContent.trim() === "Account");
|
|
account.click();
|
|
await delay(30);
|
|
expect(ui.window.location.pathname).to.equal("/profile");
|
|
expect(ui.window.document.querySelector("#settings-account").style.display).not.to.equal("none");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("loads and saves account email separately from defaults with CSRF", async () => {
|
|
ui = await browser("/profile", {
|
|
"/api/user": { username: "owner" },
|
|
"/github/connections/email": request => request.method === "POST"
|
|
? { notificationEmail: request.payload.email.trim() }
|
|
: { notificationEmail: "old@example.com", csrf: "profile-csrf" },
|
|
});
|
|
expect(ui.window.document.querySelector("#account-email").value).to.equal("old@example.com");
|
|
await ui.input("#account-email", "new@example.com");
|
|
ui.window.document.querySelector('.paper-account-email button[type="submit"]').click();
|
|
await delay(30);
|
|
const save = ui.requests.find(r => r.url.pathname === "/github/connections/email" && r.method === "POST");
|
|
expect(save.payload).to.deep.equal({ email: "new@example.com" });
|
|
expect(save.headers["X-CSRF-Token"]).to.equal("profile-csrf");
|
|
expect(ui.requests.filter(r => r.url.pathname === "/api/user/default" && r.method === "POST")).to.have.length(0);
|
|
expect(ui.window.document.querySelector(".paper-account-email").textContent).to.include("Email address saved.");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("retries email loading and keeps failed edits while preventing duplicate saves", async () => {
|
|
let failLoad = true, finishSave;
|
|
ui = await browser("/profile", {
|
|
"/api/user": { username: "owner" },
|
|
"/github/connections/email": request => request.method === "POST"
|
|
? new Promise(resolve => { finishSave = () => resolve({ __status: 503, body: {} }); })
|
|
: failLoad ? { __status: 503, body: {} } : { notificationEmail: "", csrf: "profile-csrf" },
|
|
});
|
|
expect(ui.window.document.querySelector("#account-email").disabled).to.equal(true);
|
|
failLoad = false;
|
|
ui.window.document.querySelector('.paper-account-email button[type="button"]').click();
|
|
await delay(30);
|
|
await ui.input("#account-email", "owner+alerts@example.com");
|
|
const form = ui.window.document.querySelector(".paper-account-email");
|
|
form.dispatchEvent(new ui.window.Event("submit", { bubbles: true, cancelable: true }));
|
|
form.dispatchEvent(new ui.window.Event("submit", { bubbles: true, cancelable: true }));
|
|
await delay(10);
|
|
expect(ui.requests.filter(r => r.url.pathname === "/github/connections/email" && r.method === "POST")).to.have.length(1);
|
|
expect(ui.window.document.querySelector("#account-email").disabled).to.equal(true);
|
|
finishSave(); await delay(30);
|
|
expect(ui.window.document.querySelector("#account-email").value).to.equal("owner+alerts@example.com");
|
|
expect(form.querySelector('[role="alert"]').textContent).to.include("Unable to save");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("prompts owners without email and saves their alert address with CSRF", async () => {
|
|
ui = await browser("/dashboard", {
|
|
"/api/user": { username: "owner", notificationEmail: "", emailNotificationsEnabled: true, showEmailPrompt: true, emailPromptCSRF: "email-csrf" },
|
|
"/github/connections": { csrf: "email-csrf", emailNotificationsEnabled: true, notificationEmail: "", resources: [] },
|
|
"/github/connections/email": request => ({ notificationEmail: request.payload.email }),
|
|
});
|
|
expect(ui.window.document.querySelector("#email-prompt").open).to.equal(true);
|
|
await ui.input("#email-prompt-address", "owner@example.com");
|
|
ui.window.document.querySelector("#email-prompt-address").closest("form")
|
|
.dispatchEvent(new ui.window.Event("submit", { bubbles: true, cancelable: true }));
|
|
await delay(30);
|
|
const saved = ui.requests.find(r => r.url.pathname === "/github/connections/email");
|
|
expect(saved.payload).to.deep.equal({ action: "save", email: "owner@example.com" });
|
|
expect(saved.headers["X-CSRF-Token"]).to.equal("email-csrf");
|
|
expect(ui.window.document.querySelector("#email-prompt")).to.equal(null);
|
|
await ui.go("/dashboard");
|
|
expect(ui.window.document.querySelector("#email-prompt")).to.equal(null);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
for (const action of ["later", "never"]) it(`dismisses the email popup with ${action} without requiring an address`, async () => {
|
|
ui = await browser("/dashboard", {
|
|
"/api/user": { username: "owner", notificationEmail: "", emailNotificationsEnabled: true, showEmailPrompt: true, emailPromptCSRF: "csrf" },
|
|
"/github/connections/email": { ok: true },
|
|
});
|
|
const text = action === "later" ? "Remind me later" : "Never";
|
|
[...ui.window.document.querySelectorAll("#email-prompt button")].find(b => b.textContent === text).click();
|
|
await delay(30);
|
|
expect(ui.requests.find(r => r.url.pathname === "/github/connections/email").payload.action).to.equal(action);
|
|
expect(ui.window.document.querySelector("#email-prompt")).to.equal(null);
|
|
});
|
|
|
|
for (const user of [
|
|
{ emailNotificationsEnabled: false, showEmailPrompt: true },
|
|
{ emailNotificationsEnabled: true, showEmailPrompt: false },
|
|
{ emailNotificationsEnabled: true, showEmailPrompt: true, notificationEmail: "owner@example.com" },
|
|
]) it(`does not show the email popup for ${JSON.stringify(user)}`, async () => {
|
|
ui = await browser("/dashboard", { "/api/user": { username: "owner", ...user } });
|
|
expect(ui.window.document.querySelector("#email-prompt")).to.equal(null);
|
|
});
|
|
|
|
it("keeps the popup open and shows an error if saving fails", async () => {
|
|
ui = await browser("/dashboard", {
|
|
"/api/user": { username: "owner", emailNotificationsEnabled: true, showEmailPrompt: true, emailPromptCSRF: "csrf" },
|
|
"/github/connections/email": { __status: 500, body: { error: "Unable to save." } },
|
|
});
|
|
await ui.input("#email-prompt-address", "owner@example.com");
|
|
ui.window.document.querySelector("#email-prompt form").dispatchEvent(new ui.window.Event("submit", { bubbles: true, cancelable: true }));
|
|
await delay(30);
|
|
expect(ui.window.document.querySelector("#email-prompt").textContent).to.include("Unable to save.");
|
|
expect(ui.window.document.querySelector("#email-prompt-address").value).to.equal("owner@example.com");
|
|
});
|
|
|
|
for (const code of ["repo_not_found", "token_expired", "github_app_reconnect_required"]) {
|
|
it(`explains ${code} without exposing the source and retries after access recovers`, async () => {
|
|
let recovered = false;
|
|
ui = await browser("/r/access-test/hello.js", {
|
|
"/api/repo/access-test/file/hello.js": () => recovered ? "recovered content" : {
|
|
__status: 404, body: { error: code, detail: "private-owner/private-source", token: "secret" },
|
|
},
|
|
});
|
|
await delay(100);
|
|
const panel = ui.window.document.querySelector(".viewer-error");
|
|
expect(panel?.textContent).to.include("GitHub access needs attention");
|
|
expect(panel.textContent).to.include("You do not need to sign in");
|
|
expect(panel.textContent).to.include("Keep the existing anonymous URL");
|
|
expect(panel.textContent).not.to.include("private-owner");
|
|
expect(panel.textContent).not.to.include("secret");
|
|
recovered = true;
|
|
panel.querySelector("button").click();
|
|
await delay(150);
|
|
expect(ui.window.document.querySelector(".viewer-error")).to.equal(null);
|
|
expect(ui.requests.filter(request => request.url.pathname.endsWith("/file/hello.js"))).to.have.length(2);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
}
|
|
|
|
it("offers a safe retry for an unrecognized gateway response", async () => {
|
|
ui = await browser("/r/access-test/hello.js", {
|
|
"/api/repo/access-test/options": { __status: 403, body: "<html>private diagnostic</html>" },
|
|
});
|
|
await delay(100);
|
|
const panel = ui.window.document.querySelector(".viewer-error");
|
|
expect(panel?.textContent).to.include("We could not load this content");
|
|
expect(panel.textContent).not.to.include("private diagnostic");
|
|
expect(panel.textContent).not.to.include("GitHub access needs attention");
|
|
expect(panel.querySelector("button").textContent).to.equal("Try again");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
const repositorySource = {
|
|
"/api/user": { username: "owner" },
|
|
"/api/user/default": { terms: ["Default Author"], options: {} },
|
|
"/api/repo/owner/repo/": { defaultBranch: "main", repo: "repo", hasPage: false },
|
|
"/api/repo/owner/repo/branches": [{ name: "main", commit: "abcdef123" }],
|
|
"/api/repo/owner/repo/readme": "# Private Author",
|
|
"/api/anonymize-preview": request => ({ content: request.payload.content.replaceAll("Private Author", "MASKED") }),
|
|
};
|
|
|
|
const taskBilling = { enabled: true, csrf: "token", allowance: { plan: "free", repositories: 5 }, plans: [
|
|
{ id: "author_monthly", amount: 500, currency: "eur", interval: "month", repositories: 20, storage: 10737418240, description: "Renews monthly." },
|
|
] };
|
|
it("saves the repository draft before contextual checkout and preserves a failed checkout", async () => {
|
|
ui = await browser("/anonymize", { ...repositorySource,
|
|
"/api/billing/": taskBilling, "/api/user/quota": { repository: { used: 5, total: 5 } },
|
|
"/api/billing/draft": { id: "saved-task" },
|
|
"/api/billing/checkout": { __status: 503, body: { error: "billing_unavailable" } },
|
|
});
|
|
const state = ui.window.document.querySelector("#sourceUrl")._field.binding.state;
|
|
state.sourceUrl = "https://github.com/owner/repo";
|
|
await state.urlSelected(); await delay(60);
|
|
await ui.input("#terms", "Uncommitted redaction");
|
|
await state.checkoutRepository();
|
|
const saved = ui.requests.find(r => r.method === "POST" && r.url.pathname === "/api/billing/draft");
|
|
expect(saved.payload.path).to.equal("/anonymize");
|
|
expect(saved.payload.values.terms).to.equal("Uncommitted redaction");
|
|
const checkout = ui.requests.find(r => r.url.pathname === "/api/billing/checkout");
|
|
expect(checkout.payload.draftId).to.equal("saved-task");
|
|
expect(state.terms).to.equal("Uncommitted redaction");
|
|
expect(state.billingError).to.include("not available");
|
|
expect(ui.requests.some(r => r.method === "POST" && r.url.pathname === "/api/repo/")).to.equal(false);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
for (const rejected of [false, true]) {
|
|
it(`attempts server admission with reported full quota (${rejected ? "rejected" : "accepted"})`, async () => {
|
|
ui = await browser("/anonymize", { ...repositorySource,
|
|
"/api/billing/": taskBilling, "/api/user/quota": { repository: { used: 5, total: 5 } },
|
|
"/api/repo/": rejected ? { __status: 403, body: { error: "quota_exceeded" } } : {},
|
|
});
|
|
const doc = ui.window.document;
|
|
const state = doc.querySelector("#sourceUrl")._field.binding.state;
|
|
state.sourceUrl = "https://github.com/owner/repo";
|
|
await state.urlSelected(); await delay(60);
|
|
await ui.input("#terms", "Private Author");
|
|
expect(state.hostingNeedsPayment()).to.equal(false);
|
|
doc.querySelector(".anonymize-submit-bar button").click(); await delay(80);
|
|
expect(ui.requests.filter(r => r.method === "POST" && r.url.pathname === "/api/repo/")).to.have.length(1);
|
|
expect(ui.requests.some(r => r.url.pathname === "/api/billing/checkout" || r.url.pathname === "/api/billing/draft")).to.equal(false);
|
|
expect(state.hostingNeedsPayment()).to.equal(rejected);
|
|
if (rejected) expect(doc.querySelector(".anonymize-submit-bar").textContent).to.include("Continue to checkout");
|
|
});
|
|
}
|
|
it("restores a paid checkout task without submitting it and rechecks the allowance", async () => {
|
|
ui = await browser("/anonymize?billingDraft=saved-task&billing=success", { ...repositorySource,
|
|
"/api/billing/": { ...taskBilling, allowance: { plan: "author_monthly", repositories: 20 } },
|
|
"/api/billing/refresh": { ok: true, pending: false },
|
|
"/api/billing/draft/saved-task": { id: "saved-task", path: "/anonymize", values: { sourceUrl: "https://github.com/owner/repo", terms: "Restored Author", repoId: "saved-repo", source: { branch: "main" }, options: { expirationMode: "never", image: false, pdf: false }, conference: "paid-conf" } },
|
|
"/api/conferences/paid-conf": { endDate: "2027-05-01", options: { image: true, pdf: true } },
|
|
});
|
|
await delay(150);
|
|
expect(ui.window.document.querySelector("#sourceUrl")._field.binding.state.terms).to.equal("Restored Author");
|
|
expect(ui.window.document.querySelector("#sourceUrl")._field.binding.state.repoId).to.equal("saved-repo");
|
|
expect(ui.window.document.querySelector("#sourceUrl")._field.binding.state.options).to.include({ expirationMode: "never", image: false, pdf: false });
|
|
expect(ui.app.router.currentRoute.value.query).to.deep.equal({});
|
|
expect(ui.window.document.querySelector("#sourceUrl")._field.binding.state.hostingNeedsPayment()).to.equal(false);
|
|
expect(ui.window.document.querySelector("#repository-hosting").textContent).to.include("subscription is active");
|
|
expect(ui.requests.some(r => r.method === "POST" && r.url.pathname === "/api/repo/")).to.equal(false);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
it("keeps all settings and the live preview visible in the split workspace", async () => {
|
|
ui = await browser("/anonymize", { ...repositorySource, "/api/billing/": taskBilling,
|
|
"/api/user/quota": { repository: { used: 5, total: 5 } } });
|
|
const doc = ui.window.document;
|
|
const state = doc.querySelector("#sourceUrl")._field.binding.state;
|
|
state.sourceUrl = "https://github.com/owner/repo";
|
|
await state.urlSelected(); await delay(60);
|
|
expect(doc.querySelector('.repository-task')).to.equal(null);
|
|
for (const selector of ['#sourceUrl', '#terms', '#image', '#conference', '#expirationDate']) {
|
|
expect(doc.querySelector(selector).closest('details')).to.equal(null);
|
|
}
|
|
state.terms = "Private Author";
|
|
await delay(450);
|
|
expect(doc.querySelector('.anonymize-preview-body').textContent).to.include("MASKED");
|
|
expect(doc.querySelector('.anonymize-preview-body').textContent).not.to.include("Private Author");
|
|
state.options.expirationMode = "never"; await delay(20);
|
|
expect(doc.querySelector('.hosting-options').textContent).to.include("20 repositories");
|
|
expect(doc.querySelector('.anonymize-submit-bar').textContent).to.include("Continue to checkout");
|
|
doc.querySelector('.reviewer-preview .rp-open').click(); await delay(10);
|
|
expect(doc.querySelector('.anonymize-page').classList.contains('preview-expanded')).to.equal(true);
|
|
doc.querySelector('.reviewer-preview .rp-open').click(); await delay(10);
|
|
expect(state.terms).to.equal("Private Author");
|
|
expect(ui.requests.some(r => r.method === "POST" && r.url.pathname === "/api/repo/")).to.equal(false);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("keeps checkout failures and pending payment in the editor without losing input", async () => {
|
|
ui = await browser("/anonymize", { ...repositorySource, "/api/billing/": taskBilling,
|
|
"/api/user/quota": { repository: { used: 5, total: 5 } },
|
|
"/api/billing/draft": { id: "saved-task" },
|
|
"/api/billing/checkout": { __status: 503, body: { error: "billing_unavailable" } } });
|
|
const doc = ui.window.document;
|
|
const state = doc.querySelector("#sourceUrl")._field.binding.state;
|
|
state.sourceUrl = "https://github.com/owner/repo";
|
|
await state.urlSelected(); await delay(60);
|
|
expect(doc.querySelector("#expirationDate").value).to.match(/^\d{4}-\d{2}-\d{2}$/);
|
|
state.options.expirationMode = "never"; await delay(20);
|
|
await ui.input("#terms", "Preserved Author");
|
|
doc.querySelector(".anonymize-submit-bar button").click(); await delay(80);
|
|
expect(doc.querySelector("#repository-hosting").textContent).to.include("Payments are not available");
|
|
expect(state.terms).to.equal("Preserved Author");
|
|
expect(ui.requests.find(r => r.url.pathname === "/api/billing/draft").payload.values.terms).to.equal("Preserved Author");
|
|
state.billingPending = true; await delay(20);
|
|
expect(doc.querySelector("#repository-hosting").textContent).to.include("Confirming your payment");
|
|
expect(doc.querySelector(".subscription-choice")).to.equal(null);
|
|
expect(doc.querySelector(".anonymize-submit-bar button").textContent).to.equal("Check payment status");
|
|
const checkoutCount = ui.requests.filter(r => r.url.pathname === "/api/billing/checkout").length;
|
|
await state.submitRepository({});
|
|
expect(ui.requests.filter(r => r.url.pathname === "/api/billing/checkout")).to.have.length(checkoutCount);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
for (const status of ["preparing", "ready", "error"]) {
|
|
it(`keeps the task summary through repository ${status} without a premature ready link`, async () => {
|
|
ui = await browser("/status/saved", { "/api/repo/saved": { repoId: "saved", status,
|
|
source: { fullName: "owner/repo" }, options: { expirationMode: "never" } } });
|
|
const doc = ui.window.document;
|
|
expect(doc.querySelector('.task-trail [aria-current="step"]').textContent).to.include("Review link");
|
|
expect(doc.querySelector(".task-summary").textContent).to.include("owner/repo");
|
|
expect(!!doc.querySelector(".task-ready")).to.equal(status === "ready");
|
|
expect(!!doc.querySelector('.task-preparation a[href="/r/saved/"]')).to.equal(status === "ready");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
}
|
|
|
|
it("opens Checkout for the subscription cycle the hosting option shows", async () => {
|
|
const billing = { ...taskBilling, plans: [...taskBilling.plans,
|
|
{ id: "author_annual", amount: 4900, currency: "eur", interval: "year", repositories: 20, storage: 10737418240, description: "Renews yearly." }] };
|
|
ui = await browser("/anonymize", { ...repositorySource, "/api/billing/": billing });
|
|
await delay(20);
|
|
const state = ui.window.document.querySelector("#sourceUrl")._field.binding.state; state.detectedType = "repo";
|
|
state.hostingQuotaBlocked = true;
|
|
expect(state.hostingOneTimePlans()).to.deep.equal([]);
|
|
expect(state.hostingSubscriptionPlan().id).to.equal("author_annual");
|
|
expect(state.hostingChoice()).to.equal("author_annual");
|
|
expect(state.selectedHostingPlan().id).to.equal("author_annual");
|
|
});
|
|
|
|
it("leaves gist and pull request expiration dates outside the repository billing limit", async () => {
|
|
ui = await browser("/anonymize", { ...repositorySource, "/api/billing/": taskBilling });
|
|
await delay(20);
|
|
const state = ui.window.document.querySelector("#sourceUrl")._field.binding.state;
|
|
const later = new Date(); later.setMonth(later.getMonth() + 10);
|
|
state.detectedType = "gist";
|
|
state.options.expirationDate = new ui.window.Date(later.getTime());
|
|
await delay(20);
|
|
expect(state.options.expirationDate.getTime()).to.equal(later.getTime());
|
|
expect(new Date(state.maxExpirationDate).getTime()).to.be.greaterThan(later.getTime());
|
|
state.detectedType = "repo";
|
|
await delay(20);
|
|
expect(state.options.expirationDate.getTime()).to.be.lessThan(later.getTime());
|
|
});
|
|
|
|
it("keeps free, conference and coauthor work out of author checkout", async () => {
|
|
ui = await browser("/anonymize", { ...repositorySource, "/api/billing/": taskBilling });
|
|
const state = ui.window.document.querySelector("#sourceUrl")._field.binding.state; state.detectedType = "repo";
|
|
state.hostingQuota = { repository: { used: 1, total: 5 } };
|
|
expect(state.hostingNeedsPayment()).to.equal(false);
|
|
state.hostingQuota.repository.used = 5;
|
|
expect(state.hostingNeedsPayment()).to.equal(false);
|
|
state.hostingQuotaBlocked = true;
|
|
expect(state.hostingNeedsPayment()).to.equal(true);
|
|
state.conference = "CONF";
|
|
state.conference_data = { name: "Unpaid", hosting: false };
|
|
expect(state.hostingNeedsPayment()).to.equal(true);
|
|
state.conference_data = { name: "Paid", hosting: true };
|
|
expect(state.hostingNeedsPayment()).to.equal(false);
|
|
state.options.expirationMode = "never";
|
|
expect(state.hostingNeedsPayment()).to.equal(true);
|
|
state.isUpdate = true; state.role = "coauthor";
|
|
expect(state.hostingNeedsPayment()).to.equal(false);
|
|
});
|
|
it("refreshes the conference in place after checkout confirmation", async () => {
|
|
let confirmed = false;
|
|
ui = await browser("/conference/CONF?billing=success", { "/api/user": { username: "chair" },
|
|
"/api/billing/": taskBilling,
|
|
"/api/billing/refresh": () => { confirmed = true; return { ok: true, pending: false }; },
|
|
"/api/conferences/CONF": () => ({ conferenceID: "CONF", name: "Conference", status: confirmed ? "ready" : "pending_payment", repositories: [] }),
|
|
});
|
|
await delay(80);
|
|
expect(ui.window.document.body.textContent).to.include("Ready");
|
|
expect(ui.window.document.querySelector('[aria-labelledby="conference-payment-heading"]')).to.equal(null);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("suggests opening existing configurations for the same repository URL without copying settings", async () => {
|
|
ui = await browser("/anonymize", {
|
|
...repositorySource,
|
|
"/api/user/anonymized_repositories": [
|
|
{ repoId: "older", source: { fullName: "Owner/Repo" }, options: { terms: ["Old Author"] }, anonymizeDate: "2025-01-01" },
|
|
{ repoId: "newer", source: { fullName: "owner/repo" }, options: { terms: ["New Author"] }, anonymizeDate: "2026-01-01" },
|
|
{ repoId: "unrelated", source: { fullName: "owner/other" }, options: {} },
|
|
],
|
|
});
|
|
const state = ui.window.document.querySelector("#sourceUrl")._field.binding.state;
|
|
state.sourceUrl = "https://github.com/owner/repo.git/";
|
|
await state.urlSelected();
|
|
await delay(30);
|
|
const suggestion = ui.window.document.querySelector('[aria-label="Previous repository configuration"]');
|
|
expect(suggestion.textContent).to.include("avoid creating another repository");
|
|
expect([...suggestion.querySelectorAll("option")].map(option => option.value)).to.deep.equal(["newer", "older"]);
|
|
expect(suggestion.querySelector("a").getAttribute("href")).to.equal("/anonymize/newer");
|
|
await ui.input("#previousConfiguration", "older", "change");
|
|
expect(suggestion.querySelector("a").getAttribute("href")).to.equal("/anonymize/older");
|
|
expect(state.terms).to.equal("Default Author");
|
|
expect(state.isUpdate).to.equal(false);
|
|
suggestion.querySelector("button").click();
|
|
await delay(10);
|
|
expect(ui.window.document.querySelector('[aria-label="Previous repository configuration"]')).to.equal(null);
|
|
expect(ui.requests.filter(request => request.method === "POST" && request.url.pathname === "/api/repo/")).to.have.length(0);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("opens the existing configuration from the suggestion without creating a repository", async () => {
|
|
const existing = { repoId: "existing-review", status: "ready", role: "owner",
|
|
source: { fullName: "owner/repo", branch: "main", commit: "abcdef123" },
|
|
options: { terms: ["Existing Author"], expirationMode: "never" } };
|
|
ui = await browser("/anonymize", {
|
|
...repositorySource,
|
|
"/api/user/anonymized_repositories": [existing],
|
|
"/api/repo/existing-review": existing,
|
|
});
|
|
const sourceInput = await ui.input("#sourceUrl-landing", "https://github.com/owner/repo");
|
|
sourceInput.dispatchEvent(new ui.window.Event("blur"));
|
|
await delay(250);
|
|
const link = ui.window.document.querySelector('[aria-label="Previous repository configuration"] a');
|
|
expect(link).not.to.equal(null);
|
|
link.click();
|
|
await delay(250);
|
|
const state = ui.window.document.querySelector("#sourceUrl")._field.binding.state;
|
|
expect(ui.window.location.pathname).to.equal("/anonymize/existing-review");
|
|
expect(state.isUpdate).to.equal(true);
|
|
expect(state.repoId).to.equal("existing-review");
|
|
expect(state.terms).to.equal("Existing Author");
|
|
expect(state.source.commit).to.equal("abcdef123");
|
|
expect(ui.window.document.querySelector('[aria-label="Previous repository configuration"]')).to.equal(null);
|
|
expect(ui.requests.filter(request => request.method === "POST" && request.url.pathname.startsWith("/api/repo"))).to.have.length(0);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("ignores delayed duplicate suggestions after switching to an invalid URL", async () => {
|
|
let finishLookup;
|
|
ui = await browser("/anonymize", {
|
|
...repositorySource,
|
|
"/api/user/anonymized_repositories": () => new Promise(resolve => {
|
|
finishLookup = () => resolve([{ repoId: "existing", source: { fullName: "owner/repo" }, options: {} }]);
|
|
}),
|
|
});
|
|
const state = ui.window.document.querySelector("#sourceUrl")._field.binding.state;
|
|
state.sourceUrl = "https://github.com/owner/repo";
|
|
await state.urlSelected();
|
|
state.sourceUrl = "invalid";
|
|
await state.urlSelected();
|
|
finishLookup();
|
|
await delay(30);
|
|
expect(state.previousConfigurations).to.have.length(0);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("allows creation when duplicate lookup fails", async () => {
|
|
ui = await browser("/anonymize", {
|
|
...repositorySource,
|
|
"/api/user/anonymized_repositories": { __status: 503, body: { error: "unavailable" } },
|
|
});
|
|
const state = ui.window.document.querySelector("#sourceUrl")._field.binding.state;
|
|
state.sourceUrl = "https://github.com/owner/repo";
|
|
await state.urlSelected();
|
|
await delay(30);
|
|
expect(state.previousConfigurations).to.have.length(0);
|
|
expect(state.details.repo).to.equal("repo");
|
|
expect(state.sourceLoading).to.equal(false);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("keeps the current repository when earlier metadata and README responses arrive late", async () => {
|
|
let finishMetadata, finishReadme;
|
|
ui = await browser("/anonymize", {
|
|
...repositorySource,
|
|
"/api/repo/owner/old/": () => new Promise(resolve => { finishMetadata = () => resolve({ repo: "old", id: "old-id", defaultBranch: "old-main", hasPage: true }); }),
|
|
"/api/repo/owner/old/readme": () => new Promise(resolve => { finishReadme = () => resolve("OLD PRIVATE README"); }),
|
|
"/api/repo/owner/repo/": { repo: "repo", id: "new-id", defaultBranch: "main", hasPage: false },
|
|
"/api/anonymize-preview": request => ({ content: request.payload.content }),
|
|
});
|
|
const state = ui.window.document.querySelector("#sourceUrl")._field.binding.state;
|
|
state.sourceUrl = "https://github.com/owner/old";
|
|
const oldLoad = state.urlSelected();
|
|
await delay(10);
|
|
state.sourceUrl = "https://github.com/owner/repo";
|
|
await state.urlSelected();
|
|
finishMetadata(); finishReadme(); await oldLoad;
|
|
await delay(240);
|
|
expect(state.details.repo).to.equal("repo");
|
|
expect(state.repositoryID).to.equal("new-id");
|
|
expect(state.source.branch).to.equal("main");
|
|
expect(ui.window.document.querySelector(".anonymize-preview-body").textContent).to.include("Private Author");
|
|
expect(ui.window.document.querySelector(".anonymize-preview-body").textContent).not.to.include("OLD PRIVATE README");
|
|
expect(state.sourceLoading).to.equal(false);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("ignores old branches and source errors after changing repositories", async () => {
|
|
let finishBranches;
|
|
ui = await browser("/anonymize", {
|
|
...repositorySource,
|
|
"/api/repo/owner/old/": { repo: "old", defaultBranch: "old-main" },
|
|
"/api/repo/owner/old/readme": "",
|
|
"/api/repo/owner/old/branches": () => new Promise(resolve => { finishBranches = () => resolve({ __status: 404, body: { error: "repo_not_found" } }); }),
|
|
});
|
|
const state = ui.window.document.querySelector("#sourceUrl")._field.binding.state;
|
|
state.sourceUrl = "https://github.com/owner/old"; const oldLoad = state.urlSelected();
|
|
await delay(10);
|
|
state.sourceUrl = "https://github.com/owner/repo"; await state.urlSelected();
|
|
finishBranches(); await oldLoad;
|
|
expect(state.branches.map(b => b.name)).to.deep.equal(["main"]);
|
|
expect(state.anonymize.sourceUrl.invalid).to.equal(false);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("reloads a source when the default GitHub connection arrives after source loading begins", async () => {
|
|
let finishConnections, finishOldMetadata;
|
|
ui = await browser("/anonymize", {
|
|
...repositorySource,
|
|
"/github/connections": () => new Promise(resolve => { finishConnections = () => resolve({ appEnabled: true, appConnected: true }); }),
|
|
"/api/repo/owner/repo/": request => request.url.searchParams.get("connection") === "github-app"
|
|
? { repo: "repo", id: "app-id", defaultBranch: "main" }
|
|
: new Promise(resolve => { finishOldMetadata = () => resolve({ repo: "old", id: "old-id", defaultBranch: "old-main" }); }),
|
|
});
|
|
const state = ui.window.document.querySelector("#sourceUrl")._field.binding.state;
|
|
state.sourceUrl = "https://github.com/owner/repo";
|
|
const oldLoad = state.urlSelected();
|
|
await delay(10);
|
|
finishConnections();
|
|
await delay(50);
|
|
finishOldMetadata(); await oldLoad;
|
|
expect(state.githubConnection).to.equal("github-app");
|
|
expect(state.repositoryID).to.equal("app-id");
|
|
expect(state.source.branch).to.equal("main");
|
|
expect(state.sourceLoading).to.equal(false);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
for (const kind of ["pr", "gist"]) {
|
|
it(`ignores late ${kind} metadata after switching sources`, async () => {
|
|
let finish;
|
|
const oldPath = kind === "pr" ? "/api/pr/owner/old/42" : "/api/gist/source/abc123";
|
|
ui = await browser("/anonymize", { ...repositorySource, [oldPath]: () => new Promise(resolve => { finish = () => resolve(kind === "pr" ? { pullRequest: { title: "OLD PR" } } : { gist: { description: "OLD GIST" } }); }) });
|
|
const state = ui.window.document.querySelector("#sourceUrl")._field.binding.state;
|
|
state.sourceUrl = kind === "pr" ? "https://github.com/owner/old/pull/42" : "https://gist.github.com/abc123";
|
|
const oldLoad = state.urlSelected(); await delay(10);
|
|
state.sourceUrl = "https://github.com/owner/repo"; await state.urlSelected();
|
|
finish(); await oldLoad;
|
|
expect(state.details.repo).to.equal("repo");
|
|
expect(state.detectedType).to.equal("repo");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
}
|
|
|
|
it("preserves and restores edits made while defaults are saving", async () => {
|
|
let finishSave;
|
|
ui = await browser("/profile", { ...repositorySource, "/api/user/default": request => request.method === "POST"
|
|
? new Promise(resolve => { finishSave = () => resolve({}); })
|
|
: { terms: ["Original"], options: {} } });
|
|
await ui.input("#terms", "Submitted terms");
|
|
ui.window.document.querySelector("#save").click(); await delay(10);
|
|
await ui.input("#terms", "Newer unsaved terms"); await delay(270);
|
|
finishSave(); await delay(30);
|
|
expect(ui.window.document.querySelector("#terms").value).to.equal("Newer unsaved terms");
|
|
expect(JSON.parse(ui.window.sessionStorage.getItem("form-draft:profile")).values.terms).to.equal("Newer unsaved terms");
|
|
expect(ui.window.document.querySelector("#save").textContent).not.to.equal("Saved");
|
|
await ui.go("/dashboard"); await ui.go("/profile");
|
|
expect(ui.window.document.querySelector("#terms").value).to.equal("Newer unsaved terms");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("discards a restored draft and cancels pending textarea edits", async () => {
|
|
ui = await browser("/profile", repositorySource, { "form-draft:profile": { account: "owner", savedAt: Date.now(), values: { terms: "Restored draft", options: {} } } });
|
|
await ui.input("#terms", "Pending edit");
|
|
ui.window.document.querySelector(".draft-discard").click(); await delay(300);
|
|
expect(ui.window.document.querySelector("#terms").value).to.equal("Default Author");
|
|
expect(ui.window.sessionStorage.getItem("form-draft:profile")).to.equal(null);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("uses a readable fallback for unknown settings errors", async () => {
|
|
ui = await browser("/profile", { ...repositorySource, "/api/user/default": request => request.method === "POST" ? { __status: 503, body: { error: "untranslated_failure" } } : { terms: [], options: {} } });
|
|
// Save is disabled on a clean form, so make a change first.
|
|
await ui.input("#terms", "Changed Author");
|
|
ui.window.document.querySelector("#save").click(); await delay(30);
|
|
expect(ui.window.document.querySelector('[role="alert"]').textContent).to.include("Unable to save your defaults");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("shows conference load failures and retries without a false empty state", async () => {
|
|
let fail = true;
|
|
ui = await browser("/conferences", { "/api/user": { username: "owner" }, "/api/conferences/": () => fail ? { __status: 503, body: { error: "unavailable" } } : [{ conferenceID: "ICSE26", name: "ICSE 2026", status: "ready" }] });
|
|
expect(ui.window.document.body.textContent).to.include("Unable to load your conferences");
|
|
expect(ui.window.document.body.textContent).not.to.include("You have not created a conference yet");
|
|
fail = false; ui.window.document.querySelector(".conference-feedback button").click(); await delay(30);
|
|
await ui.input("#search", " icse ");
|
|
expect(ui.window.document.querySelector(".repo-name").textContent).to.equal("ICSE 2026");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("prevents duplicate conference saves and keeps a visible failure message", async () => {
|
|
let finishSave;
|
|
ui = await browser("/conference/new", { "/api/user": { username: "owner" }, "/api/conferences/": () => new Promise(resolve => { finishSave = () => resolve({ __status: 503, body: { error: "unavailable" } }); }) });
|
|
await ui.input("#name", "Test venue"); await ui.input("#conferenceID", "TEST26");
|
|
const button = ui.window.document.querySelector("#send"); button.click(); button.click(); await delay(10);
|
|
expect(button.disabled).to.equal(true);
|
|
expect(ui.requests.filter(r => r.method === "POST" && r.url.pathname === "/api/conferences/")).to.have.length(1);
|
|
finishSave(); await delay(30);
|
|
expect(button.disabled).to.equal(false);
|
|
expect(ui.window.document.querySelector('[role="alert"]').textContent).to.include("Unable to save the conference");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("keeps an enabled PR preview selected and supports arrow-key navigation", async () => {
|
|
ui = await browser("/pull-request-anonymize/saved", {
|
|
...repositorySource,
|
|
"/api/pr/saved": { source: { repositoryFullName: "owner/repo", pullRequestId: 42 }, options: { terms: [] } },
|
|
"/api/pr/owner/repo/42": { pullRequest: { title: "Example", diff: "diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -1 +1 @@\n-old\n+new", comments: [{ author: "Reviewer", body: "Visible comment" }] } },
|
|
"/api/anonymize-preview": request => ({ contents: request.payload.contents }),
|
|
});
|
|
await delay(250);
|
|
ui.window.document.querySelector("#diff").click(); await delay(240);
|
|
expect(ui.window.document.querySelector("#preview-pr-comments-tab").getAttribute("aria-selected")).to.equal("true");
|
|
expect(ui.window.document.querySelector("#preview-pr-comments-panel").textContent).to.include("Visible comment");
|
|
ui.window.document.querySelector("#diff").click(); await delay(240);
|
|
ui.window.document.querySelector("#preview-pr-comments-tab").dispatchEvent(new ui.window.KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true })); await delay(10);
|
|
expect(ui.window.document.activeElement.id).to.equal("preview-pr-diff-tab");
|
|
expect(ui.window.document.querySelector("#preview-pr-diff-tab").getAttribute("aria-selected")).to.equal("true");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("keeps focus during partial URL entry and transfers it after a valid URL", async () => {
|
|
ui = await browser("/anonymize", repositorySource);
|
|
const landing = ui.window.document.querySelector("#sourceUrl-landing");
|
|
landing.focus();
|
|
await ui.input("#sourceUrl-landing", "h");
|
|
await delay(1050);
|
|
expect(ui.window.document.activeElement).to.equal(landing);
|
|
expect(ui.window.document.querySelector(".anonymize-workspace").style.display).to.equal("none");
|
|
await ui.input("#sourceUrl-landing", "https://github.com/owner/repo");
|
|
landing.dispatchEvent(new ui.window.Event("blur"));
|
|
await delay(40);
|
|
expect(ui.window.document.activeElement.id).to.equal("sourceUrl");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("keeps custom redactions, identifiers and commits when correcting the same source URL", async () => {
|
|
ui = await browser("/anonymize", repositorySource);
|
|
const source = await ui.input("#sourceUrl", "https://github.com/owner/repo");
|
|
source.dispatchEvent(new ui.window.Event("blur"));
|
|
await delay(40);
|
|
await ui.input("#terms", "Private Author");
|
|
await delay(270);
|
|
const id = await ui.input("#repoId", "chosen-id");
|
|
id.dispatchEvent(new ui.window.Event("blur"));
|
|
await ui.input("#commit", "123456abcdef");
|
|
await ui.input("#sourceUrl", "https://github.com/owner/repo/");
|
|
source.dispatchEvent(new ui.window.Event("blur"));
|
|
await delay(50);
|
|
expect(ui.window.document.querySelector("#terms").value).to.equal("Private Author");
|
|
expect(ui.window.document.querySelector("#repoId").value).to.equal("chosen-id");
|
|
expect(ui.window.document.querySelector("#commit").value).to.equal("123456abcdef");
|
|
expect(ui.window.document.querySelector(".anonymize-form-col").textContent).not.to.include("Switch the branch above");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
for (const kind of ["repo", "pr", "gist"]) {
|
|
it(`hides original ${kind} content after a preview failure and supports retry`, async () => {
|
|
let fail = true;
|
|
const route = { repo: "/anonymize/saved", pr: "/pull-request-anonymize/saved", gist: "/gist-anonymize/saved" }[kind];
|
|
const fixtures = {
|
|
...repositorySource,
|
|
"/api/repo/saved": { source: { fullName: "owner/repo", branch: "main", commit: "abcdef123" }, options: { terms: ["Private Author"] } },
|
|
"/api/pr/saved": { source: { repositoryFullName: "owner/repo", pullRequestId: 42 }, options: { terms: ["Private Author"] } },
|
|
"/api/pr/owner/repo/42": { pullRequest: { title: "Private Author", body: "Private Author body", comments: [] } },
|
|
"/api/gist/saved": { source: { gistId: "abc123" }, options: { terms: ["Private Author"] } },
|
|
"/api/gist/source/abc123": { gist: { description: "Private Author", files: [{ filename: "code.txt", content: "Private Author" }], comments: [] } },
|
|
"/api/anonymize-preview": request => {
|
|
if (fail) return { __status: 503, body: { error: "unavailable" } };
|
|
const mask = value => value.replaceAll("Private Author", "MASKED");
|
|
return request.payload.contents ? { contents: request.payload.contents.map(mask) } : { content: mask(request.payload.content) };
|
|
},
|
|
};
|
|
ui = await browser(route, fixtures);
|
|
await delay(280);
|
|
const preview = () => ui.window.document.querySelector(".anonymize-preview-col");
|
|
expect(preview().textContent).to.include("Unable to generate");
|
|
expect(preview().textContent).not.to.include("Private Author");
|
|
expect(preview().textContent).not.to.include("redactions applied");
|
|
fail = false;
|
|
[...preview().querySelectorAll("button")].find(b => b.textContent === "Retry preview").click();
|
|
await delay(260);
|
|
expect(preview().textContent).to.include("MASKED");
|
|
expect(preview().querySelector(".anonymize-preview-body")).not.to.equal(null);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
}
|
|
|
|
it("ignores an old preview response while new redactions are waiting for the debounce", async () => {
|
|
let finishOld;
|
|
let count = 0;
|
|
ui = await browser("/pull-request-anonymize/saved", {
|
|
"/api/user": { username: "owner" },
|
|
"/api/pr/saved": { source: { repositoryFullName: "owner/repo", pullRequestId: 42 }, options: { terms: ["Alice"] } },
|
|
"/api/pr/owner/repo/42": { pullRequest: { title: "Alice", comments: [] } },
|
|
"/api/anonymize-preview": () => ++count === 1
|
|
? new Promise(resolve => { finishOld = () => resolve({ contents: ["OLD PREVIEW"] }); })
|
|
: { contents: ["LATEST PREVIEW"] },
|
|
});
|
|
await delay(240);
|
|
await ui.input("#terms", "Bob");
|
|
await delay(270);
|
|
finishOld();
|
|
await delay(20);
|
|
const preview = () => ui.window.document.querySelector(".anonymize-preview-col").textContent;
|
|
expect(preview()).not.to.include("OLD PREVIEW");
|
|
expect(preview()).to.include("Updating");
|
|
await delay(230);
|
|
expect(preview()).to.include("LATEST PREVIEW");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("offers a paid review package per venue size for a new conference", async () => {
|
|
ui = await browser("/conference/new", {
|
|
"/api/user": { username: "owner" },
|
|
"/api/conferences/plans": [
|
|
{ id: "conference_workshop", name: "Workshop", pricePerRepo: 0, repositories: 25, storagePerRepo: 10737418240 },
|
|
{ id: "conference", name: "Conference", pricePerRepo: 0, repositories: 100, storagePerRepo: 53687091200 },
|
|
{ id: "conference_large", name: "Large conference", pricePerRepo: 0, repositories: 400, storagePerRepo: 214748364800 }],
|
|
"/api/billing/": { enabled: true, grants: [], plans: [
|
|
{ id: "conference_workshop", amount: 4900, currency: "eur", repositories: 25, storage: 10737418240 },
|
|
{ id: "conference", amount: 14900, currency: "eur", repositories: 100, storage: 53687091200 },
|
|
{ id: "conference_large", amount: 39900, currency: "eur", repositories: 400, storage: 214748364800 }] },
|
|
});
|
|
await delay(30);
|
|
const doc = ui.window.document;
|
|
const tiers = [...doc.querySelectorAll(".conference-tier")];
|
|
expect(tiers.map(t => t.querySelector(".conference-tier-head strong").textContent)).to.deep.equal(["Workshop", "Conference", "Large conference"]);
|
|
expect(tiers[0].textContent).to.include("49").and.include("Up to 25 papers");
|
|
expect(doc.querySelector('input[name="planPicker"]:checked').value).to.equal("conference");
|
|
expect(doc.querySelector("#conf-plan").textContent).to.not.include("Free");
|
|
doc.querySelector('input[value="conference_workshop"]').click();
|
|
await delay(20);
|
|
expect(tiers[0].classList.contains("is-selected")).to.equal(true);
|
|
await ui.input("#name", "Review venue");
|
|
await ui.input("#conferenceID", "REVIEW26");
|
|
doc.querySelector("#send").click();
|
|
await delay(30);
|
|
const request = ui.requests.find(r => r.method === "POST" && r.url.pathname === "/api/conferences/");
|
|
expect(request?.payload.plan.planID).to.equal("conference_workshop");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("submits billing details for a new paid conference", async () => {
|
|
ui = await browser("/conference/new", {
|
|
"/api/user": { username: "owner" },
|
|
"/api/conferences/plans": [{ id: "free_conference", name: "Free", pricePerRepo: 0 }, { id: "premium_conference", name: "Premium", pricePerRepo: 0.5 }],
|
|
});
|
|
await ui.input("#name", "Review venue");
|
|
await ui.input("#conferenceID", "REVIEW26");
|
|
ui.window.document.querySelector('input[value="premium_conference"]').click();
|
|
await delay(20);
|
|
for (const [id, value] of Object.entries({ billing_name: "Jane Smith", email: "jane@example.org", inputAddress: "12 Main St", city: "Paris", country: "France", zip: "75001" })) await ui.input("#" + id, value);
|
|
expect(ui.window.document.querySelector("#billing_name").value).to.equal("Jane Smith");
|
|
ui.window.document.querySelector("#send").click();
|
|
await delay(30);
|
|
const request = ui.requests.find(r => r.method === "POST" && r.url.pathname === "/api/conferences/");
|
|
expect(request?.payload.billing).to.include({ name: "Jane Smith", email: "jane@example.org", city: "Paris" });
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("opens FAQ fragments on direct load and subsequent navigation", async () => {
|
|
ui = await browser("/faq#formats");
|
|
await delay(370);
|
|
expect(ui.window.document.querySelector("#formats").classList.contains("show")).to.equal(true);
|
|
expect(ui.window.document.activeElement.id).to.equal("headingFormats");
|
|
await ui.go("/faq#permissions");
|
|
await delay(370);
|
|
expect(ui.window.document.querySelector("#permissions").classList.contains("show")).to.equal(true);
|
|
expect(ui.window.document.activeElement.id).to.equal("headingPermissions");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
for (const page of ["profile", "anonymize"]) {
|
|
it(`restores unsaved ${page} redactions after navigation`, async () => {
|
|
ui = await browser("/" + page, repositorySource);
|
|
await ui.input("#terms", "Unsaved Author");
|
|
// Leaving must flush the pending textarea debounce into the draft.
|
|
await ui.go("/dashboard");
|
|
await ui.go("/" + page);
|
|
await delay(40);
|
|
expect(ui.window.document.querySelector("#terms").value).to.equal("Unsaved Author");
|
|
expect(ui.window.document.querySelector(".draft-status").textContent).to.include("restored");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
}
|
|
|
|
it("does not restore another account's draft", async () => {
|
|
ui = await browser("/profile", { "/api/user": { username: "owner" } }, {
|
|
"form-draft:profile": { account: "someone-else", savedAt: Date.now(), values: { terms: "Other account", options: {} } },
|
|
});
|
|
expect(ui.window.document.querySelector("#terms").value).not.to.equal("Other account");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("copies the anonymous URL and reports a clipboard failure without claiming success", async () => {
|
|
ui = await browser("/dashboard", {
|
|
"/api/user": { username: "owner" },
|
|
"/api/user/anonymized_repositories": [{ repoId: "share-me", status: "ready", source: { fullName: "owner/private" }, options: {} }],
|
|
});
|
|
const copied = [];
|
|
ui.window.navigator.clipboard = { writeText: async value => copied.push(value) };
|
|
const button = [...ui.window.document.querySelectorAll("button")].find(b => b.textContent.includes("Copy anonymous link"));
|
|
button.click();
|
|
await delay(20);
|
|
expect(copied).to.deep.equal(["http://localhost/r/share-me/"]);
|
|
expect(ui.app.state.toasts[0].title).to.equal("Link copied");
|
|
ui.window.navigator.clipboard.writeText = async () => { throw Error("Denied"); };
|
|
button.click();
|
|
await delay(20);
|
|
expect(ui.app.state.toasts.at(-1).title).to.equal("Copy this anonymous link");
|
|
expect(ui.app.state.toasts.at(-1).body).to.equal("http://localhost/r/share-me/");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
for (const path of [
|
|
"/r/submission-artifact-604F/",
|
|
"/r/submission-artifact-604F",
|
|
"/r/submission-artifact-604F/src/hello%20world.js?raw=1&value=a%2Fb#L12",
|
|
]) {
|
|
it(`opens a legacy hashbang repository link at ${path}`, async function () {
|
|
ui = await browser("/#!" + path);
|
|
expect(ui.app.router.currentRoute.value.fullPath).to.equal(path);
|
|
expect(ui.app.router.currentRoute.value.params.repoId).to.equal("submission-artifact-604F");
|
|
expect(ui.window.location.href).to.equal("http://localhost" + path);
|
|
expect(ui.window.history.length).to.equal(1);
|
|
expect(ui.requests.some(request => request.url.pathname.startsWith("/api/repo/submission-artifact-604F/"))).to.equal(true);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
}
|
|
|
|
for (const path of ["/r/test/#L12", "/#ordinary-anchor", "/#!//example.com/r/test/"]) {
|
|
it(`leaves a nonlegacy URL unchanged: ${path}`, async function () {
|
|
ui = await browser(path);
|
|
expect(ui.window.location.href).to.equal("http://localhost" + path);
|
|
expect(ui.app.router.currentRoute.value.fullPath).to.equal(path);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
}
|
|
|
|
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("renders admin users with missing email entries and links to their repositories", async function () {
|
|
const users = [
|
|
{ username: "no-emails" }, { username: "empty-emails", emails: [] },
|
|
{ username: "null-email", emails: [null] }, { username: "missing-email", emails: [{}] },
|
|
{ username: "with-email", emails: [{ email: "owner@example.com" }] },
|
|
].map(user => ({ ...user, status: "active", repoCount: 2 }));
|
|
ui = await browser("/", { "/api/user": { username: "admin", isAdmin: true },
|
|
"/api/admin/users": { total: users.length, results: users, statusCounts: [] } });
|
|
await ui.go("/admin/users");
|
|
const rows = [...ui.window.document.querySelectorAll(".paper-table-row.admin-users-row")];
|
|
expect(rows).to.have.length(users.length);
|
|
expect(rows.at(-1).textContent).to.include("owner@example.com");
|
|
for (const [index, row] of rows.entries()) {
|
|
expect(row.querySelector(".repo-name").textContent).to.equal(users[index].username);
|
|
expect(row.querySelector(".cell-views a").getAttribute("href")).to.equal("/admin/repositories?owner=" + users[index].username);
|
|
}
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("shows cache budgets and filters monitoring traffic without confusing unavailable data with zero", async function () {
|
|
ui = await browser("/", { "/api/user": { username: "admin", isAdmin: true },
|
|
"/api/admin/overview": { history: [] },
|
|
"/api/admin/cache": { available: true, enabled: true, filesystemSupported: true, sampledAt: Date.now(),
|
|
source: { allocatedBytes: 4096, budgetBytes: 65536, reservedBytes: 0, originalBytes: 19000, blobs: 1, activeReaders: 0 },
|
|
latencyBucketsMs: [0.1, 1, 10, 100, 1000, 10000],
|
|
rows: [{ name: "source_hit", traffic: "file", count: 4, bytes: 400, ms: 0 },
|
|
{ name: "source_miss", traffic: "file", count: 1, bytes: 100, ms: 0 },
|
|
{ name: "source_hit", traffic: "monitor", count: 10, bytes: 1000, ms: 0 }] } });
|
|
await ui.go("/admin/");
|
|
const panel = ui.window.document.querySelector(".ov-cache");
|
|
expect(panel.textContent).to.include("4 KB / 64 KB");
|
|
await ui.input("#cache-traffic", "file", "change");
|
|
expect(panel.textContent).to.include("80.0%");
|
|
await ui.input("#cache-traffic", "zip", "change");
|
|
expect(panel.textContent).to.include("No cache events");
|
|
await ui.input("#cache-window", "1440", "change");
|
|
expect(ui.requests.filter(r => r.url.pathname === "/api/admin/cache").at(-1).url.searchParams.get("minutes")).to.equal("1440");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("shows unavailable cache telemetry with a retry action", async function () {
|
|
ui = await browser("/", { "/api/user": { username: "admin", isAdmin: true },
|
|
"/api/admin/overview": { history: [] }, "/api/admin/cache": { available: false, reason: "Cache index unavailable" } });
|
|
await ui.go("/admin/");
|
|
const panel = ui.window.document.querySelector(".ov-cache");
|
|
expect(panel.querySelector('[role="alert"]').textContent).to.include("Cache index unavailable");
|
|
expect(panel.querySelector("button").textContent).to.equal("Retry");
|
|
expect(panel.textContent).not.to.include("Source request hit rate"); expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("shows latency bounds, interruptions and resource samples on the admin overview", async function () {
|
|
const sample = { instance: "streamer:fixture", service: "streamer", sampledAt: Date.now(), cpuPercent: 2,
|
|
memory: { rss: 16 * 1024 * 1024 }, memoryLimitBytes: 128 * 1024 * 1024,
|
|
eventLoop: { p95Ms: 20 }, activeRequests: 2, sockets: { descriptors: 15, closeWait: 1 },
|
|
workers: { running: 1, waiting: 2 }, droppedBatches: 0, droppedMetrics: 0 };
|
|
const row = { service: "api", method: "GET", route: "/api/repo/:repoId/file/:path", count: 10,
|
|
avgMs: 120, p95UpperMs: 250, p99UpperMs: 500, aborted: 1, errors: 0, slow: 0, firstByteCount: 10, firstByteP95UpperMs: 50 };
|
|
ui = await browser("/", { "/api/user": { username: "admin", isAdmin: true },
|
|
"/api/admin/overview": { history: [] },
|
|
"/api/admin/performance": { available: true, instances: [sample], routes: [row], stages: [
|
|
{ service: "streamer", metric: "cache_hit", count: 3, avgMs: 4, p95UpperMs: 10 },
|
|
{ service: "api", metric: "first_byte", count: 10, avgMs: 20, p95UpperMs: 50 },
|
|
],
|
|
runtimeSeries: [{ instance: sample.instance, rss: 20 * 1024 * 1024 }] } });
|
|
await ui.go("/admin/");
|
|
const text = ui.window.document.querySelector(".app-view").textContent;
|
|
expect(text).to.include("Request performance"); expect(text).to.include("\u2264 250\u202Fms");
|
|
expect(text).to.include("\u2264 50\u202Fms"); expect(text).to.include("streamer:fixture"); expect(text).to.include("20 MB");
|
|
await ui.input('select[aria-label="Performance metrics"]', "processes", "change");
|
|
expect(ui.window.document.querySelector("#performance-processes").style.display).not.to.equal("none");
|
|
expect(ui.window.document.querySelector("#performance-routes").style.display).to.equal("none");
|
|
expect(ui.window.document.querySelector(".ov-metric-warning-row").textContent).to.include("Needs attention");
|
|
await ui.input('select[aria-label="Performance metrics"]', "stages", "change");
|
|
expect(ui.window.document.querySelector("#performance-stages").style.display).not.to.equal("none");
|
|
const stageRows = ui.window.document.querySelectorAll("#performance-stages tbody tr");
|
|
expect(stageRows).to.have.length(1);
|
|
expect(stageRows[0].textContent).to.include("cache_hit");
|
|
await ui.input('select[aria-label="Performance time range"]', "60", "change");
|
|
expect(ui.requests.filter(request => request.url.pathname === "/api/admin/performance").at(-1).url.searchParams.get("minutes")).to.equal("60");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("renders Go process samples without inventing event-loop or initial CPU measurements", async function () {
|
|
const sample = { instance: "api:go-fixture", runtime: "go", service: "api", sampledAt: Date.now(),
|
|
cpuPercent: null, memory: { rss: 16 * 1024 * 1024 }, memoryLimitBytes: null,
|
|
eventLoop: { available: false, p95Ms: null }, activeRequests: 1, sockets: null,
|
|
workers: { running: 1, waiting: 0 }, droppedBatches: 0, droppedMetrics: 0 };
|
|
ui = await browser("/", { "/api/user": { username: "admin", isAdmin: true },
|
|
"/api/admin/overview": { history: [] },
|
|
"/api/admin/performance": { available: true, instances: [sample], routes: [], stages: [], runtimeSeries: [] } });
|
|
await ui.go("/admin/");
|
|
const row = ui.window.document.querySelector("#performance-processes tbody tr");
|
|
expect(row.textContent).to.include("api:go-fixture");
|
|
expect(row.textContent).to.include("Unavailable");
|
|
expect(row.textContent).to.include("Not applicable");
|
|
expect(row.textContent).not.to.include("NaN");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("shows missing monitoring as unavailable on the admin overview", async function () {
|
|
ui = await browser("/", { "/api/user": { username: "admin", isAdmin: true },
|
|
"/api/admin/overview": { history: [] }, "/api/admin/performance": { available: false } });
|
|
await ui.go("/admin/");
|
|
expect(ui.window.document.querySelector(".app-view").textContent).to.include("Performance monitoring is unavailable");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("retries a failed performance request and shows explicit empty metrics", async function () {
|
|
let calls = 0;
|
|
ui = await browser("/", { "/api/user": { username: "admin", isAdmin: true },
|
|
"/api/admin/overview": { history: [] },
|
|
"/api/admin/performance": () => ++calls === 1 ? { __status: 503, body: {} }
|
|
: { available: true, instances: [], routes: [], stages: [], runtimeSeries: [] } });
|
|
await ui.go("/admin/");
|
|
const alert = ui.window.document.querySelector(".ov-performance [role=alert]");
|
|
expect(alert.textContent).to.include("Could not refresh performance monitoring");
|
|
alert.querySelector("button").click();
|
|
await delay(20);
|
|
expect(calls).to.equal(2);
|
|
expect(ui.window.document.querySelector(".ov-performance [role=alert]")).to.equal(null);
|
|
expect(ui.window.document.querySelector("#performance-routes").textContent).to.include("No recorded requests");
|
|
await ui.input('select[aria-label="Performance metrics"]', "stages", "change");
|
|
expect(ui.window.document.querySelector("#performance-stages").textContent).to.include("No recorded work stages");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
for (const failure of [false, true]) {
|
|
it(`refreshes the selected performance window after an old request ${failure ? "fails" : "completes"}`, async function () {
|
|
let finishOld, finishNew;
|
|
const report = minutes => ({ available: true, windowMinutes: minutes, instances: [], stages: [], runtimeSeries: [],
|
|
routes: [{ service: "api", method: "GET", route: `window-${minutes}`, count: 1, avgMs: 1, p95UpperMs: 10, p99UpperMs: 10 }] });
|
|
ui = await browser("/", { "/api/user": { username: "admin", isAdmin: true }, "/api/admin/overview": { history: [] },
|
|
"/api/admin/performance": request => new Promise(resolve => {
|
|
if (request.url.searchParams.get("minutes") === "15") finishOld = () => resolve(failure ? { __status: 503, body: {} } : report(15));
|
|
else finishNew = () => resolve(report(60));
|
|
}) });
|
|
await ui.go("/admin/");
|
|
await ui.input('select[aria-label="Performance time range"]', "60", "change");
|
|
expect(ui.requests.filter(request => request.url.pathname === "/api/admin/performance")).to.have.length(1);
|
|
finishOld(); await delay(20);
|
|
const calls = ui.requests.filter(request => request.url.pathname === "/api/admin/performance");
|
|
expect(calls.map(request => request.url.searchParams.get("minutes"))).to.deep.equal(["15", "60"]);
|
|
expect(ui.window.document.querySelector(".app-view").textContent).not.to.include("window-15");
|
|
expect(ui.window.document.querySelector(".app-view").textContent).not.to.include("Could not refresh performance monitoring");
|
|
finishNew(); await delay(20);
|
|
expect(ui.window.document.querySelector(".app-view").textContent).to.include("window-60");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
}
|
|
|
|
it("offers one App sign-in for new and existing accounts", async function () {
|
|
ui = await browser("/signin", { "/api/options": { GITHUB_APP_ENABLED: true, GITHUB_OAUTH_ENABLED: true } });
|
|
expect(ui.window.document.querySelector('a[href="/github/app/login"]')).not.to.equal(null);
|
|
expect(ui.window.document.querySelector('a[href="/github/login"]')).to.equal(null);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("shows previous-connection verification only for a pending recovery", async function () {
|
|
ui = await browser("/signin?recover=1", {
|
|
"/api/options": { GITHUB_APP_ENABLED: true, GITHUB_OAUTH_ENABLED: true },
|
|
"/github/account-recovery": { required: true },
|
|
});
|
|
expect(ui.window.document.querySelector('a[href="/github/login?recover=1"]')).not.to.equal(null);
|
|
expect(ui.window.document.querySelector('a[href="/github/app/login"]')).to.equal(null);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("falls back to one OAuth sign-in when the App is disabled", async function () {
|
|
ui = await browser("/signin", { "/api/options": { GITHUB_APP_ENABLED: false, GITHUB_OAUTH_ENABLED: true } });
|
|
expect(ui.window.document.querySelector('a[href="/github/login"]')).not.to.equal(null);
|
|
expect(ui.window.document.querySelector('a[href="/github/app/login"]')).to.equal(null);
|
|
});
|
|
|
|
for (const provider of ["OAuth", "App"]) {
|
|
it(`offers ${provider} recovery when gist credentials are rejected`, async function () {
|
|
ui = await browser("/gist-anonymize", {
|
|
"/api/user": { username: "owner" },
|
|
"/github/connections": { appEnabled: true, appConnected: provider === "App", oauthEnabled: true, oauthConnected: provider === "OAuth" },
|
|
"/api/gist/source/311fc9": { __status: 403, body: { error: provider === "App" ? "github_app_reconnect_required" : "github_oauth_required" } },
|
|
});
|
|
const input = await ui.input("#sourceUrl", "https://gist.github.com/311fc9");
|
|
input.dispatchEvent(new ui.window.Event("blur"));
|
|
await delay(60);
|
|
const button = [...ui.window.document.querySelectorAll("button")].find(node => node.textContent.includes(`Connect GitHub ${provider} to access gists`));
|
|
expect(button).not.to.equal(undefined);
|
|
expect(button.closest(".anonymize-workspace")).not.to.equal(null);
|
|
for (let node = button; node; node = node.parentElement) {
|
|
expect(ui.window.getComputedStyle(node).display, node.className).not.to.equal("none");
|
|
}
|
|
expect(ui.requests.some(r => r.url.pathname === "/api/gist/source/311fc9")).to.equal(true);
|
|
button.click();
|
|
const saved = JSON.parse(ui.window.sessionStorage.getItem("github-access-draft"));
|
|
expect(saved.path).to.equal("/gist-anonymize");
|
|
expect(saved.draft.sourceUrl).to.equal("https://gist.github.com/311fc9");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
}
|
|
|
|
it("previews a gist with only an App login", async function () {
|
|
ui = await browser("/gist-anonymize", {
|
|
"/api/user": { username: "owner" },
|
|
"/github/connections": { appEnabled: true, appConnected: true, gistConnection: "github-app", oauthEnabled: true, oauthConnected: false },
|
|
"/api/user/default": { terms: [], options: { content: true } },
|
|
"/api/anonymize-preview": request => ({ contents: request.payload.contents }),
|
|
"/api/gist/source/311fc9": { gist: { description: "App gist", files: [{ filename: "example.txt", content: "example" }], comments: [] } },
|
|
});
|
|
const input = await ui.input("#sourceUrl", "https://gist.github.com/311fc9");
|
|
input.dispatchEvent(new ui.window.Event("blur"));
|
|
await delay(300);
|
|
expect(ui.requests.some(r => r.url.pathname === "/api/gist/source/311fc9")).to.equal(true);
|
|
expect(ui.window.document.body.textContent).to.include("example.txt");
|
|
expect(ui.window.document.body.textContent).not.to.include("Connect GitHub OAuth to access gists");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
for (const status of ["archived", "preparing", "queue", "download", "removing", "expiring"]) {
|
|
it(`hides connection changes on ${status} resources`, async function () {
|
|
ui = await browser("/connections", {
|
|
"/github/connections": { appEnabled: true, appConnected: true, oauthConnected: true,
|
|
resources: [{ type: "repository", id: "archived", connection: "github-app", status, eligible: true }] },
|
|
});
|
|
const resource = ui.window.document.querySelector(".connection-resource");
|
|
expect(resource).not.to.equal(null);
|
|
expect(resource.querySelectorAll("button")).to.have.length(0);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
}
|
|
|
|
for (const age of [0, 6 * 60 * 1000]) {
|
|
it("allows reconnect only for stale pull-request downloads: " + age, async function () {
|
|
ui = await browser("/connections", {
|
|
"/github/connections": { appEnabled: true, appConnected: true, resources: [
|
|
{ type: "pull-request", id: "stale", connection: "github-app", status: "download", statusDate: new Date(Date.now() - age).toISOString() },
|
|
] },
|
|
});
|
|
expect(ui.window.document.querySelector(".connection-resource-actions") !== null).to.equal(age > 0);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
}
|
|
for (const connection of ["oauth", "github-app"]) {
|
|
it(`previews and saves ${connection} access with CSRF protection`, async function () {
|
|
const action = connection === "github-app" ? "Reconnect read-only access" : "Switch to read-only access";
|
|
const resource = { type: "repository", id: "saved", name: "owner/private", connection, status: "ready" };
|
|
ui = await browser("/connections", {
|
|
"/api/user": { username: "owner" },
|
|
"/github/connections": { csrf: "csrf-value", appEnabled: true, appConnected: true, oauthEnabled: true, oauthConnected: connection === "oauth",
|
|
installations: [{ id: 4, account: "owner" }, { id: 5, account: "org" }], gistCount: 0, resources: [resource] },
|
|
"/github/connections/migrate": request => request.payload.preview ? { eligible: true } : { connection: "github-app" },
|
|
});
|
|
const button = label => [...ui.window.document.querySelectorAll("button")].find(node => node.textContent.includes(label));
|
|
expect(button(action)).to.equal(undefined);
|
|
expect(ui.window.document.querySelector('a[href="/github/app/install?installationId=4"]')).not.to.equal(null);
|
|
button("Check read-only access").click();
|
|
await delay(30);
|
|
button(action).click();
|
|
await delay(30);
|
|
const changes = ui.requests.filter(r => r.url.pathname === "/github/connections/migrate");
|
|
expect(changes).to.have.length(2);
|
|
expect(changes[0].payload.preview).to.equal(true);
|
|
expect(changes[1].payload.preview).to.equal(false);
|
|
expect(changes[1].payload.connection).to.equal("github-app");
|
|
expect(changes[1].headers["X-CSRF-Token"]).to.equal("csrf-value");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
}
|
|
|
|
|
|
for (const [appConnected, oauthConnected] of [[true, false], [false, true], [false, false], [true, true]]) {
|
|
it(`shows the access chooser only for two connections: ${appConnected}/${oauthConnected}`, async () => {
|
|
ui = await browser("/anonymize", { "/github/connections": { appEnabled: true, appConnected, oauthConnected } });
|
|
expect(Boolean(ui.window.document.querySelector(".repo-access"))).to.equal(appConnected && oauthConnected);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
}
|
|
|
|
for (const type of ["repo", "pr"]) {
|
|
it(`offers installation access to App-only users for private ${type} sources`, async function () {
|
|
const sourceUrl = "https://github.com/owner/private" + (type === "pr" ? "/pull/7" : "");
|
|
ui = await browser("/anonymize", {
|
|
"/api/user": { username: "owner" },
|
|
"/github/connections": { appEnabled: true, appConnected: true, oauthConnected: false },
|
|
"/api/repo/owner/private/": { __status: 403, body: { error: "github_app_access_required" } },
|
|
"/api/pr/owner/private/7": { __status: 403, body: { error: "github_app_access_required" } },
|
|
});
|
|
const input = await ui.input("#sourceUrl-landing", sourceUrl);
|
|
input.dispatchEvent(new ui.window.Event("blur"));
|
|
await delay(60);
|
|
const workspace = ui.window.document.querySelector(".anonymize-workspace");
|
|
const button = [...workspace.querySelectorAll("button")].find(b => b.textContent.trim() === "Manage GitHub access");
|
|
expect(button).not.to.equal(undefined);
|
|
for (let node = button; node; node = node.parentElement) expect(ui.window.getComputedStyle(node).display).not.to.equal("none");
|
|
expect(workspace.textContent).to.include("Signing in does not grant access to private repositories");
|
|
button.click();
|
|
const draft = JSON.parse(ui.window.sessionStorage.getItem("github-access-draft"));
|
|
expect(draft.draft.sourceUrl).to.equal(sourceUrl);
|
|
expect(ui.requests.some(r => r.method === "POST")).to.equal(false);
|
|
});
|
|
}
|
|
|
|
it("selects the connected GitHub App by default beside the source URL", async () => {
|
|
ui = await browser("/anonymize", { "/github/connections": { appEnabled: true, appConnected: true, oauthConnected: true } });
|
|
const panel = ui.window.document.querySelector(".anonymize-landing-inner .repo-access");
|
|
expect(panel).not.to.equal(null);
|
|
expect(panel.querySelector('button[aria-pressed="true"]').textContent).to.include("Read-only GitHub App");
|
|
[...panel.querySelectorAll("button")].find(b => b.textContent.includes("Legacy OAuth")).click();
|
|
await delay(30);
|
|
expect(panel.querySelector('button[aria-pressed="true"]').textContent).to.include("Legacy OAuth");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("preserves redactions, identifiers and pinned commits when switching connections", async () => {
|
|
ui = await browser("/anonymize", {
|
|
"/github/connections": { appEnabled: true, appConnected: true, oauthConnected: true },
|
|
"/api/repo/owner/repo/": { defaultBranch: "main", repo: "repo" },
|
|
"/api/repo/owner/repo/branches": [{ name: "main", commit: "abcdef123" }],
|
|
"/api/repo/owner/repo/readme": "",
|
|
});
|
|
const url = await ui.input("#sourceUrl", "https://github.com/owner/repo");
|
|
url.dispatchEvent(new ui.window.Event("blur"));
|
|
await delay(50);
|
|
await ui.input("#terms", "Private Author");
|
|
await delay(300);
|
|
const id = await ui.input("#repoId", "chosen-id");
|
|
id.dispatchEvent(new ui.window.Event("blur"));
|
|
await ui.input("#commit", "123456abcdef");
|
|
[...ui.window.document.querySelectorAll("button")].find(b => b.textContent.includes("Read-only GitHub App")).click();
|
|
await delay(60);
|
|
expect(ui.window.document.querySelector("#terms").value).to.equal("Private Author");
|
|
expect(ui.window.document.querySelector("#repoId").value).to.equal("chosen-id");
|
|
expect(ui.window.document.querySelector("#commit").value).to.equal("123456abcdef");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
for (const type of ["repo", "pr", "gist"]) {
|
|
it(`restores unsaved ${type} edits after granting GitHub access`, async () => {
|
|
const route = { repo: "anonymize", pr: "pull-request-anonymize", gist: "gist-anonymize" }[type];
|
|
const source = { repo: { fullName: "owner/repo", branch: "main", commit: "123456abcdef" },
|
|
pr: { repositoryFullName: "owner/repo", pullRequestId: 1 }, gist: { gistId: "311fc9" } }[type];
|
|
const sourceUrl = { repo: "https://github.com/owner/repo", pr: "https://github.com/owner/repo/pull/1", gist: "https://gist.github.com/311fc9" }[type];
|
|
const routePath = `/${route}/test`;
|
|
ui = await browser(routePath, {
|
|
[`/api/${type}/test`]: { status: "ready", source, options: { terms: ["persisted"], update: false } },
|
|
"/api/repo/owner/repo/": { defaultBranch: "main" },
|
|
"/api/repo/owner/repo/branches": [{ name: "main", commit: "abcdef123" }],
|
|
"/api/repo/owner/repo/readme": "",
|
|
"/api/pr/owner/repo/1": { pullRequest: { title: "Test", body: "", comments: [] } },
|
|
"/api/gist/source/311fc9": { files: [], comments: [] },
|
|
}, { "github-access-draft": { path: routePath, savedAt: Date.now(), draft: {
|
|
sourceUrl, source, terms: "unsaved redaction", options: { update: false, expirationDate: "2030-01-01" },
|
|
} } });
|
|
await delay(60);
|
|
expect(ui.window.document.querySelector("#terms").value).to.equal("unsaved redaction");
|
|
if (type === "repo") expect(ui.window.document.querySelector("#commit").value).to.equal("123456abcdef");
|
|
expect(ui.window.sessionStorage.getItem("github-access-draft")).to.equal(null);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
}
|
|
|
|
for (const type of ["gist", "pr", "repo"]) {
|
|
for (const status of ["removed", "expired", "error", "ready"]) {
|
|
it(`submits ${status} ${type} edits and exposes invalid expiration dates`, async function () {
|
|
const route = { gist: "gist-anonymize", pr: "pull-request-anonymize", repo: "anonymize" }[type];
|
|
const source = { gist: { gistId: "311fc9" }, pr: { repositoryFullName: "owner/repo", pullRequestId: 1 }, repo: { fullName: "owner/repo", branch: "main", commit: "abcdef123" } }[type];
|
|
const endpoint = `/api/${type}/test`;
|
|
ui = await browser(`/${route}/test`, {
|
|
[endpoint]: request => request.method === "POST" ? {} : { status, source, options: { terms: [], update: false, expirationDate: "2000-01-01" } },
|
|
"/api/gist/source/311fc9": { files: [], comments: [] },
|
|
"/api/pr/owner/repo/1": { pullRequest: { title: "Test", body: "", comments: [] } },
|
|
"/api/repo/owner/repo/": { defaultBranch: "main" },
|
|
"/api/repo/owner/repo/branches": [{ name: "main", commit: "abcdef123" }],
|
|
"/api/repo/owner/repo/readme": "",
|
|
});
|
|
const button = [...ui.window.document.querySelectorAll("button")].find(b => b.textContent.includes("Save changes"));
|
|
const posts = () => ui.requests.filter(r => r.method === "POST" && r.url.pathname === endpoint);
|
|
button.click();
|
|
await delay(10);
|
|
expect(posts()).to.have.length(0);
|
|
expect(ui.window.document.activeElement.id).to.equal("expirationDate");
|
|
const future = new Date();
|
|
future.setDate(future.getDate() + 30);
|
|
await ui.input("#expirationDate", future.toISOString().slice(0, 10), "change");
|
|
button.click();
|
|
await delay(20);
|
|
expect(posts()).to.have.length(1);
|
|
expect(ui.window.document.querySelector("#commit") === null).to.equal(type !== "repo");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const type of ["gist", "pr"]) {
|
|
it(`creates a new ${type} with auto-update disabled`, async function () {
|
|
const sourceUrl = type === "gist" ? "https://gist.github.com/311fc9" : "https://github.com/owner/repo/pull/1";
|
|
ui = await browser("/anonymize", {
|
|
"/api/gist/source/311fc9": { files: [], comments: [] },
|
|
"/api/pr/owner/repo/1": { pullRequest: { title: "Test", body: "", comments: [] } },
|
|
});
|
|
const input = await ui.input("#sourceUrl", sourceUrl);
|
|
input.dispatchEvent(new ui.window.Event("blur"));
|
|
await delay(40);
|
|
expect(ui.window.document.querySelector("#update").checked).to.equal(false);
|
|
const button = [...ui.window.document.querySelectorAll("button[type=submit]")][0];
|
|
button.click();
|
|
await delay(20);
|
|
expect(ui.requests.filter(r => r.method === "POST" && r.url.pathname === `/api/${type}/`)).to.have.length(1);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
}
|
|
|
|
for (const scenario of ["success", "failure", "renamed"]) {
|
|
it(`repairs App access without replacing the source or snapshot (${scenario})`, async function () {
|
|
const failed = scenario === "failure";
|
|
const fullName = scenario === "renamed" ? "owner/renamed" : "owner/repo";
|
|
let repaired = false;
|
|
const available = value => () => repaired ? value : { __status: 403, body: { error: "github_app_access_required" } };
|
|
ui = await browser("/anonymize/test", {
|
|
"/api/user": { username: "owner" },
|
|
"/github/connections": { csrf: "repair-csrf", appEnabled: true, appConnected: true },
|
|
"/api/repo/test": { connection: "github-app", role: "owner",
|
|
source: { fullName: "owner/repo", repositoryID: "saved-record", branch: "main", commit: "saved-sha" },
|
|
options: { terms: ["author"], update: false, expirationMode: "never" } },
|
|
"/api/repo/owner/repo/": available({ id: "saved-record", repo: "repo", defaultBranch: "main" }),
|
|
"/api/repo/owner/repo/branches": available([{ name: "main", commit: "new-sha" }]),
|
|
"/api/repo/owner/repo/readme": available("readme"),
|
|
"/github/connections/migrate": async request => {
|
|
expect(request.headers["X-CSRF-Token"]).to.equal("repair-csrf");
|
|
expect(request.payload).to.deep.equal({ type: "repository", id: "test", connection: "github-app" });
|
|
await delay(20);
|
|
if (failed) return { __status: 403, body: { error: "github_app_access_required" } };
|
|
repaired = true;
|
|
return { connection: "github-app", fullName };
|
|
},
|
|
"/api/repo/owner/renamed/": { id: "saved-record", repo: "renamed", defaultBranch: "main" },
|
|
"/api/repo/owner/renamed/branches": [{ name: "main", commit: "new-sha" }],
|
|
"/api/repo/owner/renamed/readme": "readme",
|
|
});
|
|
await delay(60);
|
|
const button = [...ui.window.document.querySelectorAll("button")].find(b => b.textContent === "Repair GitHub access");
|
|
expect(button).not.to.equal(undefined);
|
|
for (let node = button; node; node = node.parentElement) expect(ui.window.getComputedStyle(node).display).not.to.equal("none");
|
|
button.click(); button.click();
|
|
await delay(100);
|
|
expect(ui.requests.filter(r => r.method === "POST")).to.have.length(1);
|
|
expect(ui.window.document.querySelector("#commit").value).to.equal("saved-sha");
|
|
expect(ui.requests.some(r => r.url.searchParams.get("reconnect") === "1")).to.equal(false);
|
|
expect(ui.window.document.body.textContent).to.include(failed ? "Unable to repair GitHub access" : "GitHub access repaired");
|
|
if (scenario === "renamed") {
|
|
expect(ui.window.document.querySelector("#sourceUrl").value).to.equal("https://github.com/owner/renamed");
|
|
const branches = ui.requests.find(r => r.url.pathname === "/api/repo/owner/renamed/branches");
|
|
expect(branches.url.searchParams.get("anonymizedRepoId")).to.equal("test");
|
|
expect(ui.window.document.querySelector("#branch").value).to.equal("main");
|
|
}
|
|
});
|
|
}
|
|
|
|
it("hides App repair controls when migrations are disabled", async function () {
|
|
ui = await browser("/anonymize/test", {
|
|
"/github/connections": { appEnabled: false, appConnected: true },
|
|
"/api/repo/test": { connection: "github-app", role: "owner",
|
|
source: { fullName: "owner/repo", branch: "main", commit: "saved-sha" }, options: { terms: [], update: false } },
|
|
});
|
|
expect([...ui.window.document.querySelectorAll("button")].some(b => b.textContent === "Repair GitHub access" || b.textContent.trim() === "Manage GitHub access")).to.equal(false);
|
|
});
|
|
|
|
for (const status of ["removed", "archived", "preparing", "queue", "download", "removing", "expiring", "ready", "error", "expired"]) {
|
|
it(`gates App access repair for repository status ${status}`, async function () {
|
|
ui = await browser("/anonymize/test", {
|
|
"/github/connections": { appEnabled: true, appConnected: true },
|
|
"/api/repo/test": { status, connection: "github-app", role: "owner",
|
|
source: { fullName: "owner/repo", branch: "main", commit: "saved-sha" }, options: { terms: [], update: false } },
|
|
});
|
|
const shown = [...ui.window.document.querySelectorAll("button")].some(b => b.textContent === "Repair GitHub access");
|
|
expect(shown).to.equal(["ready", "error", "expired"].includes(status));
|
|
});
|
|
}
|
|
|
|
it("reconnects a recreated source without saving until the owner submits", async function () {
|
|
const oldCommit = "deadbeef";
|
|
const newCommit = "1a212f1deb74b123804ec90d098c0f843c30da5b";
|
|
const requireReconnect = data => request => request.url.searchParams.get("reconnect") === "1"
|
|
? data : { __status: 404, body: { error: "repo_not_found" } };
|
|
ui = await browser("/anonymize/test", {
|
|
"/api/repo/test": request => request.method === "POST" ? { status: "preparing" } : {
|
|
connection: "github-app", role: "owner",
|
|
source: { fullName: "owner/repo", repositoryID: "old-record", branch: "main", commit: oldCommit },
|
|
options: { terms: ["author"], update: false, expirationMode: "never" },
|
|
},
|
|
"/api/repo/owner/repo/": requireReconnect({ id: "new-record", externalId: "gh_1396237353", defaultBranch: "main" }),
|
|
"/api/repo/owner/repo/branches": requireReconnect([{ name: "main", commit: newCommit }]),
|
|
"/api/repo/owner/repo/readme": requireReconnect("replacement readme"),
|
|
});
|
|
const button = [...ui.window.document.querySelectorAll("button")].find(b => b.textContent === "Reconnect source repository");
|
|
expect(button).not.to.equal(undefined);
|
|
const before = ui.requests.length;
|
|
ui.window.confirm = () => false;
|
|
button.click();
|
|
await delay(20);
|
|
expect(ui.requests.length).to.equal(before);
|
|
ui.window.confirm = () => true;
|
|
button.click();
|
|
await delay(100);
|
|
const previews = ui.requests.filter(r => r.url.searchParams.get("reconnect") === "1");
|
|
expect(previews.some(r => r.url.pathname.endsWith("/branches"))).to.equal(true);
|
|
for (const request of previews) {
|
|
expect(request.url.searchParams.get("anonymizedRepoId")).to.equal("test");
|
|
expect(request.url.searchParams.has("repositoryID")).to.equal(false);
|
|
}
|
|
expect(ui.window.document.querySelector("#commit").value).to.equal(newCommit);
|
|
expect(ui.requests.filter(r => r.method === "POST" && r.url.pathname === "/api/repo/test")).to.have.length(0);
|
|
ui.window.document.querySelector("button[type=submit]").click();
|
|
await delay(30);
|
|
const save = ui.requests.find(r => r.method === "POST" && r.url.pathname === "/api/repo/test");
|
|
expect(save?.payload.reconnectRepositoryId).to.equal("gh_1396237353");
|
|
expect(save.payload.source.commit).to.equal(newCommit);
|
|
expect(save.payload.terms).to.deep.equal(["author"]);
|
|
});
|
|
|
|
it("still rejects a missing commit for a repository with auto-update disabled", async function () {
|
|
ui = await browser("/anonymize/test", {
|
|
"/api/repo/test": { source: { fullName: "owner/repo", branch: "main", commit: "abcdef123" }, options: { terms: [], update: false } },
|
|
"/api/repo/owner/repo/": { defaultBranch: "main" },
|
|
"/api/repo/owner/repo/branches": [{ name: "main", commit: "abcdef123" }],
|
|
"/api/repo/owner/repo/readme": "",
|
|
});
|
|
await ui.input("#commit", "");
|
|
ui.window.document.querySelector("button[type=submit]").click();
|
|
expect(ui.requests.filter(r => r.method === "POST" && r.url.pathname === "/api/repo/test")).to.have.length(0);
|
|
expect(ui.window.document.activeElement.id).to.equal("commit");
|
|
});
|
|
|
|
for (const [file, mode] of [["data.csv", "text"], ["LICENSE", "text"], ["model.idp", "text"], ["hello.js", "javascript"], ...["csp", "applescript", "logtalk", "redshift", "sparql", "turtle"].map(mode => ["file." + mode, mode])]) {
|
|
it("uses an available editor mode for " + file, async function () {
|
|
ui = await browser("/r/test/" + file, {
|
|
"/api/repo/test/files/": [{ name: file, path: "", sha: "1", size: 5 }],
|
|
});
|
|
await delay(80);
|
|
if (/\.csv$/.test(file)) {
|
|
// CSV opens as a table; its Text view is the code editor.
|
|
expect(ui.window.document.querySelector(".csv-table")).not.to.equal(null);
|
|
ui.window.document.querySelector(".csv-toggle .seg-chip:last-child").click();
|
|
await delay(80);
|
|
}
|
|
const host = ui.window.document.querySelector(".ace_editor");
|
|
expect(host).not.to.equal(null);
|
|
expect(ui.window.ace.edit(host).session.getMode().$id).to.equal("ace/mode/" + mode);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
expect(ui.assets.some(url => /mode-(csv|license|idp)\.js/.test(url))).to.equal(false);
|
|
});
|
|
}
|
|
|
|
it("loads document libraries on demand and reuses them across navigation", async function () {
|
|
ui = await browser("/dashboard");
|
|
expect(ui.assets.filter(url => url.endsWith(".js"))).to.deep.equal([]);
|
|
await ui.go("/r/test/README.md");
|
|
expect(ui.assets.some(url => /\/markdown\./.test(url))).to.equal(true);
|
|
expect(ui.assets.some(url => /\/(pdf|editor|notebook)\./.test(url))).to.equal(false);
|
|
await ui.go("/faq");
|
|
await ui.go("/r/test/README.md");
|
|
expect(ui.assets.filter(url => /\/markdown\./.test(url))).to.have.length(1);
|
|
await ui.go("/r/test/hello.js");
|
|
expect(ui.assets.some(url => /\/editor\./.test(url))).to.equal(true);
|
|
expect(ui.window.document.querySelector(".ace_editor")).not.to.equal(null);
|
|
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("distinguishes source, settings and readiness dates in the explorer", async function () {
|
|
ui = await browser("/r/test/hello.txt", {
|
|
"/api/repo/test/options": { anonymizedAt: "2026-01-01T00:00:00Z", sourceCommitDate: "2020-01-01T00:00:00Z",
|
|
settingsSavedAt: "2026-02-01T00:00:00Z", publishedAt: "2026-03-01T00:00:00Z" },
|
|
});
|
|
const snapshot = ui.window.document.querySelector(".vb-snapshot");
|
|
expect(snapshot.textContent).to.include("Mar 1, 2026");
|
|
expect(snapshot.title).to.include("Source commit").and.include("snapshot ready");
|
|
expect(snapshot.title).not.to.include("no recorded publication date");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
it("identifies an unknown legacy publication date", async function () {
|
|
ui = await browser("/r/test/hello.txt", {
|
|
"/api/repo/test/options": { anonymizedAt: "2026-01-01T00:00:00Z", sourceCommitDate: "2020-01-01T00:00:00Z" },
|
|
});
|
|
const snapshot = ui.window.document.querySelector(".vb-snapshot");
|
|
expect(snapshot.title).to.include("no recorded publication date");
|
|
expect(snapshot.textContent).not.to.include("Settings saved");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("keeps the reviewer header to the essentials and shows when the link expires", async function () {
|
|
ui = await browser("/r/test/hello.txt", {
|
|
"/api/repo/test/options": { anonymizedAt: "2026-01-01T00:00:00Z", expirationDate: "2099-01-01T00:00:00Z" },
|
|
});
|
|
// Reviewers get the viewer's own bar, not the site header; expiry is part of the snapshot details.
|
|
expect(ui.window.document.querySelector(".navbar")).to.equal(null);
|
|
const bar = ui.window.document.querySelector(".viewer-bar");
|
|
expect(bar.querySelector(".vb-help-btn").textContent).to.include("Help");
|
|
expect(bar.querySelector(".vb-snapshot").getAttribute("title")).to.include("Available until");
|
|
await ui.go("/faq");
|
|
expect(ui.window.document.querySelector(".navbar .btn-signin")).to.not.equal(null);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("leads a ready status page with the copyable anonymous link", async function () {
|
|
ui = await browser("/status/test", { "/api/repo/test": { repoId: "test", status: "ready", options: {} } });
|
|
const link = ui.window.document.querySelector(".share-link-url");
|
|
expect(link.textContent.trim()).to.match(/\/r\/test\/$/);
|
|
expect(ui.window.document.querySelector(".paper-progress")).to.equal(null);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
for (const [code, message] of [["repo_not_found", "GitHub access needs attention"], ["untranslated_failure", "We could not load this content"]]) {
|
|
it("shows the " + code + " explorer error in a card without file actions", async function () {
|
|
ui = await browser("/r/gone/", { "/api/repo/gone/options": { __status: 404, body: { error: code } } });
|
|
const card = ui.window.document.querySelector(".file-error-card");
|
|
expect(card.textContent).to.include(message);
|
|
expect(card.textContent).not.to.include("ERRORS.");
|
|
expect(card.querySelector(code === "repo_not_found" ? 'a[href="/dashboard"]' : 'a[href="/"]')).to.not.equal(null);
|
|
expect(ui.window.document.querySelector(".status-bar").style.display).to.equal("none");
|
|
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");
|
|
// A folder holding a single file stays a folder (only folder chains are joined as "a/b").
|
|
expect(tree.textContent).to.include(filename).and.include("constructor");
|
|
expect(tree.querySelector('li.folder > a[data-path="/constructor"]')).to.not.equal(null);
|
|
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(tree.querySelector('li.active > a[data-path="/constructor/index.js"]')).to.not.equal(null);
|
|
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("loads scoped folder counts lazily without delaying content and does not refetch on reopen", async function () {
|
|
let finishRootCounts;
|
|
ui = await browser("/r/test/hello.txt", {
|
|
"/api/repo/test/files/": request => request.url.searchParams.get("path") === "src"
|
|
? [{ name: "a.txt", path: "src", size: 3 }, { name: "b.txt", path: "src", size: 3 }]
|
|
: [{ name: "hello.txt", path: "", size: 3 }, { name: "src", path: "" }],
|
|
"/api/repo/test/files/counts": request => request.url.searchParams.get("path") === "src"
|
|
? { src: 2 } : new Promise(resolve => { finishRootCounts = resolve; }),
|
|
});
|
|
expect(ui.requests.filter(r => /files\/counts$/.test(r.url.pathname)).map(r => r.url.searchParams.get("path"))).to.deep.equal([""]);
|
|
finishRootCounts({ "": 3, src: 2 });
|
|
await delay(30);
|
|
// The viewer shows no repository-wide count (anonymity by design); counts feed the tree only.
|
|
expect(ui.window.document.querySelector(".project-file-count")).to.equal(null);
|
|
const folder = () => ui.window.document.querySelector('tree a[data-path="/src"]');
|
|
folder().click(); await delay(90);
|
|
expect(ui.window.document.querySelector('tree a[data-path="/src/a.txt"]')).not.to.equal(null);
|
|
folder().click(); await delay(10); folder().click(); await delay(10);
|
|
expect(ui.requests.filter(r => /files\/counts$/.test(r.url.pathname)).map(r => r.url.searchParams.get("path"))).to.deep.equal(["", "src"]);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
for (const status of [413, 429, 503]) it(`keeps browsing without count badges or retries after counts return ${status}`, async function () {
|
|
ui = await browser("/r/test/hello.txt", {
|
|
"/api/repo/test/files/": request => request.url.searchParams.get("path") === "src"
|
|
? [{ name: "a.txt", path: "src", size: 3 }]
|
|
: [{ name: "hello.txt", path: "", size: 3 }, { name: "src", path: "" }],
|
|
"/api/repo/test/files/counts": { __status: status, body: { error: "file_listing_too_large" } },
|
|
});
|
|
await delay(20);
|
|
expect(ui.window.document.querySelector(".project-file-count")).to.equal(null);
|
|
ui.window.document.querySelector('tree a[data-path="/src"]').click(); await delay(30);
|
|
expect(ui.window.document.querySelector('tree a[data-path="/src/a.txt"]')).not.to.equal(null);
|
|
expect(ui.requests.filter(r => /files\/counts$/.test(r.url.pathname))).to.have.length(1);
|
|
expect(ui.window.document.querySelector(".tree-count")).to.equal(null);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("ignores a previous repository's delayed counts after navigation", async function () {
|
|
let finish;
|
|
ui = await browser("/r/test/hello.txt", {
|
|
"/api/repo/test/files/counts": () => new Promise(resolve => { finish = resolve; }),
|
|
"/api/repo/other/files/counts": { "": 9 },
|
|
});
|
|
await ui.go("/r/other/hello.txt");
|
|
finish({ "": 999 }); await delay(30);
|
|
const counts = ui.window.document.querySelector("tree")?.__vueParentComponent?.props?.page?.fileCounts ?? ui.app.state.fileCounts;
|
|
expect(counts?.[""]).to.not.equal(999);
|
|
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("shows quota limits accessibly and dismisses usage with Escape or an outside click", async () => {
|
|
ui = await browser("/dashboard", {
|
|
"/api/user": { username: "tester" },
|
|
"/api/user/quota": {
|
|
repository: { used: 21, total: 20 },
|
|
storage: { used: 0, total: 1024 },
|
|
file: { used: 1381, total: 0 },
|
|
},
|
|
});
|
|
const doc = ui.window.document;
|
|
const details = doc.querySelector(".dashboard-quota");
|
|
const summary = details.querySelector("summary");
|
|
summary.click();
|
|
await delay(20);
|
|
expect(details.open).to.equal(true);
|
|
const bars = [...details.querySelectorAll('[role="progressbar"]')];
|
|
expect(bars).to.have.length(2);
|
|
expect(bars[0].getAttribute("aria-valuenow")).to.equal("20");
|
|
expect(bars[0].getAttribute("aria-valuemax")).to.equal("20");
|
|
expect(bars[1].getAttribute("aria-valuenow")).to.equal("0");
|
|
expect(details.textContent).to.include("Limit exceeded").and.include("Unlimited");
|
|
doc.querySelector('[aria-label="Close usage"]').focus();
|
|
doc.activeElement.dispatchEvent(new ui.window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
|
await delay(20);
|
|
expect(details.open).to.equal(false);
|
|
expect(doc.activeElement).to.equal(summary);
|
|
summary.click();
|
|
await delay(20);
|
|
doc.querySelector("#search").click();
|
|
await delay(20);
|
|
expect(details.open).to.equal(false);
|
|
expect(summary.getAttribute("aria-expanded")).to.equal("false");
|
|
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("loads dashboard summaries once initially and reloads when search or status changes", async () => {
|
|
ui = await browser("/dashboard", {
|
|
"/api/user": { username: "tester" },
|
|
"/api/user/dashboard": { items: [], total: 0, filtered: 0, attention: 0, cursor: null },
|
|
});
|
|
const requests = () => ui.requests.filter(request => request.url.pathname === "/api/user/dashboard");
|
|
await delay(200);
|
|
expect(requests()).to.have.length(1);
|
|
await ui.input('input[type="search"]', "package");
|
|
await delay(200);
|
|
expect(requests()).to.have.length(2); expect(requests()[1].url.searchParams.get("q")).to.equal("package");
|
|
ui.window.document.querySelector("#status-ready").click();
|
|
await delay(200);
|
|
expect(requests()).to.have.length(3); expect(requests()[2].url.searchParams.get("statuses")).not.to.include("ready");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("shows repository commit badges and details from dashboard summaries", async () => {
|
|
const commit = "abcdef1234567890";
|
|
ui = await browser("/dashboard", {
|
|
"/api/user": { username: "tester" },
|
|
"/api/user/dashboard": { items: [{ _type: "repo", repoId: "package", status: "ready",
|
|
source: { fullName: "owner/package", commit } }], total: 1, filtered: 1, attention: 0, cursor: null },
|
|
});
|
|
expect(ui.window.document.querySelector(".commit-hash").textContent).to.equal(commit.slice(0, 7));
|
|
const state = ui.window.document.querySelector("#search")._field.binding.state;
|
|
await state.showProjectDetails(state.items[0]);
|
|
expect(ui.window.document.querySelector(".project-details").textContent).to.include(commit);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("distinguishes empty server-filtered pages from an empty account", async () => {
|
|
const item = { _type: "repo", repoId: "package", status: "ready", source: { fullName: "owner/package" } };
|
|
ui = await browser("/dashboard", {
|
|
"/api/user": { username: "tester" },
|
|
"/api/user/dashboard": request => {
|
|
const empty = !!request.url.searchParams.get("q") || !request.url.searchParams.get("statuses").split(",").includes("ready");
|
|
return { items: empty ? [] : [item], total: 2, filtered: empty ? 0 : 1, attention: 0, cursor: null };
|
|
},
|
|
});
|
|
const check = () => {
|
|
const empty = ui.window.document.querySelector(".paper-table-empty");
|
|
expect(empty.textContent).to.include("Nothing matches the current filters.").not.to.include("You have no anonymizations yet.");
|
|
expect(empty.querySelector('a[href="/anonymize"]')).to.equal(null);
|
|
expect(empty.querySelector("button").textContent).to.include("Clear filters");
|
|
};
|
|
await ui.input('input[type="search"]', "missing"); await delay(200); check();
|
|
const state = ui.window.document.querySelector("#search")._field.binding.state;
|
|
state.clearFilters(); await delay(200);
|
|
ui.window.document.querySelector("#status-ready").click(); await delay(20); check();
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("offers a new anonymization when the server reports an empty account", async () => {
|
|
ui = await browser("/dashboard", {
|
|
"/api/user": { username: "tester" },
|
|
"/api/user/dashboard": { items: [], total: 0, filtered: 0, attention: 0, cursor: null },
|
|
});
|
|
const empty = ui.window.document.querySelector(".paper-table-empty");
|
|
expect(empty.textContent).to.include("You have no anonymizations yet.").not.to.include("Nothing matches");
|
|
expect(empty.querySelector('a[href="/anonymize"]')).not.to.equal(null);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
for (const status of ["error", "download"]) {
|
|
it(`updates the global attention badge after polling a repository in ${status}`, async () => {
|
|
const item = { _type: "repo", repoId: "package", status, anonymizeDate: "2000-01-01", source: { fullName: "owner/package" } };
|
|
let ready = false;
|
|
ui = await browser("/dashboard", {
|
|
"/api/user": { username: "tester" },
|
|
"/api/user/dashboard": () => ({ items: ready ? [] : [item], total: 20,
|
|
filtered: ready ? 6 : 7, attention: ready ? 6 : 7, cursor: ready ? "new-next" : "next" }),
|
|
"/api/repo/package/refresh": {}, "/api/repo/package": () => { ready = true; return { status: "ready" }; },
|
|
});
|
|
const state = ui.window.document.querySelector("#search")._field.binding.state;
|
|
state.setProjectView("attention"); await delay(200);
|
|
expect(state.filteredItems).to.have.length(1); expect(state.attentionCount()).to.equal(7);
|
|
const requests = ui.requests.filter(request => request.url.pathname === "/api/user/dashboard").length;
|
|
const original = state.items[0];
|
|
state.refreshItem(original); await delay(30);
|
|
expect(state.filteredItems).to.have.length(0);
|
|
expect(state.attentionCount()).to.equal(6);
|
|
expect(ui.window.document.querySelector(".attention-count").textContent).to.equal("6");
|
|
state.refreshItem(original); await delay(30);
|
|
expect(state.attentionCount()).to.equal(6);
|
|
expect(state.dashboardTotals.filtered).to.equal(6);
|
|
expect(state.dashboardCursor).to.equal("new-next");
|
|
expect(ui.requests.filter(request => request.url.pathname === "/api/user/dashboard")).to.have.length(requests + 1);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
}
|
|
|
|
for (const filtered of [false, true]) {
|
|
it(`reloads pagination after a polled status change${filtered ? " under a status filter" : " sorted by status"}`, async () => {
|
|
const original = { _type: "repo", repoId: "package", status: "error", source: { fullName: "owner/package" } };
|
|
const other = { _type: "repo", repoId: "z-other", status: "error", source: { fullName: "owner/z-other" } };
|
|
let ready = false, release;
|
|
ui = await browser("/dashboard", {
|
|
"/api/user": { username: "tester" },
|
|
"/api/user/dashboard": request => {
|
|
const cursor = request.url.searchParams.get("cursor");
|
|
if (cursor === "old-next") return new Promise(resolve => { release = () => resolve({ items: [other], total: 2, filtered: 2, attention: 2, cursor: null }); });
|
|
if (cursor === "new-next") return { items: [{ ...original, status: "ready" }], total: 2, filtered: 2, attention: 1, cursor: null };
|
|
return { items: [ready ? other : original], total: 2, filtered: ready && filtered ? 1 : 2,
|
|
attention: ready ? 1 : 2, cursor: ready ? (filtered ? null : "new-next") : "old-next" };
|
|
},
|
|
"/api/repo/package/refresh": {}, "/api/repo/package": () => { ready = true; return { status: "ready" }; },
|
|
});
|
|
const state = ui.window.document.querySelector("#search")._field.binding.state;
|
|
state.orderBy = "status";
|
|
if (filtered) Object.keys(state.filters.status).forEach(key => { state.filters.status[key] = key === "error"; });
|
|
await delay(200);
|
|
await state.showProjectDetails(state.items[0]);
|
|
const selected = state.items[0];
|
|
const pending = state.loadMoreProjects(); await delay(10);
|
|
expect(release).to.be.a("function");
|
|
const count = ui.requests.filter(request => request.url.pathname === "/api/user/dashboard").length;
|
|
state.refreshItem(selected); await delay(50);
|
|
const reloads = ui.requests.filter(request => request.url.pathname === "/api/user/dashboard").slice(count);
|
|
expect(reloads).to.have.length(1); expect(reloads[0].url.searchParams.has("cursor")).to.equal(false);
|
|
expect(reloads[0].url.searchParams.get("sort")).to.equal("status");
|
|
if (filtered) expect(reloads[0].url.searchParams.get("statuses")).to.equal("error");
|
|
expect(state.dashboardTotals.filtered).to.equal(filtered ? 1 : 2);
|
|
expect(state.dashboardCursor).to.equal(filtered ? null : "new-next");
|
|
expect(state.selectedProject).to.equal(null);
|
|
release(); await pending;
|
|
expect(state.items.map(item => item._id)).to.deep.equal(["z-other"]);
|
|
if (!filtered) {
|
|
await state.loadMoreProjects();
|
|
expect(state.items.map(item => item._id)).to.deep.equal(["z-other", "package"]);
|
|
expect(state.items[1].status).to.equal("ready");
|
|
}
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
}
|
|
|
|
it("reloads later status transitions after a repository leaves the loaded page", async () => {
|
|
const item = { _type: "repo", repoId: "package", status: "error", source: { fullName: "owner/package" } };
|
|
let status = "error", polls = 0;
|
|
ui = await browser("/dashboard", {
|
|
"/api/user": { username: "tester" },
|
|
"/api/user/dashboard": () => ({ items: status === "error" ? [{ ...item, status }] : [], total: 1,
|
|
filtered: status === "error" ? 1 : 0, attention: status === "error" ? 1 : 0, cursor: null }),
|
|
"/api/repo/package/refresh": {},
|
|
"/api/repo/package": () => { status = ++polls === 1 ? "download" : "error"; return { status, statusMessage: "failed refresh" }; },
|
|
});
|
|
const state = ui.window.document.querySelector("#search")._field.binding.state;
|
|
Object.keys(state.filters.status).forEach(key => { state.filters.status[key] = key === "error"; });
|
|
await delay(20);
|
|
state.refreshItem(state.items[0]); await delay(50);
|
|
expect(state.items).to.have.length(0); expect(state.dashboardTotals.filtered).to.equal(0);
|
|
await delay(2600);
|
|
expect(polls).to.equal(2); expect(state.items.map(item => item._id)).to.deep.equal(["package"]);
|
|
expect(state.items[0].status).to.equal("error");
|
|
expect(state.dashboardTotals.filtered).to.equal(1); expect(state.attentionCount()).to.equal(1);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("finds errors and stalled downloads without treating fresh queued projects as failures", async () => {
|
|
ui = await browser("/dashboard", {
|
|
"/api/user": { username: "tester" },
|
|
"/api/user/anonymized_repositories": [
|
|
{ repoId: "ready", status: "ready", source: { fullName: "owner/ready" } },
|
|
{ repoId: "error", status: "error", statusMessage: "branch_not_found", source: { fullName: "owner/error" } },
|
|
{ repoId: "fresh", status: "queue", anonymizeDate: new Date().toISOString(), source: { fullName: "owner/fresh" } },
|
|
{ repoId: "stalled", status: "download", anonymizeDate: "2000-01-01T00:00:00Z", source: { fullName: "owner/stalled" } },
|
|
],
|
|
});
|
|
const state = ui.window.document.querySelector("#search")._field.binding.state;
|
|
expect(state.attentionCount()).to.equal(2);
|
|
state.filters.status.error = false;
|
|
state.setProjectView("attention");
|
|
await delay(10);
|
|
expect(state.filteredItems.map(item => item._id).sort()).to.deep.equal(["error", "stalled"]);
|
|
expect(ui.window.document.querySelector('[href="/anonymize/error"]').textContent).to.equal("Fix source");
|
|
expect(ui.window.document.querySelector(".filter-chip")?.textContent ?? "").not.to.include("Hiding error");
|
|
await ui.input("#search", "stalled");
|
|
expect(state.filteredItems).to.have.length(1);
|
|
state.clearFilters(); await delay(10);
|
|
expect(state.filteredItems).to.have.length(4);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("saves private project names and can search them after returning to the dashboard", async () => {
|
|
const gist = { gistId: "g-demo", status: "ready", source: { gistId: "a1b2c3d4e5" } };
|
|
let payload;
|
|
ui = await browser("/dashboard", {
|
|
"/api/user": { username: "tester" },
|
|
"/api/user/anonymized_gists": () => [gist],
|
|
"/api/user/project-name": request => { payload = request.payload; gist.projectName = payload.name; return { name: payload.name }; },
|
|
});
|
|
const state = ui.window.document.querySelector("#search")._field.binding.state;
|
|
expect(state.items[0]._label).to.equal("Gist g-demo");
|
|
await state.showProjectDetails(state.items[0]);
|
|
await ui.input("#project-name-gist-g-demo", "Training utilities");
|
|
ui.window.document.querySelector(".project-name-form").dispatchEvent(new ui.window.Event("submit", { bubbles: true, cancelable: true }));
|
|
await delay(20);
|
|
expect(payload).to.deep.equal({ type: "gist", id: "g-demo", name: "Training utilities" });
|
|
expect(ui.window.document.querySelector(".repo-name").textContent).to.equal("Training utilities");
|
|
await ui.go("/faq"); await ui.go("/dashboard");
|
|
await ui.input("#search", " training ");
|
|
expect(ui.window.document.querySelectorAll(".paper-table-row:not(.paper-table-skeleton)")).to.have.length(1);
|
|
expect(ui.window.document.querySelector(".repo-name").textContent).to.equal("Training utilities");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
it("keeps the current project name visible when saving a replacement fails", async () => {
|
|
let complete;
|
|
ui = await browser("/dashboard", {
|
|
"/api/user": { username: "tester" },
|
|
"/api/user/anonymized_repositories": [{ repoId: "package", status: "ready", projectName: "Saved package", source: { fullName: "owner/package" } }],
|
|
"/api/user/project-name": () => new Promise(resolve => { complete = () => resolve({ __status: 503, body: { error: "unavailable" } }); }),
|
|
});
|
|
const state = ui.window.document.querySelector("#search")._field.binding.state;
|
|
const item = state.items[0];
|
|
await state.showProjectDetails(item);
|
|
state.projectName = "Replacement";
|
|
const pending = state.saveProjectName(item);
|
|
await state.saveProjectName(item);
|
|
expect(ui.requests.filter(r => r.url.pathname === "/api/user/project-name")).to.have.length(1);
|
|
complete(); await pending; await delay(10);
|
|
expect(item._label).to.equal("Saved package");
|
|
expect(ui.window.document.querySelector(".project-name-error").textContent).to.include("could not be saved");
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
|
|
for (const filtered of [false, true]) {
|
|
it(`reloads summary pagination after renaming a project${filtered ? " in a search" : " sorted by label"}`, async () => {
|
|
let name = "Old name", queryCount = 0;
|
|
const item = () => ({ _type: "repo", repoId: "package", status: "ready", projectName: name, source: { fullName: "owner/package" } });
|
|
ui = await browser("/dashboard", {
|
|
"/api/user": { username: "tester" },
|
|
"/api/user/dashboard": request => {
|
|
queryCount++;
|
|
const match = !request.url.searchParams.get("q") || name.includes(request.url.searchParams.get("q"));
|
|
return { items: match ? [item()] : [], total: 2, filtered: match ? 2 : 0, attention: 0,
|
|
cursor: match ? name + "-cursor" : null };
|
|
},
|
|
"/api/user/project-name": request => { name = request.payload.name; return { name }; },
|
|
});
|
|
const state = ui.window.document.querySelector("#search")._field.binding.state;
|
|
state.orderBy = "_label"; if (filtered) state.search = "Old";
|
|
await delay(200);
|
|
await state.showProjectDetails(state.items[0]);
|
|
state.projectName = "New name";
|
|
const before = queryCount;
|
|
await state.saveProjectName(state.items[0]); await delay(10);
|
|
expect(queryCount).to.equal(before + 1);
|
|
expect(state.dashboardCursor).to.equal(filtered ? null : "New name-cursor");
|
|
expect(state.dashboardTotals.filtered).to.equal(filtered ? 0 : 2);
|
|
expect(state.items).to.have.length(filtered ? 0 : 1);
|
|
expect(state.selectedProject).to.equal(filtered ? null : state.items[0]);
|
|
const last = ui.requests.filter(request => request.url.pathname === "/api/user/dashboard").at(-1);
|
|
expect(last.url.searchParams.has("cursor")).to.equal(false);
|
|
expect(ui.errors).to.deep.equal([]);
|
|
});
|
|
}
|
|
|
|
it("shows full project details as text and restores keyboard focus when closed", async () => {
|
|
const source = "owner/" + "very-long-project-name-".repeat(5);
|
|
ui = await browser("/dashboard", {
|
|
"/api/user": { username: "tester" },
|
|
"/api/user/anonymized_repositories": [{ repoId: "package", status: "ready", projectName: '<img src=x onerror="alert(1)">', source: { fullName: source, commit: "abcdef1234567890" }, conference: "A long conference name" }],
|
|
});
|
|
const state = ui.window.document.querySelector("#search")._field.binding.state;
|
|
await state.showProjectDetails(state.items[0]);
|
|
const panel = ui.window.document.querySelector(".project-details");
|
|
expect(panel.textContent).to.include(source).and.include("abcdef1234567890").and.include("A long conference name");
|
|
expect(ui.window.document.querySelector(".repo-name img")).to.equal(null);
|
|
expect(ui.window.document.activeElement).to.equal(panel);
|
|
panel.dispatchEvent(new ui.window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
|
await delay(10);
|
|
expect(ui.window.document.querySelector(".project-details")).to.equal(null);
|
|
expect(ui.window.document.activeElement.getAttribute("data-project-actions")).to.equal("repo%3Apackage");
|
|
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 = { GlobalWorkerOptions: {}, 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("/");
|
|
});
|
|
});
|