mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-08 12:10:13 +00:00
Retire legacy maestro per-platform contexts from cancel-e2e-runs and document the canonical PR, master, and CMT status context mapping. Wire Detox + Maestro (PR/main/CMT) into Test System IO with a single commit status (e2e/mobile; CMT uses e2e/compatibility-matrix-testing) and groups mobile-pr / mobile-main / cmt-mobile. Upload shard results with correct framework labels, merge Jest shards into TSIO Detox shape, stub missing/dead shards as failures, and post channel summaries after status settles. Matterwick MASTER is treated as MAIN for mobile-main routing. Also harden report/fail paths, stage Maestro screenshots per flow for TSIO galleries, fix a few Detox setup/testID issues, and skip known platform flakes so the pipeline stays actionable while TSIO lands.
308 lines
12 KiB
TypeScript
308 lines
12 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
import {timeouts, wait} from '@support/utils';
|
|
|
|
import client from './client';
|
|
import {getResponseFromError} from './common';
|
|
|
|
const GROUP_NAME = 'access_control';
|
|
const OBJECT_TYPE = 'template';
|
|
const TARGET_TYPE = 'system';
|
|
const FIELD_NAME = 'classification';
|
|
const LINKED_FIELD_NAME = 'classification';
|
|
const LINKED_OBJECT_TYPE = 'system';
|
|
const DISPLAY_BANNER_TOP = 'display_banner_top';
|
|
|
|
// Match Playwright classification_markings helpers: permission "admin" (not "sysadmin").
|
|
const ADMIN_PERMISSION = 'admin';
|
|
|
|
// Server model.IsValidId requires exactly 26 alphanumeric characters.
|
|
// Playwright uses padded IDs like lvlsecret00000000000000000 — same constraint.
|
|
export const CLASSIFICATION_LEVEL_IDS = {
|
|
topSecret: 'lvltopsecret00000000000000',
|
|
secret: 'lvlsecret00000000000000000',
|
|
unclassified: 'lvlunclassified00000000000',
|
|
} as const;
|
|
|
|
type PropertyFieldOption = {
|
|
id: string;
|
|
name: string;
|
|
color: string;
|
|
rank?: number;
|
|
};
|
|
|
|
const DEFAULT_CLASSIFICATION_LEVELS: PropertyFieldOption[] = [
|
|
{id: CLASSIFICATION_LEVEL_IDS.topSecret, name: 'TOP SECRET', color: '#FCE83A', rank: 1},
|
|
{id: CLASSIFICATION_LEVEL_IDS.secret, name: 'SECRET', color: '#FF0000', rank: 2},
|
|
{id: CLASSIFICATION_LEVEL_IDS.unclassified, name: 'UNCLASSIFIED', color: '#00FF00', rank: 3},
|
|
];
|
|
|
|
/**
|
|
* Get all property fields for a group/objectType.
|
|
*/
|
|
export const apiGetPropertyFields = async (baseUrl: string, groupName: string, objectType: string, targetType: string, targetId?: string) => {
|
|
try {
|
|
let url = `${baseUrl}/api/v4/properties/groups/${groupName}/${objectType}/fields?target_type=${targetType}`;
|
|
if (targetId !== undefined) {
|
|
url += `&target_id=${encodeURIComponent(targetId)}`;
|
|
}
|
|
const response = await client.get(url);
|
|
return {fields: response.data};
|
|
} catch (err) {
|
|
return getResponseFromError(err);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Create a property field.
|
|
*/
|
|
export const apiCreatePropertyField = async (baseUrl: string, groupName: string, objectType: string, field: Record<string, unknown>) => {
|
|
try {
|
|
const response = await client.post(
|
|
`${baseUrl}/api/v4/properties/groups/${groupName}/${objectType}/fields`,
|
|
field,
|
|
);
|
|
return {field: response.data};
|
|
} catch (err) {
|
|
return getResponseFromError(err);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Delete a property field.
|
|
*/
|
|
export const apiDeletePropertyField = async (baseUrl: string, groupName: string, objectType: string, fieldId: string) => {
|
|
try {
|
|
await client.delete(`${baseUrl}/api/v4/properties/groups/${groupName}/${objectType}/fields/${fieldId}`);
|
|
return {};
|
|
} catch (err) {
|
|
return getResponseFromError(err);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get property values for a target.
|
|
*/
|
|
export const apiGetPropertyValues = async (baseUrl: string, groupName: string, objectType: string, targetId: string) => {
|
|
try {
|
|
const response = await client.get(
|
|
`${baseUrl}/api/v4/properties/groups/${groupName}/${objectType}/values/${targetId}`,
|
|
);
|
|
return {values: response.data};
|
|
} catch (err) {
|
|
return getResponseFromError(err);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Upsert property values for a target.
|
|
*/
|
|
export const apiPatchPropertyValues = async (baseUrl: string, groupName: string, objectType: string, targetId: string, values: Array<{field_id: string; value: string}>) => {
|
|
try {
|
|
const response = await client.patch(
|
|
`${baseUrl}/api/v4/properties/groups/${groupName}/${objectType}/values/${targetId}`,
|
|
values,
|
|
);
|
|
return {values: response.data};
|
|
} catch (err) {
|
|
return getResponseFromError(err);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get system-scoped property values.
|
|
*/
|
|
export const apiGetSystemPropertyValues = async (baseUrl: string, groupName: string) => {
|
|
try {
|
|
const response = await client.get(
|
|
`${baseUrl}/api/v4/properties/groups/${groupName}/system/values`,
|
|
);
|
|
return {values: response.data};
|
|
} catch (err) {
|
|
return getResponseFromError(err);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Upsert system-scoped property values.
|
|
*/
|
|
export const apiPatchSystemPropertyValues = async (baseUrl: string, groupName: string, values: Array<{field_id: string; value: string}>) => {
|
|
try {
|
|
const response = await client.patch(
|
|
`${baseUrl}/api/v4/properties/groups/${groupName}/system/values`,
|
|
values,
|
|
);
|
|
return {values: response.data};
|
|
} catch (err) {
|
|
return getResponseFromError(err);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Setup a complete classification system for E2E testing:
|
|
* 1. Create a template property field with classification levels
|
|
* 2. Create a linked system classification field with banner actions
|
|
* 3. Set a system property value for the classification level
|
|
*
|
|
* Levels are identified by their `id` field. The `levelId` option selects which
|
|
* level the global banner should display, matching Playwright webapp E2E
|
|
* (keyed by option ID, not name). Option IDs must be valid Mattermost IDs
|
|
* (exactly 26 alphanumeric characters).
|
|
*
|
|
* @returns Object containing the created field IDs and option IDs
|
|
*/
|
|
export const apiSetupClassificationWithBanner = async (
|
|
baseUrl: string,
|
|
options?: {
|
|
levels?: PropertyFieldOption[];
|
|
levelId?: string;
|
|
},
|
|
) => {
|
|
const levels = options?.levels ?? DEFAULT_CLASSIFICATION_LEVELS;
|
|
const levelId = options?.levelId ?? CLASSIFICATION_LEVEL_IDS.topSecret;
|
|
|
|
await apiCleanupClassification(baseUrl);
|
|
|
|
// Match Playwright: type select, no CPA "managed" attr, permission "admin".
|
|
const templateResult = await apiCreatePropertyField(baseUrl, GROUP_NAME, OBJECT_TYPE, {
|
|
name: FIELD_NAME,
|
|
type: 'select',
|
|
target_type: TARGET_TYPE,
|
|
target_id: '',
|
|
attrs: {
|
|
options: levels.map((l) => ({id: l.id, name: l.name, color: l.color, rank: l.rank})),
|
|
},
|
|
permission_field: ADMIN_PERMISSION,
|
|
permission_values: ADMIN_PERMISSION,
|
|
permission_options: ADMIN_PERMISSION,
|
|
});
|
|
|
|
const templateResult_ = templateResult as {field?: any; error?: unknown};
|
|
if (!templateResult_.field) {
|
|
throw new Error(`Failed to create template classification field: ${JSON.stringify(templateResult_.error ?? templateResult)}`);
|
|
}
|
|
|
|
const templateField = templateResult_.field;
|
|
const templateOptions = templateField.attrs?.options ?? [];
|
|
const selectedOption = templateOptions.find((o: PropertyFieldOption) => o.id === levelId);
|
|
if (!selectedOption) {
|
|
const available = templateOptions.map((o: PropertyFieldOption) => `${o.name} (${o.id})`).join(', ');
|
|
throw new Error(`Classification level ID "${levelId}" not found in created options. Available: [${available}]`);
|
|
}
|
|
|
|
const linkedResult = await apiCreatePropertyField(baseUrl, GROUP_NAME, LINKED_OBJECT_TYPE, {
|
|
name: LINKED_FIELD_NAME,
|
|
type: 'select',
|
|
target_type: TARGET_TYPE,
|
|
target_id: '',
|
|
linked_field_id: templateField.id,
|
|
attrs: {
|
|
actions: [DISPLAY_BANNER_TOP],
|
|
},
|
|
});
|
|
|
|
const linkedResult_ = linkedResult as {field?: any; error?: unknown};
|
|
if (!linkedResult_.field) {
|
|
throw new Error(`Failed to create linked system classification field: ${JSON.stringify(linkedResult_.error ?? linkedResult)}`);
|
|
}
|
|
|
|
const linkedField = linkedResult_.field;
|
|
|
|
const patchResult = await apiPatchSystemPropertyValues(baseUrl, GROUP_NAME, [
|
|
{field_id: linkedField.id, value: selectedOption.id},
|
|
]);
|
|
if ('error' in patchResult) {
|
|
throw new Error(`Failed to set system property value for field_id=${linkedField.id}, value=${selectedOption.id}: ${JSON.stringify(patchResult.error)}`);
|
|
}
|
|
|
|
// Poll the same list endpoint the mobile app uses until the linked field (and its selected
|
|
// option) are readable. On cloud servers the config/property write propagates slowly, so a
|
|
// single GET right after create races propagation and the app's post-reload fetch then logs
|
|
// "No classification fields returned". Polling here guarantees the server has fully
|
|
// propagated before the test reloads the app.
|
|
// NOTE: pass '' for target_id to mirror the app client exactly — app/client/rest/properties.ts
|
|
// uses `if (targetId !== undefined)` and CLASSIFICATIONS_FIELD_TARGET_ID = '', so the app sends
|
|
// `&target_id=`. The verify GET must send the identical URL or it proves nothing.
|
|
const checkLinkedVisible = async (): Promise<string | undefined> => {
|
|
const verify = await apiGetPropertyFields(baseUrl, GROUP_NAME, LINKED_OBJECT_TYPE, TARGET_TYPE, '') as {fields?: any[]; error?: unknown};
|
|
const visibleLinked = (verify.fields ?? []).filter(
|
|
(f) => f.name === LINKED_FIELD_NAME && f.delete_at === 0 && f.linked_field_id && f.id === linkedField.id,
|
|
);
|
|
if (visibleLinked.length === 0) {
|
|
return `linked system field ${linkedField.id} not returned by GET ` +
|
|
`/properties/groups/${GROUP_NAME}/${LINKED_OBJECT_TYPE}/fields?target_type=${TARGET_TYPE}&target_id=. ` +
|
|
`Response: ${JSON.stringify(verify)}`;
|
|
}
|
|
const linkedOptions = (visibleLinked[0].attrs?.options as PropertyFieldOption[] | undefined) ?? [];
|
|
if (!linkedOptions.some((o) => o.id === selectedOption.id)) {
|
|
return `linked field missing selected option ${selectedOption.id}. options=${JSON.stringify(linkedOptions)}`;
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const pollLinkedVisible = async (sessionLabel: string, maxAttempts = 20) => {
|
|
let lastError: string | undefined;
|
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
// eslint-disable-next-line no-await-in-loop -- sequential poll until propagated
|
|
lastError = await checkLinkedVisible();
|
|
if (!lastError) {
|
|
return;
|
|
}
|
|
if (attempt < maxAttempts - 1) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await wait(timeouts.TWO_SEC);
|
|
}
|
|
}
|
|
throw new Error(`apiSetupClassificationWithBanner (${sessionLabel}): ${lastError}`);
|
|
};
|
|
|
|
await pollLinkedVisible('admin');
|
|
|
|
return {
|
|
templateFieldId: templateField.id,
|
|
linkedFieldId: linkedField.id,
|
|
selectedOptionId: selectedOption.id,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Clean up classification property fields and values.
|
|
*/
|
|
export const apiCleanupClassification = async (baseUrl: string) => {
|
|
const linkedFieldsResult = await apiGetPropertyFields(baseUrl, GROUP_NAME, LINKED_OBJECT_TYPE, TARGET_TYPE) as {fields?: any[]};
|
|
if (linkedFieldsResult.fields) {
|
|
for (const field of linkedFieldsResult.fields) {
|
|
if (field.name === LINKED_FIELD_NAME && field.delete_at === 0) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await apiDeletePropertyField(baseUrl, GROUP_NAME, LINKED_OBJECT_TYPE, field.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
const templateFieldsResult = await apiGetPropertyFields(baseUrl, GROUP_NAME, OBJECT_TYPE, TARGET_TYPE) as {fields?: any[]};
|
|
if (templateFieldsResult.fields) {
|
|
for (const field of templateFieldsResult.fields) {
|
|
if (field.name === FIELD_NAME && field.delete_at === 0) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await apiDeletePropertyField(baseUrl, GROUP_NAME, OBJECT_TYPE, field.id);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
export const Properties = {
|
|
CLASSIFICATION_LEVEL_IDS,
|
|
apiGetPropertyFields,
|
|
apiCreatePropertyField,
|
|
apiDeletePropertyField,
|
|
apiGetPropertyValues,
|
|
apiPatchPropertyValues,
|
|
apiGetSystemPropertyValues,
|
|
apiPatchSystemPropertyValues,
|
|
apiSetupClassificationWithBanner,
|
|
apiCleanupClassification,
|
|
};
|
|
|
|
export default Properties;
|