const { expect } = require("chai"); const { JSDOM, VirtualConsole, ResourceLoader } = require("jsdom"); const fs = require("fs"); const path = require("path"); const { URL } = require("node:url"); const { setTimeout: delay } = require("node:timers/promises"); const publicDir = path.join(__dirname, "../public"); const bundles = ["core.min.js", "vendor.min.js"].map(name => fs.readFileSync(path.join(publicDir, "script", name), "utf8")); async function browser(route = "/", overrides = {}) { const errors = [], requests = [], assets = []; const virtualConsole = new VirtualConsole(); virtualConsole.on("jsdomError", error => { if (!error.message.includes("navigation (except hash changes)")) errors.push(error.message); }); virtualConsole.on("error", error => errors.push(error?.message || String(error))); const dom = new JSDOM('
', { resources: new class extends ResourceLoader { fetch(url) { assets.push(new URL(url).pathname); if (/\/pdf\.[a-f0-9]+\.min\.js$/.test(new URL(url).pathname) && dom.window.pdfjsLib) return Promise.resolve(Buffer.from("")); const pathname = new URL(url).pathname.replace(/\.[a-f0-9]{10}\.min\.js$/, ".min.js"); if (pathname.startsWith("/script/")) return Promise.resolve(fs.readFileSync(path.join(publicDir, pathname))); return null; } }(), url: "http://localhost" + route, runScripts: "dangerously", pretendToBeVisual: true, virtualConsole, }); const window = dom.window; window.matchMedia = () => ({ matches: false }); window.HTMLCanvasElement.prototype.getContext = () => null; window.fetch = async (url, options = {}) => { const target = new URL(url); const request = { url: target, ...options, payload: options.body ? JSON.parse(options.body) : undefined }; requests.push(request); let data; const custom = overrides[target.pathname]; if (custom !== undefined) data = typeof custom === "function" ? await custom(request) : custom; else if (target.pathname === "/api/user") data = null; else if (target.pathname === "/api/user/default") data = { options: {}, terms: [] }; else if (/\/files\/$/.test(target.pathname)) data = [{ name: "hello.js", path: "", sha: "1", size: 8 }]; else if (/\/files\/counts$/.test(target.pathname)) data = { "": 1 }; else if (target.pathname === "/api/admin/errors") data = { entries: [] }; else if (target.pathname === "/api/conferences/plans") data = []; else if (/\/options$|\/quota$|\/stats$|\/content$|\/api\/conferences\/[^/]+$/.test(target.pathname)) data = {}; else if (/\/file\//.test(target.pathname)) data = "hello"; else data = []; const status = data?.__status || 200; if (data?.__status) data = data.body; return { ok: status < 400, status, headers: { get: () => typeof data === "string" ? "text/plain" : "application/json" }, text: async () => typeof data === "string" ? data : JSON.stringify(data) }; }; bundles.forEach(bundle => window.eval(bundle)); const app = window.anonymousApp; await app.router.isReady(); await delay(30); return { window, app, errors, requests, 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("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([]); }); 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("Update ")); 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([]); }); } 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"); }); 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("renders hostile filenames as text and updates the explorer without losing the tree", async function () { const filename = '{{constructor.constructor("window.probe=1")()}}.txt'; ui = await browser("/r/test/hello.txt", { "/api/repo/test/files/": [{ name: filename, path: "", size: 1 }, { name: "constructor", path: "" }, { name: "index.js", path: "constructor", size: 2 }, { name: "hello.txt", path: "", size: 3 }], }); const tree = ui.window.document.querySelector("tree"); expect(tree.textContent).to.include(filename).and.include("constructor/index.js"); expect(ui.window.probe).to.equal(undefined); await ui.go("/r/test/constructor/index.js"); expect(ui.window.document.querySelector("tree")).to.equal(tree); expect(ui.requests.some(r => r.url.pathname === "/api/repo/test/file/constructor/index.js")).to.equal(true); await ui.go("/r/other/hello.txt"); expect(ui.requests.some(r => r.url.pathname === "/api/repo/other/options")).to.equal(true); expect(ui.errors).to.deep.equal([]); }); it("starts folders collapsed and opens the selected file's ancestors", async function () { ui = await browser("/r/test/hello.txt", { "/api/repo/test/files/": [ { name: "hello.txt", path: "", size: 3 }, { name: "src", path: "" }, { name: "a.js", path: "src", size: 2 }, { name: "b.js", path: "src", size: 2 }, { name: "lazy", path: "" }, ], }); const folder = name => ui.window.document.querySelector(`tree a[data-path="/${name}"]`).parentElement; expect(folder("src").classList.contains("open")).to.equal(false); expect(folder("lazy").classList.contains("open")).to.equal(false); expect(folder("src").querySelector("ul")).to.equal(null); folder("src").querySelector("a").click(); await delay(10); expect(folder("src").textContent).to.include("a.js"); folder("src").querySelector("a").click(); await delay(10); expect(folder("src").querySelector("ul")).to.equal(null); await ui.go("/r/test/src/a.js"); expect(folder("src").classList.contains("open")).to.equal(true); expect(folder("lazy").classList.contains("open")).to.equal(false); folder("lazy").querySelector("a").click(); await delay(10); expect(ui.requests.filter(r => r.url.pathname === "/api/repo/test/files/" && r.url.searchParams.get("path") === "lazy")).to.have.length(1); folder("lazy").querySelector("a").click(); await delay(10); expect(ui.requests.filter(r => r.url.pathname === "/api/repo/test/files/" && r.url.searchParams.get("path") === "lazy")).to.have.length(1); expect(ui.errors).to.deep.equal([]); }); it("reuses query-only routes but reloads PR and gist data when their IDs change", async function () { ui = await browser("/pr/first/"); await ui.go("/pr/second/"); await ui.go("/gist/first/"); await ui.go("/gist/second/"); expect(ui.requests.some(r => r.url.pathname === "/api/pr/second/content")).to.equal(true); expect(ui.requests.some(r => r.url.pathname === "/api/gist/second/content")).to.equal(true); const count = ui.requests.length; await ui.go("/gist/second/?tab=comments"); expect(ui.requests).to.have.length(count); expect(ui.errors).to.deep.equal([]); }); it("keeps rendered HTML in an opaque sandbox when scripts are enabled", async function () { ui = await browser("/r/test/report.html", { "/api/repo/test/file/report.html": '