ci(e2e): update snapshots on github actions
48
.github/workflows/ci.yml
vendored
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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: "/",
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 26 KiB |
@@ -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
@@ -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()
|
||||
}
|
||||
})(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
83
e2e/utils/defineCapture.ts
Normal 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;
|
||||
};
|
||||
@@ -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(
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,6 +2,9 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||