mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-02 12:22:22 +00:00
* 🌿 fix: Preserve Viewed Branch on Sibling-Tree Churn Regenerating a message could snap the view to an unrelated newest branch. MultiMessage reset siblingIdx to 0 (newest) on any messagesTree.length change, but getRegenerateSubmissionMessages slices the flat message array during a regenerate — the streaming handlers render a tree missing unrelated sibling branches, then finalHandler restores the full set. That 2→1→2 child-count swing snapped unrelated forks to their newest sibling, so regenerating the latest response on an older branch jumped to a previously regenerated branch. Replace the indiscriminate reset with per-fork branch memory: a 'seen' set distinguishes a genuinely new sibling (submission/regeneration/edit here — focus it) from one transiently dropped and restored (preserve the user's branch). Decision extracted as the pure, unit-tested resolveSiblingSelection. - client/src/utils/messages.ts: resolveSiblingSelection + tests - MultiMessage: seen/selectedId refs, structural id-signature effect - e2e: regenerate-latest-on-older-branch keeps the viewed branch (fails on the old reset, passes now) * 🧪 test: Long-Thread Branch Preservation E2E Add the user-reported scenario: in a multi-turn thread, regenerate an earlier response (forking a root branch), switch back to the original, then regenerate a later response on it — the original branch must stay intact. Uses labeled prompts so each turn's unique reply is a reliable settle signal. Verified it fails on the original MultiMessage and passes with the fix. * 🎨 style: Fix import order in MultiMessage (react before recoil) * 🌿 fix: Keep Unrelated Branches in Regenerate Optimistic Render Regenerating a message used a flat `messages.slice(0, targetIndex)` for the optimistic render, which also drops unrelated sibling branches that merely sit later in the flat array. Mid-regenerate the thread briefly collapsed to a short branch (visible flash) and the scroll jumped to the shrunken content and didn't recover — the same flat-array root cause as the branch-reset bug. Remove only the regenerated response and its descendants, keeping unrelated branches. The thread (and scroll) stay put through the regenerate. This array is render-only — the server regenerates from parentMessageId and createPayload doesn't include it — so summing by subtree never affects the request. Verified via a small-viewport scroll trace: old collapses 903->295px / 8->2 renders mid-stream; fixed stays 903px / 8 renders, scroll held at bottom. Unit test covers the keep-unrelated-branches behavior (fails on the old slice). * 🌿 fix: Let an Explicit Branch Selection Survive Streaming ID Churn resolveSiblingSelection focused any unseen sibling id before checking the committed selection. When an in-flight response's id is replaced mid-stream (placeholder → server/run id, e.g. useStepHandler re-keys to runId) after the user switched to a different sibling, that swap looked like a brand-new sibling and stole focus back to the streaming branch. Reorder: the committed selection wins while still present; only focus a fresh sibling when the selection is gone (regenerated away, or its own placeholder id was just replaced — that's how a regen/edit still takes focus, since the slice removes the old response). Added unit tests for both churn directions. * 🌿 fix: Only Focus a New Sibling When the Fork Actually Grew The previous churn fix (selection-wins-first) was too aggressive: a genuinely new sibling ADDED while the prior selection is still present — e.g. a follow-up re-parented as a sibling after a generation-start failure — was no longer focused, so its reply never rendered (broke message-tree generation-start recovery e2e). Gate new-sibling focus on actual growth: resolveSiblingSelection now takes prevCount and only focuses a never-seen id when ids.length > prevCount. A same-count placeholder→server id swap (churn) or a restored already-seen sibling is not growth, so the committed selection still wins there. Covers follow-up/new-branch focus, churn steal-prevention, and self-churn follow. message-tree + chat e2e: 17 passed (incl. the recovered generation-start test). * 🌿 refactor: Drop MultiMessage Branch-Memory in Favor of the Slice Fix The regenerate-slice fix (keep unrelated branches in the optimistic render) is the true root cause: with no spurious tree collapse, the original setSiblingIdx(0)-on-length-change never misfires, so the branch-reset is fixed without per-fork memory. The earlier MultiMessage rewrite (seen/selectedId/ prevCount + resolveSiblingSelection) was a symptom patch added before the root cause was found, and its per-instance memory generated two edge-case findings (placeholder→server id churn; divergence from external siblingIdx writes like resume restore). Revert MultiMessage to the simple upstream version and remove resolveSiblingSelection (+ its tests). The slice fix + the existing branch e2e (chat.spec: switch-back, regenerate-latest, long-thread) cover the behavior; all 17 chat + message-tree branch specs pass with this version. * 🌿 fix: Focus the Regenerated Response When Its Fork Count Is Unchanged When a parent already has multiple sibling responses and the user switches to a non-latest one and regenerates it, the optimistic slice drops the target but keeps the other siblings, so the child count is unchanged. MultiMessage only resets the (reversed) sibling index on a length change, so the stale index kept pointing at the kept sibling and the regenerating response stayed hidden until the server restored the dropped sibling at finalize (count bump → reset). Explicitly focus the newest sibling (reversed index 0 = the appended response) of the regenerated fork in createdHandler. Position-based, fires only on the regenerate action, so it doesn't reintroduce the placeholder→server id churn or external-write fragility that a per-render selection memory had. E2E: new during-stream test (slow+counted reply marker) asserting the regenerating response is visible before finalize; negatively verified (fails without the focus call, passes with it). * 🌿 fix: Eliminate Pre-Created Flash by Focusing at the Optimistic Render The createdHandler focus removed the until-finalize bug, but a brief flash remained between clicking regenerate and the `created` event: useChatFunctions renders the optimistic placeholder first, and that render has the same unchanged-count problem, so the kept sibling showed until createdHandler fired. Extract the focus into a shared useFocusRegeneratedResponse hook and apply it at the optimistic render too (useChatFunctions) and on `created` (useEventHandlers). The placeholder is now focused from the first frame. E2E: gated pre-created test — holds the SSE stream GET (the chat POST returns a stream id; the stream is a separate GET) so `created` cannot arrive, leaving only the optimistic render, then asserts the kept sibling is already gone. This isolates the optimistic focus (createdHandler cannot mask it); negatively verified (fails without the optimistic focus call). * 🧪 test: Extend Store Mock for the Regenerate Focus Hook useChatFunctions.regenerate.spec.tsx mocks ~/store and recoil partially; the new useFocusRegeneratedResponse calls store.messagesSiblingIdxFamily via a recoil `set`, neither of which the mock provided (TypeError on regenerate). Add messagesSiblingIdxFamily to the store mock and `set` to the useRecoilCallback mock. Test-only; production code unchanged.
396 lines
15 KiB
TypeScript
396 lines
15 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
|
import type { Page } from '@playwright/test';
|
|
import {
|
|
isAgentsStream,
|
|
MOCK_ENDPOINTS,
|
|
NEW_CHAT_PATH,
|
|
messagesView,
|
|
mockReply,
|
|
replyText,
|
|
replyPrompt,
|
|
selectMockEndpoint,
|
|
sendMessage,
|
|
} from './helpers';
|
|
|
|
type UploadFixture = {
|
|
name: string;
|
|
mimeType: string;
|
|
buffer: Buffer;
|
|
};
|
|
|
|
const pdfFixture: UploadFixture = {
|
|
name: 'provider-context.pdf',
|
|
mimeType: 'application/pdf',
|
|
buffer: Buffer.from(
|
|
`%PDF-1.4
|
|
1 0 obj
|
|
<< /Type /Catalog /Pages 2 0 R >>
|
|
endobj
|
|
2 0 obj
|
|
<< /Type /Pages /Count 0 >>
|
|
endobj
|
|
trailer
|
|
<< /Root 1 0 R >>
|
|
%%EOF
|
|
`,
|
|
),
|
|
};
|
|
|
|
const textFixture: UploadFixture = {
|
|
name: 'provider-context.txt',
|
|
mimeType: 'text/plain',
|
|
buffer: Buffer.from('This text attachment should be available to the mock model.\n'),
|
|
};
|
|
|
|
const imageFixture: UploadFixture = {
|
|
name: 'provider-context.png',
|
|
mimeType: 'image/png',
|
|
buffer: Buffer.from(
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
|
|
'base64',
|
|
),
|
|
};
|
|
|
|
const composer = (page: Page) => page.locator('form');
|
|
|
|
async function openProviderFileChooser(page: Page) {
|
|
await page.getByRole('button', { name: 'Attach File Options' }).click();
|
|
await expect(page.getByText('Upload to Provider')).toBeVisible();
|
|
|
|
const fileChooserPromise = page.waitForEvent('filechooser');
|
|
await page.getByText('Upload to Provider').click();
|
|
const fileChooser = await fileChooserPromise;
|
|
expect(await fileChooser.element().getAttribute('type')).toBe('file');
|
|
return fileChooser;
|
|
}
|
|
|
|
async function uploadProviderFile(page: Page, fixture: UploadFixture) {
|
|
const fileChooser = await openProviderFileChooser(page);
|
|
const uploadResponsePromise = page.waitForResponse(
|
|
(response) =>
|
|
response.url().includes('/api/files') &&
|
|
response.request().method() === 'POST' &&
|
|
response.status() === 200,
|
|
{ timeout: 30000 },
|
|
);
|
|
await fileChooser.setFiles(fixture);
|
|
const uploadResponse = await uploadResponsePromise;
|
|
expect(uploadResponse.ok()).toBeTruthy();
|
|
await page.waitForTimeout(350);
|
|
return uploadResponse;
|
|
}
|
|
|
|
test.describe('core chat loop', () => {
|
|
test('streams a response, saves the conversation, and persists across reload', async ({
|
|
page,
|
|
}) => {
|
|
test.setTimeout(60000);
|
|
const userMessage = 'ping from e2e';
|
|
|
|
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
|
|
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
|
|
|
|
const response = await sendMessage(page, userMessage);
|
|
expect(response.ok()).toBeTruthy();
|
|
|
|
await expect(page.getByText(userMessage)).toBeVisible();
|
|
const userMessageTurn = messagesView(page)
|
|
.locator('.message-render')
|
|
.filter({ hasText: userMessage });
|
|
await expect(userMessageTurn.locator('.user-turn')).toBeVisible();
|
|
await expect(userMessageTurn.locator('.agent-turn')).toHaveCount(0);
|
|
await expect(mockReply(page)).toBeVisible();
|
|
|
|
await expect(page).toHaveURL(/\/c\/[0-9a-fA-F-]{36}$/);
|
|
const conversationUrl = page.url();
|
|
|
|
await expect(page.getByTestId('convo-item').first()).toBeVisible();
|
|
|
|
await page.reload({ timeout: 10000 });
|
|
await expect(page).toHaveURL(conversationUrl);
|
|
await expect(page.getByText(userMessage)).toBeVisible();
|
|
await expect(mockReply(page)).toBeVisible();
|
|
await expect(page.getByTestId('convo-item').first()).toBeVisible();
|
|
});
|
|
|
|
test('keeps send disabled until the composer has message text', async ({ page }) => {
|
|
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
|
|
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
|
|
|
|
const input = page.getByRole('textbox', { name: 'Message input' });
|
|
const sendButton = page.getByTestId('send-button');
|
|
|
|
await expect(sendButton).toBeDisabled();
|
|
await input.fill('ready to send');
|
|
await expect(sendButton).toBeEnabled();
|
|
await input.fill(' ');
|
|
await expect(sendButton).toBeDisabled();
|
|
});
|
|
|
|
test('renders assistant markdown and syntax-highlighted code blocks', async ({ page }) => {
|
|
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
|
|
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
|
|
|
|
const response = await sendMessage(page, 'E2E_MARKDOWN_REPLY');
|
|
expect(response.ok()).toBeTruthy();
|
|
|
|
const assistantMessage = messagesView(page)
|
|
.locator('.message-render')
|
|
.filter({ hasText: 'E2E markdown heading' })
|
|
.last();
|
|
await expect(assistantMessage.locator('.agent-turn')).toBeVisible();
|
|
await expect(
|
|
assistantMessage.getByRole('heading', { name: 'E2E markdown heading' }),
|
|
).toBeVisible();
|
|
await expect(
|
|
assistantMessage.locator('strong').filter({ hasText: 'E2E bold text' }),
|
|
).toBeVisible();
|
|
await expect(
|
|
assistantMessage.getByRole('listitem').filter({ hasText: 'E2E list item' }),
|
|
).toBeVisible();
|
|
|
|
const codeBlock = assistantMessage.locator('code').filter({ hasText: 'e2eSyntaxHighlight' });
|
|
await expect(codeBlock).toBeVisible();
|
|
await expect(codeBlock).toHaveClass(/hljs/);
|
|
await expect(codeBlock).toHaveClass(/language-javascript/);
|
|
});
|
|
|
|
test('can switch back to the previous branch after regenerating an earlier response', async ({
|
|
page,
|
|
}) => {
|
|
test.setTimeout(90000);
|
|
const firstMessage = 'branch root from e2e';
|
|
const followUpMessage = 'follow-up on original branch from e2e';
|
|
|
|
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
|
|
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
|
|
|
|
let response = await sendMessage(page, firstMessage);
|
|
expect(response.ok()).toBeTruthy();
|
|
await expect(mockReply(page)).toBeVisible();
|
|
|
|
response = await sendMessage(page, followUpMessage);
|
|
expect(response.ok()).toBeTruthy();
|
|
await expect(page.getByText(followUpMessage)).toBeVisible();
|
|
|
|
const firstAssistantMessage = messagesView(page).locator('.message-render').nth(1);
|
|
await firstAssistantMessage.hover();
|
|
const regenerateButton = firstAssistantMessage.locator('button[title="Regenerate"]').last();
|
|
await expect(regenerateButton).toBeVisible();
|
|
|
|
const [regenerateResponse] = await Promise.all([
|
|
page.waitForResponse(isAgentsStream, { timeout: 30000 }),
|
|
regenerateButton.click(),
|
|
]);
|
|
expect(regenerateResponse.ok()).toBeTruthy();
|
|
|
|
await expect(page.getByText('2 / 2')).toBeVisible();
|
|
await page.getByRole('button', { name: 'Previous sibling message' }).click();
|
|
await expect(page.getByText('1 / 2')).toBeVisible();
|
|
await expect(page.getByText(followUpMessage)).toBeVisible();
|
|
});
|
|
|
|
test('keeps the viewed branch when regenerating its latest response with an earlier branch present', async ({
|
|
page,
|
|
}) => {
|
|
test.setTimeout(120000);
|
|
const firstMessage = 'first turn from e2e';
|
|
const secondMessage = 'second turn from e2e';
|
|
|
|
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
|
|
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
|
|
|
|
let response = await sendMessage(page, firstMessage);
|
|
expect(response.ok()).toBeTruthy();
|
|
await expect(mockReply(page).first()).toBeVisible();
|
|
response = await sendMessage(page, secondMessage);
|
|
expect(response.ok()).toBeTruthy();
|
|
await expect(page.getByText(secondMessage)).toBeVisible();
|
|
|
|
// Regenerate the INITIAL response → a second root-level branch the second
|
|
// turn does not belong to.
|
|
const firstAssistant = messagesView(page).locator('.message-render').nth(1);
|
|
await firstAssistant.hover();
|
|
const regenInitial = firstAssistant.locator('button[title="Regenerate"]').last();
|
|
await expect(regenInitial).toBeVisible();
|
|
[response] = await Promise.all([
|
|
page.waitForResponse(isAgentsStream, { timeout: 30000 }),
|
|
regenInitial.click(),
|
|
]);
|
|
expect(response.ok()).toBeTruthy();
|
|
await expect(page.getByText('2 / 2')).toBeVisible();
|
|
await expect(page.getByText(secondMessage)).toHaveCount(0);
|
|
|
|
// Back to the ORIGINAL branch (both turns present).
|
|
await page.getByRole('button', { name: 'Previous sibling message' }).click();
|
|
await expect(page.getByText('1 / 2')).toBeVisible();
|
|
await expect(page.getByText(secondMessage)).toBeVisible();
|
|
|
|
// Regenerate the LATEST response on the original branch. The bug snapped the
|
|
// root fork back to the newest (regenerated-initial) branch, dropping the
|
|
// original thread; the view must stay put.
|
|
const latestAssistant = messagesView(page).locator('.message-render').last();
|
|
await latestAssistant.hover();
|
|
const regenLatest = latestAssistant.locator('button[title="Regenerate"]').last();
|
|
await expect(regenLatest).toBeVisible();
|
|
[response] = await Promise.all([
|
|
page.waitForResponse(isAgentsStream, { timeout: 30000 }),
|
|
regenLatest.click(),
|
|
]);
|
|
expect(response.ok()).toBeTruthy();
|
|
|
|
// Still on the original branch: the second turn survives and the root fork
|
|
// still reads 1 / 2 (rather than snapping to the regenerated-initial branch).
|
|
await expect(page.getByText(secondMessage)).toBeVisible();
|
|
await expect(page.getByText('1 / 2')).toBeVisible();
|
|
});
|
|
|
|
test('preserves a long original branch when regenerating early then later on it', async ({
|
|
page,
|
|
}) => {
|
|
test.setTimeout(150000);
|
|
// Labeled prompts give each turn a unique reply, so we can both settle on
|
|
// it (turn complete) and assert which branch is visible.
|
|
const turns = [
|
|
{ prompt: replyPrompt('lb-one'), reply: replyText('lb-one') },
|
|
{ prompt: replyPrompt('lb-two'), reply: replyText('lb-two') },
|
|
{ prompt: replyPrompt('lb-three'), reply: replyText('lb-three') },
|
|
];
|
|
|
|
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
|
|
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
|
|
|
|
// Build a three-turn thread (the "long running thread"), waiting for each
|
|
// turn's unique reply to render before sending the next.
|
|
for (const turn of turns) {
|
|
const response = await sendMessage(page, turn.prompt);
|
|
expect(response.ok()).toBeTruthy();
|
|
await expect(messagesView(page).getByText(turn.reply)).toBeVisible({ timeout: 30000 });
|
|
}
|
|
|
|
// Regenerate from an EARLIER part of the branch (the first response). This
|
|
// forks a fresh root branch that does not contain the later turns.
|
|
const earlyAssistant = messagesView(page).locator('.message-render').nth(1);
|
|
await earlyAssistant.hover();
|
|
const regenEarly = earlyAssistant.locator('button[title="Regenerate"]').last();
|
|
await expect(regenEarly).toBeVisible();
|
|
let [response] = await Promise.all([
|
|
page.waitForResponse(isAgentsStream, { timeout: 30000 }),
|
|
regenEarly.click(),
|
|
]);
|
|
expect(response.ok()).toBeTruthy();
|
|
await expect(page.getByText('2 / 2')).toBeVisible();
|
|
// The fresh branch does not contain the later turns' replies.
|
|
await expect(messagesView(page).getByText(turns[1].reply)).toHaveCount(0);
|
|
await expect(messagesView(page).getByText(turns[2].reply)).toHaveCount(0);
|
|
|
|
// Go back to the ORIGINAL branch — all three turns are present again.
|
|
await page.getByRole('button', { name: 'Previous sibling message' }).click();
|
|
await expect(page.getByText('1 / 2')).toBeVisible();
|
|
await expect(messagesView(page).getByText(turns[1].reply)).toBeVisible();
|
|
await expect(messagesView(page).getByText(turns[2].reply)).toBeVisible();
|
|
|
|
// Regenerate from LATER in the original branch (its latest response). The
|
|
// bug snapped the early fork back to the regenerated branch, collapsing the
|
|
// long original thread; it must stay intact.
|
|
const lateAssistant = messagesView(page).locator('.message-render').last();
|
|
await lateAssistant.hover();
|
|
const regenLate = lateAssistant.locator('button[title="Regenerate"]').last();
|
|
await expect(regenLate).toBeVisible();
|
|
[response] = await Promise.all([
|
|
page.waitForResponse(isAgentsStream, { timeout: 30000 }),
|
|
regenLate.click(),
|
|
]);
|
|
expect(response.ok()).toBeTruthy();
|
|
|
|
// The whole original branch survives and its first fork still reads 1 / 2.
|
|
await expect(messagesView(page).getByText(turns[1].reply)).toBeVisible();
|
|
await expect(page.getByText('1 / 2')).toBeVisible();
|
|
});
|
|
|
|
test('keeps upload-to-provider CSV attached to the sent message and model input', async ({
|
|
page,
|
|
}) => {
|
|
test.setTimeout(90000);
|
|
|
|
const csvFixture: UploadFixture = {
|
|
name: 'provider-upload.csv',
|
|
mimeType: 'text/csv',
|
|
buffer: Buffer.from('name,value\nalpha,1\n'),
|
|
};
|
|
const filename = csvFixture.name;
|
|
const assertionText = `E2E_ASSERT_PROVIDER_FILE:${filename}`;
|
|
const fileChip = messagesView(page).getByRole('button', { name: filename });
|
|
|
|
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
|
|
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
|
|
|
|
await uploadProviderFile(page, csvFixture);
|
|
|
|
await expect(page.getByRole('button', { name: filename })).toBeVisible();
|
|
|
|
const input = page.getByRole('textbox', { name: 'Message input' });
|
|
await input.click();
|
|
await input.fill(assertionText);
|
|
await expect(page.getByTestId('send-button')).toBeEnabled();
|
|
|
|
const [response] = await Promise.all([
|
|
page.waitForResponse(isAgentsStream, { timeout: 30000 }),
|
|
page.getByTestId('send-button').click(),
|
|
]);
|
|
expect(response.ok()).toBeTruthy();
|
|
|
|
await expect(
|
|
messagesView(page).getByText(`E2E provider file assertion passed: ${filename}`),
|
|
).toBeVisible();
|
|
await expect(fileChip).toBeVisible();
|
|
|
|
await expect(page).toHaveURL(/\/c\/[0-9a-fA-F-]{36}$/);
|
|
const conversationUrl = page.url();
|
|
await page.reload({ timeout: 10000 });
|
|
await expect(page).toHaveURL(conversationUrl);
|
|
await expect(fileChip).toBeVisible();
|
|
});
|
|
|
|
test('supports attaching, removing, and sending provider files from the composer', async ({
|
|
page,
|
|
}) => {
|
|
test.setTimeout(90000);
|
|
|
|
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
|
|
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
|
|
|
|
await uploadProviderFile(page, pdfFixture);
|
|
const pdfChip = composer(page).getByRole('button', { name: pdfFixture.name });
|
|
await expect(pdfChip).toBeVisible();
|
|
|
|
await composer(page).getByRole('button', { name: 'Remove file' }).click();
|
|
await expect(pdfChip).toHaveCount(0);
|
|
|
|
await uploadProviderFile(page, imageFixture);
|
|
await expect(
|
|
composer(page).getByRole('button', { name: 'View Preview image in full size' }),
|
|
).toBeVisible();
|
|
|
|
await uploadProviderFile(page, textFixture);
|
|
const textChip = composer(page).getByRole('button', { name: textFixture.name });
|
|
await expect(textChip).toBeVisible();
|
|
|
|
const assertionText = `E2E_ASSERT_PROVIDER_FILE:${textFixture.name}`;
|
|
const input = page.getByRole('textbox', { name: 'Message input' });
|
|
await input.click();
|
|
await input.fill(assertionText);
|
|
await expect(page.getByTestId('send-button')).toBeEnabled();
|
|
|
|
const [response] = await Promise.all([
|
|
page.waitForResponse(isAgentsStream, { timeout: 30000 }),
|
|
page.getByTestId('send-button').click(),
|
|
]);
|
|
expect(response.ok()).toBeTruthy();
|
|
|
|
await expect(
|
|
messagesView(page).getByText(`E2E provider file assertion passed: ${textFixture.name}`),
|
|
).toBeVisible();
|
|
await expect(messagesView(page).getByRole('button', { name: textFixture.name })).toBeVisible();
|
|
});
|
|
});
|