ci(e2e): start:e2e server before capturing

This commit is contained in:
d-kimsuon
2025-10-12 23:08:51 +09:00
parent a19d5f627c
commit 314cdcc749
12 changed files with 101 additions and 304 deletions

19
.github/actions/setup-node/action.yml vendored Normal file
View File

@@ -0,0 +1,19 @@
name: "Setup Node & pnpm"
description: "Install pnpm and setup Node.js with pnpm cache (fixed versions)"
runs:
using: "composite"
steps:
- 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: Install Dependencies
shell: bash
run: pnpm install --frozen-lockfile --ignore-scripts

View File

@@ -21,19 +21,8 @@ jobs:
- 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: Install dependencies
run: pnpm install --frozen-lockfile
- name: Setup Node & pnpm
uses: ./.github/actions/setup-node
- name: Run linting
run: pnpm lint
@@ -46,20 +35,15 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
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 Node & pnpm
uses: ./.github/actions/setup-node
- name: Setup Git user
shell: bash
@@ -67,16 +51,20 @@ jobs:
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config --global user.name "github-actions[bot]"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright
run: |
sudo npx playwright install-deps
pnpm exec playwright install
- name: Build project
run: pnpm build
- name: Capture screenshots
run: pnpx tsx ./e2e/captureSnapshot/index.ts
timeout-minutes: 3
run: pnpm e2e
env:
MAX_CONCURRENCY: 5
PORT: 4000
- name: Commit screenshots
run: |

View File

