VYPR
Low severity3.1NVD Advisory· Published May 15, 2026· Updated May 18, 2026

CVE-2026-4053

CVE-2026-4053

Description

Mattermost versions 11.5.x <= 11.5.1, 10.11.x <= 10.11.13 fail to enforce the PostEditTimeLimit on non-message post fields which allows an authenticated user to modify post file attachments, props, and pin status after the edit window has expired via the post patch and update API endpoints.. Mattermost Advisory ID: MMSA-2026-00631

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Mattermost fails to enforce PostEditTimeLimit on non-message post fields, allowing authenticated users to edit attachments, props, and pin status after the edit window.

Vulnerability

Mattermost versions 11.5.x up to 11.5.1 and 10.11.x up to 10.11.13 do not enforce the PostEditTimeLimit on non-message post fields. This allows an authenticated user to modify post file attachments, props, and pin status via the post patch and update API endpoints, even after the configured edit window has expired [1].

Exploitation

An authenticated user can send API requests to the post patch or update endpoints to change file attachments, custom properties (props), or pin status of any post, regardless of the time elapsed since the post was created. No special privileges beyond being a logged-in user are required, and the user can target any post they have access to in channels they are a member of.

Impact

An attacker can alter the appearance and metadata of past messages without detection. This can lead to misinformation, as file attachments can be swapped, pins can be added or removed, and custom props modified, potentially affecting integrations or custom functionality relying on those fields. The integrity of past communications is undermined.

Mitigation

Mattermost has not yet released a fix for this vulnerability as of the publication date. Administrators should monitor the Mattermost security updates page [1] for patches. As a workaround, reducing the PostEditTimeLimit to zero (preventing all edits) can mitigate the issue, though it may impact legitimate use cases.

AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

1

Patches

3
b21ef302025e

Mm 67896 manual cherry pick onto release 10.11 (#35989)

https://github.com/mattermost/mattermostCarlos GarciaApr 14, 2026Fixed in 10.11.14via llm-release-walk
38 files changed · +59606 934
  • e2e-tests/.ci/server.run_specs.sh+101 0 added
    @@ -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"
    
  • e2e-tests/Makefile+3 1 modified
    @@ -9,14 +9,16 @@ 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
     	bash ./.ci/server.start.sh
     	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
    
  • e2e-tests/playwright/merge.config.mjs+13 0 added
    @@ -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'}],
    +    ],
    +};
    
  • .github/actions/calculate-cypress-results/action.yaml+50 0 added
    @@ -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
    
  • .github/actions/calculate-cypress-results/dist/index.js+19347 0 added
  • .github/actions/calculate-cypress-results/.gitignore+2 0 added
    @@ -0,0 +1,2 @@
    +node_modules/
    +.env
    
  • .github/actions/calculate-cypress-results/jest.config.js+15 0 added
    @@ -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,
    +            },
    +        ],
    +    },
    +};
    
  • .github/actions/calculate-cypress-results/package.json+27 0 added
    @@ -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"
    +  }
    +}
    
  • .github/actions/calculate-cypress-results/package-lock.json+9136 0 added
  • .github/actions/calculate-cypress-results/src/index.ts+3 0 added
    @@ -0,0 +1,3 @@
    +import { run } from "./main";
    +
    +run();
    
  • .github/actions/calculate-cypress-results/src/main.ts+101 0 added
    @@ -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);
    +}
    
  • .github/actions/calculate-cypress-results/src/merge.test.ts+271 0 added
    @@ -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);
    +    });
    +});
    
  • .github/actions/calculate-cypress-results/src/merge.ts+358 0 added
    @@ -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 };
    +}
    
  • .github/actions/calculate-cypress-results/src/types.ts+139 0 added
    @@ -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;
    +}
    
  • .github/actions/calculate-cypress-results/tsconfig.json+17 0 added
    @@ -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"]
    +}
    
  • .github/actions/calculate-cypress-results/tsconfig.tsbuildinfo+1 0 added
    @@ -0,0 +1 @@
    +{"root":["./src/index.ts","./src/main.ts","./src/merge.ts","./src/types.ts"],"version":"5.9.3"}
    \ No newline at end of file
    
  • .github/actions/calculate-cypress-results/tsup.config.ts+13 0 added
    @@ -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: [/.*/],
    +});
    
  • .github/actions/calculate-playwright-results/action.yaml+53 0 added
    @@ -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
    
  • .github/actions/calculate-playwright-results/dist/index.js+19323 0 added
  • .github/actions/calculate-playwright-results/.gitignore+2 0 added
    @@ -0,0 +1,2 @@
    +node_modules/
    +.env
    
  • .github/actions/calculate-playwright-results/jest.config.js+6 0 added
    @@ -0,0 +1,6 @@
    +module.exports = {
    +    preset: "ts-jest",
    +    testEnvironment: "node",
    +    testMatch: ["**/*.test.ts"],
    +    moduleFileExtensions: ["ts", "js"],
    +};
    
  • .github/actions/calculate-playwright-results/package.json+27 0 added
    @@ -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"
    +  }
    +}
    
  • .github/actions/calculate-playwright-results/package-lock.json+9136 0 added
  • .github/actions/calculate-playwright-results/src/index.ts+3 0 added
    @@ -0,0 +1,3 @@
    +import { run } from "./main";
    +
    +run();
    
  • .github/actions/calculate-playwright-results/src/main.ts+123 0 added
    @@ -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);
    +}
    
  • .github/actions/calculate-playwright-results/src/merge.test.ts+509 0 added
    @@ -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);
    +    });
    +});
    
  • .github/actions/calculate-playwright-results/src/merge.ts+304 0 added
    @@ -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,
    +    };
    +}
    
  • .github/actions/calculate-playwright-results/src/types.ts+89 0 added
    @@ -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;
    +}
    
  • .github/actions/calculate-playwright-results/tsconfig.json+17 0 added
    @@ -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"]
    +}
    
  • .github/actions/calculate-playwright-results/tsconfig.tsbuildinfo+1 0 added
    @@ -0,0 +1 @@
    +{"root":["./src/index.ts","./src/main.ts","./src/merge.ts","./src/types.ts"],"version":"5.9.3"}
    \ No newline at end of file
    
  • .github/actions/calculate-playwright-results/tsup.config.ts+12 0 added
    @@ -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",
    +});
    
  • .github/workflows/e2e-fulltests-ci.yml+0 345 removed
    @@ -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
    
  • .github/workflows/e2e-tests-ci-template.yml+0 572 removed
    @@ -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 }}
    
  • .github/workflows/e2e-tests-ci.yml+0 5 modified
    @@ -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>" } }
    
  • .github/workflows/e2e-tests-playwright-template.yml+10 2 modified
    @@ -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
    
  • server/channels/api4/post.go+40 9 modified
    @@ -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
     	}
    
  • server/channels/api4/post_test.go+350 0 modified
    @@ -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)
    +	})
     }
    
  • server/public/model/post.go+4 0 modified
    @@ -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.
    
