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)
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
# Argo Events Trigger (automated):
|
||||
# - Triggered by: Enterprise CI/docker-image status check (success)
|
||||
# - 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
|
||||
|
||||
# Merge blob reports using Playwright merge-reports (per docs)
|
||||
npm install --no-save @playwright/test
|
||||
npx playwright merge-reports --config merge.config.mjs ./shard-results/results/blob-report/
|
||||
# Pin version to match the one used to generate blob reports (avoids format mismatch)
|
||||
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
|
||||
id: calculate
|
||||
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/.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:
|
||||
bash ./.ci/server.generate.sh
|
||||
start-server: generate-server
|
||||
@@ -17,6 +17,8 @@ start-server: generate-server
|
||||
bash ./.ci/server.prepare.sh
|
||||
run-test:
|
||||
bash ./.ci/server.run_test.sh
|
||||
run-specs:
|
||||
bash ./.ci/server.run_specs.sh
|
||||
stop-server: generate-server
|
||||
bash ./.ci/server.stop.sh
|
||||
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"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"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) {
|
||||
c.RequirePostId()
|
||||
if c.Err != nil {
|
||||
@@ -1035,6 +1044,21 @@ func updatePost(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
checkUploadFilePermissionForNewFiles(c, post.FileIds, originalPost)
|
||||
if c.Err != nil {
|
||||
@@ -1051,11 +1075,6 @@ func updatePost(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
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})
|
||||
if err != nil {
|
||||
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 {
|
||||
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)
|
||||
if err != nil {
|
||||
c.SetPermissionError(model.PermissionEditPost)
|
||||
@@ -1169,7 +1188,7 @@ func postPatchChecks(c *Context, auditRec *model.AuditRecord, message *string) b
|
||||
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)
|
||||
return isMember
|
||||
}
|
||||
@@ -1264,6 +1283,18 @@ func saveIsPinnedPost(c *Context, w http.ResponseWriter, isPinned bool) {
|
||||
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.IsPinned = model.NewPointer(isPinned)
|
||||
|
||||
@@ -1557,7 +1588,7 @@ func restorePostVersion(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
isMember := postPatchChecks(c, auditRec, &toRestorePost.Message)
|
||||
isMember := postPatchChecks(c, auditRec, &model.PostPatch{Message: &toRestorePost.Message, FileIds: &toRestorePost.FileIds})
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1565,6 +1565,94 @@ func TestUpdatePost(t *testing.T) {
|
||||
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) {
|
||||
originalHardenedModeSetting := *th.App.Config().ServiceSettings.ExperimentalEnableHardenedMode
|
||||
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")
|
||||
})
|
||||
|
||||
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) {
|
||||
originalHardenedModeSetting := *th.App.Config().ServiceSettings.ExperimentalEnableHardenedMode
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
@@ -2259,6 +2451,61 @@ func TestPinPost(t *testing.T) {
|
||||
defer th.TearDown()
|
||||
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
|
||||
_, err := client.PinPost(context.Background(), post.Id)
|
||||
require.NoError(t, err)
|
||||
@@ -2292,6 +2539,28 @@ func TestUnpinPost(t *testing.T) {
|
||||
defer th.TearDown()
|
||||
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()
|
||||
_, err := client.UnpinPost(context.Background(), pinnedPost.Id)
|
||||
require.NoError(t, err)
|
||||
@@ -5700,4 +5969,85 @@ func TestRestorePostVersion(t *testing.T) {
|
||||
CheckForbiddenStatus(t, response)
|
||||
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"`
|
||||
}
|
||||
|
||||
func (o *PostPatch) IsEmpty() bool {
|
||||
return o.IsPinned == nil && o.Message == nil && o.Props == nil && o.FileIds == nil && o.HasReactions == nil
|
||||
}
|
||||
|
||||
type PostReminder struct {
|
||||
TargetTime int64 `json:"target_time"`
|
||||
// These fields are only used internally for interacting with DB.
|
||||
|
||||
Ссылка в новой задаче
Block a user