@bugzy-ai/bugzy 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +248 -0
- package/dist/cli/index.cjs +7547 -0
- package/dist/cli/index.cjs.map +1 -0
- package/dist/cli/index.d.cts +1 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +7539 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/index.cjs +6439 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +54 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.js +6383 -0
- package/dist/index.js.map +1 -0
- package/dist/subagents/index.cjs +2703 -0
- package/dist/subagents/index.cjs.map +1 -0
- package/dist/subagents/index.d.cts +34 -0
- package/dist/subagents/index.d.ts +34 -0
- package/dist/subagents/index.js +2662 -0
- package/dist/subagents/index.js.map +1 -0
- package/dist/subagents/metadata.cjs +207 -0
- package/dist/subagents/metadata.cjs.map +1 -0
- package/dist/subagents/metadata.d.cts +31 -0
- package/dist/subagents/metadata.d.ts +31 -0
- package/dist/subagents/metadata.js +174 -0
- package/dist/subagents/metadata.js.map +1 -0
- package/dist/tasks/index.cjs +3464 -0
- package/dist/tasks/index.cjs.map +1 -0
- package/dist/tasks/index.d.cts +44 -0
- package/dist/tasks/index.d.ts +44 -0
- package/dist/tasks/index.js +3431 -0
- package/dist/tasks/index.js.map +1 -0
- package/dist/templates/init/.bugzy/runtime/project-context.md +35 -0
- package/dist/templates/init/.bugzy/runtime/templates/test-plan-template.md +25 -0
- package/dist/templates/init/.bugzy/runtime/testing-best-practices.md +278 -0
- package/dist/templates/init/.gitignore-template +4 -0
- package/package.json +95 -0
- package/templates/init/.bugzy/runtime/knowledge-base.md +61 -0
- package/templates/init/.bugzy/runtime/knowledge-maintenance-guide.md +97 -0
- package/templates/init/.bugzy/runtime/project-context.md +35 -0
- package/templates/init/.bugzy/runtime/subagent-memory-guide.md +87 -0
- package/templates/init/.bugzy/runtime/templates/test-plan-template.md +25 -0
- package/templates/init/.bugzy/runtime/templates/test-result-schema.md +498 -0
- package/templates/init/.bugzy/runtime/test-execution-strategy.md +535 -0
- package/templates/init/.bugzy/runtime/testing-best-practices.md +632 -0
- package/templates/init/.gitignore-template +25 -0
- package/templates/init/CLAUDE.md +157 -0
- package/templates/init/test-runs/README.md +45 -0
- package/templates/playwright/BasePage.template.ts +190 -0
- package/templates/playwright/auth.setup.template.ts +89 -0
- package/templates/playwright/dataGenerators.helper.template.ts +148 -0
- package/templates/playwright/dateUtils.helper.template.ts +96 -0
- package/templates/playwright/pages.fixture.template.ts +50 -0
- package/templates/playwright/playwright.config.template.ts +97 -0
- package/templates/playwright/reporters/bugzy-reporter.ts +454 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Date Utility Functions
|
|
3
|
+
* Generated by Bugzy - https://github.com/bugzy-ai/bugzy
|
|
4
|
+
*
|
|
5
|
+
* Helper functions for working with dates in tests
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Get today's date in YYYY-MM-DD format
|
|
10
|
+
*/
|
|
11
|
+
export function getTodayDate(): string {
|
|
12
|
+
const today = new Date();
|
|
13
|
+
return formatDate(today);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Get tomorrow's date in YYYY-MM-DD format
|
|
18
|
+
*/
|
|
19
|
+
export function getTomorrowDate(): string {
|
|
20
|
+
const tomorrow = new Date();
|
|
21
|
+
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
22
|
+
return formatDate(tomorrow);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Get yesterday's date in YYYY-MM-DD format
|
|
27
|
+
*/
|
|
28
|
+
export function getYesterdayDate(): string {
|
|
29
|
+
const yesterday = new Date();
|
|
30
|
+
yesterday.setDate(yesterday.getDate() - 1);
|
|
31
|
+
return formatDate(yesterday);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Get date N days from now
|
|
36
|
+
* @param days - Number of days (positive for future, negative for past)
|
|
37
|
+
*/
|
|
38
|
+
export function getDateDaysFromNow(days: number): string {
|
|
39
|
+
const date = new Date();
|
|
40
|
+
date.setDate(date.getDate() + days);
|
|
41
|
+
return formatDate(date);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Format date as YYYY-MM-DD
|
|
46
|
+
* @param date - Date object to format
|
|
47
|
+
*/
|
|
48
|
+
export function formatDate(date: Date): string {
|
|
49
|
+
const year = date.getFullYear();
|
|
50
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
51
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
52
|
+
return `${year}-${month}-${day}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Format date as MM/DD/YYYY
|
|
57
|
+
* @param date - Date object to format
|
|
58
|
+
*/
|
|
59
|
+
export function formatDateUS(date: Date): string {
|
|
60
|
+
const year = date.getFullYear();
|
|
61
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
62
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
63
|
+
return `${month}/${day}/${year}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Parse date string (YYYY-MM-DD) to Date object
|
|
68
|
+
* @param dateString - Date string in YYYY-MM-DD format
|
|
69
|
+
*/
|
|
70
|
+
export function parseDate(dateString: string): Date {
|
|
71
|
+
const [year, month, day] = dateString.split('-').map(Number);
|
|
72
|
+
return new Date(year, month - 1, day);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Get current timestamp in milliseconds
|
|
77
|
+
*/
|
|
78
|
+
export function getCurrentTimestamp(): number {
|
|
79
|
+
return Date.now();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get current ISO timestamp string
|
|
84
|
+
*/
|
|
85
|
+
export function getCurrentISOTimestamp(): string {
|
|
86
|
+
return new Date().toISOString();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Wait for a specific amount of time (use sparingly!)
|
|
91
|
+
* Note: Prefer Playwright's built-in waiting mechanisms over this
|
|
92
|
+
* @param ms - Milliseconds to wait
|
|
93
|
+
*/
|
|
94
|
+
export async function wait(ms: number): Promise<void> {
|
|
95
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
96
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { test as base } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Custom Fixtures for Page Objects
|
|
5
|
+
* Generated by Bugzy - https://github.com/bugzy-ai/bugzy
|
|
6
|
+
*
|
|
7
|
+
* Fixtures provide a way to set up and tear down test dependencies
|
|
8
|
+
* This example shows how to create fixtures for page objects
|
|
9
|
+
*
|
|
10
|
+
* Usage in tests:
|
|
11
|
+
* test('my test', async ({ homePage }) => {
|
|
12
|
+
* await homePage.navigate();
|
|
13
|
+
* // ...
|
|
14
|
+
* });
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// Define fixture types
|
|
18
|
+
type PageFixtures = {
|
|
19
|
+
// Add your page object fixtures here
|
|
20
|
+
// Example:
|
|
21
|
+
// homePage: HomePage;
|
|
22
|
+
// loginPage: LoginPage;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Extend the base test with custom fixtures
|
|
27
|
+
*
|
|
28
|
+
* Example implementation:
|
|
29
|
+
*
|
|
30
|
+
* import { HomePage } from '@pages/HomePage';
|
|
31
|
+
* import { LoginPage } from '@pages/LoginPage';
|
|
32
|
+
*
|
|
33
|
+
* export const test = base.extend<PageFixtures>({
|
|
34
|
+
* homePage: async ({ page }, use) => {
|
|
35
|
+
* const homePage = new HomePage(page);
|
|
36
|
+
* await use(homePage);
|
|
37
|
+
* },
|
|
38
|
+
*
|
|
39
|
+
* loginPage: async ({ page }, use) => {
|
|
40
|
+
* const loginPage = new LoginPage(page);
|
|
41
|
+
* await use(loginPage);
|
|
42
|
+
* },
|
|
43
|
+
* });
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
export const test = base.extend<PageFixtures>({
|
|
47
|
+
// Add your fixtures here
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export { expect } from '@playwright/test';
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { defineConfig, devices } from '@playwright/test';
|
|
2
|
+
import * as dotenv from 'dotenv';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
|
|
5
|
+
// Load non-secret environment variables from .env.testdata
|
|
6
|
+
dotenv.config({ path: path.resolve(__dirname, '.env.testdata') });
|
|
7
|
+
|
|
8
|
+
// Load secret environment variables from .env (for local development)
|
|
9
|
+
dotenv.config({ path: path.resolve(__dirname, '.env') });
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Playwright Configuration
|
|
13
|
+
* Generated by Bugzy - https://github.com/bugzy-ai/bugzy
|
|
14
|
+
*
|
|
15
|
+
* This configuration follows Playwright best practices:
|
|
16
|
+
* - Parallel execution for speed
|
|
17
|
+
* - Retries in CI for stability
|
|
18
|
+
* - Trace capture for debugging
|
|
19
|
+
* - Optimized artifact collection
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export default defineConfig({
|
|
23
|
+
// Test directory
|
|
24
|
+
testDir: './tests/specs',
|
|
25
|
+
|
|
26
|
+
// Fully parallel test execution
|
|
27
|
+
fullyParallel: true,
|
|
28
|
+
|
|
29
|
+
// Fail the build on CI if you accidentally left test.only in the source code
|
|
30
|
+
forbidOnly: !!process.env.CI,
|
|
31
|
+
|
|
32
|
+
// Retry on CI only (2 retries), no retries locally
|
|
33
|
+
retries: process.env.CI ? 2 : 0,
|
|
34
|
+
|
|
35
|
+
// Opt out of parallel tests on CI for stability (can be adjusted)
|
|
36
|
+
workers: process.env.CI ? 2 : undefined,
|
|
37
|
+
|
|
38
|
+
// Reporters
|
|
39
|
+
reporter: [
|
|
40
|
+
['./reporters/bugzy-reporter.ts']
|
|
41
|
+
],
|
|
42
|
+
|
|
43
|
+
// Global timeout
|
|
44
|
+
timeout: 30000,
|
|
45
|
+
|
|
46
|
+
// Expect timeout
|
|
47
|
+
expect: {
|
|
48
|
+
timeout: 5000
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
// Shared settings for all projects
|
|
52
|
+
use: {
|
|
53
|
+
// Base URL from environment variable
|
|
54
|
+
baseURL: process.env.TEST_BASE_URL || 'http://localhost:3000',
|
|
55
|
+
|
|
56
|
+
// Collect trace on failure
|
|
57
|
+
trace: 'retain-on-failure',
|
|
58
|
+
|
|
59
|
+
// Screenshot only on failure
|
|
60
|
+
screenshot: 'only-on-failure',
|
|
61
|
+
|
|
62
|
+
// Video for all tests (always record)
|
|
63
|
+
video: 'on',
|
|
64
|
+
|
|
65
|
+
// Maximum time for actions (click, fill, etc.)
|
|
66
|
+
actionTimeout: 10000,
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
// Configure projects for different browsers
|
|
70
|
+
projects: [
|
|
71
|
+
// Setup project - runs once before other tests
|
|
72
|
+
{
|
|
73
|
+
name: 'setup',
|
|
74
|
+
testDir: './tests/setup',
|
|
75
|
+
testMatch: /.*\.setup\.ts/,
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
// Chromium
|
|
79
|
+
{
|
|
80
|
+
name: 'chromium',
|
|
81
|
+
use: {
|
|
82
|
+
...devices['Desktop Chrome'],
|
|
83
|
+
// Use authenticated state if available
|
|
84
|
+
storageState: 'tests/.auth/user.json',
|
|
85
|
+
},
|
|
86
|
+
dependencies: ['setup'],
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
|
|
90
|
+
// Run your local dev server before starting the tests
|
|
91
|
+
// Uncomment and configure if needed:
|
|
92
|
+
// webServer: {
|
|
93
|
+
// command: 'npm run dev',
|
|
94
|
+
// url: 'http://127.0.0.1:3000',
|
|
95
|
+
// reuseExistingServer: !process.env.CI,
|
|
96
|
+
// },
|
|
97
|
+
});
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Reporter,
|
|
3
|
+
FullConfig,
|
|
4
|
+
Suite,
|
|
5
|
+
TestCase,
|
|
6
|
+
TestResult,
|
|
7
|
+
FullResult,
|
|
8
|
+
TestStep,
|
|
9
|
+
} from '@playwright/test/reporter';
|
|
10
|
+
import * as fs from 'fs';
|
|
11
|
+
import * as path from 'path';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Step data for steps.json
|
|
15
|
+
*/
|
|
16
|
+
interface StepData {
|
|
17
|
+
index: number;
|
|
18
|
+
timestamp: string;
|
|
19
|
+
videoTimeSeconds: number;
|
|
20
|
+
action: string;
|
|
21
|
+
status: 'success' | 'failed' | 'skipped';
|
|
22
|
+
description: string;
|
|
23
|
+
technicalDetails: string;
|
|
24
|
+
duration?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Bugzy Custom Playwright Reporter
|
|
29
|
+
*
|
|
30
|
+
* Records test executions in hierarchical structure:
|
|
31
|
+
* test-runs/YYYYMMDD-HHMMSS/TC-{id}/exec-{num}/
|
|
32
|
+
*
|
|
33
|
+
* Features:
|
|
34
|
+
* - Groups multiple test runs under same directory when BUGZY_EXECUTION_ID matches
|
|
35
|
+
* - Checks latest directory's manifest to reuse existing session directory
|
|
36
|
+
* - Tracks multiple execution attempts per test
|
|
37
|
+
* - Records videos for all tests
|
|
38
|
+
* - Captures traces/screenshots for failures only
|
|
39
|
+
* - Links to BUGZY_EXECUTION_ID for session tracking
|
|
40
|
+
* - Generates manifest.json with execution summary
|
|
41
|
+
* - Generates steps.json with video timestamps for test.step() calls
|
|
42
|
+
*/
|
|
43
|
+
class BugzyReporter implements Reporter {
|
|
44
|
+
private testRunDir!: string;
|
|
45
|
+
private timestamp!: string;
|
|
46
|
+
private bugzyExecutionId!: string;
|
|
47
|
+
private startTime!: Date;
|
|
48
|
+
private testResults: Map<string, Array<any>> = new Map();
|
|
49
|
+
private testSteps: Map<string, Array<StepData>> = new Map();
|
|
50
|
+
private testStartTimes: Map<string, number> = new Map();
|
|
51
|
+
|
|
52
|
+
constructor() {
|
|
53
|
+
// No longer need to read execution number from environment
|
|
54
|
+
// It will be auto-detected per test case
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Called once before running tests
|
|
59
|
+
*/
|
|
60
|
+
onBegin(config: FullConfig, suite: Suite): void {
|
|
61
|
+
this.startTime = new Date();
|
|
62
|
+
|
|
63
|
+
// Generate timestamp in YYYYMMDD-HHMMSS format
|
|
64
|
+
this.timestamp = this.startTime
|
|
65
|
+
.toISOString()
|
|
66
|
+
.replace(/[-:]/g, '')
|
|
67
|
+
.replace(/T/, '-')
|
|
68
|
+
.slice(0, 15);
|
|
69
|
+
|
|
70
|
+
const testRunsRoot = path.join(process.cwd(), 'test-runs');
|
|
71
|
+
|
|
72
|
+
// Check if we should reuse an existing session
|
|
73
|
+
let reuseDir: string | null = null;
|
|
74
|
+
|
|
75
|
+
// If BUGZY_EXECUTION_ID is provided, use it directly
|
|
76
|
+
if (process.env.BUGZY_EXECUTION_ID) {
|
|
77
|
+
this.bugzyExecutionId = process.env.BUGZY_EXECUTION_ID;
|
|
78
|
+
} else {
|
|
79
|
+
// For local runs, check if we can reuse the latest session
|
|
80
|
+
// Reuse if the latest manifest is within 60 minutes
|
|
81
|
+
if (fs.existsSync(testRunsRoot)) {
|
|
82
|
+
const dirs = fs.readdirSync(testRunsRoot)
|
|
83
|
+
.filter(d => fs.statSync(path.join(testRunsRoot, d)).isDirectory())
|
|
84
|
+
.sort()
|
|
85
|
+
.reverse(); // Sort descending (latest first)
|
|
86
|
+
|
|
87
|
+
if (dirs.length > 0) {
|
|
88
|
+
const latestDir = dirs[0];
|
|
89
|
+
const manifestPath = path.join(testRunsRoot, latestDir, 'manifest.json');
|
|
90
|
+
|
|
91
|
+
if (fs.existsSync(manifestPath)) {
|
|
92
|
+
try {
|
|
93
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
94
|
+
const manifestTime = new Date(manifest.startTime).getTime();
|
|
95
|
+
const currentTime = this.startTime.getTime();
|
|
96
|
+
const minutesDiff = (currentTime - manifestTime) / (1000 * 60);
|
|
97
|
+
|
|
98
|
+
// Reuse if within 60 minutes and has a local execution ID
|
|
99
|
+
if (minutesDiff <= 60 && manifest.bugzyExecutionId?.startsWith('local-')) {
|
|
100
|
+
this.bugzyExecutionId = manifest.bugzyExecutionId;
|
|
101
|
+
reuseDir = latestDir;
|
|
102
|
+
}
|
|
103
|
+
} catch (err) {
|
|
104
|
+
// Ignore parsing errors
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// If no session to reuse, generate new local ID
|
|
111
|
+
if (!this.bugzyExecutionId) {
|
|
112
|
+
this.bugzyExecutionId = 'local-' + this.timestamp;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// If we have a specific execution ID but haven't found a reuse dir yet, check for matching session
|
|
117
|
+
if (!reuseDir && fs.existsSync(testRunsRoot)) {
|
|
118
|
+
const dirs = fs.readdirSync(testRunsRoot)
|
|
119
|
+
.filter(d => fs.statSync(path.join(testRunsRoot, d)).isDirectory())
|
|
120
|
+
.sort()
|
|
121
|
+
.reverse();
|
|
122
|
+
|
|
123
|
+
for (const dir of dirs) {
|
|
124
|
+
const manifestPath = path.join(testRunsRoot, dir, 'manifest.json');
|
|
125
|
+
if (fs.existsSync(manifestPath)) {
|
|
126
|
+
try {
|
|
127
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
128
|
+
if (manifest.bugzyExecutionId === this.bugzyExecutionId) {
|
|
129
|
+
reuseDir = dir;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
} catch (err) {
|
|
133
|
+
// Ignore parsing errors
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (reuseDir) {
|
|
140
|
+
this.testRunDir = path.join(testRunsRoot, reuseDir);
|
|
141
|
+
console.log(`\n🔄 Continuing test run: ${reuseDir}`);
|
|
142
|
+
console.log(`📋 Execution ID: ${this.bugzyExecutionId}`);
|
|
143
|
+
console.log(`📁 Output directory: ${this.testRunDir}\n`);
|
|
144
|
+
} else {
|
|
145
|
+
this.testRunDir = path.join(testRunsRoot, this.timestamp);
|
|
146
|
+
fs.mkdirSync(this.testRunDir, { recursive: true });
|
|
147
|
+
console.log(`\n🆕 New test run: ${this.timestamp}`);
|
|
148
|
+
console.log(`📋 Execution ID: ${this.bugzyExecutionId}`);
|
|
149
|
+
console.log(`📁 Output directory: ${this.testRunDir}\n`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Called after each test completes
|
|
155
|
+
*/
|
|
156
|
+
onTestEnd(test: TestCase, result: TestResult): void {
|
|
157
|
+
// Extract test ID from test title or file path
|
|
158
|
+
const testId = this.extractTestId(test);
|
|
159
|
+
|
|
160
|
+
// Create test case directory
|
|
161
|
+
const testCaseDir = path.join(this.testRunDir, testId);
|
|
162
|
+
fs.mkdirSync(testCaseDir, { recursive: true });
|
|
163
|
+
|
|
164
|
+
// Auto-detect execution number from existing folders
|
|
165
|
+
let executionNum = 1;
|
|
166
|
+
if (fs.existsSync(testCaseDir)) {
|
|
167
|
+
const existingExecs = fs.readdirSync(testCaseDir)
|
|
168
|
+
.filter(d => d.startsWith('exec-') && fs.statSync(path.join(testCaseDir, d)).isDirectory())
|
|
169
|
+
.map(d => parseInt(d.replace('exec-', ''), 10))
|
|
170
|
+
.filter(n => !isNaN(n));
|
|
171
|
+
|
|
172
|
+
if (existingExecs.length > 0) {
|
|
173
|
+
executionNum = Math.max(...existingExecs) + 1;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Create execution directory
|
|
178
|
+
const execDir = path.join(testCaseDir, `exec-${executionNum}`);
|
|
179
|
+
fs.mkdirSync(execDir, { recursive: true });
|
|
180
|
+
|
|
181
|
+
// Prepare result data in Playwright format
|
|
182
|
+
const resultData = {
|
|
183
|
+
status: result.status,
|
|
184
|
+
duration: result.duration,
|
|
185
|
+
errors: result.errors,
|
|
186
|
+
retry: result.retry,
|
|
187
|
+
startTime: result.startTime.toISOString(),
|
|
188
|
+
attachments: [] as Array<{ name: string; path: string; contentType: string }>,
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// Handle attachments (videos, traces, screenshots)
|
|
192
|
+
let hasVideo = false;
|
|
193
|
+
let hasTrace = false;
|
|
194
|
+
let hasScreenshots = false;
|
|
195
|
+
|
|
196
|
+
for (const attachment of result.attachments) {
|
|
197
|
+
if (attachment.name === 'video' && attachment.path) {
|
|
198
|
+
// Copy video file to execution directory
|
|
199
|
+
const videoFileName = 'video.webm';
|
|
200
|
+
const videoDestPath = path.join(execDir, videoFileName);
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
fs.copyFileSync(attachment.path, videoDestPath);
|
|
204
|
+
resultData.attachments.push({
|
|
205
|
+
name: 'video',
|
|
206
|
+
path: videoFileName,
|
|
207
|
+
contentType: attachment.contentType || 'video/webm',
|
|
208
|
+
});
|
|
209
|
+
hasVideo = true;
|
|
210
|
+
} catch (err) {
|
|
211
|
+
console.error(`Failed to copy video: ${err}`);
|
|
212
|
+
}
|
|
213
|
+
} else if (attachment.name === 'trace' && attachment.path) {
|
|
214
|
+
// Copy trace file to execution directory (only for failures)
|
|
215
|
+
if (result.status === 'failed' || result.status === 'timedOut') {
|
|
216
|
+
const traceFileName = 'trace.zip';
|
|
217
|
+
const traceDestPath = path.join(execDir, traceFileName);
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
fs.copyFileSync(attachment.path, traceDestPath);
|
|
221
|
+
resultData.attachments.push({
|
|
222
|
+
name: 'trace',
|
|
223
|
+
path: traceFileName,
|
|
224
|
+
contentType: attachment.contentType || 'application/zip',
|
|
225
|
+
});
|
|
226
|
+
hasTrace = true;
|
|
227
|
+
} catch (err) {
|
|
228
|
+
console.error(`Failed to copy trace: ${err}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
} else if (attachment.name === 'screenshot' && attachment.path) {
|
|
232
|
+
// Copy screenshots to execution directory (only for failures)
|
|
233
|
+
if (result.status === 'failed' || result.status === 'timedOut') {
|
|
234
|
+
const screenshotsDir = path.join(execDir, 'screenshots');
|
|
235
|
+
fs.mkdirSync(screenshotsDir, { recursive: true });
|
|
236
|
+
|
|
237
|
+
const screenshotFileName = path.basename(attachment.path);
|
|
238
|
+
const screenshotDestPath = path.join(screenshotsDir, screenshotFileName);
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
fs.copyFileSync(attachment.path, screenshotDestPath);
|
|
242
|
+
resultData.attachments.push({
|
|
243
|
+
name: 'screenshot',
|
|
244
|
+
path: path.join('screenshots', screenshotFileName),
|
|
245
|
+
contentType: attachment.contentType || 'image/png',
|
|
246
|
+
});
|
|
247
|
+
hasScreenshots = true;
|
|
248
|
+
} catch (err) {
|
|
249
|
+
console.error(`Failed to copy screenshot: ${err}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Write result.json
|
|
256
|
+
const resultPath = path.join(execDir, 'result.json');
|
|
257
|
+
fs.writeFileSync(resultPath, JSON.stringify(resultData, null, 2));
|
|
258
|
+
|
|
259
|
+
// Store execution info for manifest
|
|
260
|
+
if (!this.testResults.has(testId)) {
|
|
261
|
+
this.testResults.set(testId, []);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
this.testResults.get(testId)!.push({
|
|
265
|
+
number: executionNum,
|
|
266
|
+
status: result.status,
|
|
267
|
+
duration: result.duration,
|
|
268
|
+
videoFile: hasVideo ? 'video.webm' : null,
|
|
269
|
+
hasTrace,
|
|
270
|
+
hasScreenshots,
|
|
271
|
+
error: result.errors.length > 0 ? result.errors[0].message : null,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// Generate steps.json if test has steps
|
|
275
|
+
const testKey = this.getTestKey(test);
|
|
276
|
+
const steps = this.testSteps.get(testKey);
|
|
277
|
+
if (steps && steps.length > 0) {
|
|
278
|
+
const stepsData = {
|
|
279
|
+
steps,
|
|
280
|
+
summary: {
|
|
281
|
+
totalSteps: steps.length,
|
|
282
|
+
successfulSteps: steps.filter(s => s.status === 'success').length,
|
|
283
|
+
failedSteps: steps.filter(s => s.status === 'failed').length,
|
|
284
|
+
skippedSteps: steps.filter(s => s.status === 'skipped').length,
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const stepsPath = path.join(execDir, 'steps.json');
|
|
289
|
+
fs.writeFileSync(stepsPath, JSON.stringify(stepsData, null, 2));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Log execution result
|
|
293
|
+
const statusIcon = result.status === 'passed' ? '✅' : result.status === 'failed' ? '❌' : '⚠️';
|
|
294
|
+
console.log(`${statusIcon} ${testId} [exec-${executionNum}] - ${result.status} (${result.duration}ms)`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Called when a test step begins
|
|
299
|
+
*/
|
|
300
|
+
onStepBegin(test: TestCase, _result: TestResult, step: TestStep): void {
|
|
301
|
+
// Only track test.step() calls (not hooks, fixtures, or expects)
|
|
302
|
+
if (step.category !== 'test.step') {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const testKey = this.getTestKey(test);
|
|
307
|
+
|
|
308
|
+
// Record test start time on first step
|
|
309
|
+
if (!this.testStartTimes.has(testKey)) {
|
|
310
|
+
this.testStartTimes.set(testKey, step.startTime.getTime());
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Initialize steps array for this test
|
|
314
|
+
if (!this.testSteps.has(testKey)) {
|
|
315
|
+
this.testSteps.set(testKey, []);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const steps = this.testSteps.get(testKey)!;
|
|
319
|
+
const testStartTime = this.testStartTimes.get(testKey)!;
|
|
320
|
+
const videoTimeSeconds = Math.floor((step.startTime.getTime() - testStartTime) / 1000);
|
|
321
|
+
|
|
322
|
+
steps.push({
|
|
323
|
+
index: steps.length + 1,
|
|
324
|
+
timestamp: step.startTime.toISOString(),
|
|
325
|
+
videoTimeSeconds,
|
|
326
|
+
action: step.title,
|
|
327
|
+
status: 'success', // Will be updated in onStepEnd if it fails
|
|
328
|
+
description: `${step.title} - in progress`,
|
|
329
|
+
technicalDetails: 'test.step',
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Called when a test step ends
|
|
335
|
+
*/
|
|
336
|
+
onStepEnd(test: TestCase, _result: TestResult, step: TestStep): void {
|
|
337
|
+
// Only track test.step() calls
|
|
338
|
+
if (step.category !== 'test.step') {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const testKey = this.getTestKey(test);
|
|
343
|
+
const steps = this.testSteps.get(testKey);
|
|
344
|
+
|
|
345
|
+
if (!steps || steps.length === 0) {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Update the last step with final status and duration
|
|
350
|
+
const lastStep = steps[steps.length - 1];
|
|
351
|
+
lastStep.duration = step.duration;
|
|
352
|
+
|
|
353
|
+
if (step.error) {
|
|
354
|
+
lastStep.status = 'failed';
|
|
355
|
+
lastStep.description = `${step.title} - failed: ${step.error.message}`;
|
|
356
|
+
} else {
|
|
357
|
+
lastStep.status = 'success';
|
|
358
|
+
lastStep.description = `${step.title} - completed successfully`;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Called after all tests complete
|
|
364
|
+
*/
|
|
365
|
+
onEnd(result: FullResult): void {
|
|
366
|
+
const endTime = new Date();
|
|
367
|
+
|
|
368
|
+
// Calculate statistics
|
|
369
|
+
let totalTests = 0;
|
|
370
|
+
let totalExecutions = 0;
|
|
371
|
+
let passedTests = 0;
|
|
372
|
+
let failedTests = 0;
|
|
373
|
+
|
|
374
|
+
const testCases: Array<any> = [];
|
|
375
|
+
|
|
376
|
+
for (const [testId, executions] of this.testResults.entries()) {
|
|
377
|
+
totalTests++;
|
|
378
|
+
totalExecutions += executions.length;
|
|
379
|
+
|
|
380
|
+
const finalStatus = executions[executions.length - 1].status;
|
|
381
|
+
if (finalStatus === 'passed') {
|
|
382
|
+
passedTests++;
|
|
383
|
+
} else {
|
|
384
|
+
failedTests++;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
testCases.push({
|
|
388
|
+
id: testId,
|
|
389
|
+
name: testId.replace(/^TC-\d+-/, '').replace(/-/g, ' '),
|
|
390
|
+
totalExecutions: executions.length,
|
|
391
|
+
finalStatus,
|
|
392
|
+
executions,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Generate manifest.json
|
|
397
|
+
const manifest = {
|
|
398
|
+
bugzyExecutionId: this.bugzyExecutionId,
|
|
399
|
+
timestamp: this.timestamp,
|
|
400
|
+
startTime: this.startTime.toISOString(),
|
|
401
|
+
endTime: endTime.toISOString(),
|
|
402
|
+
status: result.status,
|
|
403
|
+
stats: {
|
|
404
|
+
totalTests,
|
|
405
|
+
passed: passedTests,
|
|
406
|
+
failed: failedTests,
|
|
407
|
+
totalExecutions,
|
|
408
|
+
},
|
|
409
|
+
testCases,
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
const manifestPath = path.join(this.testRunDir, 'manifest.json');
|
|
413
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
414
|
+
|
|
415
|
+
console.log(`\n📊 Test Run Summary:`);
|
|
416
|
+
console.log(` Total tests: ${totalTests}`);
|
|
417
|
+
console.log(` Passed: ${passedTests}`);
|
|
418
|
+
console.log(` Failed: ${failedTests}`);
|
|
419
|
+
console.log(` Total executions: ${totalExecutions}`);
|
|
420
|
+
console.log(` Manifest: ${manifestPath}\n`);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Extract test ID from test case
|
|
425
|
+
* Generates TC-XXX-{test-name} format
|
|
426
|
+
*/
|
|
427
|
+
private extractTestId(test: TestCase): string {
|
|
428
|
+
// Try to extract from test title
|
|
429
|
+
const title = test.title.toLowerCase().replace(/\s+/g, '-');
|
|
430
|
+
|
|
431
|
+
// Get test file name without extension
|
|
432
|
+
const fileName = path.basename(test.location.file, path.extname(test.location.file));
|
|
433
|
+
|
|
434
|
+
// Extract number from filename if it follows TC-XXX pattern
|
|
435
|
+
const tcMatch = fileName.match(/TC-(\d+)/i);
|
|
436
|
+
if (tcMatch) {
|
|
437
|
+
return `TC-${tcMatch[1]}-${title}`;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Otherwise generate from index
|
|
441
|
+
// This is a simple fallback - you may want to improve this
|
|
442
|
+
const testIndex = String(test.parent.tests.indexOf(test) + 1).padStart(3, '0');
|
|
443
|
+
return `TC-${testIndex}-${title}`;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Generate unique key for test to track steps across retries
|
|
448
|
+
*/
|
|
449
|
+
private getTestKey(test: TestCase): string {
|
|
450
|
+
return `${test.location.file}::${test.title}`;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export default BugzyReporter;
|