import { describe, expect, it } from "vitest"; import { buildJunitXml } from "./junit.js"; import type { CrawlReport, CrawlSummary, PageError, PageResult } from "./types.js"; function summary(overrides: Partial = {}): CrawlSummary { return { successPages: 0, errorPages: 0, timeoutPages: 0, recoveredPages: 0, pagesWithErrors: 0, consoleErrors: 0, networkErrors: 0, jsExceptions: 0, unhandledRejections: 0, invariantViolations: 0, avgLoadTime: 0, ...overrides, }; } function page(url: string, overrides: Partial = {}): PageResult { return { url, status: "success", loadTime: 250, errors: [], hasErrors: false, warnings: [], links: [], ...overrides, }; } function report(pages: PageResult[], overrides: Partial = {}): CrawlReport { return { baseUrl: "http://localhost:3000", seed: 42, reproCommand: "chaosbringer --url http://localhost:3000", startTime: 0, endTime: 1500, duration: 1500, pagesVisited: pages.length, totalErrors: pages.reduce((n, p) => n + p.errors.length, 0), totalWarnings: 0, blockedExternalNavigations: 0, recoveryCount: 0, pages, actions: [], summary: summary(), errorClusters: [], ...overrides, }; } describe("buildJunitXml", () => { it("emits a Surefire-style header with totals", () => { const xml = buildJunitXml( report([page("http://localhost:3000/"), page("http://localhost:3000/about")]) ); expect(xml).toMatch(/^<\?xml version="1.0" encoding="UTF-8"\?>/); expect(xml).toContain(' { const xml = buildJunitXml(report([page("http://localhost:3000/")])); expect(xml).toContain(''); }); it("strips the baseUrl prefix from the testcase name", () => { const xml = buildJunitXml(report([page("http://localhost:3000/docs/intro")])); expect(xml).toContain('name="/docs/intro"'); }); it("keeps full URLs when the page is on a different origin", () => { const xml = buildJunitXml( report([page("https://other.example.com/x")], { baseUrl: "http://localhost:3000", }) ); expect(xml).toContain('name="https://other.example.com/x"'); }); it("matches the baseUrl on a path boundary, not a raw prefix", () => { // /application must NOT be truncated to "lication" just because the // baseUrl path is /app — the testcase name must not be "lication". const xml = buildJunitXml( report([page("https://site.example.com/application")], { baseUrl: "https://site.example.com/app", }) ); expect(xml).not.toContain('name="lication"'); expect(xml).toContain('name="https://site.example.com/application"'); }); it("strips the baseUrl prefix when the baseUrl has a path subtree", () => { const xml = buildJunitXml( report([page("https://site.example.com/app/page")], { baseUrl: "https://site.example.com/app", }) ); expect(xml).toContain('name="/page"'); }); it("preserves the leading slash even when the baseUrl ends with /", () => { const xml = buildJunitXml( report([page("http://localhost:3000/docs/intro")], { baseUrl: "http://localhost:3000/", }) ); expect(xml).toContain('name="/docs/intro"'); }); it("includes the query and hash in the test name", () => { const xml = buildJunitXml( report([page("http://localhost:3000/search?q=foo#hits")]) ); // attribute is XML-escaped: ? stays, # stays, & is escaped if present expect(xml).toContain('name="/search?q=foo#hits"'); }); it("falls back to the full URL when the page is on the same origin but outside the baseUrl path", () => { const xml = buildJunitXml( report([page("https://site.example.com/other")], { baseUrl: "https://site.example.com/app", }) ); expect(xml).toContain('name="https://site.example.com/other"'); }); it("emits for status=timeout", () => { const xml = buildJunitXml( report([page("http://localhost:3000/slow", { status: "timeout" })]) ); expect(xml).toContain(" for status=error with the HTTP code in the message", () => { const xml = buildJunitXml( report([page("http://localhost:3000/missing", { status: "error", statusCode: 500 })]) ); expect(xml).toContain(" for a successful page with console errors", () => { const err: PageError = { type: "console", message: "boom", timestamp: 0, }; const xml = buildJunitXml( report([page("http://localhost:3000/", { errors: [err], hasErrors: true })]) ); expect(xml).toContain(" { const errs: PageError[] = [ { type: "console", message: "a", timestamp: 0 }, { type: "exception", message: "b", timestamp: 0 }, ]; const xml = buildJunitXml( report([page("http://localhost:3000/x", { errors: errs, hasErrors: true })]) ); expect(xml).toContain("[console] a"); expect(xml).toContain("[exception] b"); expect(xml).toContain("console,exception"); }); it("escapes XML special characters in messages and URLs", () => { const err: PageError = { type: "console", message: `Error: "tag" & 'quotes'`, timestamp: 0, }; const xml = buildJunitXml( report([page("http://localhost:3000/?q=", { errors: [err], hasErrors: true })]) ); expect(xml).not.toMatch(//); expect(xml).toContain("<html>"); expect(xml).toContain(""tag""); expect(xml).toContain("&"); expect(xml).toContain("'quotes'"); }); it("annotates invariant-violation entries with the invariant name", () => { const err: PageError = { type: "invariant-violation", message: "no

", timestamp: 0, invariantName: "has-h1", }; const xml = buildJunitXml( report([page("http://localhost:3000/", { errors: [err], hasErrors: true })]) ); expect(xml).toContain("[invariant-violation:has-h1]"); }); it("handles an empty report", () => { const xml = buildJunitXml(report([])); expect(xml).toContain('tests="0"'); expect(xml).toContain(""); }); it("respects custom suiteName and classname", () => { const xml = buildJunitXml(report([page("http://localhost:3000/")]), { suiteName: "smoke", classname: "e2e.chaos", }); expect(xml).toContain('name="smoke"'); expect(xml).toContain('classname="e2e.chaos"'); }); });