LibreChat/e2e/bombadil/specification.ts
Ravi Kumar L 7cf4c3f73f
🧪 test(e2e): add Bombadil property exploration (#14462)
* test(e2e): add Bombadil property exploration

* fix(e2e): address Bombadil review feedback
2026-08-10 02:11:06 +02:00

570 lines
19 KiB
TypeScript

import { always, eventually, extract, from, integers, next, now } from '@antithesishq/bombadil';
import { actions } from '@antithesishq/bombadil/browser';
import type { Action, Point, State } from '@antithesishq/bombadil/browser';
import {
noConsoleErrors,
noHttpErrorCodes,
noUncaughtExceptions,
noUnhandledPromiseRejections,
} from '@antithesishq/bombadil/browser/defaults/properties';
type Target = {
name: string;
point: Point;
};
type NavigationStatus = {
current: number;
total: number;
previousDisabled: boolean;
nextDisabled: boolean;
};
const LOGIN_EMAIL = '__BOMBADIL_E2E_USER_EMAIL__';
const LOGIN_PASSWORD = '__BOMBADIL_E2E_USER_PASSWORD__';
const ENTER_KEY_CODE = 13;
const RENAME_MARKER_PREFIX = ' — Bombadil:';
const MAX_CONVERSATION_FINGERPRINT_LENGTH = 80;
const PROMPT_VARIANTS = from([
{ name: 'short', suffix: '' },
{ name: 'unicode-雪-🙂', suffix: '' },
{ name: 'spaces', suffix: ' with internal spaces' },
{ name: 'markdown', suffix: ' [x](y)' },
{ name: 'long', suffix: `-${'x'.repeat(256)}` },
]);
const PROMPT_NONCES = integers().min(0).max(2_147_483_647);
function generatePrompt(): string {
const variant = PROMPT_VARIANTS.generate();
return `E2E_REPLY:bombadil-${variant.name}-${PROMPT_NONCES.generate()}${variant.suffix}`;
}
function promptMarker(text: string): string {
return text.match(/E2E_REPLY:[^\s]+/)?.[0] ?? '';
}
function conversationFingerprint(text: string): string {
return promptMarker(text).slice(0, MAX_CONVERSATION_FINGERPRINT_LENGTH);
}
function expectedReply(text: string): string {
const marker = promptMarker(text);
return marker === '' ? '' : `E2E reply ${marker.slice('E2E_REPLY:'.length)}`;
}
function visiblePoint(state: State, element: Element | null): Point | null {
if (!element) {
return null;
}
const style = state.window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
if (
style.display === 'none' ||
style.visibility === 'hidden' ||
style.pointerEvents === 'none' ||
rect.width <= 0 ||
rect.height <= 0
) {
return null;
}
const point = {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
};
if (
point.x < 0 ||
point.y < 0 ||
point.x > state.window.innerWidth ||
point.y > state.window.innerHeight
) {
return null;
}
const hitElement = state.document.elementFromPoint(point.x, point.y);
if (!hitElement || (hitElement !== element && !element.contains(hitElement))) {
return null;
}
return point;
}
function target(
state: State,
selector: string,
name: string,
text?: string,
last = false,
): Target | null {
const candidates = Array.from(state.document.querySelectorAll(selector)).filter((element) => {
if (text == null) {
return true;
}
return element.textContent?.trim() === text;
});
const elements = last ? candidates.reverse() : candidates;
for (const element of elements) {
if (element.matches(':disabled') || element.getAttribute('aria-disabled') === 'true') {
continue;
}
const point = visiblePoint(state, element);
if (point) {
return { name, point };
}
}
return null;
}
function conversationTargets(state: State): Target[] {
return Array.from(state.document.querySelectorAll('[data-testid="convo-item"]')).flatMap(
(element, index) => {
if (element.querySelector('[aria-current="page"]')) {
return [];
}
const point = visiblePoint(state, element);
return point ? [{ name: `Open conversation ${index + 1}`, point }] : [];
},
);
}
function clickedConversationIndex(lastAction: unknown): number | null {
if (typeof lastAction !== 'object' || lastAction === null || !('Click' in lastAction)) {
return null;
}
const click = lastAction.Click;
if (typeof click !== 'object' || click === null || !('name' in click)) {
return null;
}
const match = String(click.name).match(/^Open conversation (\d+)$/);
return match ? Number(match[1]) : null;
}
function isPersistedConversation(pathname: string): boolean {
return pathname.startsWith('/c/') && pathname !== '/c/new';
}
function inputValue(state: State, selector: string): string {
const element = state.document.querySelector(selector) as
| HTMLInputElement
| HTMLTextAreaElement
| null;
return element?.value ?? '';
}
function isFocused(state: State, selector: string): boolean {
return state.document.activeElement?.matches(selector) === true;
}
function clickAction(targetValue: Target | null): Action[] {
return targetValue ? [{ Click: targetValue }] : [];
}
function clickOrWait(targetValue: Target | null): Action[] {
const clicks = clickAction(targetValue);
return clicks.length > 0 ? clicks : ['Wait'];
}
const ui = extract((state: State) => {
const statuses: NavigationStatus[] = [];
for (const navigation of state.document.querySelectorAll(
'nav[aria-label="Sibling message navigation"]',
)) {
const text = navigation.querySelector('[role="status"]')?.textContent?.trim() ?? '';
const match = text.match(/^(\d+)\s*\/\s*(\d+)$/);
const previous = navigation.querySelector(
'button[aria-label="Previous sibling message"]',
) as HTMLButtonElement | null;
const nextButton = navigation.querySelector(
'button[aria-label="Next sibling message"]',
) as HTMLButtonElement | null;
if (match && previous && nextButton) {
statuses.push({
current: Number(match[1]),
total: Number(match[2]),
previousDisabled: previous.disabled || previous.getAttribute('aria-disabled') === 'true',
nextDisabled: nextButton.disabled || nextButton.getAttribute('aria-disabled') === 'true',
});
}
}
const messageIds = Array.from(state.document.querySelectorAll('.message-render'))
.map((element) => element.id)
.filter(Boolean);
const messageText = Array.from(state.document.querySelectorAll('.message-render'))
.map((element) => element.textContent ?? '')
.join('\n');
const parallelColumnCounts = Array.from(
state.document.querySelectorAll('.sibling-content-group'),
).map((group) => group.children.length);
const modelTrigger = state.document.querySelector('button[aria-label="Select a model"]');
const conversationElements = Array.from(
state.document.querySelectorAll('[data-testid="convo-item"]'),
);
const activeConversationIndexes = conversationElements.flatMap((element, index) =>
element.querySelector('[aria-current="page"]') ? [index + 1] : [],
);
const activeConversation = conversationElements.find((element) =>
element.querySelector('[aria-current="page"]'),
);
return {
path: state.window.location.pathname,
lastAction: state.lastAction,
messageText,
messageIds,
statuses,
parallelColumnCounts,
composerValue: inputValue(state, '#prompt-textarea'),
composerFocused: isFocused(state, '#prompt-textarea'),
renameValue: inputValue(state, 'input[aria-label="New Conversation Title"]'),
renameFocused: isFocused(state, 'input[aria-label="New Conversation Title"]'),
emailValue: inputValue(state, '#email'),
emailFocused: isFocused(state, '#email'),
passwordValue: inputValue(state, '#password'),
passwordFocused: isFocused(state, '#password'),
modelLabel: modelTrigger?.textContent?.trim() ?? '',
activeConversationIndexes,
activeConversationTitle: activeConversation?.textContent?.trim() ?? '',
modelOptionsOpen: state.document.querySelector('[role="option"]') !== null,
hasAddedConversation:
state.document.querySelector('button[aria-label="Close added conversation"]') !== null,
isSubmitting: state.document.querySelector('button[aria-label="Stop generating"]') !== null,
hasComposer: state.document.querySelector('#prompt-textarea') !== null,
loginEmail: target(state, '#email', 'Login email'),
loginPassword: target(state, '#password', 'Login password'),
loginSubmit: target(state, '[data-testid="login-button"]', 'Login'),
modelTrigger: target(state, 'button[aria-label="Select a model"]', 'Model selector'),
providerA: target(state, '[role="option"]', 'Mock Provider A', 'Mock Provider A'),
providerB: target(state, '[role="option"]', 'Mock Provider B', 'Mock Provider B'),
modelA: target(state, '[role="option"]', 'mock-model-a', 'mock-model-a'),
modelB: target(state, '[role="option"]', 'mock-model-b', 'mock-model-b'),
starterSpec: target(state, '[role="option"]', 'E2E Starters', 'E2E Starters'),
composer: target(state, '#prompt-textarea', 'Message input'),
addMultiConversation: target(
state,
'[data-testid="add-multi-convo-button"]',
'Add multi-conversation',
),
closeAddedConversation: target(
state,
'button[aria-label="Close added conversation"]',
'Close added conversation',
),
regenerate: target(state, 'button[title="Regenerate"]', 'Regenerate', undefined, true),
previousSibling: target(
state,
'button[aria-label="Previous sibling message"]',
'Previous sibling message',
undefined,
true,
),
nextSibling: target(
state,
'button[aria-label="Next sibling message"]',
'Next sibling message',
undefined,
true,
),
branchParallel: target(
state,
'button[aria-label="Create branch from this response"]',
'Create branch from parallel response',
undefined,
true,
),
newConversation: target(state, '[data-testid="new-chat-button"]', 'New conversation'),
conversationItems: conversationTargets(state),
conversationMenu: target(
state,
'button[aria-label="Conversation Menu Options"]',
'Conversation menu',
),
renameMenuItem: target(state, '[role="menuitem"]', 'Rename conversation', 'Rename'),
renameInput: target(state, 'input[aria-label="New Conversation Title"]', 'Conversation title'),
renameSave: target(state, 'button[aria-label="Save"]', 'Save conversation title'),
};
});
export { noConsoleErrors, noHttpErrorCodes, noUncaughtExceptions, noUnhandledPromiseRejections };
export const libreChatActions = actions(() => {
const state = ui.current;
if (state.path === '/login') {
if (!state.emailFocused && state.emailValue === '') {
return clickOrWait(state.loginEmail);
}
if (state.emailFocused && state.emailValue === '') {
return [{ TypeText: { text: LOGIN_EMAIL, delayMillis: 0 } }];
}
if (!state.passwordFocused && state.passwordValue === '') {
return clickOrWait(state.loginPassword);
}
if (state.passwordFocused && state.passwordValue === '') {
return [{ TypeText: { text: LOGIN_PASSWORD, delayMillis: 0 } }];
}
return clickOrWait(state.loginSubmit);
}
if (state.renameInput) {
if (!state.renameFocused) {
return clickAction(state.renameInput);
}
if (!state.renameValue.includes(RENAME_MARKER_PREFIX.trim())) {
const fingerprint = conversationFingerprint(state.messageText);
return fingerprint === ''
? ['Wait']
: [{ TypeText: { text: `${RENAME_MARKER_PREFIX}${fingerprint}`, delayMillis: 0 } }];
}
return clickOrWait(state.renameSave);
}
if (state.isSubmitting) {
return ['Wait'];
}
const selectorActions: Action[] = ['Wait'];
if (state.providerA) {
selectorActions.push(...clickAction(state.providerA));
}
if (state.providerB) {
selectorActions.push(...clickAction(state.providerB));
}
if (state.modelA) {
selectorActions.push(...clickAction(state.modelA));
}
if (state.modelB) {
selectorActions.push(...clickAction(state.modelB));
}
if (state.starterSpec) {
selectorActions.push(...clickAction(state.starterSpec));
}
if (state.modelOptionsOpen || selectorActions.length > 1) {
if (state.modelOptionsOpen) {
// Clicking a visible point outside the popover closes unknown submenu
// states without getting trapped in a keyboard-action retry loop.
selectorActions.push(...clickAction(state.composer));
}
return selectorActions;
}
if (state.renameMenuItem) {
return ['Wait', ...clickAction(state.renameMenuItem)];
}
const isPersisted = isPersistedConversation(state.path);
if (!state.hasComposer || (isPersisted && state.messageIds.length === 0)) {
return ['Wait'];
}
const possible: Action[] = ['Wait'];
if (state.modelLabel === '' || state.modelLabel === 'Select a model') {
possible.push(...clickAction(state.modelTrigger));
return possible;
}
const composeActions = (): Action[] => {
if (state.composerValue !== '') {
if (state.composerFocused) {
return [{ PressKey: { code: ENTER_KEY_CODE } }];
}
return clickOrWait(state.composer);
}
if (state.composerFocused) {
return [{ TypeText: { text: generatePrompt(), delayMillis: 0 } }];
}
return clickOrWait(state.composer);
};
if (state.messageIds.length === 0) {
return composeActions();
}
if (state.parallelColumnCounts.length === 0) {
if (!state.hasAddedConversation) {
return clickOrWait(state.addMultiConversation);
}
return composeActions();
}
if (state.statuses.length === 0 && state.branchParallel) {
return clickAction(state.branchParallel);
}
if (state.composerValue === '') {
if (state.composerFocused) {
possible.push({ TypeText: { text: generatePrompt(), delayMillis: 0 } });
} else {
possible.push(...clickAction(state.composer));
}
} else if (state.composerFocused) {
possible.push({ PressKey: { code: ENTER_KEY_CODE } });
}
possible.push(...clickAction(state.modelTrigger));
possible.push(...clickAction(state.addMultiConversation));
possible.push(...clickAction(state.closeAddedConversation));
possible.push(...clickAction(state.regenerate));
possible.push(...clickAction(state.previousSibling));
possible.push(...clickAction(state.nextSibling));
possible.push(...clickAction(state.branchParallel));
possible.push(...clickAction(state.newConversation));
possible.push(...state.conversationItems.map((item) => ({ Click: item }) as Action));
possible.push(...clickAction(state.conversationMenu));
possible.push(...clickAction(state.renameMenuItem));
if (state.path.startsWith('/c/') && state.path !== '/c/new') {
possible.push('Reload');
}
return possible;
});
export const messageIdsRemainUnique = always(() => {
const ids = ui.current.messageIds;
return new Set(ids).size === ids.length;
});
export const siblingNavigationRemainsValid = always(() =>
ui.current.statuses.every(
({ current, total, previousDisabled, nextDisabled }) =>
total > 1 &&
current >= 1 &&
current <= total &&
previousDisabled === (current === 1) &&
nextDisabled === (current === total),
),
);
export const multiConversationAlwaysRendersTwoColumns = always(() =>
ui.current.parallelColumnCounts.every((count) => count === 2),
);
export const sidebarNavigationEventuallySelectsTarget = always(() => {
const expectedIndex = clickedConversationIndex(ui.current.lastAction);
return now(() => expectedIndex !== null).implies(
eventually(
() =>
expectedIndex !== null &&
isPersistedConversation(ui.current.path) &&
ui.current.activeConversationIndexes.length === 1 &&
ui.current.activeConversationIndexes[0] === expectedIndex &&
ui.current.messageIds.length > 0,
).within(10, 'seconds'),
);
});
export const sidebarEventuallyMatchesRenderedConversation = always(() =>
now(
() =>
isPersistedConversation(ui.current.path) &&
ui.current.hasComposer &&
ui.current.messageIds.length > 0,
).implies(
eventually(() => {
const state = ui.current;
const fingerprint = conversationFingerprint(state.activeConversationTitle);
return (
isPersistedConversation(state.path) &&
state.activeConversationIndexes.length === 1 &&
(fingerprint === '' || state.messageText.includes(fingerprint))
);
}).within(10, 'seconds'),
),
);
export const loginEventuallySucceeds = eventually(() => ui.current.path !== '/login').within(
30,
'seconds',
);
export const anExchangeEventuallyHappens = eventually(
() => ui.current.messageIds.length >= 2,
).within(60, 'seconds');
export const multiConversationEventuallyRenders = eventually(
() => ui.current.parallelColumnCounts.length > 0,
).within(75, 'seconds');
export const aBranchEventuallyRenders = eventually(() => ui.current.statuses.length > 0).within(
85,
'seconds',
);
export const streamingEventuallyTerminates = always(() =>
now(() => ui.current.isSubmitting).implies(
eventually(() => !ui.current.isSubmitting).within(45, 'seconds'),
),
);
export const submittedPromptEventuallyAppears = always(() => {
const submittedText = ui.current.composerValue.trim();
const marker = promptMarker(submittedText);
const eligible = ui.current.composerFocused && submittedText !== '';
return next(
now(
() =>
eligible &&
typeof ui.current.lastAction === 'object' &&
ui.current.lastAction !== null &&
'PressKey' in ui.current.lastAction &&
ui.current.lastAction.PressKey.code === ENTER_KEY_CODE,
).implies(
eventually(() => marker !== '' && ui.current.messageText.includes(marker)).within(
30,
'seconds',
),
),
);
});
export const assistantReplyEventuallyAppears = always(() => {
const replyText = expectedReply(ui.current.composerValue.trim());
const eligible = ui.current.composerFocused && replyText !== '';
return next(
now(
() =>
eligible &&
typeof ui.current.lastAction === 'object' &&
ui.current.lastAction !== null &&
'PressKey' in ui.current.lastAction &&
ui.current.lastAction.PressKey.code === ENTER_KEY_CODE,
).implies(eventually(() => ui.current.messageText.includes(replyText)).within(30, 'seconds')),
);
});
export const composerEventuallyClearsAfterSubmit = always(() => {
const eligible = ui.current.composerFocused && ui.current.composerValue.trim() !== '';
return next(
now(
() =>
eligible &&
typeof ui.current.lastAction === 'object' &&
ui.current.lastAction !== null &&
'PressKey' in ui.current.lastAction &&
ui.current.lastAction.PressKey.code === ENTER_KEY_CODE,
).implies(eventually(() => ui.current.composerValue === '').within(5, 'seconds')),
);
});
export const multiConversationSubmissionEventuallyRendersInParallel = always(() => {
const submittedText = ui.current.composerValue.trim();
const marker = promptMarker(submittedText);
const eligible =
ui.current.hasAddedConversation && ui.current.composerFocused && submittedText !== '';
return next(
now(
() =>
eligible &&
typeof ui.current.lastAction === 'object' &&
ui.current.lastAction !== null &&
'PressKey' in ui.current.lastAction &&
ui.current.lastAction.PressKey.code === ENTER_KEY_CODE,
).implies(
eventually(() => {
const counts = ui.current.parallelColumnCounts;
return (
marker !== '' &&
ui.current.messageText.includes(marker) &&
counts.length > 0 &&
counts[counts.length - 1] === 2
);
}).within(30, 'seconds'),
),
);
});