snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
This commit is contained in:
62
e2e-tests/playwright/script/README.md
Normal file
62
e2e-tests/playwright/script/README.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Playwright E2E Test Scripts
|
||||
|
||||
This directory contains utility scripts for the Playwright E2E test suite.
|
||||
|
||||
## Test Documentation Format Linter
|
||||
|
||||
The `lint-test-docs.js` script verifies that all spec files follow the required documentation format:
|
||||
|
||||
- JSDoc with `@objective` and `@precondition` tags
|
||||
- Proper test title with MM-T ID (e.g., MM-T1234)
|
||||
- Tag for feature categorization (e.g., `{tag: '@feature_name'}`)
|
||||
- Action comments (e.g., `// # Action`)
|
||||
- Verification comments (e.g., `// * Verification`)
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
# Run the linter directly
|
||||
node script/lint-test-docs.js
|
||||
|
||||
# Or use the npm script
|
||||
npm run lint:test-docs
|
||||
```
|
||||
|
||||
### Integration with CI
|
||||
|
||||
The linter is also integrated with the main `check` command, which is typically run before committing changes:
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
```
|
||||
|
||||
This will run ESLint, Prettier, TypeScript type checking, and the test documentation format linter.
|
||||
|
||||
### Requirements
|
||||
|
||||
All spec files should follow this format:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* @objective Clear description of what the test verifies
|
||||
*
|
||||
* @precondition
|
||||
* Special setup or conditions required for the test
|
||||
* Note: Only include for non-default requirements
|
||||
*/
|
||||
test('descriptive test title', {tag: '@feature_tag'}, async ({pw}) => {
|
||||
// # Initialize setup and login
|
||||
const {user} = await pw.initSetup();
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
|
||||
// # Navigate to channel and post a message
|
||||
await channelsPage.goto();
|
||||
await channelsPage.postMessage('Test message');
|
||||
|
||||
// * Verify message appears in the channel
|
||||
const lastPost = await channelsPage.getLastPost();
|
||||
await expect(lastPost.body).toContainText('Test message');
|
||||
});
|
||||
```
|
||||
|
||||
This ensures consistency across all test files and makes it easier to understand the purpose and requirements of each test.
|
||||
390
e2e-tests/playwright/script/lint-test-docs.js
Executable file
390
e2e-tests/playwright/script/lint-test-docs.js
Executable file
@@ -0,0 +1,390 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/**
|
||||
* Test Documentation Format Linter
|
||||
*
|
||||
* This script verifies that all spec files follow the required documentation format:
|
||||
* - JSDoc with @objective and @precondition
|
||||
* - Proper test title with MM-T ID
|
||||
* - Tag for feature categorization
|
||||
* - Action/Verification comments
|
||||
*/
|
||||
|
||||
/* eslint-disable import/order */
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const glob = require('glob');
|
||||
|
||||
/* eslint-disable no-console */
|
||||
|
||||
// Colors for terminal output
|
||||
const colors = {
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
reset: '\x1b[0m',
|
||||
cyan: '\x1b[36m',
|
||||
magenta: '\x1b[35m',
|
||||
white: '\x1b[37m',
|
||||
};
|
||||
|
||||
// Regex patterns
|
||||
const patterns = {
|
||||
// Support both single-line and multi-line test declarations
|
||||
testDeclaration:
|
||||
/test\(\s*['"]([^'"]+)['"]\s*,(?:\s*|\n\s*){.*?tag:\s*['"]@.*?['"].*?}(?:\s*|\n\s*),(?:\s*|\n\s*)async/g,
|
||||
jsdocBlock: /\/\*\*\s*\n(?:.*\n)*?\s*\*\//g,
|
||||
objective: /@objective\s+.*?(?=\n|\*\/)/g,
|
||||
precondition: /@precondition\s+.*?(?=\n|\*\/)/g,
|
||||
tagDeclaration: /{tag:\s*['"]@[\w_]+['"]}|{\s*tag:\s*['"]@[\w_]+['"]\s*}/g,
|
||||
// Match action comments - note the difference between # as a comment marker vs # in strings
|
||||
actionComment: /(?:^|\n)\s*\/\/\s*#\s*.+/g,
|
||||
// Match verification comments - note the difference between * as a comment marker vs * in strings
|
||||
verificationComment: /(?:^|\n)\s*\/\/\s*\*\s*.+/g,
|
||||
};
|
||||
|
||||
// Get all spec files
|
||||
const specFiles = glob.sync(path.join(process.cwd(), 'specs/**/*.spec.ts'));
|
||||
|
||||
// Results
|
||||
const results = {
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
warnings: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
// Process each file
|
||||
specFiles.forEach((filePath) => {
|
||||
const relativeFilePath = path.relative(process.cwd(), filePath);
|
||||
const fileContent = fs.readFileSync(filePath, 'utf-8');
|
||||
const fileErrors = [];
|
||||
|
||||
// Extract all test declarations
|
||||
const testDeclarations = Array.from(fileContent.matchAll(patterns.testDeclaration));
|
||||
const jsdocBlocks = Array.from(fileContent.matchAll(patterns.jsdocBlock));
|
||||
|
||||
// If no test declarations found, warn but don't fail
|
||||
if (testDeclarations.length === 0) {
|
||||
results.warnings++;
|
||||
console.log(
|
||||
`${colors.yellow}WARNING${colors.reset}: No test declarations found in ${colors.cyan}${relativeFilePath}${colors.reset}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check each test for proper format
|
||||
testDeclarations.forEach((testMatch) => {
|
||||
const testDeclaration = testMatch[0];
|
||||
const testName = testMatch[1];
|
||||
|
||||
// Get test function position to find corresponding JSDoc
|
||||
const testPosition = testMatch.index;
|
||||
|
||||
// Get the JSDoc block that immediately precedes this test
|
||||
const precedingJSDoc = jsdocBlocks.find((jsdoc) => {
|
||||
const jsdocEnd = jsdoc.index + jsdoc[0].length;
|
||||
// JSDoc should end right before the test with only whitespace in between
|
||||
const textBetween = fileContent.substring(jsdocEnd, testPosition).trim();
|
||||
return textBetween === '';
|
||||
});
|
||||
|
||||
// Check if test has a test key (MM-T####)
|
||||
if (testName.match(/^MM-T\d+/)) {
|
||||
// This is an existing test with a test key
|
||||
const baseTestKey = testName.match(/^(MM-T\d+)/)[1];
|
||||
|
||||
// Check if this is a step of a multi-step test case
|
||||
const stepMatch = testName.match(/^MM-T\d+(_\d+)/);
|
||||
const isStep = stepMatch !== null;
|
||||
const stepSuffix = isStep ? stepMatch[1] : '';
|
||||
const testKey = baseTestKey + (stepSuffix || '');
|
||||
|
||||
// Log different message for test steps
|
||||
if (isStep) {
|
||||
// Extract the step number without the underscore
|
||||
const stepNumber = stepSuffix.substring(1);
|
||||
console.log(
|
||||
`${colors.magenta}DOCUMENTATION UPDATE: "${testKey}"${colors.reset} - step ${stepNumber} of ${baseTestKey} - Changes will be mapped to test management system after merge`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`${colors.magenta}DOCUMENTATION UPDATE: "${testKey}"${colors.reset} - Changes will be saved to test management system after merge`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check if documentation is present and mark for update
|
||||
if (precedingJSDoc) {
|
||||
results.updatedTests = results.updatedTests || [];
|
||||
results.updatedTests.push({
|
||||
file: relativeFilePath,
|
||||
testKey,
|
||||
baseTestKey,
|
||||
isStep,
|
||||
stepSuffix,
|
||||
testName,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// This is a new test without a test key
|
||||
console.log(
|
||||
`${colors.cyan}NEW TEST FOUND: "${testName}"${colors.reset} - Will be registered in the test management system after merge`,
|
||||
);
|
||||
results.newTests = results.newTests || [];
|
||||
results.newTests.push({file: relativeFilePath, testName});
|
||||
}
|
||||
|
||||
// JSDoc was already retrieved above, no need to get it again
|
||||
|
||||
if (precedingJSDoc) {
|
||||
const jsdocContent = precedingJSDoc[0];
|
||||
|
||||
// Check for @objective
|
||||
if (!jsdocContent.match(patterns.objective)) {
|
||||
fileErrors.push(`Missing @objective in JSDoc at "${testName}"`);
|
||||
}
|
||||
|
||||
// Note: @precondition is optional and should only be included when there are
|
||||
// non-default requirements for the test
|
||||
} else {
|
||||
fileErrors.push(`Missing JSDoc documentation at "${testName}"`);
|
||||
}
|
||||
|
||||
// Check for tag declaration
|
||||
if (!testDeclaration.match(patterns.tagDeclaration)) {
|
||||
fileErrors.push(`Missing feature tag at "${testName}"`);
|
||||
}
|
||||
|
||||
// Simpler approach to extract test body by using string search and tracking braces to find function boundaries
|
||||
const testNameEscaped = testName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const testDeclarationPattern = new RegExp(`test\\s*\\([\\s\\n]*['"]${testNameEscaped}['"]`);
|
||||
|
||||
// Find the position of the test declaration
|
||||
const testDeclarationMatch = testDeclarationPattern.exec(fileContent);
|
||||
let testFnMatch = null;
|
||||
|
||||
if (testDeclarationMatch) {
|
||||
// Find the position of "async" after the test declaration
|
||||
const startPos = testDeclarationMatch.index;
|
||||
const asyncPattern = /async\s*\([^)]*\)\s*=>\s*{/g;
|
||||
asyncPattern.lastIndex = startPos;
|
||||
|
||||
const asyncMatch = asyncPattern.exec(fileContent);
|
||||
if (asyncMatch) {
|
||||
// Find the position of the opening brace of the function body
|
||||
const openBracePos = asyncMatch.index + asyncMatch[0].length - 1;
|
||||
|
||||
// Find the matching closing brace
|
||||
let braceCount = 1;
|
||||
let currentPos = openBracePos + 1;
|
||||
|
||||
while (braceCount > 0 && currentPos < fileContent.length) {
|
||||
const char = fileContent[currentPos];
|
||||
|
||||
if (char === '{') {
|
||||
braceCount++;
|
||||
} else if (char === '}') {
|
||||
braceCount--;
|
||||
}
|
||||
|
||||
currentPos++;
|
||||
}
|
||||
|
||||
// If we found the matching closing brace, extract everything between them
|
||||
if (braceCount === 0) {
|
||||
const testBody = fileContent.substring(openBracePos + 1, currentPos - 1);
|
||||
testFnMatch = [null, testBody];
|
||||
}
|
||||
}
|
||||
|
||||
// If we couldn't extract using the above method, try a more direct string search approach
|
||||
if (!testFnMatch) {
|
||||
// Find the test function by searching for the test name and extracting all content until the end of the function
|
||||
const testIndex =
|
||||
fileContent.indexOf(`test('${testName}'`) ||
|
||||
fileContent.indexOf(`test("${testName}"`) ||
|
||||
fileContent.indexOf(`test(\n '${testName}'`) ||
|
||||
fileContent.indexOf(`test(\n "${testName}"`);
|
||||
|
||||
if (testIndex !== -1) {
|
||||
// Find the position of "async" which indicates the start of the function body
|
||||
const asyncIndex = fileContent.indexOf('async', testIndex);
|
||||
if (asyncIndex !== -1) {
|
||||
// Find the opening brace after "async"
|
||||
const openBraceIndex = fileContent.indexOf('{', asyncIndex);
|
||||
if (openBraceIndex !== -1) {
|
||||
// Count braces to find the matching closing brace
|
||||
let braceCount = 1;
|
||||
let closePos = openBraceIndex + 1;
|
||||
|
||||
while (braceCount > 0 && closePos < fileContent.length) {
|
||||
const char = fileContent[closePos];
|
||||
|
||||
if (char === '{') {
|
||||
braceCount++;
|
||||
} else if (char === '}') {
|
||||
braceCount--;
|
||||
}
|
||||
|
||||
closePos++;
|
||||
}
|
||||
|
||||
if (braceCount === 0) {
|
||||
const testBody = fileContent.substring(openBraceIndex + 1, closePos - 1);
|
||||
testFnMatch = [null, testBody];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (testFnMatch) {
|
||||
const testBody = testFnMatch[1];
|
||||
|
||||
// Check for action comments (// #) - we need to be more flexible in our detection
|
||||
let hasActionComments = false;
|
||||
const actionPattern = /\/\/\s*#/;
|
||||
|
||||
if (actionPattern.test(testBody)) {
|
||||
hasActionComments = true;
|
||||
}
|
||||
|
||||
if (!hasActionComments) {
|
||||
fileErrors.push(`Missing action comments at "${testName}" (format: "// # Some descriptive action")`);
|
||||
}
|
||||
|
||||
// Check for verification comments (// *) - we need to be more flexible in our detection
|
||||
let hasVerificationComments = false;
|
||||
const verificationPattern = /\/\/\s*\*/;
|
||||
|
||||
if (verificationPattern.test(testBody)) {
|
||||
hasVerificationComments = true;
|
||||
}
|
||||
|
||||
if (!hasVerificationComments) {
|
||||
fileErrors.push(
|
||||
`Missing verification comments at "${testName}" (format: "// * Some descriptive verification")`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
fileErrors.push(`Could not extract test body for "${testName}"`);
|
||||
}
|
||||
});
|
||||
|
||||
if (fileErrors.length > 0) {
|
||||
results.failed++;
|
||||
results.errors.push({
|
||||
file: relativeFilePath,
|
||||
errors: fileErrors,
|
||||
});
|
||||
} else {
|
||||
results.passed++;
|
||||
}
|
||||
});
|
||||
|
||||
// Print results
|
||||
console.log('\n' + '-'.repeat(80));
|
||||
console.log(`${colors.cyan}Test Documentation Format Linter Results${colors.reset}`);
|
||||
console.log(`Files checked: ${specFiles.length}`);
|
||||
console.log(`${colors.green}Passed: ${results.passed}${colors.reset}`);
|
||||
console.log(`${colors.red}Failed: ${results.failed}${colors.reset}`);
|
||||
console.log(`${colors.yellow}Warnings: ${results.warnings}${colors.reset}`);
|
||||
console.log(`${colors.cyan}New tests: ${results.newTests ? results.newTests.length : 0}${colors.reset}`);
|
||||
console.log(`${colors.magenta}Updated tests: ${results.updatedTests ? results.updatedTests.length : 0}${colors.reset}`);
|
||||
|
||||
if (results.errors.length > 0) {
|
||||
console.log('\n' + '-'.repeat(80));
|
||||
console.log(`${colors.red}Errors:${colors.reset}`);
|
||||
|
||||
results.errors.forEach((fileResult) => {
|
||||
console.log(`\n${colors.cyan}${fileResult.file}${colors.reset}:`);
|
||||
fileResult.errors.forEach((error) => {
|
||||
console.log(` ${colors.red}•${colors.reset} ${error}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Display new tests summary if any were found
|
||||
if (results.newTests && results.newTests.length > 0) {
|
||||
console.log('\n' + '-'.repeat(80));
|
||||
console.log(`${colors.cyan}New Tests to be Registered:${colors.reset}`);
|
||||
|
||||
results.newTests.forEach((newTest) => {
|
||||
console.log(` ${colors.cyan}•${colors.reset} ${newTest.file}: "${newTest.testName}"`);
|
||||
});
|
||||
}
|
||||
|
||||
// Display updated tests summary if any were found
|
||||
if (results.updatedTests && results.updatedTests.length > 0) {
|
||||
console.log('\n' + '-'.repeat(80));
|
||||
console.log(`${colors.magenta}Tests with Documentation Updates:${colors.reset}`);
|
||||
|
||||
// Group the updated tests by base test key
|
||||
const groupedTests = {};
|
||||
results.updatedTests.forEach((test) => {
|
||||
if (!groupedTests[test.baseTestKey]) {
|
||||
groupedTests[test.baseTestKey] = [];
|
||||
}
|
||||
groupedTests[test.baseTestKey].push(test);
|
||||
});
|
||||
|
||||
// Display the tests grouped by base test key
|
||||
Object.keys(groupedTests).forEach((baseTestKey) => {
|
||||
const tests = groupedTests[baseTestKey];
|
||||
|
||||
if (tests.length === 1 && !tests[0].isStep) {
|
||||
// If there's only one test with this base key and it's not a step
|
||||
|
||||
const test = tests[0];
|
||||
console.log(
|
||||
` ${colors.magenta}•${colors.reset} ${test.file}: ${test.testKey} "${test.testName.substring(test.testKey.length).trim()}"`,
|
||||
);
|
||||
} else {
|
||||
// If there are multiple steps of the same test
|
||||
|
||||
console.log(
|
||||
` ${colors.magenta}•${colors.reset} ${tests[0].file}: ${colors.cyan}${baseTestKey}${colors.reset} (with ${tests.length} steps):`,
|
||||
);
|
||||
|
||||
// Sort steps by their step number
|
||||
tests.sort((a, b) => {
|
||||
if (!a.isStep) {
|
||||
return -1;
|
||||
}
|
||||
if (!b.isStep) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const aNum = parseInt(a.stepSuffix.substring(1), 10);
|
||||
const bNum = parseInt(b.stepSuffix.substring(1), 10);
|
||||
return aNum - bNum;
|
||||
});
|
||||
|
||||
// Display each step
|
||||
tests.forEach((test) => {
|
||||
const namePart = test.testName.substring(test.baseTestKey.length).trim();
|
||||
if (test.isStep) {
|
||||
// Extract the step number without the underscore
|
||||
const stepNumber = test.stepSuffix.substring(1);
|
||||
console.log(
|
||||
` ${colors.magenta}◦${colors.reset} step ${stepNumber}: "${namePart.substring(test.stepSuffix.length).trim()}"`,
|
||||
);
|
||||
} else {
|
||||
console.log(` ${colors.magenta}◦${colors.reset} Base test: "${namePart}"`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n' + '-'.repeat(80));
|
||||
if (results.failed > 0) {
|
||||
console.log(`${colors.red}Linter failed!${colors.reset} Please fix the test documentation issues.`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(`${colors.green}Linter passed!${colors.reset} All spec files follow the required format.`);
|
||||
process.exit(0);
|
||||
}
|
||||
12
e2e-tests/playwright/script/post_install.sh
Executable file
12
e2e-tests/playwright/script/post_install.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [ -n "$PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD" ]; then
|
||||
echo "Skipped browsers download for Playwright"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Install needed Playwright browsers only -- chromium and firefox only for these are the ones being used by the tests.
|
||||
# May add more browsers in the future.
|
||||
# https://playwright.dev/docs/library#browser-downloads
|
||||
npx playwright install chromium
|
||||
npx playwright install firefox
|
||||
161
e2e-tests/playwright/script/resolve_upgrade_matrix.mjs
Normal file
161
e2e-tests/playwright/script/resolve_upgrade_matrix.mjs
Normal file
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env node
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
// Resolves the rolling-upgrade matrix for CI: last 3 minor releases + any active Extended
|
||||
// Support release. Prints JSON objects with docker tags and ESR metadata, e.g.
|
||||
// [{"dockerTag":"release-11.9",...}] — or `[]` when no supported from-versions remain (CI posts
|
||||
// e2e-test/playwright-full/{edition}/upgrade-from-none and skips workers).
|
||||
//
|
||||
// Current version + last-3 come from this local checkout's version.go. Support-end dates and ESR
|
||||
// status are fetched live from master, since those lapse with calendar time.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
const RELEASES_MDX_URL =
|
||||
'https://raw.githubusercontent.com/mattermost/mattermost/refs/heads/master/docs/main/product-overview/mattermost-server-releases.mdx';
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const VERSION_GO_PATH = path.resolve(currentDir, '../../../server/public/model/version.go');
|
||||
|
||||
// Named (not inline) so referencing `.test()` below is unambiguous to both eslint and Prettier.
|
||||
const RELEASE_ROW_PATTERN = /^\|\s*v\d+\.\d+\s/;
|
||||
const CELL_SEPARATOR_PATTERN = /(?<!\\)\|/;
|
||||
|
||||
function toMinor(version) {
|
||||
const [major, minor] = version.split('.');
|
||||
return `${major}.${minor}`;
|
||||
}
|
||||
|
||||
/** Numeric major.minor ordering — string compare would sort 11.9 after 11.10. */
|
||||
function compareMinor(a, b) {
|
||||
const [aMajor, aMinor] = a.split('.').map(Number);
|
||||
const [bMajor, bMinor] = b.split('.').map(Number);
|
||||
return aMajor - bMajor || aMinor - bMinor;
|
||||
}
|
||||
|
||||
/** Maps a release patch version to the registry's floating minor tag. */
|
||||
function toDockerImageTag(patchVersion) {
|
||||
const [major, minor] = patchVersion.split('.');
|
||||
return `release-${major}.${minor}`;
|
||||
}
|
||||
|
||||
/** GitHub commit-status / dashboard label for a matrix entry. */
|
||||
export function upgradeMatrixContextLabel(entry) {
|
||||
return entry.isESR ? `${entry.dockerTag}-esr` : entry.dockerTag;
|
||||
}
|
||||
|
||||
/** Reads current + last-3-minor versions from the local checkout's version.go. */
|
||||
function readLocalVersions() {
|
||||
const source = fs.readFileSync(VERSION_GO_PATH, 'utf-8');
|
||||
const block = source.match(/var versions = \[\]string\{([^}]*)\}/);
|
||||
if (!block) {
|
||||
throw new Error(`Could not find "var versions = []string{...}" block in ${VERSION_GO_PATH}`);
|
||||
}
|
||||
const versions = [...block[1].matchAll(/"([\d.]+)"/g)].map((m) => m[1]);
|
||||
|
||||
// Release branches list every hotfix of their own minor first ("11.9.2", "11.9.1", "11.9.0",
|
||||
// "11.8.0", ...), so collapse to unique minors before slicing. Taking the raw entries would
|
||||
// make lastThree on release-11.9 read 11.9, 11.9, 11.8 — offering the branch its own version
|
||||
// as an upgrade source and dropping a genuinely older one.
|
||||
const minors = [...new Set(versions.map(toMinor))];
|
||||
if (minors.length < 4) {
|
||||
throw new Error(`Expected at least 4 distinct minors in ${VERSION_GO_PATH}, found ${minors.length}`);
|
||||
}
|
||||
return {
|
||||
current: minors[0],
|
||||
lastThree: minors.slice(1, 4),
|
||||
};
|
||||
}
|
||||
|
||||
/** Fetches and parses the releases table's rows into {minor, patch, supportEnds, isESR}. */
|
||||
async function fetchReleaseRows() {
|
||||
const response = await fetch(RELEASES_MDX_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${RELEASES_MDX_URL}: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const mdx = await response.text();
|
||||
|
||||
const rows = [];
|
||||
for (const line of mdx.split('\n')) {
|
||||
if (!RELEASE_ROW_PATTERN.test(line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Cell separators are unescaped `|`; the first cell's own markdown link text contains
|
||||
// escaped `\|` between [Download]/[Changelog]/SBOM links, which CELL_SEPARATOR_PATTERN's
|
||||
// lookbehind skips.
|
||||
const rawCells = line.split(CELL_SEPARATOR_PATTERN);
|
||||
const cells = rawCells.map((cell) => cell.trim()).filter(Boolean);
|
||||
if (cells.length < 3) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [releaseCell, , supportEndsCell] = cells;
|
||||
|
||||
const minorMatch = releaseCell.match(/^v(\d+\.\d+)/);
|
||||
const patchMatch = releaseCell.match(/releases\.mattermost\.com\/(\d+\.\d+\.\d+)\//);
|
||||
const dateMatch = supportEndsCell.match(/(\d{4}-\d{2}-\d{2})/);
|
||||
if (!minorMatch || !patchMatch || !dateMatch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.push({
|
||||
minor: minorMatch[1],
|
||||
patch: patchMatch[1],
|
||||
supportEnds: new Date(dateMatch[1]),
|
||||
isESR: supportEndsCell.includes('[EXTENDED]'),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function resolveUpgradeMatrix(now = new Date()) {
|
||||
const {current, lastThree} = readLocalVersions();
|
||||
const rows = await fetchReleaseRows();
|
||||
|
||||
const active = rows.filter((row) => row.supportEnds > now);
|
||||
|
||||
// Only strictly older minors are upgrade sources. The ESR clause below is otherwise unbounded:
|
||||
// cutting release-11.7 while 11.7 is the active ESR would put 11.7 in its own matrix, which is
|
||||
// a no-op upgrade rather than coverage.
|
||||
const older = active.filter((row) => compareMinor(row.minor, current) < 0);
|
||||
const matrix = older.filter((row) => lastThree.includes(row.minor) || row.isESR);
|
||||
|
||||
const seenMinors = new Set();
|
||||
const entries = [];
|
||||
for (const row of matrix) {
|
||||
if (seenMinors.has(row.minor)) {
|
||||
continue;
|
||||
}
|
||||
seenMinors.add(row.minor);
|
||||
const dockerTag = toDockerImageTag(row.patch);
|
||||
entries.push({
|
||||
dockerTag,
|
||||
minor: row.minor,
|
||||
patch: row.patch,
|
||||
isESR: row.isESR,
|
||||
contextLabel: upgradeMatrixContextLabel({dockerTag, isESR: row.isESR}),
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const entries = await resolveUpgradeMatrix();
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(JSON.stringify(entries));
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main().catch((error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(String(error));
|
||||
|
||||
// Rethrow (not process.exit()) — an unhandled rejection still exits non-zero, which is
|
||||
// all CI/local invocations need to fail loudly.
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
120
e2e-tests/playwright/script/testcontainers_down.mjs
Normal file
120
e2e-tests/playwright/script/testcontainers_down.mjs
Normal file
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env node
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
// Reused containers are deliberately left running after a Playwright process exits, so there's no
|
||||
// in-process teardown to call from a fresh invocation of this script — it finds and removes them
|
||||
// directly via Docker, by the label every container this project starts carries.
|
||||
//
|
||||
// Also doubles as CI's final teardown step: with PW_TESTCONTAINERS_REUSE, the stack stays up
|
||||
// across every per-spec test invocation in a job, so none of them ever run the real (non-adopted)
|
||||
// teardown path that collects container logs or archives the boot-env drift history. This script
|
||||
// does both before removing anything, so `ci/upload-debug-artifacts` still picks them up.
|
||||
|
||||
import {execSync} from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const LABEL_FILTER = 'label=mm-playwright-testcontainers=true';
|
||||
const LOG_DIR = path.resolve(process.cwd(), 'logs');
|
||||
const ENV_FILE_PATH = path.resolve(process.cwd(), '.env.testcontainers');
|
||||
|
||||
function run(command) {
|
||||
try {
|
||||
return execSync(command, {encoding: 'utf-8'}).trim();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Reads the last-written PW_TESTCONTAINERS_NETWORK_NAME= line from .env.testcontainers before
|
||||
// it's archived/removed, so this run's network can be removed by name instead of via a host-wide
|
||||
// `docker network prune`. Entries are appended on each write, so the last match is current.
|
||||
function readTestcontainersNetworkName() {
|
||||
if (!fs.existsSync(ENV_FILE_PATH)) {
|
||||
return undefined;
|
||||
}
|
||||
const matches = [...fs.readFileSync(ENV_FILE_PATH, 'utf-8').matchAll(/^PW_TESTCONTAINERS_NETWORK_NAME=(.*)$/gm)];
|
||||
return matches.at(-1)?.[1]?.trim() || undefined;
|
||||
}
|
||||
|
||||
const SENSITIVE_BOOT_ENV_KEYS = new Set([
|
||||
'MM_LICENSE',
|
||||
'MM_FILESETTINGS_AMAZONS3SECRETACCESSKEY',
|
||||
'MM_FILESETTINGS_AZUREACCESSKEY',
|
||||
]);
|
||||
|
||||
function redactBootEnvValue(key, value) {
|
||||
if (SENSITIVE_BOOT_ENV_KEYS.has(key)) {
|
||||
return '***';
|
||||
}
|
||||
if (key === 'MM_SQLSETTINGS_DATASOURCE') {
|
||||
return value.replace(/:([^:@/]+)@/, ':***@');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function redactEnvFileForArchive(contents) {
|
||||
return contents.replace(/^PW_TESTCONTAINERS_BOOT_ENV='(.*)'$/gm, (_match, json) => {
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
const redacted = Object.fromEntries(
|
||||
Object.entries(parsed).map(([key, value]) => [key, redactBootEnvValue(key, value)]),
|
||||
);
|
||||
return `PW_TESTCONTAINERS_BOOT_ENV='${JSON.stringify(redacted)}'`;
|
||||
} catch {
|
||||
return "PW_TESTCONTAINERS_BOOT_ENV='{}'";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Archives, then removes, .env.testcontainers — a stale copy left behind would seed the next
|
||||
// fresh boot's config overrides from a container that no longer exists, wrongly convincing a
|
||||
// later pw.ensure*() call that some old setting is already active. Secrets stay in the live
|
||||
// file for stack adoption; the logs/ copy is redacted for ci/upload-debug-artifacts.
|
||||
function archiveEnvFile() {
|
||||
if (!fs.existsSync(ENV_FILE_PATH)) {
|
||||
return;
|
||||
}
|
||||
fs.mkdirSync(LOG_DIR, {recursive: true});
|
||||
const redacted = redactEnvFileForArchive(fs.readFileSync(ENV_FILE_PATH, 'utf-8'));
|
||||
fs.writeFileSync(path.join(LOG_DIR, 'testcontainers_env_history.log'), redacted, 'utf-8');
|
||||
fs.rmSync(ENV_FILE_PATH);
|
||||
}
|
||||
|
||||
function collectLogs(containerIds) {
|
||||
fs.mkdirSync(LOG_DIR, {recursive: true});
|
||||
for (const id of containerIds) {
|
||||
const image = run(`docker inspect -f "{{.Config.Image}}" ${id}`) || id;
|
||||
const safeName = image.replace(/[^a-zA-Z0-9_.-]/g, '_');
|
||||
const outPath = path.join(LOG_DIR, `${safeName}-${id.slice(0, 12)}.log`);
|
||||
try {
|
||||
execSync(`docker logs "${id}" > "${outPath}" 2>&1`);
|
||||
} catch {
|
||||
// Best-effort — a container that's already gone or never logged anything shouldn't
|
||||
// block teardown.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const containerIds = run(`docker ps -aq --filter "${LABEL_FILTER}"`).split('\n').filter(Boolean);
|
||||
|
||||
const networkName = readTestcontainersNetworkName();
|
||||
archiveEnvFile();
|
||||
|
||||
if (containerIds.length === 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('No Testcontainers-managed containers found (nothing to remove).');
|
||||
} else {
|
||||
collectLogs(containerIds);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Removing ${containerIds.length} Testcontainers-managed container(s): ${containerIds.join(', ')}`);
|
||||
execSync(`docker rm -f ${containerIds.join(' ')}`, {stdio: 'inherit'});
|
||||
|
||||
if (networkName) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Removing Testcontainers-managed network: ${networkName}`);
|
||||
run(`docker network rm ${networkName}`);
|
||||
}
|
||||
}
|
||||
9
e2e-tests/playwright/script/testcontainers_up.spec.ts
Normal file
9
e2e-tests/playwright/script/testcontainers_up.spec.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {test} from '@playwright/test';
|
||||
|
||||
// No-op: playwright.testcontainers-up.config.ts's globalSetup (the same global_setup.ts the real
|
||||
// suite uses) already brought the stack up, or reused one already running. This test exists only
|
||||
// because Playwright's global setup/teardown fire around an actual test run, not standalone.
|
||||
test('testcontainers stack is up', () => {});
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import chalk from 'chalk';
|
||||
|
||||
import {startStack, stopStack} from '@mattermost/playwright-lib';
|
||||
|
||||
// Deliberately skips baseGlobalSetup() (admin user bootstrap, sysadmin setup, plugin config):
|
||||
// `npm run testcontainers:up` is for bringing up a fresh, untouched server for poking around or
|
||||
// for a later `npm run test` to reuse — not for preparing it as if a real suite were about to run.
|
||||
async function globalSetup() {
|
||||
try {
|
||||
await startStack();
|
||||
} catch (error: unknown) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(chalk.cyan('[testcontainers]'), error);
|
||||
await stopStack();
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(chalk.red(`[testcontainers] stack failed to start: ${message}`));
|
||||
}
|
||||
|
||||
return async function () {
|
||||
await stopStack();
|
||||
};
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
Reference in New Issue
Block a user