Mm 67896 manual cherry pick onto release 10.11 (#35989)
* improves time limit checks * consistently check for presence of patch fields * fix variable shadowing in test * allow idempotent pinning operations with time limit expired * new utility function for post limit time check * fix style issue * Add missing E2E CI files and delivery-platform migration for release-10.11 - Add calculate-playwright-results and calculate-cypress-results GitHub Actions (referenced by e2e-tests-playwright-template.yml and e2e-tests-cypress-template.yml but never backported to release-10.11) - Add e2e-tests/playwright/merge.config.mjs (required by merge-reports step) - Add run-specs Makefile target and server.run_specs.sh (required by run-failed-tests job) - Fix merge-shard-results step: pin @playwright/test version and add fallback for when no blob reports exist (json reporter output used directly) - Remove pull_request trigger from e2e-tests-ci.yml (delivery-platform migration) - Remove dead e2e-fulltests-ci.yml and e2e-tests-ci-template.yml Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Mattermost Build <build@mattermost.com> Co-authored-by: yasserfaraazkhan <attitude3cena.yf@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
e092af7a33
Коммит
b21ef30202
2
.github/actions/calculate-cypress-results/.gitignore
поставляемый
Обычный файл
2
.github/actions/calculate-cypress-results/.gitignore
поставляемый
Обычный файл
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
50
.github/actions/calculate-cypress-results/action.yaml
поставляемый
Обычный файл
50
.github/actions/calculate-cypress-results/action.yaml
поставляемый
Обычный файл
@@ -0,0 +1,50 @@
|
|||||||
|
name: Calculate Cypress Results
|
||||||
|
description: Calculate Cypress test results with optional merge of retest results
|
||||||
|
author: Mattermost
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
original-results-path:
|
||||||
|
description: Path to the original Cypress results directory (e.g., e2e-tests/cypress/results)
|
||||||
|
required: true
|
||||||
|
retest-results-path:
|
||||||
|
description: Path to the retest Cypress results directory (optional - if not provided, only calculates from original)
|
||||||
|
required: false
|
||||||
|
write-merged:
|
||||||
|
description: Whether to write merged results back to the original directory (default true)
|
||||||
|
required: false
|
||||||
|
default: "true"
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
# Merge outputs
|
||||||
|
merged:
|
||||||
|
description: Whether merge was performed (true/false)
|
||||||
|
|
||||||
|
# Calculation outputs (same as calculate-cypress-test-results)
|
||||||
|
passed:
|
||||||
|
description: Number of passed tests
|
||||||
|
failed:
|
||||||
|
description: Number of failed tests
|
||||||
|
pending:
|
||||||
|
description: Number of pending/skipped tests
|
||||||
|
total_specs:
|
||||||
|
description: Total number of spec files
|
||||||
|
commit_status_message:
|
||||||
|
description: Message for commit status (e.g., "X failed, Y passed (Z spec files)")
|
||||||
|
failed_specs:
|
||||||
|
description: Comma-separated list of failed spec files (for retest)
|
||||||
|
failed_specs_count:
|
||||||
|
description: Number of failed spec files
|
||||||
|
failed_tests:
|
||||||
|
description: Markdown table rows of failed tests (for GitHub summary)
|
||||||
|
total:
|
||||||
|
description: Total number of tests (passed + failed)
|
||||||
|
pass_rate:
|
||||||
|
description: Pass rate percentage (e.g., "100.00")
|
||||||
|
color:
|
||||||
|
description: Color for webhook based on pass rate (green=100%, yellow=99%+, orange=98%+, red=<98%)
|
||||||
|
test_duration:
|
||||||
|
description: Wall-clock test duration (earliest start to latest end across all specs, formatted as "Xm Ys")
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: node24
|
||||||
|
main: dist/index.js
|
||||||
19347
.github/actions/calculate-cypress-results/dist/index.js
поставляемый
Обычный файл
19347
.github/actions/calculate-cypress-results/dist/index.js
поставляемый
Обычный файл
Различия файлов скрыты, потому что одна или несколько строк слишком длинны
15
.github/actions/calculate-cypress-results/jest.config.js
поставляемый
Обычный файл
15
.github/actions/calculate-cypress-results/jest.config.js
поставляемый
Обычный файл
@@ -0,0 +1,15 @@
|
|||||||
|
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||||
|
module.exports = {
|
||||||
|
preset: "ts-jest",
|
||||||
|
testEnvironment: "node",
|
||||||
|
testMatch: ["**/*.test.ts"],
|
||||||
|
moduleFileExtensions: ["ts", "js"],
|
||||||
|
transform: {
|
||||||
|
"^.+\\.ts$": [
|
||||||
|
"ts-jest",
|
||||||
|
{
|
||||||
|
useESM: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
9136
.github/actions/calculate-cypress-results/package-lock.json
сгенерированный
поставляемый
Обычный файл
9136
.github/actions/calculate-cypress-results/package-lock.json
сгенерированный
поставляемый
Обычный файл
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
27
.github/actions/calculate-cypress-results/package.json
поставляемый
Обычный файл
27
.github/actions/calculate-cypress-results/package.json
поставляемый
Обычный файл
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "calculate-cypress-results",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup",
|
||||||
|
"prettier": "npx prettier --write \"src/**/*.ts\"",
|
||||||
|
"local-action": "local-action . src/main.ts .env",
|
||||||
|
"test": "jest --verbose",
|
||||||
|
"test:watch": "jest --watch --verbose",
|
||||||
|
"test:silent": "jest --silent",
|
||||||
|
"tsc": "tsc -b"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@actions/core": "3.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@github/local-action": "7.0.0",
|
||||||
|
"@types/jest": "30.0.0",
|
||||||
|
"@types/node": "25.2.0",
|
||||||
|
"jest": "30.2.0",
|
||||||
|
"ts-jest": "29.4.6",
|
||||||
|
"tsup": "8.5.1",
|
||||||
|
"typescript": "5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
3
.github/actions/calculate-cypress-results/src/index.ts
поставляемый
Обычный файл
3
.github/actions/calculate-cypress-results/src/index.ts
поставляемый
Обычный файл
@@ -0,0 +1,3 @@
|
|||||||
|
import { run } from "./main";
|
||||||
|
|
||||||
|
run();
|
||||||
101
.github/actions/calculate-cypress-results/src/main.ts
поставляемый
Обычный файл
101
.github/actions/calculate-cypress-results/src/main.ts
поставляемый
Обычный файл
@@ -0,0 +1,101 @@
|
|||||||
|
import * as core from "@actions/core";
|
||||||
|
import {
|
||||||
|
loadSpecFiles,
|
||||||
|
mergeResults,
|
||||||
|
writeMergedResults,
|
||||||
|
calculateResultsFromSpecs,
|
||||||
|
} from "./merge";
|
||||||
|
|
||||||
|
export async function run(): Promise<void> {
|
||||||
|
const originalPath = core.getInput("original-results-path", {
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
const retestPath = core.getInput("retest-results-path"); // Optional
|
||||||
|
const shouldWriteMerged = core.getInput("write-merged") !== "false"; // Default true
|
||||||
|
|
||||||
|
core.info(`Original results: ${originalPath}`);
|
||||||
|
core.info(`Retest results: ${retestPath || "(not provided)"}`);
|
||||||
|
|
||||||
|
let merged = false;
|
||||||
|
let specs;
|
||||||
|
|
||||||
|
if (retestPath) {
|
||||||
|
// Check if retest path has results
|
||||||
|
const retestSpecs = await loadSpecFiles(retestPath);
|
||||||
|
|
||||||
|
if (retestSpecs.length > 0) {
|
||||||
|
core.info(`Found ${retestSpecs.length} retest spec files`);
|
||||||
|
|
||||||
|
// Merge results
|
||||||
|
core.info("Merging results...");
|
||||||
|
const mergeResult = await mergeResults(originalPath, retestPath);
|
||||||
|
specs = mergeResult.specs;
|
||||||
|
merged = true;
|
||||||
|
|
||||||
|
core.info(`Retested specs: ${mergeResult.retestFiles.join(", ")}`);
|
||||||
|
core.info(`Total merged specs: ${specs.length}`);
|
||||||
|
|
||||||
|
// Write merged results back to original directory
|
||||||
|
if (shouldWriteMerged) {
|
||||||
|
core.info("Writing merged results to original directory...");
|
||||||
|
const writeResult = await writeMergedResults(
|
||||||
|
originalPath,
|
||||||
|
retestPath,
|
||||||
|
);
|
||||||
|
core.info(`Updated files: ${writeResult.updatedFiles.length}`);
|
||||||
|
core.info(
|
||||||
|
`Removed duplicates: ${writeResult.removedFiles.length}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
core.warning(
|
||||||
|
`No retest results found at ${retestPath}, using original only`,
|
||||||
|
);
|
||||||
|
specs = await loadSpecFiles(originalPath);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
core.info("No retest path provided, using original results only");
|
||||||
|
specs = await loadSpecFiles(originalPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
core.info(`Calculating results from ${specs.length} spec files...`);
|
||||||
|
|
||||||
|
// Handle case where no results found
|
||||||
|
if (specs.length === 0) {
|
||||||
|
core.setFailed("No Cypress test results found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate all outputs from final results
|
||||||
|
const calc = calculateResultsFromSpecs(specs);
|
||||||
|
|
||||||
|
// Log results
|
||||||
|
core.startGroup("Final Results");
|
||||||
|
core.info(`Passed: ${calc.passed}`);
|
||||||
|
core.info(`Failed: ${calc.failed}`);
|
||||||
|
core.info(`Pending: ${calc.pending}`);
|
||||||
|
core.info(`Total: ${calc.total}`);
|
||||||
|
core.info(`Pass Rate: ${calc.passRate}%`);
|
||||||
|
core.info(`Color: ${calc.color}`);
|
||||||
|
core.info(`Spec Files: ${calc.totalSpecs}`);
|
||||||
|
core.info(`Failed Specs Count: ${calc.failedSpecsCount}`);
|
||||||
|
core.info(`Commit Status Message: ${calc.commitStatusMessage}`);
|
||||||
|
core.info(`Failed Specs: ${calc.failedSpecs || "none"}`);
|
||||||
|
core.info(`Test Duration: ${calc.testDuration}`);
|
||||||
|
core.endGroup();
|
||||||
|
|
||||||
|
// Set all outputs
|
||||||
|
core.setOutput("merged", merged.toString());
|
||||||
|
core.setOutput("passed", calc.passed);
|
||||||
|
core.setOutput("failed", calc.failed);
|
||||||
|
core.setOutput("pending", calc.pending);
|
||||||
|
core.setOutput("total_specs", calc.totalSpecs);
|
||||||
|
core.setOutput("commit_status_message", calc.commitStatusMessage);
|
||||||
|
core.setOutput("failed_specs", calc.failedSpecs);
|
||||||
|
core.setOutput("failed_specs_count", calc.failedSpecsCount);
|
||||||
|
core.setOutput("failed_tests", calc.failedTests);
|
||||||
|
core.setOutput("total", calc.total);
|
||||||
|
core.setOutput("pass_rate", calc.passRate);
|
||||||
|
core.setOutput("color", calc.color);
|
||||||
|
core.setOutput("test_duration", calc.testDuration);
|
||||||
|
}
|
||||||
271
.github/actions/calculate-cypress-results/src/merge.test.ts
поставляемый
Обычный файл
271
.github/actions/calculate-cypress-results/src/merge.test.ts
поставляемый
Обычный файл
@@ -0,0 +1,271 @@
|
|||||||
|
import { calculateResultsFromSpecs } from "./merge";
|
||||||
|
import type { ParsedSpecFile, MochawesomeResult } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create a mochawesome result for testing
|
||||||
|
*/
|
||||||
|
function createMochawesomeResult(
|
||||||
|
specFile: string,
|
||||||
|
tests: { title: string; state: "passed" | "failed" | "pending" }[],
|
||||||
|
): MochawesomeResult {
|
||||||
|
return {
|
||||||
|
stats: {
|
||||||
|
suites: 1,
|
||||||
|
tests: tests.length,
|
||||||
|
passes: tests.filter((t) => t.state === "passed").length,
|
||||||
|
pending: tests.filter((t) => t.state === "pending").length,
|
||||||
|
failures: tests.filter((t) => t.state === "failed").length,
|
||||||
|
start: new Date().toISOString(),
|
||||||
|
end: new Date().toISOString(),
|
||||||
|
duration: 1000,
|
||||||
|
testsRegistered: tests.length,
|
||||||
|
passPercent: 0,
|
||||||
|
pendingPercent: 0,
|
||||||
|
other: 0,
|
||||||
|
hasOther: false,
|
||||||
|
skipped: 0,
|
||||||
|
hasSkipped: false,
|
||||||
|
},
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
uuid: "uuid-1",
|
||||||
|
title: specFile,
|
||||||
|
fullFile: `/app/e2e-tests/cypress/tests/integration/${specFile}`,
|
||||||
|
file: `tests/integration/${specFile}`,
|
||||||
|
beforeHooks: [],
|
||||||
|
afterHooks: [],
|
||||||
|
tests: tests.map((t, i) => ({
|
||||||
|
title: t.title,
|
||||||
|
fullTitle: `${specFile} > ${t.title}`,
|
||||||
|
timedOut: null,
|
||||||
|
duration: 500,
|
||||||
|
state: t.state,
|
||||||
|
speed: "fast",
|
||||||
|
pass: t.state === "passed",
|
||||||
|
fail: t.state === "failed",
|
||||||
|
pending: t.state === "pending",
|
||||||
|
context: null,
|
||||||
|
code: "",
|
||||||
|
err: t.state === "failed" ? { message: "Test failed" } : {},
|
||||||
|
uuid: `test-uuid-${i}`,
|
||||||
|
parentUUID: "uuid-1",
|
||||||
|
isHook: false,
|
||||||
|
skipped: false,
|
||||||
|
})),
|
||||||
|
suites: [],
|
||||||
|
passes: tests
|
||||||
|
.filter((t) => t.state === "passed")
|
||||||
|
.map((_, i) => `test-uuid-${i}`),
|
||||||
|
failures: tests
|
||||||
|
.filter((t) => t.state === "failed")
|
||||||
|
.map((_, i) => `test-uuid-${i}`),
|
||||||
|
pending: tests
|
||||||
|
.filter((t) => t.state === "pending")
|
||||||
|
.map((_, i) => `test-uuid-${i}`),
|
||||||
|
skipped: [],
|
||||||
|
duration: 1000,
|
||||||
|
root: true,
|
||||||
|
rootEmpty: false,
|
||||||
|
_timeout: 60000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createParsedSpecFile(
|
||||||
|
specFile: string,
|
||||||
|
tests: { title: string; state: "passed" | "failed" | "pending" }[],
|
||||||
|
): ParsedSpecFile {
|
||||||
|
return {
|
||||||
|
filePath: `/path/to/${specFile}.json`,
|
||||||
|
specPath: `tests/integration/${specFile}`,
|
||||||
|
result: createMochawesomeResult(specFile, tests),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("calculateResultsFromSpecs", () => {
|
||||||
|
it("should calculate all outputs correctly for passing results", () => {
|
||||||
|
const specs: ParsedSpecFile[] = [
|
||||||
|
createParsedSpecFile("login.spec.ts", [
|
||||||
|
{
|
||||||
|
title: "should login with valid credentials",
|
||||||
|
state: "passed",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
createParsedSpecFile("messaging.spec.ts", [
|
||||||
|
{ title: "should send a message", state: "passed" },
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const calc = calculateResultsFromSpecs(specs);
|
||||||
|
|
||||||
|
expect(calc.passed).toBe(2);
|
||||||
|
expect(calc.failed).toBe(0);
|
||||||
|
expect(calc.pending).toBe(0);
|
||||||
|
expect(calc.total).toBe(2);
|
||||||
|
expect(calc.passRate).toBe("100.00");
|
||||||
|
expect(calc.color).toBe("#43A047"); // green
|
||||||
|
expect(calc.totalSpecs).toBe(2);
|
||||||
|
expect(calc.failedSpecs).toBe("");
|
||||||
|
expect(calc.failedSpecsCount).toBe(0);
|
||||||
|
expect(calc.commitStatusMessage).toBe("100% passed (2), 2 specs");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should calculate all outputs correctly for results with failures", () => {
|
||||||
|
const specs: ParsedSpecFile[] = [
|
||||||
|
createParsedSpecFile("login.spec.ts", [
|
||||||
|
{
|
||||||
|
title: "should login with valid credentials",
|
||||||
|
state: "passed",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
createParsedSpecFile("channels.spec.ts", [
|
||||||
|
{ title: "should create a channel", state: "failed" },
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const calc = calculateResultsFromSpecs(specs);
|
||||||
|
|
||||||
|
expect(calc.passed).toBe(1);
|
||||||
|
expect(calc.failed).toBe(1);
|
||||||
|
expect(calc.pending).toBe(0);
|
||||||
|
expect(calc.total).toBe(2);
|
||||||
|
expect(calc.passRate).toBe("50.00");
|
||||||
|
expect(calc.color).toBe("#F44336"); // red
|
||||||
|
expect(calc.totalSpecs).toBe(2);
|
||||||
|
expect(calc.failedSpecs).toBe("tests/integration/channels.spec.ts");
|
||||||
|
expect(calc.failedSpecsCount).toBe(1);
|
||||||
|
expect(calc.commitStatusMessage).toBe(
|
||||||
|
"50.0% passed (1/2), 1 failed, 2 specs",
|
||||||
|
);
|
||||||
|
expect(calc.failedTests).toContain("should create a channel");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle pending tests correctly", () => {
|
||||||
|
const specs: ParsedSpecFile[] = [
|
||||||
|
createParsedSpecFile("login.spec.ts", [
|
||||||
|
{ title: "should login", state: "passed" },
|
||||||
|
{ title: "should logout", state: "pending" },
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const calc = calculateResultsFromSpecs(specs);
|
||||||
|
|
||||||
|
expect(calc.passed).toBe(1);
|
||||||
|
expect(calc.failed).toBe(0);
|
||||||
|
expect(calc.pending).toBe(1);
|
||||||
|
expect(calc.total).toBe(1); // Total excludes pending
|
||||||
|
expect(calc.passRate).toBe("100.00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should limit failed tests to 10 entries", () => {
|
||||||
|
const specs: ParsedSpecFile[] = [
|
||||||
|
createParsedSpecFile("big-test.spec.ts", [
|
||||||
|
{ title: "test 1", state: "failed" },
|
||||||
|
{ title: "test 2", state: "failed" },
|
||||||
|
{ title: "test 3", state: "failed" },
|
||||||
|
{ title: "test 4", state: "failed" },
|
||||||
|
{ title: "test 5", state: "failed" },
|
||||||
|
{ title: "test 6", state: "failed" },
|
||||||
|
{ title: "test 7", state: "failed" },
|
||||||
|
{ title: "test 8", state: "failed" },
|
||||||
|
{ title: "test 9", state: "failed" },
|
||||||
|
{ title: "test 10", state: "failed" },
|
||||||
|
{ title: "test 11", state: "failed" },
|
||||||
|
{ title: "test 12", state: "failed" },
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const calc = calculateResultsFromSpecs(specs);
|
||||||
|
|
||||||
|
expect(calc.failed).toBe(12);
|
||||||
|
expect(calc.failedTests).toContain("...and 2 more failed tests");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("merge simulation", () => {
|
||||||
|
it("should produce correct results when merging original with retest", () => {
|
||||||
|
// Simulate original: 2 passed, 1 failed
|
||||||
|
const originalSpecs: ParsedSpecFile[] = [
|
||||||
|
createParsedSpecFile("login.spec.ts", [
|
||||||
|
{ title: "should login", state: "passed" },
|
||||||
|
]),
|
||||||
|
createParsedSpecFile("messaging.spec.ts", [
|
||||||
|
{ title: "should send message", state: "passed" },
|
||||||
|
]),
|
||||||
|
createParsedSpecFile("channels.spec.ts", [
|
||||||
|
{ title: "should create channel", state: "failed" },
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Verify original has failure
|
||||||
|
const originalCalc = calculateResultsFromSpecs(originalSpecs);
|
||||||
|
expect(originalCalc.passed).toBe(2);
|
||||||
|
expect(originalCalc.failed).toBe(1);
|
||||||
|
expect(originalCalc.passRate).toBe("66.67");
|
||||||
|
|
||||||
|
// Simulate retest: channels.spec.ts now passes
|
||||||
|
const retestSpec = createParsedSpecFile("channels.spec.ts", [
|
||||||
|
{ title: "should create channel", state: "passed" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Simulate merge: replace original channels.spec.ts with retest
|
||||||
|
const specMap = new Map<string, ParsedSpecFile>();
|
||||||
|
for (const spec of originalSpecs) {
|
||||||
|
specMap.set(spec.specPath, spec);
|
||||||
|
}
|
||||||
|
specMap.set(retestSpec.specPath, retestSpec);
|
||||||
|
|
||||||
|
const mergedSpecs = Array.from(specMap.values());
|
||||||
|
|
||||||
|
// Calculate final results
|
||||||
|
const finalCalc = calculateResultsFromSpecs(mergedSpecs);
|
||||||
|
|
||||||
|
expect(finalCalc.passed).toBe(3);
|
||||||
|
expect(finalCalc.failed).toBe(0);
|
||||||
|
expect(finalCalc.pending).toBe(0);
|
||||||
|
expect(finalCalc.total).toBe(3);
|
||||||
|
expect(finalCalc.passRate).toBe("100.00");
|
||||||
|
expect(finalCalc.color).toBe("#43A047"); // green
|
||||||
|
expect(finalCalc.totalSpecs).toBe(3);
|
||||||
|
expect(finalCalc.failedSpecs).toBe("");
|
||||||
|
expect(finalCalc.failedSpecsCount).toBe(0);
|
||||||
|
expect(finalCalc.commitStatusMessage).toBe("100% passed (3), 3 specs");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle case where retest still fails", () => {
|
||||||
|
// Original: 1 passed, 1 failed
|
||||||
|
const originalSpecs: ParsedSpecFile[] = [
|
||||||
|
createParsedSpecFile("login.spec.ts", [
|
||||||
|
{ title: "should login", state: "passed" },
|
||||||
|
]),
|
||||||
|
createParsedSpecFile("channels.spec.ts", [
|
||||||
|
{ title: "should create channel", state: "failed" },
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Retest: channels.spec.ts still fails
|
||||||
|
const retestSpec = createParsedSpecFile("channels.spec.ts", [
|
||||||
|
{ title: "should create channel", state: "failed" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Merge
|
||||||
|
const specMap = new Map<string, ParsedSpecFile>();
|
||||||
|
for (const spec of originalSpecs) {
|
||||||
|
specMap.set(spec.specPath, spec);
|
||||||
|
}
|
||||||
|
specMap.set(retestSpec.specPath, retestSpec);
|
||||||
|
|
||||||
|
const mergedSpecs = Array.from(specMap.values());
|
||||||
|
const finalCalc = calculateResultsFromSpecs(mergedSpecs);
|
||||||
|
|
||||||
|
expect(finalCalc.passed).toBe(1);
|
||||||
|
expect(finalCalc.failed).toBe(1);
|
||||||
|
expect(finalCalc.passRate).toBe("50.00");
|
||||||
|
expect(finalCalc.color).toBe("#F44336"); // red
|
||||||
|
expect(finalCalc.failedSpecs).toBe(
|
||||||
|
"tests/integration/channels.spec.ts",
|
||||||
|
);
|
||||||
|
expect(finalCalc.failedSpecsCount).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
358
.github/actions/calculate-cypress-results/src/merge.ts
поставляемый
Обычный файл
358
.github/actions/calculate-cypress-results/src/merge.ts
поставляемый
Обычный файл
@@ -0,0 +1,358 @@
|
|||||||
|
import * as fs from "fs/promises";
|
||||||
|
import * as path from "path";
|
||||||
|
import type {
|
||||||
|
MochawesomeResult,
|
||||||
|
ParsedSpecFile,
|
||||||
|
CalculationResult,
|
||||||
|
FailedTest,
|
||||||
|
TestItem,
|
||||||
|
SuiteItem,
|
||||||
|
ResultItem,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all JSON files in a directory recursively
|
||||||
|
*/
|
||||||
|
async function findJsonFiles(dir: string): Promise<string[]> {
|
||||||
|
const files: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const subFiles = await findJsonFiles(fullPath);
|
||||||
|
files.push(...subFiles);
|
||||||
|
} else if (entry.isFile() && entry.name.endsWith(".json")) {
|
||||||
|
files.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Directory doesn't exist or not accessible
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a mochawesome JSON file
|
||||||
|
*/
|
||||||
|
async function parseSpecFile(filePath: string): Promise<ParsedSpecFile | null> {
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(filePath, "utf8");
|
||||||
|
const result: MochawesomeResult = JSON.parse(content);
|
||||||
|
|
||||||
|
// Extract spec path from results[0].file
|
||||||
|
const specPath = result.results?.[0]?.file;
|
||||||
|
if (!specPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
filePath,
|
||||||
|
specPath,
|
||||||
|
result,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract all tests from a result recursively
|
||||||
|
*/
|
||||||
|
function getAllTests(result: MochawesomeResult): TestItem[] {
|
||||||
|
const tests: TestItem[] = [];
|
||||||
|
|
||||||
|
function extractFromSuite(suite: SuiteItem | ResultItem) {
|
||||||
|
tests.push(...(suite.tests || []));
|
||||||
|
for (const nestedSuite of suite.suites || []) {
|
||||||
|
extractFromSuite(nestedSuite);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const resultItem of result.results || []) {
|
||||||
|
extractFromSuite(resultItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tests;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get color based on pass rate
|
||||||
|
*/
|
||||||
|
function getColor(passRate: number): string {
|
||||||
|
if (passRate === 100) {
|
||||||
|
return "#43A047"; // green
|
||||||
|
} else if (passRate >= 99) {
|
||||||
|
return "#FFEB3B"; // yellow
|
||||||
|
} else if (passRate >= 98) {
|
||||||
|
return "#FF9800"; // orange
|
||||||
|
} else {
|
||||||
|
return "#F44336"; // red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate results from parsed spec files
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Format milliseconds as "Xm Ys"
|
||||||
|
*/
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
const totalSeconds = Math.round(ms / 1000);
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return `${minutes}m ${seconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateResultsFromSpecs(
|
||||||
|
specs: ParsedSpecFile[],
|
||||||
|
): CalculationResult {
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
let pending = 0;
|
||||||
|
const failedSpecsSet = new Set<string>();
|
||||||
|
const failedTestsList: FailedTest[] = [];
|
||||||
|
|
||||||
|
for (const spec of specs) {
|
||||||
|
const tests = getAllTests(spec.result);
|
||||||
|
|
||||||
|
for (const test of tests) {
|
||||||
|
if (test.state === "passed") {
|
||||||
|
passed++;
|
||||||
|
} else if (test.state === "failed") {
|
||||||
|
failed++;
|
||||||
|
failedSpecsSet.add(spec.specPath);
|
||||||
|
failedTestsList.push({
|
||||||
|
title: test.title,
|
||||||
|
file: spec.specPath,
|
||||||
|
});
|
||||||
|
} else if (test.state === "pending") {
|
||||||
|
pending++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute test duration from earliest start to latest end across all specs
|
||||||
|
let earliestStart: number | null = null;
|
||||||
|
let latestEnd: number | null = null;
|
||||||
|
for (const spec of specs) {
|
||||||
|
const { start, end } = spec.result.stats;
|
||||||
|
if (start) {
|
||||||
|
const startMs = new Date(start).getTime();
|
||||||
|
if (earliestStart === null || startMs < earliestStart) {
|
||||||
|
earliestStart = startMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (end) {
|
||||||
|
const endMs = new Date(end).getTime();
|
||||||
|
if (latestEnd === null || endMs > latestEnd) {
|
||||||
|
latestEnd = endMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const testDurationMs =
|
||||||
|
earliestStart !== null && latestEnd !== null
|
||||||
|
? latestEnd - earliestStart
|
||||||
|
: 0;
|
||||||
|
const testDuration = formatDuration(testDurationMs);
|
||||||
|
|
||||||
|
const totalSpecs = specs.length;
|
||||||
|
const failedSpecs = Array.from(failedSpecsSet).join(",");
|
||||||
|
const failedSpecsCount = failedSpecsSet.size;
|
||||||
|
|
||||||
|
// Build failed tests markdown table (limit to 10)
|
||||||
|
let failedTests = "";
|
||||||
|
const uniqueFailedTests = failedTestsList.filter(
|
||||||
|
(test, index, self) =>
|
||||||
|
index ===
|
||||||
|
self.findIndex(
|
||||||
|
(t) => t.title === test.title && t.file === test.file,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (uniqueFailedTests.length > 0) {
|
||||||
|
const limitedTests = uniqueFailedTests.slice(0, 10);
|
||||||
|
failedTests = limitedTests
|
||||||
|
.map((t) => {
|
||||||
|
const escapedTitle = t.title
|
||||||
|
.replace(/`/g, "\\`")
|
||||||
|
.replace(/\|/g, "\\|");
|
||||||
|
return `| ${escapedTitle} | ${t.file} |`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
if (uniqueFailedTests.length > 10) {
|
||||||
|
const remaining = uniqueFailedTests.length - 10;
|
||||||
|
failedTests += `\n| _...and ${remaining} more failed tests_ | |`;
|
||||||
|
}
|
||||||
|
} else if (failed > 0) {
|
||||||
|
failedTests = "| Unable to parse failed tests | - |";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate totals and pass rate
|
||||||
|
// Pass rate = passed / (passed + failed), excluding pending
|
||||||
|
const total = passed + failed;
|
||||||
|
const passRate = total > 0 ? ((passed * 100) / total).toFixed(2) : "0.00";
|
||||||
|
const color = getColor(parseFloat(passRate));
|
||||||
|
|
||||||
|
// Build commit status message
|
||||||
|
const rate = total > 0 ? (passed * 100) / total : 0;
|
||||||
|
const rateStr = rate === 100 ? "100%" : `${rate.toFixed(1)}%`;
|
||||||
|
const specSuffix = totalSpecs > 0 ? `, ${totalSpecs} specs` : "";
|
||||||
|
const commitStatusMessage =
|
||||||
|
rate === 100
|
||||||
|
? `${rateStr} passed (${passed})${specSuffix}`
|
||||||
|
: `${rateStr} passed (${passed}/${total}), ${failed} failed${specSuffix}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
passed,
|
||||||
|
failed,
|
||||||
|
pending,
|
||||||
|
totalSpecs,
|
||||||
|
commitStatusMessage,
|
||||||
|
failedSpecs,
|
||||||
|
failedSpecsCount,
|
||||||
|
failedTests,
|
||||||
|
total,
|
||||||
|
passRate,
|
||||||
|
color,
|
||||||
|
testDuration,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all spec files from a mochawesome results directory
|
||||||
|
*/
|
||||||
|
export async function loadSpecFiles(
|
||||||
|
resultsPath: string,
|
||||||
|
): Promise<ParsedSpecFile[]> {
|
||||||
|
// Mochawesome results are at: results/mochawesome-report/json/tests/
|
||||||
|
const mochawesomeDir = path.join(
|
||||||
|
resultsPath,
|
||||||
|
"mochawesome-report",
|
||||||
|
"json",
|
||||||
|
"tests",
|
||||||
|
);
|
||||||
|
|
||||||
|
const jsonFiles = await findJsonFiles(mochawesomeDir);
|
||||||
|
const specs: ParsedSpecFile[] = [];
|
||||||
|
|
||||||
|
for (const file of jsonFiles) {
|
||||||
|
const parsed = await parseSpecFile(file);
|
||||||
|
if (parsed) {
|
||||||
|
specs.push(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return specs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge original and retest results
|
||||||
|
* - For each spec in retest, replace the matching spec in original
|
||||||
|
* - Keep original specs that are not in retest
|
||||||
|
*/
|
||||||
|
export async function mergeResults(
|
||||||
|
originalPath: string,
|
||||||
|
retestPath: string,
|
||||||
|
): Promise<{
|
||||||
|
specs: ParsedSpecFile[];
|
||||||
|
retestFiles: string[];
|
||||||
|
mergedCount: number;
|
||||||
|
}> {
|
||||||
|
const originalSpecs = await loadSpecFiles(originalPath);
|
||||||
|
const retestSpecs = await loadSpecFiles(retestPath);
|
||||||
|
|
||||||
|
// Build a map of original specs by spec path
|
||||||
|
const specMap = new Map<string, ParsedSpecFile>();
|
||||||
|
for (const spec of originalSpecs) {
|
||||||
|
specMap.set(spec.specPath, spec);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace with retest results
|
||||||
|
const retestFiles: string[] = [];
|
||||||
|
for (const retestSpec of retestSpecs) {
|
||||||
|
specMap.set(retestSpec.specPath, retestSpec);
|
||||||
|
retestFiles.push(retestSpec.specPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
specs: Array.from(specMap.values()),
|
||||||
|
retestFiles,
|
||||||
|
mergedCount: retestSpecs.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write merged results back to the original directory
|
||||||
|
* This updates the original JSON files with retest results
|
||||||
|
*/
|
||||||
|
export async function writeMergedResults(
|
||||||
|
originalPath: string,
|
||||||
|
retestPath: string,
|
||||||
|
): Promise<{ updatedFiles: string[]; removedFiles: string[] }> {
|
||||||
|
const mochawesomeDir = path.join(
|
||||||
|
originalPath,
|
||||||
|
"mochawesome-report",
|
||||||
|
"json",
|
||||||
|
"tests",
|
||||||
|
);
|
||||||
|
const retestMochawesomeDir = path.join(
|
||||||
|
retestPath,
|
||||||
|
"mochawesome-report",
|
||||||
|
"json",
|
||||||
|
"tests",
|
||||||
|
);
|
||||||
|
|
||||||
|
const originalJsonFiles = await findJsonFiles(mochawesomeDir);
|
||||||
|
const retestJsonFiles = await findJsonFiles(retestMochawesomeDir);
|
||||||
|
|
||||||
|
const updatedFiles: string[] = [];
|
||||||
|
const removedFiles: string[] = [];
|
||||||
|
|
||||||
|
// For each retest file, find and replace the original
|
||||||
|
for (const retestFile of retestJsonFiles) {
|
||||||
|
const retestSpec = await parseSpecFile(retestFile);
|
||||||
|
if (!retestSpec) continue;
|
||||||
|
|
||||||
|
const specPath = retestSpec.specPath;
|
||||||
|
|
||||||
|
// Find all original files with matching spec path
|
||||||
|
// Prefer nested path (under integration/), remove flat duplicates
|
||||||
|
let nestedFile: string | null = null;
|
||||||
|
const flatFiles: string[] = [];
|
||||||
|
|
||||||
|
for (const origFile of originalJsonFiles) {
|
||||||
|
const origSpec = await parseSpecFile(origFile);
|
||||||
|
if (origSpec && origSpec.specPath === specPath) {
|
||||||
|
if (origFile.includes("/integration/")) {
|
||||||
|
nestedFile = origFile;
|
||||||
|
} else {
|
||||||
|
flatFiles.push(origFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the nested file (proper location) or first flat file if no nested
|
||||||
|
const retestContent = await fs.readFile(retestFile, "utf8");
|
||||||
|
|
||||||
|
if (nestedFile) {
|
||||||
|
await fs.writeFile(nestedFile, retestContent);
|
||||||
|
updatedFiles.push(nestedFile);
|
||||||
|
|
||||||
|
// Remove flat duplicates
|
||||||
|
for (const flatFile of flatFiles) {
|
||||||
|
await fs.unlink(flatFile);
|
||||||
|
removedFiles.push(flatFile);
|
||||||
|
}
|
||||||
|
} else if (flatFiles.length > 0) {
|
||||||
|
await fs.writeFile(flatFiles[0], retestContent);
|
||||||
|
updatedFiles.push(flatFiles[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { updatedFiles, removedFiles };
|
||||||
|
}
|
||||||
139
.github/actions/calculate-cypress-results/src/types.ts
поставляемый
Обычный файл
139
.github/actions/calculate-cypress-results/src/types.ts
поставляемый
Обычный файл
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* Mochawesome result structure for a single spec file
|
||||||
|
*/
|
||||||
|
export interface MochawesomeResult {
|
||||||
|
stats: MochawesomeStats;
|
||||||
|
results: ResultItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MochawesomeStats {
|
||||||
|
suites: number;
|
||||||
|
tests: number;
|
||||||
|
passes: number;
|
||||||
|
pending: number;
|
||||||
|
failures: number;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
duration: number;
|
||||||
|
testsRegistered: number;
|
||||||
|
passPercent: number;
|
||||||
|
pendingPercent: number;
|
||||||
|
other: number;
|
||||||
|
hasOther: boolean;
|
||||||
|
skipped: number;
|
||||||
|
hasSkipped: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResultItem {
|
||||||
|
uuid: string;
|
||||||
|
title: string;
|
||||||
|
fullFile: string;
|
||||||
|
file: string;
|
||||||
|
beforeHooks: Hook[];
|
||||||
|
afterHooks: Hook[];
|
||||||
|
tests: TestItem[];
|
||||||
|
suites: SuiteItem[];
|
||||||
|
passes: string[];
|
||||||
|
failures: string[];
|
||||||
|
pending: string[];
|
||||||
|
skipped: string[];
|
||||||
|
duration: number;
|
||||||
|
root: boolean;
|
||||||
|
rootEmpty: boolean;
|
||||||
|
_timeout: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SuiteItem {
|
||||||
|
uuid: string;
|
||||||
|
title: string;
|
||||||
|
fullFile: string;
|
||||||
|
file: string;
|
||||||
|
beforeHooks: Hook[];
|
||||||
|
afterHooks: Hook[];
|
||||||
|
tests: TestItem[];
|
||||||
|
suites: SuiteItem[];
|
||||||
|
passes: string[];
|
||||||
|
failures: string[];
|
||||||
|
pending: string[];
|
||||||
|
skipped: string[];
|
||||||
|
duration: number;
|
||||||
|
root: boolean;
|
||||||
|
rootEmpty: boolean;
|
||||||
|
_timeout: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestItem {
|
||||||
|
title: string;
|
||||||
|
fullTitle: string;
|
||||||
|
timedOut: boolean | null;
|
||||||
|
duration: number;
|
||||||
|
state: "passed" | "failed" | "pending";
|
||||||
|
speed: string | null;
|
||||||
|
pass: boolean;
|
||||||
|
fail: boolean;
|
||||||
|
pending: boolean;
|
||||||
|
context: string | null;
|
||||||
|
code: string;
|
||||||
|
err: TestError;
|
||||||
|
uuid: string;
|
||||||
|
parentUUID: string;
|
||||||
|
isHook: boolean;
|
||||||
|
skipped: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestError {
|
||||||
|
message?: string;
|
||||||
|
estack?: string;
|
||||||
|
diff?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Hook {
|
||||||
|
title: string;
|
||||||
|
fullTitle: string;
|
||||||
|
timedOut: boolean | null;
|
||||||
|
duration: number;
|
||||||
|
state: string | null;
|
||||||
|
speed: string | null;
|
||||||
|
pass: boolean;
|
||||||
|
fail: boolean;
|
||||||
|
pending: boolean;
|
||||||
|
context: string | null;
|
||||||
|
code: string;
|
||||||
|
err: TestError;
|
||||||
|
uuid: string;
|
||||||
|
parentUUID: string;
|
||||||
|
isHook: boolean;
|
||||||
|
skipped: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsed spec file with its path and results
|
||||||
|
*/
|
||||||
|
export interface ParsedSpecFile {
|
||||||
|
filePath: string;
|
||||||
|
specPath: string;
|
||||||
|
result: MochawesomeResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculation result outputs
|
||||||
|
*/
|
||||||
|
export interface CalculationResult {
|
||||||
|
passed: number;
|
||||||
|
failed: number;
|
||||||
|
pending: number;
|
||||||
|
totalSpecs: number;
|
||||||
|
commitStatusMessage: string;
|
||||||
|
failedSpecs: string;
|
||||||
|
failedSpecsCount: number;
|
||||||
|
failedTests: string;
|
||||||
|
total: number;
|
||||||
|
passRate: string;
|
||||||
|
color: string;
|
||||||
|
testDuration: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FailedTest {
|
||||||
|
title: string;
|
||||||
|
file: string;
|
||||||
|
}
|
||||||
17
.github/actions/calculate-cypress-results/tsconfig.json
поставляемый
Обычный файл
17
.github/actions/calculate-cypress-results/tsconfig.json
поставляемый
Обычный файл
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"declaration": true,
|
||||||
|
"isolatedModules": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||||
|
}
|
||||||
1
.github/actions/calculate-cypress-results/tsconfig.tsbuildinfo
поставляемый
Обычный файл
1
.github/actions/calculate-cypress-results/tsconfig.tsbuildinfo
поставляемый
Обычный файл
@@ -0,0 +1 @@
|
|||||||
|
{"root":["./src/index.ts","./src/main.ts","./src/merge.ts","./src/types.ts"],"version":"5.9.3"}
|
||||||
13
.github/actions/calculate-cypress-results/tsup.config.ts
поставляемый
Обычный файл
13
.github/actions/calculate-cypress-results/tsup.config.ts
поставляемый
Обычный файл
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from "tsup";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ["src/index.ts"],
|
||||||
|
format: ["cjs"],
|
||||||
|
target: "node24",
|
||||||
|
clean: true,
|
||||||
|
minify: false,
|
||||||
|
sourcemap: false,
|
||||||
|
splitting: false,
|
||||||
|
bundle: true,
|
||||||
|
noExternal: [/.*/],
|
||||||
|
});
|
||||||
2
.github/actions/calculate-playwright-results/.gitignore
поставляемый
Обычный файл
2
.github/actions/calculate-playwright-results/.gitignore
поставляемый
Обычный файл
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
53
.github/actions/calculate-playwright-results/action.yaml
поставляемый
Обычный файл
53
.github/actions/calculate-playwright-results/action.yaml
поставляемый
Обычный файл
@@ -0,0 +1,53 @@
|
|||||||
|
name: Calculate Playwright Results
|
||||||
|
description: Calculate Playwright test results with optional merge of retest results
|
||||||
|
author: Mattermost
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
original-results-path:
|
||||||
|
description: Path to the original Playwright results.json file
|
||||||
|
required: true
|
||||||
|
retest-results-path:
|
||||||
|
description: Path to the retest Playwright results.json file (optional - if not provided, only calculates from original)
|
||||||
|
required: false
|
||||||
|
output-path:
|
||||||
|
description: Path to write the merged results.json file (defaults to original-results-path)
|
||||||
|
required: false
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
# Merge outputs
|
||||||
|
merged:
|
||||||
|
description: Whether merge was performed (true/false)
|
||||||
|
|
||||||
|
# Calculation outputs (same as calculate-playwright-test-results)
|
||||||
|
passed:
|
||||||
|
description: Number of passed tests (not including flaky)
|
||||||
|
failed:
|
||||||
|
description: Number of failed tests
|
||||||
|
flaky:
|
||||||
|
description: Number of flaky tests (failed initially but passed on retry)
|
||||||
|
skipped:
|
||||||
|
description: Number of skipped tests
|
||||||
|
total_specs:
|
||||||
|
description: Total number of spec files
|
||||||
|
commit_status_message:
|
||||||
|
description: Message for commit status (e.g., "X failed, Y passed (Z spec files)")
|
||||||
|
failed_specs:
|
||||||
|
description: Comma-separated list of failed spec files (for retest)
|
||||||
|
failed_specs_count:
|
||||||
|
description: Number of failed spec files
|
||||||
|
failed_tests:
|
||||||
|
description: Markdown table rows of failed tests (for GitHub summary)
|
||||||
|
total:
|
||||||
|
description: Total number of tests (passed + flaky + failed)
|
||||||
|
pass_rate:
|
||||||
|
description: Pass rate percentage (e.g., "100.00")
|
||||||
|
passing:
|
||||||
|
description: Number of passing tests (passed + flaky)
|
||||||
|
color:
|
||||||
|
description: Color for webhook based on pass rate (green=100%, yellow=99%+, orange=98%+, red=<98%)
|
||||||
|
test_duration:
|
||||||
|
description: Test execution duration from stats (formatted as "Xm Ys")
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: node24
|
||||||
|
main: dist/index.js
|
||||||
19323
.github/actions/calculate-playwright-results/dist/index.js
поставляемый
Обычный файл
19323
.github/actions/calculate-playwright-results/dist/index.js
поставляемый
Обычный файл
Различия файлов скрыты, потому что одна или несколько строк слишком длинны
6
.github/actions/calculate-playwright-results/jest.config.js
поставляемый
Обычный файл
6
.github/actions/calculate-playwright-results/jest.config.js
поставляемый
Обычный файл
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
preset: "ts-jest",
|
||||||
|
testEnvironment: "node",
|
||||||
|
testMatch: ["**/*.test.ts"],
|
||||||
|
moduleFileExtensions: ["ts", "js"],
|
||||||
|
};
|
||||||
9136
.github/actions/calculate-playwright-results/package-lock.json
сгенерированный
поставляемый
Обычный файл
9136
.github/actions/calculate-playwright-results/package-lock.json
сгенерированный
поставляемый
Обычный файл
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
27
.github/actions/calculate-playwright-results/package.json
поставляемый
Обычный файл
27
.github/actions/calculate-playwright-results/package.json
поставляемый
Обычный файл
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "calculate-playwright-results",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup",
|
||||||
|
"prettier": "npx prettier --write \"src/**/*.ts\"",
|
||||||
|
"local-action": "local-action . src/main.ts .env",
|
||||||
|
"test": "jest --verbose",
|
||||||
|
"test:watch": "jest --watch --verbose",
|
||||||
|
"test:silent": "jest --silent",
|
||||||
|
"tsc": "tsc -b"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@actions/core": "3.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@github/local-action": "7.0.0",
|
||||||
|
"@types/jest": "30.0.0",
|
||||||
|
"@types/node": "25.2.0",
|
||||||
|
"jest": "30.2.0",
|
||||||
|
"ts-jest": "29.4.6",
|
||||||
|
"tsup": "8.5.1",
|
||||||
|
"typescript": "5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
3
.github/actions/calculate-playwright-results/src/index.ts
поставляемый
Обычный файл
3
.github/actions/calculate-playwright-results/src/index.ts
поставляемый
Обычный файл
@@ -0,0 +1,3 @@
|
|||||||
|
import { run } from "./main";
|
||||||
|
|
||||||
|
run();
|
||||||
123
.github/actions/calculate-playwright-results/src/main.ts
поставляемый
Обычный файл
123
.github/actions/calculate-playwright-results/src/main.ts
поставляемый
Обычный файл
@@ -0,0 +1,123 @@
|
|||||||
|
import * as core from "@actions/core";
|
||||||
|
import * as fs from "fs/promises";
|
||||||
|
import type { PlaywrightResults } from "./types";
|
||||||
|
import { mergeResults, calculateResults } from "./merge";
|
||||||
|
|
||||||
|
export async function run(): Promise<void> {
|
||||||
|
const originalPath = core.getInput("original-results-path", {
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
const retestPath = core.getInput("retest-results-path"); // Optional
|
||||||
|
const outputPath = core.getInput("output-path") || originalPath;
|
||||||
|
|
||||||
|
core.info(`Original results: ${originalPath}`);
|
||||||
|
core.info(`Retest results: ${retestPath || "(not provided)"}`);
|
||||||
|
core.info(`Output path: ${outputPath}`);
|
||||||
|
|
||||||
|
// Check if original file exists
|
||||||
|
const originalExists = await fs
|
||||||
|
.access(originalPath)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
|
||||||
|
if (!originalExists) {
|
||||||
|
core.setFailed(`Original results not found at ${originalPath}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read original file
|
||||||
|
core.info("Reading original results...");
|
||||||
|
const originalContent = await fs.readFile(originalPath, "utf8");
|
||||||
|
const original: PlaywrightResults = JSON.parse(originalContent);
|
||||||
|
|
||||||
|
core.info(
|
||||||
|
`Original: ${original.suites.length} suites, stats: ${JSON.stringify(original.stats)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if retest path is provided and exists
|
||||||
|
let finalResults: PlaywrightResults;
|
||||||
|
let merged = false;
|
||||||
|
|
||||||
|
if (retestPath) {
|
||||||
|
const retestExists = await fs
|
||||||
|
.access(retestPath)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
|
||||||
|
if (retestExists) {
|
||||||
|
// Read retest file and merge
|
||||||
|
core.info("Reading retest results...");
|
||||||
|
const retestContent = await fs.readFile(retestPath, "utf8");
|
||||||
|
const retest: PlaywrightResults = JSON.parse(retestContent);
|
||||||
|
|
||||||
|
core.info(
|
||||||
|
`Retest: ${retest.suites.length} suites, stats: ${JSON.stringify(retest.stats)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Merge results
|
||||||
|
core.info("Merging results at suite level...");
|
||||||
|
const mergeResult = mergeResults(original, retest);
|
||||||
|
finalResults = mergeResult.merged;
|
||||||
|
merged = true;
|
||||||
|
|
||||||
|
core.info(`Retested specs: ${mergeResult.retestFiles.join(", ")}`);
|
||||||
|
core.info(
|
||||||
|
`Kept ${original.suites.length - mergeResult.retestFiles.length} original suites`,
|
||||||
|
);
|
||||||
|
core.info(`Added ${retest.suites.length} retest suites`);
|
||||||
|
core.info(`Total merged suites: ${mergeResult.totalSuites}`);
|
||||||
|
|
||||||
|
// Write merged results
|
||||||
|
core.info(`Writing merged results to ${outputPath}...`);
|
||||||
|
await fs.writeFile(
|
||||||
|
outputPath,
|
||||||
|
JSON.stringify(finalResults, null, 2),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
core.warning(
|
||||||
|
`Retest results not found at ${retestPath}, using original only`,
|
||||||
|
);
|
||||||
|
finalResults = original;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
core.info("No retest path provided, using original results only");
|
||||||
|
finalResults = original;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate all outputs from final results
|
||||||
|
const calc = calculateResults(finalResults);
|
||||||
|
|
||||||
|
// Log results
|
||||||
|
core.startGroup("Final Results");
|
||||||
|
core.info(`Passed: ${calc.passed}`);
|
||||||
|
core.info(`Failed: ${calc.failed}`);
|
||||||
|
core.info(`Flaky: ${calc.flaky}`);
|
||||||
|
core.info(`Skipped: ${calc.skipped}`);
|
||||||
|
core.info(`Passing (passed + flaky): ${calc.passing}`);
|
||||||
|
core.info(`Total: ${calc.total}`);
|
||||||
|
core.info(`Pass Rate: ${calc.passRate}%`);
|
||||||
|
core.info(`Color: ${calc.color}`);
|
||||||
|
core.info(`Spec Files: ${calc.totalSpecs}`);
|
||||||
|
core.info(`Failed Specs Count: ${calc.failedSpecsCount}`);
|
||||||
|
core.info(`Commit Status Message: ${calc.commitStatusMessage}`);
|
||||||
|
core.info(`Failed Specs: ${calc.failedSpecs || "none"}`);
|
||||||
|
core.info(`Test Duration: ${calc.testDuration}`);
|
||||||
|
core.endGroup();
|
||||||
|
|
||||||
|
// Set all outputs
|
||||||
|
core.setOutput("merged", merged.toString());
|
||||||
|
core.setOutput("passed", calc.passed);
|
||||||
|
core.setOutput("failed", calc.failed);
|
||||||
|
core.setOutput("flaky", calc.flaky);
|
||||||
|
core.setOutput("skipped", calc.skipped);
|
||||||
|
core.setOutput("total_specs", calc.totalSpecs);
|
||||||
|
core.setOutput("commit_status_message", calc.commitStatusMessage);
|
||||||
|
core.setOutput("failed_specs", calc.failedSpecs);
|
||||||
|
core.setOutput("failed_specs_count", calc.failedSpecsCount);
|
||||||
|
core.setOutput("failed_tests", calc.failedTests);
|
||||||
|
core.setOutput("total", calc.total);
|
||||||
|
core.setOutput("pass_rate", calc.passRate);
|
||||||
|
core.setOutput("passing", calc.passing);
|
||||||
|
core.setOutput("color", calc.color);
|
||||||
|
core.setOutput("test_duration", calc.testDuration);
|
||||||
|
}
|
||||||
509
.github/actions/calculate-playwright-results/src/merge.test.ts
поставляемый
Обычный файл
509
.github/actions/calculate-playwright-results/src/merge.test.ts
поставляемый
Обычный файл
@@ -0,0 +1,509 @@
|
|||||||
|
import { mergeResults, computeStats, calculateResults } from "./merge";
|
||||||
|
import type { PlaywrightResults, Suite } from "./types";
|
||||||
|
|
||||||
|
describe("mergeResults", () => {
|
||||||
|
const createSuite = (file: string, tests: { status: string }[]): Suite => ({
|
||||||
|
title: file,
|
||||||
|
file,
|
||||||
|
column: 0,
|
||||||
|
line: 0,
|
||||||
|
specs: [
|
||||||
|
{
|
||||||
|
title: "test spec",
|
||||||
|
ok: true,
|
||||||
|
tags: [],
|
||||||
|
tests: tests.map((t) => ({
|
||||||
|
timeout: 60000,
|
||||||
|
annotations: [],
|
||||||
|
expectedStatus: "passed",
|
||||||
|
projectId: "chrome",
|
||||||
|
projectName: "chrome",
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
workerIndex: 0,
|
||||||
|
parallelIndex: 0,
|
||||||
|
status: t.status,
|
||||||
|
duration: 1000,
|
||||||
|
errors: [],
|
||||||
|
stdout: [],
|
||||||
|
stderr: [],
|
||||||
|
retry: 0,
|
||||||
|
startTime: new Date().toISOString(),
|
||||||
|
annotations: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should keep original suites not in retest", () => {
|
||||||
|
const original: PlaywrightResults = {
|
||||||
|
config: {},
|
||||||
|
suites: [
|
||||||
|
createSuite("spec1.ts", [{ status: "passed" }]),
|
||||||
|
createSuite("spec2.ts", [{ status: "failed" }]),
|
||||||
|
createSuite("spec3.ts", [{ status: "passed" }]),
|
||||||
|
],
|
||||||
|
stats: {
|
||||||
|
startTime: new Date().toISOString(),
|
||||||
|
duration: 10000,
|
||||||
|
expected: 2,
|
||||||
|
unexpected: 1,
|
||||||
|
skipped: 0,
|
||||||
|
flaky: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const retest: PlaywrightResults = {
|
||||||
|
config: {},
|
||||||
|
suites: [createSuite("spec2.ts", [{ status: "passed" }])],
|
||||||
|
stats: {
|
||||||
|
startTime: new Date().toISOString(),
|
||||||
|
duration: 5000,
|
||||||
|
expected: 1,
|
||||||
|
unexpected: 0,
|
||||||
|
skipped: 0,
|
||||||
|
flaky: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = mergeResults(original, retest);
|
||||||
|
|
||||||
|
expect(result.totalSuites).toBe(3);
|
||||||
|
expect(result.retestFiles).toEqual(["spec2.ts"]);
|
||||||
|
expect(result.merged.suites.map((s) => s.file)).toEqual([
|
||||||
|
"spec1.ts",
|
||||||
|
"spec3.ts",
|
||||||
|
"spec2.ts",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should compute correct stats from merged suites", () => {
|
||||||
|
const original: PlaywrightResults = {
|
||||||
|
config: {},
|
||||||
|
suites: [
|
||||||
|
createSuite("spec1.ts", [{ status: "passed" }]),
|
||||||
|
createSuite("spec2.ts", [{ status: "failed" }]),
|
||||||
|
],
|
||||||
|
stats: {
|
||||||
|
startTime: new Date().toISOString(),
|
||||||
|
duration: 10000,
|
||||||
|
expected: 1,
|
||||||
|
unexpected: 1,
|
||||||
|
skipped: 0,
|
||||||
|
flaky: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const retest: PlaywrightResults = {
|
||||||
|
config: {},
|
||||||
|
suites: [createSuite("spec2.ts", [{ status: "passed" }])],
|
||||||
|
stats: {
|
||||||
|
startTime: new Date().toISOString(),
|
||||||
|
duration: 5000,
|
||||||
|
expected: 1,
|
||||||
|
unexpected: 0,
|
||||||
|
skipped: 0,
|
||||||
|
flaky: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = mergeResults(original, retest);
|
||||||
|
|
||||||
|
expect(result.stats.expected).toBe(2);
|
||||||
|
expect(result.stats.unexpected).toBe(0);
|
||||||
|
expect(result.stats.duration).toBe(15000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeStats", () => {
|
||||||
|
it("should count flaky tests correctly", () => {
|
||||||
|
const suites: Suite[] = [
|
||||||
|
{
|
||||||
|
title: "spec1.ts",
|
||||||
|
file: "spec1.ts",
|
||||||
|
column: 0,
|
||||||
|
line: 0,
|
||||||
|
specs: [
|
||||||
|
{
|
||||||
|
title: "flaky test",
|
||||||
|
ok: true,
|
||||||
|
tags: [],
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
timeout: 60000,
|
||||||
|
annotations: [],
|
||||||
|
expectedStatus: "passed",
|
||||||
|
projectId: "chrome",
|
||||||
|
projectName: "chrome",
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
workerIndex: 0,
|
||||||
|
parallelIndex: 0,
|
||||||
|
status: "failed",
|
||||||
|
duration: 1000,
|
||||||
|
errors: [],
|
||||||
|
stdout: [],
|
||||||
|
stderr: [],
|
||||||
|
retry: 0,
|
||||||
|
startTime: new Date().toISOString(),
|
||||||
|
annotations: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
workerIndex: 0,
|
||||||
|
parallelIndex: 0,
|
||||||
|
status: "passed",
|
||||||
|
duration: 1000,
|
||||||
|
errors: [],
|
||||||
|
stdout: [],
|
||||||
|
stderr: [],
|
||||||
|
retry: 1,
|
||||||
|
startTime: new Date().toISOString(),
|
||||||
|
annotations: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const stats = computeStats(suites);
|
||||||
|
|
||||||
|
expect(stats.expected).toBe(0);
|
||||||
|
expect(stats.flaky).toBe(1);
|
||||||
|
expect(stats.unexpected).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("calculateResults", () => {
|
||||||
|
const createSuiteWithSpec = (
|
||||||
|
file: string,
|
||||||
|
specTitle: string,
|
||||||
|
testResults: { status: string; retry: number }[],
|
||||||
|
): Suite => ({
|
||||||
|
title: file,
|
||||||
|
file,
|
||||||
|
column: 0,
|
||||||
|
line: 0,
|
||||||
|
specs: [
|
||||||
|
{
|
||||||
|
title: specTitle,
|
||||||
|
ok: testResults[testResults.length - 1].status === "passed",
|
||||||
|
tags: [],
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
timeout: 60000,
|
||||||
|
annotations: [],
|
||||||
|
expectedStatus: "passed",
|
||||||
|
projectId: "chrome",
|
||||||
|
projectName: "chrome",
|
||||||
|
results: testResults.map((r) => ({
|
||||||
|
workerIndex: 0,
|
||||||
|
parallelIndex: 0,
|
||||||
|
status: r.status,
|
||||||
|
duration: 1000,
|
||||||
|
errors:
|
||||||
|
r.status === "failed"
|
||||||
|
? [{ message: "error" }]
|
||||||
|
: [],
|
||||||
|
stdout: [],
|
||||||
|
stderr: [],
|
||||||
|
retry: r.retry,
|
||||||
|
startTime: new Date().toISOString(),
|
||||||
|
annotations: [],
|
||||||
|
})),
|
||||||
|
location: {
|
||||||
|
file,
|
||||||
|
line: 10,
|
||||||
|
column: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should calculate all outputs correctly for passing results", () => {
|
||||||
|
const results: PlaywrightResults = {
|
||||||
|
config: {},
|
||||||
|
suites: [
|
||||||
|
createSuiteWithSpec("login.spec.ts", "should login", [
|
||||||
|
{ status: "passed", retry: 0 },
|
||||||
|
]),
|
||||||
|
createSuiteWithSpec(
|
||||||
|
"messaging.spec.ts",
|
||||||
|
"should send message",
|
||||||
|
[{ status: "passed", retry: 0 }],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
stats: {
|
||||||
|
startTime: new Date().toISOString(),
|
||||||
|
duration: 5000,
|
||||||
|
expected: 2,
|
||||||
|
unexpected: 0,
|
||||||
|
skipped: 0,
|
||||||
|
flaky: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const calc = calculateResults(results);
|
||||||
|
|
||||||
|
expect(calc.passed).toBe(2);
|
||||||
|
expect(calc.failed).toBe(0);
|
||||||
|
expect(calc.flaky).toBe(0);
|
||||||
|
expect(calc.skipped).toBe(0);
|
||||||
|
expect(calc.total).toBe(2);
|
||||||
|
expect(calc.passing).toBe(2);
|
||||||
|
expect(calc.passRate).toBe("100.00");
|
||||||
|
expect(calc.color).toBe("#43A047"); // green
|
||||||
|
expect(calc.totalSpecs).toBe(2);
|
||||||
|
expect(calc.failedSpecs).toBe("");
|
||||||
|
expect(calc.failedSpecsCount).toBe(0);
|
||||||
|
expect(calc.commitStatusMessage).toBe("100% passed (2), 2 specs");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should calculate all outputs correctly for results with failures", () => {
|
||||||
|
const results: PlaywrightResults = {
|
||||||
|
config: {},
|
||||||
|
suites: [
|
||||||
|
createSuiteWithSpec("login.spec.ts", "should login", [
|
||||||
|
{ status: "passed", retry: 0 },
|
||||||
|
]),
|
||||||
|
createSuiteWithSpec(
|
||||||
|
"channels.spec.ts",
|
||||||
|
"should create channel",
|
||||||
|
[
|
||||||
|
{ status: "failed", retry: 0 },
|
||||||
|
{ status: "failed", retry: 1 },
|
||||||
|
{ status: "failed", retry: 2 },
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
stats: {
|
||||||
|
startTime: new Date().toISOString(),
|
||||||
|
duration: 10000,
|
||||||
|
expected: 1,
|
||||||
|
unexpected: 1,
|
||||||
|
skipped: 0,
|
||||||
|
flaky: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const calc = calculateResults(results);
|
||||||
|
|
||||||
|
expect(calc.passed).toBe(1);
|
||||||
|
expect(calc.failed).toBe(1);
|
||||||
|
expect(calc.flaky).toBe(0);
|
||||||
|
expect(calc.total).toBe(2);
|
||||||
|
expect(calc.passing).toBe(1);
|
||||||
|
expect(calc.passRate).toBe("50.00");
|
||||||
|
expect(calc.color).toBe("#F44336"); // red
|
||||||
|
expect(calc.totalSpecs).toBe(2);
|
||||||
|
expect(calc.failedSpecs).toBe("channels.spec.ts");
|
||||||
|
expect(calc.failedSpecsCount).toBe(1);
|
||||||
|
expect(calc.commitStatusMessage).toBe(
|
||||||
|
"50.0% passed (1/2), 1 failed, 2 specs",
|
||||||
|
);
|
||||||
|
expect(calc.failedTests).toContain("should create channel");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("full integration: original with failure, retest passes", () => {
|
||||||
|
const createSuiteWithSpec = (
|
||||||
|
file: string,
|
||||||
|
specTitle: string,
|
||||||
|
testResults: { status: string; retry: number }[],
|
||||||
|
): Suite => ({
|
||||||
|
title: file,
|
||||||
|
file,
|
||||||
|
column: 0,
|
||||||
|
line: 0,
|
||||||
|
specs: [
|
||||||
|
{
|
||||||
|
title: specTitle,
|
||||||
|
ok: testResults[testResults.length - 1].status === "passed",
|
||||||
|
tags: [],
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
timeout: 60000,
|
||||||
|
annotations: [],
|
||||||
|
expectedStatus: "passed",
|
||||||
|
projectId: "chrome",
|
||||||
|
projectName: "chrome",
|
||||||
|
results: testResults.map((r) => ({
|
||||||
|
workerIndex: 0,
|
||||||
|
parallelIndex: 0,
|
||||||
|
status: r.status,
|
||||||
|
duration: 1000,
|
||||||
|
errors:
|
||||||
|
r.status === "failed"
|
||||||
|
? [{ message: "error" }]
|
||||||
|
: [],
|
||||||
|
stdout: [],
|
||||||
|
stderr: [],
|
||||||
|
retry: r.retry,
|
||||||
|
startTime: new Date().toISOString(),
|
||||||
|
annotations: [],
|
||||||
|
})),
|
||||||
|
location: {
|
||||||
|
file,
|
||||||
|
line: 10,
|
||||||
|
column: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should merge and calculate correctly when failed test passes on retest", () => {
|
||||||
|
// Original: 2 passed, 1 failed (channels.spec.ts)
|
||||||
|
const original: PlaywrightResults = {
|
||||||
|
config: {},
|
||||||
|
suites: [
|
||||||
|
createSuiteWithSpec("login.spec.ts", "should login", [
|
||||||
|
{ status: "passed", retry: 0 },
|
||||||
|
]),
|
||||||
|
createSuiteWithSpec(
|
||||||
|
"messaging.spec.ts",
|
||||||
|
"should send message",
|
||||||
|
[{ status: "passed", retry: 0 }],
|
||||||
|
),
|
||||||
|
createSuiteWithSpec(
|
||||||
|
"channels.spec.ts",
|
||||||
|
"should create channel",
|
||||||
|
[
|
||||||
|
{ status: "failed", retry: 0 },
|
||||||
|
{ status: "failed", retry: 1 },
|
||||||
|
{ status: "failed", retry: 2 },
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
stats: {
|
||||||
|
startTime: new Date().toISOString(),
|
||||||
|
duration: 18000,
|
||||||
|
expected: 2,
|
||||||
|
unexpected: 1,
|
||||||
|
skipped: 0,
|
||||||
|
flaky: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Retest: channels.spec.ts now passes
|
||||||
|
const retest: PlaywrightResults = {
|
||||||
|
config: {},
|
||||||
|
suites: [
|
||||||
|
createSuiteWithSpec(
|
||||||
|
"channels.spec.ts",
|
||||||
|
"should create channel",
|
||||||
|
[{ status: "passed", retry: 0 }],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
stats: {
|
||||||
|
startTime: new Date().toISOString(),
|
||||||
|
duration: 3000,
|
||||||
|
expected: 1,
|
||||||
|
unexpected: 0,
|
||||||
|
skipped: 0,
|
||||||
|
flaky: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 1: Verify original has failure
|
||||||
|
const originalCalc = calculateResults(original);
|
||||||
|
expect(originalCalc.passed).toBe(2);
|
||||||
|
expect(originalCalc.failed).toBe(1);
|
||||||
|
expect(originalCalc.passRate).toBe("66.67");
|
||||||
|
|
||||||
|
// Step 2: Merge results
|
||||||
|
const mergeResult = mergeResults(original, retest);
|
||||||
|
|
||||||
|
// Step 3: Verify merge structure
|
||||||
|
expect(mergeResult.totalSuites).toBe(3);
|
||||||
|
expect(mergeResult.retestFiles).toEqual(["channels.spec.ts"]);
|
||||||
|
expect(mergeResult.merged.suites.map((s) => s.file)).toEqual([
|
||||||
|
"login.spec.ts",
|
||||||
|
"messaging.spec.ts",
|
||||||
|
"channels.spec.ts",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Step 4: Calculate final results
|
||||||
|
const finalCalc = calculateResults(mergeResult.merged);
|
||||||
|
|
||||||
|
// Step 5: Verify all outputs
|
||||||
|
expect(finalCalc.passed).toBe(3);
|
||||||
|
expect(finalCalc.failed).toBe(0);
|
||||||
|
expect(finalCalc.flaky).toBe(0);
|
||||||
|
expect(finalCalc.skipped).toBe(0);
|
||||||
|
expect(finalCalc.total).toBe(3);
|
||||||
|
expect(finalCalc.passing).toBe(3);
|
||||||
|
expect(finalCalc.passRate).toBe("100.00");
|
||||||
|
expect(finalCalc.color).toBe("#43A047"); // green
|
||||||
|
expect(finalCalc.totalSpecs).toBe(3);
|
||||||
|
expect(finalCalc.failedSpecs).toBe("");
|
||||||
|
expect(finalCalc.failedSpecsCount).toBe(0);
|
||||||
|
expect(finalCalc.commitStatusMessage).toBe("100% passed (3), 3 specs");
|
||||||
|
expect(finalCalc.failedTests).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle case where retest still fails", () => {
|
||||||
|
// Original: 2 passed, 1 failed
|
||||||
|
const original: PlaywrightResults = {
|
||||||
|
config: {},
|
||||||
|
suites: [
|
||||||
|
createSuiteWithSpec("login.spec.ts", "should login", [
|
||||||
|
{ status: "passed", retry: 0 },
|
||||||
|
]),
|
||||||
|
createSuiteWithSpec(
|
||||||
|
"channels.spec.ts",
|
||||||
|
"should create channel",
|
||||||
|
[{ status: "failed", retry: 0 }],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
stats: {
|
||||||
|
startTime: new Date().toISOString(),
|
||||||
|
duration: 10000,
|
||||||
|
expected: 1,
|
||||||
|
unexpected: 1,
|
||||||
|
skipped: 0,
|
||||||
|
flaky: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Retest: channels.spec.ts still fails
|
||||||
|
const retest: PlaywrightResults = {
|
||||||
|
config: {},
|
||||||
|
suites: [
|
||||||
|
createSuiteWithSpec(
|
||||||
|
"channels.spec.ts",
|
||||||
|
"should create channel",
|
||||||
|
[
|
||||||
|
{ status: "failed", retry: 0 },
|
||||||
|
{ status: "failed", retry: 1 },
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
stats: {
|
||||||
|
startTime: new Date().toISOString(),
|
||||||
|
duration: 5000,
|
||||||
|
expected: 0,
|
||||||
|
unexpected: 1,
|
||||||
|
skipped: 0,
|
||||||
|
flaky: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergeResult = mergeResults(original, retest);
|
||||||
|
const finalCalc = calculateResults(mergeResult.merged);
|
||||||
|
|
||||||
|
expect(finalCalc.passed).toBe(1);
|
||||||
|
expect(finalCalc.failed).toBe(1);
|
||||||
|
expect(finalCalc.passRate).toBe("50.00");
|
||||||
|
expect(finalCalc.color).toBe("#F44336"); // red
|
||||||
|
expect(finalCalc.failedSpecs).toBe("channels.spec.ts");
|
||||||
|
expect(finalCalc.failedSpecsCount).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
304
.github/actions/calculate-playwright-results/src/merge.ts
поставляемый
Обычный файл
304
.github/actions/calculate-playwright-results/src/merge.ts
поставляемый
Обычный файл
@@ -0,0 +1,304 @@
|
|||||||
|
import type {
|
||||||
|
PlaywrightResults,
|
||||||
|
Suite,
|
||||||
|
Test,
|
||||||
|
Stats,
|
||||||
|
MergeResult,
|
||||||
|
CalculationResult,
|
||||||
|
FailedTest,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
interface TestInfo {
|
||||||
|
title: string;
|
||||||
|
file: string;
|
||||||
|
finalStatus: string;
|
||||||
|
hadFailure: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract all tests from suites recursively with their info
|
||||||
|
*/
|
||||||
|
function getAllTestsWithInfo(suites: Suite[]): TestInfo[] {
|
||||||
|
const tests: TestInfo[] = [];
|
||||||
|
|
||||||
|
function extractFromSuite(suite: Suite) {
|
||||||
|
for (const spec of suite.specs || []) {
|
||||||
|
for (const test of spec.tests || []) {
|
||||||
|
if (!test.results || test.results.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalResult = test.results[test.results.length - 1];
|
||||||
|
const hadFailure = test.results.some(
|
||||||
|
(r) => r.status === "failed" || r.status === "timedOut",
|
||||||
|
);
|
||||||
|
|
||||||
|
tests.push({
|
||||||
|
title: spec.title || test.projectName,
|
||||||
|
file: test.location?.file || suite.file,
|
||||||
|
finalStatus: finalResult.status,
|
||||||
|
hadFailure,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const nestedSuite of suite.suites || []) {
|
||||||
|
extractFromSuite(nestedSuite);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const suite of suites) {
|
||||||
|
extractFromSuite(suite);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tests;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract all tests from suites recursively
|
||||||
|
*/
|
||||||
|
function getAllTests(suites: Suite[]): Test[] {
|
||||||
|
const tests: Test[] = [];
|
||||||
|
|
||||||
|
function extractFromSuite(suite: Suite) {
|
||||||
|
for (const spec of suite.specs || []) {
|
||||||
|
tests.push(...spec.tests);
|
||||||
|
}
|
||||||
|
for (const nestedSuite of suite.suites || []) {
|
||||||
|
extractFromSuite(nestedSuite);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const suite of suites) {
|
||||||
|
extractFromSuite(suite);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tests;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute stats from suites
|
||||||
|
*/
|
||||||
|
export function computeStats(
|
||||||
|
suites: Suite[],
|
||||||
|
originalStats?: Stats,
|
||||||
|
retestStats?: Stats,
|
||||||
|
): Stats {
|
||||||
|
const tests = getAllTests(suites);
|
||||||
|
|
||||||
|
let expected = 0;
|
||||||
|
let unexpected = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let flaky = 0;
|
||||||
|
|
||||||
|
for (const test of tests) {
|
||||||
|
if (!test.results || test.results.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalResult = test.results[test.results.length - 1];
|
||||||
|
const finalStatus = finalResult.status;
|
||||||
|
|
||||||
|
// Check if any result was a failure
|
||||||
|
const hadFailure = test.results.some(
|
||||||
|
(r) => r.status === "failed" || r.status === "timedOut",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (finalStatus === "skipped") {
|
||||||
|
skipped++;
|
||||||
|
} else if (finalStatus === "failed" || finalStatus === "timedOut") {
|
||||||
|
unexpected++;
|
||||||
|
} else if (finalStatus === "passed") {
|
||||||
|
if (hadFailure) {
|
||||||
|
flaky++;
|
||||||
|
} else {
|
||||||
|
expected++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute duration as sum of both runs
|
||||||
|
const duration =
|
||||||
|
(originalStats?.duration || 0) + (retestStats?.duration || 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
startTime: originalStats?.startTime || new Date().toISOString(),
|
||||||
|
duration,
|
||||||
|
expected,
|
||||||
|
unexpected,
|
||||||
|
skipped,
|
||||||
|
flaky,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format milliseconds as "Xm Ys"
|
||||||
|
*/
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
const totalSeconds = Math.round(ms / 1000);
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return `${minutes}m ${seconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get color based on pass rate
|
||||||
|
*/
|
||||||
|
function getColor(passRate: number): string {
|
||||||
|
if (passRate === 100) {
|
||||||
|
return "#43A047"; // green
|
||||||
|
} else if (passRate >= 99) {
|
||||||
|
return "#FFEB3B"; // yellow
|
||||||
|
} else if (passRate >= 98) {
|
||||||
|
return "#FF9800"; // orange
|
||||||
|
} else {
|
||||||
|
return "#F44336"; // red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate all outputs from results
|
||||||
|
*/
|
||||||
|
export function calculateResults(
|
||||||
|
results: PlaywrightResults,
|
||||||
|
): CalculationResult {
|
||||||
|
const stats = results.stats || {
|
||||||
|
expected: 0,
|
||||||
|
unexpected: 0,
|
||||||
|
skipped: 0,
|
||||||
|
flaky: 0,
|
||||||
|
startTime: new Date().toISOString(),
|
||||||
|
duration: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const passed = stats.expected;
|
||||||
|
const failed = stats.unexpected;
|
||||||
|
const flaky = stats.flaky;
|
||||||
|
const skipped = stats.skipped;
|
||||||
|
|
||||||
|
// Count unique spec files
|
||||||
|
const specFiles = new Set<string>();
|
||||||
|
for (const suite of results.suites) {
|
||||||
|
specFiles.add(suite.file);
|
||||||
|
}
|
||||||
|
const totalSpecs = specFiles.size;
|
||||||
|
|
||||||
|
// Get all tests with info for failed tests extraction
|
||||||
|
const testsInfo = getAllTestsWithInfo(results.suites);
|
||||||
|
|
||||||
|
// Extract failed specs
|
||||||
|
const failedSpecsSet = new Set<string>();
|
||||||
|
const failedTestsList: FailedTest[] = [];
|
||||||
|
|
||||||
|
for (const test of testsInfo) {
|
||||||
|
if (test.finalStatus === "failed" || test.finalStatus === "timedOut") {
|
||||||
|
failedSpecsSet.add(test.file);
|
||||||
|
failedTestsList.push({
|
||||||
|
title: test.title,
|
||||||
|
file: test.file,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const failedSpecs = Array.from(failedSpecsSet).join(",");
|
||||||
|
const failedSpecsCount = failedSpecsSet.size;
|
||||||
|
|
||||||
|
// Build failed tests markdown table (limit to 10)
|
||||||
|
let failedTests = "";
|
||||||
|
const uniqueFailedTests = failedTestsList.filter(
|
||||||
|
(test, index, self) =>
|
||||||
|
index ===
|
||||||
|
self.findIndex(
|
||||||
|
(t) => t.title === test.title && t.file === test.file,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (uniqueFailedTests.length > 0) {
|
||||||
|
const limitedTests = uniqueFailedTests.slice(0, 10);
|
||||||
|
failedTests = limitedTests
|
||||||
|
.map((t) => {
|
||||||
|
const escapedTitle = t.title
|
||||||
|
.replace(/`/g, "\\`")
|
||||||
|
.replace(/\|/g, "\\|");
|
||||||
|
return `| ${escapedTitle} | ${t.file} |`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
if (uniqueFailedTests.length > 10) {
|
||||||
|
const remaining = uniqueFailedTests.length - 10;
|
||||||
|
failedTests += `\n| _...and ${remaining} more failed tests_ | |`;
|
||||||
|
}
|
||||||
|
} else if (failed > 0) {
|
||||||
|
failedTests = "| Unable to parse failed tests | - |";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate totals and pass rate
|
||||||
|
const passing = passed + flaky;
|
||||||
|
const total = passing + failed;
|
||||||
|
const passRate = total > 0 ? ((passing * 100) / total).toFixed(2) : "0.00";
|
||||||
|
const color = getColor(parseFloat(passRate));
|
||||||
|
|
||||||
|
// Build commit status message
|
||||||
|
const rate = total > 0 ? (passing * 100) / total : 0;
|
||||||
|
const rateStr = rate === 100 ? "100%" : `${rate.toFixed(1)}%`;
|
||||||
|
const specSuffix = totalSpecs > 0 ? `, ${totalSpecs} specs` : "";
|
||||||
|
const commitStatusMessage =
|
||||||
|
rate === 100
|
||||||
|
? `${rateStr} passed (${passing})${specSuffix}`
|
||||||
|
: `${rateStr} passed (${passing}/${total}), ${failed} failed${specSuffix}`;
|
||||||
|
|
||||||
|
const testDuration = formatDuration(stats.duration || 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
passed,
|
||||||
|
failed,
|
||||||
|
flaky,
|
||||||
|
skipped,
|
||||||
|
totalSpecs,
|
||||||
|
commitStatusMessage,
|
||||||
|
failedSpecs,
|
||||||
|
failedSpecsCount,
|
||||||
|
failedTests,
|
||||||
|
total,
|
||||||
|
passRate,
|
||||||
|
passing,
|
||||||
|
color,
|
||||||
|
testDuration,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge original and retest results at suite level
|
||||||
|
* - Keep original suites that are NOT in retest
|
||||||
|
* - Add all retest suites (replacing matching originals)
|
||||||
|
*/
|
||||||
|
export function mergeResults(
|
||||||
|
original: PlaywrightResults,
|
||||||
|
retest: PlaywrightResults,
|
||||||
|
): MergeResult {
|
||||||
|
// Get list of retested spec files
|
||||||
|
const retestFiles = retest.suites.map((s) => s.file);
|
||||||
|
|
||||||
|
// Filter original suites - keep only those NOT in retest
|
||||||
|
const keptOriginalSuites = original.suites.filter(
|
||||||
|
(suite) => !retestFiles.includes(suite.file),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Merge: kept original suites + all retest suites
|
||||||
|
const mergedSuites = [...keptOriginalSuites, ...retest.suites];
|
||||||
|
|
||||||
|
// Compute stats from merged suites
|
||||||
|
const stats = computeStats(mergedSuites, original.stats, retest.stats);
|
||||||
|
|
||||||
|
const merged: PlaywrightResults = {
|
||||||
|
config: original.config,
|
||||||
|
suites: mergedSuites,
|
||||||
|
stats,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
merged,
|
||||||
|
stats,
|
||||||
|
totalSuites: mergedSuites.length,
|
||||||
|
retestFiles,
|
||||||
|
};
|
||||||
|
}
|
||||||
89
.github/actions/calculate-playwright-results/src/types.ts
поставляемый
Обычный файл
89
.github/actions/calculate-playwright-results/src/types.ts
поставляемый
Обычный файл
@@ -0,0 +1,89 @@
|
|||||||
|
export interface PlaywrightResults {
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
suites: Suite[];
|
||||||
|
stats?: Stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Suite {
|
||||||
|
title: string;
|
||||||
|
file: string;
|
||||||
|
column: number;
|
||||||
|
line: number;
|
||||||
|
specs: Spec[];
|
||||||
|
suites?: Suite[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Spec {
|
||||||
|
title: string;
|
||||||
|
ok: boolean;
|
||||||
|
tags: string[];
|
||||||
|
tests: Test[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Test {
|
||||||
|
timeout: number;
|
||||||
|
annotations: unknown[];
|
||||||
|
expectedStatus: string;
|
||||||
|
projectId: string;
|
||||||
|
projectName: string;
|
||||||
|
results: TestResult[];
|
||||||
|
location?: TestLocation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestResult {
|
||||||
|
workerIndex: number;
|
||||||
|
parallelIndex: number;
|
||||||
|
status: string;
|
||||||
|
duration: number;
|
||||||
|
errors: unknown[];
|
||||||
|
stdout: unknown[];
|
||||||
|
stderr: unknown[];
|
||||||
|
retry: number;
|
||||||
|
startTime: string;
|
||||||
|
annotations: unknown[];
|
||||||
|
attachments?: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestLocation {
|
||||||
|
file: string;
|
||||||
|
line: number;
|
||||||
|
column: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Stats {
|
||||||
|
startTime: string;
|
||||||
|
duration: number;
|
||||||
|
expected: number;
|
||||||
|
unexpected: number;
|
||||||
|
skipped: number;
|
||||||
|
flaky: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MergeResult {
|
||||||
|
merged: PlaywrightResults;
|
||||||
|
stats: Stats;
|
||||||
|
totalSuites: number;
|
||||||
|
retestFiles: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalculationResult {
|
||||||
|
passed: number;
|
||||||
|
failed: number;
|
||||||
|
flaky: number;
|
||||||
|
skipped: number;
|
||||||
|
totalSpecs: number;
|
||||||
|
commitStatusMessage: string;
|
||||||
|
failedSpecs: string;
|
||||||
|
failedSpecsCount: number;
|
||||||
|
failedTests: string;
|
||||||
|
total: number;
|
||||||
|
passRate: string;
|
||||||
|
passing: number;
|
||||||
|
color: string;
|
||||||
|
testDuration: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FailedTest {
|
||||||
|
title: string;
|
||||||
|
file: string;
|
||||||
|
}
|
||||||
17
.github/actions/calculate-playwright-results/tsconfig.json
поставляемый
Обычный файл
17
.github/actions/calculate-playwright-results/tsconfig.json
поставляемый
Обычный файл
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"declaration": true,
|
||||||
|
"isolatedModules": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||||
|
}
|
||||||
1
.github/actions/calculate-playwright-results/tsconfig.tsbuildinfo
поставляемый
Обычный файл
1
.github/actions/calculate-playwright-results/tsconfig.tsbuildinfo
поставляемый
Обычный файл
@@ -0,0 +1 @@
|
|||||||
|
{"root":["./src/index.ts","./src/main.ts","./src/merge.ts","./src/types.ts"],"version":"5.9.3"}
|
||||||
12
.github/actions/calculate-playwright-results/tsup.config.ts
поставляемый
Обычный файл
12
.github/actions/calculate-playwright-results/tsup.config.ts
поставляемый
Обычный файл
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from "tsup";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ["src/index.ts"],
|
||||||
|
format: ["cjs"],
|
||||||
|
outDir: "dist",
|
||||||
|
clean: true,
|
||||||
|
noExternal: [/.*/], // Bundle all dependencies
|
||||||
|
minify: false,
|
||||||
|
sourcemap: false,
|
||||||
|
target: "node24",
|
||||||
|
});
|
||||||
345
.github/workflows/e2e-fulltests-ci.yml
поставляемый
345
.github/workflows/e2e-fulltests-ci.yml
поставляемый
@@ -1,345 +0,0 @@
|
|||||||
---
|
|
||||||
name: E2E Tests
|
|
||||||
on:
|
|
||||||
# For PRs, this workflow gets triggered from the Argo Events platform.
|
|
||||||
# Check the following repo for details: https://github.com/mattermost/delivery-platform
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
ref:
|
|
||||||
type: string
|
|
||||||
description: Git ref to test. Must be a full commit SHA for PR testing, and a tag for release testing. Ignored for daily tests.
|
|
||||||
required: false
|
|
||||||
PR_NUMBER:
|
|
||||||
type: string
|
|
||||||
description: PR number (if applicable)
|
|
||||||
required: false
|
|
||||||
ROLLING_RELEASE_FROM_TAG:
|
|
||||||
type: string
|
|
||||||
description: Mattermost release git tag for RollingRelease tests. Optional.
|
|
||||||
required: false
|
|
||||||
MM_ENV:
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
description: A comma-separated list of environment variables to set for the server. Spaces are not supported.
|
|
||||||
MM_SERVICE_OVERRIDES:
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
description: A comma-separated list of service overrides. E.g. "-elasticsearch,+opensearch"
|
|
||||||
REPORT_TYPE:
|
|
||||||
type: choice
|
|
||||||
description: The context this report is being generated in
|
|
||||||
options:
|
|
||||||
- PR
|
|
||||||
- RELEASE
|
|
||||||
- RELEASE_CLOUD
|
|
||||||
- MASTER
|
|
||||||
- MASTER_UNSTABLE
|
|
||||||
- CLOUD
|
|
||||||
- CLOUD_UNSTABLE
|
|
||||||
- NONE
|
|
||||||
default: NONE
|
|
||||||
RUN_CYPRESS:
|
|
||||||
type: string
|
|
||||||
description: Enable Cypress run
|
|
||||||
default: "true"
|
|
||||||
RUN_PLAYWRIGHT:
|
|
||||||
type: string
|
|
||||||
description: Enable Playwright run
|
|
||||||
default: "true"
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: "${{ github.workflow }}-${{ inputs.REPORT_TYPE }}-${{ inputs.PR_NUMBER || inputs.ref }}-${{ inputs.MM_ENV }}"
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
generate-test-variables:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
issues: write
|
|
||||||
pull-requests: write
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: bash
|
|
||||||
outputs:
|
|
||||||
commit_sha: "${{ steps.generate.outputs.commit_sha }}"
|
|
||||||
BRANCH: "${{ steps.generate.outputs.BRANCH }}"
|
|
||||||
SERVER_IMAGE: "${{ steps.generate.outputs.SERVER_IMAGE }}"
|
|
||||||
status_check_context: "${{ steps.generate.outputs.status_check_context }}"
|
|
||||||
workers_number: "${{ steps.generate.outputs.workers_number }}"
|
|
||||||
server_uppercase: "${{ steps.generate.outputs.server_uppercase }}" # Required for license selection
|
|
||||||
SERVER: "${{ steps.generate.outputs.SERVER }}"
|
|
||||||
ENABLED_DOCKER_SERVICES: "${{ steps.generate.outputs.ENABLED_DOCKER_SERVICES }}"
|
|
||||||
TEST_FILTER_CYPRESS: "${{ steps.generate.outputs.TEST_FILTER_CYPRESS }}"
|
|
||||||
TEST_FILTER_PLAYWRIGHT: "tests"
|
|
||||||
BUILD_ID: "${{ steps.generate.outputs.BUILD_ID }}"
|
|
||||||
TM4J_ENABLE: "${{ steps.generate.outputs.TM4J_ENABLE }}"
|
|
||||||
REPORT_TYPE: "${{ steps.generate.outputs.REPORT_TYPE }}"
|
|
||||||
TESTCASE_FAILURE_FATAL: "${{ steps.generate.outputs.TESTCASE_FAILURE_FATAL }}"
|
|
||||||
ROLLING_RELEASE_commit_sha: "${{ steps.generate.outputs.ROLLING_RELEASE_commit_sha }}"
|
|
||||||
ROLLING_RELEASE_SERVER_IMAGE: "${{ steps.generate.outputs.ROLLING_RELEASE_SERVER_IMAGE }}"
|
|
||||||
WORKFLOW_RUN_URL: "${{steps.generate.outputs.WORKFLOW_RUN_URL}}"
|
|
||||||
CYCLE_URL: "${{steps.generate.outputs.CYCLE_URL}}"
|
|
||||||
env:
|
|
||||||
GH_TOKEN: "${{ github.token }}"
|
|
||||||
REF: "${{ inputs.ref || github.sha }}"
|
|
||||||
PR_NUMBER: "${{ inputs.PR_NUMBER || '' }}"
|
|
||||||
REPORT_TYPE: "${{ inputs.REPORT_TYPE }}"
|
|
||||||
ROLLING_RELEASE_FROM_TAG: "${{ inputs.ROLLING_RELEASE_FROM_TAG }}"
|
|
||||||
AUTOMATION_DASHBOARD_URL: "${{ secrets.MM_E2E_AUTOMATION_DASHBOARD_URL }}"
|
|
||||||
# We could exclude the @smoke group for PRs, but then we wouldn't have it in the report
|
|
||||||
TEST_FILTER_CYPRESS_PR: >-
|
|
||||||
--stage="@prod"
|
|
||||||
--excludeGroup="@te_only,@cloud_only,@high_availability"
|
|
||||||
--sortFirst="@compliance_export,@elasticsearch,@ldap_group,@ldap"
|
|
||||||
--sortLast="@saml,@keycloak,@plugin,@plugins_uninstall,@mfa,@license_removal"
|
|
||||||
TEST_FILTER_CYPRESS_PROD_ONPREM: >-
|
|
||||||
--stage="@prod"
|
|
||||||
--excludeGroup="@te_only,@cloud_only,@high_availability"
|
|
||||||
--sortFirst="@compliance_export,@elasticsearch,@ldap_group,@ldap,@playbooks"
|
|
||||||
--sortLast="@saml,@keycloak,@plugin,@plugins_uninstall,@mfa,@license_removal"
|
|
||||||
TEST_FILTER_CYPRESS_PROD_CLOUD: >-
|
|
||||||
--stage="@prod"
|
|
||||||
--excludeGroup="@not_cloud,@cloud_trial,@e20_only,@te_only,@high_availability,@license_removal"
|
|
||||||
--sortFirst="@compliance_export,@elasticsearch,@ldap_group,@ldap,@playbooks"
|
|
||||||
--sortLast="@saml,@keycloak,@plugin,@plugins_uninstall,@mfa"
|
|
||||||
MM_ENV: "${{ inputs.MM_ENV || '' }}"
|
|
||||||
MM_SERVICE_OVERRIDES: "${{ inputs.MM_SERVICE_OVERRIDES }}"
|
|
||||||
steps:
|
|
||||||
- name: ci/checkout-repo
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
with:
|
|
||||||
ref: "${{ inputs.ref || github.sha }}"
|
|
||||||
fetch-depth: 0
|
|
||||||
- name: ci/generate-test-variables
|
|
||||||
id: generate
|
|
||||||
run: |
|
|
||||||
MM_ENV_HASH=$(md5sum -z <<<"$MM_ENV" | cut -c-8)
|
|
||||||
TESTCASE_FAILURE_FATAL="true"
|
|
||||||
if grep -q CLOUD <<<"$REPORT_TYPE"; then
|
|
||||||
SERVER=cloud
|
|
||||||
else
|
|
||||||
SERVER=onprem
|
|
||||||
fi
|
|
||||||
case "$REPORT_TYPE" in
|
|
||||||
NONE | PR)
|
|
||||||
### Populate support variables
|
|
||||||
_COMMIT_SHA_COMPUTED=$(git rev-parse --verify "$REF") # NB: not actually used for resolving the commit; it's only to double check the value of 'inputs.ref'
|
|
||||||
### For image tag generation: utilize 'inputs.ref', assume that it is a full commit SHA
|
|
||||||
COMMIT_SHA="${REF}"
|
|
||||||
BRANCH="server-pr-${PR_NUMBER}" # For reference, the real branch name may be retrievable with command: 'jq -r .head.ref <pr.json'
|
|
||||||
SERVER_IMAGE_TAG="${COMMIT_SHA::7}"
|
|
||||||
SERVER_IMAGE_ORG=mattermostdevelopment
|
|
||||||
BUILD_ID_SUFFIX="${REPORT_TYPE@L}-${SERVER}-ent"
|
|
||||||
WORKERS_NUMBER=20
|
|
||||||
TEST_FILTER_CYPRESS="$TEST_FILTER_CYPRESS_PR"
|
|
||||||
COMPUTED_REPORT_TYPE="${REPORT_TYPE}"
|
|
||||||
### Run sanity assertions after variable generations
|
|
||||||
[ "$REF" = "${_COMMIT_SHA_COMPUTED}" ] # 'inputs.ref' must be a full commit hash, and the commit must exist
|
|
||||||
[ "$REPORT_TYPE" != "PR" ] || [ "$PR_NUMBER" -gt "0" ] # If report type is PR, then PR_NUMBER must be set to a number
|
|
||||||
;;
|
|
||||||
MASTER | MASTER_UNSTABLE | CLOUD | CLOUD_UNSTABLE)
|
|
||||||
### Populate support variables
|
|
||||||
_IS_TEST_UNSTABLE=$(sed -n -E 's/^.*(UNSTABLE).*$/\1/p' <<< "$REPORT_TYPE") # The variable's value is 'UNSTABLE' if report type is for unstable tests, otherwise it's empty
|
|
||||||
_TEST_FILTER_CYPRESS_VARIABLE="TEST_FILTER_CYPRESS_PROD_${SERVER@U}"
|
|
||||||
### For ref and image tag generation: ignore 'inputs.ref', and use master branch directly. Note that 'COMMIT_SHA' will be used for reporting the test result, and for checking out the testing scripts and test cases
|
|
||||||
COMMIT_SHA="$(git rev-parse --verify origin/master)"
|
|
||||||
BRANCH=master
|
|
||||||
SERVER_IMAGE_TAG=master
|
|
||||||
SERVER_IMAGE_ORG=mattermostdevelopment
|
|
||||||
BUILD_ID_SUFFIX="${_IS_TEST_UNSTABLE:+unstable-}daily-${SERVER}-ent"
|
|
||||||
BUILD_ID_SUFFIX_IN_STATUS_CHECK=true
|
|
||||||
WORKERS_NUMBER=10 # Daily tests are not time critical, and it's more efficient to run on fewer workers
|
|
||||||
TEST_FILTER_CYPRESS="${!_TEST_FILTER_CYPRESS_VARIABLE} ${_IS_TEST_UNSTABLE:+--invert}"
|
|
||||||
TM4J_ENABLE=true
|
|
||||||
COMPUTED_REPORT_TYPE="${REPORT_TYPE}"
|
|
||||||
[ -z "$_IS_TEST_UNSTABLE" ] || TESTCASE_FAILURE_FATAL="" # Assert that tests are stable. If they are not, the status check will be always green
|
|
||||||
;;
|
|
||||||
RELEASE | RELEASE_CLOUD)
|
|
||||||
### Populate support variables
|
|
||||||
_TEST_FILTER_CYPRESS_VARIABLE="TEST_FILTER_CYPRESS_PROD_${SERVER@U}"
|
|
||||||
### For ref and image tag generation: assume the 'inputs.ref' is a tag, and use the first two digits to construct the branch name
|
|
||||||
COMMIT_SHA="$(git rev-parse --verify HEAD)"
|
|
||||||
BRANCH=$(sed -E "s/v([0-9]+)\.([0-9]+)\..+$/release-\1.\2/g" <<<$REF)
|
|
||||||
SERVER_IMAGE_TAG="$(cut -c2- <<<$REF)" # Remove the leading 'v' from the given tag name, to generate the docker image tag
|
|
||||||
SERVER_IMAGE_ORG=mattermost
|
|
||||||
BUILD_ID_SUFFIX="release-${SERVER}-ent"
|
|
||||||
BUILD_ID_SUFFIX_IN_STATUS_CHECK=true
|
|
||||||
WORKERS_NUMBER=20
|
|
||||||
TEST_FILTER_CYPRESS="${!_TEST_FILTER_CYPRESS_VARIABLE}"
|
|
||||||
TM4J_ENABLE=true
|
|
||||||
COMPUTED_REPORT_TYPE=RELEASE
|
|
||||||
### Run sanity assertions after variable generations
|
|
||||||
git show-ref --verify "refs/tags/${REF}" # 'inputs.ref' must be a tag, for release report types
|
|
||||||
git show-ref --verify "refs/remotes/origin/${BRANCH}" # The release branch computed from the given tag must exist
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Fatal: unimplemented test type. Aborting."
|
|
||||||
exit 1
|
|
||||||
esac
|
|
||||||
if [ -n "$ROLLING_RELEASE_FROM_TAG" ]; then
|
|
||||||
ROLLING_RELEASE_COMMIT_SHA=$(git rev-parse --verify "$ROLLING_RELEASE_FROM_TAG")
|
|
||||||
ROLLING_RELEASE_SERVER_IMAGE_TAG=$(echo "$ROLLING_RELEASE_FROM_TAG" | sed 's/^v//') # Remove the leading 'v' from the given tag name, to generate the docker image tag
|
|
||||||
ROLLING_RELEASE_SERVER_IMAGE="mattermost/mattermost-enterprise-edition:${ROLLING_RELEASE_SERVER_IMAGE_TAG}"
|
|
||||||
BUILD_ID_SUFFIX="rolling${ROLLING_RELEASE_FROM_TAG/-/_}-$BUILD_ID_SUFFIX"
|
|
||||||
BUILD_ID_SUFFIX_IN_STATUS_CHECK=true
|
|
||||||
WORKERS_NUMBER=10 # Rolling release tests are particularly impacted by increased parallelism. It's more efficient to run on fewer workers
|
|
||||||
### Run sanity assertions after variable generations
|
|
||||||
git show-ref --verify "refs/tags/${ROLLING_RELEASE_FROM_TAG}" # 'inputs.ROLLING_RELEASE_FROM_TAG' must be a tag, for release report types
|
|
||||||
fi
|
|
||||||
ENABLED_DOCKER_SERVICES="postgres inbucket minio openldap elasticsearch keycloak"
|
|
||||||
for SVC_OP in $(tr , ' '<<<"$MM_SERVICE_OVERRIDES"); do
|
|
||||||
OP=$(cut -c1 <<<$SVC_OP)
|
|
||||||
SVC=$(cut -c2- <<<$SVC_OP)
|
|
||||||
case "$OP" in
|
|
||||||
"+") ENABLED_DOCKER_SERVICES="$ENABLED_DOCKER_SERVICES $SVC" ;;
|
|
||||||
"-") ENABLED_DOCKER_SERVICES=$(sed -E "s:(^| )${SVC}( |\$): :g" <<<"$ENABLED_DOCKER_SERVICES") ;;
|
|
||||||
*) echo "Invalid MM_SERVICE_OVERRIDE value: $SVC_OP"; exit 1 ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
# BUILD_ID format: $pipelineID-$imageTag-$testType-$serverType-$serverEdition
|
|
||||||
# Reference on BUILD_ID parsing: https://github.com/saturninoabril/automation-dashboard/blob/175891781bf1072c162c58c6ec0abfc5bcb3520e/lib/common_utils.ts#L3-L23
|
|
||||||
BUILD_ID="${{ github.run_id }}_${{ github.run_attempt }}-${SERVER_IMAGE_TAG}-${BUILD_ID_SUFFIX}"
|
|
||||||
echo "commit_sha=${COMMIT_SHA}" >> $GITHUB_OUTPUT
|
|
||||||
echo "BRANCH=${BRANCH}" >> $GITHUB_OUTPUT
|
|
||||||
echo "SERVER_IMAGE=${SERVER_IMAGE_ORG}/mattermost-enterprise-edition:${SERVER_IMAGE_TAG}" >> $GITHUB_OUTPUT
|
|
||||||
echo "SERVER=${SERVER}" >> $GITHUB_OUTPUT
|
|
||||||
echo "server_uppercase=${SERVER@U}" >> $GITHUB_OUTPUT
|
|
||||||
echo "ENABLED_DOCKER_SERVICES=${ENABLED_DOCKER_SERVICES}" >> $GITHUB_OUTPUT
|
|
||||||
echo "status_check_context=E2E Tests/test${BUILD_ID_SUFFIX_IN_STATUS_CHECK:+-$BUILD_ID_SUFFIX}${MM_ENV:+/$MM_ENV_HASH}" >> $GITHUB_OUTPUT
|
|
||||||
echo "workers_number=${WORKERS_NUMBER}" >> $GITHUB_OUTPUT
|
|
||||||
echo "TEST_FILTER_CYPRESS=${TEST_FILTER_CYPRESS}" >> $GITHUB_OUTPUT
|
|
||||||
echo "TESTCASE_FAILURE_FATAL=${TESTCASE_FAILURE_FATAL}" >> $GITHUB_OUTPUT
|
|
||||||
echo "TM4J_ENABLE=${TM4J_ENABLE:-}" >> $GITHUB_OUTPUT
|
|
||||||
echo "REPORT_TYPE=${COMPUTED_REPORT_TYPE}" >> $GITHUB_OUTPUT
|
|
||||||
echo "ROLLING_RELEASE_commit_sha=${ROLLING_RELEASE_COMMIT_SHA}" >> $GITHUB_OUTPUT
|
|
||||||
echo "ROLLING_RELEASE_SERVER_IMAGE=${ROLLING_RELEASE_SERVER_IMAGE}" >> $GITHUB_OUTPUT
|
|
||||||
echo "BUILD_ID=${BUILD_ID}" >> $GITHUB_OUTPUT
|
|
||||||
# User notification variables
|
|
||||||
echo "WORKFLOW_RUN_URL=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{github.run_id}}" >> $GITHUB_OUTPUT
|
|
||||||
echo "CYCLE_URL=${AUTOMATION_DASHBOARD_URL%%/api}/cycle/${BUILD_ID}" >> $GITHUB_OUTPUT
|
|
||||||
- name: ci/notify-user
|
|
||||||
env:
|
|
||||||
COMMIT_SHA: "${{steps.generate.outputs.commit_sha}}"
|
|
||||||
STATUS_CHECK_CONTEXT: "${{steps.generate.outputs.status_check_context}}"
|
|
||||||
WORKFLOW_RUN_URL: "${{steps.generate.outputs.WORKFLOW_RUN_URL}}"
|
|
||||||
CYCLE_URL: "${{steps.generate.outputs.CYCLE_URL}}"
|
|
||||||
RUN_CYPRESS: "${{inputs.RUN_CYPRESS == 'true' || ''}}"
|
|
||||||
RUN_PLAYWRIGHT: "${{inputs.RUN_PLAYWRIGHT == 'true' || ''}}"
|
|
||||||
run: |
|
|
||||||
if [ -n "$PR_NUMBER" ]; then
|
|
||||||
gh issue -R "${{ github.repository }}" comment "$PR_NUMBER" --body-file - <<EOF
|
|
||||||
E2E test run is starting for commit \`${COMMIT_SHA}\`${MM_ENV:+, with \`MM_ENV=$MM_ENV\`}${MM_SERVICE_OVERRIDES:+, Cypress service overrides \`$MM_SERVICE_OVERRIDES\`}.
|
|
||||||
To check the run progress:
|
|
||||||
- Cypress: ${RUN_CYPRESS:+look for commit status \`$STATUS_CHECK_CONTEXT\` or the access the [Automation Dashboard Cycle URL]($CYCLE_URL)}$([ -n "${RUN_CYPRESS:-}" ] || echo -n "will not run").
|
|
||||||
- Playwright: ${RUN_PLAYWRIGHT:+look for commit status \`$STATUS_CHECK_CONTEXT-playwright\`}$([ -n "${RUN_PLAYWRIGHT:-}" ] || echo -n "will not run").
|
|
||||||
|
|
||||||
You can also look at the [E2E test's Workflow Run URL]($WORKFLOW_RUN_URL) (run ID \`${{ github.run_id }}\`).
|
|
||||||
EOF
|
|
||||||
fi
|
|
||||||
|
|
||||||
e2e-fulltest-cypress:
|
|
||||||
needs:
|
|
||||||
- generate-test-variables
|
|
||||||
uses: ./.github/workflows/e2e-tests-ci-template.yml
|
|
||||||
if: ${{ inputs.RUN_CYPRESS == 'true' }}
|
|
||||||
with:
|
|
||||||
commit_sha: "${{ needs.generate-test-variables.outputs.commit_sha }}"
|
|
||||||
status_check_context: "${{ needs.generate-test-variables.outputs.status_check_context }}"
|
|
||||||
workers_number: "${{ needs.generate-test-variables.outputs.workers_number }}"
|
|
||||||
testcase_failure_fatal: "${{ needs.generate-test-variables.outputs.TESTCASE_FAILURE_FATAL == 'true' }}"
|
|
||||||
run_preflight_checks: false
|
|
||||||
enable_reporting: true
|
|
||||||
SERVER: "${{ needs.generate-test-variables.outputs.SERVER }}"
|
|
||||||
SERVER_IMAGE: "${{ needs.generate-test-variables.outputs.SERVER_IMAGE }}"
|
|
||||||
ENABLED_DOCKER_SERVICES: "${{ needs.generate-test-variables.outputs.ENABLED_DOCKER_SERVICES }}"
|
|
||||||
TEST: "cypress"
|
|
||||||
TEST_FILTER: "${{ needs.generate-test-variables.outputs.TEST_FILTER_CYPRESS }}"
|
|
||||||
MM_ENV: "${{ inputs.MM_ENV || '' }}"
|
|
||||||
BRANCH: "${{ needs.generate-test-variables.outputs.BRANCH }}"
|
|
||||||
BUILD_ID: "${{ needs.generate-test-variables.outputs.BUILD_ID }}"
|
|
||||||
REPORT_TYPE: "${{ needs.generate-test-variables.outputs.REPORT_TYPE }}"
|
|
||||||
ROLLING_RELEASE_commit_sha: "${{ needs.generate-test-variables.outputs.ROLLING_RELEASE_commit_sha }}"
|
|
||||||
ROLLING_RELEASE_SERVER_IMAGE: "${{ needs.generate-test-variables.outputs.ROLLING_RELEASE_SERVER_IMAGE }}"
|
|
||||||
PR_NUMBER: "${{ inputs.PR_NUMBER }}"
|
|
||||||
secrets:
|
|
||||||
MM_LICENSE: "${{ secrets[format('MM_E2E_TEST_LICENSE_{0}_ENT', needs.generate-test-variables.outputs.server_uppercase)] }}"
|
|
||||||
AUTOMATION_DASHBOARD_URL: "${{ secrets.MM_E2E_AUTOMATION_DASHBOARD_URL }}"
|
|
||||||
AUTOMATION_DASHBOARD_TOKEN: "${{ secrets.MM_E2E_AUTOMATION_DASHBOARD_TOKEN }}"
|
|
||||||
PUSH_NOTIFICATION_SERVER: "${{ secrets.MM_E2E_PUSH_NOTIFICATION_SERVER }}"
|
|
||||||
REPORT_WEBHOOK_URL: "${{ secrets.MM_E2E_REPORT_WEBHOOK_URL }}"
|
|
||||||
REPORT_TM4J_API_KEY: "${{ needs.generate-test-variables.outputs.TM4J_ENABLE == 'true' && secrets.MM_E2E_TM4J_API_KEY || '' }}"
|
|
||||||
REPORT_TM4J_TEST_CYCLE_LINK_PREFIX: "${{ secrets.MM_E2E_TEST_CYCLE_LINK_PREFIX }}"
|
|
||||||
CWS_URL: "${{ needs.generate-test-variables.outputs.SERVER == 'cloud' && secrets.MM_E2E_CWS_URL || '' }}"
|
|
||||||
CWS_EXTRA_HTTP_HEADERS: "${{ needs.generate-test-variables.outputs.SERVER == 'cloud' && secrets.MM_E2E_CWS_EXTRA_HTTP_HEADERS || '' }}"
|
|
||||||
AWS_ACCESS_KEY_ID: "${{ secrets.CYPRESS_AWS_ACCESS_KEY_ID }}"
|
|
||||||
AWS_SECRET_ACCESS_KEY: "${{ secrets.CYPRESS_AWS_SECRET_ACCESS_KEY }}"
|
|
||||||
|
|
||||||
e2e-fulltest-playwright:
|
|
||||||
needs:
|
|
||||||
- generate-test-variables
|
|
||||||
uses: ./.github/workflows/e2e-tests-ci-template.yml
|
|
||||||
if: ${{ inputs.RUN_PLAYWRIGHT == 'true' }}
|
|
||||||
with:
|
|
||||||
commit_sha: "${{ needs.generate-test-variables.outputs.commit_sha }}"
|
|
||||||
status_check_context: "${{ needs.generate-test-variables.outputs.status_check_context }}-playwright"
|
|
||||||
workers_number: "1"
|
|
||||||
testcase_failure_fatal: "${{ needs.generate-test-variables.outputs.TESTCASE_FAILURE_FATAL == 'true' }}"
|
|
||||||
run_preflight_checks: false
|
|
||||||
enable_reporting: true
|
|
||||||
SERVER: "${{ needs.generate-test-variables.outputs.SERVER }}"
|
|
||||||
SERVER_IMAGE: "${{ needs.generate-test-variables.outputs.SERVER_IMAGE }}"
|
|
||||||
TEST: "playwright"
|
|
||||||
TEST_FILTER: "${{ needs.generate-test-variables.outputs.TEST_FILTER_PLAYWRIGHT }}"
|
|
||||||
MM_ENV: "${{ inputs.MM_ENV || '' }}"
|
|
||||||
BRANCH: "${{ needs.generate-test-variables.outputs.BRANCH }}"
|
|
||||||
BUILD_ID: "${{ needs.generate-test-variables.outputs.BUILD_ID }}"
|
|
||||||
REPORT_TYPE: "${{ needs.generate-test-variables.outputs.REPORT_TYPE }}"
|
|
||||||
ROLLING_RELEASE_commit_sha: "${{ needs.generate-test-variables.outputs.ROLLING_RELEASE_commit_sha }}"
|
|
||||||
ROLLING_RELEASE_SERVER_IMAGE: "${{ needs.generate-test-variables.outputs.ROLLING_RELEASE_SERVER_IMAGE }}"
|
|
||||||
PR_NUMBER: "${{ inputs.PR_NUMBER }}"
|
|
||||||
secrets:
|
|
||||||
MM_LICENSE: "${{ secrets[format('MM_E2E_TEST_LICENSE_{0}_ENT', needs.generate-test-variables.outputs.server_uppercase)] }}"
|
|
||||||
PUSH_NOTIFICATION_SERVER: "${{ secrets.MM_E2E_PUSH_NOTIFICATION_SERVER }}"
|
|
||||||
REPORT_WEBHOOK_URL: "${{ secrets.MM_E2E_REPORT_WEBHOOK_URL }}"
|
|
||||||
CWS_URL: "${{ needs.generate-test-variables.outputs.SERVER == 'cloud' && secrets.MM_E2E_CWS_URL || '' }}"
|
|
||||||
CWS_EXTRA_HTTP_HEADERS: "${{ needs.generate-test-variables.outputs.SERVER == 'cloud' && secrets.MM_E2E_CWS_EXTRA_HTTP_HEADERS || '' }}"
|
|
||||||
AWS_ACCESS_KEY_ID: "${{ secrets.CYPRESS_AWS_ACCESS_KEY_ID }}"
|
|
||||||
AWS_SECRET_ACCESS_KEY: "${{ secrets.CYPRESS_AWS_SECRET_ACCESS_KEY }}"
|
|
||||||
|
|
||||||
notify-user:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: always()
|
|
||||||
needs:
|
|
||||||
- generate-test-variables
|
|
||||||
- e2e-fulltest-cypress
|
|
||||||
- e2e-fulltest-playwright
|
|
||||||
permissions:
|
|
||||||
issues: write
|
|
||||||
pull-requests: write
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
GH_TOKEN: "${{ github.token }}"
|
|
||||||
PR_NUMBER: "${{ inputs.PR_NUMBER || '' }}"
|
|
||||||
MM_ENV: "${{ inputs.MM_ENV || '' }}"
|
|
||||||
COMMIT_SHA: "${{ needs.generate-test-variables.outputs.commit_sha }}"
|
|
||||||
STATUS_CHECK_CONTEXT: "${{ needs.generate-test-variables.outputs.status_check_context }}"
|
|
||||||
WORKFLOW_RUN_URL: "${{ needs.generate-test-variables.outputs.WORKFLOW_RUN_URL }}"
|
|
||||||
CYCLE_URL: "${{ needs.generate-test-variables.outputs.CYCLE_URL }}"
|
|
||||||
RUN_CYPRESS: "${{inputs.RUN_CYPRESS == 'true' || ''}}"
|
|
||||||
RUN_PLAYWRIGHT: "${{inputs.RUN_PLAYWRIGHT == 'true' || ''}}"
|
|
||||||
PLAYWRIGHT_REPORT_URL: "${{ needs.e2e-fulltest-playwright.outputs.playwright_report_url }}"
|
|
||||||
steps:
|
|
||||||
- name: ci/notify-user-test-completion
|
|
||||||
run: |
|
|
||||||
if [ -n "$PR_NUMBER" ]; then
|
|
||||||
gh issue -R "${{ github.repository }}" comment "$PR_NUMBER" --body-file - <<EOF
|
|
||||||
E2E test has completed for commit \`${COMMIT_SHA}\`${MM_ENV:+, with \`MM_ENV=$MM_ENV\`}.
|
|
||||||
Results summary:
|
|
||||||
- Cypress: ${RUN_CYPRESS:+pass rate is \`${{ needs.e2e-fulltest-cypress.outputs.pass_rate || 'unknown' }}\` (see [Automation Dashboard]($CYCLE_URL) and commit status check \`$STATUS_CHECK_CONTEXT\`)}$([ -n "${RUN_CYPRESS:-}" ] || echo -n "did not run").
|
|
||||||
- Playwright: ${RUN_PLAYWRIGHT:+pass rate is \`${{ needs.e2e-fulltest-playwright.outputs.pass_rate || 'unknown' }}\` (see [Playwright Report URL]($PLAYWRIGHT_REPORT_URL) and commit status check \`$STATUS_CHECK_CONTEXT-playwright\`)}$([ -n "${RUN_PLAYWRIGHT:-}" ] || echo -n "did not run").
|
|
||||||
|
|
||||||
The run summary artifacts are available in the corresponding [Workflow Run]($WORKFLOW_RUN_URL).
|
|
||||||
EOF
|
|
||||||
fi
|
|
||||||
572
.github/workflows/e2e-tests-ci-template.yml
поставляемый
572
.github/workflows/e2e-tests-ci-template.yml
поставляемый
@@ -1,572 +0,0 @@
|
|||||||
---
|
|
||||||
name: E2E Tests Template
|
|
||||||
on:
|
|
||||||
workflow_call:
|
|
||||||
inputs:
|
|
||||||
# NB: this does not support using branch names that belong to forks.
|
|
||||||
# In those cases, you should specify directly the commit SHA that you want to test, or
|
|
||||||
# some wrapper workflow that does it for you (e.g. the slash command for initiating a PR test)
|
|
||||||
commit_sha:
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
status_check_context:
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
workers_number:
|
|
||||||
type: string # Should ideally be a number; see https://github.com/orgs/community/discussions/67182
|
|
||||||
required: false
|
|
||||||
default: "1"
|
|
||||||
testcase_failure_fatal:
|
|
||||||
type: boolean
|
|
||||||
required: false
|
|
||||||
default: true
|
|
||||||
# NB: the following toggles will skip individual steps, rather than the whole jobs,
|
|
||||||
# to let the dependent jobs run even if these are false
|
|
||||||
run_preflight_checks:
|
|
||||||
type: boolean
|
|
||||||
required: false
|
|
||||||
default: true
|
|
||||||
enable_reporting:
|
|
||||||
type: boolean
|
|
||||||
required: false
|
|
||||||
default: false
|
|
||||||
SERVER:
|
|
||||||
type: string # Valid values are: onprem, cloud
|
|
||||||
required: false
|
|
||||||
default: onprem
|
|
||||||
SERVER_IMAGE:
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
ENABLED_DOCKER_SERVICES:
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
TEST: # Valid values are: cypress, playwright
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
default: "cypress"
|
|
||||||
TEST_FILTER:
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
MM_ENV:
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
BRANCH:
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
BUILD_ID:
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
REPORT_TYPE:
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
ROLLING_RELEASE_commit_sha:
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
ROLLING_RELEASE_SERVER_IMAGE:
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
PR_NUMBER:
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
secrets:
|
|
||||||
MM_LICENSE:
|
|
||||||
required: false
|
|
||||||
AUTOMATION_DASHBOARD_URL:
|
|
||||||
required: false
|
|
||||||
AUTOMATION_DASHBOARD_TOKEN:
|
|
||||||
required: false
|
|
||||||
PUSH_NOTIFICATION_SERVER:
|
|
||||||
required: false
|
|
||||||
REPORT_WEBHOOK_URL:
|
|
||||||
required: false
|
|
||||||
REPORT_TM4J_API_KEY:
|
|
||||||
required: false
|
|
||||||
REPORT_TM4J_TEST_CYCLE_LINK_PREFIX:
|
|
||||||
required: false
|
|
||||||
CWS_URL:
|
|
||||||
required: false
|
|
||||||
CWS_EXTRA_HTTP_HEADERS:
|
|
||||||
required: false
|
|
||||||
AWS_ACCESS_KEY_ID:
|
|
||||||
required: false
|
|
||||||
AWS_SECRET_ACCESS_KEY:
|
|
||||||
required: false
|
|
||||||
outputs:
|
|
||||||
passed:
|
|
||||||
value: "${{ jobs.report.outputs.passed }}"
|
|
||||||
failed:
|
|
||||||
value: "${{ jobs.report.outputs.failed }}"
|
|
||||||
failed_expected:
|
|
||||||
value: "${{ jobs.report.outputs.failed_expected }}"
|
|
||||||
pass_rate:
|
|
||||||
value: "${{ jobs.report.outputs.pass_rate }}"
|
|
||||||
playwright_report_url:
|
|
||||||
value: ${{ jobs.report.outputs.playwright_report_url }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
update-initial-status:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: mattermost/actions/delivery/update-commit-status@main
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
|
||||||
with:
|
|
||||||
repository_full_name: ${{ github.repository }}
|
|
||||||
commit_sha: ${{ inputs.commit_sha }}
|
|
||||||
context: ${{ inputs.status_check_context }}
|
|
||||||
description: E2E tests for mattermost server app
|
|
||||||
status: pending
|
|
||||||
|
|
||||||
cypress-check:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs:
|
|
||||||
- update-initial-status
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: e2e-tests/cypress
|
|
||||||
steps:
|
|
||||||
- name: ci/checkout-repo
|
|
||||||
if: "${{ inputs.run_preflight_checks }}"
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
with:
|
|
||||||
ref: ${{ inputs.commit_sha }}
|
|
||||||
fetch-depth: 0
|
|
||||||
- name: ci/setup-node
|
|
||||||
if: "${{ inputs.run_preflight_checks }}"
|
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
|
||||||
id: setup_node
|
|
||||||
with:
|
|
||||||
node-version-file: ".nvmrc"
|
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: "e2e-tests/cypress/package-lock.json"
|
|
||||||
- name: ci/cypress/npm-install
|
|
||||||
if: "${{ inputs.run_preflight_checks }}"
|
|
||||||
run: |
|
|
||||||
npm ci
|
|
||||||
- name: ci/cypress/npm-check
|
|
||||||
if: "${{ inputs.run_preflight_checks }}"
|
|
||||||
run: |
|
|
||||||
npm run check
|
|
||||||
|
|
||||||
playwright-check:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs:
|
|
||||||
- update-initial-status
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: e2e-tests/playwright
|
|
||||||
steps:
|
|
||||||
- name: ci/checkout-repo
|
|
||||||
if: "${{ inputs.run_preflight_checks }}"
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
with:
|
|
||||||
ref: ${{ inputs.commit_sha }}
|
|
||||||
fetch-depth: 0
|
|
||||||
- name: ci/setup-node
|
|
||||||
if: "${{ inputs.run_preflight_checks }}"
|
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
|
||||||
id: setup_node
|
|
||||||
with:
|
|
||||||
node-version-file: ".nvmrc"
|
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: "e2e-tests/playwright/package-lock.json"
|
|
||||||
- name: ci/get-webapp-node-modules
|
|
||||||
if: "${{ inputs.run_preflight_checks }}"
|
|
||||||
working-directory: webapp
|
|
||||||
# requires build of client and types
|
|
||||||
run: |
|
|
||||||
make node_modules
|
|
||||||
- name: ci/playwright/npm-install
|
|
||||||
if: "${{ inputs.run_preflight_checks }}"
|
|
||||||
run: |
|
|
||||||
npm ci
|
|
||||||
- name: ci/playwright/npm-check
|
|
||||||
if: "${{ inputs.run_preflight_checks }}"
|
|
||||||
run: |
|
|
||||||
npm run check
|
|
||||||
|
|
||||||
shell-check:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs:
|
|
||||||
- update-initial-status
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: e2e-tests
|
|
||||||
steps:
|
|
||||||
- name: ci/checkout-repo
|
|
||||||
if: "${{ inputs.run_preflight_checks }}"
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
with:
|
|
||||||
ref: ${{ inputs.commit_sha }}
|
|
||||||
fetch-depth: 0
|
|
||||||
- name: ci/shell-check
|
|
||||||
if: "${{ inputs.run_preflight_checks }}"
|
|
||||||
run: make check-shell
|
|
||||||
|
|
||||||
generate-build-variables:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs:
|
|
||||||
- update-initial-status
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: bash
|
|
||||||
outputs:
|
|
||||||
workers: "${{ steps.generate.outputs.workers }}"
|
|
||||||
node-cache-dependency-path: "${{ steps.generate.outputs.node-cache-dependency-path }}"
|
|
||||||
steps:
|
|
||||||
- name: ci/checkout-repo
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
with:
|
|
||||||
ref: ${{ inputs.commit_sha }}
|
|
||||||
fetch-depth: 0
|
|
||||||
- name: ci/generate-build-variables
|
|
||||||
id: generate
|
|
||||||
env:
|
|
||||||
WORKERS: ${{ inputs.workers_number }}
|
|
||||||
TEST: ${{ inputs.TEST }}
|
|
||||||
run: |
|
|
||||||
[ "$WORKERS" -gt "0" ] # Assert that the workers number is an integer greater than 0
|
|
||||||
echo "workers="$(jq --slurp --compact-output '[range('"$WORKERS"')] | map(tostring)' /dev/null) >> $GITHUB_OUTPUT
|
|
||||||
echo "node-cache-dependency-path=e2e-tests/${TEST}/package-lock.json" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
generate-test-cycle:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs:
|
|
||||||
- generate-build-variables
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: bash
|
|
||||||
working-directory: e2e-tests
|
|
||||||
outputs:
|
|
||||||
status_check_url: "${{ steps.e2e-test-gencycle.outputs.status_check_url }}"
|
|
||||||
steps:
|
|
||||||
- name: ci/checkout-repo
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
with:
|
|
||||||
ref: ${{ inputs.commit_sha }}
|
|
||||||
fetch-depth: 0
|
|
||||||
- name: ci/setup-node
|
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
|
||||||
id: setup_node
|
|
||||||
with:
|
|
||||||
node-version-file: ".nvmrc"
|
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: "e2e-tests/cypress/package-lock.json" # NB: the generate-cycle script is cypress-specific operation for now
|
|
||||||
- name: ci/e2e-test-gencycle
|
|
||||||
id: e2e-test-gencycle
|
|
||||||
env:
|
|
||||||
AUTOMATION_DASHBOARD_URL: "${{ secrets.AUTOMATION_DASHBOARD_URL }}"
|
|
||||||
AUTOMATION_DASHBOARD_TOKEN: "${{ secrets.AUTOMATION_DASHBOARD_TOKEN }}"
|
|
||||||
BRANCH: "${{ inputs.BRANCH }}"
|
|
||||||
BUILD_ID: "${{ inputs.BUILD_ID }}"
|
|
||||||
TEST: "${{ inputs.TEST }}"
|
|
||||||
TEST_FILTER: "${{ inputs.TEST_FILTER }}"
|
|
||||||
run: |
|
|
||||||
set -e -o pipefail
|
|
||||||
make generate-test-cycle | tee generate-test-cycle.out
|
|
||||||
# Extract cycle's dashboard URL, if present
|
|
||||||
TEST_CYCLE_ID=$(sed -nE "s/^.*id: '([^']+)'.*$/\1/p" <generate-test-cycle.out)
|
|
||||||
if [ -n "$TEST_CYCLE_ID" ]; then
|
|
||||||
echo "status_check_url=https://automation-dashboard.vercel.app/cycles/${TEST_CYCLE_ID}" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "status_check_url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
test:
|
|
||||||
continue-on-error: true # Individual runner failures shouldn't prevent the completion of an E2E run
|
|
||||||
strategy:
|
|
||||||
fail-fast: false # Individual runner failures shouldn't prevent the completion of an E2E run
|
|
||||||
matrix:
|
|
||||||
#
|
|
||||||
# Note that E2E tests should be run only on ubuntu, for QA purposes.
|
|
||||||
# But it's useful to be able to run and debug the E2E tests for different OSes.
|
|
||||||
# Notes:
|
|
||||||
# - For MacOS: works on developer machines, but uses too many resources to be able to run on Github Actions
|
|
||||||
# - for Windows: cannot currently run on Github Actions, since the runners do not support running linux containers, at the moment
|
|
||||||
#
|
|
||||||
#os: [ubuntu-latest, windows-2022, macos-12-xl]
|
|
||||||
os: [ubuntu-latest]
|
|
||||||
worker_index: ${{ fromJSON(needs.generate-build-variables.outputs.workers) }} # https://docs.github.com/en/actions/learn-github-actions/expressions#example-returning-a-json-object
|
|
||||||
runs-on: "${{ matrix.os }}"
|
|
||||||
timeout-minutes: 120
|
|
||||||
needs:
|
|
||||||
- cypress-check
|
|
||||||
- playwright-check
|
|
||||||
- shell-check
|
|
||||||
- generate-build-variables
|
|
||||||
- generate-test-cycle
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: bash
|
|
||||||
working-directory: e2e-tests
|
|
||||||
env:
|
|
||||||
AUTOMATION_DASHBOARD_URL: "${{ secrets.AUTOMATION_DASHBOARD_URL }}"
|
|
||||||
AUTOMATION_DASHBOARD_TOKEN: "${{ secrets.AUTOMATION_DASHBOARD_TOKEN }}"
|
|
||||||
SERVER: "${{ inputs.SERVER }}"
|
|
||||||
SERVER_IMAGE: "${{ inputs.SERVER_IMAGE }}"
|
|
||||||
MM_LICENSE: "${{ secrets.MM_LICENSE }}"
|
|
||||||
ENABLED_DOCKER_SERVICES: "${{ inputs.ENABLED_DOCKER_SERVICES }}"
|
|
||||||
TEST: "${{ inputs.TEST }}"
|
|
||||||
TEST_FILTER: "${{ inputs.TEST_FILTER }}"
|
|
||||||
MM_ENV: "${{ inputs.MM_ENV }}"
|
|
||||||
BRANCH: "${{ inputs.BRANCH }}"
|
|
||||||
BUILD_ID: "${{ inputs.BUILD_ID }}"
|
|
||||||
CI_BASE_URL: "${{ matrix.os }}-${{ matrix.worker_index }}"
|
|
||||||
CYPRESS_pushNotificationServer: "${{ secrets.PUSH_NOTIFICATION_SERVER }}"
|
|
||||||
CWS_URL: "${{ secrets.CWS_URL }}"
|
|
||||||
CWS_EXTRA_HTTP_HEADERS: "${{ secrets.CWS_EXTRA_HTTP_HEADERS }}"
|
|
||||||
ROLLING_RELEASE_COMMIT_SHA: "${{ inputs.ROLLING_RELEASE_commit_sha }}"
|
|
||||||
ROLLING_RELEASE_SERVER_IMAGE: "${{ inputs.ROLLING_RELEASE_SERVER_IMAGE }}"
|
|
||||||
steps:
|
|
||||||
- name: ci/checkout-repo
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
with:
|
|
||||||
ref: ${{ inputs.commit_sha }}
|
|
||||||
fetch-depth: 0
|
|
||||||
- name: ci/setup-macos-docker
|
|
||||||
if: runner.os == 'macos'
|
|
||||||
# https://github.com/actions/runner-images/issues/17#issuecomment-1537238473
|
|
||||||
run: |
|
|
||||||
brew install docker docker-compose
|
|
||||||
colima start
|
|
||||||
mkdir -p ~/.docker/cli-plugins
|
|
||||||
ln -sfn /usr/local/opt/docker-compose/bin/docker-compose ~/.docker/cli-plugins/docker-compose
|
|
||||||
sudo ln -sf $HOME/.colima/default/docker.sock /var/run/docker.sock
|
|
||||||
- name: ci/setup-node
|
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
|
||||||
id: setup_node
|
|
||||||
with:
|
|
||||||
node-version-file: ".nvmrc"
|
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: ${{ needs.generate-build-variables.outputs.node-cache-dependency-path }}
|
|
||||||
- name: ci/e2e-test
|
|
||||||
run: |
|
|
||||||
make cloud-init
|
|
||||||
if [ -n "$ROLLING_RELEASE_SERVER_IMAGE" ]; then
|
|
||||||
echo "RollingRelease: checking out E2E test cases from revision ${ROLLING_RELEASE_COMMIT_SHA}, for initial smoketest"
|
|
||||||
git checkout "${ROLLING_RELEASE_COMMIT_SHA}" -- "${TEST}/" && git status
|
|
||||||
(
|
|
||||||
echo "RollingRelease: running initial smoketest against image $ROLLING_RELEASE_SERVER_IMAGE"
|
|
||||||
export SERVER_IMAGE="$ROLLING_RELEASE_SERVER_IMAGE"
|
|
||||||
export TEST_FILTER=""
|
|
||||||
export AUTOMATION_DASHBOARD_URL=""
|
|
||||||
make
|
|
||||||
)
|
|
||||||
echo "RollingRelease: asserting smoketest result has zero failures."
|
|
||||||
FAILURES=$(jq -r '.failed' "${TEST}/results/summary.json")
|
|
||||||
if [ "$FAILURES" -ne "0" ]; then
|
|
||||||
echo "RollingRelease: initial smoketest for rolling release E2E run has nonzero ($FAILURES) failures. Aborting test run." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
rm -rfv "${TEST}/{results,logs}"
|
|
||||||
echo "RollingRelease: reset the E2E test cases to the revision to test"
|
|
||||||
git reset --hard HEAD && git status
|
|
||||||
echo "RollingRelease: smoketest completed. Starting full E2E tests."
|
|
||||||
fi
|
|
||||||
make
|
|
||||||
make cloud-teardown
|
|
||||||
- name: ci/e2e-test-store-results
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
||||||
if: always()
|
|
||||||
with:
|
|
||||||
name: e2e-test-results-${{ inputs.TEST }}-${{ matrix.os }}-${{ matrix.worker_index }}
|
|
||||||
path: |
|
|
||||||
e2e-tests/${{ inputs.TEST }}/logs/
|
|
||||||
e2e-tests/${{ inputs.TEST }}/results/
|
|
||||||
retention-days: 1
|
|
||||||
|
|
||||||
report:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs:
|
|
||||||
- test
|
|
||||||
- generate-build-variables
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: bash
|
|
||||||
working-directory: e2e-tests
|
|
||||||
outputs:
|
|
||||||
passed: "${{ steps.calculate-results.outputs.passed }}"
|
|
||||||
failed: "${{ steps.calculate-results.outputs.failed }}"
|
|
||||||
failed_expected: "${{ steps.calculate-results.outputs.failed_expected }}"
|
|
||||||
pass_rate: "${{ steps.calculate-results.outputs.pass_rate }}"
|
|
||||||
commit_status_message: "${{ steps.calculate-results.outputs.commit_status_message }}"
|
|
||||||
playwright_report_url: "${{ steps.upload-to-s3.outputs.report_url }}"
|
|
||||||
steps:
|
|
||||||
- name: ci/checkout-repo
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
with:
|
|
||||||
ref: ${{ inputs.commit_sha }}
|
|
||||||
fetch-depth: 0
|
|
||||||
- name: ci/download-artifacts
|
|
||||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
|
||||||
with:
|
|
||||||
pattern: e2e-test-results-${{ inputs.TEST }}-*
|
|
||||||
path: e2e-tests/${{ inputs.TEST }}/
|
|
||||||
merge-multiple: true
|
|
||||||
- name: ci/upload-report-global
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
||||||
with:
|
|
||||||
name: e2e-test-results-${{ inputs.TEST }}
|
|
||||||
path: |
|
|
||||||
e2e-tests/${{ inputs.TEST }}/logs/
|
|
||||||
e2e-tests/${{ inputs.TEST }}/results/
|
|
||||||
- name: ci/setup-node
|
|
||||||
if: "${{ inputs.enable_reporting }}"
|
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
|
||||||
id: setup_node
|
|
||||||
with:
|
|
||||||
node-version-file: ".nvmrc"
|
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: ${{ needs.generate-build-variables.outputs.node-cache-dependency-path }}
|
|
||||||
- name: ci/publish-report
|
|
||||||
if: "${{ inputs.enable_reporting }}"
|
|
||||||
env:
|
|
||||||
TYPE: "${{ inputs.REPORT_TYPE }}"
|
|
||||||
TEST: "${{ inputs.TEST }}"
|
|
||||||
SERVER: "${{ inputs.SERVER }}"
|
|
||||||
SERVER_IMAGE: "${{ inputs.SERVER_IMAGE }}"
|
|
||||||
AUTOMATION_DASHBOARD_URL: "${{ secrets.AUTOMATION_DASHBOARD_URL }}"
|
|
||||||
WEBHOOK_URL: "${{ secrets.REPORT_WEBHOOK_URL }}"
|
|
||||||
BRANCH: "${{ inputs.BRANCH }}"
|
|
||||||
BUILD_ID: "${{ inputs.BUILD_ID }}"
|
|
||||||
MM_ENV: "${{ inputs.MM_ENV }}"
|
|
||||||
TM4J_API_KEY: "${{ secrets.REPORT_TM4J_API_KEY }}"
|
|
||||||
TEST_CYCLE_LINK_PREFIX: "${{ secrets.REPORT_TM4J_TEST_CYCLE_LINK_PREFIX }}"
|
|
||||||
run: |
|
|
||||||
make report
|
|
||||||
# The results dir may have been modified as part of the reporting: re-upload
|
|
||||||
- name: ci/upload-report-global
|
|
||||||
if: "${{ inputs.enable_reporting }}"
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
||||||
with:
|
|
||||||
name: e2e-test-results-${{ inputs.TEST }}
|
|
||||||
path: |
|
|
||||||
e2e-tests/${{ inputs.TEST }}/logs/
|
|
||||||
e2e-tests/${{ inputs.TEST }}/results/
|
|
||||||
overwrite: true
|
|
||||||
|
|
||||||
# Configure AWS credentials
|
|
||||||
- name: ci/aws-configure
|
|
||||||
if: (inputs.TEST == 'playwright')
|
|
||||||
uses: aws-actions/configure-aws-credentials@v4.2.0
|
|
||||||
with:
|
|
||||||
aws-region: us-east-1
|
|
||||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
|
||||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
|
||||||
|
|
||||||
# Upload the playwright reports to S3
|
|
||||||
- name: ci/upload-results-to-s3
|
|
||||||
if: (inputs.TEST == 'playwright')
|
|
||||||
id: upload-to-s3
|
|
||||||
run: |
|
|
||||||
echo "🔍 Checking if results directory exists..."
|
|
||||||
|
|
||||||
PR_NUMBER="${{ inputs.PR_NUMBER }}"
|
|
||||||
LOCAL_RESULTS_PATH="${{ inputs.TEST }}/results/"
|
|
||||||
LOCAL_LOGS_PATH="${{ inputs.TEST }}/logs/"
|
|
||||||
RUN_ID="${{ github.run_id }}"
|
|
||||||
S3_PATH="server-pr-${PR_NUMBER}/e2e-reports/${{ inputs.TEST }}/${RUN_ID}"
|
|
||||||
|
|
||||||
echo "📤 Uploading to s3://${AWS_S3_BUCKET}/${S3_PATH}/"
|
|
||||||
|
|
||||||
if [[ -d "$LOCAL_LOGS_PATH" ]]; then
|
|
||||||
aws s3 sync "$LOCAL_LOGS_PATH" "s3://${AWS_S3_BUCKET}/${S3_PATH}/logs/" \
|
|
||||||
--acl public-read \
|
|
||||||
--cache-control "no-cache"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -d "$LOCAL_RESULTS_PATH" ]]; then
|
|
||||||
aws s3 sync "$LOCAL_RESULTS_PATH" "s3://${AWS_S3_BUCKET}/${S3_PATH}/results/" \
|
|
||||||
--acl public-read \
|
|
||||||
--cache-control "no-cache"
|
|
||||||
fi
|
|
||||||
|
|
||||||
REPORT_URL="https://${AWS_S3_BUCKET}.s3.amazonaws.com/${S3_PATH}/results/reporter/index.html"
|
|
||||||
echo "✅ Report uploaded to: $REPORT_URL"
|
|
||||||
|
|
||||||
echo "report_url=$REPORT_URL" >> "$GITHUB_OUTPUT"
|
|
||||||
env:
|
|
||||||
AWS_REGION: us-east-1
|
|
||||||
AWS_S3_BUCKET: mattermost-cypress-report
|
|
||||||
|
|
||||||
- name: ci/report-calculate-results
|
|
||||||
id: calculate-results
|
|
||||||
env:
|
|
||||||
TEST: "${{ inputs.TEST }}"
|
|
||||||
run: |
|
|
||||||
AD_CYCLE_FILE="${TEST}/results/ad_cycle.json"
|
|
||||||
if [ -f "$AD_CYCLE_FILE" ]; then
|
|
||||||
# Prefer using the Automation Dashboard's results to calculate failures
|
|
||||||
export PASSED=$(jq -r .pass "$AD_CYCLE_FILE")
|
|
||||||
export FAILED=$(jq -r .fail "$AD_CYCLE_FILE")
|
|
||||||
export FAILED_EXPECTED=$(jq -r ".known + .flaky + .skipped" "$AD_CYCLE_FILE")
|
|
||||||
else
|
|
||||||
# Otherwise, utilize summary.json to calculate the failures
|
|
||||||
# NB: in this job, this file only makes sense if a single worker is used, as with Playwright
|
|
||||||
export PASSED=$(jq '.passed' "${TEST}/results/summary.json")
|
|
||||||
export FAILED=$(jq '.failed' "${TEST}/results/summary.json")
|
|
||||||
export FAILED_EXPECTED=$(jq '.failed_expected' "${TEST}/results/summary.json")
|
|
||||||
fi
|
|
||||||
export TOTAL_SPECS=$(( PASSED + FAILED ))
|
|
||||||
export PASS_RATE=$(jq -r '100 * (env.PASSED | tonumber) / (env.TOTAL_SPECS | tonumber)' <<<'{}' | xargs -l printf '%.2f')
|
|
||||||
if [ "$FAILED" = "0" ]; then
|
|
||||||
export COMMIT_STATUS_MESSAGE="All test cases passed"
|
|
||||||
else
|
|
||||||
export COMMIT_STATUS_MESSAGE="${FAILED} test cases failed. Please check the workflow logs"
|
|
||||||
fi
|
|
||||||
echo "passed=${PASSED:?}" >> $GITHUB_OUTPUT
|
|
||||||
echo "failed=${FAILED:?}" >> $GITHUB_OUTPUT
|
|
||||||
echo "failed_expected=${FAILED_EXPECTED:?}" >> $GITHUB_OUTPUT
|
|
||||||
echo "pass_rate=${PASS_RATE:?}%" >> $GITHUB_OUTPUT
|
|
||||||
echo "commit_status_message=${COMMIT_STATUS_MESSAGE:?}" >> $GITHUB_OUTPUT
|
|
||||||
echo "$COMMIT_STATUS_MESSAGE"
|
|
||||||
- name: ci/e2e-test-assert-results
|
|
||||||
if: "${{ inputs.testcase_failure_fatal }}"
|
|
||||||
run: |
|
|
||||||
# Assert that the run contained 0 failures
|
|
||||||
[ "${{ steps.calculate-results.outputs.failed }}" = "0" ]
|
|
||||||
|
|
||||||
update-failure-final-status:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: failure() || cancelled()
|
|
||||||
needs:
|
|
||||||
- generate-test-cycle
|
|
||||||
- test
|
|
||||||
- report
|
|
||||||
steps:
|
|
||||||
- uses: mattermost/actions/delivery/update-commit-status@main
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
|
||||||
with:
|
|
||||||
repository_full_name: ${{ github.repository }}
|
|
||||||
commit_sha: ${{ inputs.commit_sha }}
|
|
||||||
context: ${{ inputs.status_check_context }}
|
|
||||||
description: ${{ needs.report.outputs.commit_status_message || 'Error during test execution' }}
|
|
||||||
status: failure
|
|
||||||
target_url: >-
|
|
||||||
${{ inputs.TEST == 'playwright'
|
|
||||||
&& needs.report.outputs.playwright_report_url
|
|
||||||
|| needs.generate-test-cycle.outputs.status_check_url }}
|
|
||||||
|
|
||||||
|
|
||||||
update-success-final-status:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: success()
|
|
||||||
needs:
|
|
||||||
- generate-test-cycle
|
|
||||||
- test
|
|
||||||
- report
|
|
||||||
steps:
|
|
||||||
- uses: mattermost/actions/delivery/update-commit-status@main
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
|
||||||
with:
|
|
||||||
repository_full_name: ${{ github.repository }}
|
|
||||||
commit_sha: ${{ inputs.commit_sha }}
|
|
||||||
context: ${{ inputs.status_check_context }}
|
|
||||||
description: ${{ needs.report.outputs.commit_status_message || 'Error during test execution' }}
|
|
||||||
status: success
|
|
||||||
target_url: >-
|
|
||||||
${{ inputs.TEST == 'playwright'
|
|
||||||
&& needs.report.outputs.playwright_report_url
|
|
||||||
|| needs.generate-test-cycle.outputs.status_check_url }}
|
|
||||||
5
.github/workflows/e2e-tests-ci.yml
поставляемый
5
.github/workflows/e2e-tests-ci.yml
поставляемый
@@ -1,11 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: E2E Tests (pull request)
|
name: E2E Tests (pull request)
|
||||||
on:
|
on:
|
||||||
pull_request:
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
- synchronize
|
|
||||||
- reopened
|
|
||||||
# Argo Events Trigger (automated):
|
# Argo Events Trigger (automated):
|
||||||
# - Triggered by: Enterprise CI/docker-image status check (success)
|
# - Triggered by: Enterprise CI/docker-image status check (success)
|
||||||
# - Payload: { ref: "<branch>", inputs: { commit_sha: "<sha>" } }
|
# - Payload: { ref: "<branch>", inputs: { commit_sha: "<sha>" } }
|
||||||
|
|||||||
12
.github/workflows/e2e-tests-playwright-template.yml
поставляемый
12
.github/workflows/e2e-tests-playwright-template.yml
поставляемый
@@ -228,8 +228,16 @@ jobs:
|
|||||||
mkdir -p results/reporter
|
mkdir -p results/reporter
|
||||||
|
|
||||||
# Merge blob reports using Playwright merge-reports (per docs)
|
# Merge blob reports using Playwright merge-reports (per docs)
|
||||||
npm install --no-save @playwright/test
|
# Pin version to match the one used to generate blob reports (avoids format mismatch)
|
||||||
npx playwright merge-reports --config merge.config.mjs ./shard-results/results/blob-report/
|
PW_VERSION=$(node -e "const p=require('./package.json');process.stdout.write(p.dependencies?.['@playwright/test']||p.devDependencies?.['@playwright/test']||'')")
|
||||||
|
npm install --no-save @playwright/test@${PW_VERSION}
|
||||||
|
npx playwright merge-reports --config merge.config.mjs ./shard-results/results/blob-report/ || true
|
||||||
|
|
||||||
|
# If merge-reports produced no results.json (no blob reports), fall back to
|
||||||
|
# the json reporter output written directly by the test shard
|
||||||
|
if [ ! -f results/reporter/results.json ] && [ -f shard-results/results/reporter/results.json ]; then
|
||||||
|
cp shard-results/results/reporter/results.json results/reporter/results.json
|
||||||
|
fi
|
||||||
- name: ci/calculate
|
- name: ci/calculate
|
||||||
id: calculate
|
id: calculate
|
||||||
uses: ./.github/actions/calculate-playwright-results
|
uses: ./.github/actions/calculate-playwright-results
|
||||||
|
|||||||
101
e2e-tests/.ci/server.run_specs.sh
Исполняемый файл
101
e2e-tests/.ci/server.run_specs.sh
Исполняемый файл
@@ -0,0 +1,101 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# shellcheck disable=SC2038
|
||||||
|
# Run specific spec files
|
||||||
|
# Usage: SPEC_FILES="path/to/spec1.ts,path/to/spec2.ts" make start-server run-specs
|
||||||
|
|
||||||
|
set -e -u -o pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
. .e2erc
|
||||||
|
|
||||||
|
if [ -z "${SPEC_FILES:-}" ]; then
|
||||||
|
mme2e_log "Error: SPEC_FILES environment variable is required"
|
||||||
|
mme2e_log "Usage: SPEC_FILES=\"path/to/spec.ts\" make start-server run-specs"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mme2e_log "Running spec files: $SPEC_FILES"
|
||||||
|
|
||||||
|
case $TEST in
|
||||||
|
cypress)
|
||||||
|
mme2e_log "Running Cypress with specified specs"
|
||||||
|
# Initialize cypress report directory
|
||||||
|
${MME2E_DC_SERVER} exec -T -u "$MME2E_UID" -- cypress bash <<EOF
|
||||||
|
rm -rf logs results
|
||||||
|
mkdir -p logs
|
||||||
|
mkdir -p results/junit
|
||||||
|
mkdir -p results/mochawesome-report/json/tests
|
||||||
|
touch results/junit/empty.xml
|
||||||
|
echo '<?xml version="1.0" encoding="UTF-8"?>' > results/junit/empty.xml
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Run cypress with specific spec files and mochawesome reporter
|
||||||
|
LOGFILE_SUFFIX="${CI_BASE_URL//\//_}_specs"
|
||||||
|
${MME2E_DC_SERVER} exec -T -u "$MME2E_UID" -- cypress npx cypress run \
|
||||||
|
--spec "$SPEC_FILES" \
|
||||||
|
--reporter cypress-multi-reporters \
|
||||||
|
--reporter-options configFile=reporter-config.json \
|
||||||
|
| tee "../cypress/logs/${LOGFILE_SUFFIX}_cypress.log" || true
|
||||||
|
|
||||||
|
# Collect run results
|
||||||
|
if [ -d ../cypress/results/mochawesome-report/json/tests/ ]; then
|
||||||
|
cat >../cypress/results/summary.json <<EOF
|
||||||
|
{
|
||||||
|
"passed": $(find ../cypress/results/mochawesome-report/json/tests/ -name '*.json' | xargs -l jq -r '.stats.passes' | jq -s add),
|
||||||
|
"failed": $(find ../cypress/results/mochawesome-report/json/tests/ -name '*.json' | xargs -l jq -r '.stats.failures' | jq -s add),
|
||||||
|
"failed_expected": 0
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect server logs
|
||||||
|
${MME2E_DC_SERVER} logs --no-log-prefix -- server >"../cypress/logs/${LOGFILE_SUFFIX}_mattermost.log" 2>&1
|
||||||
|
;;
|
||||||
|
playwright)
|
||||||
|
mme2e_log "Running Playwright with specified specs"
|
||||||
|
# Convert comma-separated to space-separated for playwright
|
||||||
|
SPEC_ARGS=$(echo "$SPEC_FILES" | tr ',' ' ')
|
||||||
|
|
||||||
|
# Initialize playwright report and logs directory
|
||||||
|
${MME2E_DC_SERVER} exec -T -u "$MME2E_UID" -- playwright bash <<EOF
|
||||||
|
cd e2e-tests/playwright
|
||||||
|
rm -rf logs results storage_state
|
||||||
|
mkdir -p logs results
|
||||||
|
touch logs/mattermost.log
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
mme2e_log "Prepare Playwright: install dependencies"
|
||||||
|
${MME2E_DC_SERVER} exec -T -u "$MME2E_UID" -- playwright bash <<EOF
|
||||||
|
cd webapp/
|
||||||
|
npm install --cache /tmp/empty-cache
|
||||||
|
cd ../e2e-tests/playwright
|
||||||
|
npm install --cache /tmp/empty-cache
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Run playwright with specific spec files
|
||||||
|
LOGFILE_SUFFIX="${CI_BASE_URL//\//_}_specs"
|
||||||
|
${MME2E_DC_SERVER} exec -T -u "$MME2E_UID" -- playwright bash -c "cd e2e-tests/playwright && npm run test:ci -- $SPEC_ARGS" | tee "../playwright/logs/${LOGFILE_SUFFIX}_playwright.log" || true
|
||||||
|
|
||||||
|
# Collect run results (if results.json exists)
|
||||||
|
if [ -f ../playwright/results/reporter/results.json ]; then
|
||||||
|
jq -f /dev/stdin ../playwright/results/reporter/results.json >../playwright/results/summary.json <<EOF
|
||||||
|
{
|
||||||
|
passed: .stats.expected,
|
||||||
|
failed: .stats.unexpected,
|
||||||
|
failed_expected: (.stats.skipped + .stats.flaky)
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
mme2e_log "Results file found and summary generated"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect server logs
|
||||||
|
${MME2E_DC_SERVER} logs --no-log-prefix -- server >"../playwright/logs/${LOGFILE_SUFFIX}_mattermost.log" 2>&1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
mme2e_log "Error, unsupported value for TEST: $TEST" >&2
|
||||||
|
mme2e_log "Aborting" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
mme2e_log "Spec run complete"
|
||||||
@@ -9,7 +9,7 @@ clean:
|
|||||||
rm -fv .ci/server.yml
|
rm -fv .ci/server.yml
|
||||||
rm -fv .ci/.env.{server,dashboard,cypress,playwright}
|
rm -fv .ci/.env.{server,dashboard,cypress,playwright}
|
||||||
|
|
||||||
.PHONY: generate-server start-server run-test stop-server restart-server
|
.PHONY: generate-server start-server run-test run-specs stop-server restart-server
|
||||||
generate-server:
|
generate-server:
|
||||||
bash ./.ci/server.generate.sh
|
bash ./.ci/server.generate.sh
|
||||||
start-server: generate-server
|
start-server: generate-server
|
||||||
@@ -17,6 +17,8 @@ start-server: generate-server
|
|||||||
bash ./.ci/server.prepare.sh
|
bash ./.ci/server.prepare.sh
|
||||||
run-test:
|
run-test:
|
||||||
bash ./.ci/server.run_test.sh
|
bash ./.ci/server.run_test.sh
|
||||||
|
run-specs:
|
||||||
|
bash ./.ci/server.run_specs.sh
|
||||||
stop-server: generate-server
|
stop-server: generate-server
|
||||||
bash ./.ci/server.stop.sh
|
bash ./.ci/server.stop.sh
|
||||||
restart-server: stop-server start-server
|
restart-server: stop-server start-server
|
||||||
|
|||||||
13
e2e-tests/playwright/merge.config.mjs
Обычный файл
13
e2e-tests/playwright/merge.config.mjs
Обычный файл
@@ -0,0 +1,13 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
// Configuration for merging sharded blob reports via:
|
||||||
|
// npx playwright merge-reports --config merge.config.mjs ./all-blob-reports/
|
||||||
|
|
||||||
|
export default {
|
||||||
|
reporter: [
|
||||||
|
['html', {open: 'never', outputFolder: './results/reporter'}],
|
||||||
|
['json', {outputFile: './results/reporter/results.json'}],
|
||||||
|
['junit', {outputFile: './results/reporter/results.xml'}],
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -977,6 +978,14 @@ func searchPosts(c *Context, w http.ResponseWriter, r *http.Request, teamId stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func postEditTimeLimitExpired(cfg *model.Config, post *model.Post) bool {
|
||||||
|
limit := *cfg.ServiceSettings.PostEditTimeLimit
|
||||||
|
if limit == -1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return model.GetMillis() > post.CreateAt+int64(limit)*1000
|
||||||
|
}
|
||||||
|
|
||||||
func updatePost(c *Context, w http.ResponseWriter, r *http.Request) {
|
func updatePost(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||||
c.RequirePostId()
|
c.RequirePostId()
|
||||||
if c.Err != nil {
|
if c.Err != nil {
|
||||||
@@ -1035,6 +1044,21 @@ func updatePost(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||||||
post.FileIds = originalPost.FileIds
|
post.FileIds = originalPost.FileIds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// passing nil props should not have any effect on a post's props
|
||||||
|
// so, we restore the original props in this case
|
||||||
|
if post.Props == nil {
|
||||||
|
post.Props = originalPost.Props
|
||||||
|
}
|
||||||
|
|
||||||
|
if postEditTimeLimitExpired(c.App.Config(), originalPost) &&
|
||||||
|
(post.Message != originalPost.Message ||
|
||||||
|
!slices.Equal(post.FileIds, originalPost.FileIds) ||
|
||||||
|
model.StringInterfaceToJSON(post.GetProps()) != model.StringInterfaceToJSON(originalPost.GetProps()) ||
|
||||||
|
post.IsPinned != originalPost.IsPinned) {
|
||||||
|
c.Err = model.NewAppError("UpdatePost", "api.post.update_post.permissions_time_limit.app_error", map[string]any{"timeLimit": *c.App.Config().ServiceSettings.PostEditTimeLimit}, "", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Check upload_file permission only if update is adding NEW files (not just keeping existing ones)
|
// Check upload_file permission only if update is adding NEW files (not just keeping existing ones)
|
||||||
checkUploadFilePermissionForNewFiles(c, post.FileIds, originalPost)
|
checkUploadFilePermissionForNewFiles(c, post.FileIds, originalPost)
|
||||||
if c.Err != nil {
|
if c.Err != nil {
|
||||||
@@ -1051,11 +1075,6 @@ func updatePost(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
post.Id = c.Params.PostId
|
post.Id = c.Params.PostId
|
||||||
|
|
||||||
if *c.App.Config().ServiceSettings.PostEditTimeLimit != -1 && model.GetMillis() > originalPost.CreateAt+int64(*c.App.Config().ServiceSettings.PostEditTimeLimit*1000) && post.Message != originalPost.Message {
|
|
||||||
c.Err = model.NewAppError("UpdatePost", "api.post.update_post.permissions_time_limit.app_error", map[string]any{"timeLimit": *c.App.Config().ServiceSettings.PostEditTimeLimit}, "", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rpost, isMemberForPreviews, err := c.App.UpdatePost(c.AppContext, c.App.PostWithProxyRemovedFromImageURLs(&post), &model.UpdatePostOptions{SafeUpdate: false})
|
rpost, isMemberForPreviews, err := c.App.UpdatePost(c.AppContext, c.App.PostWithProxyRemovedFromImageURLs(&post), &model.UpdatePostOptions{SafeUpdate: false})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Err = err
|
c.Err = err
|
||||||
@@ -1104,7 +1123,7 @@ func patchPost(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isMember := postPatchChecks(c, auditRec, post.Message)
|
isMember := postPatchChecks(c, auditRec, &post)
|
||||||
if c.Err != nil {
|
if c.Err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1140,7 +1159,7 @@ func patchPost(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func postPatchChecks(c *Context, auditRec *model.AuditRecord, message *string) bool {
|
func postPatchChecks(c *Context, auditRec *model.AuditRecord, patch *model.PostPatch) bool {
|
||||||
originalPost, err := c.App.GetSinglePost(c.AppContext, c.Params.PostId, false)
|
originalPost, err := c.App.GetSinglePost(c.AppContext, c.Params.PostId, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.SetPermissionError(model.PermissionEditPost)
|
c.SetPermissionError(model.PermissionEditPost)
|
||||||
@@ -1169,7 +1188,7 @@ func postPatchChecks(c *Context, auditRec *model.AuditRecord, message *string) b
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if *c.App.Config().ServiceSettings.PostEditTimeLimit != -1 && model.GetMillis() > originalPost.CreateAt+int64(*c.App.Config().ServiceSettings.PostEditTimeLimit*1000) && message != nil {
|
if postEditTimeLimitExpired(c.App.Config(), originalPost) && !patch.IsEmpty() {
|
||||||
c.Err = model.NewAppError("patchPost", "api.post.update_post.permissions_time_limit.app_error", map[string]any{"timeLimit": *c.App.Config().ServiceSettings.PostEditTimeLimit}, "", http.StatusBadRequest)
|
c.Err = model.NewAppError("patchPost", "api.post.update_post.permissions_time_limit.app_error", map[string]any{"timeLimit": *c.App.Config().ServiceSettings.PostEditTimeLimit}, "", http.StatusBadRequest)
|
||||||
return isMember
|
return isMember
|
||||||
}
|
}
|
||||||
@@ -1264,6 +1283,18 @@ func saveIsPinnedPost(c *Context, w http.ResponseWriter, isPinned bool) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Allow no-op requests (e.g. pinning an already-pinned post) regardless of age.
|
||||||
|
if post.IsPinned == isPinned {
|
||||||
|
auditRec.Success()
|
||||||
|
ReturnStatusOK(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if postEditTimeLimitExpired(c.App.Config(), post) {
|
||||||
|
c.Err = model.NewAppError("saveIsPinnedPost", "api.post.update_post.permissions_time_limit.app_error", map[string]any{"timeLimit": *c.App.Config().ServiceSettings.PostEditTimeLimit}, "", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
patch := &model.PostPatch{}
|
patch := &model.PostPatch{}
|
||||||
patch.IsPinned = model.NewPointer(isPinned)
|
patch.IsPinned = model.NewPointer(isPinned)
|
||||||
|
|
||||||
@@ -1557,7 +1588,7 @@ func restorePostVersion(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isMember := postPatchChecks(c, auditRec, &toRestorePost.Message)
|
isMember := postPatchChecks(c, auditRec, &model.PostPatch{Message: &toRestorePost.Message, FileIds: &toRestorePost.FileIds})
|
||||||
if c.Err != nil {
|
if c.Err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1565,6 +1565,94 @@ func TestUpdatePost(t *testing.T) {
|
|||||||
CheckBadRequestStatus(t, resp)
|
CheckBadRequestStatus(t, resp)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("change file ids but not message, post too old", func(t *testing.T) {
|
||||||
|
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||||
|
*cfg.ServiceSettings.PostEditTimeLimit = 1
|
||||||
|
})
|
||||||
|
defer th.App.UpdateConfig(func(cfg *model.Config) {
|
||||||
|
*cfg.ServiceSettings.PostEditTimeLimit = -1
|
||||||
|
})
|
||||||
|
|
||||||
|
fileResp, _, err := client.UploadFile(context.Background(), data, channel.Id, "test.png")
|
||||||
|
require.NoError(t, err)
|
||||||
|
newFileId := fileResp.FileInfos[0].Id
|
||||||
|
|
||||||
|
oldPost, _, appErr := th.App.CreatePost(th.Context, &model.Post{
|
||||||
|
ChannelId: channel.Id,
|
||||||
|
Message: "original message",
|
||||||
|
UserId: th.BasicUser.Id,
|
||||||
|
CreateAt: model.GetMillis() - 2000,
|
||||||
|
}, channel, model.CreatePostFlags{SetOnline: true})
|
||||||
|
require.Nil(t, appErr)
|
||||||
|
|
||||||
|
up := &model.Post{
|
||||||
|
Id: oldPost.Id,
|
||||||
|
ChannelId: channel.Id,
|
||||||
|
Message: oldPost.Message,
|
||||||
|
FileIds: model.StringArray{newFileId},
|
||||||
|
}
|
||||||
|
_, resp, err := client.UpdatePost(context.Background(), oldPost.Id, up)
|
||||||
|
require.Error(t, err)
|
||||||
|
CheckBadRequestStatus(t, resp)
|
||||||
|
require.Equal(t, "api.post.update_post.permissions_time_limit.app_error", err.(*model.AppError).Id)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("change props but not message, post too old", func(t *testing.T) {
|
||||||
|
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||||
|
*cfg.ServiceSettings.PostEditTimeLimit = 1
|
||||||
|
})
|
||||||
|
defer th.App.UpdateConfig(func(cfg *model.Config) {
|
||||||
|
*cfg.ServiceSettings.PostEditTimeLimit = -1
|
||||||
|
})
|
||||||
|
|
||||||
|
oldPost, _, appErr := th.App.CreatePost(th.Context, &model.Post{
|
||||||
|
ChannelId: channel.Id,
|
||||||
|
Message: "original message",
|
||||||
|
UserId: th.BasicUser.Id,
|
||||||
|
CreateAt: model.GetMillis() - 2000,
|
||||||
|
}, channel, model.CreatePostFlags{SetOnline: true})
|
||||||
|
require.Nil(t, appErr)
|
||||||
|
|
||||||
|
up := &model.Post{
|
||||||
|
Id: oldPost.Id,
|
||||||
|
ChannelId: channel.Id,
|
||||||
|
Message: oldPost.Message,
|
||||||
|
Props: model.StringInterface{"channel_header": "injected"},
|
||||||
|
}
|
||||||
|
_, resp, err := client.UpdatePost(context.Background(), oldPost.Id, up)
|
||||||
|
require.Error(t, err)
|
||||||
|
CheckBadRequestStatus(t, resp)
|
||||||
|
require.Equal(t, "api.post.update_post.permissions_time_limit.app_error", err.(*model.AppError).Id)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("change is_pinned but not message, post too old", func(t *testing.T) {
|
||||||
|
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||||
|
*cfg.ServiceSettings.PostEditTimeLimit = 1
|
||||||
|
})
|
||||||
|
defer th.App.UpdateConfig(func(cfg *model.Config) {
|
||||||
|
*cfg.ServiceSettings.PostEditTimeLimit = -1
|
||||||
|
})
|
||||||
|
|
||||||
|
oldPost, _, appErr := th.App.CreatePost(th.Context, &model.Post{
|
||||||
|
ChannelId: channel.Id,
|
||||||
|
Message: "original message",
|
||||||
|
UserId: th.BasicUser.Id,
|
||||||
|
CreateAt: model.GetMillis() - 2000,
|
||||||
|
}, channel, model.CreatePostFlags{SetOnline: true})
|
||||||
|
require.Nil(t, appErr)
|
||||||
|
|
||||||
|
up := &model.Post{
|
||||||
|
Id: oldPost.Id,
|
||||||
|
ChannelId: channel.Id,
|
||||||
|
Message: oldPost.Message,
|
||||||
|
IsPinned: true,
|
||||||
|
}
|
||||||
|
_, resp, err := client.UpdatePost(context.Background(), oldPost.Id, up)
|
||||||
|
require.Error(t, err)
|
||||||
|
CheckBadRequestStatus(t, resp)
|
||||||
|
require.Equal(t, "api.post.update_post.permissions_time_limit.app_error", err.(*model.AppError).Id)
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("err with integrations-reserved props", func(t *testing.T) {
|
t.Run("err with integrations-reserved props", func(t *testing.T) {
|
||||||
originalHardenedModeSetting := *th.App.Config().ServiceSettings.ExperimentalEnableHardenedMode
|
originalHardenedModeSetting := *th.App.Config().ServiceSettings.ExperimentalEnableHardenedMode
|
||||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||||
@@ -2096,6 +2184,110 @@ func TestPatchPost(t *testing.T) {
|
|||||||
require.Equal(t, "api.post.update_post.permissions_time_limit.app_error", err.(*model.AppError).Id, "should be time limit error")
|
require.Equal(t, "api.post.update_post.permissions_time_limit.app_error", err.(*model.AppError).Id, "should be time limit error")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("patch file ids only, time limit expired", func(t *testing.T) {
|
||||||
|
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||||
|
*cfg.ServiceSettings.PostEditTimeLimit = 1
|
||||||
|
})
|
||||||
|
defer th.App.UpdateConfig(func(cfg *model.Config) {
|
||||||
|
*cfg.ServiceSettings.PostEditTimeLimit = -1
|
||||||
|
})
|
||||||
|
|
||||||
|
fileResp, _, err := th.SystemAdminClient.UploadFile(context.Background(), data, channel.Id, "test.png")
|
||||||
|
require.NoError(t, err)
|
||||||
|
newFileId := fileResp.FileInfos[0].Id
|
||||||
|
|
||||||
|
oldPost := &model.Post{
|
||||||
|
ChannelId: channel.Id,
|
||||||
|
Message: "original message",
|
||||||
|
CreateAt: model.GetMillis() - 2000,
|
||||||
|
}
|
||||||
|
oldPost, _, err = th.SystemAdminClient.CreatePost(context.Background(), oldPost)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
patch := &model.PostPatch{
|
||||||
|
FileIds: &model.StringArray{newFileId},
|
||||||
|
}
|
||||||
|
_, resp, err := th.SystemAdminClient.PatchPost(context.Background(), oldPost.Id, patch)
|
||||||
|
require.Error(t, err)
|
||||||
|
CheckBadRequestStatus(t, resp)
|
||||||
|
require.Equal(t, "api.post.update_post.permissions_time_limit.app_error", err.(*model.AppError).Id)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("patch props only, time limit expired", func(t *testing.T) {
|
||||||
|
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||||
|
*cfg.ServiceSettings.PostEditTimeLimit = 1
|
||||||
|
})
|
||||||
|
defer th.App.UpdateConfig(func(cfg *model.Config) {
|
||||||
|
*cfg.ServiceSettings.PostEditTimeLimit = -1
|
||||||
|
})
|
||||||
|
|
||||||
|
oldPost := &model.Post{
|
||||||
|
ChannelId: channel.Id,
|
||||||
|
Message: "original message",
|
||||||
|
CreateAt: model.GetMillis() - 2000,
|
||||||
|
}
|
||||||
|
oldPost, _, err := th.SystemAdminClient.CreatePost(context.Background(), oldPost)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
patch := &model.PostPatch{
|
||||||
|
Props: &model.StringInterface{"channel_header": "injected"},
|
||||||
|
}
|
||||||
|
_, resp, err := th.SystemAdminClient.PatchPost(context.Background(), oldPost.Id, patch)
|
||||||
|
require.Error(t, err)
|
||||||
|
CheckBadRequestStatus(t, resp)
|
||||||
|
require.Equal(t, "api.post.update_post.permissions_time_limit.app_error", err.(*model.AppError).Id)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("patch is_pinned only, time limit expired", func(t *testing.T) {
|
||||||
|
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||||
|
*cfg.ServiceSettings.PostEditTimeLimit = 1
|
||||||
|
})
|
||||||
|
defer th.App.UpdateConfig(func(cfg *model.Config) {
|
||||||
|
*cfg.ServiceSettings.PostEditTimeLimit = -1
|
||||||
|
})
|
||||||
|
|
||||||
|
oldPost := &model.Post{
|
||||||
|
ChannelId: channel.Id,
|
||||||
|
Message: "original message",
|
||||||
|
CreateAt: model.GetMillis() - 2000,
|
||||||
|
}
|
||||||
|
oldPost, _, err := th.SystemAdminClient.CreatePost(context.Background(), oldPost)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
patch := &model.PostPatch{
|
||||||
|
IsPinned: model.NewPointer(true),
|
||||||
|
}
|
||||||
|
_, resp, err := th.SystemAdminClient.PatchPost(context.Background(), oldPost.Id, patch)
|
||||||
|
require.Error(t, err)
|
||||||
|
CheckBadRequestStatus(t, resp)
|
||||||
|
require.Equal(t, "api.post.update_post.permissions_time_limit.app_error", err.(*model.AppError).Id)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("patch has_reactions only, time limit expired", func(t *testing.T) {
|
||||||
|
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||||
|
*cfg.ServiceSettings.PostEditTimeLimit = 1
|
||||||
|
})
|
||||||
|
defer th.App.UpdateConfig(func(cfg *model.Config) {
|
||||||
|
*cfg.ServiceSettings.PostEditTimeLimit = -1
|
||||||
|
})
|
||||||
|
|
||||||
|
oldPost := &model.Post{
|
||||||
|
ChannelId: channel.Id,
|
||||||
|
Message: "original message",
|
||||||
|
CreateAt: model.GetMillis() - 2000,
|
||||||
|
}
|
||||||
|
oldPost, _, err := th.SystemAdminClient.CreatePost(context.Background(), oldPost)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
patch := &model.PostPatch{
|
||||||
|
HasReactions: model.NewPointer(true),
|
||||||
|
}
|
||||||
|
_, resp, err := th.SystemAdminClient.PatchPost(context.Background(), oldPost.Id, patch)
|
||||||
|
require.Error(t, err)
|
||||||
|
CheckBadRequestStatus(t, resp)
|
||||||
|
require.Equal(t, "api.post.update_post.permissions_time_limit.app_error", err.(*model.AppError).Id)
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("err with integrations-reserved props", func(t *testing.T) {
|
t.Run("err with integrations-reserved props", func(t *testing.T) {
|
||||||
originalHardenedModeSetting := *th.App.Config().ServiceSettings.ExperimentalEnableHardenedMode
|
originalHardenedModeSetting := *th.App.Config().ServiceSettings.ExperimentalEnableHardenedMode
|
||||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||||
@@ -2259,6 +2451,61 @@ func TestPinPost(t *testing.T) {
|
|||||||
defer th.TearDown()
|
defer th.TearDown()
|
||||||
client := th.Client
|
client := th.Client
|
||||||
|
|
||||||
|
t.Run("pin post after time limit", func(t *testing.T) {
|
||||||
|
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||||
|
*cfg.ServiceSettings.PostEditTimeLimit = 1
|
||||||
|
})
|
||||||
|
defer th.App.UpdateConfig(func(cfg *model.Config) {
|
||||||
|
*cfg.ServiceSettings.PostEditTimeLimit = -1
|
||||||
|
})
|
||||||
|
|
||||||
|
oldPost, _, appErr := th.App.CreatePost(th.Context, &model.Post{
|
||||||
|
ChannelId: th.BasicChannel.Id,
|
||||||
|
Message: "original message",
|
||||||
|
UserId: th.BasicUser.Id,
|
||||||
|
CreateAt: model.GetMillis() - 2000,
|
||||||
|
}, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
|
||||||
|
require.Nil(t, appErr)
|
||||||
|
|
||||||
|
resp, err := client.PinPost(context.Background(), oldPost.Id)
|
||||||
|
require.Error(t, err)
|
||||||
|
CheckBadRequestStatus(t, resp)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("idempotent pin/unpin after time limit", func(t *testing.T) {
|
||||||
|
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||||
|
*cfg.ServiceSettings.PostEditTimeLimit = 1
|
||||||
|
})
|
||||||
|
defer th.App.UpdateConfig(func(cfg *model.Config) {
|
||||||
|
*cfg.ServiceSettings.PostEditTimeLimit = -1
|
||||||
|
})
|
||||||
|
|
||||||
|
pinnedPost, _, appErr := th.App.CreatePost(th.Context, &model.Post{
|
||||||
|
ChannelId: th.BasicChannel.Id,
|
||||||
|
Message: "original message",
|
||||||
|
UserId: th.BasicUser.Id,
|
||||||
|
IsPinned: true,
|
||||||
|
CreateAt: model.GetMillis() - 2000,
|
||||||
|
}, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
|
||||||
|
require.Nil(t, appErr)
|
||||||
|
|
||||||
|
unpinnedPost, _, appErr := th.App.CreatePost(th.Context, &model.Post{
|
||||||
|
ChannelId: th.BasicChannel.Id,
|
||||||
|
Message: "original message",
|
||||||
|
UserId: th.BasicUser.Id,
|
||||||
|
IsPinned: false,
|
||||||
|
CreateAt: model.GetMillis() - 2000,
|
||||||
|
}, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
|
||||||
|
require.Nil(t, appErr)
|
||||||
|
|
||||||
|
// Both are no-ops and must succeed regardless of age.
|
||||||
|
_, err := client.PinPost(context.Background(), pinnedPost.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = client.UnpinPost(context.Background(), unpinnedPost.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
post := th.BasicPost
|
post := th.BasicPost
|
||||||
_, err := client.PinPost(context.Background(), post.Id)
|
_, err := client.PinPost(context.Background(), post.Id)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -2292,6 +2539,28 @@ func TestUnpinPost(t *testing.T) {
|
|||||||
defer th.TearDown()
|
defer th.TearDown()
|
||||||
client := th.Client
|
client := th.Client
|
||||||
|
|
||||||
|
t.Run("unpin post after time limit", func(t *testing.T) {
|
||||||
|
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||||
|
*cfg.ServiceSettings.PostEditTimeLimit = 1
|
||||||
|
})
|
||||||
|
defer th.App.UpdateConfig(func(cfg *model.Config) {
|
||||||
|
*cfg.ServiceSettings.PostEditTimeLimit = -1
|
||||||
|
})
|
||||||
|
|
||||||
|
oldPost, _, appErr := th.App.CreatePost(th.Context, &model.Post{
|
||||||
|
ChannelId: th.BasicChannel.Id,
|
||||||
|
Message: "original message",
|
||||||
|
UserId: th.BasicUser.Id,
|
||||||
|
IsPinned: true,
|
||||||
|
CreateAt: model.GetMillis() - 2000,
|
||||||
|
}, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
|
||||||
|
require.Nil(t, appErr)
|
||||||
|
|
||||||
|
resp, err := client.UnpinPost(context.Background(), oldPost.Id)
|
||||||
|
require.Error(t, err)
|
||||||
|
CheckBadRequestStatus(t, resp)
|
||||||
|
})
|
||||||
|
|
||||||
pinnedPost := th.CreatePinnedPost()
|
pinnedPost := th.CreatePinnedPost()
|
||||||
_, err := client.UnpinPost(context.Background(), pinnedPost.Id)
|
_, err := client.UnpinPost(context.Background(), pinnedPost.Id)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -5700,4 +5969,85 @@ func TestRestorePostVersion(t *testing.T) {
|
|||||||
CheckForbiddenStatus(t, response)
|
CheckForbiddenStatus(t, response)
|
||||||
require.Nil(t, restoredPost)
|
require.Nil(t, restoredPost)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("restore post version blocked when time limit expired", func(t *testing.T) {
|
||||||
|
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||||
|
*cfg.ServiceSettings.PostEditTimeLimit = 1
|
||||||
|
})
|
||||||
|
defer th.App.UpdateConfig(func(cfg *model.Config) {
|
||||||
|
*cfg.ServiceSettings.PostEditTimeLimit = -1
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create post in the past via app layer (bypasses API time limit)
|
||||||
|
oldPost, _, appErr := th.App.CreatePost(th.Context, &model.Post{
|
||||||
|
ChannelId: th.BasicChannel.Id,
|
||||||
|
Message: "original message",
|
||||||
|
UserId: th.BasicUser.Id,
|
||||||
|
CreateAt: model.GetMillis() - 2000,
|
||||||
|
}, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
|
||||||
|
require.Nil(t, appErr)
|
||||||
|
|
||||||
|
// Patch via app layer to create edit history (bypasses API time limit check)
|
||||||
|
_, _, appErr = th.App.PatchPost(th.Context, oldPost.Id, &model.PostPatch{
|
||||||
|
Message: model.NewPointer("edited message"),
|
||||||
|
}, &model.UpdatePostOptions{})
|
||||||
|
require.Nil(t, appErr)
|
||||||
|
|
||||||
|
// Get edit history
|
||||||
|
editHistory, response, err := client.GetEditHistoryForPost(context.Background(), oldPost.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
CheckOKStatus(t, response)
|
||||||
|
require.Equal(t, 1, len(editHistory))
|
||||||
|
|
||||||
|
// Restore should be blocked by time limit
|
||||||
|
_, resp, err := client.RestorePostVersion(context.Background(), oldPost.Id, editHistory[0].Id)
|
||||||
|
require.Error(t, err)
|
||||||
|
CheckBadRequestStatus(t, resp)
|
||||||
|
require.Equal(t, "api.post.update_post.permissions_time_limit.app_error", err.(*model.AppError).Id)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("restore post version with file change blocked when time limit expired", func(t *testing.T) {
|
||||||
|
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||||
|
*cfg.ServiceSettings.PostEditTimeLimit = 1
|
||||||
|
})
|
||||||
|
defer th.App.UpdateConfig(func(cfg *model.Config) {
|
||||||
|
*cfg.ServiceSettings.PostEditTimeLimit = -1
|
||||||
|
})
|
||||||
|
|
||||||
|
// Upload a file for the original post
|
||||||
|
fileResp, _, err := client.UploadFile(context.Background(), []byte("data"), th.BasicChannel.Id, "test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
fileId := fileResp.FileInfos[0].Id
|
||||||
|
|
||||||
|
// Create post in the past with file attached
|
||||||
|
oldPost, _, appErr := th.App.CreatePost(th.Context, &model.Post{
|
||||||
|
ChannelId: th.BasicChannel.Id,
|
||||||
|
Message: "original message",
|
||||||
|
UserId: th.BasicUser.Id,
|
||||||
|
FileIds: model.StringArray{fileId},
|
||||||
|
CreateAt: model.GetMillis() - 2000,
|
||||||
|
}, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
|
||||||
|
require.Nil(t, appErr)
|
||||||
|
|
||||||
|
// Remove file via app layer to create history entry that has the file
|
||||||
|
emptyFiles := model.StringArray{}
|
||||||
|
_, _, appErr = th.App.PatchPost(th.Context, oldPost.Id, &model.PostPatch{
|
||||||
|
Message: model.NewPointer("edited message"),
|
||||||
|
FileIds: &emptyFiles,
|
||||||
|
}, &model.UpdatePostOptions{})
|
||||||
|
require.Nil(t, appErr)
|
||||||
|
|
||||||
|
// Get edit history (should have the entry with the original file)
|
||||||
|
editHistory, response, err := client.GetEditHistoryForPost(context.Background(), oldPost.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
CheckOKStatus(t, response)
|
||||||
|
require.Equal(t, 1, len(editHistory))
|
||||||
|
require.Equal(t, 1, len(editHistory[0].FileIds))
|
||||||
|
|
||||||
|
// Restore (which would re-attach the file) should be blocked by time limit
|
||||||
|
_, resp, err := client.RestorePostVersion(context.Background(), oldPost.Id, editHistory[0].Id)
|
||||||
|
require.Error(t, err)
|
||||||
|
CheckBadRequestStatus(t, resp)
|
||||||
|
require.Equal(t, "api.post.update_post.permissions_time_limit.app_error", err.(*model.AppError).Id)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,6 +168,10 @@ type PostPatch struct {
|
|||||||
HasReactions *bool `json:"has_reactions"`
|
HasReactions *bool `json:"has_reactions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (o *PostPatch) IsEmpty() bool {
|
||||||
|
return o.IsPinned == nil && o.Message == nil && o.Props == nil && o.FileIds == nil && o.HasReactions == nil
|
||||||
|
}
|
||||||
|
|
||||||
type PostReminder struct {
|
type PostReminder struct {
|
||||||
TargetTime int64 `json:"target_time"`
|
TargetTime int64 `json:"target_time"`
|
||||||
// These fields are only used internally for interacting with DB.
|
// These fields are only used internally for interacting with DB.
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user