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:
160
e2e-tests/playwright/upgrade-specs/from/upgrade_from.spec.ts
Normal file
160
e2e-tests/playwright/upgrade-specs/from/upgrade_from.spec.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {saveUpgradePhaseLogs, test} from '@mattermost/playwright-lib';
|
||||
|
||||
import {
|
||||
assertProfileImageFetchable,
|
||||
clearUpgradeBaseline,
|
||||
ensureDirectChannel,
|
||||
ensureGroupChannel,
|
||||
ensureUpgradePlaybooksEnabled,
|
||||
ensureUpgradePluginActive,
|
||||
loadUpgradeFromContext,
|
||||
mergeUpgradeBaseline,
|
||||
persistUpgradeLicenseFromEnv,
|
||||
postMessage,
|
||||
readFileDriverName,
|
||||
readServerIdentity,
|
||||
readUpgradeBaseline,
|
||||
readUpgradeLicenseBaseline,
|
||||
readUpgradeSchemaBaseline,
|
||||
seedUpgradeAttachments,
|
||||
seeder,
|
||||
uploadUpgradeProfileImage,
|
||||
} from '../upgrade_fixtures';
|
||||
|
||||
/**
|
||||
* @objective Seeds upgrade actors and content via Client4 / PlaywrightClient4 on the from-image.
|
||||
* Each test writes a slice of `.upgrade_baseline.json` so the captured state maps clearly to
|
||||
* what upgrade-to re-verifies. Prefer API clients for all setup/verify.
|
||||
*/
|
||||
test.describe.serial('upgrade-from baseline', {tag: ['@upgrade-from']}, () => {
|
||||
test.afterAll(async () => {
|
||||
// Snapshot from-image logs before upgrade-swap-to replaces the Mattermost container.
|
||||
await saveUpgradePhaseLogs('from');
|
||||
});
|
||||
|
||||
test('server identity, actors, and channel ids', async ({pw}) => {
|
||||
// # Start from a clean baseline for this upgrade-from run
|
||||
clearUpgradeBaseline();
|
||||
|
||||
// # Ensure shared team/users/channels and persist license into Postgres
|
||||
const {adminClient, user, adminMe, publicChannel, privateChannel} = await loadUpgradeFromContext(pw);
|
||||
await persistUpgradeLicenseFromEnv(adminClient);
|
||||
|
||||
// # Capture from-image identity, license, schema migrations, and channel ids for upgrade-to
|
||||
const fromIdentity = await readServerIdentity(adminClient);
|
||||
const license = await readUpgradeLicenseBaseline(adminClient);
|
||||
const schema = await readUpgradeSchemaBaseline(adminClient);
|
||||
mergeUpgradeBaseline({
|
||||
serverVersion: fromIdentity.serverVersion,
|
||||
buildNumber: fromIdentity.buildNumber,
|
||||
schema,
|
||||
fileDriverName: await readFileDriverName(adminClient),
|
||||
license,
|
||||
userId: user.id,
|
||||
adminUserId: adminMe.id,
|
||||
publicChannelId: publicChannel.id,
|
||||
privateChannelId: privateChannel.id,
|
||||
});
|
||||
});
|
||||
|
||||
test('user channel messages and dm/gm channel ids', async ({pw}) => {
|
||||
const {userClient, user, peers, publicChannel, privateChannel} = await loadUpgradeFromContext(pw);
|
||||
|
||||
// # Post user messages in public/private (including a search marker)
|
||||
await postMessage(userClient, publicChannel.id, seeder.UPGRADE_PUBLIC_MESSAGE);
|
||||
await postMessage(userClient, publicChannel.id, seeder.UPGRADE_SEARCH_MESSAGE);
|
||||
await postMessage(userClient, privateChannel.id, seeder.UPGRADE_PRIVATE_MESSAGE);
|
||||
|
||||
// # Create user DM/GM and post messages there
|
||||
const userDmChannel = await ensureDirectChannel(userClient, user.id, peers[0].id);
|
||||
await postMessage(userClient, userDmChannel.id, seeder.UPGRADE_DM_MESSAGE);
|
||||
|
||||
const userGmChannel = await ensureGroupChannel(userClient, [user.id, peers[1].id, peers[2].id]);
|
||||
await postMessage(userClient, userGmChannel.id, seeder.UPGRADE_GM_MESSAGE);
|
||||
|
||||
// # Record DM/GM channel ids for upgrade-to
|
||||
mergeUpgradeBaseline({
|
||||
userDmChannelId: userDmChannel.id,
|
||||
userGmChannelId: userGmChannel.id,
|
||||
});
|
||||
});
|
||||
|
||||
test('attachments across channel types', async ({pw, request}) => {
|
||||
const {adminClient, userClient, user, peers, publicChannel, privateChannel} = await loadUpgradeFromContext(pw);
|
||||
const userDmChannel = await ensureDirectChannel(userClient, user.id, peers[0].id);
|
||||
|
||||
// # Seed file attachments across public/private/DM and verify each is downloadable
|
||||
const attachments = await seedUpgradeAttachments(request, userClient, adminClient, {
|
||||
publicId: publicChannel.id,
|
||||
privateId: privateChannel.id,
|
||||
userDmId: userDmChannel.id,
|
||||
});
|
||||
|
||||
// # Record attachment post ids for upgrade-to
|
||||
mergeUpgradeBaseline({attachments});
|
||||
});
|
||||
|
||||
test('profile image and avatar thread post id', async ({pw, request}) => {
|
||||
const {adminClient, userClient, user, publicChannel} = await loadUpgradeFromContext(pw);
|
||||
|
||||
// # Upload user profile image and confirm it is fetchable
|
||||
await uploadUpgradeProfileImage(userClient, user.id, seeder.UPGRADE_PROFILE_PHOTO_FILE);
|
||||
// * Verify profile image bytes are served
|
||||
await assertProfileImageFetchable(request, userClient, user.id);
|
||||
|
||||
// # Create an avatar thread (root + reply) whose post id upgrade-to will re-check
|
||||
await postMessage(adminClient, publicChannel.id, 'upgrade-check avatar separator');
|
||||
const avatarPost = await postMessage(userClient, publicChannel.id, 'upgrade-check avatar message');
|
||||
await postMessage(adminClient, publicChannel.id, 'upgrade-check thread reply for avatar footer', avatarPost.id);
|
||||
|
||||
mergeUpgradeBaseline({avatarPostId: avatarPost.id});
|
||||
});
|
||||
|
||||
test('admin channel messages and dm/gm channel ids', async ({pw}) => {
|
||||
const {adminClient, userClient, adminMe, peers, publicChannel, privateChannel} =
|
||||
await loadUpgradeFromContext(pw);
|
||||
|
||||
// # Post admin messages in public/private (including a search marker)
|
||||
await postMessage(adminClient, publicChannel.id, seeder.UPGRADE_ADMIN_PUBLIC_MESSAGE);
|
||||
await postMessage(adminClient, publicChannel.id, seeder.UPGRADE_ADMIN_SEARCH_MESSAGE);
|
||||
await postMessage(adminClient, privateChannel.id, seeder.UPGRADE_ADMIN_PRIVATE_MESSAGE);
|
||||
|
||||
// # Create admin DM/GM and post messages there
|
||||
const adminDmChannel = await ensureDirectChannel(adminClient, adminMe.id, peers[0].id);
|
||||
await postMessage(adminClient, adminDmChannel.id, seeder.UPGRADE_ADMIN_DM_MESSAGE);
|
||||
|
||||
const adminGmChannel = await ensureGroupChannel(adminClient, [adminMe.id, peers[1].id, peers[2].id]);
|
||||
await postMessage(adminClient, adminGmChannel.id, seeder.UPGRADE_ADMIN_GM_MESSAGE);
|
||||
|
||||
// # Post an admin avatar message for post-upgrade re-check
|
||||
await postMessage(userClient, publicChannel.id, 'upgrade-check admin avatar separator');
|
||||
await postMessage(adminClient, publicChannel.id, seeder.UPGRADE_ADMIN_AVATAR_MESSAGE);
|
||||
|
||||
// # Record admin DM/GM channel ids for upgrade-to
|
||||
mergeUpgradeBaseline({
|
||||
adminDmChannelId: adminDmChannel.id,
|
||||
adminGmChannelId: adminGmChannel.id,
|
||||
});
|
||||
});
|
||||
|
||||
test('playbooks plugin enabled', async ({pw}) => {
|
||||
// # Enable prepackaged playbooks on the from-image (API only — not boot env)
|
||||
const {adminClient} = await loadUpgradeFromContext(pw);
|
||||
const playbooks = await ensureUpgradePlaybooksEnabled(adminClient);
|
||||
|
||||
// # Record playbooks config/runtime state for upgrade-to
|
||||
mergeUpgradeBaseline({playbooks});
|
||||
});
|
||||
|
||||
test('demo plugin active', async ({pw, request}) => {
|
||||
const baseline = readUpgradeBaseline();
|
||||
test.skip(!baseline.license?.isLicensed, 'Demo plugin requires an enterprise license');
|
||||
|
||||
// # Install and enable the demo plugin on the from-image
|
||||
const {adminClient} = await loadUpgradeFromContext(pw);
|
||||
await ensureUpgradePluginActive(request, adminClient);
|
||||
});
|
||||
});
|
||||
147
e2e-tests/playwright/upgrade-specs/to/upgrade_to.spec.ts
Normal file
147
e2e-tests/playwright/upgrade-specs/to/upgrade_to.spec.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {expect, getPluginStatus, saveUpgradePhaseLogs, test} from '@mattermost/playwright-lib';
|
||||
|
||||
import {
|
||||
assertChannelContainsMessage,
|
||||
assertSearchFinds,
|
||||
assertUpgradeFileBackendMatches,
|
||||
assertUpgradeLicenseMatches,
|
||||
assertUpgradePlaybooksMatches,
|
||||
assertUpgradeSchemaMatches,
|
||||
channelIdForAttachmentSeed,
|
||||
ensureUpgradeTeam,
|
||||
loadUpgradeToContext,
|
||||
readServerIdentity,
|
||||
seeder,
|
||||
} from '../upgrade_fixtures';
|
||||
|
||||
/**
|
||||
* @objective Re-verifies upgrade actors and content via Client4 after swap to the to-image.
|
||||
* Each test checks a slice of `.upgrade_baseline.json` written by upgrade-from.
|
||||
*/
|
||||
test.describe.serial('upgrade-to baseline', {tag: ['@upgrade-to']}, () => {
|
||||
test.afterAll(async () => {
|
||||
// Snapshot to-image logs (includes post-swap migrations + verification traffic).
|
||||
await saveUpgradePhaseLogs('to');
|
||||
});
|
||||
|
||||
test('server identity upgraded and license matches baseline', async ({pw}) => {
|
||||
const {adminClient, baseline} = await loadUpgradeToContext(pw);
|
||||
|
||||
// # Read the running to-image identity
|
||||
const toIdentity = await readServerIdentity(adminClient);
|
||||
|
||||
// * Verify server version/build changed from the from-image baseline
|
||||
expect(`${toIdentity.serverVersion}+${toIdentity.buildNumber}`).not.toBe(
|
||||
`${baseline.serverVersion}+${baseline.buildNumber}`,
|
||||
);
|
||||
|
||||
// * Verify license state still matches the from-image baseline after swap
|
||||
await assertUpgradeLicenseMatches(adminClient, baseline);
|
||||
});
|
||||
|
||||
test('schema migrations match baseline', async ({pw}) => {
|
||||
const {adminClient, baseline} = await loadUpgradeToContext(pw);
|
||||
test.skip(!baseline.schema, 'From-image did not expose GET /api/v4/system/schema/version');
|
||||
|
||||
// * Verify migrations are present and did not regress vs the from-image baseline
|
||||
const schema = await assertUpgradeSchemaMatches(adminClient, baseline);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`upgrade-to: schema versionMax=${schema?.versionMax} count=${schema?.count} (from ${baseline.schema?.versionMax}/${baseline.schema?.count})`,
|
||||
);
|
||||
});
|
||||
|
||||
test('user channel messages match baseline', async ({pw}) => {
|
||||
const {userClient, adminClient, baseline, publicChannelId, privateChannelId} = await loadUpgradeToContext(pw);
|
||||
const team = await ensureUpgradeTeam(adminClient);
|
||||
|
||||
// * Verify user public/private/DM/GM messages still exist
|
||||
await assertChannelContainsMessage(userClient, publicChannelId, seeder.UPGRADE_PUBLIC_MESSAGE);
|
||||
await assertChannelContainsMessage(userClient, privateChannelId, seeder.UPGRADE_PRIVATE_MESSAGE);
|
||||
await assertChannelContainsMessage(userClient, baseline.userDmChannelId, seeder.UPGRADE_DM_MESSAGE);
|
||||
await assertChannelContainsMessage(userClient, baseline.userGmChannelId, seeder.UPGRADE_GM_MESSAGE);
|
||||
|
||||
// * Verify search still finds the user search marker
|
||||
await assertSearchFinds(userClient, team.id, seeder.UPGRADE_SEARCH_MESSAGE);
|
||||
});
|
||||
|
||||
test('attachments and file backend match baseline', async ({pw, request}) => {
|
||||
const {adminClient, userClient, baseline, publicChannelId, privateChannelId} = await loadUpgradeToContext(pw);
|
||||
|
||||
// * Verify each seeded attachment post message still exists
|
||||
for (const seed of seeder.UPGRADE_ATTACHMENT_SEEDS) {
|
||||
const channelId = channelIdForAttachmentSeed(seed.channel, {
|
||||
publicId: publicChannelId,
|
||||
privateId: privateChannelId,
|
||||
userDmId: baseline.userDmChannelId,
|
||||
});
|
||||
const client = seed.author === 'user' ? userClient : adminClient;
|
||||
await assertChannelContainsMessage(client, channelId, seed.message);
|
||||
}
|
||||
|
||||
// * Verify attachments/profile image remain downloadable on the active file backend
|
||||
await assertUpgradeFileBackendMatches(request, adminClient, userClient, baseline);
|
||||
});
|
||||
|
||||
test('profile image and avatar thread match baseline', async ({pw}) => {
|
||||
const {userClient, baseline, publicChannelId} = await loadUpgradeToContext(pw);
|
||||
|
||||
// * Verify the avatar root post survived the upgrade
|
||||
const avatarPost = await userClient.getPost(baseline.avatarPostId);
|
||||
expect(avatarPost.id).toBe(baseline.avatarPostId);
|
||||
expect(avatarPost.message).toContain('upgrade-check avatar message');
|
||||
|
||||
// * Verify the thread reply is still under that root post
|
||||
const threadPosts = await userClient.getPosts(publicChannelId, 0, 60);
|
||||
const threadReply = Object.values(threadPosts.posts).find(
|
||||
(post) => post.root_id === baseline.avatarPostId && post.message.includes('thread reply'),
|
||||
);
|
||||
expect(threadReply).toBeDefined();
|
||||
});
|
||||
|
||||
test('admin channel messages match baseline', async ({pw}) => {
|
||||
const {adminClient, baseline, publicChannelId, privateChannelId} = await loadUpgradeToContext(pw);
|
||||
const team = await ensureUpgradeTeam(adminClient);
|
||||
|
||||
// * Verify admin public/private/DM/GM messages still exist
|
||||
await assertChannelContainsMessage(adminClient, publicChannelId, seeder.UPGRADE_ADMIN_PUBLIC_MESSAGE);
|
||||
await assertChannelContainsMessage(adminClient, publicChannelId, seeder.UPGRADE_ADMIN_AVATAR_MESSAGE);
|
||||
await assertChannelContainsMessage(adminClient, privateChannelId, seeder.UPGRADE_ADMIN_PRIVATE_MESSAGE);
|
||||
await assertChannelContainsMessage(adminClient, baseline.adminDmChannelId, seeder.UPGRADE_ADMIN_DM_MESSAGE);
|
||||
await assertChannelContainsMessage(adminClient, baseline.adminGmChannelId, seeder.UPGRADE_ADMIN_GM_MESSAGE);
|
||||
|
||||
// * Verify search still finds the admin search marker
|
||||
await assertSearchFinds(adminClient, team.id, seeder.UPGRADE_ADMIN_SEARCH_MESSAGE);
|
||||
});
|
||||
|
||||
test('playbooks plugin matches baseline', async ({pw}) => {
|
||||
const {adminClient, baseline} = await loadUpgradeToContext(pw);
|
||||
test.skip(!baseline.playbooks, 'From-image did not record playbooks plugin state');
|
||||
|
||||
// * Verify playbooks config/runtime state survived the swap (read-only — no re-enable)
|
||||
await assertUpgradePlaybooksMatches(adminClient, baseline);
|
||||
});
|
||||
|
||||
test('mmctl talks to the upgraded server', async ({pw}) => {
|
||||
// * Verify mmctl still works against the upgraded server, including unlicensed team runs
|
||||
const mmctlResult = await pw.runMmctl(['version']);
|
||||
expect(mmctlResult.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test('demo plugin matches baseline', async ({pw}) => {
|
||||
const {adminClient, baseline} = await loadUpgradeToContext(pw);
|
||||
|
||||
test.skip(!baseline.license?.isLicensed, 'Demo plugin requires an enterprise license');
|
||||
|
||||
// * Verify the demo plugin is still installed and active after upgrade (read-only)
|
||||
await expect
|
||||
.poll(() => getPluginStatus(adminClient, seeder.UPGRADE_PLUGIN_ID), {timeout: 30000})
|
||||
.toEqual({
|
||||
isInstalled: true,
|
||||
isActive: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
824
e2e-tests/playwright/upgrade-specs/upgrade_fixtures.ts
Normal file
824
e2e-tests/playwright/upgrade-specs/upgrade_fixtures.ts
Normal file
@@ -0,0 +1,824 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
// Fixed seed data shared across separate upgrade-from and upgrade-to processes (see `seeder`).
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type {Client4} from '@mattermost/client';
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
import type {Post} from '@mattermost/types/posts';
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
import type {APIRequestContext} from '@playwright/test';
|
||||
|
||||
import {
|
||||
expect,
|
||||
getFileData,
|
||||
getFileFromAsset,
|
||||
getPluginStatus,
|
||||
listAzuriteBlobNames,
|
||||
listMattermostDataFiles,
|
||||
listMinioObjectKeys,
|
||||
playbooksPluginId,
|
||||
testConfig,
|
||||
} from '@mattermost/playwright-lib';
|
||||
|
||||
const BASELINE_PATH = path.resolve(process.cwd(), '.upgrade_baseline.json');
|
||||
|
||||
/** Attachment files from `asset/`, spread across channel types in upgrade-from. */
|
||||
export type UpgradeAttachmentSeed = {
|
||||
fileName: string;
|
||||
message: string;
|
||||
author: 'user' | 'admin';
|
||||
channel: 'public' | 'private' | 'dm';
|
||||
};
|
||||
|
||||
/** Fixed seed data shared across upgrade-from and upgrade-to processes. */
|
||||
export const seeder = {
|
||||
UPGRADE_TEAM_NAME: 'upgrade-test',
|
||||
UPGRADE_PUBLIC_CHANNEL_NAME: 'upgrade-public',
|
||||
UPGRADE_PRIVATE_CHANNEL_NAME: 'upgrade-private',
|
||||
|
||||
UPGRADE_USER: {
|
||||
username: 'upgradetestuser',
|
||||
email: 'upgradetestuser@sample.mattermost.com',
|
||||
password: 'Upgradetestuser123!',
|
||||
first_name: 'Upgrade',
|
||||
last_name: 'User',
|
||||
} as UserProfile,
|
||||
|
||||
UPGRADE_PEER_USERS: [
|
||||
{
|
||||
username: 'upgradetestpeer1',
|
||||
email: 'upgradetestpeer1@sample.mattermost.com',
|
||||
password: 'Upgradetestpeer123!',
|
||||
},
|
||||
{
|
||||
username: 'upgradetestpeer2',
|
||||
email: 'upgradetestpeer2@sample.mattermost.com',
|
||||
password: 'Upgradetestpeer123!',
|
||||
},
|
||||
{
|
||||
username: 'upgradetestpeer3',
|
||||
email: 'upgradetestpeer3@sample.mattermost.com',
|
||||
password: 'Upgradetestpeer123!',
|
||||
},
|
||||
].map((u) => u as UserProfile),
|
||||
|
||||
UPGRADE_PUBLIC_MESSAGE: 'upgrade-check public channel message',
|
||||
UPGRADE_PRIVATE_MESSAGE: 'upgrade-check private channel message',
|
||||
UPGRADE_DM_MESSAGE: 'upgrade-check direct message',
|
||||
UPGRADE_GM_MESSAGE: 'upgrade-check group message',
|
||||
UPGRADE_SEARCH_MESSAGE: 'upgrade-check searchable-marker-abc123',
|
||||
UPGRADE_PROFILE_PHOTO_FILE: 'mattermost-icon_128x128.png',
|
||||
UPGRADE_ADMIN_ATTACHMENT_MESSAGE: 'upgrade-check admin attachment message',
|
||||
|
||||
UPGRADE_ATTACHMENT_SEEDS: [
|
||||
{
|
||||
fileName: 'small-image.png',
|
||||
message: 'upgrade-check public png attachment',
|
||||
author: 'user',
|
||||
channel: 'public',
|
||||
},
|
||||
{
|
||||
fileName: 'sample_text_file.txt',
|
||||
message: 'upgrade-check private text attachment',
|
||||
author: 'user',
|
||||
channel: 'private',
|
||||
},
|
||||
{
|
||||
fileName: 'mattermost.png',
|
||||
message: 'upgrade-check admin attachment message',
|
||||
author: 'admin',
|
||||
channel: 'public',
|
||||
},
|
||||
{
|
||||
fileName: 'image-400x40.jpg',
|
||||
message: 'upgrade-check dm jpeg attachment',
|
||||
author: 'user',
|
||||
channel: 'dm',
|
||||
},
|
||||
] as UpgradeAttachmentSeed[],
|
||||
|
||||
UPGRADE_ADMIN_PUBLIC_MESSAGE: 'upgrade-check admin public channel message',
|
||||
UPGRADE_ADMIN_PRIVATE_MESSAGE: 'upgrade-check admin private channel message',
|
||||
UPGRADE_ADMIN_DM_MESSAGE: 'upgrade-check admin direct message',
|
||||
UPGRADE_ADMIN_GM_MESSAGE: 'upgrade-check admin group message',
|
||||
UPGRADE_ADMIN_SEARCH_MESSAGE: 'upgrade-check admin-searchable-marker-xyz789',
|
||||
UPGRADE_ADMIN_AVATAR_MESSAGE: 'upgrade-check admin avatar message',
|
||||
|
||||
UPGRADE_PLUGIN_ID: 'com.mattermost.demo-plugin',
|
||||
UPGRADE_PLUGIN_BUNDLE_URL:
|
||||
'https://github.com/mattermost/mattermost-plugin-demo/releases/download/v0.11.0/mattermost-plugin-demo-v0.11.0.tar.gz',
|
||||
UPGRADE_PLUGIN_BUNDLE_PATH: path.resolve(process.cwd(), '.upgrade_plugin_bundle.tar.gz'),
|
||||
};
|
||||
|
||||
/** Prepackaged plugin exercised across upgrade-from → upgrade-to (enabled in the from-spec). */
|
||||
export const UPGRADE_PREPACKAGED_PLUGIN_ID = playbooksPluginId;
|
||||
|
||||
export function channelIdForAttachmentSeed(
|
||||
channel: UpgradeAttachmentSeed['channel'],
|
||||
channels: {publicId: string; privateId: string; userDmId: string},
|
||||
): string {
|
||||
switch (channel) {
|
||||
case 'public':
|
||||
return channels.publicId;
|
||||
case 'private':
|
||||
return channels.privateId;
|
||||
case 'dm':
|
||||
return channels.userDmId;
|
||||
default: {
|
||||
const exhaustive: never = channel;
|
||||
throw new Error(`Unknown attachment seed channel: ${exhaustive}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Looks up the shared upgrade-test team, creating it on first (from-phase) run. */
|
||||
export async function ensureUpgradeTeam(adminClient: Client4) {
|
||||
try {
|
||||
return await adminClient.getTeamByName(seeder.UPGRADE_TEAM_NAME);
|
||||
} catch {
|
||||
return adminClient.createTeam({
|
||||
name: seeder.UPGRADE_TEAM_NAME,
|
||||
display_name: 'Upgrade Test',
|
||||
type: 'O',
|
||||
} as any);
|
||||
}
|
||||
}
|
||||
|
||||
/** Looks up a shared upgrade-test user by username, creating (and adding to the team) if new. */
|
||||
export async function ensureUpgradeUser(adminClient: Client4, teamId: string, spec: UserProfile) {
|
||||
let user: UserProfile;
|
||||
try {
|
||||
const existing = await adminClient.getUserByUsername(spec.username);
|
||||
user = {...existing, password: spec.password} as UserProfile;
|
||||
} catch {
|
||||
const created = await adminClient.createUser(spec, '', '');
|
||||
await adminClient.addToTeam(teamId, created.id);
|
||||
user = {...created, password: spec.password} as UserProfile;
|
||||
}
|
||||
|
||||
await adminClient.savePreferences(user.id, [
|
||||
{user_id: user.id, category: 'tutorial_step', name: user.id, value: '999'},
|
||||
{user_id: user.id, category: 'crt_thread_pane_step', name: user.id, value: '999'},
|
||||
{user_id: user.id, category: 'onboarding_task_list', name: 'onboarding_task_list_show', value: 'false'},
|
||||
{user_id: user.id, category: 'onboarding_task_list', name: 'onboarding_task_list_open', value: 'false'},
|
||||
]);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/** Looks up a shared upgrade-test channel by name, creating it under the team if new. */
|
||||
export async function ensureUpgradeChannel(adminClient: Client4, teamId: string, name: string, type: 'O' | 'P') {
|
||||
try {
|
||||
return await adminClient.getChannelByName(teamId, name);
|
||||
} catch {
|
||||
return adminClient.createChannel({
|
||||
team_id: teamId,
|
||||
name,
|
||||
display_name: name,
|
||||
type,
|
||||
} as any);
|
||||
}
|
||||
}
|
||||
|
||||
/** Adds the user to the channel only when not already a member (avoids "already a member" warns). */
|
||||
export async function ensureChannelMember(adminClient: Client4, userId: string, channelId: string): Promise<void> {
|
||||
try {
|
||||
await adminClient.getChannelMember(channelId, userId);
|
||||
} catch {
|
||||
await adminClient.addToChannel(userId, channelId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates or returns an existing DM between the two users. */
|
||||
export async function ensureDirectChannel(client: Client4, userId: string, peerUserId: string): Promise<Channel> {
|
||||
return client.createDirectChannel([userId, peerUserId]);
|
||||
}
|
||||
|
||||
/** Creates or returns an existing GM among the given users (includes the acting user). */
|
||||
export async function ensureGroupChannel(client: Client4, userIds: string[]): Promise<Channel> {
|
||||
return client.createGroupChannel(userIds);
|
||||
}
|
||||
|
||||
/** Creates a text post in the given channel. */
|
||||
export async function postMessage(client: Client4, channelId: string, message: string, rootId?: string): Promise<Post> {
|
||||
return client.createPost({
|
||||
channel_id: channelId,
|
||||
message,
|
||||
...(rootId ? {root_id: rootId} : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** Uploads an asset file and creates a post that attaches it. */
|
||||
export async function postWithAttachment(
|
||||
client: Client4,
|
||||
channelId: string,
|
||||
message: string,
|
||||
fileName: string,
|
||||
): Promise<Post> {
|
||||
const formData = new FormData();
|
||||
formData.set('channel_id', channelId);
|
||||
formData.set('files', getFileFromAsset(fileName), fileName);
|
||||
const uploaded = await client.uploadFile(formData);
|
||||
const fileId = uploaded.file_infos[0].id;
|
||||
|
||||
return client.createPost({
|
||||
channel_id: channelId,
|
||||
message,
|
||||
file_ids: [fileId],
|
||||
});
|
||||
}
|
||||
|
||||
export type UpgradeAttachmentBaseline = {
|
||||
postId: string;
|
||||
fileName: string;
|
||||
author: 'user' | 'admin';
|
||||
};
|
||||
|
||||
/** Seeds attachment posts from `seeder.UPGRADE_ATTACHMENT_SEEDS` and verifies each upload on the active file backend. */
|
||||
export async function seedUpgradeAttachments(
|
||||
request: APIRequestContext,
|
||||
userClient: Client4,
|
||||
adminClient: Client4,
|
||||
channels: {publicId: string; privateId: string; userDmId: string},
|
||||
): Promise<UpgradeAttachmentBaseline[]> {
|
||||
const clientFor = (author: 'user' | 'admin') => (author === 'user' ? userClient : adminClient);
|
||||
|
||||
const baseline: UpgradeAttachmentBaseline[] = [];
|
||||
for (const seed of seeder.UPGRADE_ATTACHMENT_SEEDS) {
|
||||
const client = clientFor(seed.author);
|
||||
const post = await postWithAttachment(
|
||||
client,
|
||||
channelIdForAttachmentSeed(seed.channel, channels),
|
||||
seed.message,
|
||||
seed.fileName,
|
||||
);
|
||||
await verifyPostAttachmentDownloadable(request, client, post.id, seed.fileName);
|
||||
baseline.push({postId: post.id, fileName: seed.fileName, author: seed.author});
|
||||
}
|
||||
return baseline;
|
||||
}
|
||||
|
||||
/** Asserts a channel's recent posts include the given message text. */
|
||||
export async function assertChannelContainsMessage(client: Client4, channelId: string, message: string): Promise<void> {
|
||||
const postList = await client.getPosts(channelId, 0, 60);
|
||||
const found = Object.values(postList.posts).some((post) => post.message.includes(message));
|
||||
expect(found).toBe(true);
|
||||
}
|
||||
|
||||
/** Asserts team search returns a post containing the given terms. */
|
||||
export async function assertSearchFinds(client: Client4, teamId: string, terms: string): Promise<void> {
|
||||
const results = await client.searchPosts(teamId, terms, false);
|
||||
const found = Object.values(results.posts).some((post) => post.message.includes(terms));
|
||||
expect(found).toBe(true);
|
||||
}
|
||||
|
||||
/** Uploads a profile image for the given user via Client4. */
|
||||
export async function uploadUpgradeProfileImage(client: Client4, userId: string, fileName: string): Promise<void> {
|
||||
await client.uploadProfileImage(userId, getFileFromAsset(fileName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts a user's profile picture returns non-empty bytes.
|
||||
* Uses Playwright's `request` fixture (not raw fetch / browser navigation).
|
||||
*/
|
||||
export async function assertProfileImageFetchable(
|
||||
request: APIRequestContext,
|
||||
client: Client4,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
const user = await client.getUser(userId);
|
||||
const url = client.getProfilePictureUrl(userId, user.last_picture_update || 0);
|
||||
const response = await request.get(url, {
|
||||
headers: {Authorization: `Bearer ${client.getToken()}`},
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
expect((await response.body()).byteLength).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
export type ServerIdentity = {
|
||||
serverVersion: string;
|
||||
buildNumber: string;
|
||||
};
|
||||
|
||||
/** Reads FileSettings.DriverName from the running server (local, amazons3, azureblob, …). */
|
||||
export async function readFileDriverName(client: Client4): Promise<string> {
|
||||
const config = await client.getConfig();
|
||||
return config.FileSettings?.DriverName || 'local';
|
||||
}
|
||||
|
||||
/** Reads server Version + BuildNumber from the client config API. */
|
||||
export async function readServerIdentity(client: Client4): Promise<ServerIdentity> {
|
||||
// Prime X-Version-Id on the client session.
|
||||
await client.getMe();
|
||||
const config = await client.getClientConfig();
|
||||
return {
|
||||
serverVersion: config.Version || client.getServerVersion(),
|
||||
buildNumber: config.BuildNumber || '',
|
||||
};
|
||||
}
|
||||
|
||||
/** License state captured on the from-image for post-upgrade comparison. */
|
||||
export type UpgradeLicenseBaseline = {
|
||||
isLicensed: boolean;
|
||||
skuShortName?: string;
|
||||
skuName?: string;
|
||||
isTrial?: boolean;
|
||||
users?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* MM_LICENSE at container boot sets an in-memory license only (LoadLicense env path).
|
||||
* Persist it to Postgres as well so license state is dual-sourced (env + DB) across the swap.
|
||||
* upgrade-swap-to keeps the same host MM_LICENSE as upgrade-from; upgrade-to compares baseline.
|
||||
*/
|
||||
export async function persistUpgradeLicenseFromEnv(client: Client4): Promise<void> {
|
||||
const licenseKey = process.env.MM_LICENSE?.trim();
|
||||
if (!licenseKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const file = new File([licenseKey], 'license.mattermost', {type: 'application/octet-stream'});
|
||||
await client.uploadLicense(file);
|
||||
}
|
||||
|
||||
/** Reads the current server license into a baseline-friendly shape. */
|
||||
export async function readUpgradeLicenseBaseline(client: Client4): Promise<UpgradeLicenseBaseline> {
|
||||
const license = await client.getClientLicenseOld();
|
||||
if (license.IsLicensed !== 'true') {
|
||||
return {isLicensed: false};
|
||||
}
|
||||
|
||||
return {
|
||||
isLicensed: true,
|
||||
skuShortName: license.SkuShortName,
|
||||
skuName: license.SkuName,
|
||||
isTrial: license.IsTrial === 'true',
|
||||
users: license.Users,
|
||||
};
|
||||
}
|
||||
|
||||
/** Asserts license state after upgrade matches what was recorded on the from-image. */
|
||||
export async function assertUpgradeLicenseMatches(client: Client4, baseline: UpgradeBaseline): Promise<void> {
|
||||
const current = await readUpgradeLicenseBaseline(client);
|
||||
|
||||
if (!baseline.license) {
|
||||
expect(current.isLicensed).toBe(true);
|
||||
return;
|
||||
}
|
||||
|
||||
expect(current.isLicensed).toBe(baseline.license.isLicensed);
|
||||
if (!baseline.license.isLicensed) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(current.skuShortName).toBe(baseline.license.skuShortName);
|
||||
expect(current.skuName).toBe(baseline.license.skuName);
|
||||
expect(current.isTrial).toBe(baseline.license.isTrial);
|
||||
expect(current.users).toBe(baseline.license.users);
|
||||
}
|
||||
|
||||
/** Schema migration snapshot from GET /api/v4/system/schema/version (db_migrations). */
|
||||
export type UpgradeSchemaBaseline = {
|
||||
versionMax: number;
|
||||
count: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads applied schema migrations. Returns undefined when the from-image lacks the endpoint
|
||||
* or returns an empty list — upgrade-to then skips the schema assert.
|
||||
*/
|
||||
export async function readUpgradeSchemaBaseline(client: Client4): Promise<UpgradeSchemaBaseline | undefined> {
|
||||
try {
|
||||
const migrations = await client.getAppliedSchemaMigrations();
|
||||
if (!migrations.length) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
versionMax: Math.max(...migrations.map((m) => m.version)),
|
||||
count: migrations.length,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts schema migrations did not regress after the to-image swap.
|
||||
* Skips when the from-phase could not record a schema baseline.
|
||||
* Returns the to-image snapshot for logging.
|
||||
*/
|
||||
export async function assertUpgradeSchemaMatches(
|
||||
client: Client4,
|
||||
baseline: UpgradeBaseline,
|
||||
): Promise<UpgradeSchemaBaseline | undefined> {
|
||||
if (!baseline.schema) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const current = await readUpgradeSchemaBaseline(client);
|
||||
expect(current).toBeDefined();
|
||||
expect(current!.count).toBeGreaterThan(0);
|
||||
expect(current!.versionMax).toBeGreaterThanOrEqual(baseline.schema.versionMax);
|
||||
expect(current!.count).toBeGreaterThanOrEqual(baseline.schema.count);
|
||||
return current;
|
||||
}
|
||||
|
||||
/** Asserts the server reports a licensed Enterprise deployment. */
|
||||
export async function assertLicensed(client: Client4): Promise<void> {
|
||||
const license = await readUpgradeLicenseBaseline(client);
|
||||
expect(license.isLicensed).toBe(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade harness defaults via patchConfig (not MM_* boot env) so later tests can override
|
||||
* without a container restart. Call before creating users / seeding files.
|
||||
* Patches are applied in sections so a from-image rejection fails clearly on that section.
|
||||
*/
|
||||
export async function ensureUpgradeServerConfig(client: Client4): Promise<void> {
|
||||
await client.patchConfig({
|
||||
EmailSettings: {
|
||||
SendEmailNotifications: true,
|
||||
},
|
||||
FileSettings: {
|
||||
EnablePublicLink: true,
|
||||
},
|
||||
ServiceSettings: {
|
||||
SiteURL: testConfig.internalBaseURL,
|
||||
},
|
||||
} as Parameters<typeof client.patchConfig>[0]);
|
||||
|
||||
await client.patchConfig({
|
||||
AccessControlSettings: {
|
||||
EnableAttributeBasedAccessControl: false,
|
||||
EnableUserManagedAttributes: false,
|
||||
},
|
||||
} as Parameters<typeof client.patchConfig>[0]);
|
||||
|
||||
// No PluginStates here — patchConfig replaces that map wholesale, which would undo whatever
|
||||
// the specs enabled. Plugin enablement is per-id via enablePlugin, and recorded in the
|
||||
// baseline. EnableUploads is likewise absent: SERVER_ENV_BASELINE owns it and the API 403s.
|
||||
await client.patchConfig({
|
||||
PluginSettings: {
|
||||
Enable: true,
|
||||
AllowInsecureDownloadURL: true,
|
||||
},
|
||||
} as Parameters<typeof client.patchConfig>[0]);
|
||||
}
|
||||
|
||||
/** Installs (if needed) and enables the upgrade demo plugin; asserts it is active. */
|
||||
export async function ensureUpgradePluginActive(request: APIRequestContext, client: Client4): Promise<void> {
|
||||
// Only the plugin's own settings — PluginStates is left alone so this does not undo the
|
||||
// playbooks enablement the from-spec just recorded in the baseline.
|
||||
await client.patchConfig({
|
||||
PluginSettings: {
|
||||
Plugins: {
|
||||
[seeder.UPGRADE_PLUGIN_ID]: {
|
||||
username: 'demouser',
|
||||
channelname: 'demo_plugin',
|
||||
lastname: 'User',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof client.patchConfig>[0]);
|
||||
|
||||
const bundlePath = await ensurePluginBundleDownloaded(request);
|
||||
const status = await getPluginStatus(client, seeder.UPGRADE_PLUGIN_ID);
|
||||
|
||||
if (!status.isInstalled) {
|
||||
const fileData = getFileData(bundlePath);
|
||||
await client.uploadPlugin(fileData, true);
|
||||
}
|
||||
if (!status.isActive) {
|
||||
await client.enablePlugin(seeder.UPGRADE_PLUGIN_ID);
|
||||
}
|
||||
|
||||
// Activation is asynchronous server-side, so poll rather than assert immediately.
|
||||
await expect
|
||||
.poll(() => getPluginStatus(client, seeder.UPGRADE_PLUGIN_ID), {timeout: 30000})
|
||||
.toEqual({
|
||||
isInstalled: true,
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Snapshot of a prepackaged plugin's config + runtime state for upgrade-to comparison. */
|
||||
export type UpgradePrepackagedPluginBaseline = {
|
||||
pluginId: string;
|
||||
configEnabled: boolean;
|
||||
isInstalled: boolean;
|
||||
isActive: boolean;
|
||||
version: string;
|
||||
};
|
||||
|
||||
/** Reads PluginStates + getPlugins for a prepackaged plugin id. */
|
||||
export async function readUpgradePrepackagedPluginBaseline(
|
||||
client: Client4,
|
||||
pluginId: string = UPGRADE_PREPACKAGED_PLUGIN_ID,
|
||||
): Promise<UpgradePrepackagedPluginBaseline> {
|
||||
const config = await client.getConfig();
|
||||
const plugins = await client.getPlugins();
|
||||
const active = plugins.active.find((p) => p.id === pluginId);
|
||||
const inactive = plugins.inactive.find((p) => p.id === pluginId);
|
||||
const manifest = active || inactive;
|
||||
|
||||
return {
|
||||
pluginId,
|
||||
configEnabled: Boolean(config.PluginSettings?.PluginStates?.[pluginId]?.Enable),
|
||||
isInstalled: Boolean(manifest),
|
||||
isActive: Boolean(active),
|
||||
version: manifest?.version || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables the upgrade prepackaged plugin (playbooks) via the enablePlugin API — not boot env.
|
||||
* Returns the observed state for the upgrade baseline.
|
||||
*/
|
||||
export async function ensureUpgradePlaybooksEnabled(client: Client4): Promise<UpgradePrepackagedPluginBaseline> {
|
||||
const initial = await getPluginStatus(client, UPGRADE_PREPACKAGED_PLUGIN_ID);
|
||||
expect(initial.isInstalled).toBe(true);
|
||||
|
||||
if (!initial.isActive) {
|
||||
await client.enablePlugin(UPGRADE_PREPACKAGED_PLUGIN_ID);
|
||||
}
|
||||
|
||||
const deadline = Date.now() + 60000;
|
||||
while (Date.now() < deadline) {
|
||||
const current = await getPluginStatus(client, UPGRADE_PREPACKAGED_PLUGIN_ID);
|
||||
if (current.isActive) {
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
|
||||
const baseline = await readUpgradePrepackagedPluginBaseline(client);
|
||||
expect(baseline.isInstalled).toBe(true);
|
||||
expect(baseline.isActive).toBe(true);
|
||||
expect(baseline.configEnabled).toBe(true);
|
||||
return baseline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts playbooks plugin state after upgrade matches the from-image baseline.
|
||||
* Read-only — does not re-enable or patchConfig PluginStates.
|
||||
*/
|
||||
export async function assertUpgradePlaybooksMatches(client: Client4, baseline: UpgradeBaseline): Promise<void> {
|
||||
expect(baseline.playbooks).toBeDefined();
|
||||
const current = await readUpgradePrepackagedPluginBaseline(client, baseline.playbooks!.pluginId);
|
||||
|
||||
expect(current.pluginId).toBe(baseline.playbooks!.pluginId);
|
||||
expect(current.isInstalled).toBe(baseline.playbooks!.isInstalled);
|
||||
expect(current.isActive).toBe(baseline.playbooks!.isActive);
|
||||
expect(current.configEnabled).toBe(baseline.playbooks!.configEnabled);
|
||||
}
|
||||
|
||||
/** Downloads the demo plugin bundle via Playwright `request` (not raw fetch). */
|
||||
export async function ensurePluginBundleDownloaded(request: APIRequestContext): Promise<string> {
|
||||
if (fs.existsSync(seeder.UPGRADE_PLUGIN_BUNDLE_PATH)) {
|
||||
return seeder.UPGRADE_PLUGIN_BUNDLE_PATH;
|
||||
}
|
||||
|
||||
const attempts = 3;
|
||||
let lastError: Error | undefined;
|
||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||
let response: Awaited<ReturnType<APIRequestContext['get']>> | undefined;
|
||||
try {
|
||||
response = await request.get(seeder.UPGRADE_PLUGIN_BUNDLE_URL);
|
||||
if (response.ok()) {
|
||||
fs.writeFileSync(seeder.UPGRADE_PLUGIN_BUNDLE_PATH, await response.body());
|
||||
return seeder.UPGRADE_PLUGIN_BUNDLE_PATH;
|
||||
}
|
||||
lastError = new Error(
|
||||
`Failed to download plugin bundle (attempt ${attempt}/${attempts}): ${response.status()} ${response.statusText()}`,
|
||||
);
|
||||
} catch (error) {
|
||||
lastError =
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(`Failed to download plugin bundle (attempt ${attempt}/${attempts}): ${String(error)}`);
|
||||
} finally {
|
||||
await response?.dispose();
|
||||
}
|
||||
if (attempt < attempts) {
|
||||
await new Promise((r) => setTimeout(r, 1000 * attempt));
|
||||
}
|
||||
}
|
||||
throw lastError ?? new Error('Failed to download plugin bundle');
|
||||
}
|
||||
|
||||
export type UpgradeFromContext = {
|
||||
adminClient: Client4;
|
||||
userClient: Client4;
|
||||
adminMe: UserProfile;
|
||||
user: UserProfile;
|
||||
peers: UserProfile[];
|
||||
publicChannel: Channel;
|
||||
privateChannel: Channel;
|
||||
};
|
||||
|
||||
/** Process-local cache so serial upgrade tests do not re-GET/re-add membership every time. */
|
||||
let upgradeFromContextCache: UpgradeFromContext | undefined;
|
||||
|
||||
export function clearUpgradeFromContextCache(): void {
|
||||
upgradeFromContextCache = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotently ensures shared upgrade actors/channels and returns API clients for from-phase tests.
|
||||
*
|
||||
* `patchConfig: false` skips the config patch for the to-phase, where writing settings before
|
||||
* asserting them would repair anything the upgrade dropped instead of reporting it.
|
||||
*/
|
||||
export async function loadUpgradeFromContext(
|
||||
pw: {
|
||||
getAdminClient: () => Promise<{adminClient: Client4}>;
|
||||
makeClient: (user: UserProfile) => Promise<{client: Client4 | undefined}>;
|
||||
},
|
||||
{patchConfig = true}: {patchConfig?: boolean} = {},
|
||||
): Promise<UpgradeFromContext> {
|
||||
if (upgradeFromContextCache) {
|
||||
return upgradeFromContextCache;
|
||||
}
|
||||
|
||||
const {adminClient} = await pw.getAdminClient();
|
||||
if (patchConfig) {
|
||||
await ensureUpgradeServerConfig(adminClient);
|
||||
}
|
||||
|
||||
const team = await ensureUpgradeTeam(adminClient);
|
||||
const user = await ensureUpgradeUser(adminClient, team.id, seeder.UPGRADE_USER);
|
||||
const peers = await Promise.all(
|
||||
seeder.UPGRADE_PEER_USERS.map((peer) => ensureUpgradeUser(adminClient, team.id, peer)),
|
||||
);
|
||||
const publicChannel = await ensureUpgradeChannel(adminClient, team.id, seeder.UPGRADE_PUBLIC_CHANNEL_NAME, 'O');
|
||||
const privateChannel = await ensureUpgradeChannel(adminClient, team.id, seeder.UPGRADE_PRIVATE_CHANNEL_NAME, 'P');
|
||||
|
||||
// Membership only on first ensure — re-calling addToChannel every test logs "already a member".
|
||||
await ensureChannelMember(adminClient, user.id, publicChannel.id);
|
||||
await ensureChannelMember(adminClient, user.id, privateChannel.id);
|
||||
|
||||
const adminMe = await adminClient.getMe();
|
||||
await ensureChannelMember(adminClient, adminMe.id, publicChannel.id);
|
||||
await ensureChannelMember(adminClient, adminMe.id, privateChannel.id);
|
||||
|
||||
const {client: userClient} = await pw.makeClient(user);
|
||||
expect(userClient).toBeTruthy();
|
||||
|
||||
upgradeFromContextCache = {
|
||||
adminClient,
|
||||
userClient: userClient!,
|
||||
adminMe,
|
||||
user,
|
||||
peers,
|
||||
publicChannel,
|
||||
privateChannel,
|
||||
};
|
||||
return upgradeFromContextCache;
|
||||
}
|
||||
|
||||
export type UpgradeBaseline = {
|
||||
serverVersion: string;
|
||||
buildNumber: string;
|
||||
/** Highest db_migrations.Version (+ count) on the from-image; upgrade-to asserts it did not regress. */
|
||||
schema?: UpgradeSchemaBaseline;
|
||||
/** FileSettings.DriverName active when from-phase seeded attachments/profile images. */
|
||||
fileDriverName: string;
|
||||
/** License on the from-image; upgrade-to asserts it still matches after swap (same env as from). */
|
||||
license: UpgradeLicenseBaseline;
|
||||
userId: string;
|
||||
adminUserId: string;
|
||||
publicChannelId: string;
|
||||
privateChannelId: string;
|
||||
userDmChannelId: string;
|
||||
userGmChannelId: string;
|
||||
adminDmChannelId: string;
|
||||
adminGmChannelId: string;
|
||||
attachments: UpgradeAttachmentBaseline[];
|
||||
avatarPostId: string;
|
||||
/** Prepackaged playbooks state from upgrade-from; upgrade-to asserts it without re-enabling. */
|
||||
playbooks?: UpgradePrepackagedPluginBaseline;
|
||||
};
|
||||
|
||||
export type UpgradeToContext = UpgradeFromContext & {
|
||||
baseline: UpgradeBaseline;
|
||||
publicChannelId: string;
|
||||
privateChannelId: string;
|
||||
};
|
||||
|
||||
/** Loads shared upgrade actors/clients plus the from-phase baseline for upgrade-to verification. */
|
||||
export async function loadUpgradeToContext(pw: {
|
||||
getAdminClient: () => Promise<{adminClient: Client4}>;
|
||||
makeClient: (user: UserProfile) => Promise<{client: Client4 | undefined}>;
|
||||
}): Promise<UpgradeToContext> {
|
||||
const baseline = readUpgradeBaseline();
|
||||
// Read-only: verification must observe the config the upgrade produced, not a repaired one.
|
||||
const ctx = await loadUpgradeFromContext(pw, {patchConfig: false});
|
||||
return {
|
||||
...ctx,
|
||||
baseline,
|
||||
publicChannelId: baseline.publicChannelId || ctx.publicChannel.id,
|
||||
privateChannelId: baseline.privateChannelId || ctx.privateChannel.id,
|
||||
};
|
||||
}
|
||||
|
||||
/** Persists the from-phase's captured state, for upgrade-to to compare against. */
|
||||
export function writeUpgradeBaseline(baseline: UpgradeBaseline): void {
|
||||
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
/** Removes any prior baseline before a fresh upgrade-from run. */
|
||||
export function clearUpgradeBaseline(): void {
|
||||
clearUpgradeFromContextCache();
|
||||
if (fs.existsSync(BASELINE_PATH)) {
|
||||
fs.unlinkSync(BASELINE_PATH);
|
||||
}
|
||||
}
|
||||
|
||||
/** Merges partial baseline fields as each upgrade-from test completes. */
|
||||
export function mergeUpgradeBaseline(partial: Partial<UpgradeBaseline>): UpgradeBaseline {
|
||||
const existing: Partial<UpgradeBaseline> = fs.existsSync(BASELINE_PATH)
|
||||
? JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf-8'))
|
||||
: {};
|
||||
const merged = {...existing, ...partial} as UpgradeBaseline;
|
||||
writeUpgradeBaseline(merged);
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function readUpgradeBaseline(): UpgradeBaseline {
|
||||
if (!fs.existsSync(BASELINE_PATH)) {
|
||||
throw new Error(`${BASELINE_PATH} not found — did the upgrade-from project run first?`);
|
||||
}
|
||||
return JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf-8'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirms a post's attachment is served by the active file backend.
|
||||
* Metadata via Client4; binary download via Playwright `request` (not raw fetch / browser UI).
|
||||
*/
|
||||
export async function verifyPostAttachmentDownloadable(
|
||||
request: APIRequestContext,
|
||||
client: Client4,
|
||||
postId: string,
|
||||
fileName: string,
|
||||
): Promise<void> {
|
||||
const post = await client.getPost(postId);
|
||||
expect(post.file_ids?.length).toBeGreaterThan(0);
|
||||
|
||||
const fileInfos = await Promise.all(post.file_ids!.map((fileId) => client.getFileInfo(fileId)));
|
||||
const fileInfo = fileInfos.find((info) => info.name === fileName);
|
||||
expect(fileInfo).toBeDefined();
|
||||
|
||||
const downloadResponse = await request.get(client.getFileUrl(fileInfo!.id, 0), {
|
||||
headers: {Authorization: `Bearer ${client.getToken()}`},
|
||||
});
|
||||
expect(downloadResponse.ok()).toBe(true);
|
||||
expect((await downloadResponse.body()).byteLength).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-checks file-backed upgrade data after the to-image swap. Uses whatever
|
||||
* FileSettings.DriverName the server reports — never restarts or patchConfig's the driver.
|
||||
* API download checks apply to every backend; optional sidecar/object-store probes match the driver.
|
||||
*/
|
||||
export async function assertUpgradeFileBackendMatches(
|
||||
request: APIRequestContext,
|
||||
adminClient: Client4,
|
||||
userClient: Client4,
|
||||
baseline: UpgradeBaseline,
|
||||
): Promise<void> {
|
||||
const currentDriver = await readFileDriverName(adminClient);
|
||||
if (baseline.fileDriverName) {
|
||||
expect(currentDriver).toBe(baseline.fileDriverName);
|
||||
}
|
||||
|
||||
const attachments = baseline.attachments ?? ([] as UpgradeAttachmentBaseline[]);
|
||||
|
||||
for (const {postId, fileName, author} of attachments) {
|
||||
const client = author === 'user' ? userClient : adminClient;
|
||||
await verifyPostAttachmentDownloadable(request, client, postId, fileName);
|
||||
}
|
||||
|
||||
await assertProfileImageFetchable(request, userClient, baseline.userId);
|
||||
|
||||
switch (currentDriver) {
|
||||
case 'local':
|
||||
expect(listMattermostDataFiles().length).toBeGreaterThan(0);
|
||||
break;
|
||||
case 'amazons3':
|
||||
if (testConfig.testcontainersServices.includes('minio')) {
|
||||
expect((await listMinioObjectKeys()).length).toBeGreaterThan(0);
|
||||
}
|
||||
break;
|
||||
case 'azureblob':
|
||||
if (testConfig.testcontainersServices.includes('azurite')) {
|
||||
expect((await listAzuriteBlobNames()).length).toBeGreaterThan(0);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
39
e2e-tests/playwright/upgrade-specs/upgrade_swap_to.ts
Normal file
39
e2e-tests/playwright/upgrade-specs/upgrade_swap_to.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {duration, expect, getUpgradeToServerImage, test as setup} from '@mattermost/playwright-lib';
|
||||
|
||||
import {readServerIdentity, readUpgradeBaseline} from './upgrade_fixtures';
|
||||
|
||||
setup('swap to to-image', async ({pw}) => {
|
||||
// Image pull + container restart + migrations can exceed the default 1m test timeout.
|
||||
setup.setTimeout(duration.four_min);
|
||||
|
||||
const toImage = getUpgradeToServerImage();
|
||||
const baseline = readUpgradeBaseline();
|
||||
|
||||
try {
|
||||
// # Swap to the to-image with the same boot env as upgrade-from (including host MM_LICENSE).
|
||||
// Plugin enablement is not forced via env — baseline compare in upgrade-to covers that.
|
||||
await pw.upgradeServerImage(toImage);
|
||||
|
||||
// * Confirm the running server is no longer the from-image identity captured in the baseline
|
||||
const {adminClient} = await pw.getAdminClient();
|
||||
const identity = await readServerIdentity(adminClient);
|
||||
expect(`${identity.serverVersion}+${identity.buildNumber}`).not.toBe(
|
||||
`${baseline.serverVersion}+${baseline.buildNumber}`,
|
||||
);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`upgrade-swap-to: running server version=${identity.serverVersion} build=${identity.buildNumber}`);
|
||||
} finally {
|
||||
// Capture to-image logs even if the swap/identity assert fails (overwritten by upgrade-to
|
||||
// afterAll on success). Guarded: a log-capture error here would otherwise replace the
|
||||
// swap failure this block exists to surface.
|
||||
try {
|
||||
await pw.saveUpgradePhaseLogs('to');
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`upgrade-swap-to: failed to capture phase logs: ${error}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user