import { join } from "node:path"; import { assertStringIncludes } from "@std/assert"; import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; import { formatSlowTestsReport, JUNIT_PATH, parseJunitDurations, readSlowTestsReport, SLOW_TEST_THRESHOLD_MS, slowTests, type TestDuration, } from "#scripts/test-durations.ts"; import { withTempFile } from "#test-utils/files.ts"; /** Build a minimal JUnit document wrapping the given `` fragments. */ const junit = (cases: string): string => `\n\n${cases}\n`; const testcase = (attrs: Record, body = ""): string => ` `${k}="${v}"`) .join(" ")}>${body}`; /** Run `body` with a temp file holding `xml`'s content, removed afterwards. */ const withTempJunitFile = ( run: (path: string) => Result | Promise, ): Promise => withTempFile(run, { suffix: ".xml" }); /** Parse a JUnit doc built from the given `` fragments. */ const parseCases = (...cases: string[]): TestDuration[] => parseJunitDurations(junit(cases.join("\n"))); const CASE_QUICK = testcase({ classname: "test/lib/foo.test.ts", line: "3", name: "quick test", time: "0.001", }); const CASE_SLOW = testcase({ classname: "test/lib/foo.test.ts", line: "7", name: "slow test", time: "0.750", }); const CASE_NESTED = testcase({ classname: "https://jsr.io/@std/testing/1.0.17/_test_suite.ts", line: "171", name: "suite > nested", time: "0.600", }); const CASE_FAIL = testcase( { classname: "test/lib/foo.test.ts", line: "12", name: "fails", time: "0.002", }, 'boom', ); const CASE_SELF_CLOSING = ``; const CASE_AT_THRESHOLD = testcase({ classname: "test/lib/thresh.test.ts", line: "1", name: "exactly threshold", time: "0.500", }); const PARSED = parseJunitDurations( junit([CASE_QUICK, CASE_SLOW, CASE_NESTED, CASE_FAIL].join("\n")), ); describe("parseJunitDurations", () => { test("parses name, file, duration (s→ms), and line from each testcase", () => { expect(PARSED).toEqual([ { durationMs: 1, file: "test/lib/foo.test.ts", line: 3, name: "quick test", }, { durationMs: 750, file: "test/lib/foo.test.ts", line: 7, name: "slow test", }, { durationMs: 600, file: "", line: 171, name: "suite > nested", }, { durationMs: 2, file: "test/lib/foo.test.ts", line: 12, name: "fails" }, ]); }); test("rounds fractional milliseconds (not truncates/floors)", () => { const parsed = parseCases( testcase({ classname: "t.ts", line: "1", name: "a", time: "0.001" }), testcase({ classname: "t.ts", line: "2", name: "b", time: "0.7509" }), testcase({ classname: "t.ts", line: "3", name: "c", time: "0.7504" }), ); expect(parsed.map((d) => d.durationMs)).toEqual([1, 751, 750]); }); test("skips entries with no parseable `time`", () => { const parsed = parseCases( testcase({ classname: "t.ts", line: "1", name: "no time" }), testcase({ classname: "t.ts", line: "2", name: "bad time", time: "n/a" }), testcase({ classname: "t.ts", line: "3", name: "ok", time: "0.1" }), ); expect(parsed.map((d) => d.name)).toEqual(["ok"]); }); test("skips entries with no `name`", () => { const parsed = parseCases( testcase({ classname: "t.ts", line: "1", time: "0.1" }), testcase({ classname: "t.ts", line: "2", name: "ok", time: "0.1" }), ); expect(parsed.map((d) => d.name)).toEqual(["ok"]); }); test("handles self-closing testcases", () => { const parsed = parseJunitDurations(junit(CASE_SELF_CLOSING)); expect(parsed).toEqual([ { durationMs: 500, file: "test/lib/bar.test.ts", line: 1, name: "self closing", }, ]); }); test("still parses attrs of a testcase that contains a ", () => { const parsed = parseJunitDurations(junit(CASE_FAIL)); expect(parsed).toEqual([ { durationMs: 2, file: "test/lib/foo.test.ts", line: 12, name: "fails" }, ]); }); test("unescapes XML entities in attribute values", () => { const parsed = parseJunitDurations( junit( testcase({ classname: "t.ts", line: "1", name: "a & b > c < d "e" 'f'", time: "0.0", }), ), ); expect(parsed[0]?.name).toBe(`a & b > c < d "e" 'f'`); }); test("treats internal classnames (ext:/http/node:) as no user file", () => { const parsed = parseCases( testcase({ classname: "ext:cli/40_test.js", line: "9", name: "ext", time: "0.1", }), testcase({ classname: "https://jsr.io/x/y.ts", line: "9", name: "http", time: "0.1", }), testcase({ classname: "node:internal", line: "9", name: "node", time: "0.1", }), testcase({ classname: "test/lib/u.test.ts", line: "9", name: "user", time: "0.1", }), ); expect(parsed.map((d) => d.file)).toEqual([ "", "", "", "test/lib/u.test.ts", ]); }); test("relativizes an absolute user-file classname under the project root", () => { const abs = join(Deno.cwd(), "test/lib/abs.test.ts"); const parsed = parseJunitDurations( junit(testcase({ classname: abs, line: "4", name: "abs", time: "0.1" })), ); expect(parsed[0]?.file).toBe("test/lib/abs.test.ts"); }); test("keeps an absolute classname that lives outside the project root as-is", () => { const outside = "/absolutely-outside.test.ts"; const parsed = parseJunitDurations( junit( testcase({ classname: outside, line: "4", name: "x", time: "0.1" }), ), ); expect(parsed[0]?.file).toBe(outside); }); test("sets line to undefined when the testcase has no `line` attr", () => { const parsed = parseCases( testcase({ classname: "test/lib/no-line.test.ts", name: "noline", time: "0.1", }), ); expect(parsed[0]?.line).toBeUndefined(); expect(parsed[0]?.file).toBe("test/lib/no-line.test.ts"); expect(parsed[0]?.durationMs).toBe(100); }); test("returns [] for empty input", () => { expect(parseJunitDurations("")).toEqual([]); }); }); describe("slowTests", () => { test("keeps only durations strictly over the threshold, slowest first", () => { const slow = slowTests(PARSED); expect(slow.map((d) => d.name)).toEqual(["slow test", "suite > nested"]); expect(slow.map((d) => d.durationMs)).toEqual([750, 600]); }); test("a test at exactly the threshold is not slow (> is strict)", () => { const at = parseJunitDurations(junit(CASE_AT_THRESHOLD)); expect(slowTests(at)).toEqual([]); expect(slowTests(at, 499).map((d) => d.name)).toEqual([ "exactly threshold", ]); }); test("threshold defaults to SLOW_TEST_THRESHOLD_MS (500)", () => { expect(slowTests(PARSED, SLOW_TEST_THRESHOLD_MS)).toEqual( slowTests(PARSED), ); }); test("threshold 0 surfaces every entry, sorted by duration descending", () => { const all = slowTests(PARSED, 0); expect(all.map((d) => d.durationMs)).toEqual([750, 600, 2, 1]); }); test("returns [] when nothing is slow", () => { expect(slowTests(PARSED, 10_000)).toEqual([]); }); }); describe("formatSlowTestsReport", () => { test("is empty when nothing exceeds the threshold", () => { expect(formatSlowTestsReport(PARSED, 10_000)).toBe(""); }); test("lists slow tests slowest-first with duration, name, and location", () => { expect(formatSlowTestsReport(PARSED)).toBe( [ "Slow tests (>500ms), slowest first:", "", " 750ms slow test (test/lib/foo.test.ts:7)", " 600ms suite > nested", ].join("\n"), ); }); test("right-aligns durations to the widest entry", () => { const durations: TestDuration[] = [ { durationMs: 1234, file: "a.ts", line: 1, name: "big" }, { durationMs: 600, file: "b.ts", line: 2, name: "small" }, ]; expect(formatSlowTestsReport(durations)).toBe( [ "Slow tests (>500ms), slowest first:", "", " 1234ms big (a.ts:1)", " 600ms small (b.ts:2)", ].join("\n"), ); }); test("omits location when there is no user file, and when line is absent", () => { const durations: TestDuration[] = [ { durationMs: 700, file: "", name: "no file" }, { durationMs: 650, file: "c.ts", name: "no line" }, ]; expect(formatSlowTestsReport(durations)).toBe( [ "Slow tests (>500ms), slowest first:", "", " 700ms no file", " 650ms no line (c.ts)", ].join("\n"), ); }); test("header reports the threshold actually applied", () => { const durations: TestDuration[] = [ { durationMs: 1000, file: "", name: "x" }, ]; assertStringIncludes( formatSlowTestsReport(durations, 999), "Slow tests (>999ms)", ); }); }); describe("readSlowTestsReport", () => { test('returns "" for a missing file', async () => { await expect( readSlowTestsReport(join(Deno.cwd(), ".does-not-exist-junit.xml")), ).resolves.toBe(""); }); test("reads, parses, and formats the JUnit file at the given path", async () => { await withTempJunitFile(async (path) => { await Deno.writeTextFile(path, junit([CASE_SLOW, CASE_QUICK].join("\n"))); await expect(readSlowTestsReport(path)).resolves.toBe( [ "Slow tests (>500ms), slowest first:", "", " 750ms slow test (test/lib/foo.test.ts:7)", ].join("\n"), ); }); }); test('returns "" when the file exists but has no slow tests', async () => { await withTempJunitFile(async (path) => { await Deno.writeTextFile(path, junit(CASE_QUICK)); await expect(readSlowTestsReport(path)).resolves.toBe(""); }); }); test("JUNIT_PATH points under the project root", () => { expect(JUNIT_PATH).toBe(join(Deno.cwd(), ".test-junit.xml")); }); });