@@ -1,7 +1,8 @@
import { projectIds } from "../config";
import { defineCapture } from "../utils/defineCapture";
export const projectDetailCapture = defineCapture({
href: "projects/L2hvbWUva2FpdG8vcmVwb3MvY2xhdWRlLWNvZGUtdmlld2VyL2Rpc3Qvc3RhbmRhbG9uZS9tb2NrLWdsb2JhbC1jbGF1ZGUtZGlyL3Byb2plY3RzL3NhbXBsZS1wcm9qZWN0",
href: `projects/${projectIds.sampleProject}`,
cases: [
{
name: "filters-expanded",

View File

@@ -1,7 +1,8 @@
import { projectIds } from "../config";
import { defineCapture } from "../utils/defineCapture";
export const sessionDetailCapture = defineCapture({
href: "projects/L2hvbWUva2FpdG8vcmVwb3MvY2xhdWRlLWNvZGUtdmlld2VyL2Rpc3Qvc3RhbmRhbG9uZS9tb2NrLWdsb2JhbC1jbGF1ZGUtZGlyL3Byb2plY3RzL3NhbXBsZS1wcm9qZWN0/sessions/fe5e1c67-53e7-4862-81ae-d0e013e3270b",
href: `projects/${projectIds.sampleProject}/sessions/fe5e1c67-53e7-4862-81ae-d0e013e3270b`,
cases: [
{
name: "sidebar-closed",
@@ -32,33 +33,6 @@ export const sessionDetailCapture = defineCapture({
},
},
{
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) => {

15
e2e/config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { homedir } from "node:os";
import { resolve } from "node:path";
import { encodeProjectId } from "../src/server/service/project/id";
// biome-ignore lint/complexity/useLiteralKeys: env var
export const globalClaudeDirectoryPath = process.env["GLOBAL_CLAUDE_DIR"]
? // biome-ignore lint/complexity/useLiteralKeys: env var
resolve(process.env["GLOBAL_CLAUDE_DIR"])
: resolve(homedir(), ".claude");
export const projectIds = {
sampleProject: encodeProjectId(
resolve(globalClaudeDirectoryPath, "projects", "sample-project"),
),
} as const;

View File

@@ -1,22 +0,0 @@
import { resolve } from "node:path";
import { testDevices } from "./testDevices";
import { withPlaywright } from "./utils/withPlaywright";
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.screenshot({
path: resolve("e2e", "snapshots", "projects", `_${name}.png`),
fullPage: true,
});
await cleanUp();
},
{
contextOptions: {
...device,
},
},
);
}

View File

@@ -1,127 +0,0 @@
import { resolve } from "node:path";
import { testDevices } from "../testDevices";
import { withPlaywright } from "./withPlaywright";
/**
* Take screenshots for a given URL across all test devices
*/
export const takeScreenshots = async (
url: string,
snapshotName: string,
options?: {
waitForSelector?: string;
timeout?: number;
},
) => {
const { waitForSelector, timeout = 5000 } = options || {};
for (const { device, name } of testDevices) {
await withPlaywright(
async ({ context, cleanUp }) => {
const page = await context.newPage();
try {
await page.goto(url);
// Wait for the page to load
await page.waitForLoadState("networkidle");
// Wait for specific selector if provided
if (waitForSelector) {
await page.waitForSelector(waitForSelector, { timeout });
}
// Additional wait for content to stabilize
await page.waitForTimeout(1000);
// Hide dynamic content that might cause flaky screenshots
await page.addStyleTag({
content: `
/* Hide elements with potential timing issues */
[data-testid="timestamp"],
.timestamp,
time {
opacity: 0 !important;
}
/* Disable animations for consistent screenshots */
*, *::before, *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
}
`,
});
await page.screenshot({
path: resolve("e2e", "snapshots", snapshotName, `${name}.png`),
fullPage: true,
});
} finally {
await cleanUp();
}
},
{
contextOptions: {
...device,
},
},
);
}
};
/**
* Take screenshots of a specific element across all test devices
*/
export const takeElementScreenshots = async (
url: string,
selector: string,
snapshotName: string,
options?: {
timeout?: number;
},
) => {
const { timeout = 5000 } = options || {};
for (const { device, name } of testDevices) {
await withPlaywright(
async ({ context, cleanUp }) => {
const page = await context.newPage();
try {
await page.goto(url);
await page.waitForLoadState("networkidle");
const element = page.locator(selector);
await element.waitFor({ state: "visible", timeout });
await page.addStyleTag({
content: `
*, *::before, *::after {
animation-duration: 0s !important;
transition-duration: 0s !important;
}
`,
});
await element.screenshot({
path: resolve(
"e2e",
"snapshots",
snapshotName,
`${name}-element.png`,
),
});
} finally {
await cleanUp();
}
},
{
contextOptions: {
...device,
},
},
);
}
};

View File

@@ -1,35 +0,0 @@
import { expect, type Page } from "@playwright/test";
/**
* Take a full page screenshot for VRT
*/
export const takeFullPageScreenshot = async (page: Page, name: string) => {
// Wait for animations to complete
await page.waitForTimeout(300);
// Hide elements that might cause flaky tests
await page.addStyleTag({
content: `
/* Hide elements with potential timing issues */
[data-testid="timestamp"],
.timestamp,
time {
visibility: hidden !important;
}
/* Disable animations for consistent screenshots */
*, *::before, *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
}
`,
});
// Take full page screenshot
await expect(page).toHaveScreenshot(`${name}.png`, {
fullPage: true,
animations: "disabled",
});
};

View File

@@ -23,9 +23,8 @@
},
"scripts": {
"dev": "run-p 'dev:*'",
"dev:next": "PORT=3400 next dev --turbopack",
"start": "PORT=3300 node dist/index.js",
"start:e2e": "PORT=4000 GLOBAL_CLAUDE_DIR=./mock-global-claude-dir node dist/index.js",
"dev:next": "next dev --turbopack",
"start": "node dist/index.js",
"build": "./scripts/build.sh",
"lint": "run-s 'lint:*'",
"lint:biome-format": "biome format .",
@@ -36,17 +35,7 @@
"typecheck": "tsc --noEmit",
"test": "vitest --run",
"test:watch": "vitest",
"test:e2e": "GLOBAL_CLAUDE_DIR=./mock-global-claude-dir playwright test",
"test:e2e:ui": "GLOBAL_CLAUDE_DIR=./mock-global-claude-dir playwright test --ui",
"test:e2e:headed": "GLOBAL_CLAUDE_DIR=./mock-global-claude-dir playwright test --headed",
"test:e2e:update": "GLOBAL_CLAUDE_DIR=./mock-global-claude-dir playwright test --update-snapshots",
"screenshots": "GLOBAL_CLAUDE_DIR=./mock-global-claude-dir node e2e/tests/projects-page-new.spec.ts",
"screenshots:all": "run-s screenshots:*",
"screenshots:projects": "GLOBAL_CLAUDE_DIR=./mock-global-claude-dir node e2e/tests/projects-page-new.spec.ts",
"screenshots:project-detail": "GLOBAL_CLAUDE_DIR=./mock-global-claude-dir node e2e/tests/project-detail-new.spec.ts",
"screenshots:session-detail": "GLOBAL_CLAUDE_DIR=./mock-global-claude-dir node e2e/tests/session-detail-new.spec.ts",
"screenshots:error-states": "GLOBAL_CLAUDE_DIR=./mock-global-claude-dir node e2e/tests/error-states.spec.ts",
"screenshots:modals": "GLOBAL_CLAUDE_DIR=./mock-global-claude-dir node e2e/tests/modal-components.spec.ts"
"e2e": "./scripts/e2e.sh"
},
"dependencies": {
"@anthropic-ai/claude-code": "^1.0.98",

View File

@@ -1,47 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
const isCI = process.env['CI'] === 'true';
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: isCI,
retries: isCI ? 2 : 0,
workers: isCI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:4000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
},
],
webServer: {
command: 'pnpm start:e2e',
url: 'http://localhost:4000',
reuseExistingServer: false,
env: {
GLOBAL_CLAUDE_DIR: './mock-global-claude-dir'
}
},
});

39
scripts/e2e.sh Executable file
View File

@@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -euo pipefail
export PORT=4000
export GLOBAL_CLAUDE_DIR=$(git rev-parse --show-toplevel)/mock-global-claude-dir
echo "Check directory structure in $GLOBAL_CLAUDE_DIR:"
ls -l $GLOBAL_CLAUDE_DIR
kill_process_group() {
local pid=$1
local sig=${2:-TERM}
local children=$(pgrep -P $pid 2>/dev/null || true)
for child in $children; do
kill_process_group $child $sig
done
kill -$sig $pid 2>/dev/null || true
}
# cleanup関数を定義してtrapで確実に実行
cleanup() {
if [ -n "${SERVER_PID:-}" ]; then
echo "Killing server (PID: $SERVER_PID) and its process tree..."
kill_process_group $SERVER_PID KILL
fi
}
trap cleanup EXIT INT TERM
pnpm start & SERVER_PID=$!
echo "Server started. pid=$SERVER_PID"
sleep 5 # 即時起動するが、一応少し待っておく
pnpx tsx ./e2e/captureSnapshot/index.ts
echo "Completed capturing screenshots. Killing server..."

View File

@@ -39,11 +39,14 @@ export const routes = (app: HonoAppType) => {
const fileWatcher = getFileWatcher();
const eventBus = getEventBus();
fileWatcher.startWatching();
// biome-ignore lint/complexity/useLiteralKeys: env var
if (process.env["NEXT_PHASE"] !== "phase-production-build") {
fileWatcher.startWatching();
setInterval(() => {
eventBus.emit("heartbeat", {});
}, 10 * 1000);
setInterval(() => {
eventBus.emit("heartbeat", {});
}, 10 * 1000);
}
return (
app