diff --git a/.vscode/settings.json b/.vscode/settings.json index 6d7cb79d0a1..85510254a8c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,7 +7,22 @@ "typescript.enablePromptUseWorkspaceTsdk": true, "editor.formatOnSave": true, "editor.defaultFormatter": "oxc.oxc-vscode", + "[typescript]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[json]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[jsonc]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, "oxc.enable": true, "oxc.typeAware": true, + "oxc.path.oxlint": "scripts/oxlint-vscode", + "oxc.path.oxfmt": "scripts/oxfmt-vscode", + "oxc.path.tsgolint": "scripts/oxlint-tsgolint-vscode", "eslint.enable": false } diff --git a/packages/cli/src/utils/environment.ts b/packages/cli/src/utils/environment.ts index 8e2d67e6146..fbf47d00e3f 100644 --- a/packages/cli/src/utils/environment.ts +++ b/packages/cli/src/utils/environment.ts @@ -1,4 +1,4 @@ -import { environmentIdentityById } from "@allurereport/core"; +import { environmentIdentityById, validateAllowedEnvironmentId } from "@allurereport/core"; import type { FullConfig } from "@allurereport/core"; import { validateEnvironmentId, validateEnvironmentName } from "@allurereport/core-api"; import { Option, UsageError } from "clipanion"; @@ -55,7 +55,7 @@ const resolveEnvironmentByName = ( }; export const resolveCommandEnvironment = ( - config: Pick, + config: Pick, options: CommandEnvironmentOptions & { source?: string }, ) => { const source = options.source ?? "cli"; @@ -74,6 +74,13 @@ export const resolveCommandEnvironment = ( } const identity = identityFromId ?? identityFromName; + const allowedEnvironmentIds = new Set(config.allowedEnvironments ?? []); + const allowlistError = + identity?.id !== undefined ? validateAllowedEnvironmentId(identity.id, allowedEnvironmentIds, "cli") : undefined; + + if (allowlistError) { + throw new UsageError(allowlistError); + } return { environmentId: identity?.id ?? config.environmentId, diff --git a/packages/cli/test/utils/environment.test.ts b/packages/cli/test/utils/environment.test.ts index dfd69253a8a..49f54a2f128 100644 --- a/packages/cli/test/utils/environment.test.ts +++ b/packages/cli/test/utils/environment.test.ts @@ -1,5 +1,13 @@ import { UsageError } from "clipanion"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@allurereport/core", async (importOriginal) => ({ + ...(await importOriginal()), + validateAllowedEnvironmentId: (environmentId: string, allowedIds: ReadonlySet, source: string) => + allowedIds.size === 0 || allowedIds.has(environmentId) + ? undefined + : `${source}: environment id ${JSON.stringify(environmentId)} is not listed in allowedEnvironments`, +})); import { resolveCommandEnvironment } from "../../src/utils/environment.js"; @@ -68,4 +76,28 @@ describe("resolveCommandEnvironment", () => { ), ).toThrow(UsageError); }); + + it("should fail when environment id is outside allowedEnvironments", () => { + expect(() => + resolveCommandEnvironment( + { + ...config, + allowedEnvironments: ["qa"], + }, + { environment: "prod_env" }, + ), + ).toThrow('cli: environment id "prod_env" is not listed in allowedEnvironments'); + }); + + it("should fail when environment name resolves to id outside allowedEnvironments", () => { + expect(() => + resolveCommandEnvironment( + { + ...config, + allowedEnvironments: ["qa"], + }, + { environmentName: "Production" }, + ), + ).toThrow('cli: environment id "prod_env" is not listed in allowedEnvironments'); + }); }); diff --git a/packages/core/src/api.ts b/packages/core/src/api.ts index a818078ed48..d21eceb7be9 100644 --- a/packages/core/src/api.ts +++ b/packages/core/src/api.ts @@ -48,6 +48,7 @@ export interface FullConfig { * Environment display name which will be assigned to all tests. */ environmentName?: string; + allowedEnvironments?: string[]; environments?: EnvironmentsConfig; environmentConfigInfo?: EnvironmentConfigInfo; variables?: ReportVariables; diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index f8afd5cfcf7..9831067e9ee 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -3,13 +3,19 @@ import { readFile, stat } from "node:fs/promises"; import { extname, resolve } from "node:path"; import * as process from "node:process"; +import { validateEnvironmentId } from "@allurereport/core-api"; import type { Config, PluginDescriptor } from "@allurereport/plugin-api"; import { parse } from "yaml"; import type { FullConfig, PluginInstance } from "./api.js"; import { readKnownIssues } from "./known.js"; import { FileSystemReportFiles } from "./plugin.js"; -import { normalizeEnvironmentDescriptorMap, resolveEnvironmentIdentity } from "./utils/environment.js"; +import { + normalizeAllowedEnvironmentIds, + normalizeEnvironmentDescriptorMap, + resolveEnvironmentIdentity, + validateAllowedEnvironmentId, +} from "./utils/environment.js"; import { importWrapper } from "./utils/module.js"; import { normalizeImportPath } from "./utils/path.js"; @@ -100,6 +106,7 @@ export const validateConfig = (config: Config) => { "environment", "environmentId", "environmentName", + "allowedEnvironments", "environments", "appendHistory", "qualityGate", @@ -164,6 +171,13 @@ export const loadJsConfig = async (configPath: string): Promise => { const resolveConfigEnvironments = (config: Config) => { const errors: string[] = []; + const { + normalized: normalizedAllowedEnvironments, + normalizedSet: allowedEnvironmentIds, + errors: allowedEnvironmentErrors, + } = normalizeAllowedEnvironmentIds(config.allowedEnvironments, "config.allowedEnvironments"); + + errors.push(...allowedEnvironmentErrors); const { normalized: environments, configInfo: environmentConfigInfo, @@ -180,6 +194,65 @@ const resolveConfigEnvironments = (config: Config) => { ); errors.push(...environmentErrors, ...forcedEnvironment.errors); + Object.keys(environments).forEach((environmentId) => { + const error = validateAllowedEnvironmentId(environmentId, allowedEnvironmentIds, "config.environments"); + + if (error) { + errors.push(error); + } + }); + + if (forcedEnvironment.identity?.id !== undefined) { + const error = validateAllowedEnvironmentId(forcedEnvironment.identity.id, allowedEnvironmentIds, "config"); + + if (error) { + errors.push(error); + } + } + + for (const [rulesetIndex, ruleset] of (config.qualityGate?.rules ?? []).entries()) { + if (ruleset.allTestsContainEnv !== undefined) { + const validation = validateEnvironmentId(ruleset.allTestsContainEnv); + + if (!validation.valid) { + errors.push(`config.qualityGate.rules[${rulesetIndex}].allTestsContainEnv: id ${validation.reason}`); + } else { + const error = validateAllowedEnvironmentId( + validation.normalized, + allowedEnvironmentIds, + `config.qualityGate.rules[${rulesetIndex}].allTestsContainEnv`, + ); + + if (error) { + errors.push(error); + } + } + } + + if (ruleset.environmentsTested !== undefined) { + for (const [environmentIndex, environmentId] of ruleset.environmentsTested.entries()) { + const validation = validateEnvironmentId(environmentId); + + if (!validation.valid) { + errors.push( + `config.qualityGate.rules[${rulesetIndex}].environmentsTested[${environmentIndex}]: id ${validation.reason}`, + ); + continue; + } + + const error = validateAllowedEnvironmentId( + validation.normalized, + allowedEnvironmentIds, + `config.qualityGate.rules[${rulesetIndex}].environmentsTested[${environmentIndex}]`, + ); + + if (error) { + errors.push(error); + } + } + } + } + if (errors.length > 0) { throw new Error(`The provided Allure config contains invalid environments: ${errors.join("; ")}`); } @@ -189,6 +262,7 @@ const resolveConfigEnvironments = (config: Config) => { environmentConfigInfo, environmentId: forcedEnvironment.identity?.id, environmentName: forcedEnvironment.identity?.name, + allowedEnvironments: normalizedAllowedEnvironments, }; }; @@ -199,7 +273,8 @@ export const resolveConfig = async (config: Config, override: ConfigOverride = { throw new Error(`The provided Allure config contains unsupported fields: ${validationResult.fields.join(", ")}`); } - const { environments, environmentConfigInfo, environmentId, environmentName } = resolveConfigEnvironments(config); + const { environments, environmentConfigInfo, environmentId, environmentName, allowedEnvironments } = + resolveConfigEnvironments(config); const name = override.name ?? config.name ?? "Allure Report"; const open = override.open ?? config.open ?? false; @@ -230,6 +305,7 @@ export const resolveConfig = async (config: Config, override: ConfigOverride = { known, environmentId, environmentName, + allowedEnvironments, variables, environments, environmentConfigInfo, diff --git a/packages/core/src/qualityGate/qualityGate.ts b/packages/core/src/qualityGate/qualityGate.ts index f8e03dfe07b..1a8b005ca2c 100644 --- a/packages/core/src/qualityGate/qualityGate.ts +++ b/packages/core/src/qualityGate/qualityGate.ts @@ -126,6 +126,7 @@ export class QualityGate { }, expected, knownIssues, + environmentId: environmentIdentity.id, environmentName: environmentIdentity.name, }); diff --git a/packages/core/src/qualityGate/rules.ts b/packages/core/src/qualityGate/rules.ts index 5299d0dd6e7..a1dfa341dc7 100644 --- a/packages/core/src/qualityGate/rules.ts +++ b/packages/core/src/qualityGate/rules.ts @@ -69,15 +69,15 @@ export const maxDurationRule: QualityGateRule = { }; /** - * Fails if any test in the run does not have the given environment. - * Expected: environment name (string). + * Fails if any test in the run does not have the given environment ID. + * Expected: environment ID (string). */ export const allTestsContainEnvRule: QualityGateRule = { rule: "allTestsContainEnv", message: ({ actual, expected }) => - `Not all tests contain the required environment, ${bold(`"${expected}"`)}; ${bold(actual.length === 1 ? "one" : String(actual))} ${actual.length === 1 ? "test has" : "tests have"} different or missing environment`, + `Not all tests contain the required environment ID, ${bold(`"${expected}"`)}; ${bold(actual.length === 1 ? "one" : String(actual))} ${actual.length === 1 ? "test has" : "tests have"} different or missing environment`, validate: async ({ trs, expected }) => { - const testsWithoutEnv = trs.filter((tr) => (tr.environment ?? "") !== expected); + const testsWithoutEnv = trs.filter((tr) => (tr.environmentId ?? "") !== expected); const actual = testsWithoutEnv.length; return { @@ -88,8 +88,8 @@ export const allTestsContainEnvRule: QualityGateRule = { }; /** - * Fails if the run does not contain at least one test for each of the given environments. - * Expected: array of environment names (string[]). + * Fails if the run does not contain at least one test for each of the given environment IDs. + * Expected: array of environment IDs (string[]). */ export const environmentsTestedRule: QualityGateRule = { rule: "environmentsTested", @@ -97,7 +97,7 @@ export const environmentsTestedRule: QualityGateRule = { `The following environments were not tested: "${actual.join('", "')}"; expected all of: "${expected.join('", "')}"`, validate: async ({ trs, expected, state }) => { const previouslyTested = new Set(state.getResult() ?? []); - const batchTested = trs.map((tr) => tr.environment).filter((env): env is string => env != null && env !== ""); + const batchTested = trs.map((tr) => tr.environmentId).filter((env): env is string => env != null && env !== ""); const testedEnvs = new Set([...previouslyTested, ...batchTested]); diff --git a/packages/core/src/report.ts b/packages/core/src/report.ts index 8f0cd171a3a..2d7af261081 100644 --- a/packages/core/src/report.ts +++ b/packages/core/src/report.ts @@ -87,6 +87,7 @@ export class AllureReport { variables = {}, environmentId, environmentName, + allowedEnvironments, environments, environmentConfigInfo, output, @@ -141,6 +142,7 @@ export class AllureReport { defaultLabels, environmentId, environmentName, + allowedEnvironments, }); this.#readers = [...readers]; this.#plugins = [...plugins]; diff --git a/packages/core/src/store/store.ts b/packages/core/src/store/store.ts index 7f02625a69a..6c54908c1ab 100644 --- a/packages/core/src/store/store.ts +++ b/packages/core/src/store/store.ts @@ -18,7 +18,6 @@ import { type Statistic, type TestCase, type TestEnvGroup, - type TestError, type TestFixtureResult, type TestResult, compareBy, @@ -35,6 +34,8 @@ import { type AllureStore, type AllureStoreDump, type ExitCode, + type PluginGlobalAttachment, + type PluginGlobalError, type QualityGateValidationResult, type RealtimeEventsDispatcher, type RealtimeSubscriber, @@ -55,9 +56,11 @@ import { assertValidRuntimeEnvironmentKey, defaultEnvironmentIdentity, environmentIdentityById, + normalizeAllowedEnvironmentIds, normalizeEnvironmentDescriptorMap, resolveEnvironmentIdentity, resolveStoredEnvironmentIdentity, + validateAllowedEnvironmentId, } from "../utils/environment.js"; import { isFlaky } from "../utils/flaky.js"; import { getStatusTransition } from "../utils/new.js"; @@ -144,6 +147,7 @@ export class DefaultAllureStore implements AllureStore, ResultsVisitor { readonly #fixtures: Map; readonly #defaultLabels: DefaultLabelsConfig = {}; readonly #environment: EnvironmentIdentity | undefined; + readonly #allowedEnvironmentIds = new Set(); readonly #environmentsConfig: EnvironmentsConfig = {}; readonly #reportVariables: ReportVariables = {}; readonly #realtimeDispatcher?: RealtimeEventsDispatcher; @@ -158,7 +162,9 @@ export class DefaultAllureStore implements AllureStore, ResultsVisitor { readonly indexKnownByHistoryId: Map = new Map(); #globalAttachmentIds: string[] = []; - #globalErrors: TestError[] = []; + #globalAttachmentIdsByEnvironmentId: Map = new Map(); + #globalErrors: PluginGlobalError[] = []; + #globalErrorsByEnvironmentId: Map = new Map(); #globalExitCode: ExitCode | undefined; #qualityGateResults: QualityGateValidationResult[] = []; #historyPoints: HistoryDataPoint[] = []; @@ -173,6 +179,7 @@ export class DefaultAllureStore implements AllureStore, ResultsVisitor { environmentId?: string; environmentName?: string; environment?: string; + allowedEnvironments?: string[]; environmentsConfig?: EnvironmentsConfig; environmentConfigInfo?: EnvironmentConfigInfo; reportVariables?: ReportVariables; @@ -186,6 +193,7 @@ export class DefaultAllureStore implements AllureStore, ResultsVisitor { environmentId, environmentName, environment, + allowedEnvironments = [], environmentsConfig = {}, environmentConfigInfo, reportVariables = {}, @@ -197,7 +205,7 @@ export class DefaultAllureStore implements AllureStore, ResultsVisitor { configInfo, identities, errors: configErrors, - } = normalizeEnvironmentDescriptorMap(environmentsConfig, "store constructor environmentsConfig"); + } = normalizeEnvironmentDescriptorMap(environmentsConfig, "environment config"); errors.push(...configErrors); const forcedEnvironment = resolveEnvironmentIdentity( { @@ -206,10 +214,32 @@ export class DefaultAllureStore implements AllureStore, ResultsVisitor { environment, }, normalizedEnvironmentsConfig, - "store constructor", + "environment selection", ); errors.push(...forcedEnvironment.errors); const resolvedEnvironment = forcedEnvironment.identity; + const { normalized: normalizedAllowedEnvironments, normalizedSet: allowedEnvironmentIds } = + normalizeAllowedEnvironmentIds(allowedEnvironments, "allowedEnvironments"); + + Object.keys(normalizedEnvironmentsConfig).forEach((environmentId) => { + const error = validateAllowedEnvironmentId(environmentId, allowedEnvironmentIds, "environment config"); + + if (error) { + errors.push(error); + } + }); + + if (resolvedEnvironment) { + const error = validateAllowedEnvironmentId( + resolvedEnvironment.id, + allowedEnvironmentIds, + "environment selection", + ); + + if (error) { + errors.push(error); + } + } if (errors.length > 0) { throw new Error(errors.join("; ")); @@ -235,6 +265,7 @@ export class DefaultAllureStore implements AllureStore, ResultsVisitor { this.#defaultLabels = defaultLabels; this.#environmentsConfig = normalizedEnvironmentsConfig; this.#environment = resolvedEnvironment; + this.#allowedEnvironmentIds = new Set(normalizedAllowedEnvironments); this.#reportVariables = reportVariables; this.#metadata.set(ENVIRONMENT_CONFIG_INFO_METADATA_KEY, environmentConfigInfo ?? configInfo); @@ -244,23 +275,29 @@ export class DefaultAllureStore implements AllureStore, ResultsVisitor { this.#qualityGateResults.push(...results); this.#addEnvironments( results - .map((result) => - this.#resolveStoredEnvironmentIdentity({ + .map((result) => { + const environmentIdentity = this.#resolveStoredEnvironmentIdentity({ environmentId: result.environmentId, environmentName: result.environmentName ?? result.environment, labels: [], - }), - ) + }); + + if (environmentIdentity) { + this.#assertAllowedEnvironmentId(environmentIdentity.id, "environment selection"); + } + + return environmentIdentity; + }) .filter(Boolean) as EnvironmentIdentity[], ); }); this.#realtimeSubscriber?.onGlobalExitCode(async (exitCode: ExitCode) => { this.#globalExitCode = exitCode; }); - this.#realtimeSubscriber?.onGlobalError(async (error: TestError) => { - this.#globalErrors.push(error); + this.#realtimeSubscriber?.onGlobalError(async (error: PluginGlobalError) => { + this.#recordGlobalError(error); }); - this.#realtimeSubscriber?.onGlobalAttachment(async ({ attachment, fileName }) => { + this.#realtimeSubscriber?.onGlobalAttachment(async ({ attachment, fileName, environmentId, environment }) => { const originalFileName = attachment.getOriginalFileName(); const attachmentLink: AttachmentLinkLinked = { id: md5(originalFileName), @@ -275,7 +312,13 @@ export class DefaultAllureStore implements AllureStore, ResultsVisitor { this.#attachments.set(attachmentLink.id, attachmentLink); this.#attachmentContents.set(attachmentLink.id, attachment); - this.#globalAttachmentIds.push(attachmentLink.id); + this.#recordGlobalAttachment( + attachmentLink, + this.#resolveGlobalEnvironmentIdentity({ + environmentId, + environment, + }), + ); }); } @@ -346,6 +389,77 @@ export class DefaultAllureStore implements AllureStore, ResultsVisitor { ).identity?.id; } + #assertAllowedEnvironmentId(environmentId: string, source: string) { + const error = validateAllowedEnvironmentId(environmentId, this.#allowedEnvironmentIds, source); + + if (error) { + throw new Error(error); + } + } + + #resolveGlobalEnvironmentIdentity( + payload: { + environmentId?: string; + environment?: string; + }, + options?: { + enforceAllowed?: boolean; + }, + ): EnvironmentIdentity { + const resolved = + this.#resolveStoredEnvironmentIdentity( + { + environmentId: payload.environmentId, + environment: payload.environment, + labels: [], + }, + { + fallbackToMatch: false, + }, + ) ?? + this.#environment ?? + defaultEnvironmentIdentity(); + + if (options?.enforceAllowed !== false) { + this.#assertAllowedEnvironmentId(resolved.id, "environment selection"); + } + + return resolved; + } + + #recordGlobalError( + error: PluginGlobalError, + options?: { + enforceAllowed?: boolean; + }, + ) { + const environmentIdentity = this.#resolveGlobalEnvironmentIdentity(error, options); + const globalError: PluginGlobalError = { + ...error, + environmentId: environmentIdentity.id, + environment: environmentIdentity.name, + }; + + this.#globalErrors.push(globalError); + + if (!this.#globalErrorsByEnvironmentId.has(environmentIdentity.id)) { + this.#globalErrorsByEnvironmentId.set(environmentIdentity.id, []); + } + + this.#globalErrorsByEnvironmentId.get(environmentIdentity.id)!.push(globalError); + this.#addEnvironments([environmentIdentity]); + } + + #recordGlobalAttachment(attachmentLink: AttachmentLinkLinked, environmentIdentity: EnvironmentIdentity) { + if (!this.#globalAttachmentIdsByEnvironmentId.has(environmentIdentity.id)) { + this.#globalAttachmentIdsByEnvironmentId.set(environmentIdentity.id, []); + } + + this.#globalAttachmentIds.push(attachmentLink.id); + this.#globalAttachmentIdsByEnvironmentId.get(environmentIdentity.id)!.push(attachmentLink.id); + this.#addEnvironments([environmentIdentity]); + } + #addEnvironments(envs: EnvironmentIdentity[]) { if (this.#environments.length === 0) { this.#environments.push(defaultEnvironmentIdentity()); @@ -437,11 +551,15 @@ export class DefaultAllureStore implements AllureStore, ResultsVisitor { return this.#globalExitCode; } - async allGlobalErrors(): Promise { + async allGlobalErrors(): Promise { return this.#globalErrors; } - async allGlobalAttachments(): Promise { + async allGlobalErrorsByEnvironmentId(): Promise> { + return mapToObject(this.#globalErrorsByEnvironmentId); + } + + async allGlobalAttachments(): Promise { return this.#globalAttachmentIds.reduce((acc, id) => { const attachment = this.#attachments.get(id); @@ -449,10 +567,43 @@ export class DefaultAllureStore implements AllureStore, ResultsVisitor { return acc; } - acc.push(attachment as AttachmentLinkLinked); + const environmentIdentity = Array.from(this.#globalAttachmentIdsByEnvironmentId.entries()).find(([, ids]) => + ids.includes(id), + )?.[0]; + const environmentName = environmentIdentity ? this.#environmentNameById(environmentIdentity) : undefined; + + acc.push({ + ...(attachment as AttachmentLinkLinked), + environmentId: environmentIdentity, + environment: environmentName, + }); return acc; - }, [] as AttachmentLinkLinked[]); + }, [] as PluginGlobalAttachment[]); + } + + async allGlobalAttachmentsByEnvironmentId(): Promise> { + const result = createDictionary(); + + this.#globalAttachmentIdsByEnvironmentId.forEach((ids, environmentId) => { + result[environmentId] = ids.reduce((acc, id) => { + const attachment = this.#attachments.get(id); + + if (!attachment) { + return acc; + } + + acc.push({ + ...(attachment as AttachmentLinkLinked), + environmentId, + environment: this.#environmentNameById(environmentId), + }); + + return acc; + }, [] as PluginGlobalAttachment[]); + }); + + return result; } // test methods @@ -491,6 +642,8 @@ export class DefaultAllureStore implements AllureStore, ResultsVisitor { const environmentIdentity = this.#environment ?? matchEnvironmentIdentity(this.#environmentsConfig, testResult); + this.#assertAllowedEnvironmentId(environmentIdentity.id, "environment selection"); + testResult.environmentId = environmentIdentity.id; testResult.environment = environmentIdentity.name; this.#addEnvironments([environmentIdentity]); @@ -575,7 +728,9 @@ export class DefaultAllureStore implements AllureStore, ResultsVisitor { async visitGlobals(globals: RawGlobals): Promise { const { errors, attachments } = globals; - this.#globalErrors.push(...errors); + errors.forEach((error) => { + this.#recordGlobalError(error); + }); attachments.forEach((attachment) => { const originalFileName = attachment.originalFileName!; @@ -591,7 +746,13 @@ export class DefaultAllureStore implements AllureStore, ResultsVisitor { }; this.#attachments.set(id, attachmentLink); - this.#globalAttachmentIds.push(id); + this.#recordGlobalAttachment( + attachmentLink, + this.#resolveGlobalEnvironmentIdentity({ + environmentId: attachment.environmentId, + environment: attachment.environment, + }), + ); }); } @@ -1042,7 +1203,9 @@ export class DefaultAllureStore implements AllureStore, ResultsVisitor { environments: this.#environments, reportVariables: this.#reportVariables, globalAttachmentIds: this.#globalAttachmentIds, + globalAttachmentIdsByEnvironmentId: mapToObject(this.#globalAttachmentIdsByEnvironmentId), globalErrors: this.#globalErrors, + globalErrorsByEnvironmentId: mapToObject(this.#globalErrorsByEnvironmentId), indexLatestEnvTestResultByHistoryId: {}, indexAttachmentByTestResult: {}, indexTestResultByHistoryId: {}, @@ -1093,7 +1256,9 @@ export class DefaultAllureStore implements AllureStore, ResultsVisitor { reportVariables, environments, globalAttachmentIds = [], + globalAttachmentIdsByEnvironmentId = {}, globalErrors = [], + globalErrorsByEnvironmentId = {}, indexAttachmentByTestResult = {}, indexTestResultByHistoryId = {}, indexTestResultByTestCase = {}, @@ -1149,8 +1314,41 @@ export class DefaultAllureStore implements AllureStore, ResultsVisitor { ) .filter(Boolean) as EnvironmentIdentity[], ); - this.#globalAttachmentIds.push(...globalAttachmentIds); - this.#globalErrors.push(...globalErrors); + if (Object.keys(globalAttachmentIdsByEnvironmentId).length > 0) { + Object.entries(globalAttachmentIdsByEnvironmentId).forEach(([environmentId, ids]) => { + this.#globalAttachmentIdsByEnvironmentId.set(environmentId, [...ids]); + }); + this.#globalAttachmentIds.push(...globalAttachmentIds); + this.#addEnvironments( + Object.keys(globalAttachmentIdsByEnvironmentId).map((environmentId) => ({ + id: environmentId, + name: this.#environmentNameById(environmentId), + })), + ); + } else { + const fallbackGlobalEnvironment = this.#resolveGlobalEnvironmentIdentity({}, { enforceAllowed: false }); + + this.#globalAttachmentIds.push(...globalAttachmentIds); + this.#globalAttachmentIdsByEnvironmentId.set(fallbackGlobalEnvironment.id, [...globalAttachmentIds]); + this.#addEnvironments([fallbackGlobalEnvironment]); + } + + if (Object.keys(globalErrorsByEnvironmentId).length > 0) { + Object.entries(globalErrorsByEnvironmentId).forEach(([environmentId, errors]) => { + this.#globalErrorsByEnvironmentId.set(environmentId, [...errors]); + this.#globalErrors.push(...errors); + }); + this.#addEnvironments( + Object.keys(globalErrorsByEnvironmentId).map((environmentId) => ({ + id: environmentId, + name: this.#environmentNameById(environmentId), + })), + ); + } else { + globalErrors.forEach((error) => { + this.#recordGlobalError(error, { enforceAllowed: false }); + }); + } Object.assign(this.#reportVariables, reportVariables); Object.entries(indexAttachmentByTestResult).forEach(([trId, links]) => { diff --git a/packages/core/src/utils/environment.ts b/packages/core/src/utils/environment.ts index dcce524899b..3d5937e1520 100644 --- a/packages/core/src/utils/environment.ts +++ b/packages/core/src/utils/environment.ts @@ -50,6 +50,54 @@ export type NormalizedEnvironmentsResult = { errors: string[]; }; +export const normalizeAllowedEnvironmentIds = ( + input: string[] | undefined, + sourcePath: string, +): { + normalized: string[]; + normalizedSet: Set; + errors: string[]; +} => { + const normalized: string[] = []; + const normalizedSet = new Set(); + const errors: string[] = []; + + for (const [index, environmentId] of (input ?? []).entries()) { + const validation = validateEnvironmentId(environmentId); + + if (!validation.valid) { + errors.push(`${sourcePath}[${index}]: id ${validation.reason}`); + continue; + } + + if (normalizedSet.has(validation.normalized)) { + errors.push(`${sourcePath}: duplicated environment id ${JSON.stringify(validation.normalized)}`); + continue; + } + + normalizedSet.add(validation.normalized); + normalized.push(validation.normalized); + } + + return { + normalized, + normalizedSet, + errors, + }; +}; + +export const validateAllowedEnvironmentId = ( + environmentId: string, + allowedIds: ReadonlySet, + sourcePath: string, +): string | undefined => { + if (allowedIds.size === 0 || allowedIds.has(environmentId)) { + return undefined; + } + + return `${sourcePath}: environment id ${JSON.stringify(environmentId)} is not listed in allowedEnvironments`; +}; + export const normalizeEnvironmentDescriptorMap = ( input: EnvironmentsConfig, sourcePath: string, diff --git a/packages/core/src/utils/event.ts b/packages/core/src/utils/event.ts index 928dfa09028..58cd55d7b79 100644 --- a/packages/core/src/utils/event.ts +++ b/packages/core/src/utils/event.ts @@ -2,10 +2,10 @@ import console from "node:console"; import type { EventEmitter } from "node:events"; import { setTimeout } from "node:timers/promises"; -import type { TestError } from "@allurereport/core-api"; import type { BatchOptions, ExitCode, + PluginGlobalError, QualityGateValidationResult, RealtimeEventsDispatcher as RealtimeEventsDispatcherType, RealtimeSubscriber as RealtimeSubscriberType, @@ -27,9 +27,11 @@ export interface AllureStoreEvents { [RealtimeEvents.TestResult]: [string]; [RealtimeEvents.TestFixtureResult]: [string]; [RealtimeEvents.AttachmentFile]: [string]; - [RealtimeEvents.GlobalAttachment]: [{ attachment: ResultFile; fileName?: string }]; + [RealtimeEvents.GlobalAttachment]: [ + { attachment: ResultFile; fileName?: string; environmentId?: string; environment?: string }, + ]; [RealtimeEvents.GlobalExitCode]: [ExitCode]; - [RealtimeEvents.GlobalError]: [TestError]; + [RealtimeEvents.GlobalError]: [PluginGlobalError]; } interface HandlerData { @@ -45,15 +47,15 @@ export class RealtimeEventsDispatcher implements RealtimeEventsDispatcherType { this.#emitter = emitter; } - sendGlobalAttachment(attachment: ResultFile, fileName?: string) { - this.#emitter.emit(RealtimeEvents.GlobalAttachment, { attachment, fileName }); + sendGlobalAttachment(attachment: ResultFile, fileName?: string, environmentId?: string, environment?: string) { + this.#emitter.emit(RealtimeEvents.GlobalAttachment, { attachment, fileName, environmentId, environment }); } sendGlobalExitCode(codes: ExitCode) { this.#emitter.emit(RealtimeEvents.GlobalExitCode, codes); } - sendGlobalError(error: TestError) { + sendGlobalError(error: PluginGlobalError) { this.#emitter.emit(RealtimeEvents.GlobalError, error); } @@ -82,7 +84,14 @@ export class RealtimeSubscriber implements RealtimeSubscriberType { this.#emitter = emitter; } - onGlobalAttachment(listener: (payload: { attachment: ResultFile; fileName?: string }) => Promise) { + onGlobalAttachment( + listener: (payload: { + attachment: ResultFile; + fileName?: string; + environmentId?: string; + environment?: string; + }) => Promise, + ) { this.#emitter.on(RealtimeEvents.GlobalAttachment, listener); return () => { @@ -98,7 +107,7 @@ export class RealtimeSubscriber implements RealtimeSubscriberType { }; } - onGlobalError(listener: (error: TestError) => Promise) { + onGlobalError(listener: (error: PluginGlobalError) => Promise) { this.#emitter.on(RealtimeEvents.GlobalError, listener); return () => { diff --git a/packages/core/test/config.test.ts b/packages/core/test/config.test.ts index 5ab21ba3443..ec840f4cfb3 100644 --- a/packages/core/test/config.test.ts +++ b/packages/core/test/config.test.ts @@ -501,6 +501,57 @@ describe("resolveConfig", () => { ); }); + it("should reject invalid allowed environment ids", async () => { + await expect( + resolveConfig({ + allowedEnvironments: ["foo", "bar baz"], + }), + ).rejects.toThrow( + "The provided Allure config contains invalid environments: config.allowedEnvironments[1]: id id must contain only latin letters, digits, underscores, and hyphens", + ); + }); + + it("should reject duplicated allowed environment ids", async () => { + await expect( + resolveConfig({ + allowedEnvironments: ["foo", "foo"], + }), + ).rejects.toThrow( + 'The provided Allure config contains invalid environments: config.allowedEnvironments: duplicated environment id "foo"', + ); + }); + + it("should reject configured environments outside allowedEnvironments", async () => { + await expect( + resolveConfig({ + allowedEnvironments: ["foo"], + environments: { + foo: { + matcher: () => true, + }, + bar: { + matcher: () => false, + }, + }, + }), + ).rejects.toThrow( + 'The provided Allure config contains invalid environments: config.environments: environment id "bar" is not listed in allowedEnvironments', + ); + }); + + it("should reject quality gate environment ids outside allowedEnvironments", async () => { + await expect( + resolveConfig({ + allowedEnvironments: ["foo"], + qualityGate: { + rules: [{ allTestsContainEnv: "bar", environmentsTested: ["foo", "bar"] }], + }, + }), + ).rejects.toThrow( + 'The provided Allure config contains invalid environments: config.qualityGate.rules[0].allTestsContainEnv: environment id "bar" is not listed in allowedEnvironments; config.qualityGate.rules[0].environmentsTested[1]: environment id "bar" is not listed in allowedEnvironments', + ); + }); + it("should accept environment names with max allowed length", async () => { const validBoundaryName = "a".repeat(MAX_ENVIRONMENT_NAME_LENGTH); diff --git a/packages/core/test/qualityGate/rules.test.ts b/packages/core/test/qualityGate/rules.test.ts index 16c7842ebe9..c7bb46ad3c5 100644 --- a/packages/core/test/qualityGate/rules.test.ts +++ b/packages/core/test/qualityGate/rules.test.ts @@ -16,6 +16,7 @@ const createTestResult = ( status: TestStatus, historyId?: string, duration?: number, + environmentId?: string, environment?: string, ) => ({ @@ -24,6 +25,7 @@ const createTestResult = ( historyId, status, duration, + environmentId, environment, flaky: false, muted: false, @@ -335,8 +337,8 @@ describe("allTestsContainEnvRule", () => { it("should pass when all tests have the required environment", async () => { const testResults: TestResult[] = [ - createTestResult("1", "passed", undefined, undefined, "staging"), - createTestResult("2", "passed", undefined, undefined, "staging"), + createTestResult("1", "passed", undefined, undefined, "staging", "Staging"), + createTestResult("2", "passed", undefined, undefined, "staging", "Staging"), ]; const result = await allTestsContainEnvRule.validate({ trs: testResults, @@ -351,8 +353,8 @@ describe("allTestsContainEnvRule", () => { it("should fail when some tests have different environment", async () => { const testResults: TestResult[] = [ - createTestResult("1", "passed", undefined, undefined, "staging"), - createTestResult("2", "passed", undefined, undefined, "prod"), + createTestResult("1", "passed", undefined, undefined, "staging", "Staging"), + createTestResult("2", "passed", undefined, undefined, "prod", "Production"), ]; const result = await allTestsContainEnvRule.validate({ trs: testResults, @@ -367,7 +369,7 @@ describe("allTestsContainEnvRule", () => { it("should fail when some tests have no environment", async () => { const testResults: TestResult[] = [ - createTestResult("1", "passed", undefined, undefined, "staging"), + createTestResult("1", "passed", undefined, undefined, "staging", "Staging"), createTestResult("2", "passed"), ]; const result = await allTestsContainEnvRule.validate({ @@ -402,8 +404,8 @@ describe("environmentsTestedRule", () => { }; const testResults: TestResult[] = [ - createTestResult("1", "passed", undefined, undefined, "staging"), - createTestResult("2", "passed", undefined, undefined, "prod"), + createTestResult("1", "passed", undefined, undefined, "staging", "Staging"), + createTestResult("2", "passed", undefined, undefined, "prod", "Production"), ]; const result = await environmentsTestedRule.validate({ trs: testResults, @@ -423,8 +425,8 @@ describe("environmentsTestedRule", () => { }; const testResults: TestResult[] = [ - createTestResult("1", "passed", undefined, undefined, "staging"), - createTestResult("2", "passed", undefined, undefined, "staging"), + createTestResult("1", "passed", undefined, undefined, "staging", "Staging"), + createTestResult("2", "passed", undefined, undefined, "staging", "Staging"), ]; const result = await environmentsTestedRule.validate({ trs: testResults, @@ -443,7 +445,7 @@ describe("environmentsTestedRule", () => { setResult: () => {}, }; - const testResults: TestResult[] = [createTestResult("1", "passed", undefined, undefined, "dev")]; + const testResults: TestResult[] = [createTestResult("1", "passed", undefined, undefined, "dev", "Development")]; const result = await environmentsTestedRule.validate({ trs: testResults, expected: ["staging", "prod"], @@ -462,7 +464,7 @@ describe("environmentsTestedRule", () => { }; const result = await environmentsTestedRule.validate({ - trs: [createTestResult("1", "passed", undefined, undefined, "staging")], + trs: [createTestResult("1", "passed", undefined, undefined, "staging", "Staging")], expected: [], knownIssues: [], state, @@ -487,8 +489,8 @@ describe("environmentsTestedRule", () => { // First batch: only "staging" is present, so rule should fail const firstBatch: TestResult[] = [ - createTestResult("1", "passed", undefined, undefined, "staging"), - createTestResult("2", "passed", undefined, undefined, "staging"), + createTestResult("1", "passed", undefined, undefined, "staging", "Staging"), + createTestResult("2", "passed", undefined, undefined, "staging", "Staging"), ]; const firstResult = await environmentsTestedRule.validate({ @@ -504,8 +506,8 @@ describe("environmentsTestedRule", () => { // Second batch: only "prod" is present, but state already contains "staging" const secondBatch: TestResult[] = [ - createTestResult("3", "passed", undefined, undefined, "prod"), - createTestResult("4", "passed", undefined, undefined, "prod"), + createTestResult("3", "passed", undefined, undefined, "prod", "Production"), + createTestResult("4", "passed", undefined, undefined, "prod", "Production"), ]; const secondResult = await environmentsTestedRule.validate({ diff --git a/packages/core/test/store/store.test.ts b/packages/core/test/store/store.test.ts index d90e0343818..eb298a1f454 100644 --- a/packages/core/test/store/store.test.ts +++ b/packages/core/test/store/store.test.ts @@ -1683,9 +1683,44 @@ describe("history", () => { }); describe("environments", () => { + it("should reject configured environment ids outside allowedEnvironments in constructor", () => { + expect( + () => + new DefaultAllureStore({ + allowedEnvironments: ["foo"], + environmentsConfig: { + bar: { + matcher: () => true, + }, + }, + }), + ).toThrow('environment config: environment id "bar" is not listed in allowedEnvironments'); + }); + + it("should reject default runtime environment when it is outside allowedEnvironments", async () => { + const store = new DefaultAllureStore({ + allowedEnvironments: ["foo"], + environmentsConfig: { + foo: { + matcher: ({ labels }) => !!labels.find(({ name, value }) => name === "env" && value === "foo"), + }, + }, + }); + + await expect( + store.visitTestResult( + { + name: "test result", + labels: [], + }, + { readerId }, + ), + ).rejects.toThrow('environment selection: environment id "default" is not listed in allowedEnvironments'); + }); + it("should reject invalid forced environment name in constructor", () => { expect(() => new DefaultAllureStore({ environment: "" })).toThrow( - "store constructor: environment name name must not be empty", + "environment selection: environment name name must not be empty", ); }); @@ -1700,7 +1735,7 @@ describe("environments", () => { }, }), ).toThrow( - 'store constructor environmentsConfig["foo\\nbar"]: id id must contain only latin letters, digits, underscores, and hyphens', + 'environment config["foo\\nbar"]: id id must contain only latin letters, digits, underscores, and hyphens', ); }); @@ -1714,9 +1749,7 @@ describe("environments", () => { }, }, }), - ).toThrow( - 'store constructor environmentsConfig["foo/bar"]: id id must contain only latin letters, digits, underscores, and hyphens', - ); + ).toThrow('environment config["foo/bar"]: id id must contain only latin letters, digits, underscores, and hyphens'); }); it("should set environment to test result on visit when they are specified", async () => { @@ -1979,8 +2012,13 @@ describe("visitGlobals", () => { expect(errors).toHaveLength(2); expect(errors).toEqual([ - { message: "Setup failed", trace: "Error at setup.js:10" }, - { message: "Teardown failed", trace: "Error at teardown.js:5" }, + { message: "Setup failed", trace: "Error at setup.js:10", environmentId: "default", environment: "default" }, + { + message: "Teardown failed", + trace: "Error at teardown.js:5", + environmentId: "default", + environment: "default", + }, ]); }); @@ -2007,6 +2045,8 @@ describe("visitGlobals", () => { originalFileName: "global-log.txt", ext: ".txt", contentType: "text/plain", + environmentId: "default", + environment: "default", used: true, missed: false, }), @@ -2016,6 +2056,8 @@ describe("visitGlobals", () => { originalFileName: "global-screen.png", ext: ".png", contentType: "image/png", + environmentId: "default", + environment: "default", used: true, missed: false, }), @@ -2055,11 +2097,13 @@ describe("visitGlobals", () => { const attachments = await store.allGlobalAttachments(); expect(errors).toHaveLength(1); - expect(errors[0]).toEqual({ message: "Something went wrong" }); + expect(errors[0]).toEqual({ message: "Something went wrong", environmentId: "default", environment: "default" }); expect(attachments).toHaveLength(1); expect(attachments[0]).toMatchObject({ name: "debug log", originalFileName: "debug.txt", + environmentId: "default", + environment: "default", }); }); @@ -2079,11 +2123,84 @@ describe("visitGlobals", () => { const attachments = await store.allGlobalAttachments(); expect(errors).toHaveLength(2); - expect(errors[0]).toEqual({ message: "Error 1" }); - expect(errors[1]).toEqual({ message: "Error 2" }); + expect(errors[0]).toEqual({ message: "Error 1", environmentId: "default", environment: "default" }); + expect(errors[1]).toEqual({ message: "Error 2", environmentId: "default", environment: "default" }); expect(attachments).toHaveLength(2); }); + it("should group globals by environment id", async () => { + const store = new DefaultAllureStore({ + environmentId: "qa", + environmentsConfig: { + qa: { + name: "QA", + matcher: () => false, + }, + }, + }); + + await store.visitGlobals({ + errors: [{ message: "Global error" }], + attachments: [{ originalFileName: "global.txt" } as RawTestAttachment], + }); + + expect(await store.allGlobalErrorsByEnvironmentId()).toEqual({ + qa: [expect.objectContaining({ message: "Global error", environmentId: "qa", environment: "QA" })], + }); + expect(await store.allGlobalAttachmentsByEnvironmentId()).toEqual({ + qa: [expect.objectContaining({ originalFileName: "global.txt", environmentId: "qa", environment: "QA" })], + }); + }); + + it("should restore legacy flat globals even when default is not allowed", async () => { + const store = new DefaultAllureStore({ + allowedEnvironments: ["qa"], + environmentsConfig: { + qa: { + name: "QA", + matcher: () => false, + }, + }, + }); + + await store.restoreState( + { + testResults: {}, + attachments: { + [md5("global.txt")]: { + id: md5("global.txt"), + name: "global.txt", + originalFileName: "global.txt", + used: true, + missed: false, + } as AttachmentLinkLinked, + }, + testCases: {}, + fixtures: {}, + environments: [], + reportVariables: {}, + globalAttachmentIds: [md5("global.txt")], + globalErrors: [{ message: "Legacy error" }], + qualityGateResults: [], + indexAttachmentByTestResult: {}, + indexTestResultByHistoryId: {}, + indexTestResultByTestCase: {}, + indexLatestEnvTestResultByHistoryId: {}, + indexAttachmentByFixture: {}, + indexFixturesByTestResult: {}, + indexKnownByHistoryId: {}, + } as unknown as AllureStoreDump, + {}, + ); + + expect(await store.allGlobalErrors()).toEqual([ + expect.objectContaining({ message: "Legacy error", environmentId: "default", environment: "default" }), + ]); + expect(await store.allGlobalAttachments()).toEqual([ + expect.objectContaining({ originalFileName: "global.txt", environmentId: "default", environment: "default" }), + ]); + }); + it("should handle empty globals", async () => { const store = new DefaultAllureStore(); const globals: RawGlobals = { @@ -2135,8 +2252,14 @@ describe("visitGlobals", () => { const dump = store.dumpState(); expect(dump.globalAttachmentIds).toHaveLength(1); + expect(dump.globalAttachmentIdsByEnvironmentId).toEqual({ + default: dump.globalAttachmentIds, + }); expect(dump.globalErrors).toHaveLength(1); - expect(dump.globalErrors[0]).toEqual({ message: "Global error" }); + expect(dump.globalErrors[0]).toEqual({ message: "Global error", environmentId: "default", environment: "default" }); + expect(dump.globalErrorsByEnvironmentId).toEqual({ + default: [expect.objectContaining({ message: "Global error", environmentId: "default", environment: "default" })], + }); const attachmentId = dump.globalAttachmentIds[0]; @@ -2315,7 +2438,10 @@ describe("dump state", () => { expect((dump.attachments[dump.globalAttachmentIds[0]] as AttachmentLinkLinked).name).toBe("global-log.txt"); expect((dump.attachments[dump.globalAttachmentIds[1]] as AttachmentLinkLinked).name).toBe("global-screenshot.png"); expect(dump.globalErrors).toHaveLength(2); - expect(dump.globalErrors).toEqual([globalError1, globalError2]); + expect(dump.globalErrors).toEqual([ + { ...globalError1, environmentId: "default", environment: "default" }, + { ...globalError2, environmentId: "default", environment: "default" }, + ]); expect(dump.qualityGateResults).toEqual([]); }); @@ -2446,9 +2572,15 @@ describe("dump state", () => { const testResults = await store.allTestResults(); expect(restoredGlobalAttachments).toHaveLength(2); - expect(restoredGlobalAttachments).toEqual([globalAttachment1, globalAttachment2]); + expect(restoredGlobalAttachments).toEqual([ + { ...globalAttachment1, environmentId: "default", environment: "default" }, + { ...globalAttachment2, environmentId: "default", environment: "default" }, + ]); expect(restoredGlobalErrors).toHaveLength(2); - expect(restoredGlobalErrors).toEqual([globalError1, globalError2]); + expect(restoredGlobalErrors).toEqual([ + { ...globalError1, environmentId: "default", environment: "default" }, + { ...globalError2, environmentId: "default", environment: "default" }, + ]); expect(restoredQualityGateResults).toEqual([]); expect(testResults).toHaveLength(1); }); @@ -2522,8 +2654,16 @@ describe("dump state", () => { expect(allGlobalAttachments.some((att) => att.name === "initial.log")).toBe(true); expect(allGlobalAttachments.some((att) => att.originalFileName === "dump.log")).toBe(true); expect(allGlobalErrors).toHaveLength(2); - expect(allGlobalErrors).toContain(initialError); - expect(allGlobalErrors).toContain(dumpError); + expect(allGlobalErrors).toContainEqual({ + ...initialError, + environmentId: "default", + environment: "default", + }); + expect(allGlobalErrors).toContainEqual({ + ...dumpError, + environmentId: "default", + environment: "default", + }); }); it("should handle restoreState with missing globalAttachments and globalErrors gracefully", async () => { diff --git a/packages/plugin-api/src/config.ts b/packages/plugin-api/src/config.ts index 7c4da743b3a..6b020052ef2 100644 --- a/packages/plugin-api/src/config.ts +++ b/packages/plugin-api/src/config.ts @@ -31,6 +31,7 @@ export interface Config { * Legacy alias for environmentName. */ environment?: string; + allowedEnvironments?: string[]; environments?: EnvironmentsConfig; variables?: ReportVariables; /** diff --git a/packages/plugin-api/src/plugin.ts b/packages/plugin-api/src/plugin.ts index 369a8667850..70a81e65d8b 100644 --- a/packages/plugin-api/src/plugin.ts +++ b/packages/plugin-api/src/plugin.ts @@ -83,10 +83,22 @@ export interface ExitCode { original: number; } +export type PluginGlobalAttachment = AttachmentLink & { + environmentId?: string; + environment?: string; +}; + +export type PluginGlobalError = TestError & { + environmentId?: string; + environment?: string; +}; + export interface PluginGlobals { exitCode?: ExitCode; - errors: TestError[]; - attachments: AttachmentLink[]; + errors: PluginGlobalError[]; + attachments: PluginGlobalAttachment[]; + errorsByEnv?: Record; + attachmentsByEnv?: Record; } export interface BatchOptions { @@ -94,11 +106,18 @@ export interface BatchOptions { } export interface RealtimeSubscriber { - onGlobalAttachment(listener: (payload: { attachment: ResultFile; fileName?: string }) => Promise): () => void; + onGlobalAttachment( + listener: (payload: { + attachment: ResultFile; + fileName?: string; + environmentId?: string; + environment?: string; + }) => Promise, + ): () => void; onGlobalExitCode(listener: (payload: ExitCode) => Promise): () => void; - onGlobalError(listener: (error: TestError) => Promise): () => void; + onGlobalError(listener: (error: PluginGlobalError) => Promise): () => void; onQualityGateResults(listener: (payload: QualityGateValidationResult[]) => Promise): () => void; @@ -110,11 +129,11 @@ export interface RealtimeSubscriber { } export interface RealtimeEventsDispatcher { - sendGlobalAttachment(attachment: ResultFile, fileName?: string): void; + sendGlobalAttachment(attachment: ResultFile, fileName?: string, environmentId?: string, environment?: string): void; sendGlobalExitCode(payload: ExitCode): void; - sendGlobalError(error: TestError): void; + sendGlobalError(error: PluginGlobalError): void; sendQualityGateResults(payload: QualityGateValidationResult[]): void; diff --git a/packages/plugin-api/src/qualityGate.ts b/packages/plugin-api/src/qualityGate.ts index 7f2fa4ea2e5..c5065d07d17 100644 --- a/packages/plugin-api/src/qualityGate.ts +++ b/packages/plugin-api/src/qualityGate.ts @@ -38,6 +38,7 @@ export type QualityGateRule = { trs: TestResult[]; knownIssues: KnownTestFailure[]; state: QualityGateRuleState; + environmentId?: string; environmentName?: string; }) => Promise; }; diff --git a/packages/plugin-api/src/store.ts b/packages/plugin-api/src/store.ts index 6beb6f58c19..0f2f5792acb 100644 --- a/packages/plugin-api/src/store.ts +++ b/packages/plugin-api/src/store.ts @@ -8,12 +8,11 @@ import type { Statistic, TestCase, TestEnvGroup, - TestError, TestFixtureResult, TestResult, } from "@allurereport/core-api"; -import type { ExitCode } from "./plugin.js"; +import type { ExitCode, PluginGlobalAttachment, PluginGlobalError } from "./plugin.js"; import type { QualityGateValidationResult } from "./qualityGate.js"; import type { ResultFile } from "./resultFile.js"; @@ -37,8 +36,10 @@ export interface AllureStore { qualityGateResultsByEnvironmentId: () => Promise>; // global data globalExitCode: () => Promise; - allGlobalErrors: () => Promise; - allGlobalAttachments: () => Promise; + allGlobalErrors: () => Promise; + allGlobalErrorsByEnvironmentId: () => Promise>; + allGlobalAttachments: () => Promise; + allGlobalAttachmentsByEnvironmentId: () => Promise>; // search api testCaseById: (tcId: string) => Promise; testResultById: (trId: string) => Promise; @@ -75,7 +76,9 @@ export interface AllureStoreDump { testResults: Record; attachments: Record; globalAttachmentIds: string[]; - globalErrors: TestError[]; + globalAttachmentIdsByEnvironmentId?: Record; + globalErrors: PluginGlobalError[]; + globalErrorsByEnvironmentId?: Record; testCases: Record; fixtures: Record; environments: Array; diff --git a/packages/plugin-awesome/src/generators.ts b/packages/plugin-awesome/src/generators.ts index 558f3e86eb9..e72ebeb96ed 100644 --- a/packages/plugin-awesome/src/generators.ts +++ b/packages/plugin-awesome/src/generators.ts @@ -10,11 +10,11 @@ import { type EnvironmentItem, type Statistic, type TestEnvGroup, - type TestError, type TestLabel, type TestResult, type TreeData, compareBy, + createDictionary, createBaseUrlScript, createFontLinkTag, createReportDataScript, @@ -29,6 +29,8 @@ import type { AllureStore, ExitCode, PluginContext, + PluginGlobalAttachment, + PluginGlobalError, PluginGlobals, QualityGateValidationResult, ReportFiles, @@ -454,16 +456,28 @@ export const generateGlobals = async ( writer: AwesomeDataWriter, payload: { globalExitCode?: ExitCode; - globalAttachments?: AttachmentLink[]; - globalErrors?: TestError[]; + globalAttachments?: PluginGlobalAttachment[]; + globalAttachmentsByEnv?: Record; + globalErrors?: PluginGlobalError[]; + globalErrorsByEnv?: Record; contentFunction: (id: string) => Promise; }, ) => { - const { globalExitCode, globalAttachments = [], globalErrors = [], contentFunction } = payload; + const { + globalExitCode, + globalAttachments = [], + globalAttachmentsByEnv = {}, + globalErrors = [], + globalErrorsByEnv = {}, + contentFunction, + } = payload; const globals: PluginGlobals = { errors: globalErrors, attachments: [], + errorsByEnv: globalErrorsByEnv, + attachmentsByEnv: createDictionary(), }; + const writtenAttachmentIds = new Set(); if (globalExitCode) { globals.exitCode = globalExitCode; @@ -480,8 +494,15 @@ export const generateGlobals = async ( await writer.writeAttachment(src, content); globals.attachments.push(attachment); + writtenAttachmentIds.add(attachment.id); } + Object.entries(globalAttachmentsByEnv).forEach(([environmentId, attachments]) => { + globals.attachmentsByEnv![environmentId] = attachments.filter((attachment) => + writtenAttachmentIds.has(attachment.id), + ); + }); + await writer.writeWidget("globals.json", globals); }; diff --git a/packages/plugin-awesome/src/plugin.ts b/packages/plugin-awesome/src/plugin.ts index bfd1ea3f7cc..0e1b08f2bbd 100644 --- a/packages/plugin-awesome/src/plugin.ts +++ b/packages/plugin-awesome/src/plugin.ts @@ -49,8 +49,10 @@ export class AwesomePlugin implements Plugin { const envStatistics = new Map(); const allTestEnvGroups = await store.allTestEnvGroups(); const globalAttachments = await store.allGlobalAttachments(); + const globalAttachmentsByEnv = await store.allGlobalAttachmentsByEnvironmentId(); const globalExitCode = await store.globalExitCode(); const globalErrors = await store.allGlobalErrors(); + const globalErrorsByEnv = await store.allGlobalErrorsByEnvironmentId(); const qualityGateResults = await store.qualityGateResultsByEnvironmentId(); for (const env of environments) { @@ -122,7 +124,9 @@ export class AwesomePlugin implements Plugin { await generateQualityGateResults(this.#writer!, qualityGateResults); await generateGlobals(this.#writer!, { globalAttachments, + globalAttachmentsByEnv, globalErrors, + globalErrorsByEnv, globalExitCode, contentFunction: (id) => store.attachmentContentById(id), }); diff --git a/packages/plugin-awesome/test/generators.test.ts b/packages/plugin-awesome/test/generators.test.ts index 1ce6367ff70..ee0ae6352d6 100644 --- a/packages/plugin-awesome/test/generators.test.ts +++ b/packages/plugin-awesome/test/generators.test.ts @@ -68,8 +68,10 @@ describe("generateAllCharts", () => { testsStatistic: vi.fn(async (filter: (tr: TestResult) => boolean) => getTestResultsStats(testResults, filter)), allTestEnvGroups: vi.fn().mockResolvedValue([]), allGlobalAttachments: vi.fn().mockResolvedValue([]), + allGlobalAttachmentsByEnvironmentId: vi.fn().mockResolvedValue({}), globalExitCode: vi.fn().mockResolvedValue(undefined), allGlobalErrors: vi.fn().mockResolvedValue([]), + allGlobalErrorsByEnvironmentId: vi.fn().mockResolvedValue({}), qualityGateResults: vi.fn().mockResolvedValue([]), qualityGateResultsByEnvironmentId: vi.fn().mockResolvedValue({}), fixturesByTrId: vi.fn().mockResolvedValue([]), diff --git a/packages/plugin-awesome/test/plugin.test.ts b/packages/plugin-awesome/test/plugin.test.ts index 42c2bab3b73..b327b1f1d9c 100644 --- a/packages/plugin-awesome/test/plugin.test.ts +++ b/packages/plugin-awesome/test/plugin.test.ts @@ -190,8 +190,10 @@ describe("plugin", () => { ), allTestEnvGroups: vi.fn().mockResolvedValue([]), allGlobalAttachments: vi.fn().mockResolvedValue([]), + allGlobalAttachmentsByEnvironmentId: vi.fn().mockResolvedValue({}), globalExitCode: vi.fn().mockResolvedValue(undefined), allGlobalErrors: vi.fn().mockResolvedValue([]), + allGlobalErrorsByEnvironmentId: vi.fn().mockResolvedValue({}), qualityGateResults: vi.fn().mockResolvedValue([]), qualityGateResultsByEnv: vi.fn().mockResolvedValue({}), qualityGateResultsByEnvironmentId: vi.fn().mockResolvedValue({}), @@ -274,8 +276,10 @@ describe("plugin", () => { testsStatistic: vi.fn(async (filter: (tr: TestResult) => boolean) => getTestResultsStats(testResults, filter)), allTestEnvGroups: vi.fn().mockResolvedValue([]), allGlobalAttachments: vi.fn().mockResolvedValue([]), + allGlobalAttachmentsByEnvironmentId: vi.fn().mockResolvedValue({}), globalExitCode: vi.fn().mockResolvedValue(undefined), allGlobalErrors: vi.fn().mockResolvedValue([]), + allGlobalErrorsByEnvironmentId: vi.fn().mockResolvedValue({}), qualityGateResults: vi.fn().mockResolvedValue([]), qualityGateResultsByEnv: vi.fn().mockResolvedValue({}), qualityGateResultsByEnvironmentId: vi.fn().mockResolvedValue({}), diff --git a/packages/plugin-testops/src/plugin.ts b/packages/plugin-testops/src/plugin.ts index a1a03b64a51..405e978e752 100644 --- a/packages/plugin-testops/src/plugin.ts +++ b/packages/plugin-testops/src/plugin.ts @@ -103,7 +103,9 @@ export class TestopsPlugin implements Plugin { fallbackEnvironmentIds.values(), ) .map((environmentLabel) => JSON.stringify(environmentLabel)) - .join(", ")}. These IDs come from legacy or fallback environment resolution and may change after rename.`, + .join( + ", ", + )}. These IDs come from non-configured compatibility environment resolution and may change after rename.`, ); fallbackEnvironmentIds.forEach((_, environmentId) => { diff --git a/packages/plugin-testops/test/plugin.test.ts b/packages/plugin-testops/test/plugin.test.ts index eddba619dd8..b0044315ed5 100644 --- a/packages/plugin-testops/test/plugin.test.ts +++ b/packages/plugin-testops/test/plugin.test.ts @@ -477,7 +477,7 @@ describe("testops plugin", () => { await plugin.start({} as PluginContext, store); expect(warn).toHaveBeenCalledWith( - 'TestOps upload uses environment IDs that are not explicitly configured: "Legacy Env". These IDs come from legacy or fallback environment resolution and may change after rename.', + 'TestOps upload uses environment IDs that are not explicitly configured: "Legacy Env". These IDs come from non-configured compatibility environment resolution and may change after rename.', ); }); diff --git a/packages/reader-api/src/model.ts b/packages/reader-api/src/model.ts index 92185b552e4..c0f6d4777cf 100644 --- a/packages/reader-api/src/model.ts +++ b/packages/reader-api/src/model.ts @@ -9,6 +9,11 @@ export type RawError = { actual?: string; }; +export type RawGlobalError = RawError & { + environmentId?: string; + environment?: string; +}; + export interface RawFixtureResult { uuid?: string; testResults?: string[]; @@ -120,7 +125,12 @@ export interface RawTestAttachment { type: "attachment"; } +export type RawGlobalAttachment = RawTestAttachment & { + environmentId?: string; + environment?: string; +}; + export interface RawGlobals { - attachments: RawTestAttachment[]; - errors: RawError[]; + attachments: RawGlobalAttachment[]; + errors: RawGlobalError[]; } diff --git a/packages/reader/src/allure2/index.ts b/packages/reader/src/allure2/index.ts index 50b7bf59a5d..df0d1995fc2 100644 --- a/packages/reader/src/allure2/index.ts +++ b/packages/reader/src/allure2/index.ts @@ -310,8 +310,20 @@ const processGlobals = async (visitor: ResultsVisitor, globals: Globals) => { await visitor.visitGlobals( { - attachments: Array.isArray(attachments) ? attachments.map((attachment) => convertAttachment(attachment)) : [], - errors: isStringAnyRecordArray(errors) ? errors : [], + attachments: Array.isArray(attachments) + ? attachments.map((attachment) => ({ + ...convertAttachment(attachment), + environmentId: ensureString((attachment as any).environmentId), + environment: ensureString((attachment as any).environment), + })) + : [], + errors: isStringAnyRecordArray(errors) + ? errors.map((error) => ({ + ...error, + environmentId: ensureString((error as any).environmentId), + environment: ensureString((error as any).environment), + })) + : [], }, { readerId }, ); diff --git a/packages/sandbox/README.md b/packages/sandbox/README.md index 6be5daa6913..5fb26e0a88a 100644 --- a/packages/sandbox/README.md +++ b/packages/sandbox/README.md @@ -1,3 +1,12 @@ # Sandbox Simple sandbox package for testing the workspace packages. + +## Env-scoped globals demo + +Generate a separate report for checking globals grouped by environment: + +```bash +yarn workspace sandbox g:globals-env +yarn workspace sandbox o:globals-env +``` diff --git a/packages/sandbox/allurerc.globals-env.mjs b/packages/sandbox/allurerc.globals-env.mjs new file mode 100644 index 00000000000..745035ae3b5 --- /dev/null +++ b/packages/sandbox/allurerc.globals-env.mjs @@ -0,0 +1,23 @@ +import { defineConfig } from "allure"; + +export default defineConfig({ + name: "Allure Report", + plugins: { + awesome: { + options: { + reportName: "Globals By Environment Demo", + open: false, + }, + }, + }, + environments: { + foo: { + name: "foo", + matcher: ({ labels }) => labels.some(({ name, value }) => name === "env" && value === "foo"), + }, + bar: { + name: "bar", + matcher: ({ labels }) => labels.some(({ name, value }) => name === "env" && value === "bar"), + }, + }, +}); diff --git a/packages/sandbox/package.json b/packages/sandbox/package.json index 5044907523e..7957d92e61b 100644 --- a/packages/sandbox/package.json +++ b/packages/sandbox/package.json @@ -9,7 +9,9 @@ "type": "module", "scripts": { "g": "yarn allure generate --config=./allurerc.mjs", + "g:globals-env": "node ./scripts/generate-globals-env-fixture.mjs && yarn allure generate ./allure-results-globals-env --config=./allurerc.globals-env.mjs --output=./allure-report-globals-env", "o": "yarn allure open ./allure-report", + "o:globals-env": "yarn allure open ./allure-report-globals-env", "pret": "rimraf ./allure-results", "t": "yarn allure run -- vitest run", "test": "vitest run", diff --git a/packages/sandbox/scripts/generate-globals-env-fixture.mjs b/packages/sandbox/scripts/generate-globals-env-fixture.mjs new file mode 100644 index 00000000000..95851b5c968 --- /dev/null +++ b/packages/sandbox/scripts/generate-globals-env-fixture.mjs @@ -0,0 +1,104 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +const rootDir = new URL("..", import.meta.url).pathname; +const resultsDir = resolve(rootDir, "allure-results-globals-env"); + +const now = Date.now(); + +const testResults = [ + { + uuid: randomUUID(), + historyId: "globals-env-foo", + fullName: "globals env foo", + name: "globals env foo", + status: "passed", + stage: "finished", + start: now, + stop: now + 100, + labels: [{ name: "env", value: "foo" }], + }, + { + uuid: randomUUID(), + historyId: "globals-env-bar", + fullName: "globals env bar", + name: "globals env bar", + status: "failed", + stage: "finished", + statusDetails: { + message: "bar test failed", + trace: "Error: bar test failed", + }, + start: now + 200, + stop: now + 350, + labels: [{ name: "env", value: "bar" }], + }, + { + uuid: randomUUID(), + historyId: "globals-env-default", + fullName: "globals env default", + name: "globals env default", + status: "passed", + stage: "finished", + start: now + 400, + stop: now + 520, + labels: [], + }, +]; + +const globals = { + attachments: [ + { + name: "foo global log", + type: "text/plain", + source: "foo-global.txt", + environmentId: "foo", + environment: "foo", + }, + { + name: "bar global log", + type: "text/plain", + source: "bar-global.txt", + environmentId: "bar", + environment: "bar", + }, + { + name: "default global log", + type: "text/plain", + source: "default-global.txt", + }, + ], + errors: [ + { + message: "foo global error", + trace: "Error: foo global error", + environmentId: "foo", + environment: "foo", + }, + { + message: "bar global error", + trace: "Error: bar global error", + environmentId: "bar", + environment: "bar", + }, + { + message: "default global error", + trace: "Error: default global error", + }, + ], +}; + +await rm(resultsDir, { recursive: true, force: true }); +await mkdir(resultsDir, { recursive: true }); + +for (const [index, result] of testResults.entries()) { + await writeFile(resolve(resultsDir, `globals-env-${index + 1}-result.json`), JSON.stringify(result, null, 2)); +} + +await writeFile(resolve(resultsDir, "foo-global.txt"), "foo scoped global attachment\n"); +await writeFile(resolve(resultsDir, "bar-global.txt"), "bar scoped global attachment\n"); +await writeFile(resolve(resultsDir, "default-global.txt"), "default scoped global attachment\n"); +await writeFile(resolve(resultsDir, `${randomUUID()}-globals.json`), JSON.stringify(globals, null, 2)); + +console.log(`Generated fixture in ${resultsDir}`); diff --git a/packages/web-awesome/src/components/MainReport/index.tsx b/packages/web-awesome/src/components/MainReport/index.tsx index da2db4ae597..3c2050bd478 100644 --- a/packages/web-awesome/src/components/MainReport/index.tsx +++ b/packages/web-awesome/src/components/MainReport/index.tsx @@ -177,17 +177,29 @@ const MainReport = () => { /> ( - <> - - {t("globalAttachments")} - - - {t("globalErrors")}{" "} - 0 ? "failed" : undefined} count={errors.length} /> - - - )} + renderData={({ attachments = [], attachmentsByEnv = {}, errors = [], errorsByEnv = {} }) => { + const currentEnvAttachments = currentEnvironment.value + ? (attachmentsByEnv[currentEnvironment.value] ?? []) + : attachments; + const currentEnvErrors = currentEnvironment.value + ? (errorsByEnv[currentEnvironment.value] ?? []) + : errors; + + return ( + <> + + {t("globalAttachments")} + + + {t("globalErrors")}{" "} + 0 ? "failed" : undefined} + count={currentEnvErrors.length} + /> + + + ); + }} />
diff --git a/packages/web-awesome/src/components/ReportGlobalAttachments/index.tsx b/packages/web-awesome/src/components/ReportGlobalAttachments/index.tsx index 1b6f5a7eb87..3495921eeac 100644 --- a/packages/web-awesome/src/components/ReportGlobalAttachments/index.tsx +++ b/packages/web-awesome/src/components/ReportGlobalAttachments/index.tsx @@ -1,35 +1,101 @@ +import { DEFAULT_ENVIRONMENT } from "@allurereport/core-api"; import type { AttachmentTestStepResult } from "@allurereport/core-api"; import { Loadable } from "@allurereport/web-components"; +import { useState } from "preact/hooks"; -import { TrAttachmentView } from "@/components/TestResult/TrAttachmentsView"; +import { MetadataButton } from "@/components/MetadataButton"; +import { TrAttachment } from "@/components/TestResult/TrSteps/TrAttachment"; import { useI18n } from "@/stores"; +import { currentEnvironment, environmentNameById } from "@/stores/env"; import { globalsStore } from "@/stores/globals"; -import { AwesomeTestResult } from "../../../types"; - import * as styles from "./styles.scss"; export const ReportGlobalAttachments = () => { const { t } = useI18n("empty"); + const { t: tEnvironments } = useI18n("environments"); + const [collapsedEnvs, setCollapsedEnvs] = useState([]); + + const renderAttachmentList = (attachments: any[]) => { + const attachmentSteps: AttachmentTestStepResult[] = attachments.map((attachment: any) => ({ + link: attachment, + type: "attachment", + })); + + return ( +
+ {attachmentSteps.map((attachment, index) => ( + + ))} +
+ ); + }; + + const renderAttachmentsContent = (attachments: any[]) => ( +
{renderAttachmentList(attachments)}
+ ); return ( { + renderData={({ attachments = [], attachmentsByEnv = {} }) => { + if (currentEnvironment.value) { + const envAttachments = attachmentsByEnv[currentEnvironment.value] ?? []; + + if (!envAttachments.length) { + return
{t("no-attachments-results")}
; + } + + return renderAttachmentsContent(envAttachments); + } + + const entries = Object.entries(attachmentsByEnv).filter(([, envAttachments]) => envAttachments.length > 0); + + if (!entries.length) { + if (!attachments.length) { + return
{t("no-attachments-results")}
; + } + + return renderAttachmentsContent(attachments); + } + + if (entries.length === 1 && entries[0][0] === DEFAULT_ENVIRONMENT) { + return renderAttachmentsContent(entries[0][1] ?? []); + } + if (!attachments.length) { return
{t("no-attachments-results")}
; } - const attachmentSteps: AttachmentTestStepResult[] = attachments.map((attachment: any) => ({ - link: attachment, - type: "attachment", - })); - return ( - +
+ {entries.map(([environmentId, envAttachments]) => { + const isOpened = !collapsedEnvs.includes(environmentId); + const toggleEnv = () => { + setCollapsedEnvs((prev) => + isOpened ? prev.concat(environmentId) : prev.filter((currentId) => currentId !== environmentId), + ); + }; + + return ( +
+ + {isOpened ? renderAttachmentList(envAttachments) : null} +
+ ); + })} +
); }} /> diff --git a/packages/web-awesome/src/components/ReportGlobalAttachments/styles.scss b/packages/web-awesome/src/components/ReportGlobalAttachments/styles.scss index a1dfd822cad..0f5a5b19389 100644 --- a/packages/web-awesome/src/components/ReportGlobalAttachments/styles.scss +++ b/packages/web-awesome/src/components/ReportGlobalAttachments/styles.scss @@ -1,6 +1,22 @@ .report-global-attachments { - padding-left: 0; - padding-right: 0; + padding: 12px 0 40px 8px; +} + +.report-global-attachments-view { + min-height: 0 !important; + padding: 20px 0 8px; + + [data-testid="test-result-attachment-header"] + div { + margin-top: 6px; + } +} + +.report-global-attachments-section { + &:not(:last-child) { + padding-bottom: 8px; + margin-bottom: 14px; + border-bottom: 1px solid var(--on-border-muted); + } } .report-global-attachments-empty { diff --git a/packages/web-awesome/src/components/ReportGlobalErrors/index.tsx b/packages/web-awesome/src/components/ReportGlobalErrors/index.tsx index 3678f22f33f..15c3f22c81b 100644 --- a/packages/web-awesome/src/components/ReportGlobalErrors/index.tsx +++ b/packages/web-awesome/src/components/ReportGlobalErrors/index.tsx @@ -1,30 +1,91 @@ +import { DEFAULT_ENVIRONMENT } from "@allurereport/core-api"; import { Loadable } from "@allurereport/web-components"; +import { useState } from "preact/hooks"; +import { MetadataButton } from "@/components/MetadataButton"; import { TrError } from "@/components/TestResult/TrError"; import { useI18n } from "@/stores"; +import { currentEnvironment, environmentNameById } from "@/stores/env"; import { globalsStore } from "@/stores/globals"; import * as styles from "./styles.scss"; export const ReportGlobalErrors = () => { const { t } = useI18n("empty"); + const { t: tEnvironments } = useI18n("environments"); + const [collapsedEnvs, setCollapsedEnvs] = useState([]); + + const renderErrors = (errors: any[]) => ( +
    + {errors.map((error, i) => ( +
  • + +
  • + ))} +
+ ); + + const renderErrorsContent = (errors: any[]) => ( +
{renderErrors(errors)}
+ ); return ( { + renderData={({ errors = [], errorsByEnv = {} }) => { + if (currentEnvironment.value) { + const envErrors = errorsByEnv[currentEnvironment.value] ?? []; + + if (!envErrors.length) { + return
{t("no-global-errors-results")}
; + } + + return renderErrorsContent(envErrors); + } + + const entries = Object.entries(errorsByEnv).filter(([, envErrors]) => envErrors.length > 0); + + if (!entries.length) { + if (!errors.length) { + return
{t("no-global-errors-results")}
; + } + + return renderErrorsContent(errors); + } + + if (entries.length === 1 && entries[0][0] === DEFAULT_ENVIRONMENT) { + return renderErrorsContent(entries[0][1] ?? []); + } + if (!errors.length) { return
{t("no-global-errors-results")}
; } return ( -
    - {errors.map((error, i) => ( -
  • - -
  • - ))} -
+
+ {entries.map(([environmentId, envErrors]) => { + const isOpened = !collapsedEnvs.includes(environmentId); + const toggleEnv = () => { + setCollapsedEnvs((prev) => + isOpened ? prev.concat(environmentId) : prev.filter((currentId) => currentId !== environmentId), + ); + }; + + return ( +
+ + {isOpened ? renderErrors(envErrors) : null} +
+ ); + })} +
); }} /> diff --git a/packages/web-awesome/src/components/ReportGlobalErrors/styles.scss b/packages/web-awesome/src/components/ReportGlobalErrors/styles.scss index dfc893f8f1e..7f7e0e91887 100644 --- a/packages/web-awesome/src/components/ReportGlobalErrors/styles.scss +++ b/packages/web-awesome/src/components/ReportGlobalErrors/styles.scss @@ -1,9 +1,21 @@ @import "~@allurereport/web-components/mixins.scss"; +.report-global-errors-container { + padding: 12px 0 40px 8px; +} + .report-global-errors { padding: 20px 0; } +.report-global-errors-section { + &:not(:last-child) { + padding-bottom: 8px; + margin-bottom: 14px; + border-bottom: 1px solid var(--on-border-muted); + } +} + .report-global-errors-empty { display: flex; padding: 48px 0; diff --git a/packages/web-awesome/test/components/ReportGlobals.test.tsx b/packages/web-awesome/test/components/ReportGlobals.test.tsx new file mode 100644 index 00000000000..d0145d1c5f1 --- /dev/null +++ b/packages/web-awesome/test/components/ReportGlobals.test.tsx @@ -0,0 +1,107 @@ +import { cleanup, render, screen } from "@testing-library/preact"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { currentEnvironment, environmentsStore } from "@/stores/env"; +import { globalsStore } from "@/stores/globals"; + +vi.mock("@/components/TestResult/TrSteps/TrAttachment", () => ({ + TrAttachment: ({ item }: { item: { link: { name: string } } }) => ( +
{item.link.name}
+ ), +})); + +vi.mock("@/components/TestResult/TrError", () => ({ + TrError: ({ message }: { message?: string }) =>
{message}
, +})); + +vi.mock("@/components/MetadataButton", () => ({ + MetadataButton: ({ title, counter }: { title?: string; counter?: number }) => ( + + ), +})); + +describe("components > ReportGlobals", () => { + beforeEach(() => { + vi.stubGlobal( + "matchMedia", + vi.fn().mockImplementation(() => ({ + matches: false, + media: "", + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); + currentEnvironment.value = ""; + environmentsStore.value = { + loading: false, + error: undefined, + data: [ + { id: "default", name: "default" }, + { id: "qa", name: "QA" }, + { id: "prod", name: "Production" }, + ], + }; + globalsStore.value = { + loading: false, + error: undefined, + data: { + attachments: [ + { id: "a-1", name: "qa-log.txt", ext: ".txt", environmentId: "qa", environment: "QA" }, + { id: "a-2", name: "prod-log.txt", ext: ".txt", environmentId: "prod", environment: "Production" }, + ], + attachmentsByEnv: { + qa: [{ id: "a-1", name: "qa-log.txt", ext: ".txt", environmentId: "qa", environment: "QA" }], + prod: [{ id: "a-2", name: "prod-log.txt", ext: ".txt", environmentId: "prod", environment: "Production" }], + }, + errors: [ + { message: "qa error", environmentId: "qa", environment: "QA" }, + { message: "prod error", environmentId: "prod", environment: "Production" }, + ], + errorsByEnv: { + qa: [{ message: "qa error", environmentId: "qa", environment: "QA" }], + prod: [{ message: "prod error", environmentId: "prod", environment: "Production" }], + }, + }, + }; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + cleanup(); + }); + + it("filters global attachments by current environment", async () => { + const { ReportGlobalAttachments } = await import("@/components/ReportGlobalAttachments"); + currentEnvironment.value = "qa"; + + render(); + + expect(screen.getByTestId("attachment-row")).toHaveTextContent("qa-log.txt"); + expect(screen.queryByText("prod-log.txt")).not.toBeInTheDocument(); + }); + + it("renders grouped global attachments when multiple environments are present", async () => { + const { ReportGlobalAttachments } = await import("@/components/ReportGlobalAttachments"); + + render(); + + expect(screen.getByRole("button", { name: /QA/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Production/ })).toBeInTheDocument(); + expect(screen.getByText("qa-log.txt")).toBeInTheDocument(); + expect(screen.getByText("prod-log.txt")).toBeInTheDocument(); + }); + + it("filters global errors by current environment", async () => { + const { ReportGlobalErrors } = await import("@/components/ReportGlobalErrors"); + currentEnvironment.value = "prod"; + + render(); + + expect(screen.getByTestId("global-error")).toHaveTextContent("prod error"); + expect(screen.queryByText("qa error")).not.toBeInTheDocument(); + }); +}); diff --git a/scripts/oxfmt-vscode b/scripts/oxfmt-vscode new file mode 100755 index 00000000000..80f3ea6a9a9 --- /dev/null +++ b/scripts/oxfmt-vscode @@ -0,0 +1,6 @@ +#!/usr/bin/env sh + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +ROOT_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) + +exec node "$ROOT_DIR/.yarn/releases/yarn-4.5.1.cjs" oxfmt "$@" diff --git a/scripts/oxlint-tsgolint-vscode b/scripts/oxlint-tsgolint-vscode new file mode 100755 index 00000000000..2aaeaeab2b4 --- /dev/null +++ b/scripts/oxlint-tsgolint-vscode @@ -0,0 +1,6 @@ +#!/usr/bin/env sh + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +ROOT_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) + +exec node "$ROOT_DIR/.yarn/releases/yarn-4.5.1.cjs" tsgolint "$@" diff --git a/scripts/oxlint-vscode b/scripts/oxlint-vscode new file mode 100755 index 00000000000..e3ab90010a6 --- /dev/null +++ b/scripts/oxlint-vscode @@ -0,0 +1,6 @@ +#!/usr/bin/env sh + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +ROOT_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) + +exec node "$ROOT_DIR/.yarn/releases/yarn-4.5.1.cjs" oxlint "$@"