diff --git a/.azure-pipelines/scripts/run-typescript-pr-dt.sh b/.azure-pipelines/scripts/run-typescript-pr-dt.sh index e17955a561..6807988eef 100644 --- a/.azure-pipelines/scripts/run-typescript-pr-dt.sh +++ b/.azure-pipelines/scripts/run-typescript-pr-dt.sh @@ -44,3 +44,5 @@ node "$tools_path/packages/dtslint-runner/dist/index.js" \ --writeFailures "$failures" 3>&1 1>&2 2>&3 | tee -a "$errors" set -o pipefail popd >/dev/null + +node "$tools_path/packages/dtslint-runner/dist/add-github-links.js" "$failures" "$dt_path" diff --git a/.changeset/friendly-dt-links.md b/.changeset/friendly-dt-links.md new file mode 100644 index 0000000000..04b754b503 --- /dev/null +++ b/.changeset/friendly-dt-links.md @@ -0,0 +1,5 @@ +--- +"@definitelytyped/dtslint-runner": patch +--- + +Add commit-pinned DefinitelyTyped diagnostic and project links to run dt results, paginate GitHub comments, and preserve full output with explicit URLs in build logs. diff --git a/packages/dtslint-runner/package.json b/packages/dtslint-runner/package.json index 8c45aa6bbc..52c3cbea8b 100644 --- a/packages/dtslint-runner/package.json +++ b/packages/dtslint-runner/package.json @@ -27,6 +27,7 @@ "@definitelytyped/dtslint": "workspace:*", "@definitelytyped/utils": "workspace:*", "@octokit/rest": "^20.1.2", + "@typescript/github-link": "^0.2.4", "yargs": "^17.7.2" }, "devDependencies": { diff --git a/packages/dtslint-runner/src/add-github-links.ts b/packages/dtslint-runner/src/add-github-links.ts new file mode 100644 index 0000000000..10219a5dd0 --- /dev/null +++ b/packages/dtslint-runner/src/add-github-links.ts @@ -0,0 +1,108 @@ +import { getGithubLinks, SourceLocation } from "@typescript/github-link"; +import { existsSync, readFileSync, realpathSync, writeFileSync } from "fs"; +import { isAbsolute, relative, resolve, sep } from "path"; + +export interface Failure { + path: string; + error: string; + packageUrl?: string; + projects?: { path: string; url: string }[]; + errorLinks?: { start: number; end: number; url: string }[]; +} + +export async function addGithubLinks(failures: Failure[], definitelyTypedPath: string): Promise { + const typesPath = realpathSync(resolve(definitelyTypedPath, "types")); + const locations: SourceLocation[] = []; + const setters: ((url: string) => void)[] = []; + + function addLocation(file: string, lineNumber: number | undefined, setUrl: (url: string) => void) { + if (!existsSync(file)) { + return; + } + const path = realpathSync(file); + const relativePath = relative(typesPath, path); + // Resolve workspace symlinks, but don't link installed or external compiler files to DT. + if ( + isAbsolute(relativePath) || + relativePath === ".." || + relativePath.startsWith(`..${sep}`) || + relativePath.split(sep).includes("node_modules") + ) { + return; + } + locations.push({ path, lineNumber }); + setters.push(setUrl); + } + + for (const failure of failures) { + const packagePath = resolve(typesPath, failure.path); + addLocation(resolve(packagePath, "package.json"), undefined, (url) => { + failure.packageUrl = url.replace(/\/blob\//, "/tree/").replace(/\/package\.json$/, ""); + }); + const packageJsonPath = resolve(packagePath, "package.json"); + const projects: NonNullable = []; + failure.projects = projects; + if (existsSync(packageJsonPath)) { + const packageJson: { tsconfigs?: unknown } = JSON.parse(readFileSync(packageJsonPath, "utf8")); + const tsconfigs = packageJson.tsconfigs ?? ["tsconfig.json"]; + if (!Array.isArray(tsconfigs) || !tsconfigs.every((config): config is string => typeof config === "string")) { + throw new Error(`Invalid tsconfigs in ${packageJsonPath}`); + } + for (const config of tsconfigs) { + addLocation(resolve(packagePath, config), undefined, (url) => { + projects.push({ path: config, url }); + }); + } + } + const links: NonNullable = []; + failure.errorLinks = links; + let stylishFile: string | undefined; + for (const match of failure.error.matchAll(/^.*$/gm)) { + const line = match[0].replace(/\r$/, ""); + const position = /^(.+?\.(?:[cm]?tsx?|json))(?::(\d+):(\d+)|\((\d+),(\d+)\))/.exec(line); + const fileHeader = /^(.+\.(?:[cm]?tsx?|json))$/.exec(line); + const stylishPosition = /^\s+(\d+):(\d+)\s+(?:error|warning)\b/.exec(line); + if (position) { + stylishFile = undefined; + const file = resolve(packagePath, position[1]); + addLocation(file, Number(position[2] ?? position[4]), (url) => { + links.push({ start: match.index, end: match.index + position[0].length, url }); + }); + } else if (fileHeader) { + stylishFile = resolve(packagePath, fileHeader[1]); + addLocation(stylishFile, undefined, (url) => { + links.push({ start: match.index, end: match.index + line.length, url }); + }); + } else if (stylishPosition && stylishFile) { + const start = match.index + line.indexOf(stylishPosition[1]); + addLocation(stylishFile, Number(stylishPosition[1]), (url) => { + links.push({ start, end: start + `${stylishPosition[1]}:${stylishPosition[2]}`.length, url }); + }); + } else if (line && !/^\s/.test(line)) { + stylishFile = undefined; + } + } + } + + const urls = await getGithubLinks(locations); + urls.forEach((url, index) => { + // github-link returns file: URLs for untracked files. + if (url.startsWith("https://github.com/DefinitelyTyped/DefinitelyTyped/")) { + setters[index](url); + } + }); +} + +if (require.main === module) { + const [failuresPath, definitelyTypedPath] = process.argv.slice(2); + if (!failuresPath || !definitelyTypedPath) { + throw new Error("Usage: add-github-links "); + } + const failures: Failure[] = JSON.parse(readFileSync(failuresPath, "utf8")); + addGithubLinks(failures, definitelyTypedPath) + .then(() => writeFileSync(failuresPath, JSON.stringify(failures), "utf8")) + .catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/packages/dtslint-runner/src/post-results.ts b/packages/dtslint-runner/src/post-results.ts index 91c20e54c6..e899c68774 100644 --- a/packages/dtslint-runner/src/post-results.ts +++ b/packages/dtslint-runner/src/post-results.ts @@ -1,11 +1,12 @@ import { Octokit } from "@octokit/rest"; import { readFileSync } from "fs"; import glob = require("glob"); +import type { Failure } from "./add-github-links"; -type Errors = { path: string; error: string }[]; +type Errors = Failure[]; // Args: [auth token] [buildId] [status comment] [user to tag] [issue] [distinct id] [job status] [?main errors file] [?branch errors file] -async function main() { +export async function main() { const [auth, buildId, statusCommentId, userToTag, issue, distinctId, status, mainErrorsPath, branchErrorsPath] = process.argv.slice(2); if (!auth) throw new Error("First argument must be a GitHub auth token."); @@ -28,12 +29,13 @@ async function main() { const buildUrl = new URL(buildPath, collectionUri); checkLogsMessage = `\n\n[You can check the log here](${buildUrl}).`; - let newComment; + let comments: string[]; let emoji = "โœ…"; if (status === "fail") { - newComment = + comments = [ `Hey @${userToTag}, it looks like the DT test run failed. Please check the log for more details.` + - checkLogsMessage; + checkLogsMessage, + ]; emoji = "โŒ"; } else { const mainErrors: Errors = []; @@ -51,39 +53,39 @@ async function main() { } } - newComment = `Hey @${userToTag}, the results of running the DT tests are ready.`; - const diffComment = getDiffComment(mainErrors, branchErrors); - if (diffComment) { - emoji = "๐Ÿ‘€"; - newComment += `\n\nThere were interesting changes:`; - if (newComment.length + diffComment.length + checkLogsMessage.length > 65535) { - const jobId = process.env.SYSTEM_JOBID; - const taskId = process.env.SYSTEM_TASKINSTANCEID; - if (!jobId) throw new Error("SYSTEM_JOBID must be set."); - if (!taskId) throw new Error("SYSTEM_TASKINSTANCEID must be set."); - const detailedLogUrl = new URL( - `${buildPath}&view=logs&j=${encodeURIComponent(jobId)}&t=${encodeURIComponent(taskId)}`, - collectionUri, - ); - newComment += `\n\nChanges are too big to display here, please check [the log](${detailedLogUrl}).`; - console.log("There were interesting changes:\n"); - console.log(diffComment); - console.log("\n"); - } else { - newComment += "\n\n" + diffComment; + const reports = getDiffReports(mainErrors, branchErrors); + if (reports.length) { + if (reports.some((report) => report.branch)) { + emoji = "๐Ÿ‘€"; } + console.log(formatDiffLog(reports)); + const jobId = process.env.SYSTEM_JOBID; + const taskId = process.env.SYSTEM_TASKINSTANCEID; + const logUrl = + jobId && taskId + ? new URL( + `${buildPath}&view=logs&j=${encodeURIComponent(jobId)}&t=${encodeURIComponent(taskId)}`, + collectionUri, + ) + : buildUrl; + comments = getResultComments(mainErrors, branchErrors, userToTag, logUrl.toString()); } else { - newComment += "\n\nEverything looks the same!"; + comments = [ + `Hey @${userToTag}, the results of running the DT tests are ready.\n\nEverything looks the same!${checkLogsMessage}`, + ]; } - newComment += checkLogsMessage; } - const resultsComment = await gh.issues.createComment({ - issue_number: +issue, - owner: "Microsoft", - repo: "TypeScript", - body: newComment, - }); + const resultUrls: string[] = []; + for (const body of comments) { + const result = await gh.issues.createComment({ + issue_number: +issue, + owner: "Microsoft", + repo: "TypeScript", + body, + }); + resultUrls.push(result.data.html_url); + } const toReplace = ``; let posted = false; @@ -101,7 +103,10 @@ async function main() { break; } - const newComment = oldComment.replace(toReplace, `[${emoji} Results](${resultsComment.data.html_url})`); + const newComment = oldComment.replace( + toReplace, + resultUrls.map((url, index) => `[${index ? `Part ${index + 1}` : `${emoji} Results`}](${url})`).join(" ยท "), + ); // Update status comment await gh.rest.issues.updateComment({ @@ -130,7 +135,18 @@ async function main() { } } -function getDiffComment(main: Errors, branch: Errors): string | undefined { +export function getDiffComment(main: Errors, branch: Errors): string | undefined { + const reports = getDiffReports(main, branch); + return reports.length ? reports.map((report) => formatReport(report)).join("\n\n") : undefined; +} + +interface DiffReport { + title: string; + main?: Failure; + branch?: Failure; +} + +function getDiffReports(main: Errors, branch: Errors): DiffReport[] { const mainMap = new Map(main.map((error) => [error.path, error])); const branchMap = new Map(branch.map((error) => [error.path, error])); @@ -142,7 +158,7 @@ function getDiffComment(main: Errors, branch: Errors): string | undefined { if (branchMap.has(path)) { const branchError = branchMap.get(path)!; if (branchError.error !== error.error) { - bothChanged.push({ path, mainError: error.error, branchError: branchError.error }); + bothChanged.push({ main: error, branch: branchError }); } } else { mainOnly.push(error); @@ -157,47 +173,147 @@ function getDiffComment(main: Errors, branch: Errors): string | undefined { } } - if (!mainOnly.length && !bothChanged.length && !branchOnly.length) { - return undefined; + return [ + ...branchOnly.map((branch) => ({ title: "Branch only errors:", branch })), + ...bothChanged.map((errors) => ({ title: "Errors that changed between main and the branch:", ...errors })), + ...mainOnly.map((main) => ({ title: "Main only errors:", main })), + ]; +} + +function formatReport(report: DiffReport, maxErrorLength = Infinity): string { + const errors = [ + ...(report.main ? [`Main error:\n${formatProjects(report.main)}${formatError(report.main, maxErrorLength)}`] : []), + ...(report.branch + ? [`Branch error:\n${formatProjects(report.branch)}${formatError(report.branch, maxErrorLength)}`] + : []), + ]; + return `
\n${report.title}\n\n${formatPackage((report.branch ?? report.main)!)}\n${errors.join("\n")}\n
`; +} + +export function getResultComments(main: Errors, branch: Errors, userToTag: string, logUrl: string): string[] { + const maxLength = 65535; + const footer = `\n\n[Full output in the log](${logUrl}).`; + const firstHeader = `Hey @${userToTag}, the results of running the DT tests are ready.\n\nThere were interesting changes:`; + const continuationHeader = `Hey @${userToTag}, here are more DT test results:`; + const truncation = "\n\nThis package report was truncated; see the log for full output."; + const comments: string[] = []; + let header = firstHeader; + let body = ""; + + function flush() { + comments.push(header + body + footer); + header = continuationHeader; + body = ""; } - const branchOnlyMessage = branchOnly.length - ? ` -
-Branch only errors: + for (const report of getDiffReports(main, branch)) { + let rendered = formatReport(report); + if (body && header.length + body.length + 2 + rendered.length + footer.length > maxLength) { + flush(); + } + const available = maxLength - header.length - 2 - footer.length; + if (rendered.length > available) { + // Truncate source text, never serialized HTML (which could split an entity or a link). + let low = 0; + let high = Math.max(report.main?.error.length ?? 0, report.branch?.error.length ?? 0); + if (formatReport(report, 0).length + truncation.length > available) { + throw new Error("Package report metadata exceeds the GitHub comment size limit."); + } + while (low < high) { + const mid = Math.ceil((low + high) / 2); + if (formatReport(report, mid).length + truncation.length <= available) { + low = mid; + } else { + high = mid - 1; + } + } + rendered = formatReport(report, low) + truncation; + } + body += "\n\n" + rendered; + } + if (body) { + flush(); + } + return comments; +} -${branchOnly.map((err) => `Package: ${err.path}\nError:\n\`\`\`\n${err.error}\n\`\`\``).join("\n\n")} -
-` - : ""; - const mainOnlyMessage = mainOnly.length - ? ` -
-Main only errors: - -${mainOnly.map((err) => `Package: ${err.path}\nError:\n\`\`\`\n${err.error}\n\`\`\``).join("\n\n")} -
-` - : ""; - const bothChangedMessage = bothChanged.length - ? ` -
-Errors that changed between main and the branch: - -${bothChanged - .map( - (err) => - `Package: ${err.path}\nMain error:\n\`\`\`\n${err.mainError}\n\`\`\`\nBranch error:\n\`\`\`\n${err.branchError}\n\`\`\``, - ) - .join("\n\n")} -
-` +export function getDiffLog(main: Errors, branch: Errors): string { + return formatDiffLog(getDiffReports(main, branch)); +} + +function formatDiffLog(reports: DiffReport[]): string { + return reports + .map((report) => { + const lines = [report.title]; + for (const [label, failure] of [ + ["Main", report.main], + ["Branch", report.branch], + ] as const) { + if (!failure) continue; + lines.push(`Package: ${failure.path}`, ...(failure.packageUrl ? [failure.packageUrl] : []), `${label} error:`); + for (const project of failure.projects ?? []) { + lines.push(`Project: ${project.path}`, project.url); + } + lines.push(failure.error); + for (const location of failure.errorLinks ?? []) { + lines.push(`${failure.error.slice(location.start, location.end)} -> ${location.url}`); + } + } + // Azure recognizes logging commands even when they aren't at the start of a line. + return lines + .join("\n") + .replace(/##(?=vso\[|\[)/gi, "# #") + .split(/\r?\n/) + .map((line) => ` ${line}`) + .join("\n"); + }) + .join("\n\n"); +} + +function escapeHtml(text: string): string { + return text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +function link(text: string, url: string | undefined): string { + const escaped = escapeHtml(text); + return url?.startsWith("https://github.com/DefinitelyTyped/DefinitelyTyped/") + ? `${escaped}` + : escaped; +} + +function formatPackage(error: Failure): string { + return `Package: ${link(error.path, error.packageUrl)}\n`; +} + +function formatProjects(error: Failure): string { + const projects = error.projects ?? []; + return projects.length + ? `\nProject scope: ${projects.map((project) => `${link(project.path, project.url)}`).join(", ")}\n\n` : ""; +} - return branchOnlyMessage + bothChangedMessage + mainOnlyMessage; +function formatError(error: Failure, maxLength = Infinity): string { + let end = Math.min(error.error.length, maxLength); + if (end > 0 && /[\uD800-\uDBFF]/.test(error.error[end - 1])) { + end--; + } + let result = ""; + let start = 0; + for (const location of error.errorLinks ?? []) { + if (location.end > end) break; + result += escapeHtml(error.error.slice(start, location.start)); + result += link(error.error.slice(location.start, location.end), location.url); + start = location.end; + } + result += escapeHtml(error.error.slice(start, end)); + if (end < error.error.length) result += "\n[... truncated ...]"; + // Fenced code blocks don't render links; HTML preserves both whitespace and clickable locations. + return `
${result}
\n`; } -main().catch((e) => { - console.error(e); - process.exit(1); -}); +if (require.main === module) { + main().catch((e) => { + console.error(e); + process.exit(1); + }); +} diff --git a/packages/dtslint-runner/test/results.test.ts b/packages/dtslint-runner/test/results.test.ts new file mode 100644 index 0000000000..7830ebafc6 --- /dev/null +++ b/packages/dtslint-runner/test/results.test.ts @@ -0,0 +1,390 @@ +import { execFileSync } from "child_process"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { dirname, join } from "path"; +import { addGithubLinks, Failure } from "../src/add-github-links"; +import { getDiffComment, getDiffLog, getResultComments, main } from "../src/post-results"; + +const mockCreateComment = jest.fn(); +const mockGetComment = jest.fn(); +const mockUpdateComment = jest.fn(); +jest.mock("@octokit/rest", () => ({ + Octokit: jest.fn().mockImplementation(() => ({ + issues: { createComment: mockCreateComment }, + rest: { issues: { getComment: mockGetComment, updateComment: mockUpdateComment } }, + })), +})); + +const repoUrl = "https://github.com/DefinitelyTyped/DefinitelyTyped"; +let checkout: string; +let commit: string; + +function git(...args: string[]): string { + return execFileSync("git", args, { cwd: checkout, encoding: "utf8" }).trim(); +} + +function writeFile(path: string, contents = ""): string { + const file = join(checkout, path); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, contents); + return file; +} + +beforeAll(() => { + checkout = mkdtempSync(join(tmpdir(), "dt-result-links-")); + git("init", "--quiet"); + writeFile("types/example/package.json", "{}"); + writeFile("types/example/tsconfig.json", "{}"); + writeFile("types/example/index.d.ts", "export {};\n"); + writeFile("types/example/example-tests.ts", "export {};\n"); + writeFile("types/example/v1/package.json", '{"tsconfigs":["tsconfig.json","tsconfig.other.json"]}'); + writeFile("types/example/v1/tsconfig.json", "{}"); + writeFile("types/example/v1/tsconfig.other.json", "{}"); + writeFile("types/example/v1/index.d.ts", "export {};\n"); + writeFile("types/dependency/package.json", "{}"); + writeFile("types/dependency/index.d.ts", "export {};\n"); + git("add", "."); + git( + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--quiet", + "-m", + "Fixture", + ); + commit = git("rev-parse", "HEAD"); + git("remote", "add", "origin", `${repoUrl}.git`); + git("update-ref", "refs/remotes/origin/master", commit); + git("checkout", "--quiet", "--detach", commit); + writeFile("types/example/untracked.ts"); + writeFile("types/example/node_modules/external/index.d.ts"); + writeFile("compiler/lib.d.ts"); + symlinkSync(join(checkout, "types/dependency"), join(checkout, "types/example/node_modules/dependency"), "dir"); +}); + +afterAll(() => { + rmSync(checkout, { recursive: true, force: true }); +}); + +test("adds pinned package and Corsa diagnostic links without changing the raw errors", async () => { + const file = join(checkout, "types/example/example-tests.ts"); + const error = `${file}:12:3\nTypeScript@local compile error TS2322: Type '' is not assignable.\n\nindex.d.ts:2:1\nExpected type.`; + const failures: Failure[] = [{ path: "example", error }]; + await addGithubLinks(failures, checkout); + expect(failures[0].error).toBe(error); + expect(failures[0].packageUrl).toBe(`${repoUrl}/tree/${commit}/types/example`); + expect(failures[0].errorLinks?.map(({ start, end, url }) => [error.slice(start, end), url])).toEqual([ + [`${file}:12:3`, `${repoUrl}/blob/${commit}/types/example/example-tests.ts#L12`], + ["index.d.ts:2:1", `${repoUrl}/blob/${commit}/types/example/index.d.ts#L2`], + ]); + const comment = getDiffComment([], failures)!; + expect(comment).toContain(`example`); + expect(comment).toContain( + `
${file}:12:3`,
+  );
+  expect(comment).toContain("Type '<T>' is not assignable.");
+});
+
+test("links ESLint stylish headers and individual error lines, including CRLF", async () => {
+  const file = join(checkout, "types/example/index.d.ts");
+  const error = `\r\n${file}\r\n  2:3  error  First error\r\n       With elaboration\r\n  4:5  warning  Second error\r\n\r\n2 problems`;
+  const failures: Failure[] = [{ path: "example", error }];
+  await addGithubLinks(failures, checkout);
+  expect(failures[0].errorLinks?.map(({ start, end, url }) => [error.slice(start, end), url])).toEqual([
+    [file, `${repoUrl}/blob/${commit}/types/example/index.d.ts`],
+    ["2:3", `${repoUrl}/blob/${commit}/types/example/index.d.ts#L2`],
+    ["4:5", `${repoUrl}/blob/${commit}/types/example/index.d.ts#L4`],
+  ]);
+});
+
+test("resolves versioned packages, workspace dependencies, and tsc locations", async () => {
+  const failures: Failure[] = [
+    { path: "example/v1", error: "index.d.ts(3,4): error TS2322: Example" },
+    { path: "example", error: "node_modules/dependency/index.d.ts:5:6\nExample" },
+  ];
+  await addGithubLinks(failures, checkout);
+  expect(failures[0].packageUrl).toBe(`${repoUrl}/tree/${commit}/types/example/v1`);
+  expect(failures[0].errorLinks?.[0].url).toBe(`${repoUrl}/blob/${commit}/types/example/v1/index.d.ts#L3`);
+  expect(failures[1].errorLinks?.[0].url).toBe(`${repoUrl}/blob/${commit}/types/dependency/index.d.ts#L5`);
+});
+
+test("leaves untracked, missing, installed, and external files unlinked", async () => {
+  const failures: Failure[] = [
+    {
+      path: "example",
+      error: [
+        "untracked.ts:1:1",
+        "missing.ts:1:1",
+        "node_modules/external/index.d.ts:1:1",
+        `${join(checkout, "compiler/lib.d.ts")}:1:1`,
+        "Out of memory",
+      ].join("\n"),
+    },
+  ];
+  await addGithubLinks(failures, checkout);
+  expect(failures[0].errorLinks).toEqual([]);
+  expect(getDiffComment([], failures)).toContain(`
${failures[0].error}
`); +}); + +test("handles empty failure lists and fails explicitly when the checkout has no GitHub remote", async () => { + await expect(addGithubLinks([], checkout)).resolves.toBeUndefined(); + git("remote", "remove", "origin"); + try { + await expect(addGithubLinks([{ path: "example", error: "Out of memory" }], checkout)).rejects.toThrow( + "not present on any remote", + ); + } finally { + git("remote", "add", "origin", `${repoUrl}.git`); + git("update-ref", "refs/remotes/origin/master", commit); + } +}); + +test("compares only raw errors, not link metadata", () => { + expect( + getDiffComment( + [{ path: "example", error: "same" }], + [{ path: "example", error: "same", packageUrl: `${repoUrl}/tree/different/types/example`, errorLinks: [] }], + ), + ).toBeUndefined(); + expect(getDiffComment([], [])).toBeUndefined(); +}); + +test("renders all three difference categories, retaining each side's links", () => { + const mainUrl = `${repoUrl}/blob/base/types/example/index.d.ts#L1`; + const branchUrl = `${repoUrl}/blob/head/types/example/index.d.ts#L2`; + const comment = getDiffComment( + [ + { path: "fixed", error: "old failure" }, + { path: "changed", error: "old", errorLinks: [{ start: 0, end: 3, url: mainUrl }] }, + ], + [ + { path: "new", error: "new failure" }, + { path: "changed", error: "new", errorLinks: [{ start: 0, end: 3, url: branchUrl }] }, + ], + )!; + expect(comment).toContain("Branch only errors:"); + expect(comment).toContain("Main only errors:"); + expect(comment).toContain("Errors that changed between main and the branch:"); + expect(comment).toContain(`
old
`); + expect(comment).toContain(`
new
`); + expect(comment).toContain("
old failure
"); + expect(comment).toContain("
new failure
"); +}); + +test("escapes diagnostic HTML and refuses non-DT links", () => { + const error = '
\n```\n& '; + const comment = getDiffComment( + [], + [ + { + path: "", + error, + packageUrl: "javascript:alert(1)", + errorLinks: [{ start: 0, end: 6, url: "file:///private/file" }], + }, + ], + )!; + expect(comment).toContain("Package: <example>"); + expect(comment).toContain("</pre><script>alert("oops")</script>\n```\n& <T>"); + expect(comment).not.toContain("