ci(e2e): update snapshots on github actions

This commit is contained in:
d-kimsuon
2025-10-12 23:02:48 +09:00
parent e0983a7b92
commit 6d081e54b8
65 changed files with 437 additions and 300 deletions

View File

@@ -41,5 +41,53 @@ jobs:
- name: Run type checking
run: pnpm typecheck
- name: Run tests
run: pnpm test
e2e:
name: E2E Visual Regression Tests
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10.8.1
- name: Setup Node.js 20.12.0
uses: actions/setup-node@v4
with:
node-version: 20.12.0
cache: 'pnpm'
- name: Setup Git user
shell: bash
run: |
git config --global user.email "xxx@example.com"
git config --global user.name "user"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project
run: pnpm build
- name: Capture screenshots
run: pnpx tsx ./e2e/captureSnapshot/index.ts
env:
MAX_CONCURRENCY: 5
- name: Setup Git user
shell: bash
run: |
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config --global user.name "github-actions[bot]"
- name: Commit screenshots
run: |
git add e2e/snapshots
git diff --staged --exit-code || (git commit -m 'ci(snapshots): update screenshots' && git push)

View File

@@ -1,45 +1,5 @@
import { resolve } from "node:path";
import { testDevices } from "../testDevices";
import { withPlaywright } from "../utils/withPlaywright";
import { defineCapture } from "../utils/defineCapture";
// Different error scenarios to capture
const errorScenarios = [
{
name: "404",
url: "http://localhost:4000/non-existent-page",
},
{
name: "invalid-project",
url: "http://localhost:4000/projects/non-existent-project",
},
{
name: "invalid-session",
url: "http://localhost:4000/projects/sample-project/sessions/non-existent-session",
},
];
for (const scenario of errorScenarios) {
for (const { device, name } of testDevices) {
await withPlaywright(
async ({ context, cleanUp }) => {
const page = await context.newPage();
await page.goto(scenario.url);
await page.screenshot({
path: resolve(
"e2e",
"snapshots",
"errors",
`${scenario.name}_${name}.png`,
),
fullPage: true,
});
await cleanUp();
},
{
contextOptions: {
...device,
},
},
);
}
}
export const errorPagesCapture = defineCapture({
href: "/non-existent-page",
});

View File

@@ -1,23 +1,5 @@
import { resolve } from "node:path";
import { testDevices } from "../testDevices";
import { withPlaywright } from "../utils/withPlaywright";
import { defineCapture } from "../utils/defineCapture";
for (const { device, name } of testDevices) {
await withPlaywright(
async ({ context, cleanUp }) => {
const page = await context.newPage();
await page.goto("http://localhost:4000/");
await page.waitForLoadState("networkidle");
await page.screenshot({
path: resolve("e2e", "snapshots", "root", `${name}.png`),
fullPage: true,
});
await cleanUp();
},
{
contextOptions: {
...device,
},
},
);
}
export const homeCapture = defineCapture({
href: "/",
});

View File

