Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ model TestRun {
id String @id @default(cuid())
testCaseId String
testCase TestCase @relation(fields: [testCaseId], references: [id], onDelete: Cascade)
triggeredByEmail String?
requiredCapability String?
requiredRunnerKind String?
requestedDeviceId String?
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/app/api/test-cases/[id]/history/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export async function GET(
status: true,
createdAt: true,
error: true,
triggeredByEmail: true,
},
skip,
take: limit
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/app/api/test-runs/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ export async function GET(
completedAt: testRun.completedAt,
createdAt: testRun.createdAt,
testCaseId: testRun.testCaseId,
triggeredByEmail: testRun.triggeredByEmail,
files,
events,
});
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/app/api/test-runs/dispatch/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
verifyAuth: vi.fn(),
resolveUserId: vi.fn(),
userFindUnique: vi.fn(),
resolveConfigs: vi.fn(),
validateTargetUrl: vi.fn(),
getTeamDevicesAvailability: vi.fn(),
Expand Down Expand Up @@ -33,6 +34,9 @@ vi.mock('@/lib/core/prisma', () => ({
testCase: {
findUnique: mocks.testCaseFindUnique,
},
user: {
findUnique: mocks.userFindUnique,
},
testCaseFile: {
findMany: mocks.testCaseFileFindMany,
},
Expand All @@ -51,6 +55,7 @@ describe('POST /api/test-runs/dispatch', () => {
beforeEach(() => {
mocks.verifyAuth.mockReset();
mocks.resolveUserId.mockReset();
mocks.userFindUnique.mockReset();
mocks.resolveConfigs.mockReset();
mocks.validateTargetUrl.mockReset();
mocks.getTeamDevicesAvailability.mockReset();
Expand All @@ -60,6 +65,7 @@ describe('POST /api/test-runs/dispatch', () => {

mocks.verifyAuth.mockResolvedValue({ sub: 'auth-user' });
mocks.resolveUserId.mockResolvedValue('user-1');
mocks.userFindUnique.mockResolvedValue({ email: 'runner@example.com' });
mocks.resolveConfigs.mockResolvedValue({
variables: { CMS: 'https://example.com' },
files: {},
Expand Down Expand Up @@ -132,6 +138,7 @@ describe('POST /api/test-runs/dispatch', () => {
status: 'QUEUED',
requiredCapability: 'BROWSER',
requiredRunnerKind: null,
triggeredByEmail: 'runner@example.com',
},
});
expect(payload).toMatchObject({
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/app/api/test-runs/dispatch/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ function createConfigurationSnapshot(config: RunTestRequest) {
return sanitized;
}

async function resolveTriggeredByEmail(userId: string): Promise<string | null> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { email: true },
});

return typeof user?.email === 'string' ? user.email : null;
}


async function validateAndroidTargets(
browserConfig: RunTestRequest['browserConfig']
Expand Down Expand Up @@ -132,6 +141,7 @@ export async function POST(request: Request) {
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const triggeredByEmail = await resolveTriggeredByEmail(userId);
const requestHasAndroidTargets = hasAndroidTargets(browserConfig);

const testCase = await prisma.testCase.findUnique({
Expand Down Expand Up @@ -302,6 +312,7 @@ export async function POST(request: Request) {
: null,
requestedDeviceId,
requestedRunnerId,
triggeredByEmail,
}
});

Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/app/test-cases/[id]/history/[runId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ interface TestRun {
logs: string | null;
error: string | null;
configurationSnapshot: string | null;
triggeredByEmail?: string | null;
events?: TestEvent[];
files?: Array<{ id: string; filename: string; storedName: string; mimeType: string; size: number; createdAt: string }>;
}
Expand Down Expand Up @@ -241,6 +242,7 @@ export default function RunDetailPage({ params }: { params: Promise<{ id: string
const runPageHref = projectId
? `/run?testCaseId=${id}&projectId=${projectId}`
: `/run?testCaseId=${id}`;
const runByEmail = testRun.triggeredByEmail || '-';

return (
<main className="min-h-screen bg-gray-50 p-8">
Expand All @@ -251,8 +253,11 @@ export default function RunDetailPage({ params }: { params: Promise<{ id: string
{ label: t('runDetail.breadcrumb.runPrefix', { time: formatDateTime(testRun.createdAt) }) }
]} />

<div className="flex items-center justify-between mb-8">
<div className="mb-8 grid grid-cols-1 lg:grid-cols-2 gap-8 items-end">
<h1 className="text-3xl font-bold text-gray-900">{t('runDetail.title')}</h1>
<p className="text-xs text-gray-500 lg:justify-self-end">
{t('runDetail.runBy', { email: runByEmail })}
</p>
</div>

<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 items-start">
Expand Down
31 changes: 20 additions & 11 deletions apps/web/src/app/test-cases/[id]/history/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ interface TestRun {
createdAt: string;
result: string;
error: string | null;
triggeredByEmail?: string | null;
}

interface HistoryResponse {
Expand Down Expand Up @@ -226,22 +227,26 @@ export default function HistoryPage({ params }: { params: Promise<{ id: string }
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div className="grid grid-cols-12 gap-4 p-4 border-b border-gray-200 bg-gray-50 items-center">
<div className="col-span-2 skeleton-block h-4 w-20" />
<div className="col-span-4 skeleton-block h-4 w-16" />
<div className="col-span-3 skeleton-block h-4 w-20" />
<div className="col-span-5 skeleton-block h-4 w-16" />
<div className="col-span-4 flex justify-end">
<div className="col-span-3 flex justify-end">
<div className="skeleton-block h-4 w-16" />
</div>
</div>
<div className="divide-y divide-gray-100">
{Array.from({ length: 8 }, (_, index) => (
<div key={`history-skeleton-${index}`} className="grid grid-cols-12 gap-4 p-4 items-center">
<div className="col-span-3">
<div className="col-span-2">
<div className="skeleton-block h-6 w-20 rounded-full" />
</div>
<div className="col-span-5">
<div className="col-span-4">
<div className="skeleton-block h-4 w-40" />
</div>
<div className="col-span-4 flex justify-end gap-2">
<div className="col-span-3">
<div className="skeleton-block h-4 w-32" />
</div>
<div className="col-span-3 flex justify-end gap-2">
<div className="skeleton-block h-8 w-20" />
<div className="skeleton-block h-8 w-8" />
</div>
Expand Down Expand Up @@ -291,9 +296,10 @@ export default function HistoryPage({ params }: { params: Promise<{ id: string }
<SectionLoadingState>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div className="grid grid-cols-12 gap-4 p-4 border-b border-gray-200 bg-gray-50 text-sm font-medium text-gray-500 items-center">
<div className="col-span-3">{t('history.table.status')}</div>
<div className="col-span-5">{t('history.table.date')}</div>
<div className="col-span-4 flex justify-end">{t('history.table.actions')}</div>
<div className="col-span-2">{t('history.table.status')}</div>
<div className="col-span-4">{t('history.table.date')}</div>
<div className="col-span-3">{t('history.table.runBy')}</div>
<div className="col-span-3 flex justify-end">{t('history.table.actions')}</div>
</div>

{testRuns.length === 0 ? (
Expand All @@ -313,15 +319,18 @@ export default function HistoryPage({ params }: { params: Promise<{ id: string }
const isRunRunningOrQueued = isRunActiveStatus(run.status);
return (
<div key={run.id} className="grid grid-cols-12 gap-4 p-4 items-center hover:bg-gray-50 transition-colors">
<div className="col-span-3">
<div className="col-span-2">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border ${getStatusBadgeClass(run.status)}`}>
{run.status}
</span>
</div>
<div className="col-span-5 text-sm text-gray-500">
<div className="col-span-4 text-sm text-gray-500">
{formatDateTime(run.createdAt)}
</div>
<div className="col-span-4 flex items-center justify-end gap-2">
<div className="col-span-3 text-sm text-gray-500 truncate">
{run.triggeredByEmail || '-'}
</div>
<div className="col-span-3 flex items-center justify-end gap-2">
{isRunRunningOrQueued ? (
<Link
href={`/run?runId=${run.id}&testCaseId=${id}`}
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ export const EN_MESSAGES: Messages = {
"history.error.load": "Failed to load test history.",
"history.table.status": "Status",
"history.table.date": "Date",
"history.table.runBy": "Run by",
"history.table.actions": "Actions",
"history.noHistory.title": "No test history",
"history.noHistory.subtitle": "This test case hasn't been run yet. Run the test to see results here.",
Expand All @@ -305,6 +306,7 @@ export const EN_MESSAGES: Messages = {
"runDetail.notFound": "Test run not found.",
"runDetail.breadcrumb.testCaseFallback": "Test Case",
"runDetail.breadcrumb.runPrefix": "Run - {time}",
"runDetail.runBy": "Run by: {email}",
"usage.agentKeys.title": "Agent API Keys",
"usage.agentKeys.description": "Generate API keys for external agents (e.g., Claude Code, Claude Desktop, Codex, Antigravity) to access SkyTest via MCP.",
"usage.agentKeys.generate": "Generate Key",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/i18n/locales/zh-hans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ export const ZH_HANS_MESSAGES: Messages = {
"history.error.load": "加载测试历史失败。",
"history.table.status": "状态",
"history.table.date": "日期",
"history.table.runBy": "运行者",
"history.table.actions": "操作",
"history.noHistory.title": "暂无运行记录",
"history.noHistory.subtitle": "该测试用例尚无运行记录。运行测试后可在此查看结果。",
Expand All @@ -305,6 +306,7 @@ export const ZH_HANS_MESSAGES: Messages = {
"runDetail.notFound": "未找到该运行记录。",
"runDetail.breadcrumb.testCaseFallback": "测试用例",
"runDetail.breadcrumb.runPrefix": "运行 - {time}",
"runDetail.runBy": "运行者:{email}",
"usage.agentKeys.title": "Agent API Keys",
"usage.agentKeys.description": "为外部代理(如 Claude Code、Claude Desktop、Codex、Antigravity)生成 API Key,通过 MCP 访问 SkyTest。",
"usage.agentKeys.generate": "生成 Key",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/i18n/locales/zh-hant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ export const ZH_HANT_MESSAGES: Messages = {
"history.error.load": "載入測試歷史失敗。",
"history.table.status": "狀態",
"history.table.date": "日期",
"history.table.runBy": "執行者",
"history.table.actions": "操作",
"history.noHistory.title": "尚無執行紀錄",
"history.noHistory.subtitle": "此測試案例尚未有任何執行紀錄。執行測試後即可在此查看結果。",
Expand All @@ -305,6 +306,7 @@ export const ZH_HANT_MESSAGES: Messages = {
"runDetail.notFound": "找不到此執行紀錄。",
"runDetail.breadcrumb.testCaseFallback": "測試案例",
"runDetail.breadcrumb.runPrefix": "執行 - {time}",
"runDetail.runBy": "執行者:{email}",
"usage.agentKeys.title": "Agent API Keys",
"usage.agentKeys.description": "為外部代理(如 Claude Code、Claude Desktop、Codex、Antigravity)生成 API Key,以透過 MCP 存取 SkyTest。",
"usage.agentKeys.generate": "生成 Key",
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/lib/mcp/__tests__/run-execution.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const mocks = vi.hoisted(() => ({
userFindUnique: vi.fn(),
testCaseFindUnique: vi.fn(),
testRunCreate: vi.fn(),
testRunFileCreateMany: vi.fn(),
Expand All @@ -11,6 +12,9 @@ const mocks = vi.hoisted(() => ({

vi.mock('@/lib/core/prisma', () => ({
prisma: {
user: {
findUnique: mocks.userFindUnique,
},
testCase: {
findUnique: mocks.testCaseFindUnique,
},
Expand Down Expand Up @@ -40,6 +44,7 @@ const { queueTestCaseRun } = await import('@/lib/mcp/run-execution');
describe('queueTestCaseRun', () => {
beforeEach(() => {
mocks.testCaseFindUnique.mockReset();
mocks.userFindUnique.mockReset();
mocks.testRunCreate.mockReset();
mocks.testRunFileCreateMany.mockReset();
mocks.resolveConfigs.mockReset();
Expand All @@ -51,6 +56,7 @@ describe('queueTestCaseRun', () => {
files: {},
allConfigs: [],
});
mocks.userFindUnique.mockResolvedValue({ email: 'runner@example.com' });
mocks.validateTargetUrl.mockReturnValue({ valid: true });
mocks.testRunCreate.mockResolvedValue({
id: 'run-1',
Expand Down Expand Up @@ -100,6 +106,7 @@ describe('queueTestCaseRun', () => {
status: 'QUEUED',
requiredCapability: 'BROWSER',
requiredRunnerKind: null,
triggeredByEmail: 'runner@example.com',
}),
});
expect(mocks.testRunFileCreateMany).toHaveBeenCalledTimes(1);
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/lib/mcp/run-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@ export async function queueTestCaseRun(
testCaseId: string,
overrides?: RunTestOverrides
): Promise<{ ok: true; data: QueueTestCaseRunResult } | { ok: false; failure: QueueTestCaseRunFailure }> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { email: true },
});
const triggeredByEmail = typeof user?.email === 'string' ? user.email : null;

const testCase = await prisma.testCase.findUnique({
where: { id: testCaseId },
include: {
Expand Down Expand Up @@ -283,6 +289,7 @@ export async function queueTestCaseRun(
: null,
requestedDeviceId,
requestedRunnerId,
triggeredByEmail,
}
});

Expand Down
1 change: 1 addition & 0 deletions apps/web/src/types/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export interface TestCase {
export interface TestRun {
id: string;
testCaseId: string;
triggeredByEmail?: string | null;
requiredCapability?: string | null;
requiredRunnerKind?: string | null;
requestedDeviceId?: string | null;
Expand Down
Loading