Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions packages/core/src/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,16 @@ const remoteReportParams = (ci: CiDescriptor | undefined): { repo?: string; bran

const errorDetails = (err: unknown): string => (err instanceof Error ? (err.stack ?? err.message) : String(err));

const getExecutorReportUrl = (executor: unknown): string | undefined => {
if (!executor || typeof executor !== "object" || !("reportUrl" in executor)) {
return undefined;
}

const { reportUrl } = executor as { reportUrl?: unknown };

return typeof reportUrl === "string" && reportUrl.length > 0 ? reportUrl : undefined;
};

const closeReadStream = async (stream: ReadStream): Promise<void> => {
if (stream.closed) {
return;
Expand Down Expand Up @@ -243,6 +253,21 @@ export class AllureReport {
return this.#realtimeChannel.dispatcher;
}

#resolveHistoryReportUrl = async (): Promise<string> => {
if (this.reportUrl) {
return this.reportUrl;
}

const executorReportUrl = getExecutorReportUrl(await this.#store.metadataByKey("allure2_executor"));

if (executorReportUrl) {
this.reportUrl = executorReportUrl;
return executorReportUrl;
}

return "";
};

#publish = async (): Promise<void> => {
if (this.#published) {
return;
Expand All @@ -257,8 +282,9 @@ export class AllureReport {
if (!historyPoint) {
const allTrs = await this.#store.allTestResults();
const allTcs = await this.#store.allTestCases();
const historyReportUrl = await this.#resolveHistoryReportUrl();

historyPoint = createHistory(this.reportUuid, this.reportName, allTcs, allTrs, this.reportUrl);
historyPoint = createHistory(this.reportUuid, this.reportName, allTcs, allTrs, historyReportUrl);
this.#historyDataPoint = historyPoint;
}

Expand Down Expand Up @@ -1048,7 +1074,15 @@ export class AllureReport {
try {
const testResults = await this.#store.allTestResults();
const testCases = await this.#store.allTestCases();
this.#historyDataPoint = createHistory(this.reportUuid, this.reportName, testCases, testResults, this.reportUrl);
const historyReportUrl = await this.#resolveHistoryReportUrl();

this.#historyDataPoint = createHistory(
this.reportUuid,
this.reportName,
testCases,
testResults,
historyReportUrl,
);

this.#realtimeChannel.close();
try {
Expand Down
121 changes: 121 additions & 0 deletions packages/core/test/report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ const createSignal = () => {
return { promise, resolve };
};

const readHistoryEntries = async (historyPath: string) =>
(await readFile(historyPath, "utf-8"))
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line));

let previousCwd: string;

beforeEach(async () => {
Expand Down Expand Up @@ -358,6 +364,121 @@ describe("report", () => {
expect(historyContent.startsWith(initialHistoryContent)).toBe(true);
});

it("should populate appended history urls from allure2 executor reportUrl", async () => {
const output = await mkdtemp(join(tmpdir(), "allure3-executor-history-url-"));
const historyPath = join(await mkdtemp(join(tmpdir(), "allure3-executor-history-url-data-")), "history.jsonl");
const reportUrl = "https://jenkins.example/job/demo/42/allure";
const config = await resolveConfig({
name: "Allure Report",
output,
historyPath,
appendHistory: true,
});

const allureReport = new AllureReport(config);

await allureReport.start();
await allureReport.store.visitMetadata({
allure2_executor: {
reportUrl,
},
});
await allureReport.store.visitTestResult(
{
uuid: "executor-history-url-result",
name: "AdditionWorks",
testId: "addition-works",
status: "passed",
},
{ readerId: "test" },
);
await allureReport.done();

const [historyPoint] = await readHistoryEntries(historyPath);
const [historyTestResult] = Object.values(historyPoint.testResults);

expect(allureReport.reportUrl).toBe(reportUrl);
expect(historyPoint.url).toBe(reportUrl);
expect(historyTestResult).toEqual(expect.objectContaining({ url: reportUrl }));
});

it("should prefer plugin reportUrl over allure2 executor reportUrl for appended history", async () => {
const output = await mkdtemp(join(tmpdir(), "allure3-plugin-history-url-"));
const historyPath = join(await mkdtemp(join(tmpdir(), "allure3-plugin-history-url-data-")), "history.jsonl");
const pluginReportUrl = "https://allure.example/reports/plugin";
const executorReportUrl = "https://jenkins.example/job/demo/42/allure";
const p1 = createPlugin("p1");
const config = await resolveConfig({
name: "Allure Report",
output,
historyPath,
appendHistory: true,
});

(p1.plugin.start as Mock).mockImplementation(async (context) => {
context.reportUrl = pluginReportUrl;
});
(p1.plugin.done as Mock).mockImplementation(async (context) => {
await context.reportFiles.addFile("index.html", Buffer.from("index"));
});
config.plugins = [p1];

const allureReport = new AllureReport(config);

await allureReport.start();
await allureReport.store.visitMetadata({
allure2_executor: {
reportUrl: executorReportUrl,
},
});
await allureReport.store.visitTestResult(
{
uuid: "plugin-history-url-result",
name: "AdditionWorks",
testId: "addition-works",
status: "passed",
},
{ readerId: "test" },
);
await allureReport.done();

const [historyPoint] = await readHistoryEntries(historyPath);
const [historyTestResult] = Object.values(historyPoint.testResults);

expect(allureReport.reportUrl).toBe(pluginReportUrl);
expect(historyPoint.url).toBe(pluginReportUrl);
expect(historyTestResult).toEqual(expect.objectContaining({ url: pluginReportUrl }));
});

it("should expose allure2 executor reportUrl to plugin done hooks when no plugin overrides it", async () => {
const output = await mkdtemp(join(tmpdir(), "allure3-plugin-context-executor-url-"));
const reportUrl = "https://jenkins.example/job/demo/42/allure";
const p1 = createPlugin("p1");
const config = await resolveConfig({
name: "Allure Report",
output,
});
let pluginDoneReportUrl: string | undefined;

(p1.plugin.done as Mock).mockImplementation(async (context) => {
pluginDoneReportUrl = context.reportUrl;
});
config.plugins = [p1];

const allureReport = new AllureReport(config);

await allureReport.start();
await allureReport.store.visitMetadata({
allure2_executor: {
reportUrl,
},
});
await allureReport.done();

expect(pluginDoneReportUrl).toBe(reportUrl);
expect(allureReport.reportUrl).toBe(reportUrl);
});

it("should read result directory files with bounded concurrency", async () => {
const previousConcurrency = process.env.ALLURE_READ_CONCURRENCY;
const resultsDir = await mkdtemp(join(tmpdir(), "allure3-read-directory-"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { type FunctionalComponent } from "preact";
import { useMemo, useState } from "preact/hooks";
import type { ReportOptions } from "types";

import { getHistoryNavigationUrl } from "@/components/TestResult/historyNavigation";
import { TrError } from "@/components/TestResult/TrError";
import { useI18n } from "@/stores";
import { timestampToDate } from "@/utils/time";
Expand Down Expand Up @@ -51,17 +52,8 @@ export const TrHistoryItem: FunctionalComponent<Props> = (props) => {
const { t } = useI18n("controls");

const navigateUrl = useMemo(() => {
if (!url) {
return undefined;
}

const { origin, pathname } = new URL(url);
const navUrl = new URL([pathname, reportOptions.id].join("/"), origin);

navUrl.hash = id;

return navUrl.toString();
}, [id, url]);
return getHistoryNavigationUrl(url, reportOptions.id, id);
}, [id, reportOptions.id, url]);

const renderExternalLink = () => {
if (!navigateUrl) {
Expand All @@ -71,7 +63,7 @@ export const TrHistoryItem: FunctionalComponent<Props> = (props) => {
return (
<TooltipWrapper tooltipText={t("openInNewTab")}>
<IconButton
href={navigateUrl.toString()}
href={navigateUrl}
target={"_blank"}
icon={allureIcons.lineGeneralLinkExternal}
style={"ghost"}
Expand Down Expand Up @@ -105,9 +97,12 @@ export const TrHistoryItem: FunctionalComponent<Props> = (props) => {
<div data-testid={"test-result-history-item"}>
<div className={styles["test-result-history-item-header"]}>
{Boolean(error) && (
<span onClick={() => setIsOpen(!isOpened)}>
<ArrowButton isOpened={isOpened} icon={allureIcons.arrowsChevronDown} />
</span>
<ArrowButton
aria-label={"toggle history error"}
isOpened={isOpened}
icon={allureIcons.arrowsChevronDown}
onClick={() => setIsOpen((value) => !value)}
/>
)}
{navigateUrl ? (
<a href={navigateUrl} className={styles["test-result-history-item-wrap"]}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,26 @@ import { SvgIcon, Text, TooltipWrapper, allureIcons } from "@allurereport/web-co
import type { FunctionalComponent } from "preact";
import type { ReportOptions, ReportTestResult } from "types";

import { getHistoryNavigationUrl } from "@/components/TestResult/historyNavigation";
import { useI18n } from "@/stores";
import { timestampToDate } from "@/utils/time";

import * as styles from "./styles.scss";

const TrPrevStatus: FunctionalComponent<{ item: HistoryTestResult }> = ({ item }) => {
const reportOptions = getReportOptions<ReportOptions & { id: string }>();
const navigateUrl = getHistoryNavigationUrl(item.url, reportOptions.id, item.id);

if (!item.url) {
if (!navigateUrl) {
return (
<div className={styles["test-result-prev-status"]}>
<SvgIcon id={allureIcons.lineShapesDotCircle} className={styles[`status-${item?.status}`]} />
</div>
);
}

const { origin, pathname } = new URL(item.url);
const navigateUrl = new URL([pathname, reportOptions.id].join("/"), origin);

navigateUrl.hash = item.id;

return (
<a className={styles["test-result-prev-status"]} href={navigateUrl.toString()}>
<a className={styles["test-result-prev-status"]} href={navigateUrl}>
<SvgIcon id={allureIcons.lineShapesDotCircle} className={styles[`status-${item?.status}`]} />
</a>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export const getHistoryNavigationUrl = (
url: string | undefined,
reportId: string,
testResultId: string,
): string | undefined => {
if (!url) {
return undefined;
}

try {
const navUrl = new URL(url);
const pathname = navUrl.pathname.endsWith("/") ? navUrl.pathname : `${navUrl.pathname}/`;
const lastSegment = pathname.slice(0, -1).split("/").pop();

navUrl.pathname = lastSegment === reportId ? pathname : `${pathname}${reportId}/`;
navUrl.hash = testResultId;

return navUrl.toString();
} catch {
return undefined;
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { HistoryTestResult } from "@allurereport/core-api";
import { getReportOptions } from "@allurereport/web-commons";
import { cleanup, render, screen } from "@testing-library/preact";
import type { Mock } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";

import { TrHistoryItem } from "@/components/TestResult/TrHistory/TrHistoryItem";

vi.mock("@allurereport/web-commons", async (importOriginal) => ({
...(await importOriginal()),
getReportOptions: vi.fn(),
}));

vi.mock("@allurereport/web-components", () => ({
ArrowButton: () => (
<button aria-label="toggle history error" data-testid="test-result-history-item-arrow-button" type="button" />
),
IconButton: (props: { href?: string; target?: string; onClick?: (event: Event) => void; className?: string }) => (
<a href={props.href} target={props.target} className={props.className} onClick={props.onClick}>
external
</a>
),
Text: (props: { children: unknown; className?: string }) => <span className={props.className}>{props.children}</span>,
TooltipWrapper: (props: { children: unknown }) => props.children,
TreeItemIcon: () => <span data-testid="history-status" />,
allureIcons: {
arrowsChevronDown: "chevron",
lineGeneralLinkExternal: "external",
},
}));

vi.mock("@/components/TestResult/TrError", () => ({
TrError: () => <div data-testid="test-result-error" />,
}));

vi.mock("@/stores", () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}));

vi.mock("@/utils/time", () => ({
timestampToDate: (value: number) => `date:${value}`,
}));

const makeHistoryResult = (overrides: Partial<HistoryTestResult> = {}): HistoryTestResult => ({
id: "5bd0de6d8fe94b75be93ae8ee778dd9e",
name: "AdditionWorks",
status: "passed",
url: "http://127.0.0.1:58888/build-1",
historyId: "addition-works-history",
stop: 1000,
duration: 20,
reportLinks: [],
...overrides,
});

beforeEach(() => {
vi.clearAllMocks();
cleanup();
(getReportOptions as Mock).mockReturnValue({ id: "awesome" });
});

describe("components > TestResult > TrHistoryItem", () => {
it("should render links through the current report plugin directory", () => {
render(<TrHistoryItem historyTr={makeHistoryResult({ url: "http://127.0.0.1:58888" })} />);

expect(screen.getAllByRole("link").map((link) => link.getAttribute("href"))).toContain(
"http://127.0.0.1:58888/awesome/#5bd0de6d8fe94b75be93ae8ee778dd9e",
);
});

it("should render a non-link row for an invalid history url", () => {
render(<TrHistoryItem historyTr={makeHistoryResult({ url: "not-a-url" })} />);

expect(screen.getByTestId("test-result-history-item")).toBeInTheDocument();
expect(screen.queryByRole("link")).not.toBeInTheDocument();
});
});
Loading
Loading