@@ -1,40 +1,26 @@
import { execSync } from "node:child_process";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { homeCapture } from "./home";
import { errorPagesCapture } from "./error-pages";
import { projectsCapture } from "./projects";
import { projectDetailCapture } from "./project-detail";
import { sessionDetailCapture } from "./session-detail";
import { TaskExecutor } from "../utils/TaskExecutor";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const executor = new TaskExecutor({
maxConcurrency: process.env['MAX_CONCURRENCY'] ? parseInt(process.env['MAX_CONCURRENCY']) : 10,
});
const scripts = [
"home.ts",
"projects.ts",
"project-detail.ts",
"session-detail.ts",
"error-pages.ts",
const tasks = [
...homeCapture.tasks,
...errorPagesCapture.tasks,
...projectsCapture.tasks,
...projectDetailCapture.tasks,
...sessionDetailCapture.tasks,
];
async function captureAllSnapshots() {
console.log("🚀 Starting screenshot capture for all pages...\n");
executor.setTasks(tasks);
for (const script of scripts) {
const scriptPath = resolve(__dirname, script);
console.log(`📸 Capturing: ${script.replace(".ts", "")}...`);
try {
// Execute each script using tsx
execSync(`npx tsx "${scriptPath}"`, {
stdio: "inherit",
cwd: resolve(__dirname, "..", ".."),
});
console.log(`✅ Completed: ${script.replace(".ts", "")}\n`);
} catch (error) {
console.error(
`❌ Failed: ${script.replace(".ts", "")} - ${error.message}\n`,
);
}
}
console.log("🎉 All screenshot captures completed!");
try {
await executor.execute();
} catch (error) {
console.error(error);
}
captureAllSnapshots().catch(console.error);

View File

@@ -1,65 +1,31 @@
import { resolve } from "node:path";
import { testDevices } from "../testDevices";
import { withPlaywright } from "../utils/withPlaywright";
import { defineCapture } from "../utils/defineCapture";
// Test different UI states on project detail page
const testStates = [
{ name: "default", action: null },
{
name: "filters-expanded",
action: async (page) => {
const filterToggle = page.locator(
'[data-testid="filter-toggle"], button:has-text("Filters"), .filter-toggle',
);
if (await filterToggle.isVisible()) {
await filterToggle.click();
await page.waitForTimeout(300);
}
},
},
{
name: "new-chat-modal",
action: async (page) => {
const newChatButton = page.locator(
'button:has-text("New Chat"), [data-testid="new-chat"], .new-chat-button',
);
if (await newChatButton.isVisible()) {
await newChatButton.click();
await page.waitForTimeout(300);
}
},
},
];
for (const state of testStates) {
for (const { device, name } of testDevices) {
await withPlaywright(
async ({ context, cleanUp }) => {
const page = await context.newPage();
await page.goto("http://localhost:4000/projects/sample-project");
await page.waitForLoadState("networkidle");
if (state.action) {
await state.action(page);
export const projectDetailCapture = defineCapture({
href: "projects/L2hvbWUva2FpdG8vcmVwb3MvY2xhdWRlLWNvZGUtdmlld2VyL2Rpc3Qvc3RhbmRhbG9uZS9tb2NrLWdsb2JhbC1jbGF1ZGUtZGlyL3Byb2plY3RzL3NhbXBsZS1wcm9qZWN0",
cases: [
{
name: "filters-expanded",
setup: async (page) => {
const filterToggle = page.locator(
'[data-testid="expand-filter-settings-button"]',
);
if (await filterToggle.isVisible()) {
await filterToggle.click();
await page.waitForTimeout(300);
} else {
throw new Error("Filter settings button not found");
}
await page.screenshot({
path: resolve(
"e2e",
"snapshots",
"project-detail",
state.name,
`${name}.png`,
),
fullPage: true,
});
await cleanUp();
},
{
contextOptions: {
...device,
},
},
{
name: "new-chat-modal",
setup: async (page) => {
const newChatButton = page.locator('[data-testid="new-chat"]');
if (await newChatButton.isVisible()) {
await newChatButton.click();
await page.waitForTimeout(300);
}
},
);
}
}
},
],
});

View File

@@ -1,48 +1,5 @@
import { resolve } from "node:path";
import { testDevices } from "../testDevices";
import { withPlaywright } from "../utils/withPlaywright";
import { defineCapture } from "../utils/defineCapture";
// Test different states on projects page
const testStates = [
{ name: "default", action: null },
{
name: "empty",
action: async (page) => {
// Check for empty state (this will capture whatever state exists)
await page.waitForTimeout(500);
},
},
];
for (const state of testStates) {
for (const { device, name } of testDevices) {
await withPlaywright(
async ({ context, cleanUp }) => {
const page = await context.newPage();
await page.goto("http://localhost:4000/projects");
await page.waitForLoadState("networkidle");
if (state.action) {
await state.action(page);
}
await page.screenshot({
path: resolve(
"e2e",
"snapshots",
"projects",
state.name,
`${name}.png`,
),
fullPage: true,
});
await cleanUp();
},
{
contextOptions: {
...device,
},
},
);
}
}
export const projectsCapture = defineCapture({
href: "/projects",
});

View File

@@ -1,65 +1,91 @@
import { resolve } from "node:path";
import { testDevices } from "../testDevices";
import { withPlaywright } from "../utils/withPlaywright";
import { defineCapture } from "../utils/defineCapture";
// Multiple session IDs to capture different session detail pages
const sessionIds = [
"1af7fc5e-8455-4414-9ccd-011d40f70b2a",
"5c0375b4-57a5-4f26-b12d-d022ee4e51b7",
"fe5e1c67-53e7-4862-81ae-d0e013e3270b",
];
export const sessionDetailCapture = defineCapture({
href: "projects/L2hvbWUva2FpdG8vcmVwb3MvY2xhdWRlLWNvZGUtdmlld2VyL2Rpc3Qvc3RhbmRhbG9uZS9tb2NrLWdsb2JhbC1jbGF1ZGUtZGlyL3Byb2plY3RzL3NhbXBsZS1wcm9qZWN0/sessions/fe5e1c67-53e7-4862-81ae-d0e013e3270b",
cases: [
{
name: "sidebar-closed",
setup: async (page) => {
const menuButton = page.locator(
'[data-testid="mobile-sidebar-toggle-button"]',
);
if (await menuButton.isVisible()) {
await menuButton.click();
await page.waitForTimeout(300);
// Test different sidebar states
const testStates = [
{ name: "default", action: null },
{
name: "sidebar-open",
action: async (page) => {
const menuButton = page.locator(
'[data-testid="menu-button"], button:has-text("Menu"), .menu-toggle, .hamburger',
);
if (await menuButton.isVisible()) {
await menuButton.click();
await page.waitForTimeout(300);
}
},
},
];
for (const sessionId of sessionIds) {
for (const state of testStates) {
for (const { device, name } of testDevices) {
await withPlaywright(
async ({ context, cleanUp }) => {
const page = await context.newPage();
await page.goto(
`http://localhost:4000/projects/sample-project/sessions/${sessionId}`,
const sessionsTabButton = page.locator(
'[data-testid="sessions-tab-button-mobile"]',
);
await page.waitForLoadState("networkidle");
if (state.action) {
await state.action(page);
if (await sessionsTabButton.isVisible()) {
await sessionsTabButton.click();
await page.waitForTimeout(300);
}
} else {
const sessionsTabButton = page.locator(
'[data-testid="sessions-tab-button"]',
);
if (await sessionsTabButton.isVisible()) {
await sessionsTabButton.click();
await page.waitForTimeout(300);
}
}
},
},
// Create separate directories for each session
await page.screenshot({
path: resolve(
"e2e",
"snapshots",
"session-detail",
state.name,
`${sessionId}_${name}.png`,
),
fullPage: true,
});
await cleanUp();
},
{
contextOptions: {
...device,
},
},
);
}
}
}
{
name: "mcp-servers-tab",
setup: async (page) => {
const menuButton = page.locator(
'[data-testid="mobile-sidebar-toggle-button"]',
);
if (await menuButton.isVisible()) {
await menuButton.click();
await page.waitForTimeout(300);
const mcpTabButton = page.locator(
'[data-testid="mcp-tab-button-mobile"]',
);
if (await mcpTabButton.isVisible()) {
await mcpTabButton.click();
await page.waitForTimeout(300);
}
} else {
const mcpTabButton = page.locator('[data-testid="mcp-tab-button"]');
if (await mcpTabButton.isVisible()) {
await mcpTabButton.click();
await page.waitForTimeout(300);
}
}
},
},
{
name: "settings-tab",
setup: async (page) => {
const menuButton = page.locator(
'[data-testid="mobile-sidebar-toggle-button"]',
);
if (await menuButton.isVisible()) {
await menuButton.click();
await page.waitForTimeout(300);
const settingsTabButton = page.locator(
'[data-testid="settings-tab-button-mobile"]',
);
if (await settingsTabButton.isVisible()) {
await settingsTabButton.click();
await page.waitForTimeout(300);
}
} else {
const settingsTabButton = page.locator(
'[data-testid="settings-tab-button"]',
);
if (await settingsTabButton.isVisible()) {
await settingsTabButton.click();
await page.waitForTimeout(300);
}
}
},
},
],
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -10,7 +10,7 @@ export const testDevices = [
device: devices["iPhone 15"],
},
{
name: "table",
name: "tablet",
device: devices["iPad Pro 11"],
},
];

136
e2e/utils/TaskExecutor.ts Normal file
View File

@@ -0,0 +1,136 @@
import { ulid } from "ulid";
export type Task = {
key: string;
execute: () => Promise<void>;
}
type TaskStatus = {
id: string
task: Task
} & (
{
status: 'pending' | 'completed' | 'failed'
} | {
status: 'running',
promise: Promise<void>
}
)
type Options = {
maxConcurrency: number
}
export class TaskExecutor {
private taskStatuses: TaskStatus[] = []
private executionPromise?: {
resolve: () => void
reject: (reason?: unknown) => void
promise: Promise<void>
}
private options: Options
constructor(options?: Partial<Options>) {
this.options = {
maxConcurrency: 10,
...options,
}
}
private setExecutionPromise() {
let resolveExecution: (() => void) | undefined
let rejectExecution: ((reason?: unknown) => void) | undefined
const promise = new Promise<void>((resolve, reject) => {
resolveExecution = resolve
rejectExecution = reject
})
if (resolveExecution === undefined || rejectExecution === undefined) {
throw new Error('Illegal state: Promise not created')
}
this.executionPromise = {
resolve: resolveExecution,
reject: rejectExecution,
promise,
}
}
public setTasks(tasks: Task[]) {
const newTaskStatuses: TaskStatus[] = tasks.map((task) => ({
id: `${task.key}-${ulid()}`,
status: 'pending',
task,
}))
this.taskStatuses.push(...newTaskStatuses)
}
private get pendingTasks() {
return this.taskStatuses.filter((task) => task.status === 'pending')
}
private get runningTasks() {
return this.taskStatuses.filter((task) => task.status === 'running')
}
private updateStatus(id: string, status: TaskStatus) {
const found = this.taskStatuses.find((task) => task.id === id)
if (!found) {
throw new Error(`Task not found: ${id}`)
}
Object.assign(found, status)
}
public async execute() {
this.setExecutionPromise()
this.refresh()
await this.executionPromise?.promise
}
private refresh() {
if (this.runningTasks.length === 0 && this.pendingTasks.length === 0) {
this.executionPromise?.resolve()
console.log('execution completed.')
return;
}
const remainingTaskCount = this.options.maxConcurrency - this.runningTasks.length
if (remainingTaskCount <= 0) {
return;
}
for (const task of this.pendingTasks.slice(0, remainingTaskCount)) {
this.updateStatus(task.id, {
id: task.id,
status: 'running',
task: task.task,
promise: (async () => {
try {
await task.task.execute()
this.updateStatus(task.id, {
id: task.id,
status: 'completed',
task: task.task,
})
} catch (error) {
console.error(error)
this.updateStatus(task.id, {
id: task.id,
status: 'failed',
task: task.task,
})
} finally {
this.refresh()
}
})(),
})
}
}
}

View File

@@ -0,0 +1,83 @@
import { resolve } from "node:path";
import type { Page } from "playwright";
import { testDevices } from "../testDevices";
import { withPlaywright } from "../utils/withPlaywright";
import { Task } from "./TaskExecutor";
type CaptureCase = {
name: string;
setup: (page: Page) => Promise<void>;
};
export const defineCapture = (options: {
href: string;
cases?: readonly CaptureCase[];
}) => {
const { href, cases = [] } = options;
const paths = href
.split("/")
.map((path) => path.trim())
.filter((path) => path !== "");
const captureWithCase = async (
device: (typeof testDevices)[number],
testCase?: CaptureCase,
) => {
await withPlaywright(
async ({ context, cleanUp }) => {
const page = await context.newPage();
await page.goto(href);
await page.waitForLoadState("domcontentloaded");
await page.waitForTimeout(1000);
if (testCase) {
await testCase.setup(page);
}
const picturePath = testCase
? resolve(
"e2e",
"snapshots",
...paths,
testCase.name,
`${device.name}.png`,
)
: resolve("e2e", "snapshots", ...paths, `${device.name}.png`);
await page.screenshot({
path: picturePath,
fullPage: true,
});
console.log(`[captured] ${picturePath}`);
await cleanUp();
},
{
contextOptions: {
...device.device,
baseURL: "http://localhost:4000",
},
},
);
};
const tasks = testDevices.flatMap((device): Task[] => {
return [
{
key: `${device.name}-default`,
execute: () => captureWithCase(device),
},
...cases.map((testCase) => ({
key: `${device.name}-${testCase.name}`,
execute: () => captureWithCase(device, testCase),
})),
];
});
return {
tasks,
} as const;
};

View File

@@ -5,14 +5,14 @@ import { withPlaywright } from "./withPlaywright";
/**
* Take screenshots for a given URL across all test devices
*/
export async function takeScreenshots(
export const takeScreenshots = async (
url: string,
snapshotName: string,
options?: {
waitForSelector?: string;
timeout?: number;
},
) {
) => {
const { waitForSelector, timeout = 5000 } = options || {};
for (const { device, name } of testDevices) {
@@ -69,19 +69,19 @@ export async function takeScreenshots(
},
);
}
}
};
/**
* Take screenshots of a specific element across all test devices
*/
export async function takeElementScreenshots(
export const takeElementScreenshots = async (
url: string,
selector: string,
snapshotName: string,
options?: {
timeout?: number;
},
) {
) => {
const { timeout = 5000 } = options || {};
for (const { device, name } of testDevices) {
@@ -124,4 +124,4 @@ export async function takeElementScreenshots(
},
);
}
}
};

View File

@@ -1,23 +1,9 @@
import { expect, type Page } from "@playwright/test";
/**
* Utility functions for e2e tests
*/
/**
* Wait for the application to fully load
*/
export async function waitForAppLoad(page: Page) {
// Wait for React to hydrate and any initial data loading
await page.waitForLoadState("networkidle");
// Additional wait to ensure all components are rendered
await page.waitForTimeout(1000);
}
/**
* Take a full page screenshot for VRT
*/
export async function takeFullPageScreenshot(page: Page, name: string) {
export const takeFullPageScreenshot = async (page: Page, name: string) => {
// Wait for animations to complete
await page.waitForTimeout(300);
@@ -46,12 +32,4 @@ export async function takeFullPageScreenshot(page: Page, name: string) {
fullPage: true,
animations: "disabled",
});
}
/**
* Navigate to a page and wait for it to load
*/
export async function navigateAndWait(page: Page, path: string) {
await page.goto(path);
await waitForAppLoad(page);
}
};

View File

@@ -2,6 +2,9 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
typescript: {
ignoreBuildErrors: true,
},
};
export default nextConfig;

View File

@@ -68,7 +68,11 @@ export const ProjectPageContent = ({ projectId }: { projectId: string }) => {
<NewChatModal
projectId={projectId}
trigger={
<Button size="lg" className="gap-2 w-full sm:w-auto">
<Button
size="lg"
className="gap-2 w-full sm:w-auto"
data-testid="new-chat"
>
<PlusIcon className="w-4 h-4 sm:w-5 sm:h-5" />
<span className="hidden sm:inline">Start New Chat</span>
<span className="sm:hidden">New Chat</span>
@@ -96,6 +100,7 @@ export const ProjectPageContent = ({ projectId }: { projectId: string }) => {
<Button
variant="outline"
className="w-full justify-between mb-2 h-auto py-3"
data-testid="expand-filter-settings-button"
>
<div className="flex items-center gap-2">
<SettingsIcon className="w-4 h-4" />

View File

@@ -99,6 +99,7 @@ export const SessionPageContent: FC<{
size="sm"
className="md:hidden flex-shrink-0"
onClick={() => setIsMobileSidebarOpen(true)}
data-testid="mobile-sidebar-toggle-button"
>
<MenuIcon className="w-4 h-4" />
</Button>

View File

@@ -129,6 +129,7 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
: "text-sidebar-foreground/70",
)}
data-testid="sessions-tab-button-mobile"
>
<MessageSquareIcon className="w-4 h-4" />
</button>
@@ -143,6 +144,7 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
: "text-sidebar-foreground/70",
)}
data-testid="mcp-tab-button-mobile"
>
<PlugIcon className="w-4 h-4" />
</button>
@@ -157,6 +159,7 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
: "text-sidebar-foreground/70",
)}
data-testid="settings-tab-button-mobile"
>
<SettingsIcon className="w-4 h-4" />
</button>

View File

@@ -103,6 +103,7 @@ export const SessionSidebar: FC<{
: "text-sidebar-foreground/70",
)}
title="Sessions"
data-testid="sessions-tab-button"
>
<MessageSquareIcon className="w-4 h-4" />
</button>
@@ -118,6 +119,7 @@ export const SessionSidebar: FC<{
: "text-sidebar-foreground/70",
)}
title="MCP Servers"
data-testid="mcp-tab-button"
>
<PlugIcon className="w-4 h-4" />
</button>
@@ -133,6 +135,7 @@ export const SessionSidebar: FC<{
: "text-sidebar-foreground/70",
)}
title="Settings"
data-testid="settings-tab-button"
>
<SettingsIcon className="w-4 h-4" />
</button>