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>
Этот коммит содержится в:
Carlos Garcia
2026-04-14 12:38:57 +02:00
коммит произвёл GitHub
родитель e092af7a33
Коммит b21ef30202
38 изменённых файлов: 59606 добавлений и 934 удалений

2
.github/actions/calculate-playwright-results/.gitignore поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,2 @@
node_modules/
.env

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 поставляемый Обычный файл

Различия файлов скрыты, потому что одна или несколько строк слишком длинны

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 сгенерированный поставляемый Обычный файл

Разница между файлами не показана из-за своего большого размера Загрузить разницу

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 поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,3 @@
import { run } from "./main";
run();

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 поставляемый Обычный файл
Просмотреть файл

@@ -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 поставляемый Обычный файл
Просмотреть файл

@@ -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 поставляемый Обычный файл
Просмотреть файл

@@ -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 поставляемый Обычный файл
Просмотреть файл

@@ -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 поставляемый Обычный файл
Просмотреть файл

@@ -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 поставляемый Обычный файл
Просмотреть файл

@@ -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",
});