From b54994ae51f1c540da2c05dde0b29d100044d008 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:29:45 -0700 Subject: [PATCH 1/2] Make DT run results navigable on GitHub DT diagnostics need direct links to the tested sources and project configs so compiler regressions are easier to investigate. Large runs also need readable results across comments, with complete linked output available in the build logs. --- .../scripts/run-typescript-pr-dt.sh | 2 + .changeset/friendly-dt-links.md | 5 + packages/dtslint-runner/package.json | 1 + .../dtslint-runner/src/add-github-links.ts | 108 ++++++ packages/dtslint-runner/src/post-results.ts | 260 +++++++++---- packages/dtslint-runner/test/results.test.ts | 366 ++++++++++++++++++ pnpm-lock.yaml | 9 + 7 files changed, 678 insertions(+), 73 deletions(-) create mode 100644 .changeset/friendly-dt-links.md create mode 100644 packages/dtslint-runner/src/add-github-links.ts create mode 100644 packages/dtslint-runner/test/results.test.ts 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..287686a578 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,37 @@ async function main() { } } - newComment = `Hey @${userToTag}, the results of running the DT tests are ready.`; - const diffComment = getDiffComment(mainErrors, branchErrors); - if (diffComment) { + const reports = getDiffReports(mainErrors, branchErrors); + if (reports.length) { 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; - } + 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 +101,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 +133,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 +156,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 +171,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 = ""; + } + + 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; +} - const branchOnlyMessage = branchOnly.length - ? ` -
-Branch only errors: +export function getDiffLog(main: Errors, branch: Errors): string { + return formatDiffLog(getDiffReports(main, branch)); +} -${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")} -
-` +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..e50f72397b --- /dev/null +++ b/packages/dtslint-runner/test/results.test.ts @@ -0,0 +1,366 @@ +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("