f2b3d1c6a945

[MM-67809] Check create post permission when editing posts (#35558) (#35713)

https://github.com/mattermost/mattermostMattermost BuildMar 20, 2026Fixed in 11.5.2via llm-release-walk
2 files changed · +56 0
  • server/channels/api4/post.go+12 0 modified
    @@ -1046,6 +1046,12 @@ func updatePost(c *Context, w http.ResponseWriter, r *http.Request) {
     		return
     	}
     
    +	// Users who can't create posts in a channel shouldn't be able to edit them either.
    +	userCreatePostPermissionCheckWithContext(c, originalPost.ChannelId)
    +	if c.Err != nil {
    +		return
    +	}
    +
     	auditRec.AddEventPriorState(originalPost)
     	auditRec.AddEventObjectType("post")
     
    @@ -1183,6 +1189,12 @@ func postPatchChecks(c *Context, auditRec *model.AuditRecord, message *string) b
     		return false
     	}
     
    +	// Users who can't create posts in a channel shouldn't be able to edit them either.
    +	userCreatePostPermissionCheckWithContext(c, originalPost.ChannelId)
    +	if c.Err != nil {
    +		return false
    +	}
    +
     	if *c.App.Config().ServiceSettings.PostEditTimeLimit != -1 && model.GetMillis() > originalPost.CreateAt+int64(*c.App.Config().ServiceSettings.PostEditTimeLimit*1000) && message != nil {
     		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
    
  • server/channels/api4/post_test.go+44 0 modified
    @@ -1701,6 +1701,29 @@ func TestUpdatePost(t *testing.T) {
     		assert.Contains(t, updatedPost.FileIds, fileId)
     	})
     
    +	t.Run("should prevent editing when create_post permission is revoked", func(t *testing.T) {
    +		th.LoginBasic(t)
    +
    +		postToEdit, _, appErr := th.App.CreatePost(th.Context, &model.Post{
    +			UserId:    th.BasicUser.Id,
    +			ChannelId: channel.Id,
    +			Message:   "original message",
    +		}, channel, model.CreatePostFlags{SetOnline: true})
    +		require.Nil(t, appErr)
    +
    +		th.RemovePermissionFromRole(t, model.PermissionCreatePost.Id, model.ChannelUserRoleId)
    +		defer th.AddPermissionToRole(t, model.PermissionCreatePost.Id, model.ChannelUserRoleId)
    +
    +		updatePost := &model.Post{
    +			Id:        postToEdit.Id,
    +			ChannelId: channel.Id,
    +			Message:   "edited message",
    +		}
    +		_, resp, err := client.UpdatePost(context.Background(), postToEdit.Id, updatePost)
    +		require.Error(t, err)
    +		CheckForbiddenStatus(t, resp)
    +	})
    +
     	t.Run("logged out", func(t *testing.T) {
     		_, err := client.Logout(context.Background())
     		require.NoError(t, err)
    @@ -2085,6 +2108,27 @@ func TestPatchPost(t *testing.T) {
     		require.NoError(t, err)
     	})
     
    +	t.Run("should prevent patching when create_post permission is revoked", func(t *testing.T) {
    +		th.LoginBasic(t)
    +
    +		postToEdit, _, err := client.CreatePost(context.Background(), &model.Post{
    +			ChannelId: channel.Id,
    +			Message:   "original message",
    +		})
    +		require.NoError(t, err)
    +
    +		defaultPerms := th.SaveDefaultRolePermissions(t)
    +		defer th.RestoreDefaultRolePermissions(t, defaultPerms)
    +		th.RemovePermissionFromRole(t, model.PermissionCreatePost.Id, model.ChannelUserRoleId)
    +
    +		patch := &model.PostPatch{
    +			Message: model.NewPointer("edited message"),
    +		}
    +		_, resp, err := client.PatchPost(context.Background(), postToEdit.Id, patch)
    +		require.Error(t, err)
    +		CheckForbiddenStatus(t, resp)
    +	})
    +
     	t.Run("time limit expired", func(t *testing.T) {
     		th.App.UpdateConfig(func(cfg *model.Config) {
     			*cfg.ServiceSettings.PostEditTimeLimit = 1
    
abc3ec4eeddd

improves time limit checks (#35638) (#35851)

https://github.com/mattermost/mattermostMattermost BuildMar 30, 2026Fixed in 11.5.2via llm-release-walk
3 files changed · +393 9
  • server/channels/api4/post.go+39 9 modified
    @@ -1003,6 +1003,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 {
    @@ -1061,6 +1069,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 {
    @@ -1077,11 +1100,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
    @@ -1130,7 +1148,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
     	}
    @@ -1166,7 +1184,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)
    @@ -1195,7 +1213,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
     	}
    @@ -1290,6 +1308,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)
     
    @@ -1660,7 +1690,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
     	}
    
  • server/channels/api4/post_test.go+350 0 modified
    @@ -1625,6 +1625,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) {
    @@ -2154,6 +2242,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) {
    @@ -2316,6 +2508,61 @@ func TestPinPost(t *testing.T) {
     	th := Setup(t).InitBasic(t)
     	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)
    @@ -2348,6 +2595,28 @@ func TestUnpinPost(t *testing.T) {
     	th := Setup(t).InitBasic(t)
     	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(t)
     	_, err := client.UnpinPost(context.Background(), pinnedPost.Id)
     	require.NoError(t, err)
    @@ -5697,6 +5966,87 @@ 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)
    +	})
     }
     
     func TestRevealPost(t *testing.T) {
    
  • server/public/model/post.go+4 0 modified
    @@ -191,6 +191,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.
    

Vulnerability mechanics

Root cause

"The PostEditTimeLimit check only compared the message field, allowing authenticated users to modify file attachments, props, and pin status on posts after the edit window expired."

Attack vector

An authenticated attacker sends a PATCH or PUT request to the post update API endpoint (`/api/v4/posts/{post_id}` or `/api/v4/posts/{post_id}/patch`) with changes to non-message fields such as `file_ids`, `props`, or `is_pinned` on a post whose creation time exceeds the configured `PostEditTimeLimit` [CWE-672]. The original code only blocked edits when the `message` field changed, so the attacker can bypass the time limit by keeping the message identical while altering other post properties. The attacker must be a channel member with login credentials (authentication required, CVSS AC:H).

Affected code

The vulnerable code is in `server/channels/api4/post.go` within the `updatePost` and `postPatchChecks` functions. The original time-limit check in `updatePost` only compared `post.Message != originalPost.Message`, and in `postPatchChecks` it only checked if the patch `message` was non-nil. The `saveIsPinnedPost` and `restorePostVersion` functions also lacked time-limit enforcement. The fix spans `server/channels/api4/post.go` and adds a `PostPatch.IsEmpty()` method in `server/public/model/post.go`.

What the fix does

The patch introduces a new `postEditTimeLimitExpired` utility function and applies it consistently across `updatePost`, `patchPost`, `saveIsPinnedPost`, and `restorePostVersion` [patch_id=1068579]. In `updatePost`, the check now compares `Message`, `FileIds`, `Props`, and `IsPinned` between the original and updated post. In `patchPost`, the check uses a new `PostPatch.IsEmpty()` method to reject any non-empty patch when the time limit has expired [patch_id=1068579]. The `saveIsPinnedPost` handler now also checks the time limit and allows idempotent pin/unpin operations. Additionally, a missing `create_post` permission check was added to both `updatePost` and `postPatchChecks` [patch_id=1068580].

Preconditions

  • authAttacker must be authenticated as a channel member (valid session token)
  • configThe target post must be older than the configured PostEditTimeLimit (in seconds)
  • configPostEditTimeLimit must be set to a non-negative value (not -1, which disables the limit)
  • networkAttacker sends HTTP requests to the post update or patch API endpoints

Generated on May 21, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

1

News mentions

0

No linked articles in our index yet.