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:
@@ -0,0 +1,93 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type {
|
||||
PlaywrightTestArgs,
|
||||
PlaywrightTestOptions,
|
||||
PlaywrightWorkerArgs,
|
||||
PlaywrightWorkerOptions,
|
||||
TestType,
|
||||
} from '@playwright/test';
|
||||
|
||||
import type {ExtendedFixtures} from '@mattermost/playwright-lib';
|
||||
import {duration, test as base} from '@mattermost/playwright-lib';
|
||||
|
||||
export {expect} from '@mattermost/playwright-lib';
|
||||
|
||||
// The server-side AI bridge mock is process-global: every test resets it and consumes from one shared
|
||||
// FIFO completion queue, so AI bridge tests running concurrently (PW_WORKERS > 1) clobber each other.
|
||||
// All workers share one machine, so serialize them with a cross-process file lock held per test.
|
||||
//
|
||||
// The lock is intentionally minimal: acquisition is a single atomic create (open with O_EXCL), and the
|
||||
// lock is only ever removed by the worker that created it, on release. There is deliberately no
|
||||
// stale-reclamation / hand-off path — reclaiming another worker's lock cannot be made race-free with
|
||||
// filesystem primitives (it invites two workers into the critical section), so instead a lock is only
|
||||
// released by its owner. Playwright runs fixture teardown even on test timeout/failure, so a held lock
|
||||
// is released in every normal path; a genuinely leaked lock (hard worker crash) surfaces as a bounded
|
||||
// acquire timeout on the other AI tests rather than as silent concurrent mock access.
|
||||
const LOCK_PATH = path.join(os.tmpdir(), 'mm-e2e-ai-bridge-mock.lock');
|
||||
|
||||
// Poll interval while waiting for a held lock, and the extra budget granted for that wait so it does
|
||||
// not eat into the per-test timeout.
|
||||
const RETRY_INTERVAL_MS = duration.half_sec;
|
||||
const LOCK_ACQUIRE_BUDGET_MS = duration.four_min;
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function acquireAIBridgeLock(): Promise<() => Promise<void>> {
|
||||
let handle: Awaited<ReturnType<typeof fs.open>>;
|
||||
for (;;) {
|
||||
try {
|
||||
handle = await fs.open(LOCK_PATH, 'wx');
|
||||
break;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
||||
throw error;
|
||||
}
|
||||
await sleep(RETRY_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
let released = false;
|
||||
return async () => {
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
released = true;
|
||||
await handle.close().catch(() => {});
|
||||
await fs.rm(LOCK_PATH, {force: true});
|
||||
};
|
||||
}
|
||||
|
||||
type AIBridgeFixtures = {aiBridgeMockLock: void};
|
||||
|
||||
export const test: TestType<
|
||||
PlaywrightTestArgs & PlaywrightTestOptions & ExtendedFixtures & AIBridgeFixtures,
|
||||
PlaywrightWorkerArgs & PlaywrightWorkerOptions
|
||||
> = base.extend<AIBridgeFixtures>({
|
||||
aiBridgeMockLock: [
|
||||
async ({}, use, testInfo) => {
|
||||
const originalTimeout = testInfo.timeout;
|
||||
|
||||
// Raise the timeout before waiting, then hand the test its original budget once it holds
|
||||
// the lock, so time spent queued does not count against the test.
|
||||
const waitStart = Date.now();
|
||||
testInfo.setTimeout(originalTimeout + LOCK_ACQUIRE_BUDGET_MS);
|
||||
const release = await acquireAIBridgeLock();
|
||||
testInfo.setTimeout(originalTimeout + (Date.now() - waitStart));
|
||||
|
||||
try {
|
||||
await use();
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
},
|
||||
{auto: true},
|
||||
],
|
||||
});
|
||||
717
e2e-tests/playwright/specs/functional/channels/ai/recaps.spec.ts
Normal file
717
e2e-tests/playwright/specs/functional/channels/ai/recaps.spec.ts
Normal file
@@ -0,0 +1,717 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {expect, test} from './ai_bridge_fixture';
|
||||
import {
|
||||
createChannelWithManyPosts,
|
||||
createRecapAndWaitForStatus,
|
||||
createUnreadChannelFixture,
|
||||
markAllCurrentChannelsRead,
|
||||
setupRecapBridge,
|
||||
waitForRecapStatus,
|
||||
waitForRecordedRequestCount,
|
||||
} from './recaps_helpers';
|
||||
|
||||
/**
|
||||
* @objective Verify a user can create a selected-channels AI recap and receive the mocked summary without reloading the page
|
||||
*/
|
||||
test('creates selected-channels recap and auto-renders mocked summary', {tag: '@ai_recaps'}, async ({pw}) => {
|
||||
const recapHighlight = `Deterministic highlight ${pw.random.id()}`;
|
||||
const recapActionItem = `Deterministic action item ${pw.random.id()}`;
|
||||
const recapTitle = `AI recap ${pw.random.id()}`;
|
||||
const sourceMessage = `Please summarize this update ${pw.random.id()}`;
|
||||
|
||||
// # Initialize the test server state and configure one deterministic recap agent.
|
||||
const {adminClient, adminUser, team, user, userClient} = await pw.initSetup();
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
const {agent} = await setupRecapBridge(pw, adminClient, {
|
||||
completions: [
|
||||
pw.recapCompletion({
|
||||
highlights: [recapHighlight],
|
||||
actionItems: [recapActionItem],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// # Seed a real unread post so the recap job has content to summarize.
|
||||
const channel = await createUnreadChannelFixture(
|
||||
pw,
|
||||
adminClient,
|
||||
adminUser.id,
|
||||
user.id,
|
||||
team.id,
|
||||
'AI Recap Selected Channel',
|
||||
sourceMessage,
|
||||
);
|
||||
|
||||
// # Login as the end user, create the recap through the selected-channels modal flow, and submit it.
|
||||
const {recapsPage} = await pw.testBrowser.login(user);
|
||||
await recapsPage.goto(team.name);
|
||||
await recapsPage.toBeVisible();
|
||||
|
||||
const createRecapModal = await recapsPage.openCreateRecap();
|
||||
await createRecapModal.fillTitle(recapTitle);
|
||||
await createRecapModal.selectSelectedChannels();
|
||||
await createRecapModal.enableRunOnce();
|
||||
await createRecapModal.clickNext();
|
||||
await createRecapModal.expectChannelSelectorVisible();
|
||||
await createRecapModal.searchChannel(channel.display_name);
|
||||
await createRecapModal.selectChannel(channel.display_name);
|
||||
await createRecapModal.clickNext();
|
||||
await createRecapModal.expectSummaryChannels([channel.display_name]);
|
||||
await createRecapModal.startRecap();
|
||||
|
||||
// * Verify the recap first appears in its pending state.
|
||||
const recap = recapsPage.getRecap(recapTitle);
|
||||
await recap.toBeVisible();
|
||||
await recap.expectProcessing();
|
||||
|
||||
// * Verify the recap completes and renders the mocked summary content without a manual page reload.
|
||||
await waitForRecapStatus(pw, userClient, recapTitle, 'completed');
|
||||
await expect(recap.container).toContainText(recapHighlight, {timeout: pw.duration.one_min});
|
||||
await expect(recap.container).toContainText(recapActionItem, {timeout: pw.duration.one_min});
|
||||
|
||||
// * Verify the recorded bridge request used the recap_summary operation and included the unread source message.
|
||||
await waitForRecordedRequestCount(pw, adminClient, 1);
|
||||
const bridgeState = await pw.getAIBridgeMock(adminClient);
|
||||
const recapRequest = bridgeState.recorded_requests.find((request) => request.operation === 'recap_summary');
|
||||
|
||||
expect(recapRequest).toBeDefined();
|
||||
expect(recapRequest?.agent_id).toBe(agent.id);
|
||||
expect(recapRequest?.messages.some((message) => message.message.includes(sourceMessage))).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify the all-unreads recap flow skips channel selection and only summarizes unread channels
|
||||
*/
|
||||
test('creates all-unreads recap for only unread channels', {tag: '@ai_recaps'}, async ({pw}) => {
|
||||
const recapTitle = `Unread recap ${pw.random.id()}`;
|
||||
const sourceMessageOne = `Unread source one ${pw.random.id()}`;
|
||||
const sourceMessageTwo = `Unread source two ${pw.random.id()}`;
|
||||
const highlightOne = `Unread highlight one ${pw.random.id()}`;
|
||||
const highlightTwo = `Unread highlight two ${pw.random.id()}`;
|
||||
|
||||
// # Initialize the test server state, clear baseline unread state, and configure two deterministic recap completions.
|
||||
const {adminClient, adminUser, team, user, userClient} = await pw.initSetup();
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
await markAllCurrentChannelsRead(userClient, team.id);
|
||||
|
||||
const {agent} = await setupRecapBridge(pw, adminClient, {
|
||||
completions: [
|
||||
pw.recapCompletion({
|
||||
highlights: [highlightOne],
|
||||
actionItems: [],
|
||||
}),
|
||||
pw.recapCompletion({
|
||||
highlights: [highlightTwo],
|
||||
actionItems: [],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const firstChannel = await createUnreadChannelFixture(
|
||||
pw,
|
||||
adminClient,
|
||||
adminUser.id,
|
||||
user.id,
|
||||
team.id,
|
||||
'Unread recap one',
|
||||
sourceMessageOne,
|
||||
);
|
||||
const secondChannel = await createUnreadChannelFixture(
|
||||
pw,
|
||||
adminClient,
|
||||
adminUser.id,
|
||||
user.id,
|
||||
team.id,
|
||||
'Unread recap two',
|
||||
sourceMessageTwo,
|
||||
);
|
||||
|
||||
// # Open the all-unreads modal flow and advance directly to the summary step.
|
||||
const {recapsPage} = await pw.testBrowser.login(user);
|
||||
await recapsPage.goto(team.name);
|
||||
await recapsPage.toBeVisible();
|
||||
|
||||
const createRecapModal = await recapsPage.openCreateRecap();
|
||||
await createRecapModal.fillTitle(recapTitle);
|
||||
await createRecapModal.selectAgent(agent.displayName);
|
||||
await createRecapModal.selectAllUnreads();
|
||||
await createRecapModal.enableRunOnce();
|
||||
await createRecapModal.clickNext();
|
||||
|
||||
// * Verify the all-unreads flow skips the channel selector and includes only the unread channels in the summary.
|
||||
await createRecapModal.expectChannelSelectorHidden();
|
||||
await createRecapModal.expectSummaryChannels([firstChannel.display_name, secondChannel.display_name]);
|
||||
await createRecapModal.startRecap();
|
||||
|
||||
// * Verify the completed recap renders summaries for both unread channels.
|
||||
const recap = recapsPage.getRecap(recapTitle);
|
||||
await expect(recap.container).toContainText(highlightOne, {timeout: pw.duration.one_min});
|
||||
await expect(recap.container).toContainText(highlightTwo, {timeout: pw.duration.one_min});
|
||||
await expect(recap.container).toContainText(firstChannel.display_name);
|
||||
await expect(recap.container).toContainText(secondChannel.display_name);
|
||||
|
||||
// * Verify the bridge recorded one recap_summary request per unread channel and included both source messages.
|
||||
await waitForRecordedRequestCount(pw, adminClient, 2);
|
||||
const bridgeState = await pw.getAIBridgeMock(adminClient);
|
||||
const recapRequests = bridgeState.recorded_requests.filter((request) => request.operation === 'recap_summary');
|
||||
|
||||
expect(recapRequests).toHaveLength(2);
|
||||
expect(
|
||||
recapRequests.some((request) => request.messages.some((message) => message.message.includes(sourceMessageOne))),
|
||||
).toBe(true);
|
||||
expect(
|
||||
recapRequests.some((request) => request.messages.some((message) => message.message.includes(sourceMessageTwo))),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify recap creation is disabled when the AI bridge reports itself as unavailable
|
||||
*/
|
||||
test('disables recap creation when the bridge is unavailable', {tag: '@ai_recaps'}, async ({pw}) => {
|
||||
// # Initialize the test server state and configure the recap bridge as unavailable.
|
||||
const {adminClient, team, user} = await pw.initSetup();
|
||||
await setupRecapBridge(pw, adminClient, {
|
||||
available: false,
|
||||
completions: [],
|
||||
});
|
||||
|
||||
// # Open the recaps page as the end user.
|
||||
const {recapsPage} = await pw.testBrowser.login(user);
|
||||
await recapsPage.goto(team.name);
|
||||
await recapsPage.toBeVisible();
|
||||
|
||||
// * Verify the page shows the caught-up empty state and disables the Add a recap button with the expected reason.
|
||||
await recapsPage.expectCaughtUpEmptyState();
|
||||
await recapsPage.expectAddRecapDisabled('Agents Bridge is not enabled');
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify marking a recap as read moves it from the Unread tab to the Read tab
|
||||
*/
|
||||
test('moves a recap from unread to read when marked read', {tag: '@ai_recaps'}, async ({pw}) => {
|
||||
const recapTitle = `Read state recap ${pw.random.id()}`;
|
||||
const recapHighlight = `Read state highlight ${pw.random.id()}`;
|
||||
const sourceMessage = `Read state source ${pw.random.id()}`;
|
||||
|
||||
// # Initialize the test server state, configure the recap bridge, and seed a completed recap for the user.
|
||||
const {adminClient, adminUser, team, user, userClient} = await pw.initSetup();
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
const {agent} = await setupRecapBridge(pw, adminClient, {
|
||||
completions: [
|
||||
pw.recapCompletion({
|
||||
highlights: [recapHighlight],
|
||||
actionItems: [],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const channel = await createUnreadChannelFixture(
|
||||
pw,
|
||||
adminClient,
|
||||
adminUser.id,
|
||||
user.id,
|
||||
team.id,
|
||||
'Read state channel',
|
||||
sourceMessage,
|
||||
);
|
||||
await createRecapAndWaitForStatus(pw, userClient, recapTitle, [channel.id], agent.id, 'completed');
|
||||
|
||||
// # Open the unread recap and mark it as read from the recap card.
|
||||
const {recapsPage} = await pw.testBrowser.login(user);
|
||||
await recapsPage.goto(team.name);
|
||||
await recapsPage.toBeVisible();
|
||||
|
||||
const unreadRecap = recapsPage.getRecap(recapTitle);
|
||||
await unreadRecap.toBeVisible();
|
||||
await unreadRecap.clickMarkRead();
|
||||
|
||||
// * Verify the recap disappears from the Unread tab and appears in the Read tab with the inline Mark read button removed.
|
||||
await recapsPage.expectRecapNotVisible(recapTitle);
|
||||
await recapsPage.switchToRead();
|
||||
|
||||
const readRecap = recapsPage.getRecap(recapTitle);
|
||||
await readRecap.toBeVisible();
|
||||
await expect(readRecap.markReadButton).not.toBeVisible();
|
||||
await expect(readRecap.menuButton).toBeVisible();
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify deleting a recap removes it from the recaps list
|
||||
*/
|
||||
test('deletes a recap from the recaps page', {tag: '@ai_recaps'}, async ({pw}) => {
|
||||
const recapTitle = `Delete recap ${pw.random.id()}`;
|
||||
const recapHighlight = `Delete recap highlight ${pw.random.id()}`;
|
||||
const sourceMessage = `Delete recap source ${pw.random.id()}`;
|
||||
|
||||
// # Initialize the test server state, configure the recap bridge, and seed a completed recap for the user.
|
||||
const {adminClient, adminUser, team, user, userClient} = await pw.initSetup();
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
const {agent} = await setupRecapBridge(pw, adminClient, {
|
||||
completions: [
|
||||
pw.recapCompletion({
|
||||
highlights: [recapHighlight],
|
||||
actionItems: [],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const channel = await createUnreadChannelFixture(
|
||||
pw,
|
||||
adminClient,
|
||||
adminUser.id,
|
||||
user.id,
|
||||
team.id,
|
||||
'Delete recap channel',
|
||||
sourceMessage,
|
||||
);
|
||||
await createRecapAndWaitForStatus(pw, userClient, recapTitle, [channel.id], agent.id, 'completed');
|
||||
|
||||
// # Open the recap, trigger the delete action, and confirm the delete modal.
|
||||
const {recapsPage} = await pw.testBrowser.login(user);
|
||||
await recapsPage.goto(team.name);
|
||||
await recapsPage.toBeVisible();
|
||||
|
||||
const recap = recapsPage.getRecap(recapTitle);
|
||||
await recap.toBeVisible();
|
||||
await recap.clickDelete();
|
||||
await recapsPage.confirmDelete();
|
||||
|
||||
// * Verify the recap disappears from the list and the page returns to the caught-up empty state.
|
||||
await recapsPage.expectRecapNotVisible(recapTitle);
|
||||
await recapsPage.expectCaughtUpEmptyState();
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify regenerating a recap sends a new summary request and replaces the rendered summary with the latest mocked response
|
||||
*/
|
||||
test('regenerates a recap with a new mocked summary', {tag: '@ai_recaps'}, async ({pw}) => {
|
||||
const recapTitle = `Regenerated recap ${pw.random.id()}`;
|
||||
const firstHighlight = `Original summary ${pw.random.id()}`;
|
||||
const secondHighlight = `Regenerated summary ${pw.random.id()}`;
|
||||
const sourceMessage = `Regenerate source ${pw.random.id()}`;
|
||||
|
||||
// # Initialize the test server state, queue two recap completions, and seed the first completed recap.
|
||||
const {adminClient, adminUser, team, user, userClient} = await pw.initSetup();
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
const {agent} = await setupRecapBridge(pw, adminClient, {
|
||||
completions: [
|
||||
pw.recapCompletion({
|
||||
highlights: [firstHighlight],
|
||||
actionItems: [],
|
||||
}),
|
||||
pw.recapCompletion({
|
||||
highlights: [secondHighlight],
|
||||
actionItems: [],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const originalConfig = await adminClient.getConfig();
|
||||
|
||||
try {
|
||||
await adminClient.patchConfig({
|
||||
AIRecapSettings: {
|
||||
EnforceCooldown: false,
|
||||
},
|
||||
});
|
||||
|
||||
const channel = await createUnreadChannelFixture(
|
||||
pw,
|
||||
adminClient,
|
||||
adminUser.id,
|
||||
user.id,
|
||||
team.id,
|
||||
'Regenerate recap channel',
|
||||
sourceMessage,
|
||||
);
|
||||
await createRecapAndWaitForStatus(pw, userClient, recapTitle, [channel.id], agent.id, 'completed');
|
||||
|
||||
// # Open the recap, confirm the original summary is visible, and trigger regeneration from the recap menu.
|
||||
const {recapsPage} = await pw.testBrowser.login(user);
|
||||
await recapsPage.goto(team.name);
|
||||
await recapsPage.toBeVisible();
|
||||
|
||||
const recap = recapsPage.getRecap(recapTitle);
|
||||
await recap.expand();
|
||||
await recap.expectText(firstHighlight);
|
||||
await recap.openMenuAction('Regenerate this recap');
|
||||
|
||||
// * Verify the regeneration request is recorded and then renders the regenerated summary.
|
||||
await waitForRecordedRequestCount(pw, adminClient, 2);
|
||||
await expect(recap.container).toContainText(secondHighlight, {timeout: pw.duration.one_min});
|
||||
await expect(recap.container).not.toContainText(firstHighlight);
|
||||
|
||||
// * Verify two recap_summary requests were recorded for the original generation and the regeneration.
|
||||
const bridgeState = await pw.getAIBridgeMock(adminClient);
|
||||
const recapRequests = bridgeState.recorded_requests.filter((request) => request.operation === 'recap_summary');
|
||||
expect(recapRequests).toHaveLength(2);
|
||||
} finally {
|
||||
await adminClient.updateConfig(originalConfig as any);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify a failed recap renders the failed state and can recover after regeneration
|
||||
*/
|
||||
test('recovers a failed recap through regeneration', {tag: '@ai_recaps'}, async ({pw}) => {
|
||||
const recapTitle = `Failed recap ${pw.random.id()}`;
|
||||
const recoveredHighlight = `Recovered highlight ${pw.random.id()}`;
|
||||
const sourceMessage = `Failed recap source ${pw.random.id()}`;
|
||||
|
||||
// # Initialize the test server state, queue a failing completion followed by a successful completion, and seed the failed recap.
|
||||
const {adminClient, adminUser, team, user, userClient} = await pw.initSetup();
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
const {agent} = await setupRecapBridge(pw, adminClient, {
|
||||
completions: [
|
||||
{error: 'deterministic recap failure', status_code: 500},
|
||||
pw.recapCompletion({
|
||||
highlights: [recoveredHighlight],
|
||||
actionItems: [],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const channel = await createUnreadChannelFixture(
|
||||
pw,
|
||||
adminClient,
|
||||
adminUser.id,
|
||||
user.id,
|
||||
team.id,
|
||||
'Failed recap channel',
|
||||
sourceMessage,
|
||||
);
|
||||
await createRecapAndWaitForStatus(pw, userClient, recapTitle, [channel.id], agent.id, 'failed');
|
||||
|
||||
// # Open the failed recap and trigger regeneration from the recap menu.
|
||||
const {recapsPage} = await pw.testBrowser.login(user);
|
||||
await recapsPage.goto(team.name);
|
||||
await recapsPage.toBeVisible();
|
||||
|
||||
const failedRecap = recapsPage.getRecap(recapTitle);
|
||||
await failedRecap.toBeVisible();
|
||||
await failedRecap.expectFailed();
|
||||
await expect(failedRecap.markReadButton).not.toBeVisible();
|
||||
await failedRecap.openMenuAction('Regenerate this recap');
|
||||
|
||||
// * Verify the failed recap returns to processing and then recovers into a completed recap.
|
||||
await failedRecap.expectProcessing();
|
||||
await waitForRecapStatus(pw, userClient, recapTitle, 'completed');
|
||||
await recapsPage.page.reload();
|
||||
await recapsPage.toBeVisible();
|
||||
await failedRecap.expand();
|
||||
await expect(failedRecap.container).toContainText(recoveredHighlight, {timeout: pw.duration.one_min});
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify recap channel card actions can mark a source channel as read and navigate back into that channel
|
||||
*/
|
||||
test('executes recap channel card actions', {tag: '@ai_recaps'}, async ({pw}) => {
|
||||
const recapTitle = `Channel actions recap ${pw.random.id()}`;
|
||||
const recapHighlight = `Channel action highlight ${pw.random.id()}`;
|
||||
const sourceMessage = `Channel action source ${pw.random.id()}`;
|
||||
|
||||
// # Initialize the test server state, configure the recap bridge, and seed a completed recap with one unread channel.
|
||||
const {adminClient, adminUser, team, user, userClient} = await pw.initSetup();
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
const {agent} = await setupRecapBridge(pw, adminClient, {
|
||||
completions: [
|
||||
pw.recapCompletion({
|
||||
highlights: [recapHighlight],
|
||||
actionItems: [],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const channel = await createUnreadChannelFixture(
|
||||
pw,
|
||||
adminClient,
|
||||
adminUser.id,
|
||||
user.id,
|
||||
team.id,
|
||||
'Channel action recap',
|
||||
sourceMessage,
|
||||
);
|
||||
await createRecapAndWaitForStatus(pw, userClient, recapTitle, [channel.id], agent.id, 'completed');
|
||||
|
||||
// # Open the recap, mark the recap channel as read from the channel card, and then navigate back into the source channel.
|
||||
const {page, recapsPage} = await pw.testBrowser.login(user);
|
||||
await recapsPage.goto(team.name);
|
||||
await recapsPage.toBeVisible();
|
||||
|
||||
const recap = recapsPage.getRecap(recapTitle);
|
||||
await recap.expand();
|
||||
|
||||
const recapChannelCard = recap.getChannelCard(channel.display_name);
|
||||
await recapChannelCard.toBeVisible();
|
||||
await recapChannelCard.openMenuAction('Mark this channel as read');
|
||||
|
||||
// * Verify the recap channel read action clears the unread count for the summarized channel.
|
||||
await pw.waitUntil(
|
||||
async () => {
|
||||
const channelMember = await userClient.getMyChannelMember(channel.id);
|
||||
return channelMember.mention_count === 0;
|
||||
},
|
||||
{timeout: pw.duration.one_min},
|
||||
);
|
||||
|
||||
// # Use the channel card to navigate back into the original channel.
|
||||
await recapChannelCard.clickChannelName();
|
||||
|
||||
// * Verify the user lands back in the source channel route.
|
||||
await expect(page).toHaveURL(new RegExp(`/${team.name}/channels/${channel.name}$`));
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify the server enforces MaxTokensPerRecap by trimming source posts before summarizing,
|
||||
* and includes all posts when token enforcement is disabled
|
||||
*/
|
||||
test('enforces the per-recap token limit on the immediate recap path', {tag: '@ai_recaps'}, async ({pw}) => {
|
||||
const limitedTitle = `Token limited recap ${pw.random.id()}`;
|
||||
const unlimitedTitle = `Token unlimited recap ${pw.random.id()}`;
|
||||
const highlight = `Token limit highlight ${pw.random.id()}`;
|
||||
|
||||
// # Initialize the test server state and queue one recap completion per immediate recap run.
|
||||
const {adminClient, adminUser, team, user, userClient} = await pw.initSetup();
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
const {agent} = await setupRecapBridge(pw, adminClient, {
|
||||
completions: [
|
||||
pw.recapCompletion({highlights: [highlight], actionItems: []}),
|
||||
pw.recapCompletion({highlights: [highlight], actionItems: []}),
|
||||
],
|
||||
});
|
||||
|
||||
const originalConfig = await adminClient.getConfig();
|
||||
|
||||
// Each post is padded to ~400 characters (~100 estimated tokens at 4 chars/token), so a 150-token
|
||||
// budget admits only the single newest post once enforcement is on.
|
||||
const postCount = 10;
|
||||
const messageLength = 400;
|
||||
|
||||
try {
|
||||
// # Enforce a small per-recap token budget and disable the cooldown so two recaps can run back to back.
|
||||
await adminClient.patchConfig({
|
||||
AIRecapSettings: {
|
||||
EnforceCooldown: false,
|
||||
EnforceTokensPerRecap: true,
|
||||
DefaultLimits: {
|
||||
MaxTokensPerRecap: 150,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const {channel, markers} = await createChannelWithManyPosts(
|
||||
pw,
|
||||
adminClient,
|
||||
adminUser.id,
|
||||
user.id,
|
||||
team.id,
|
||||
'Token limit channel',
|
||||
postCount,
|
||||
messageLength,
|
||||
);
|
||||
|
||||
// # Run an immediate recap with token enforcement ON.
|
||||
const limitedRecap = await createRecapAndWaitForStatus(
|
||||
pw,
|
||||
userClient,
|
||||
limitedTitle,
|
||||
[channel.id],
|
||||
agent.id,
|
||||
'completed',
|
||||
);
|
||||
|
||||
// * Verify the server trimmed the source posts: fewer than were created are recorded on the recap channel.
|
||||
const limitedChannel = limitedRecap.channels?.find((recapChannel) => recapChannel.channel_id === channel.id);
|
||||
expect(limitedChannel).toBeDefined();
|
||||
const limitedCount = limitedChannel?.source_post_ids.length ?? 0;
|
||||
expect(limitedCount).toBeGreaterThan(0);
|
||||
expect(limitedCount).toBeLessThan(postCount);
|
||||
|
||||
// # Disable token enforcement and run a second immediate recap over the same channel.
|
||||
await adminClient.patchConfig({
|
||||
AIRecapSettings: {
|
||||
EnforceCooldown: false,
|
||||
EnforceTokensPerRecap: false,
|
||||
},
|
||||
});
|
||||
|
||||
const unlimitedRecap = await createRecapAndWaitForStatus(
|
||||
pw,
|
||||
userClient,
|
||||
unlimitedTitle,
|
||||
[channel.id],
|
||||
agent.id,
|
||||
'completed',
|
||||
);
|
||||
|
||||
// * Verify all created posts are recorded when enforcement is off, and that it exceeds the trimmed count.
|
||||
const unlimitedChannel = unlimitedRecap.channels?.find(
|
||||
(recapChannel) => recapChannel.channel_id === channel.id,
|
||||
);
|
||||
expect(unlimitedChannel).toBeDefined();
|
||||
const unlimitedCount = unlimitedChannel?.source_post_ids.length ?? 0;
|
||||
expect(unlimitedCount).toBeGreaterThanOrEqual(postCount);
|
||||
expect(limitedCount).toBeLessThan(unlimitedCount);
|
||||
|
||||
// * Verify the recorded LLM payloads corroborate the trimming: fewer seeded posts reached the bridge
|
||||
// for the enforced recap than for the unenforced one.
|
||||
await waitForRecordedRequestCount(pw, adminClient, 2);
|
||||
const bridgeState = await pw.getAIBridgeMock(adminClient);
|
||||
const recapRequests = bridgeState.recorded_requests.filter((request) => request.operation === 'recap_summary');
|
||||
expect(recapRequests).toHaveLength(2);
|
||||
|
||||
const countMarkersInRequest = (request: (typeof recapRequests)[number]) => {
|
||||
const text = request.messages.map((message) => message.message).join('\n');
|
||||
return markers.filter((marker) => text.includes(marker)).length;
|
||||
};
|
||||
|
||||
const limitedMarkers = countMarkersInRequest(recapRequests[0]);
|
||||
const unlimitedMarkers = countMarkersInRequest(recapRequests[1]);
|
||||
expect(limitedMarkers).toBeGreaterThan(0);
|
||||
expect(limitedMarkers).toBeLessThan(postCount);
|
||||
expect(unlimitedMarkers).toBe(postCount);
|
||||
expect(limitedMarkers).toBeLessThan(unlimitedMarkers);
|
||||
} finally {
|
||||
await adminClient.updateConfig(originalConfig as any);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify MaxTokensPerRecap is enforced as an independent per-channel cap: a single recap
|
||||
* spanning two channels trims each channel's source posts to its own budget rather than sharing one
|
||||
* cross-channel budget
|
||||
*/
|
||||
test('enforces the per-recap token limit independently per channel', {tag: '@ai_recaps'}, async ({pw}) => {
|
||||
const recapTitle = `Per-channel token recap ${pw.random.id()}`;
|
||||
const highlight = `Per-channel token highlight ${pw.random.id()}`;
|
||||
|
||||
// # Initialize the test server state and queue one recap completion per channel summarized.
|
||||
const {adminClient, adminUser, team, user, userClient} = await pw.initSetup();
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
const {agent} = await setupRecapBridge(pw, adminClient, {
|
||||
completions: [
|
||||
pw.recapCompletion({highlights: [highlight], actionItems: []}),
|
||||
pw.recapCompletion({highlights: [highlight], actionItems: []}),
|
||||
],
|
||||
});
|
||||
|
||||
const originalConfig = await adminClient.getConfig();
|
||||
|
||||
// Each post is padded to ~400 characters (~100 estimated tokens), so a 150-token per-channel
|
||||
// budget admits only the single newest post in each channel once enforcement is on.
|
||||
const postCount = 6;
|
||||
const messageLength = 400;
|
||||
|
||||
try {
|
||||
// # Enforce a small per-channel token budget; disable cooldown so the immediate recap runs cleanly.
|
||||
await adminClient.patchConfig({
|
||||
AIRecapSettings: {
|
||||
EnforceCooldown: false,
|
||||
EnforceTokensPerRecap: true,
|
||||
DefaultLimits: {
|
||||
MaxTokensPerRecap: 150,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const first = await createChannelWithManyPosts(
|
||||
pw,
|
||||
adminClient,
|
||||
adminUser.id,
|
||||
user.id,
|
||||
team.id,
|
||||
'Per-channel token channel one',
|
||||
postCount,
|
||||
messageLength,
|
||||
);
|
||||
const second = await createChannelWithManyPosts(
|
||||
pw,
|
||||
adminClient,
|
||||
adminUser.id,
|
||||
user.id,
|
||||
team.id,
|
||||
'Per-channel token channel two',
|
||||
postCount,
|
||||
messageLength,
|
||||
);
|
||||
|
||||
// # Run a single immediate recap covering both seeded channels.
|
||||
const recap = await createRecapAndWaitForStatus(
|
||||
pw,
|
||||
userClient,
|
||||
recapTitle,
|
||||
[first.channel.id, second.channel.id],
|
||||
agent.id,
|
||||
'completed',
|
||||
);
|
||||
|
||||
// * Verify each channel was trimmed independently to its own per-channel budget.
|
||||
const firstChannel = recap.channels?.find((recapChannel) => recapChannel.channel_id === first.channel.id);
|
||||
const secondChannel = recap.channels?.find((recapChannel) => recapChannel.channel_id === second.channel.id);
|
||||
expect(firstChannel).toBeDefined();
|
||||
expect(secondChannel).toBeDefined();
|
||||
|
||||
const firstCount = firstChannel?.source_post_ids.length ?? 0;
|
||||
const secondCount = secondChannel?.source_post_ids.length ?? 0;
|
||||
|
||||
expect(firstCount).toBeGreaterThan(0);
|
||||
expect(firstCount).toBeLessThan(postCount);
|
||||
expect(secondCount).toBeGreaterThan(0);
|
||||
expect(secondCount).toBeLessThan(postCount);
|
||||
|
||||
// * Verify the cap is per channel, not a shared cross-channel budget: a shared 150-token budget
|
||||
// could only retain posts in one channel (exhausting it before the second), so both channels
|
||||
// retaining posts means the combined kept count exceeds any single channel's budget.
|
||||
expect(firstCount + secondCount).toBeGreaterThan(firstCount);
|
||||
expect(firstCount + secondCount).toBeGreaterThan(secondCount);
|
||||
|
||||
// * Verify the recorded LLM payloads corroborate per-channel trimming: one request per channel,
|
||||
// each carrying only its own trimmed subset of seeded markers.
|
||||
await waitForRecordedRequestCount(pw, adminClient, 2);
|
||||
const bridgeState = await pw.getAIBridgeMock(adminClient);
|
||||
const recapRequests = bridgeState.recorded_requests.filter((request) => request.operation === 'recap_summary');
|
||||
expect(recapRequests).toHaveLength(2);
|
||||
|
||||
const countMarkers = (markers: string[]) => {
|
||||
const text = recapRequests
|
||||
.flatMap((request) => request.messages.map((message) => message.message))
|
||||
.join('\n');
|
||||
return markers.filter((marker) => text.includes(marker)).length;
|
||||
};
|
||||
|
||||
const firstMarkers = countMarkers(first.markers);
|
||||
const secondMarkers = countMarkers(second.markers);
|
||||
expect(firstMarkers).toBe(firstCount);
|
||||
expect(secondMarkers).toBe(secondCount);
|
||||
} finally {
|
||||
await adminClient.updateConfig(originalConfig as any);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Client4} from '@mattermost/client';
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
|
||||
import type {PlaywrightExtended} from '@mattermost/playwright-lib';
|
||||
|
||||
type RecapBridgeSetup = {
|
||||
agent: {
|
||||
id: string;
|
||||
displayName: string;
|
||||
username: string;
|
||||
};
|
||||
service: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
|
||||
export async function setupRecapBridge(
|
||||
pw: PlaywrightExtended,
|
||||
adminClient: Client4,
|
||||
{
|
||||
available = true,
|
||||
completions,
|
||||
}: {
|
||||
available?: boolean;
|
||||
completions: Array<{completion?: string; error?: string; status_code?: number}>;
|
||||
},
|
||||
): Promise<RecapBridgeSetup> {
|
||||
await pw.enableAIBridgeTestMode(adminClient, {enableRecaps: true});
|
||||
await pw.resetAIBridgeMock(adminClient);
|
||||
|
||||
const {agent, service} = await pw.createMockAIAgent(adminClient, {
|
||||
agent: {
|
||||
id: `recap-agent-${pw.random.id()}`,
|
||||
displayName: 'Recap Summary Agent',
|
||||
username: `recap.summary.${pw.random.id()}`,
|
||||
is_default: true,
|
||||
},
|
||||
service: {
|
||||
id: `recap-service-${pw.random.id()}`,
|
||||
name: 'Recap Summary Service',
|
||||
type: 'anthropic',
|
||||
},
|
||||
});
|
||||
|
||||
await pw.configureAIBridgeMock(adminClient, {
|
||||
status: {available},
|
||||
agents: [agent],
|
||||
services: [service],
|
||||
agent_completions: {
|
||||
recap_summary: completions,
|
||||
},
|
||||
record_requests: true,
|
||||
});
|
||||
|
||||
return {agent, service};
|
||||
}
|
||||
|
||||
export async function createUnreadChannelFixture(
|
||||
pw: PlaywrightExtended,
|
||||
adminClient: Client4,
|
||||
adminUserId: string,
|
||||
userId: string,
|
||||
teamId: string,
|
||||
displayName: string,
|
||||
sourceMessage: string,
|
||||
) {
|
||||
const channel = await adminClient.createChannel(
|
||||
pw.random.channel({
|
||||
teamId,
|
||||
name: `recap${pw.random.id()}`,
|
||||
displayName,
|
||||
unique: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await adminClient.addToChannel(userId, channel.id);
|
||||
await adminClient.createPost({
|
||||
channel_id: channel.id,
|
||||
user_id: adminUserId,
|
||||
message: sourceMessage,
|
||||
});
|
||||
|
||||
return channel;
|
||||
}
|
||||
|
||||
// createChannelWithManyPosts seeds a channel (with the user added as a member) and posts `postCount`
|
||||
// admin messages, each padded to roughly `messageLength` characters and tagged with a unique marker so
|
||||
// tests can detect exactly which posts reached the recap. Posts are returned in creation order
|
||||
// (oldest first); markers[i] corresponds to posts[i].
|
||||
export async function createChannelWithManyPosts(
|
||||
pw: PlaywrightExtended,
|
||||
adminClient: Client4,
|
||||
adminUserId: string,
|
||||
userId: string,
|
||||
teamId: string,
|
||||
displayName: string,
|
||||
postCount: number,
|
||||
messageLength: number,
|
||||
) {
|
||||
const channel = await adminClient.createChannel(
|
||||
pw.random.channel({
|
||||
teamId,
|
||||
name: `recap${pw.random.id()}`,
|
||||
displayName,
|
||||
unique: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await adminClient.addToChannel(userId, channel.id);
|
||||
|
||||
const runId = pw.random.id();
|
||||
const posts = [];
|
||||
const markers: string[] = [];
|
||||
for (let index = 0; index < postCount; index++) {
|
||||
const marker = `RTM-${runId}-${index}`;
|
||||
const padding = 'x'.repeat(Math.max(0, messageLength - marker.length - 1));
|
||||
const message = `${marker} ${padding}`;
|
||||
const post = await adminClient.createPost({
|
||||
channel_id: channel.id,
|
||||
user_id: adminUserId,
|
||||
message,
|
||||
});
|
||||
posts.push(post);
|
||||
markers.push(marker);
|
||||
}
|
||||
|
||||
return {channel, posts, markers};
|
||||
}
|
||||
|
||||
export async function createRecapAndWaitForStatus(
|
||||
pw: PlaywrightExtended,
|
||||
userClient: Client4,
|
||||
recapTitle: string,
|
||||
channelIds: string[],
|
||||
agentId: string,
|
||||
expectedStatus: string,
|
||||
) {
|
||||
const recap = await userClient.createRecap({
|
||||
title: recapTitle,
|
||||
channel_ids: channelIds,
|
||||
agent_id: agentId,
|
||||
});
|
||||
|
||||
await pw.waitUntil(
|
||||
async () => {
|
||||
const currentRecap = await userClient.getRecap(recap.id);
|
||||
return currentRecap.status === expectedStatus;
|
||||
},
|
||||
{timeout: pw.duration.one_min},
|
||||
);
|
||||
|
||||
return userClient.getRecap(recap.id);
|
||||
}
|
||||
|
||||
export async function waitForRecapStatus(
|
||||
pw: PlaywrightExtended,
|
||||
userClient: Client4,
|
||||
recapTitle: string,
|
||||
expectedStatus: string,
|
||||
) {
|
||||
await pw.waitUntil(
|
||||
async () => {
|
||||
const recaps = await userClient.getRecaps(0, 60);
|
||||
return recaps.some((recap) => recap.title === recapTitle && recap.status === expectedStatus);
|
||||
},
|
||||
{timeout: pw.duration.one_min},
|
||||
);
|
||||
}
|
||||
|
||||
export async function waitForRecordedRequestCount(pw: PlaywrightExtended, adminClient: Client4, requestCount: number) {
|
||||
await pw.waitUntil(
|
||||
async () => {
|
||||
const bridgeState = await pw.getAIBridgeMock(adminClient);
|
||||
return (
|
||||
bridgeState.recorded_requests.filter((request) => request.operation === 'recap_summary').length ===
|
||||
requestCount
|
||||
);
|
||||
},
|
||||
{timeout: pw.duration.one_min},
|
||||
);
|
||||
}
|
||||
|
||||
export async function markAllCurrentChannelsRead(userClient: Client4, teamId: string) {
|
||||
const currentChannels = await userClient.getMyChannels(teamId);
|
||||
await userClient.readMultipleChannels(currentChannels.map((channel: Channel) => channel.id));
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {expect, test} from './ai_bridge_fixture';
|
||||
import {createUnreadChannelFixture, setupRecapBridge} from './recaps_helpers';
|
||||
|
||||
const MONDAY = 1 << 1;
|
||||
const WEDNESDAY = 1 << 3;
|
||||
|
||||
/**
|
||||
* @objective Verify a user can create, list, pause, and resume a scheduled AI recap
|
||||
*/
|
||||
test('creates and manages a scheduled recap', {tag: '@ai_recaps'}, async ({pw}) => {
|
||||
const recapTitle = `Scheduled recap ${pw.random.id()}`;
|
||||
const sourceMessage = `Scheduled recap source ${pw.random.id()}`;
|
||||
|
||||
// # Initialize the test server state, configure a deterministic recap agent, and seed a channel.
|
||||
const {adminClient, adminUser, team, user, userClient} = await pw.initSetup();
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
const {agent} = await setupRecapBridge(pw, adminClient, {
|
||||
completions: [],
|
||||
});
|
||||
|
||||
const channel = await createUnreadChannelFixture(
|
||||
pw,
|
||||
adminClient,
|
||||
adminUser.id,
|
||||
user.id,
|
||||
team.id,
|
||||
'Scheduled recap channel',
|
||||
sourceMessage,
|
||||
);
|
||||
|
||||
// # Create a selected-channel scheduled recap through the modal flow.
|
||||
const {recapsPage} = await pw.testBrowser.login(user);
|
||||
await recapsPage.goto(team.name);
|
||||
await recapsPage.toBeVisible();
|
||||
|
||||
const createRecapModal = await recapsPage.openCreateRecap();
|
||||
await createRecapModal.fillTitle(recapTitle);
|
||||
await createRecapModal.selectSelectedChannels();
|
||||
await createRecapModal.clickNext();
|
||||
await createRecapModal.expectChannelSelectorVisible();
|
||||
await createRecapModal.searchChannel(channel.display_name);
|
||||
await createRecapModal.selectChannel(channel.display_name);
|
||||
await createRecapModal.clickNext();
|
||||
await createRecapModal.expectScheduleConfigurationVisible();
|
||||
await createRecapModal.selectScheduleDay('M');
|
||||
await createRecapModal.createSchedule();
|
||||
|
||||
// * Verify the scheduled recap is listed with stable schedule structure and active state.
|
||||
await recapsPage.toBeVisible();
|
||||
await recapsPage.switchToScheduled();
|
||||
|
||||
const scheduledRecap = recapsPage.getScheduledRecap(recapTitle);
|
||||
await scheduledRecap.toBeVisible();
|
||||
await scheduledRecap.expectSchedulePattern(/Mon|Monday/);
|
||||
await scheduledRecap.expectSchedulePattern(/ at /);
|
||||
await scheduledRecap.expectActive();
|
||||
|
||||
// * Verify pause and resume update both the UI and the stored scheduled recap.
|
||||
await scheduledRecap.pause();
|
||||
await scheduledRecap.resume();
|
||||
|
||||
const scheduledRecaps = await userClient.getScheduledRecaps(0, 60);
|
||||
const createdRecap = scheduledRecaps.find((recap) => recap.title === recapTitle);
|
||||
expect(createdRecap).toBeDefined();
|
||||
expect(createdRecap?.agent_id).toBe(agent.id);
|
||||
expect(createdRecap?.channel_ids).toContain(channel.id);
|
||||
expect(createdRecap?.days_of_week).toBe(MONDAY);
|
||||
expect(createdRecap?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify the scheduled recap limit rejects creating more active scheduled recaps than configured
|
||||
*/
|
||||
test('rejects scheduled recap creation after the active schedule limit', {tag: '@ai_recaps'}, async ({pw}) => {
|
||||
const titlePrefix = `Limited scheduled recap ${pw.random.id()}`;
|
||||
const sourceMessage = `Scheduled limit source ${pw.random.id()}`;
|
||||
|
||||
// # Initialize an isolated user, configure the recap bridge, and set a one-scheduled-recap limit.
|
||||
const {adminClient, adminUser, team, user, userClient} = await pw.initSetup();
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
const {agent} = await setupRecapBridge(pw, adminClient, {
|
||||
completions: [],
|
||||
});
|
||||
const originalConfig = await adminClient.getConfig();
|
||||
|
||||
try {
|
||||
await adminClient.patchConfig({
|
||||
AIRecapSettings: {
|
||||
EnforceScheduledRecaps: true,
|
||||
DefaultLimits: {
|
||||
MaxScheduledRecaps: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const channel = await createUnreadChannelFixture(
|
||||
pw,
|
||||
adminClient,
|
||||
adminUser.id,
|
||||
user.id,
|
||||
team.id,
|
||||
'Scheduled limit recap channel',
|
||||
sourceMessage,
|
||||
);
|
||||
const scheduledRecapInput = {
|
||||
days_of_week: WEDNESDAY,
|
||||
time_of_day: '09:00',
|
||||
timezone: 'UTC',
|
||||
time_period: 'last_24h' as const,
|
||||
channel_mode: 'specific' as const,
|
||||
channel_ids: [channel.id],
|
||||
agent_id: agent.id,
|
||||
is_recurring: true,
|
||||
};
|
||||
|
||||
const allowedRecap = await userClient.createScheduledRecap({
|
||||
...scheduledRecapInput,
|
||||
title: `${titlePrefix} allowed`,
|
||||
});
|
||||
|
||||
let blockedError: {status_code?: number; message?: string} | undefined;
|
||||
try {
|
||||
await userClient.createScheduledRecap({
|
||||
...scheduledRecapInput,
|
||||
title: `${titlePrefix} blocked`,
|
||||
});
|
||||
} catch (error) {
|
||||
blockedError = error as {status_code?: number; message?: string};
|
||||
}
|
||||
|
||||
// * Verify the API rejects the over-limit schedule without creating the second recap.
|
||||
expect(blockedError?.status_code).toBe(400);
|
||||
expect(blockedError?.message).toContain('scheduled recaps');
|
||||
|
||||
const scheduledRecaps = await userClient.getScheduledRecaps(0, 60);
|
||||
expect(scheduledRecaps.some((recap) => recap.id === allowedRecap.id)).toBe(true);
|
||||
expect(scheduledRecaps.some((recap) => recap.title === `${titlePrefix} blocked`)).toBe(false);
|
||||
} finally {
|
||||
await adminClient.updateConfig(originalConfig as any);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify a user can edit a scheduled recap's title and schedule days through the Options menu
|
||||
*/
|
||||
test('edits a scheduled recap through the options menu', {tag: '@ai_recaps'}, async ({pw}) => {
|
||||
const originalTitle = `Editable scheduled recap ${pw.random.id()}`;
|
||||
const updatedTitle = `Edited scheduled recap ${pw.random.id()}`;
|
||||
const sourceMessage = `Edit scheduled source ${pw.random.id()}`;
|
||||
|
||||
// # Initialize the test server state, configure the recap bridge, and seed a channel.
|
||||
const {adminClient, adminUser, team, user, userClient} = await pw.initSetup();
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
const {agent} = await setupRecapBridge(pw, adminClient, {
|
||||
completions: [],
|
||||
});
|
||||
|
||||
const channel = await createUnreadChannelFixture(
|
||||
pw,
|
||||
adminClient,
|
||||
adminUser.id,
|
||||
user.id,
|
||||
team.id,
|
||||
'Edit scheduled recap channel',
|
||||
sourceMessage,
|
||||
);
|
||||
|
||||
// # Create a Monday-only scheduled recap through the API so the edit flow starts from a known state.
|
||||
const createdRecap = await userClient.createScheduledRecap({
|
||||
title: originalTitle,
|
||||
days_of_week: MONDAY,
|
||||
time_of_day: '09:00',
|
||||
timezone: 'UTC',
|
||||
time_period: 'last_24h',
|
||||
channel_mode: 'specific',
|
||||
channel_ids: [channel.id],
|
||||
agent_id: agent.id,
|
||||
is_recurring: true,
|
||||
});
|
||||
|
||||
// # Open the Scheduled tab and launch the edit modal from the recap's Options menu.
|
||||
const {recapsPage} = await pw.testBrowser.login(user);
|
||||
await recapsPage.goto(team.name);
|
||||
await recapsPage.toBeVisible();
|
||||
await recapsPage.switchToScheduled();
|
||||
|
||||
const scheduledRecap = recapsPage.getScheduledRecap(originalTitle);
|
||||
await scheduledRecap.toBeVisible();
|
||||
await scheduledRecap.editViaMenu();
|
||||
|
||||
// # Update the title on the first step and add Wednesday on the schedule step of the pre-filled modal.
|
||||
const editModal = recapsPage.createRecapModal;
|
||||
await editModal.toBeVisible();
|
||||
await editModal.fillTitle(updatedTitle);
|
||||
await editModal.clickNext();
|
||||
await editModal.expectChannelSelectorVisible();
|
||||
await editModal.clickNext();
|
||||
await editModal.expectScheduleConfigurationVisible();
|
||||
await editModal.selectScheduleDay('W');
|
||||
await editModal.saveChanges();
|
||||
|
||||
// * Verify the list reflects the new title and the dropped original title, and shows the added day.
|
||||
await recapsPage.toBeVisible();
|
||||
const updatedRecap = recapsPage.getScheduledRecap(updatedTitle);
|
||||
await updatedRecap.toBeVisible();
|
||||
await updatedRecap.expectSchedulePattern(/Wed|Wednesday/);
|
||||
await recapsPage.expectScheduledRecapNotVisible(originalTitle);
|
||||
|
||||
// * Verify the persisted scheduled recap carries the updated title and the Monday+Wednesday schedule.
|
||||
await pw.waitUntil(
|
||||
async () => {
|
||||
const scheduledRecaps = await userClient.getScheduledRecaps(0, 60);
|
||||
const persisted = scheduledRecaps.find((recap) => recap.title === updatedTitle);
|
||||
return Boolean(persisted) && persisted?.days_of_week === (MONDAY | WEDNESDAY);
|
||||
},
|
||||
{timeout: pw.duration.one_min},
|
||||
);
|
||||
|
||||
const scheduledRecaps = await userClient.getScheduledRecaps(0, 60);
|
||||
expect(scheduledRecaps.some((recap) => recap.title === originalTitle)).toBe(false);
|
||||
const persisted = scheduledRecaps.find((recap) => recap.title === updatedTitle);
|
||||
expect(persisted).toBeDefined();
|
||||
expect(persisted?.days_of_week).toBe(MONDAY | WEDNESDAY);
|
||||
expect(persisted?.channel_ids).toContain(channel.id);
|
||||
|
||||
// * Verify the edit updated the existing recap in place (same id) rather than recreating it, and
|
||||
// that fields untouched by the edit (time_of_day) survived.
|
||||
expect(persisted?.id).toBe(createdRecap.id);
|
||||
expect(persisted?.time_of_day).toBe('09:00');
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify a user can delete a scheduled recap through the Options menu and confirm dialog
|
||||
*/
|
||||
test('deletes a scheduled recap through the options menu', {tag: '@ai_recaps'}, async ({pw}) => {
|
||||
const recapTitle = `Deletable scheduled recap ${pw.random.id()}`;
|
||||
const sourceMessage = `Delete scheduled source ${pw.random.id()}`;
|
||||
|
||||
// # Initialize the test server state, configure the recap bridge, and seed a channel.
|
||||
const {adminClient, adminUser, team, user, userClient} = await pw.initSetup();
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
const {agent} = await setupRecapBridge(pw, adminClient, {
|
||||
completions: [],
|
||||
});
|
||||
|
||||
const channel = await createUnreadChannelFixture(
|
||||
pw,
|
||||
adminClient,
|
||||
adminUser.id,
|
||||
user.id,
|
||||
team.id,
|
||||
'Delete scheduled recap channel',
|
||||
sourceMessage,
|
||||
);
|
||||
|
||||
// # Create a scheduled recap through the API so the UI delete flow has something to remove.
|
||||
const createdRecap = await userClient.createScheduledRecap({
|
||||
title: recapTitle,
|
||||
days_of_week: MONDAY,
|
||||
time_of_day: '09:00',
|
||||
timezone: 'UTC',
|
||||
time_period: 'last_24h',
|
||||
channel_mode: 'specific',
|
||||
channel_ids: [channel.id],
|
||||
agent_id: agent.id,
|
||||
is_recurring: true,
|
||||
});
|
||||
|
||||
// # Open the Scheduled tab, trigger Delete from the Options menu, and confirm the dialog.
|
||||
const {recapsPage} = await pw.testBrowser.login(user);
|
||||
await recapsPage.goto(team.name);
|
||||
await recapsPage.toBeVisible();
|
||||
await recapsPage.switchToScheduled();
|
||||
|
||||
const scheduledRecap = recapsPage.getScheduledRecap(recapTitle);
|
||||
await scheduledRecap.toBeVisible();
|
||||
await scheduledRecap.deleteViaMenu();
|
||||
await recapsPage.confirmDelete();
|
||||
|
||||
// * Verify the recap disappears from the list and the empty state returns.
|
||||
await recapsPage.expectScheduledRecapNotVisible(recapTitle);
|
||||
await recapsPage.expectScheduledEmptyState();
|
||||
|
||||
// * Verify the scheduled recap is no longer returned by the API.
|
||||
await pw.waitUntil(
|
||||
async () => {
|
||||
const scheduledRecaps = await userClient.getScheduledRecaps(0, 60);
|
||||
return !scheduledRecaps.some((recap) => recap.id === createdRecap.id);
|
||||
},
|
||||
{timeout: pw.duration.one_min},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify a fresh user with no scheduled recaps sees the scheduled empty-state call to action
|
||||
*/
|
||||
test('shows the scheduled empty state for a user with no scheduled recaps', {tag: '@ai_recaps'}, async ({pw}) => {
|
||||
// # Initialize the test server state and configure the recap bridge so the page renders fully.
|
||||
const {adminClient, team, user} = await pw.initSetup();
|
||||
await setupRecapBridge(pw, adminClient, {
|
||||
completions: [],
|
||||
});
|
||||
|
||||
// # Open the recaps page and switch to the Scheduled tab.
|
||||
const {recapsPage} = await pw.testBrowser.login(user);
|
||||
await recapsPage.goto(team.name);
|
||||
await recapsPage.toBeVisible();
|
||||
await recapsPage.switchToScheduled();
|
||||
|
||||
// * Verify the scheduled empty state heading, description, and create CTA are shown.
|
||||
await recapsPage.expectScheduledEmptyState();
|
||||
|
||||
// * Verify the empty-state CTA is wired up: clicking it opens the create recap modal.
|
||||
const createRecapModal = await recapsPage.openCreateRecapFromScheduledEmptyState();
|
||||
await createRecapModal.toBeVisible();
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify the all-unreads scheduled flow skips channel selection and stores the all_unreads mode
|
||||
*/
|
||||
test('creates an all-unreads scheduled recap through the modal', {tag: '@ai_recaps'}, async ({pw}) => {
|
||||
const recapTitle = `All unreads scheduled recap ${pw.random.id()}`;
|
||||
const sourceMessage = `All unreads scheduled source ${pw.random.id()}`;
|
||||
|
||||
// # Initialize the test server state, configure the recap bridge, and seed an unread channel.
|
||||
const {adminClient, adminUser, team, user, userClient} = await pw.initSetup();
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
|
||||
const {agent} = await setupRecapBridge(pw, adminClient, {
|
||||
completions: [],
|
||||
});
|
||||
|
||||
await createUnreadChannelFixture(
|
||||
pw,
|
||||
adminClient,
|
||||
adminUser.id,
|
||||
user.id,
|
||||
team.id,
|
||||
'All unreads scheduled channel',
|
||||
sourceMessage,
|
||||
);
|
||||
|
||||
// # Open the modal, choose all-unreads mode, and advance directly to the schedule step.
|
||||
const {recapsPage} = await pw.testBrowser.login(user);
|
||||
await recapsPage.goto(team.name);
|
||||
await recapsPage.toBeVisible();
|
||||
|
||||
const createRecapModal = await recapsPage.openCreateRecap();
|
||||
await createRecapModal.fillTitle(recapTitle);
|
||||
await createRecapModal.selectAllUnreads();
|
||||
await createRecapModal.clickNext();
|
||||
|
||||
// * Verify the all-unreads flow skips the channel selector and lands on schedule configuration.
|
||||
await createRecapModal.expectChannelSelectorHidden();
|
||||
await createRecapModal.expectScheduleConfigurationVisible();
|
||||
await createRecapModal.selectScheduleDay('M');
|
||||
await createRecapModal.createSchedule();
|
||||
|
||||
// * Verify the scheduled recap is listed.
|
||||
await recapsPage.toBeVisible();
|
||||
await recapsPage.switchToScheduled();
|
||||
await recapsPage.getScheduledRecap(recapTitle).toBeVisible();
|
||||
|
||||
// * Verify the persisted scheduled recap uses the all_unreads channel mode and is active.
|
||||
await pw.waitUntil(
|
||||
async () => {
|
||||
const scheduledRecaps = await userClient.getScheduledRecaps(0, 60);
|
||||
return scheduledRecaps.some((recap) => recap.title === recapTitle);
|
||||
},
|
||||
{timeout: pw.duration.one_min},
|
||||
);
|
||||
|
||||
const scheduledRecaps = await userClient.getScheduledRecaps(0, 60);
|
||||
const persisted = scheduledRecaps.find((recap) => recap.title === recapTitle);
|
||||
expect(persisted).toBeDefined();
|
||||
expect(persisted?.channel_mode).toBe('all_unreads');
|
||||
expect(persisted?.agent_id).toBe(agent.id);
|
||||
expect(persisted?.days_of_week).toBe(MONDAY);
|
||||
expect(persisted?.enabled).toBe(true);
|
||||
|
||||
// * Verify the all_unreads contract is fully pinned: no specific channels are persisted.
|
||||
expect(persisted?.channel_ids ?? []).toHaveLength(0);
|
||||
});
|
||||
Reference in New Issue
Block a user