diff --git a/packages/allure-codeceptjs/test/spec/runtime/legacy/links.test.ts b/packages/allure-codeceptjs/test/spec/runtime/legacy/links.test.ts index 01d67c746..417a36c6b 100644 --- a/packages/allure-codeceptjs/test/spec/runtime/legacy/links.test.ts +++ b/packages/allure-codeceptjs/test/spec/runtime/legacy/links.test.ts @@ -30,16 +30,14 @@ it("sets runtime links", async () => { require: require.resolve("allure-codeceptjs"), testMode: true, enabled: true, - links: [ - { - type: "${LinkType.ISSUE}", + links: { + issue: { urlTemplate: "https://example.org/issues/%s", }, - { - type: "${LinkType.TMS}", + tms: { urlTemplate: "https://example.org/tasks/%s", } - ] + } }, }, helpers: { diff --git a/packages/allure-codeceptjs/test/spec/runtime/modern/links.test.ts b/packages/allure-codeceptjs/test/spec/runtime/modern/links.test.ts index 652decf61..8825fef2e 100644 --- a/packages/allure-codeceptjs/test/spec/runtime/modern/links.test.ts +++ b/packages/allure-codeceptjs/test/spec/runtime/modern/links.test.ts @@ -31,16 +31,14 @@ it("sets runtime links", async () => { require: require.resolve("allure-codeceptjs"), testMode: true, enabled: true, - links: [ - { - type: "${LinkType.ISSUE}", + links: { + issue: { urlTemplate: "https://example.org/issues/%s", }, - { - type: "${LinkType.TMS}", + tms: { urlTemplate: "https://example.org/tasks/%s", } - ] + } }, }, helpers: { diff --git a/packages/allure-cucumberjs/src/model.ts b/packages/allure-cucumberjs/src/model.ts index 6b3ee0b24..0c5e5eb00 100644 --- a/packages/allure-cucumberjs/src/model.ts +++ b/packages/allure-cucumberjs/src/model.ts @@ -1,5 +1,5 @@ -import type { LabelName, LinkType } from "allure-js-commons"; -import type { Config } from "allure-js-commons/sdk/reporter"; +import type { LabelName } from "allure-js-commons"; +import type { Config, LinkConfig, LinkTypeOptions } from "allure-js-commons/sdk/reporter"; export const ALLURE_SETUP_REPORTER_HOOK = "__allure_reporter_setup_hook__"; @@ -8,14 +8,10 @@ export type LabelConfig = { name: LabelName | string; }; -export type LinkConfig = { - pattern: RegExp[]; - urlTemplate: string; - type: LinkType | string; -}; +export type AllureCucumberLinkConfig = LinkConfig; export interface AllureCucumberReporterConfig extends Omit { testMode?: boolean; - links?: LinkConfig[]; + links?: AllureCucumberLinkConfig; labels?: LabelConfig[]; } diff --git a/packages/allure-cucumberjs/src/reporter.ts b/packages/allure-cucumberjs/src/reporter.ts index 95561c700..e124fb4bb 100644 --- a/packages/allure-cucumberjs/src/reporter.ts +++ b/packages/allure-cucumberjs/src/reporter.ts @@ -13,13 +13,13 @@ import { FileSystemWriter, MessageWriter, ReporterRuntime, + applyLinkTemplate, createStepResult, getWorstStepResultStatus, md5, } from "allure-js-commons/sdk/reporter"; -import type { Config } from "allure-js-commons/sdk/reporter"; import { AllureCucumberWorld } from "./legacy.js"; -import type { AllureCucumberReporterConfig, LabelConfig, LinkConfig } from "./model.js"; +import type { AllureCucumberLinkConfig, AllureCucumberReporterConfig, LabelConfig } from "./model.js"; import { ALLURE_SETUP_REPORTER_HOOK } from "./model.js"; const { ALLURE_THREAD_NAME } = process.env; @@ -28,7 +28,7 @@ export default class AllureCucumberReporter extends Formatter { private readonly afterHooks: Record = {}; private readonly beforeHooks: Record = {}; - private linksConfigs: LinkConfig[] = []; + private linksConfigs: AllureCucumberLinkConfig = {}; private labelsConfigs: LabelConfig[] = []; private allureRuntime: ReporterRuntime; @@ -59,10 +59,10 @@ export default class AllureCucumberReporter extends Formatter { : new FileSystemWriter({ resultsDir, }), - links: links as Config["links"] | undefined, + links, ...rest, }); - this.linksConfigs = links || []; + this.linksConfigs = links || {}; this.labelsConfigs = labels || []; options.eventBroadcaster.on("envelope", this.parseEnvelope.bind(this)); @@ -85,8 +85,8 @@ export default class AllureCucumberReporter extends Formatter { private get tagsIgnorePatterns(): RegExp[] { const { labelsConfigs, linksConfigs } = this; - - return [...labelsConfigs, ...linksConfigs].flatMap(({ pattern }) => pattern); + const linkConfigEntries = Object.entries(linksConfigs).map(([, v]) => v); + return [...labelsConfigs, ...linkConfigEntries].flatMap(({ pattern }) => pattern); } private parseEnvelope(envelope: messages.Envelope) { @@ -155,18 +155,18 @@ export default class AllureCucumberReporter extends Formatter { const tagKeyRe = /^@\S+=/; const links: Link[] = []; - if (this.linksConfigs.length === 0) { + if (Object.keys(this.linksConfigs).length === 0) { return links; } - this.linksConfigs.forEach((matcher) => { + Object.entries(this.linksConfigs).forEach(([type, matcher]) => { const matchedTags = tags.filter((tag) => matcher.pattern.some((pattern) => pattern.test(tag.name))); const matchedLinks = matchedTags.map((tag) => { const tagValue = tag.name.replace(tagKeyRe, ""); return { - url: matcher.urlTemplate.replace(/%s$/, tagValue) || tagValue, - type: matcher.type, + url: applyLinkTemplate(matcher.urlTemplate, tagValue) || tagValue, + type, }; }); diff --git a/packages/allure-cucumberjs/test/utils.ts b/packages/allure-cucumberjs/test/utils.ts index 8814c1e40..2c14ddb19 100644 --- a/packages/allure-cucumberjs/test/utils.ts +++ b/packages/allure-cucumberjs/test/utils.ts @@ -34,18 +34,16 @@ export const runCucumberInlineTest = async ( name: "severity", }, ], - links: [ - { + links: { + issue: { pattern: [/@issue=(.*)/], - type: "issue", urlTemplate: "https://example.com/issues/%s", }, - { + tms: { pattern: [/@tms=(.*)/], - type: "tms", urlTemplate: "https://example.com/tasks/%s", }, - ], + }, } } } diff --git a/packages/allure-cypress/src/reporter.ts b/packages/allure-cypress/src/reporter.ts index c541e04e0..d6a93a15f 100644 --- a/packages/allure-cypress/src/reporter.ts +++ b/packages/allure-cypress/src/reporter.ts @@ -2,14 +2,12 @@ import type Cypress from "cypress"; import { ContentType, LabelName, Stage } from "allure-js-commons"; import { extractMetadataFromString } from "allure-js-commons/sdk"; import { FileSystemWriter, ReporterRuntime, getSuiteLabels } from "allure-js-commons/sdk/reporter"; +import type { LinkConfig } from "allure-js-commons/sdk/reporter"; import type { CypressRuntimeMessage, CypressTestEndRuntimeMessage, CypressTestStartRuntimeMessage } from "./model.js"; export type AllureCypressConfig = { resultsDir?: string; - links?: { - type: string; - urlTemplate: string; - }[]; + links?: LinkConfig; }; export class AllureCypress { diff --git a/packages/allure-cypress/test/utils.ts b/packages/allure-cypress/test/utils.ts index 7b94ddd30..8561c14b8 100644 --- a/packages/allure-cypress/test/utils.ts +++ b/packages/allure-cypress/test/utils.ts @@ -32,16 +32,14 @@ export const runCypressInlineTest = async ( viewportWidth: 1240, setupNodeEvents: (on, config) => { const reporter = allureCypress(on, { - links: [ - { - type: "issue", + links: { + issue: { urlTemplate: "https://allurereport.org/issues/%s" }, - { - type: "tms", + tms: { urlTemplate: "https://allurereport.org/tasks/%s" }, - ] + } }); on("after:spec", (spec, result) => { diff --git a/packages/allure-jasmine/test/fixtures/spec/helpers/legacy/allure.cjs b/packages/allure-jasmine/test/fixtures/spec/helpers/legacy/allure.cjs index 31b6cdd09..7c08a93d1 100644 --- a/packages/allure-jasmine/test/fixtures/spec/helpers/legacy/allure.cjs +++ b/packages/allure-jasmine/test/fixtures/spec/helpers/legacy/allure.cjs @@ -3,16 +3,14 @@ const fixture = ` const reporter = new AllureJasmineReporter({ testMode: true, - links: [ - { - type: "issue", + links: { + issue: { urlTemplate: "https://example.org/issues/%s", }, - { - type: "tms", + tms: { urlTemplate: "https://example.org/tasks/%s", } - ], + }, categories: [ { name: "Sad tests", diff --git a/packages/allure-jasmine/test/fixtures/spec/helpers/modern/allure.cjs b/packages/allure-jasmine/test/fixtures/spec/helpers/modern/allure.cjs index 09ab4149b..68361673c 100644 --- a/packages/allure-jasmine/test/fixtures/spec/helpers/modern/allure.cjs +++ b/packages/allure-jasmine/test/fixtures/spec/helpers/modern/allure.cjs @@ -3,16 +3,14 @@ const fixture = ` const reporter = new AllureJasmineReporter({ testMode: true, - links: [ - { - type: "issue", + links: { + issue: { urlTemplate: "https://example.org/issues/%s", }, - { - type: "tms", + tms: { urlTemplate: "https://example.org/tasks/%s", } - ], + }, categories: [ { name: "Sad tests", diff --git a/packages/allure-jest/test/utils.ts b/packages/allure-jest/test/utils.ts index 3e1b597da..4fbb212ff 100644 --- a/packages/allure-jest/test/utils.ts +++ b/packages/allure-jest/test/utils.ts @@ -14,16 +14,14 @@ export const runJestInlineTest = async (test: string): Promise => testEnvironment: require.resolve("allure-jest/node"), testEnvironmentOptions: { testMode: true, - links: [ - { - type: "issue", + links: { + issue: { urlTemplate: "https://example.org/issues/%s", }, - { - type: "tms", + tms: { urlTemplate: "https://example.org/tasks/%s", } - ] + } }, }; diff --git a/packages/allure-js-commons/src/model.ts b/packages/allure-js-commons/src/model.ts index 6cc3db0a9..6a275368f 100644 --- a/packages/allure-js-commons/src/model.ts +++ b/packages/allure-js-commons/src/model.ts @@ -151,6 +151,7 @@ export enum ContentType { /* eslint-disable no-shadow */ export enum LinkType { + DEFAULT = "link", ISSUE = "issue", TMS = "tms", } diff --git a/packages/allure-js-commons/src/sdk/reporter/ReporterRuntime.ts b/packages/allure-js-commons/src/sdk/reporter/ReporterRuntime.ts index 7f3448f69..85cb08866 100644 --- a/packages/allure-js-commons/src/sdk/reporter/ReporterRuntime.ts +++ b/packages/allure-js-commons/src/sdk/reporter/ReporterRuntime.ts @@ -1,6 +1,6 @@ /* eslint max-lines: 0 */ import { extname } from "path"; -import type { Attachment, AttachmentOptions, FixtureResult, Link, StepResult, TestResult } from "../../model.js"; +import type { Attachment, AttachmentOptions, FixtureResult, StepResult, TestResult } from "../../model.js"; import { Stage } from "../../model.js"; import type { Category, @@ -20,7 +20,7 @@ import { MutableAllureContextHolder, StaticContextProvider } from "./context/Sta import type { AllureContextProvider } from "./context/types.js"; import { createFixtureResult, createStepResult, createTestResult } from "./factory.js"; import type { Config, FixtureType, FixtureWrapper, LinkConfig, TestScope, Writer } from "./types.js"; -import { deepClone, randomUuid } from "./utils.js"; +import { deepClone, formatLinks, randomUuid } from "./utils.js"; import { getTestResultHistoryId, getTestResultTestCaseId } from "./utils.js"; import { buildAttachmentFileName } from "./utils/attachments.js"; import { resolveWriter } from "./writer/loader.js"; @@ -144,7 +144,7 @@ type MessageTargets = { export class ReporterRuntime { private readonly state = new LifecycleState(); private notifier: Notifier; - private links: LinkConfig[] = []; + private links: LinkConfig; private contextProvider: AllureContextProvider; writer: Writer; categories?: Category[]; @@ -153,7 +153,7 @@ export class ReporterRuntime { constructor({ writer, listeners = [], - links = [], + links = {}, environmentInfo, categories, contextProvider = StaticContextProvider.wrap(new MutableAllureContextHolder()), @@ -784,7 +784,7 @@ export class ReporterRuntime { private handleMetadataMessage = (message: RuntimeMetadataMessage, { test, root, step }: MessageTargets) => { const { links = [], attachments = [], displayName, parameters = [], labels = [], ...rest } = message.data; - const formattedLinks = this.formatLinks(links); + const formattedLinks = formatLinks(this.links, links); if (displayName) { root.name = displayName; @@ -1047,41 +1047,6 @@ export class ReporterRuntime { } }; - private formatLinks = (links: Link[]) => { - if (!this.links.length) { - return links; - } - - return links.map((link) => { - // TODO: - // @ts-ignore - const matcher = this.links?.find?.(({ type }) => type === link.type); - - // TODO: - if (!matcher || link.url.startsWith("http")) { - return link; - } - - const url = matcher.urlTemplate.replace("%s", link.url); - - // we shouldn't need to reassign already assigned name - if (link.name || !matcher.nameTemplate) { - return { - ...link, - url, - }; - } - - const name = matcher.nameTemplate.replace("%s", link.url); - - return { - ...link, - name, - url, - }; - }); - }; - private introduceTestIntoScopes = (testUuid: string, scopeUuid: string) => { const scope = this.state.getScope(scopeUuid); if (!scope) { diff --git a/packages/allure-js-commons/src/sdk/reporter/types.ts b/packages/allure-js-commons/src/sdk/reporter/types.ts index 67ee20984..c14242c56 100644 --- a/packages/allure-js-commons/src/sdk/reporter/types.ts +++ b/packages/allure-js-commons/src/sdk/reporter/types.ts @@ -2,6 +2,7 @@ import type { FixtureResult, Label, Link, + LinkType, Parameter, StepResult, TestResult, @@ -61,11 +62,15 @@ export interface LifecycleListener { afterStepStop?: (result: StepResult) => void; } -export interface LinkConfig { - type: string; - urlTemplate: string; - nameTemplate?: string; -} +export type LinkTemplate = string | ((url: string) => string); + +export type LinkTypeOptions = { + urlTemplate: LinkTemplate; + nameTemplate?: LinkTemplate; +}; + +export type LinkConfig = Partial> & + Record; export type WriterDescriptor = [cls: string, ...args: readonly unknown[]] | string; @@ -74,7 +79,7 @@ export interface Config { readonly writer: Writer | WriterDescriptor; // TODO: handle lifecycle hooks here readonly testMapper?: (test: TestResult) => TestResult | null; - readonly links?: LinkConfig[]; + readonly links?: LinkConfig; readonly listeners?: LifecycleListener[]; readonly environmentInfo?: EnvironmentInfo; readonly categories?: Category[]; diff --git a/packages/allure-js-commons/src/sdk/reporter/utils.ts b/packages/allure-js-commons/src/sdk/reporter/utils.ts index b45c524c7..808ab681d 100644 --- a/packages/allure-js-commons/src/sdk/reporter/utils.ts +++ b/packages/allure-js-commons/src/sdk/reporter/utils.ts @@ -4,9 +4,9 @@ import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import properties from "properties"; -import type { Status, StepResult, TestResult } from "../../model.js"; -import { LabelName, StatusByPriority } from "../../model.js"; -import type { Label } from "../../model.js"; +import type { Label, Link, Status, StepResult, TestResult } from "../../model.js"; +import { LabelName, LinkType, StatusByPriority } from "../../model.js"; +import type { LinkConfig, LinkTemplate } from "./types.js"; export const randomUuid = () => { return randomUUID(); @@ -201,3 +201,43 @@ export const escapeRegExp = (value: string): string => { export const parseProperties = properties.parse; export const stringifyProperties = (data: any): string => properties.stringify(data, { unicode: true }).toString(); + +// TODO: may also use URL.canParse instead (requires node.js v18.17, v19.9, or higher) +const isUrl = (potentialUrl: string) => { + // Short-circuits the check for many short URL cases, bypassing the try-catch logic. + if (potentialUrl.indexOf(":") === -1) { + return false; + } + + // There is ':' in the string: a potential scheme separator. + // The string might be a proper URL already. + try { + new URL(potentialUrl); + return true; + } catch (e) { + return false; + } +}; + +export const applyLinkTemplate = (template: LinkTemplate, value: string) => + typeof template === "string" ? template.replace("%s", value) : template(value); + +export const formatLink = (templates: LinkConfig, link: Link) => { + const { url: originalUrl, name, type } = link; + if (isUrl(originalUrl)) { + return link; + } else { + const formattedLink = { ...link }; + const { urlTemplate, nameTemplate } = templates[type ?? LinkType.DEFAULT] ?? {}; + if (urlTemplate !== undefined) { + formattedLink.url = applyLinkTemplate(urlTemplate, originalUrl); + } + if (name === undefined && nameTemplate !== undefined) { + formattedLink.name = applyLinkTemplate(nameTemplate, originalUrl); + } + return formattedLink; + } +}; + +export const formatLinks = (templates: LinkConfig, links: readonly Link[]) => + links.map((link) => formatLink(templates, link)); diff --git a/packages/allure-js-commons/test/sdk/reporter/ReporterRuntime.spec.ts b/packages/allure-js-commons/test/sdk/reporter/ReporterRuntime.spec.ts index 0dbadffb9..17cbedab8 100644 --- a/packages/allure-js-commons/test/sdk/reporter/ReporterRuntime.spec.ts +++ b/packages/allure-js-commons/test/sdk/reporter/ReporterRuntime.spec.ts @@ -113,18 +113,16 @@ describe("ReporterRuntime", () => { const writer = mockWriter(); const runtime = new ReporterRuntime({ writer, - links: [ - { - type: "issue", + links: { + issue: { urlTemplate: "https://allurereport.org/issues/%s", nameTemplate: "Issue %s", }, - { - type: "tms", + tms: { urlTemplate: "https://allurereport.org/tasks/%s", nameTemplate: "Task %s", }, - ], + }, }); runtime.startTest({}); diff --git a/packages/allure-js-commons/test/sdk/reporter/utils/links.spec.ts b/packages/allure-js-commons/test/sdk/reporter/utils/links.spec.ts new file mode 100644 index 000000000..bbfc4d77e --- /dev/null +++ b/packages/allure-js-commons/test/sdk/reporter/utils/links.spec.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { LinkType } from "../../../../src/model.js"; +import type { LinkConfig } from "../../../../src/sdk/reporter/types.js"; +import { formatLinks } from "../../../../src/sdk/reporter/utils.js"; + +describe("formatLinks", () => { + describe("with no templates", () => { + it("shouldn't affect any link", () => { + const links = [{ url: "foo" }, { url: "foo", name: "bar" }, { url: "foo", name: "bar", type: "baz" }]; + expect(formatLinks({}, links)).toEqual(links); + }); + }); + + describe("with a URL-only default link template", () => { + const templates: LinkConfig = { [LinkType.DEFAULT]: { urlTemplate: "https://qux/%s" } }; + + it("should affect the URL of a link with no type", () => { + expect(formatLinks(templates, [{ url: "foo" }])).toEqual([{ url: "https://qux/foo" }]); + }); + + it("should affect the URL of a link with the default type", () => { + expect(formatLinks(templates, [{ url: "foo", type: LinkType.DEFAULT }])).toEqual([ + { url: "https://qux/foo", type: LinkType.DEFAULT }, + ]); + }); + + it("should ignore links of other types", () => { + const links = [ + { url: "foo", type: "bar" }, + { url: "bar", type: "quux" }, + ]; + expect(formatLinks(templates, links)).toEqual(links); + }); + }); + + describe("with URL and name templates", () => { + const templates: LinkConfig = { + qux: { + urlTemplate: "https://qux/%s", + nameTemplate: "qux-%s", + }, + }; + + it("should affect the name if not set", () => { + expect(formatLinks(templates, [{ url: "foo", type: "qux" }])).toEqual([ + { url: "https://qux/foo", name: "qux-foo", type: "qux" }, + ]); + }); + + it("shouldn't affect the name if set", () => { + expect(formatLinks(templates, [{ url: "foo", name: "bar", type: "qux" }])).toEqual([ + { url: "https://qux/foo", name: "bar", type: "qux" }, + ]); + }); + + it("should ignore links with a proper URL", () => { + const links = [ + { url: "http://foo", type: "qux" }, + { url: "https://foo", type: "qux" }, + { url: "ftp://foo", type: "qux" }, + { url: "file:///foo", type: "qux" }, + { url: "customapp:custompath?foo=bar&baz=qux", type: "qux" }, + ]; + expect(formatLinks(templates, links)).toEqual(links); + }); + }); + + describe("with URL and name template functions", () => { + const templates: LinkConfig = { + qux: { + urlTemplate: (v) => `https://qux/${v}`, + nameTemplate: (v) => `qux-${v}`, + }, + }; + + it("should transform URLs and names with functions", () => { + expect(formatLinks(templates, [{ url: "foo", type: "qux" }])).toEqual([ + { url: "https://qux/foo", name: "qux-foo", type: "qux" }, + ]); + }); + }); +}); diff --git a/packages/allure-playwright/test/spec/runtime/legacy/links.spec.ts b/packages/allure-playwright/test/spec/runtime/legacy/links.spec.ts index 4e07dd7a2..16dbba4e3 100644 --- a/packages/allure-playwright/test/spec/runtime/legacy/links.spec.ts +++ b/packages/allure-playwright/test/spec/runtime/legacy/links.spec.ts @@ -24,16 +24,14 @@ it("sets runtime links", async () => { resultsDir: "./allure-results", testMode: true, suiteTitle: true, - links: [ - { - type: "issue", + links: { + issue: { urlTemplate: "https://example.org/issues/%s", }, - { - type: "tms", + tms: { urlTemplate: "https://example.org/tasks/%s", } - ] + } }, ], ["dot"], diff --git a/packages/allure-playwright/test/spec/runtime/modern/links.spec.ts b/packages/allure-playwright/test/spec/runtime/modern/links.spec.ts index 1d4bf7d0d..2dee00bf7 100644 --- a/packages/allure-playwright/test/spec/runtime/modern/links.spec.ts +++ b/packages/allure-playwright/test/spec/runtime/modern/links.spec.ts @@ -26,16 +26,14 @@ it("sets runtime links", async () => { resultsDir: "./allure-results", testMode: true, suiteTitle: true, - links: [ - { - type: "${LinkType.ISSUE}", + links: { + issue: { urlTemplate: "https://example.org/issues/%s", }, - { - type: "${LinkType.TMS}", + tms: { urlTemplate: "https://example.org/tasks/%s", } - ] + } }, ], ["dot"], diff --git a/packages/allure-vitest/test/utils.ts b/packages/allure-vitest/test/utils.ts index 23b7fd157..119dd7409 100644 --- a/packages/allure-vitest/test/utils.ts +++ b/packages/allure-vitest/test/utils.ts @@ -35,16 +35,14 @@ export const runVitestInlineTest = async ( "default", new AllureReporter({ testMode: true, - links: [ - { - type: "issue", + links: { + issue: { urlTemplate: "https://example.org/issue/%s", }, - { - type: "tms", + tms: { urlTemplate: "https://example.org/tms/%s", }, - ], + }, resultsDir: "${join(testDir, "allure-results")}", }), ],