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

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View File

@@ -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},
],
});

View 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);
}
});

View File

@@ -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));
}

View File

@@ -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);
});